From d77eaa9ecd5be12fd706979d48283acfdfc83aba Mon Sep 17 00:00:00 2001 From: Roland Date: Tue, 25 Aug 2026 21:03:11 -0600 Subject: [PATCH 001/181] ai: add the /api/ai blueprint, the read-only LLM tool catalog Lean, flat, LLM-shaped read-only endpoints over the library: global, video, archive, and doc search; paged captions, archive text, and zim entries; collections, inventories, and a media-directory text reader. Links are server-computed; big content is paged, never truncated. This ports the formatting layer from wrolpi_mcp (untouched for now) into tested code. First phase of ai.md. Co-Authored-By: Claude Fable 5 --- conftest.py | 1 + modules/ai/__init__.py | 1 + modules/ai/api.py | 361 ++++++++++++++++++++++++++++++++++++ modules/ai/conftest.py | 3 + modules/ai/lib.py | 219 ++++++++++++++++++++++ modules/ai/schema.py | 62 +++++++ modules/ai/test/__init__.py | 0 modules/ai/test/test_api.py | 267 ++++++++++++++++++++++++++ modules/ai/test/test_lib.py | 130 +++++++++++++ wrolpi/root_api.py | 2 + 10 files changed, 1046 insertions(+) create mode 100644 modules/ai/__init__.py create mode 100644 modules/ai/api.py create mode 100644 modules/ai/conftest.py create mode 100644 modules/ai/lib.py create mode 100644 modules/ai/schema.py create mode 100644 modules/ai/test/__init__.py create mode 100644 modules/ai/test/test_api.py create mode 100644 modules/ai/test/test_lib.py diff --git a/conftest.py b/conftest.py index 04a24e10..1e08eef2 100644 --- a/conftest.py +++ b/conftest.py @@ -1,3 +1,4 @@ +from modules.ai.conftest import * # noqa from modules.archive.conftest import * # noqa from modules.inventory.conftest import * # noqa from modules.videos.conftest import * # noqa diff --git a/modules/ai/__init__.py b/modules/ai/__init__.py new file mode 100644 index 00000000..8f05d9c0 --- /dev/null +++ b/modules/ai/__init__.py @@ -0,0 +1 @@ +from modules.ai.api import ai_bp # noqa diff --git a/modules/ai/api.py b/modules/ai/api.py new file mode 100644 index 00000000..191d5730 --- /dev/null +++ b/modules/ai/api.py @@ -0,0 +1,361 @@ +"""The /api/ai blueprint — read-only, LLM-shaped endpoints. + +This blueprint IS the AI tool catalog: the agent loop generates its tool definitions from these +endpoints' OpenAPI operations, and the external MCP server proxies them. Every endpoint is +read-only; descriptions are written for the model, not for humans. +""" +from http import HTTPStatus + +from sanic import Blueprint, Request +from sanic_ext import validate +from sanic_ext.extensions.openapi import openapi + +from modules.archive import lib as archive_lib +from modules.docs.lib import _doc_response, _get_doc, _search_docs +from modules.inventory.common import get_inventory_configs +from modules.inventory.errors import UnknownInventory +from modules.videos.video import lib as videos_lib +from modules.zim import lib as zim_lib +from wrolpi.api_utils import json_response +from wrolpi.collections.lib import search_collections +from wrolpi.common import api_param_limiter, get_media_directory, logger +from wrolpi.errors import InvalidFile, SearchEmpty, UnknownFile +from wrolpi.files.lib import search_files +from wrolpi.schema import JSONErrorResponse +from . import lib, schema + +ai_bp = Blueprint('AI', url_prefix='/api/ai') + +logger = logger.getChild(__name__) + +ai_limiter = api_param_limiter(25, default=5) + +# Refuse to serve files this large through the text reader; real content is paged anyway. +MAX_TEXT_FILE_SIZE = 10 * 1024 * 1024 + + +def _offset(request: Request) -> int: + try: + return max(int(request.args.get('offset', 0)), 0) + except ValueError: + return 0 + + +@ai_bp.post('/search') +@openapi.definition( + summary='Search all WROLPi content', + description='Search everything in the library at once: videos, archived web pages, ebooks, PDFs, and other' + ' files. Returns lean results with a WROLPi link for each item. Prefer the type-specific search' + ' (videos/archives/docs) when the user asks about one kind of content. When total is large,' + ' narrow the query instead of paging.', + body=schema.AISearchRequest, +) +@openapi.operation('search_all') +@openapi.response(HTTPStatus.OK, schema.AISearchResponse) +@validate(schema.AISearchRequest) +async def search_all(_: Request, body: schema.AISearchRequest): + file_groups, total = search_files( + body.search_str, + ai_limiter(body.limit), + body.offset or 0, + mimetypes=body.mimetypes, + tag_names=body.tag_names, + headline=True, + ) + return json_response(lib.format_file_groups(file_groups, total)) + + +@ai_bp.post('/videos/search') +@openapi.definition( + summary='Search videos', + description='Search downloaded videos (and audio) by title and captions. Returns lean results with a' + ' WROLPi link and a captions_link for each video. When total is large, narrow the query' + ' instead of paging.', + body=schema.AIVideoSearchRequest, +) +@openapi.operation('search_videos') +@openapi.response(HTTPStatus.OK, schema.AISearchResponse) +@validate(schema.AIVideoSearchRequest) +async def search_videos(_: Request, body: schema.AIVideoSearchRequest): + file_groups, total = videos_lib.search_videos( + search_str=body.search_str, + offset=body.offset or 0, + limit=ai_limiter(body.limit), + channel_id=body.channel_id, + tag_names=body.tag_names, + headline=True, + ) + return json_response(lib.format_file_groups(file_groups, total)) + + +@ai_bp.get('/videos/') +@openapi.definition( + summary='Get one video', + description='Get details about one video by its ID from search results: title, channel, description,' + ' duration, link, and a captions_link to read what is said in the video.', +) +@openapi.operation('get_video') +@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) +async def get_video(_: Request, file_group_id: int): + video, _prev, _next = videos_lib.get_video_for_app(file_group_id, skip_viewed=True) + result = lib.format_file_group(video, description_length=lib.DETAIL_DESCRIPTION_LENGTH) + return json_response(result) + + +@ai_bp.get('/videos//captions') +@openapi.definition( + summary='Get video captions', + description='Read the timestamped captions of a video by its ID; use this to learn what is said in a video' + ' without watching it. Long captions are paged: request again with offset=next_offset to' + ' continue reading.', +) +@openapi.operation('get_video_captions') +@openapi.response(HTTPStatus.OK, schema.AIPagedTextResponse) +@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) +async def get_video_captions(request: Request, file_group_id: int): + video = videos_lib.get_video(file_group_id) + text = lib.format_caption_chunks(video.get_caption_chunks()) + return json_response(lib.paginate_text(text, _offset(request))) + + +@ai_bp.post('/archives/search') +@openapi.definition( + summary='Search archived web pages', + description='Search archived web pages (saved with SingleFile) by title and content. Returns lean results' + ' with a WROLPi link and a text_link to read each page. Filter with domain (e.g. "example.com")' + ' when the user asks about one site.', + body=schema.AIArchiveSearchRequest, +) +@openapi.operation('search_archives') +@openapi.response(HTTPStatus.OK, schema.AISearchResponse) +@validate(schema.AIArchiveSearchRequest) +async def search_archives(_: Request, body: schema.AIArchiveSearchRequest): + file_groups, total = archive_lib.search_archives( + body.search_str, + body.domain, + ai_limiter(body.limit), + body.offset or 0, + None, # order: default (most recently published) + body.tag_names, + headline=True, + ) + return json_response(lib.format_file_groups(file_groups, total)) + + +@ai_bp.get('/archives//text') +@openapi.definition( + summary='Read an archived web page', + description='Read the plain text content of an archived web page by its ID. Long pages are paged: request' + ' again with offset=next_offset to continue reading.', +) +@openapi.operation('get_archive_text') +@openapi.response(HTTPStatus.OK, schema.AIPagedTextResponse) +@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) +async def get_archive_text(request: Request, file_group_id: int): + archive = archive_lib.get_archive_by_file_group_id(request.ctx.session, file_group_id, skip_viewed=True) + text = lib.read_archive_text(archive) + if text is None: + raise UnknownFile(f'No readable text for archive {file_group_id}') + return json_response(lib.paginate_text(text, _offset(request))) + + +@ai_bp.post('/docs/search') +@openapi.definition( + summary='Search documents and ebooks', + description='Search documents (ebooks, PDFs, comics, office docs) by title, content, author, or subject.' + ' Returns lean results with a WROLPi link for each document. Provide at least one of' + ' search_str, author, subject, or tag_names.', + body=schema.AIDocSearchRequest, +) +@openapi.operation('search_docs') +@openapi.response(HTTPStatus.OK, schema.AISearchResponse) +@validate(schema.AIDocSearchRequest) +async def search_docs(_: Request, body: schema.AIDocSearchRequest): + file_groups, total = _search_docs( + search_str=body.search_str, + author=body.author, + subject=body.subject, + language=body.language, + mimetype=body.mimetype, + limit=ai_limiter(body.limit), + offset=body.offset or 0, + order_by='rank' if body.search_str else 'published_datetime', + tag_names=body.tag_names, + ) + return json_response(lib.format_file_groups(file_groups, total)) + + +@ai_bp.get('/docs/') +@openapi.definition( + summary='Get one document', + description='Get details about one document (ebook, PDF, comic, etc.) by its ID from search results:' + ' title, author, subject, description, and its WROLPi link.', +) +@openapi.operation('get_doc') +@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) +async def get_doc(request: Request, file_group_id: int): + doc = _get_doc(request.ctx.session, file_group_id) + response = _doc_response(doc) + file_group = response['file_group'] + # Merge the Doc's details into the FileGroup dict so the lean formatter can use them. + file_group['doc'] = response['doc'] + result = lib.format_file_group(file_group, description_length=lib.DETAIL_DESCRIPTION_LENGTH) + return json_response(result) + + +@ai_bp.get('/zims') +@openapi.definition( + summary='List Zim encyclopedias', + description='List the Zim encyclopedias in the library (Wikipedia, Wiktionary, etc.). Returns the zim_id' + ' needed to search a specific Zim or read one of its entries.', +) +@openapi.operation('list_zims') +async def list_zims(request: Request): + zims = zim_lib.get_zims(request.ctx.session) + results = [] + for zim in zims: + metadata = lib.zim_metadata_dict(zim.zim_metadata) + results.append({k: v for k, v in dict( + id=zim.id, + title=metadata.get('title'), + creator=metadata.get('creator'), + description=metadata.get('description'), + size=zim.file_group.size if zim.file_group else None, + auto_search=zim.auto_search, + ).items() if v is not None}) + return json_response(dict(results=results, total=len(results))) + + +@ai_bp.post('/zims/search') +@openapi.definition( + summary='Search Zim encyclopedias', + description='Search Zim encyclopedias (Wikipedia, etc.). Without zim_id this searches only the Zims that' + ' have search-by-default enabled; pass zim_id (from list_zims) to search one specific Zim.' + ' Use the returned link (or zim_id and path) to read a full entry.', + body=schema.AIZimSearchRequest, +) +@openapi.operation('search_zims') +@openapi.response(HTTPStatus.OK, schema.AISearchResponse) +@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) +@validate(schema.AIZimSearchRequest) +async def search_zims(request: Request, body: schema.AIZimSearchRequest): + if not body.search_str: + raise SearchEmpty() + limit = ai_limiter(body.limit) + offset = body.offset or 0 + if body.zim_id: + result = zim_lib.headline_zim(request.ctx.session, body.search_str, body.zim_id, offset=offset, limit=limit) + for entry in result.get('search') or []: + entry['zim_id'] = body.zim_id + zim_results = [result] + else: + zim_results = zim_lib.search_all_zims(request.ctx.session, body.search_str, offset=offset, limit=limit) + return json_response(lib.format_zim_search(zim_results)) + + +@ai_bp.get('/zims//entry') +@openapi.definition( + summary='Read a Zim entry', + description='Read one article from a Zim encyclopedia as plain text. Pass the path from Zim search' + ' results. Long articles are paged: request again with offset=next_offset to continue' + ' reading.', +) +@openapi.operation('get_zim_entry') +@openapi.response(HTTPStatus.OK, schema.AIPagedTextResponse) +@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) +async def get_zim_entry(request: Request, zim_id: int): + path = request.args.get('path') + if not path: + raise InvalidFile('path query parameter is required') + entry = zim_lib.get_entry(path, zim_id) + html = bytes(entry.get_item().content).decode('UTF-8', errors='replace') + result = lib.paginate_text(lib.html_to_text(html), _offset(request)) + result['link'] = lib.zim_entry_link(zim_id, path) + return json_response(result) + + +@ai_bp.get('/collections') +@openapi.definition( + summary='List collections', + description='List collections in the library. Filter with kind: "channel" (video channels), "domain"' + ' (archived websites), or "playlist". Use a channel\'s id with search_videos, or a domain\'s' + ' name with search_archives.', +) +@openapi.operation('list_collections') +async def list_collections(request: Request): + kind = request.args.get('kind') + collections = search_collections(request.ctx.session, kind=kind) + results = [] + for collection in collections: + results.append({k: v for k, v in dict( + id=collection.get('id'), + name=collection.get('name'), + kind=collection.get('kind'), + directory=collection.get('directory'), + ).items() if v is not None}) + return json_response(dict(results=results, total=len(results))) + + +@ai_bp.get('/inventories') +@openapi.definition( + summary='List inventories', + description='List the inventories (food storage, emergency supplies, etc.). Use the slug with' + ' get_inventory to read the items.', +) +@openapi.operation('list_inventories') +async def list_inventories(_: Request): + inventories = get_inventory_configs().all_inventories() + results = [{k: v for k, v in dict( + slug=i.get('slug'), + name=i.get('name'), + type=i.get('type'), + item_count=len(i.get('items') or []), + ).items() if v is not None} for i in inventories] + return json_response(dict(results=results, total=len(results))) + + +@ai_bp.get('/inventories/') +@openapi.definition( + summary='Get one inventory', + description='Get one inventory in full (its fields and every item) by its slug from list_inventories.', +) +@openapi.operation('get_inventory') +@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) +async def get_inventory(_: Request, slug: str): + inventory = get_inventory_configs().get_inventory(slug) + if inventory is None: + raise UnknownInventory(f'No inventory: {slug}') + return json_response(dict(inventory=inventory)) + + +@ai_bp.get('/files/read') +@openapi.definition( + summary='Read a text file', + description='Read a plain-text file from the media directory by its relative path (from search result' + ' links or collection directories). Only text files can be read. Long files are paged:' + ' request again with offset=next_offset to continue reading.', +) +@openapi.operation('read_file') +@openapi.response(HTTPStatus.OK, schema.AIPagedTextResponse) +@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) +async def read_file(request: Request): + relative = request.args.get('path') + if not relative: + raise InvalidFile('path query parameter is required') + + media_directory = get_media_directory().resolve() + path = (media_directory / relative.lstrip('/')).resolve() + if not str(path).startswith(f'{media_directory}/'): + raise InvalidFile('Cannot read outside the media directory') + # The config directory can contain secrets (Wi-Fi credentials, API keys); never serve it. + if str(path).startswith(f'{media_directory / "config"}/'): + raise InvalidFile('Cannot read config files') + if not path.is_file(): + raise UnknownFile(f'No such file: {relative}') + if path.stat().st_size > MAX_TEXT_FILE_SIZE: + raise InvalidFile('File is too large to read') + + data = path.read_bytes() + if b'\x00' in data[:8192]: + raise InvalidFile('Not a text file') + return json_response(lib.paginate_text(data.decode('UTF-8', errors='replace'), _offset(request))) diff --git a/modules/ai/conftest.py b/modules/ai/conftest.py new file mode 100644 index 00000000..25fcf989 --- /dev/null +++ b/modules/ai/conftest.py @@ -0,0 +1,3 @@ +# AI module test fixtures. The docs conftest is not star-imported by the root conftest, but the +# AI endpoints cover docs; import its fixtures so AI tests can use doc_factory. +from modules.docs.conftest import * # noqa diff --git a/modules/ai/lib.py b/modules/ai/lib.py new file mode 100644 index 00000000..b780b333 --- /dev/null +++ b/modules/ai/lib.py @@ -0,0 +1,219 @@ +"""Formatting helpers for the /api/ai blueprint — the LLM tool catalog. + +Results are lean, flat JSON with server-computed links so the model never constructs URLs. +Links are relative to the WROLPi host (e.g. /videos/123); the caller (frontend, MCP client) +prefixes its own base URL. +""" +import pathlib +from typing import Optional + +from bs4 import BeautifulSoup + +from wrolpi.common import get_relative_to_media_directory, logger + +logger = logger.getChild(__name__) + +# Big content (captions, archive text, zim entries, help docs) is paged in chunks of this many +# characters, never silently truncated. Sized for small (8k-token) model contexts. +PAGE_SIZE = 4_000 +# Description length in a detail response; descriptions in search listings are shorter. +DETAIL_DESCRIPTION_LENGTH = 1_000 +LISTING_DESCRIPTION_LENGTH = 300 + + +def _relative_path(path) -> Optional[str]: + """Return the media-relative string of a path from a FileGroup JSON dict, if possible.""" + if not path: + return None + try: + return str(get_relative_to_media_directory(path)) + except Exception: + return str(path) + + +def file_group_kind(fg: dict) -> str: + """Categorize a FileGroup JSON dict for the model: video/archive/doc/image/file.""" + mimetype = fg.get('mimetype') or '' + if fg.get('model') == 'video' or isinstance(fg.get('video'), dict): + return 'video' + if fg.get('model') == 'archive' or isinstance(fg.get('archive'), dict): + return 'archive' + if fg.get('model') == 'doc': + return 'doc' + if mimetype.startswith('image/'): + return 'image' + return 'file' + + +def wrolpi_link(fg: dict) -> Optional[str]: + """Build the relative WROLPi UI link for a FileGroup JSON dict. + + Videos/docs/archives deep-link by FileGroup ID; epubs open in the reader; everything else is + served directly from /media/ or /download/. + """ + fg_id = fg.get('id') + kind = file_group_kind(fg) + if kind == 'video' and fg_id: + return f'/videos/{fg_id}' + if kind == 'doc' and fg_id: + return f'/docs/{fg_id}' + if kind == 'archive' and fg_id: + return f'/archives/{fg_id}' + + mimetype = fg.get('mimetype') or '' + primary_path = _relative_path(fg.get('primary_path')) + if not primary_path: + return None + if mimetype.startswith('application/epub'): + return f'/epub/epub.html?url=/download/{primary_path}' + if mimetype.startswith('application/x-mobipocket-ebook'): + return f'/download/{primary_path}' + # Caddy serves any media file at /media/{path}. + return f'/media/{primary_path}' + + +def _truncate(text: Optional[str], length: int) -> Optional[str]: + if text and len(text) > length: + return text[:length] + '…' + return text + + +def format_file_group(fg: dict, description_length: int = LISTING_DESCRIPTION_LENGTH) -> dict: + """Format a FileGroup JSON dict into the lean, flat shape the model consumes. + + Empty fields are omitted so results stay compact on small contexts. + """ + kind = file_group_kind(fg) + result = dict( + id=fg.get('id'), + kind=kind, + title=fg.get('title') or fg.get('name'), + link=wrolpi_link(fg), + mimetype=fg.get('mimetype'), + size=fg.get('size'), + published=fg.get('published_datetime'), + author=fg.get('author'), + tags=fg.get('tags') or None, + url=fg.get('url'), + ) + + # FTS headline (content match context), if the search requested headlines. + headline = fg.get('d_headline') or fg.get('b_headline') or fg.get('c_headline') + if headline: + result['headline'] = headline + + video = fg.get('video') + if kind == 'video': + result['duration'] = fg.get('length') + result['captions_link'] = f'/api/ai/videos/{fg["id"]}/captions' if fg.get('id') else None + if isinstance(video, dict): + channel = video.get('channel') + if isinstance(channel, dict): + result['channel'] = channel.get('name') or channel.get('id') + elif video.get('channel_id'): + result['channel_id'] = video['channel_id'] + result['description'] = _truncate(video.get('description'), description_length) + + if kind == 'archive' and fg.get('id'): + result['text_link'] = f'/api/ai/archives/{fg["id"]}/text' + + if kind == 'doc': + doc = fg.get('doc') or {} + result['subject'] = doc.get('subject') + result['language'] = doc.get('language') + result['pages'] = doc.get('page_count') + result['description'] = _truncate(doc.get('description'), description_length) + + return {k: v for k, v in result.items() if v is not None} + + +def format_file_groups(file_groups: list, total: int) -> dict: + """Format search results: lean items plus the total so the model narrows instead of paging.""" + return dict(results=[format_file_group(i) for i in file_groups], total=total) + + +def paginate_text(text: Optional[str], offset: int = 0) -> dict: + """Page big content: ~PAGE_SIZE chars plus next_offset/total_chars, never silent truncation.""" + text = text or '' + offset = max(int(offset or 0), 0) + end = offset + PAGE_SIZE + next_offset = end if end < len(text) else None + return dict(content=text[offset:end], next_offset=next_offset, total_chars=len(text)) + + +def html_to_text(html: str) -> str: + """Strip HTML down to clean plain text for the model. Pagination handles size.""" + soup = BeautifulSoup(html, 'html.parser') + for tag in soup(['script', 'style', 'nav', 'footer', 'header']): + tag.decompose() + + text = soup.get_text(separator='\n', strip=True) + + # Collapse multiple blank lines. + lines = [] + prev_blank = False + for line in text.splitlines(): + stripped = line.strip() + if not stripped: + if not prev_blank: + lines.append('') + prev_blank = True + else: + lines.append(stripped) + prev_blank = False + return '\n'.join(lines) + + +def format_caption_chunks(chunks: Optional[list]) -> str: + """Render caption chunks ({start_seconds, text}) as timestamped lines.""" + if not chunks: + return '' + lines = [] + for chunk in chunks: + minutes, seconds = divmod(int(chunk.get('start_seconds') or 0), 60) + hours, minutes = divmod(minutes, 60) + lines.append(f'[{hours:02d}:{minutes:02d}:{seconds:02d}] {chunk.get("text") or ""}') + return '\n'.join(lines) + + +def zim_entry_link(zim_id: int, path: str) -> str: + return f'/api/zim/{zim_id}/entry/{path}' + + +def zim_metadata_dict(metadata) -> dict: + """Zim lib functions return a ZimMetadata object; normalize it to a dict.""" + if metadata is None: + return {} + return metadata if isinstance(metadata, dict) else metadata.__json__() + + +def format_zim_search(zim_results: list) -> dict: + """Flatten per-Zim search results into lean entries with links.""" + results = [] + total = 0 + for zim_result in zim_results: + metadata = zim_metadata_dict(zim_result.get('metadata')) + zim_title = metadata.get('title') + total += zim_result.get('estimate') or 0 + for entry in zim_result.get('search') or []: + zim_id = entry.get('zim_id') + path = entry.get('path') + result = dict( + zim_id=zim_id, + zim=zim_title, + path=path, + title=entry.get('title'), + headline=entry.get('headline'), + link=zim_entry_link(zim_id, path) if zim_id and path else None, + ) + results.append({k: v for k, v in result.items() if v is not None}) + return dict(results=results, total=total) + + +def read_archive_text(archive) -> Optional[str]: + """Return the readable plain text of an Archive: readability text, else stripped singlefile.""" + if (path := archive.readability_txt_path) and pathlib.Path(path).is_file(): + return pathlib.Path(path).read_text(errors='replace') + if (path := archive.singlefile_path) and pathlib.Path(path).is_file(): + return html_to_text(pathlib.Path(path).read_text(errors='replace')) + return None diff --git a/modules/ai/schema.py b/modules/ai/schema.py new file mode 100644 index 00000000..0a2cc717 --- /dev/null +++ b/modules/ai/schema.py @@ -0,0 +1,62 @@ +import dataclasses +from typing import Optional + + +@dataclasses.dataclass +class AISearchRequest: + search_str: Optional[str] = None + limit: int = 5 + offset: int = 0 + mimetypes: Optional[list] = None + tag_names: Optional[list] = None + + +@dataclasses.dataclass +class AIVideoSearchRequest: + search_str: Optional[str] = None + limit: int = 5 + offset: int = 0 + channel_id: Optional[int] = None + tag_names: Optional[list] = None + + +@dataclasses.dataclass +class AIArchiveSearchRequest: + search_str: Optional[str] = None + limit: int = 5 + offset: int = 0 + domain: Optional[str] = None + tag_names: Optional[list] = None + + +@dataclasses.dataclass +class AIDocSearchRequest: + search_str: Optional[str] = None + author: Optional[str] = None + subject: Optional[str] = None + language: Optional[str] = None + mimetype: Optional[str] = None + limit: int = 5 + offset: int = 0 + tag_names: Optional[list] = None + + +@dataclasses.dataclass +class AIZimSearchRequest: + search_str: Optional[str] = None + zim_id: Optional[int] = None + limit: int = 5 + offset: int = 0 + + +@dataclasses.dataclass +class AISearchResponse: + results: list = dataclasses.field(default_factory=list) + total: int = 0 + + +@dataclasses.dataclass +class AIPagedTextResponse: + content: str = '' + next_offset: Optional[int] = None + total_chars: int = 0 diff --git a/modules/ai/test/__init__.py b/modules/ai/test/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/ai/test/test_api.py b/modules/ai/test/test_api.py new file mode 100644 index 00000000..ea0928a8 --- /dev/null +++ b/modules/ai/test/test_api.py @@ -0,0 +1,267 @@ +"""Tests for the /api/ai blueprint — the LLM tool catalog.""" +import json +from http import HTTPStatus + +import pytest + +from modules.ai import lib + + +@pytest.mark.asyncio +async def test_ai_search_all(async_client, test_session, video_factory, archive_factory): + """Global search returns lean results of every kind with links and a total.""" + video_factory(title='canning tomatoes') + archive_factory(domain='example.com', title='canning peppers', contents='all about canning peppers') + test_session.commit() + + request, response = await async_client.post('/api/ai/search', content=json.dumps(dict(search_str='canning'))) + assert response.status_code == HTTPStatus.OK + assert response.json['total'] == 2 + kinds = {i['kind'] for i in response.json['results']} + assert kinds == {'video', 'archive'} + for result in response.json['results']: + assert result['link'] + assert result['id'] + + +@pytest.mark.asyncio +async def test_ai_search_all_limit(async_client, test_session, archive_factory): + """The limit is clamped and the total reports all matches so the model narrows, not pages.""" + for i in range(3): + archive_factory(domain='example.com', title=f'gardening {i}', contents='gardening guide') + test_session.commit() + + content = dict(search_str='gardening', limit=1) + request, response = await async_client.post('/api/ai/search', content=json.dumps(content)) + assert response.status_code == HTTPStatus.OK + assert len(response.json['results']) == 1 + assert response.json['total'] == 3 + + # An excessive limit is clamped to the maximum. + content = dict(search_str='gardening', limit=10_000) + request, response = await async_client.post('/api/ai/search', content=json.dumps(content)) + assert response.status_code == HTTPStatus.OK + + +@pytest.mark.asyncio +async def test_ai_search_videos(async_client, test_session, video_factory): + """Video search returns lean video results with captions links.""" + video_factory(title='sourdough starter', with_caption_file=True) + video_factory(title='unrelated') + test_session.commit() + + content = dict(search_str='sourdough') + request, response = await async_client.post('/api/ai/videos/search', content=json.dumps(content)) + assert response.status_code == HTTPStatus.OK + assert response.json['total'] == 1 + result = response.json['results'][0] + assert result['kind'] == 'video' + assert result['link'] == f'/videos/{result["id"]}' + assert result['captions_link'] == f'/api/ai/videos/{result["id"]}/captions' + + +@pytest.mark.asyncio +async def test_ai_get_video(async_client, test_session, video_factory): + """A single video can be fetched by its FileGroup ID.""" + video = video_factory(title='wood stove install') + test_session.commit() + file_group_id = video.file_group_id + + request, response = await async_client.get(f'/api/ai/videos/{file_group_id}') + assert response.status_code == HTTPStatus.OK + assert response.json['id'] == file_group_id + assert response.json['kind'] == 'video' + assert response.json['link'] == f'/videos/{file_group_id}' + + # Fetching does not mark the video viewed. + test_session.expire_all() + assert video.file_group.viewed is None + + request, response = await async_client.get('/api/ai/videos/123456') + assert response.status_code == HTTPStatus.NOT_FOUND + + +@pytest.mark.asyncio +async def test_ai_get_video_captions(async_client, test_session, video_factory): + """Captions are returned as timestamped, paged text.""" + video = video_factory(title='captioned', with_caption_file=True) + test_session.commit() + + request, response = await async_client.get(f'/api/ai/videos/{video.file_group_id}/captions') + assert response.status_code == HTTPStatus.OK + assert response.json['content'].startswith('[00:00:0') + assert response.json['total_chars'] > 0 + + # An offset beyond the end returns an empty page, never an error. + offset = response.json['total_chars'] + 100 + request, response = await async_client.get(f'/api/ai/videos/{video.file_group_id}/captions?offset={offset}') + assert response.status_code == HTTPStatus.OK + assert response.json['content'] == '' + assert response.json['next_offset'] is None + + +@pytest.mark.asyncio +async def test_ai_search_archives(async_client, test_session, archive_factory): + """Archive search supports the domain filter and returns text links.""" + archive_factory(domain='example.com', title='pressure canning', contents='pressure canning guide') + archive_factory(domain='other.org', title='water bath canning', contents='water bath canning guide') + test_session.commit() + + content = dict(search_str='canning', domain='example.com') + request, response = await async_client.post('/api/ai/archives/search', content=json.dumps(content)) + assert response.status_code == HTTPStatus.OK + assert response.json['total'] == 1 + result = response.json['results'][0] + assert result['kind'] == 'archive' + assert result['text_link'] == f'/api/ai/archives/{result["id"]}/text' + + +@pytest.mark.asyncio +async def test_ai_get_archive_text(async_client, test_session, archive_factory): + """Archive text is served from the readability text file and paged.""" + contents = 'word ' * 2_000 # 10,000 chars, more than two pages. + archive = archive_factory(domain='example.com', title='long read', contents=contents) + test_session.commit() + file_group_id = archive.file_group_id + + request, response = await async_client.get(f'/api/ai/archives/{file_group_id}/text') + assert response.status_code == HTTPStatus.OK + assert len(response.json['content']) == lib.PAGE_SIZE + assert response.json['next_offset'] == lib.PAGE_SIZE + assert response.json['total_chars'] == len(contents) + + # The second page continues where the first ended. + request, response = await async_client.get( + f'/api/ai/archives/{file_group_id}/text?offset={response.json["next_offset"]}') + assert response.status_code == HTTPStatus.OK + assert response.json['content'] == contents[lib.PAGE_SIZE:2 * lib.PAGE_SIZE] + + request, response = await async_client.get('/api/ai/archives/123456/text') + assert response.status_code == HTTPStatus.NOT_FOUND + + +@pytest.mark.asyncio +async def test_ai_search_docs(async_client, test_session, test_directory, example_epub, refresh_files): + """Docs can be searched and fetched with links.""" + await refresh_files() + + content = dict(search_str='WROLPi') + request, response = await async_client.post('/api/ai/docs/search', content=json.dumps(content)) + assert response.status_code == HTTPStatus.OK + assert response.json['total'] >= 1 + result = response.json['results'][0] + assert result['kind'] == 'doc' + assert result['link'] == f'/docs/{result["id"]}' + + request, response = await async_client.get(f'/api/ai/docs/{result["id"]}') + assert response.status_code == HTTPStatus.OK + assert response.json['id'] == result['id'] + assert response.json['kind'] == 'doc' + + +@pytest.mark.asyncio +async def test_ai_zims(async_client, test_session, test_zim): + """Zims can be listed, searched, and their entries read as paged text.""" + request, response = await async_client.get('/api/ai/zims') + assert response.status_code == HTTPStatus.OK + assert response.json['total'] == 1 + zim_id = response.json['results'][0]['id'] + + # Search one specific Zim. + content = dict(search_str='item', zim_id=zim_id) + request, response = await async_client.post('/api/ai/zims/search', content=json.dumps(content)) + assert response.status_code == HTTPStatus.OK + assert response.json['total'] >= 1 + result = response.json['results'][0] + assert result['zim_id'] == zim_id + assert result['link'] == f'/api/zim/{zim_id}/entry/{result["path"]}' + + # An empty search is a clear error for the model. + request, response = await async_client.post('/api/ai/zims/search', content=json.dumps(dict())) + assert response.status_code == HTTPStatus.BAD_REQUEST + + # Read the entry the search found. + request, response = await async_client.get(f'/api/ai/zims/{zim_id}/entry?path={result["path"]}') + assert response.status_code == HTTPStatus.OK + assert response.json['content'] + assert response.json['link'] == f'/api/zim/{zim_id}/entry/{result["path"]}' + + # The path is required. + request, response = await async_client.get(f'/api/ai/zims/{zim_id}/entry') + assert response.status_code == HTTPStatus.BAD_REQUEST + + +@pytest.mark.asyncio +async def test_ai_list_collections(async_client, test_session, archive_factory): + """Collections can be listed and filtered by kind.""" + archive_factory(domain='example.com', title='a page', contents='contents') + test_session.commit() + + request, response = await async_client.get('/api/ai/collections?kind=domain') + assert response.status_code == HTTPStatus.OK + assert response.json['total'] == 1 + assert response.json['results'][0]['name'] == 'example.com' + assert response.json['results'][0]['kind'] == 'domain' + + request, response = await async_client.get('/api/ai/collections?kind=channel') + assert response.status_code == HTTPStatus.OK + assert response.json['total'] == 0 + + +@pytest.mark.asyncio +async def test_ai_inventories(async_client, food_inventory_factory): + """Inventories are listed lean, and fetched in full by slug.""" + slug = food_inventory_factory(items=[dict(name='rice', count=2)]) + + request, response = await async_client.get('/api/ai/inventories') + assert response.status_code == HTTPStatus.OK + assert response.json['total'] >= 1 + lean = next(i for i in response.json['results'] if i['slug'] == slug) + assert lean['item_count'] == 1 + assert 'items' not in lean + + request, response = await async_client.get(f'/api/ai/inventories/{slug}') + assert response.status_code == HTTPStatus.OK + assert response.json['inventory']['items'][0]['name'] == 'rice' + + request, response = await async_client.get('/api/ai/inventories/no-such-inventory') + assert response.status_code == HTTPStatus.NOT_FOUND + + +@pytest.mark.asyncio +async def test_ai_read_file(async_client, test_directory): + """Text files in the media directory can be read; everything else is refused.""" + (test_directory / 'notes').mkdir() + (test_directory / 'notes/todo.txt').write_text('buy more rice\n' * 400) # > one page + + request, response = await async_client.get('/api/ai/files/read?path=notes/todo.txt') + assert response.status_code == HTTPStatus.OK + assert response.json['content'].startswith('buy more rice') + assert response.json['next_offset'] == lib.PAGE_SIZE + + # Missing path parameter. + request, response = await async_client.get('/api/ai/files/read') + assert response.status_code == HTTPStatus.BAD_REQUEST + + # Missing file. + request, response = await async_client.get('/api/ai/files/read?path=notes/nope.txt') + assert response.status_code == HTTPStatus.NOT_FOUND + + # Binary files are refused. + (test_directory / 'notes/blob.bin').write_bytes(b'\x00\x01\x02') + request, response = await async_client.get('/api/ai/files/read?path=notes/blob.bin') + assert response.status_code == HTTPStatus.BAD_REQUEST + + +@pytest.mark.asyncio +async def test_ai_read_file_refuses_escapes(async_client, test_directory): + """Reads outside the media directory, and of config files, are refused.""" + # Path traversal. + request, response = await async_client.get('/api/ai/files/read?path=../secrets.txt') + assert response.status_code == HTTPStatus.BAD_REQUEST + + # The config directory can contain secrets. + (test_directory / 'config').mkdir(exist_ok=True) + (test_directory / 'config/wrolpi.yaml').write_text('secret: hunter2') + request, response = await async_client.get('/api/ai/files/read?path=config/wrolpi.yaml') + assert response.status_code == HTTPStatus.BAD_REQUEST diff --git a/modules/ai/test/test_lib.py b/modules/ai/test/test_lib.py new file mode 100644 index 00000000..00826954 --- /dev/null +++ b/modules/ai/test/test_lib.py @@ -0,0 +1,130 @@ +"""Tests for the /api/ai formatting helpers.""" +from modules.ai import lib + + +def test_wrolpi_link_video(): + fg = dict(id=123, model='video', video=dict(), mimetype='video/mp4') + assert lib.wrolpi_link(fg) == '/videos/123' + + +def test_wrolpi_link_doc(): + fg = dict(id=5, model='doc', mimetype='application/pdf') + assert lib.wrolpi_link(fg) == '/docs/5' + + +def test_wrolpi_link_archive(): + fg = dict(id=7, model='archive', mimetype='text/html') + assert lib.wrolpi_link(fg) == '/archives/7' + + +def test_wrolpi_link_epub(test_directory): + fg = dict(id=9, mimetype='application/epub+zip', primary_path=test_directory / 'books/foo.epub') + assert lib.wrolpi_link(fg) == '/epub/epub.html?url=/download/books/foo.epub' + + +def test_wrolpi_link_mobi(test_directory): + fg = dict(id=9, mimetype='application/x-mobipocket-ebook', primary_path=test_directory / 'books/foo.mobi') + assert lib.wrolpi_link(fg) == '/download/books/foo.mobi' + + +def test_wrolpi_link_media_fallback(test_directory): + fg = dict(id=2, mimetype='image/png', primary_path=test_directory / 'photos/a.png') + assert lib.wrolpi_link(fg) == '/media/photos/a.png' + + +def test_wrolpi_link_no_path(): + assert lib.wrolpi_link(dict(mimetype='image/png')) is None + + +def test_format_file_group_video(): + fg = dict( + id=42, + model='video', + title='Canning Basics', + mimetype='video/mp4', + size=1000, + length=4021, + published_datetime='2024-01-02', + tags=['food'], + video=dict(channel=dict(id=1, name='Homesteading'), description='x' * 500), + d_headline='pressure canning', + ) + result = lib.format_file_group(fg) + assert result['id'] == 42 + assert result['kind'] == 'video' + assert result['link'] == '/videos/42' + assert result['captions_link'] == '/api/ai/videos/42/captions' + assert result['channel'] == 'Homesteading' + assert result['duration'] == 4021 + assert result['headline'] == 'pressure canning' + assert result['tags'] == ['food'] + # Listing descriptions are truncated. + assert len(result['description']) == lib.LISTING_DESCRIPTION_LENGTH + 1 + # Empty fields are omitted. + assert 'author' not in result and 'url' not in result + + +def test_format_file_group_archive(): + fg = dict(id=8, model='archive', title='Some Page', mimetype='text/html', url='https://example.com/a') + result = lib.format_file_group(fg) + assert result['kind'] == 'archive' + assert result['link'] == '/archives/8' + assert result['text_link'] == '/api/ai/archives/8/text' + assert result['url'] == 'https://example.com/a' + + +def test_format_file_groups_total(): + result = lib.format_file_groups([dict(id=1, model='archive')], 55) + assert result['total'] == 55 + assert len(result['results']) == 1 + + +def test_paginate_text(): + text = 'a' * (lib.PAGE_SIZE + 100) + page = lib.paginate_text(text, 0) + assert len(page['content']) == lib.PAGE_SIZE + assert page['next_offset'] == lib.PAGE_SIZE + assert page['total_chars'] == lib.PAGE_SIZE + 100 + + page = lib.paginate_text(text, page['next_offset']) + assert len(page['content']) == 100 + assert page['next_offset'] is None + + # Exact boundary has no next page. + page = lib.paginate_text('a' * lib.PAGE_SIZE, 0) + assert page['next_offset'] is None + + # Empty text. + page = lib.paginate_text(None, 0) + assert page == dict(content='', next_offset=None, total_chars=0) + + +def test_html_to_text(): + html = '' \ + '

Hello

World

' + text = lib.html_to_text(html) + assert 'Hello' in text and 'World' in text + assert 'evil' not in text and 'menu' not in text and '.x{}' not in text + + +def test_format_caption_chunks(): + chunks = [dict(start_seconds=0, text='hello'), dict(start_seconds=3671, text='goodbye')] + text = lib.format_caption_chunks(chunks) + assert text == '[00:00:00] hello\n[01:01:11] goodbye' + assert lib.format_caption_chunks(None) == '' + + +def test_format_zim_search(): + zim_results = [dict( + metadata=dict(title='Test Zim'), + estimate=2, + search=[ + dict(zim_id=1, path='one', title='One', headline='the first', rank=1), + dict(zim_id=1, path='two', title='Two', headline=None, rank=1), + ], + )] + result = lib.format_zim_search(zim_results) + assert result['total'] == 2 + assert result['results'][0]['link'] == '/api/zim/1/entry/one' + assert result['results'][0]['zim'] == 'Test Zim' + assert 'headline' not in result['results'][1] diff --git a/wrolpi/root_api.py b/wrolpi/root_api.py index 01d459c2..736bdfe0 100644 --- a/wrolpi/root_api.py +++ b/wrolpi/root_api.py @@ -13,6 +13,7 @@ from sanic_ext.extensions.openapi import openapi from vininfo import Vin from vininfo.details._base import VinDetails +from modules.ai.api import ai_bp from modules.archive.api import archive_bp from modules.docs.api import docs_bp from modules.flasher.api import flasher_bp @@ -54,6 +55,7 @@ api_bp = Blueprint('RootAPI', url_prefix='/api') # Blueprints order here defines what order they are displayed in OpenAPI Docs. api_app.blueprint(api_bp) +api_app.blueprint(ai_bp) api_app.blueprint(archive_bp) api_app.blueprint(collection_bp) # Unified collection endpoints api_app.blueprint(config_bp) From b07672b9760480cfdc76b524642e9e2d2d121c8f Mon Sep 17 00:00:00 2001 From: Roland Date: Tue, 25 Aug 2026 21:08:39 -0600 Subject: [PATCH 002/181] =?UTF-8?q?ai:=20system=20endpoints=20=E2=80=94=20?= =?UTF-8?q?aggregate=20status,=20services,=20logs,=20disks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proxy the Controller under /api/ai so the model sees one API: an aggregate /status to start every diagnosis, lean service list, recent service logs (lines clamped), and disks + SMART. One controller_client helper resolves the Controller base URL (docker service name vs localhost). Every proxy degrades to an `errors` entry when the Controller is unreachable — never a 500. Co-Authored-By: Claude Fable 5 --- modules/ai/api.py | 144 ++++++++++++++++++++++++++++- modules/ai/controller_client.py | 27 ++++++ modules/ai/errors.py | 9 ++ modules/ai/test/test_system_api.py | 130 ++++++++++++++++++++++++++ 4 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 modules/ai/controller_client.py create mode 100644 modules/ai/errors.py create mode 100644 modules/ai/test/test_system_api.py diff --git a/modules/ai/api.py b/modules/ai/api.py index 191d5730..957b4ecc 100644 --- a/modules/ai/api.py +++ b/modules/ai/api.py @@ -16,13 +16,19 @@ from modules.inventory.common import get_inventory_configs from modules.inventory.errors import UnknownInventory from modules.videos.video import lib as videos_lib from modules.zim import lib as zim_lib +from wrolpi import flags from wrolpi.api_utils import json_response from wrolpi.collections.lib import search_collections -from wrolpi.common import api_param_limiter, get_media_directory, logger +from wrolpi.common import api_param_limiter, get_media_directory, logger, wrol_mode_enabled +from wrolpi.downloader import download_manager from wrolpi.errors import InvalidFile, SearchEmpty, UnknownFile from wrolpi.files.lib import search_files +from wrolpi.vars import DOCKERIZED +from wrolpi.version import __version__ from wrolpi.schema import JSONErrorResponse from . import lib, schema +from .controller_client import controller_get +from .errors import ControllerUnavailable ai_bp = Blueprint('AI', url_prefix='/api/ai') @@ -328,6 +334,142 @@ async def get_inventory(_: Request, slug: str): return json_response(dict(inventory=inventory)) +# --------------------------------------------------------------------------- +# System endpoints (System mode). The Controller is proxied so the model sees one API; every +# proxy degrades gracefully — a Controller outage becomes an entry in `errors`, never a 500. +# --------------------------------------------------------------------------- + +# The lean subset of the Controller's cached status that is useful for diagnosis; the full blob +# (processes, iostat, disk bandwidth) is too big for small model contexts. +SYSTEM_STATUS_KEYS = ('cpu', 'memory', 'load', 'drives', 'network', 'uptime', 'hotspot', 'throttle', 'is_rpi') + +log_lines_limiter = api_param_limiter(1_000, default=100) + + +@ai_bp.get('/status') +@openapi.definition( + summary='Get WROLPi system status', + description='Get an aggregate of the whole system in one call: WROLPi version, mode flags, download' + ' summary, CPU/memory/load/disk usage, and the state of every WROLPi service. Start' + ' troubleshooting here. Any part that could not be gathered is listed in errors.', +) +@openapi.operation('get_system_status') +async def get_system_status(_: Request): + errors = [] + ret = dict( + version=__version__.strip(), + dockerized=DOCKERIZED, + wrol_mode=wrol_mode_enabled(), + flags=flags.get_flags(), + errors=errors, + ) + + try: + ret['downloads'] = download_manager.get_summary() + except Exception as e: + logger.debug('AI status could not get download summary', exc_info=e) + errors.append('Could not get download summary') + + try: + status, stats = await controller_get('/api/stats') + if status == HTTPStatus.OK: + ret['system'] = {k: v for k, v in stats.items() if k in SYSTEM_STATUS_KEYS} + else: + errors.append(f'Controller stats returned HTTP {status}') + except Exception as e: + logger.debug('AI status could not reach Controller stats', exc_info=e) + errors.append('Could not reach the Controller for system stats') + + try: + ret['services'] = await _lean_services() + except Exception as e: + logger.debug('AI status could not get services', exc_info=e) + errors.append('Could not get service statuses') + + return json_response(ret) + + +async def _lean_services() -> list: + status, services = await controller_get('/api/services') + if status != HTTPStatus.OK or not isinstance(services, list): + raise ControllerUnavailable(f'Controller services returned HTTP {status}') + return [{k: v for k, v in dict( + name=i.get('name'), + status=i.get('status'), + enabled=i.get('enabled'), + port=i.get('port'), + description=i.get('description') or None, + ).items() if v is not None} for i in services] + + +@ai_bp.get('/services') +@openapi.definition( + summary='List WROLPi services', + description='List every WROLPi service with its state (running, stopped, failed) and port. Use the' + ' name with get_service_logs to read a failing service\'s logs.', +) +@openapi.operation('list_services') +@openapi.response(HTTPStatus.BAD_GATEWAY, JSONErrorResponse) +async def ai_list_services(_: Request): + services = await _lean_services() + return json_response(dict(results=services, total=len(services))) + + +@ai_bp.get('/services//logs') +@openapi.definition( + summary='Read service logs', + description='Read the most recent log lines of one WROLPi service by its name from list_services.' + ' Pass lines to read more history (at most 1000).', +) +@openapi.operation('get_service_logs') +@openapi.response(HTTPStatus.BAD_GATEWAY, JSONErrorResponse) +async def ai_get_service_logs(request: Request, name: str): + try: + lines = log_lines_limiter(int(request.args.get('lines', 100))) + except ValueError: + lines = 100 + status, body = await controller_get(f'/api/services/{name}/logs', + params=dict(lines=max(lines, 1), since=request.args.get('since'))) + if status != HTTPStatus.OK: + raise ControllerUnavailable(f'Controller logs returned HTTP {status}: {body.get("detail")}') + return json_response(dict(service=body.get('service', name), lines=body.get('lines'), logs=body.get('logs', ''))) + + +@ai_bp.get('/disks') +@openapi.definition( + summary='Get disks and drive health', + description='List the disks and partitions (size, filesystem, where mounted) and SMART drive-health' + ' status. Use this to diagnose full or failing drives. Parts that could not be gathered' + ' are listed in errors.', +) +@openapi.operation('list_disks') +async def ai_list_disks(_: Request): + errors = [] + ret = dict(errors=errors) + + try: + status, disks = await controller_get('/api/disks') + if status == HTTPStatus.OK: + ret['disks'] = disks + else: + errors.append(f'Controller disks returned HTTP {status}: {disks.get("detail")}') + except Exception as e: + logger.debug('AI disks could not reach Controller', exc_info=e) + errors.append('Could not reach the Controller for disks') + + try: + status, smart = await controller_get('/api/disks/smart') + if status == HTTPStatus.OK: + ret['smart'] = smart + else: + errors.append(f'Controller SMART returned HTTP {status}: {smart.get("detail")}') + except Exception as e: + logger.debug('AI disks could not reach Controller SMART', exc_info=e) + errors.append('Could not reach the Controller for SMART status') + + return json_response(ret) + + @ai_bp.get('/files/read') @openapi.definition( summary='Read a text file', diff --git a/modules/ai/controller_client.py b/modules/ai/controller_client.py new file mode 100644 index 00000000..bef142e3 --- /dev/null +++ b/modules/ai/controller_client.py @@ -0,0 +1,27 @@ +"""One helper for API -> Controller requests. + +Existing call sites disagree on the base URL (wrolpi/common.py uses http://127.0.0.1, the zim +module uses the docker service name); this module resolves it once: the docker service name when +DOCKERIZED, else localhost, overridable with the CONTROLLER_URL environment variable. +""" +import os +from typing import Tuple +from urllib.parse import urlencode + +from wrolpi.common import aiohttp_get, logger +from wrolpi.vars import DOCKERIZED + +logger = logger.getChild(__name__) + +CONTROLLER_URL = os.environ.get('CONTROLLER_URL') or ('http://controller' if DOCKERIZED else 'http://127.0.0.1') + + +async def controller_get(path: str, params: dict = None, timeout: int = 10) -> Tuple[int, dict]: + """GET a Controller endpoint, returning (status, JSON body). Raises on connection errors.""" + url = f'{CONTROLLER_URL}{path}' + if params: + params = {k: v for k, v in params.items() if v is not None} + if params: + url = f'{url}?{urlencode(params)}' + async with aiohttp_get(url, timeout=timeout) as response: + return response.status, await response.json() diff --git a/modules/ai/errors.py b/modules/ai/errors.py new file mode 100644 index 00000000..37ee8840 --- /dev/null +++ b/modules/ai/errors.py @@ -0,0 +1,9 @@ +from http import HTTPStatus + +from wrolpi.errors import APIError + + +class ControllerUnavailable(APIError): + code = 'CONTROLLER_UNAVAILABLE' + summary = 'The Controller did not answer, or answered with an error.' + status_code = HTTPStatus.BAD_GATEWAY diff --git a/modules/ai/test/test_system_api.py b/modules/ai/test/test_system_api.py new file mode 100644 index 00000000..79395853 --- /dev/null +++ b/modules/ai/test/test_system_api.py @@ -0,0 +1,130 @@ +"""Tests for the /api/ai system endpoints (System mode).""" +from http import HTTPStatus +from unittest import mock + +import pytest + +CONTROLLER_STATS = dict( + cpu=dict(percent=12.5), + memory=dict(total=8_000_000_000, used=2_000_000_000), + load=dict(minute_1=0.5), + drives=[dict(mount='/media/wrolpi', percent=41)], + # Keys the lean status must NOT forward (too big for small contexts). + processes=[dict(pid=1, name='huge')], + iostat=dict(huge=True), +) + +CONTROLLER_SERVICES = [ + dict(name='wrolpi-api', status='running', enabled=True, port=8081, description='API', viewable=True), + dict(name='wrolpi-kiwix', status='failed', enabled=True, port=9085, description='', view_path='/'), +] + + +def _mock_controller(responses: dict): + """Patch controller_get to answer from {path: (status, body)}.""" + + async def fake_controller_get(path, params=None, timeout=10): + if path not in responses: + raise ConnectionError(f'no controller response for {path}') + return responses[path] + + return mock.patch('modules.ai.api.controller_get', side_effect=fake_controller_get) + + +@pytest.mark.asyncio +async def test_ai_system_status(async_client, test_session): + """The aggregate status contains API state, lean Controller stats, and services.""" + responses = { + '/api/stats': (200, CONTROLLER_STATS), + '/api/services': (200, CONTROLLER_SERVICES), + } + with _mock_controller(responses): + request, response = await async_client.get('/api/ai/status') + assert response.status_code == HTTPStatus.OK + assert response.json['version'] + assert response.json['wrol_mode'] is False + assert isinstance(response.json['flags'], dict) + # Only the lean subset of Controller stats is forwarded. + assert response.json['system']['cpu'] == dict(percent=12.5) + assert 'processes' not in response.json['system'] + assert 'iostat' not in response.json['system'] + # Services are lean: no view_path/viewable noise, empty description omitted. + services = {i['name']: i for i in response.json['services']} + assert services['wrolpi-kiwix']['status'] == 'failed' + assert 'viewable' not in services['wrolpi-api'] + assert 'description' not in services['wrolpi-kiwix'] + assert response.json['errors'] == [] + + +@pytest.mark.asyncio +async def test_ai_system_status_controller_down(async_client, test_session): + """A Controller outage degrades to errors entries, never a 500.""" + with _mock_controller({}): + request, response = await async_client.get('/api/ai/status') + assert response.status_code == HTTPStatus.OK + assert response.json['version'] + assert any('system stats' in i for i in response.json['errors']) + assert any('service statuses' in i for i in response.json['errors']) + assert 'system' not in response.json + assert 'services' not in response.json + + +@pytest.mark.asyncio +async def test_ai_list_services(async_client, test_session): + """Services are listed lean; a Controller failure is a clear 502.""" + with _mock_controller({'/api/services': (200, CONTROLLER_SERVICES)}): + request, response = await async_client.get('/api/ai/services') + assert response.status_code == HTTPStatus.OK + assert response.json['total'] == 2 + assert response.json['results'][0]['name'] == 'wrolpi-api' + + with _mock_controller({'/api/services': (500, dict(error='boom'))}): + request, response = await async_client.get('/api/ai/services') + assert response.status_code == HTTPStatus.BAD_GATEWAY + + +@pytest.mark.asyncio +async def test_ai_get_service_logs(async_client, test_session): + """Logs are proxied with the lines param clamped.""" + captured = {} + + async def fake_controller_get(path, params=None, timeout=10): + captured['path'] = path + captured['params'] = params + return 200, dict(service='wrolpi-kiwix', lines=params['lines'], logs='line1\nline2') + + with mock.patch('modules.ai.api.controller_get', side_effect=fake_controller_get): + request, response = await async_client.get('/api/ai/services/wrolpi-kiwix/logs?lines=999999') + assert response.status_code == HTTPStatus.OK + assert captured['path'] == '/api/services/wrolpi-kiwix/logs' + assert captured['params']['lines'] == 1_000 # clamped + assert response.json['logs'] == 'line1\nline2' + + # Unknown service: the Controller's error becomes a 502 with its detail. + with _mock_controller({'/api/services/nope/logs': (404, dict(detail='Service not found'))}): + request, response = await async_client.get('/api/ai/services/nope/logs') + assert response.status_code == HTTPStatus.BAD_GATEWAY + + +@pytest.mark.asyncio +async def test_ai_list_disks(async_client, test_session): + """Disks and SMART are aggregated; partial failures land in errors.""" + disks = [dict(name='sda1', path='/dev/sda1', size='500G', fstype='ext4', mountpoint='/media/wrolpi')] + responses = { + '/api/disks': (200, disks), + '/api/disks/smart': (200, dict(available=True, drives=[dict(name='sda', assessment='PASS')])), + } + with _mock_controller(responses): + request, response = await async_client.get('/api/ai/disks') + assert response.status_code == HTTPStatus.OK + assert response.json['disks'] == disks + assert response.json['smart']['available'] is True + assert response.json['errors'] == [] + + # SMART unavailable (e.g. docker) is an error entry, not a failure. + with _mock_controller({'/api/disks': (200, disks)}): + request, response = await async_client.get('/api/ai/disks') + assert response.status_code == HTTPStatus.OK + assert response.json['disks'] == disks + assert 'smart' not in response.json + assert len(response.json['errors']) == 1 From 85604c80fe49f3fc444e3d11b31e53a896d103ed Mon Sep 17 00:00:00 2001 From: Roland Date: Tue, 25 Aug 2026 21:13:59 -0600 Subject: [PATCH 003/181] =?UTF-8?q?ai:=20help-docs=20search=20=E2=80=94=20?= =?UTF-8?q?Help=20mode=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search the wrolpi-help markdown in-process: files are parsed on demand and cached by mtime; title matches outrank headings outrank body. No database table — the corpus is dozens of small files. Pages are read as paged markdown. The docker api container gets a read-only bind mount of the help docs at the native /opt/wrolpi-help location; a missing help directory degrades to empty results with a message. Co-Authored-By: Claude Fable 5 --- docker-compose.yml | 2 + modules/ai/api.py | 45 ++++++++- modules/ai/help_docs.py | 152 ++++++++++++++++++++++++++++++ modules/ai/schema.py | 6 ++ modules/ai/test/test_help_docs.py | 114 ++++++++++++++++++++++ 5 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 modules/ai/help_docs.py create mode 100644 modules/ai/test/test_help_docs.py diff --git a/docker-compose.yml b/docker-compose.yml index 6de09e1c..e719f961 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,6 +49,8 @@ services: - './scripts:/opt/wrolpi/scripts' - './test:/opt/wrolpi/test' - './test:/media/wrolpi' + # Help markdown for the AI help search (native installs read /opt/wrolpi-help directly). + - './docker/help/wrolpi-help/docs:/opt/wrolpi-help/docs:ro' - './alembic:/opt/wrolpi/alembic' - './alembic.ini:/opt/wrolpi/alembic.ini' ports: diff --git a/modules/ai/api.py b/modules/ai/api.py index 957b4ecc..b65f6ec3 100644 --- a/modules/ai/api.py +++ b/modules/ai/api.py @@ -26,7 +26,7 @@ from wrolpi.files.lib import search_files from wrolpi.vars import DOCKERIZED from wrolpi.version import __version__ from wrolpi.schema import JSONErrorResponse -from . import lib, schema +from . import help_docs, lib, schema from .controller_client import controller_get from .errors import ControllerUnavailable @@ -334,6 +334,49 @@ async def get_inventory(_: Request, slug: str): return json_response(dict(inventory=inventory)) +# --------------------------------------------------------------------------- +# Help endpoints (Help mode). Help markdown is searched in-process; see help_docs.py. +# --------------------------------------------------------------------------- + +@ai_bp.post('/help/search') +@openapi.definition( + summary='Search the WROLPi help documentation', + description='Search the WROLPi help documentation for how to use and repair WROLPi. Returns matching' + ' help pages with a snippet; read a full page with get_help_doc. Links are pages on the' + ' WROLPi help site.', + body=schema.AIHelpSearchRequest, +) +@openapi.operation('search_help') +@openapi.response(HTTPStatus.OK, schema.AISearchResponse) +@validate(schema.AIHelpSearchRequest) +async def search_help(_: Request, body: schema.AIHelpSearchRequest): + if not body.search_str: + raise SearchEmpty() + if not help_docs.get_help_docs_directory(): + return json_response(dict(results=[], total=0, message='Help documentation is not installed.')) + results, total = help_docs.search_help(body.search_str, ai_limiter(body.limit)) + return json_response(dict(results=results, total=total)) + + +@ai_bp.get('/help/') +@openapi.definition( + summary='Read a help page', + description='Read one WROLPi help page as markdown by its doc slug from search_help results. Long' + ' pages are paged: request again with offset=next_offset to continue reading.', +) +@openapi.operation('get_help_doc') +@openapi.response(HTTPStatus.OK, schema.AIPagedTextResponse) +@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) +async def get_help_doc(request: Request, slug: str): + doc = help_docs.get_help_doc(slug) + if not doc: + raise UnknownFile(f'No such help doc: {slug}') + result = lib.paginate_text(doc.body, _offset(request)) + result['title'] = doc.title + result['link'] = help_docs.help_doc_link(doc.slug) + return json_response(result) + + # --------------------------------------------------------------------------- # System endpoints (System mode). The Controller is proxied so the model sees one API; every # proxy degrades gracefully — a Controller outage becomes an entry in `errors`, never a 500. diff --git a/modules/ai/help_docs.py b/modules/ai/help_docs.py new file mode 100644 index 00000000..b0af1dcd --- /dev/null +++ b/modules/ai/help_docs.py @@ -0,0 +1,152 @@ +"""In-memory search over the WROLPi help markdown. + +The help docs are a small corpus (dozens of markdown files) served by mkdocs from the wrolpi-help +repository. They live outside the media directory and get no FileGroups, so they are searched +in-process: files are parsed on demand and cached by mtime. No database, no index to build. +""" +import dataclasses +import os +import pathlib +import re +from typing import Dict, List, Optional + +from wrolpi.common import logger +from wrolpi.vars import PROJECT_DIR + +logger = logger.getChild(__name__) + +# Native installs clone wrolpi-help to /opt/wrolpi-help; development uses the git submodule. +# The docker api container gets a read-only bind mount at the native location. +DEFAULT_HELP_DIRS = ( + pathlib.Path('/opt/wrolpi-help/docs'), + PROJECT_DIR / 'docker/help/wrolpi-help/docs', +) + +SNIPPET_LENGTH = 200 + + +def get_help_docs_directory() -> Optional[pathlib.Path]: + """The directory containing the help markdown, or None when help is not installed.""" + if directory := os.environ.get('HELP_DOCS_DIR'): + directory = pathlib.Path(directory) + return directory if directory.is_dir() else None + for directory in DEFAULT_HELP_DIRS: + if directory.is_dir(): + return directory + return None + + +@dataclasses.dataclass +class HelpDoc: + slug: str # relative path without .md, e.g. "system/logs" + title: str + headings: List[str] + body: str + mtime: float + + +# slug -> HelpDoc, invalidated per-file by mtime. +_CACHE: Dict[str, HelpDoc] = {} + + +def _parse_doc(slug: str, path: pathlib.Path) -> HelpDoc: + body = path.read_text(errors='replace') + title = None + headings = [] + for line in body.splitlines(): + if match := re.match(r'^(#{1,6})\s+(.+)', line): + heading = match.group(2).strip() + headings.append(heading) + if title is None and match.group(1) == '#': + title = heading + return HelpDoc(slug=slug, title=title or slug, headings=headings, body=body, mtime=path.stat().st_mtime) + + +def load_help_docs() -> List[HelpDoc]: + """Load every help markdown file, reusing cached parses of unchanged files.""" + directory = get_help_docs_directory() + if not directory or not directory.is_dir(): + return [] + + docs = [] + seen = set() + for path in sorted(directory.rglob('*.md')): + slug = str(path.relative_to(directory)).removesuffix('.md') + seen.add(slug) + try: + cached = _CACHE.get(slug) + if not cached or cached.mtime != path.stat().st_mtime: + _CACHE[slug] = _parse_doc(slug, path) + docs.append(_CACHE[slug]) + except OSError as e: + logger.warning(f'Could not read help doc {path}', exc_info=e) + # Drop deleted files from the cache. + for slug in set(_CACHE) - seen: + del _CACHE[slug] + return docs + + +def help_doc_link(slug: str) -> str: + """The mkdocs URL path of a doc; the help server (port 8086) serves index.md at /.""" + if slug == 'index': + return '/' + slug = slug.removesuffix('/index') + return f'/{slug}/' + + +def _tokenize(text: str) -> List[str]: + return [i for i in re.split(r'\W+', text.lower()) if len(i) >= 2] + + +def _snippet(body: str, tokens: List[str]) -> Optional[str]: + """A little context around the first token match in the body.""" + lower = body.lower() + position = min((p for token in tokens if (p := lower.find(token)) >= 0), default=-1) + if position < 0: + return None + start = max(position - SNIPPET_LENGTH // 4, 0) + snippet = body[start:start + SNIPPET_LENGTH] + snippet = re.sub(r'\s+', ' ', snippet).strip() + return f'…{snippet}…' + + +def search_help(search_str: str, limit: int = 5) -> (List[dict], int): + """Score every help doc against the query; title matches beat heading matches beat body matches.""" + tokens = _tokenize(search_str or '') + if not tokens: + return [] + + scored = [] + for doc in load_help_docs(): + title = doc.title.lower() + headings = ' '.join(doc.headings).lower() + body = doc.body.lower() + score = 0 + for token in tokens: + score += 10 * title.count(token) + score += 4 * headings.count(token) + score += body.count(token) + if score: + scored.append((score, doc)) + + scored.sort(key=lambda i: (-i[0], i[1].slug)) + results = [] + for score, doc in scored[:limit]: + result = dict( + doc=doc.slug, + title=doc.title, + link=help_doc_link(doc.slug), + snippet=_snippet(doc.body, tokens), + ) + results.append({k: v for k, v in result.items() if v is not None}) + return results, len(scored) + + +def get_help_doc(slug: str) -> Optional[HelpDoc]: + """Fetch one help doc by its slug. Returns None when it (or the help directory) is missing.""" + if '..' in slug.split('/'): + return None + for doc in load_help_docs(): + if doc.slug == slug: + return doc + return None diff --git a/modules/ai/schema.py b/modules/ai/schema.py index 0a2cc717..1c48ef74 100644 --- a/modules/ai/schema.py +++ b/modules/ai/schema.py @@ -49,6 +49,12 @@ class AIZimSearchRequest: offset: int = 0 +@dataclasses.dataclass +class AIHelpSearchRequest: + search_str: Optional[str] = None + limit: int = 5 + + @dataclasses.dataclass class AISearchResponse: results: list = dataclasses.field(default_factory=list) diff --git a/modules/ai/test/test_help_docs.py b/modules/ai/test/test_help_docs.py new file mode 100644 index 00000000..7e495a32 --- /dev/null +++ b/modules/ai/test/test_help_docs.py @@ -0,0 +1,114 @@ +"""Tests for the help-docs search (Help mode). Uses a fixture corpus, never the real submodule.""" +import json +from http import HTTPStatus + +import pytest + +from modules.ai import help_docs, lib + + +@pytest.fixture +def help_corpus(test_directory, monkeypatch): + """A small help-docs corpus in a temporary directory.""" + docs = test_directory / 'help-docs' + (docs / 'system').mkdir(parents=True) + (docs / 'modules').mkdir() + (docs / 'index.md').write_text('# Home\n\nWelcome to WROLPi help.\n') + (docs / 'system/logs.md').write_text( + '# Logs\n\n## Reading logs\n\nUse journalctl to read service logs on a WROLPi.\n') + (docs / 'modules/videos.md').write_text( + '# Videos\n\n## Downloading\n\nVideos are downloaded with yt-dlp into channels.\n' + + ('More text about videos. ' * 400)) # > one page + monkeypatch.setenv('HELP_DOCS_DIR', str(docs)) + help_docs._CACHE.clear() + try: + yield docs + finally: + help_docs._CACHE.clear() + + +def test_search_help_scoring(help_corpus): + """Title matches outrank body matches; snippets show context.""" + results, total = help_docs.search_help('logs') + assert total == 1 + assert results[0]['doc'] == 'system/logs' + assert results[0]['title'] == 'Logs' + assert results[0]['link'] == '/system/logs/' + assert 'journalctl' in results[0]['snippet'] or 'Logs' in results[0]['snippet'] + + # 'videos' matches the videos doc far better than any other. + results, _ = help_docs.search_help('downloading videos') + assert results[0]['doc'] == 'modules/videos' + + # No matches. + results, total = help_docs.search_help('xyzzy') + assert results == [] and total == 0 + + +def test_help_doc_cache_invalidation(help_corpus): + """Edited files are re-parsed; deleted files leave the corpus.""" + results, _ = help_docs.search_help('welcome') + assert results[0]['doc'] == 'index' + assert results[0]['link'] == '/' + + (help_corpus / 'index.md').write_text('# Home\n\nGreetings, traveler.\n') + import os + os.utime(help_corpus / 'index.md', (0, 0)) # force a distinct mtime + results, total = help_docs.search_help('welcome') + assert total == 0 + results, _ = help_docs.search_help('greetings') + assert results[0]['doc'] == 'index' + + (help_corpus / 'system/logs.md').unlink() + _, total = help_docs.search_help('logs') + assert total == 0 + + +def test_get_help_doc_refuses_traversal(help_corpus): + assert help_docs.get_help_doc('../secrets') is None + assert help_docs.get_help_doc('system/logs') is not None + + +@pytest.mark.asyncio +async def test_ai_help_endpoints(async_client, help_corpus): + """Help can be searched and pages read as paged markdown.""" + content = dict(search_str='logs') + request, response = await async_client.post('/api/ai/help/search', content=json.dumps(content)) + assert response.status_code == HTTPStatus.OK + assert response.json['total'] == 1 + result = response.json['results'][0] + assert result['doc'] == 'system/logs' + + request, response = await async_client.get(f'/api/ai/help/{result["doc"]}') + assert response.status_code == HTTPStatus.OK + assert response.json['title'] == 'Logs' + assert 'journalctl' in response.json['content'] + + # Long pages are paged. + request, response = await async_client.get('/api/ai/help/modules/videos') + assert response.status_code == HTTPStatus.OK + assert response.json['next_offset'] == lib.PAGE_SIZE + + # Unknown page. + request, response = await async_client.get('/api/ai/help/no/such/page') + assert response.status_code == HTTPStatus.NOT_FOUND + + # Empty search is a clear error. + request, response = await async_client.post('/api/ai/help/search', content=json.dumps(dict())) + assert response.status_code == HTTPStatus.BAD_REQUEST + + +@pytest.mark.asyncio +async def test_ai_help_not_installed(async_client, monkeypatch, test_directory): + """A missing help directory returns empty results with a message, never a 500.""" + monkeypatch.setenv('HELP_DOCS_DIR', str(test_directory / 'nope')) + help_docs._CACHE.clear() + + content = dict(search_str='logs') + request, response = await async_client.post('/api/ai/help/search', content=json.dumps(content)) + assert response.status_code == HTTPStatus.OK + assert response.json['results'] == [] + assert 'not installed' in response.json['message'] + + request, response = await async_client.get('/api/ai/help/system/logs') + assert response.status_code == HTTPStatus.NOT_FOUND From c6e0f18d5e0d618fc266f6527c20b5ae7fcc2675 Mon Sep 17 00:00:00 2001 From: Roland Date: Tue, 25 Aug 2026 21:21:34 -0600 Subject: [PATCH 004/181] ai: ai.yaml config, model catalog, and the GGUF downloader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ai.yaml is the source of truth (opt-in: enabled=False by default) with active model, idle-unload minutes, context override, and tool-call bound. The model catalog follows the map pattern: a bundled constant keeps the Manage tab working offline, the CDN manifest (ai/models.json + detached GPG signature) wins when reachable. GGUF downloads clone the map-extract flow — the per-file meta4 is GPG-verified once and the same bytes handed to aria2c, so the verified hash is the enforced one; unverified downloads are refused. Models land in /ai/models/ (gated by the existing media_mounted download rules). Manage endpoints (catalog with RAM-tier recommendation, settings) live under /api/ai/manage/ which the future agent loop excludes from its tool catalog. Co-Authored-By: Claude Fable 5 --- main.py | 4 + modules/ai/__init__.py | 1 + modules/ai/api.py | 69 ++++++++++- modules/ai/catalog.py | 108 ++++++++++++++++ modules/ai/config.py | 101 +++++++++++++++ modules/ai/conftest.py | 14 +++ modules/ai/downloader.py | 120 ++++++++++++++++++ modules/ai/schema.py | 8 ++ modules/ai/test/test_config_catalog.py | 163 +++++++++++++++++++++++++ wrolpi/contexts.py | 8 ++ 10 files changed, 593 insertions(+), 3 deletions(-) create mode 100644 modules/ai/catalog.py create mode 100644 modules/ai/config.py create mode 100644 modules/ai/downloader.py create mode 100644 modules/ai/test/test_config_catalog.py diff --git a/main.py b/main.py index 92cd3506..1ce06cd7 100755 --- a/main.py +++ b/main.py @@ -289,6 +289,10 @@ async def start_single_tasks(app: Sanic): from wrolpi.downloader import get_download_cache_config get_download_cache_config().import_config() logger.debug('download cache config imported') + with log_and_suppress(Exception, message='Failed to import ai config'): + from modules.ai.config import get_ai_config + get_ai_config().import_config() + logger.debug('ai config imported') logger.debug('start_single_tasks waiting for db...') async with flags.db_up.wait_for(): diff --git a/modules/ai/__init__.py b/modules/ai/__init__.py index 8f05d9c0..a34acee8 100644 --- a/modules/ai/__init__.py +++ b/modules/ai/__init__.py @@ -1 +1,2 @@ from modules.ai.api import ai_bp # noqa +from modules.ai.downloader import ModelDownloader # noqa Instantiation registers the downloader. diff --git a/modules/ai/api.py b/modules/ai/api.py index b65f6ec3..4a72ef94 100644 --- a/modules/ai/api.py +++ b/modules/ai/api.py @@ -21,12 +21,13 @@ from wrolpi.api_utils import json_response from wrolpi.collections.lib import search_collections from wrolpi.common import api_param_limiter, get_media_directory, logger, wrol_mode_enabled from wrolpi.downloader import download_manager -from wrolpi.errors import InvalidFile, SearchEmpty, UnknownFile +from wrolpi.errors import InvalidFile, SearchEmpty, UnknownFile, ValidationError from wrolpi.files.lib import search_files -from wrolpi.vars import DOCKERIZED +from wrolpi.vars import DOCKERIZED, IS_RPI4 from wrolpi.version import __version__ from wrolpi.schema import JSONErrorResponse -from . import help_docs, lib, schema +from . import catalog, help_docs, lib, schema +from .config import get_ai_config from .controller_client import controller_get from .errors import ControllerUnavailable @@ -513,6 +514,68 @@ async def ai_list_disks(_: Request): return json_response(ret) +# --------------------------------------------------------------------------- +# Manage endpoints — for the AI page's Manage tab, NOT part of the model's tool catalog. +# The agent loop excludes everything under /api/ai/manage/ when building tool definitions. +# --------------------------------------------------------------------------- + +@ai_bp.get('/manage/catalog') +@openapi.description('The AI model catalog, download/active state, and the recommended tier for this' + ' device. For the Manage tab, not the model.') +async def manage_catalog(_: Request): + models, source = await catalog.get_models_catalog() + models_directory = catalog.get_models_directory() + downloaded = {i.name for i in models_directory.glob('*.gguf')} if models_directory.is_dir() else set() + disk_usage = sum(i.stat().st_size for i in models_directory.glob('*')) if models_directory.is_dir() else 0 + + config = get_ai_config() + models = [dict(i, downloaded=i['name'] in downloaded, active=i['name'] == config.active_model) + for i in models] + + total_ram = catalog.get_total_ram_bytes() + return json_response(dict( + models=models, + catalog_source=source, + total_ram=total_ram, + recommended_tier=catalog.recommend_tier(total_ram), + slow_hardware=IS_RPI4, + models_directory=str(models_directory), + disk_usage=disk_usage, + enabled=config.enabled, + active_model=config.active_model, + idle_unload_minutes=config.idle_unload_minutes, + context_size=config.context_size, + )) + + +@ai_bp.post('/manage/settings') +@openapi.definition( + description='Update the AI settings (ai.yaml). For the Manage tab, not the model.', + body=schema.AIManageSettingsRequest, +) +@validate(schema.AIManageSettingsRequest) +async def manage_settings(_: Request, body: schema.AIManageSettingsRequest): + config = get_ai_config() + if body.active_model is not None: + if body.active_model and not (catalog.get_models_directory() / body.active_model).is_file(): + raise ValidationError(f'Model is not downloaded: {body.active_model}') + config.active_model = body.active_model + if body.enabled is not None: + config.enabled = body.enabled + if body.idle_unload_minutes is not None: + if body.idle_unload_minutes < 1: + raise ValidationError('idle_unload_minutes must be at least 1') + config.idle_unload_minutes = body.idle_unload_minutes + if body.context_size is not None: + config.context_size = body.context_size + return json_response(dict( + enabled=config.enabled, + active_model=config.active_model, + idle_unload_minutes=config.idle_unload_minutes, + context_size=config.context_size, + )) + + @ai_bp.get('/files/read') @openapi.definition( summary='Read a text file', diff --git a/modules/ai/catalog.py b/modules/ai/catalog.py new file mode 100644 index 00000000..dadf3579 --- /dev/null +++ b/modules/ai/catalog.py @@ -0,0 +1,108 @@ +"""The AI model catalog. + +The bundled AI_MODELS constant is the offline fallback (like the map module's MAP_REGIONS); +the CDN manifest at ai/models.json is fetched at browse/download time and GPG-verified with the +committed WROLPi public key. The release process refreshes AI_MODELS from the published manifest. +""" +import json +import tempfile +from pathlib import Path + +import psutil + +from wrolpi.common import aiohttp_get, get_media_directory, logger, verify_gpg_signature + +logger = logger.getChild(__name__) + +CDN = 'https://wrolpi.nyc3.cdn.digitaloceanspaces.com' +MODELS_MANIFEST_URL = f'{CDN}/ai/models.json' + +# Sizes are bytes; sha256 values are filled from the published manifest at release time. The +# meta4 sidecar (GPG-verified at download time) is what actually enforces the hash. +AI_MODELS = [ + dict( + name='Qwen3-1.7B-Q4_K_M.gguf', + tier='small', + url=f'{CDN}/ai/Qwen3-1.7B-Q4_K_M.gguf', + sha256='', + size=1_200_000_000, + min_ram_gb=4, + default_context=8_192, + license='Apache 2.0', + description='The default assistant for 4GB devices (Pi 4/5). Best tool-calling per byte.', + ), + dict( + name='Qwen3-4B-Instruct-2507-Q4_K_M.gguf', + tier='medium', + url=f'{CDN}/ai/Qwen3-4B-Instruct-2507-Q4_K_M.gguf', + sha256='', + size=2_600_000_000, + min_ram_gb=8, + default_context=16_384, + license='Apache 2.0', + description='The default assistant for 8GB+ devices. A standout small tool-caller.', + ), +] + + +def get_models_directory() -> Path: + """Where GGUF model files are stored. Always inside the media directory.""" + return get_media_directory() / 'ai/models' + + +async def fetch_models_manifest(url: str = None) -> dict: + """Fetch the models manifest from the CDN and verify its GPG signature (map-manifest pattern).""" + url = url or MODELS_MANIFEST_URL + sig_url = f'{url}.sig' + + async with aiohttp_get(url, timeout=30) as response: + if response.status != 200: + raise RuntimeError(f'Failed to fetch models manifest: HTTP {response.status}') + manifest_bytes = await response.content.read() + + async with aiohttp_get(sig_url, timeout=30) as response: + if response.status != 200: + raise RuntimeError(f'Failed to fetch models manifest signature: HTTP {response.status}') + signature_bytes = await response.content.read() + + with tempfile.TemporaryDirectory() as tmpdir: + manifest_path = Path(tmpdir) / 'models.json' + sig_path = Path(tmpdir) / 'models.json.sig' + manifest_path.write_bytes(manifest_bytes) + sig_path.write_bytes(signature_bytes) + + if not await verify_gpg_signature(manifest_path, sig_path): + raise RuntimeError('Models manifest GPG signature verification failed') + + logger.info('Models manifest GPG signature verified successfully') + return json.loads(manifest_bytes) + + +async def get_models_catalog() -> (list, str): + """The list of downloadable models, and where it came from ('cdn' or 'bundled'). + + The CDN manifest wins when reachable; the bundled constant keeps the Manage tab working + offline.""" + try: + manifest = await fetch_models_manifest() + models = manifest.get('models') or [] + if models: + return models, 'cdn' + except Exception as e: + logger.debug(f'Could not fetch models manifest, using bundled catalog: {e}') + return AI_MODELS, 'bundled' + + +def get_total_ram_bytes() -> int: + return psutil.virtual_memory().total + + +def recommend_tier(total_ram_bytes: int = None) -> str: + """Recommend a model tier for this device's RAM. Other tiers stay selectable with a warning.""" + total_ram_bytes = total_ram_bytes if total_ram_bytes is not None else get_total_ram_bytes() + gb = total_ram_bytes / 1024 ** 3 + if gb < 6: + return 'small' + if gb < 24: + return 'medium' + return 'large' diff --git a/modules/ai/config.py b/modules/ai/config.py new file mode 100644 index 00000000..29569d36 --- /dev/null +++ b/modules/ai/config.py @@ -0,0 +1,101 @@ +"""The ai.yaml config — the source of truth for the AI assistant's state. + +AI is opt-in: nothing is downloaded and llama-server does not run until the user enables it on +the Manage tab. YAML-only (no database).""" +import contextlib +from dataclasses import dataclass +from typing import Optional + +from wrolpi.common import ConfigFile + + +@dataclass +class AIConfigValidator: + version: int = None + enabled: bool = None + active_model: str = None + idle_unload_minutes: int = None + context_size: Optional[int] = None + max_tool_calls: int = None + + +class AIConfig(ConfigFile): + file_name = 'ai.yaml' + default_config = dict( + version=0, + enabled=False, + active_model='', # a GGUF file name in /ai/models/ + idle_unload_minutes=15, + context_size=None, # None uses the active model's default from the catalog. + max_tool_calls=6, + ) + validator = AIConfigValidator + + def import_config(self, file=None, send_events=False): + super().import_config(file, send_events) + # YAML-only config (no database); a successful read is a successful import. + self.successful_import = True + + @property + def enabled(self) -> bool: + return self._config['enabled'] + + @enabled.setter + def enabled(self, value: bool): + self.update({'enabled': bool(value)}) + + @property + def active_model(self) -> str: + return self._config['active_model'] + + @active_model.setter + def active_model(self, value: str): + self.update({'active_model': value or ''}) + + @property + def idle_unload_minutes(self) -> int: + return self._config['idle_unload_minutes'] + + @idle_unload_minutes.setter + def idle_unload_minutes(self, value: int): + self.update({'idle_unload_minutes': int(value)}) + + @property + def context_size(self) -> Optional[int]: + return self._config['context_size'] + + @context_size.setter + def context_size(self, value: Optional[int]): + self.update({'context_size': int(value) if value else None}) + + @property + def max_tool_calls(self) -> int: + return self._config['max_tool_calls'] + + @max_tool_calls.setter + def max_tool_calls(self, value: int): + self.update({'max_tool_calls': int(value)}) + + +AI_CONFIG: AIConfig = AIConfig() + +# Test override (see get_ai_config). +TEST_AI_CONFIG: Optional[AIConfig] = None + + +def get_ai_config() -> AIConfig: + global TEST_AI_CONFIG + if isinstance(TEST_AI_CONFIG, ConfigFile): + return TEST_AI_CONFIG + return AI_CONFIG + + +@contextlib.contextmanager +def ai_config_context(): + """Used to create a test config.""" + global TEST_AI_CONFIG + TEST_AI_CONFIG = AIConfig() + try: + yield TEST_AI_CONFIG + finally: + TEST_AI_CONFIG = None diff --git a/modules/ai/conftest.py b/modules/ai/conftest.py index 25fcf989..7fdd759b 100644 --- a/modules/ai/conftest.py +++ b/modules/ai/conftest.py @@ -1,3 +1,17 @@ # AI module test fixtures. The docs conftest is not star-imported by the root conftest, but the # AI endpoints cover docs; import its fixtures so AI tests can use doc_factory. +import pytest + +from modules.ai.config import ai_config_context from modules.docs.conftest import * # noqa +from wrolpi.api_utils import api_app + + +@pytest.fixture +def test_ai_config(test_directory, async_client): + """An isolated ai.yaml config for one test.""" + (test_directory / 'config').mkdir(exist_ok=True) + config_path = test_directory / 'config/ai.yaml' + with ai_config_context() as config: + config.initialize(api_app.shared_ctx.ai_config) + yield config_path diff --git a/modules/ai/downloader.py b/modules/ai/downloader.py new file mode 100644 index 00000000..30d95bec --- /dev/null +++ b/modules/ai/downloader.py @@ -0,0 +1,120 @@ +"""Downloads GGUF model files from the WROLPi CDN into /ai/models/. + +Follows the map-extract pattern: the per-file .meta4 sidecar is fetched once and its hash +signature GPG-verified, then the same bytes are handed to aria2c so the hash that was verified +is the hash that is enforced. Downloads are resumable and respect the media_mounted rules via +the download manager.""" +import pathlib +from dataclasses import dataclass +from typing import Optional + +from sqlalchemy.orm import Session + +from modules.ai.catalog import get_models_directory +from modules.map.downloader import verify_meta4_signature +from wrolpi.common import logger +from wrolpi.downloader import Download, DownloadContext, Downloader, DownloadResult +from wrolpi.events import Events + +__all__ = ['ModelDownloader', 'PreparedModel', 'ExecutedModel', 'ai_model_downloader'] + +logger = logger.getChild(__name__) + + +@dataclass +class PreparedModel: + """Plan produced by ModelDownloader.prepare_download.""" + name: str + models_directory: pathlib.Path + output_path: pathlib.Path + tmp_path: pathlib.Path + already_done: bool = False + error: Optional[str] = None + + +@dataclass +class ExecutedModel: + """Output of ModelDownloader.execute_download.""" + name: str + output_path: pathlib.Path + skipped: bool = False + error: Optional[str] = None + + +class ModelDownloader(Downloader): + """Downloads one GGUF model file via aria2c with GPG-verified hash checking.""" + + name = 'ai_model' + listable = False + + def prepare_download(self, session: Session, download: Download) -> PreparedModel: + name = download.url.rstrip('/').rsplit('/', 1)[-1] + models_directory = get_models_directory() + output_path = models_directory / name + tmp_path = models_directory / f'{name}.tmp' + + if not name.endswith('.gguf'): + return PreparedModel(name=name, models_directory=models_directory, output_path=output_path, + tmp_path=tmp_path, error=f'Not a GGUF model URL: {download.url}') + + prepared = PreparedModel(name=name, models_directory=models_directory, output_path=output_path, + tmp_path=tmp_path) + if output_path.is_file(): + logger.info(f'{name} already exists, skipping download') + prepared.already_done = True + return prepared + + models_directory.mkdir(parents=True, exist_ok=True) + # Clean up any leftover temp file; aria2c resumes its own .aria2 checkpoints. + tmp_path.unlink(missing_ok=True) + return prepared + + async def execute_download(self, prepared: PreparedModel, ctx: DownloadContext, + download: Download = None) -> ExecutedModel: + if prepared.error: + return ExecutedModel(name=prepared.name, output_path=prepared.output_path, error=prepared.error) + if prepared.already_done: + return ExecutedModel(name=prepared.name, output_path=prepared.output_path, skipped=True) + + url = download.url + + # Fetch the meta4 once, GPG-verify the hash signature before downloading. + meta4_contents = await self.get_meta4_contents(url) + if not meta4_contents: + return ExecutedModel(name=prepared.name, output_path=prepared.output_path, + error=f'No meta4 available for {prepared.name}; refusing an unverified download') + if not await verify_meta4_signature(meta4_contents): + return ExecutedModel(name=prepared.name, output_path=prepared.output_path, + error=f'meta4 hash signature verification failed for {prepared.name}') + logger.info(f'meta4 signature verified for {prepared.name}') + + downloaded_path = None + try: + downloaded_path = await self.download_file( + download, url, prepared.models_directory, + check_for_meta4=False, concurrent=1, meta4_xml=meta4_contents, + ctx=ctx, + ) + downloaded_path.rename(prepared.tmp_path) + except Exception as e: + logger.error(f'Model download failed for {prepared.name}: {e}') + prepared.tmp_path.unlink(missing_ok=True) + if downloaded_path: + downloaded_path.unlink(missing_ok=True) + return ExecutedModel(name=prepared.name, output_path=prepared.output_path, + error=f'Download failed: {str(e)[:500]}') + + # Replace atomically. + prepared.tmp_path.rename(prepared.output_path) + return ExecutedModel(name=prepared.name, output_path=prepared.output_path) + + def finalize_download(self, session: Session, download: Download, executed: ExecutedModel) -> DownloadResult: + if executed.error: + return DownloadResult(success=False, error=executed.error) + if not executed.skipped: + Events.send_user_notify(f'AI model downloaded: {executed.name}', url='/ai/manage') + return DownloadResult(success=True, location='/ai/manage') + + +# Instantiating registers with the download manager. +ai_model_downloader = ModelDownloader() diff --git a/modules/ai/schema.py b/modules/ai/schema.py index 1c48ef74..7d7390db 100644 --- a/modules/ai/schema.py +++ b/modules/ai/schema.py @@ -55,6 +55,14 @@ class AIHelpSearchRequest: limit: int = 5 +@dataclasses.dataclass +class AIManageSettingsRequest: + enabled: Optional[bool] = None + active_model: Optional[str] = None + idle_unload_minutes: Optional[int] = None + context_size: Optional[int] = None + + @dataclasses.dataclass class AISearchResponse: results: list = dataclasses.field(default_factory=list) diff --git a/modules/ai/test/test_config_catalog.py b/modules/ai/test/test_config_catalog.py new file mode 100644 index 00000000..6fca689a --- /dev/null +++ b/modules/ai/test/test_config_catalog.py @@ -0,0 +1,163 @@ +"""Tests for the ai.yaml config, the model catalog, and the GGUF downloader.""" +import json +from http import HTTPStatus +from unittest import mock + +import pytest +import yaml + +from modules.ai import catalog +from modules.ai.config import get_ai_config +from modules.ai.downloader import ai_model_downloader +from wrolpi.downloader import Download + + +def test_ai_config_defaults_and_update(test_ai_config): + """The config has safe defaults (AI off) and round-trips values.""" + config = get_ai_config() + assert config.enabled is False + assert config.active_model == '' + assert config.idle_unload_minutes == 15 + assert config.context_size is None + assert config.max_tool_calls == 6 + + config.enabled = True + config.active_model = 'Qwen3-1.7B-Q4_K_M.gguf' + config.context_size = 8_192 + assert get_ai_config().enabled is True + assert get_ai_config().active_model == 'Qwen3-1.7B-Q4_K_M.gguf' + assert get_ai_config().context_size == 8_192 + + +@pytest.mark.asyncio +async def test_ai_config_import(test_ai_config): + """A hand-edited ai.yaml is the source of truth on import.""" + test_ai_config.write_text(yaml.dump(dict( + version=1, enabled=True, active_model='custom.gguf', idle_unload_minutes=5, + context_size=None, max_tool_calls=4, + ))) + config = get_ai_config() + config.import_config(test_ai_config) + assert config.enabled is True + assert config.active_model == 'custom.gguf' + assert config.idle_unload_minutes == 5 + assert config.max_tool_calls == 4 + assert config.successful_import is True + + +@pytest.mark.asyncio +async def test_models_catalog_fallback(async_client): + """The bundled catalog is served when the CDN is unreachable.""" + with mock.patch('modules.ai.catalog.fetch_models_manifest', side_effect=RuntimeError('offline')): + models, source = await catalog.get_models_catalog() + assert source == 'bundled' + assert models == catalog.AI_MODELS + + manifest = dict(version=1, models=[dict(name='new.gguf', tier='small')]) + with mock.patch('modules.ai.catalog.fetch_models_manifest', return_value=manifest): + models, source = await catalog.get_models_catalog() + assert source == 'cdn' + assert models[0]['name'] == 'new.gguf' + + +def test_recommend_tier(): + gb = 1024 ** 3 + assert catalog.recommend_tier(4 * gb) == 'small' + assert catalog.recommend_tier(8 * gb) == 'medium' + assert catalog.recommend_tier(32 * gb) == 'large' + + +@pytest.mark.asyncio +async def test_model_downloader(test_session, test_directory, test_download_manager): + """The model downloader verifies the meta4 signature, stages, and lands in /ai/models.""" + url = 'https://example.com/ai/Test-Model-Q4_K_M.gguf' + download = Download(url=url, downloader='ai_model') + + prepared = ai_model_downloader.prepare_download(test_session, download) + assert prepared.error is None + assert prepared.output_path == test_directory / 'ai/models/Test-Model-Q4_K_M.gguf' + assert prepared.models_directory.is_dir() + + meta4 = b'fake' + + async def fake_download_file(download_, url_, directory, **kwargs): + assert kwargs['meta4_xml'] == meta4 + path = directory / 'Test-Model-Q4_K_M.gguf' + path.write_bytes(b'GGUF fake weights') + return path + + with mock.patch.object(ai_model_downloader, 'get_meta4_contents', return_value=meta4), \ + mock.patch('modules.ai.downloader.verify_meta4_signature', return_value=True), \ + mock.patch.object(ai_model_downloader, 'download_file', side_effect=fake_download_file): + executed = await ai_model_downloader.execute_download(prepared, ctx=None, download=download) + assert executed.error is None + assert prepared.output_path.is_file() + + result = ai_model_downloader.finalize_download(test_session, download, executed) + assert result.success is True + + # A second download of the same model is skipped. + prepared = ai_model_downloader.prepare_download(test_session, download) + assert prepared.already_done is True + executed = await ai_model_downloader.execute_download(prepared, ctx=None, download=download) + assert executed.skipped is True + + +@pytest.mark.asyncio +async def test_model_downloader_refuses_unverified(test_session, test_directory, test_download_manager): + """No meta4, or a bad signature, refuses the download.""" + url = 'https://example.com/ai/Test-Model.gguf' + download = Download(url=url, downloader='ai_model') + prepared = ai_model_downloader.prepare_download(test_session, download) + + with mock.patch.object(ai_model_downloader, 'get_meta4_contents', return_value=None): + executed = await ai_model_downloader.execute_download(prepared, ctx=None, download=download) + assert 'refusing an unverified download' in executed.error + + with mock.patch.object(ai_model_downloader, 'get_meta4_contents', return_value=b''), \ + mock.patch('modules.ai.downloader.verify_meta4_signature', return_value=False): + executed = await ai_model_downloader.execute_download(prepared, ctx=None, download=download) + assert 'signature verification failed' in executed.error + assert not prepared.output_path.exists() + + # Not a GGUF URL is refused in prepare. + bad = ai_model_downloader.prepare_download(test_session, Download(url='https://example.com/x.zip')) + assert 'Not a GGUF model URL' in bad.error + + +@pytest.mark.asyncio +async def test_ai_manage_endpoints(async_client, test_directory, test_ai_config): + """The Manage tab's catalog and settings endpoints.""" + with mock.patch('modules.ai.catalog.fetch_models_manifest', side_effect=RuntimeError('offline')): + request, response = await async_client.get('/api/ai/manage/catalog') + assert response.status_code == HTTPStatus.OK + assert response.json['catalog_source'] == 'bundled' + assert response.json['enabled'] is False + assert response.json['recommended_tier'] in ('small', 'medium', 'large') + assert all(i['downloaded'] is False for i in response.json['models']) + + # Selecting a model that is not downloaded is refused. + content = dict(active_model='Qwen3-1.7B-Q4_K_M.gguf') + request, response = await async_client.post('/api/ai/manage/settings', content=json.dumps(content)) + assert response.status_code == HTTPStatus.BAD_REQUEST + + # Once the file exists it can be selected, and shows downloaded/active in the catalog. + models_dir = test_directory / 'ai/models' + models_dir.mkdir(parents=True) + (models_dir / 'Qwen3-1.7B-Q4_K_M.gguf').write_bytes(b'GGUF') + content = dict(active_model='Qwen3-1.7B-Q4_K_M.gguf', enabled=True, idle_unload_minutes=5) + request, response = await async_client.post('/api/ai/manage/settings', content=json.dumps(content)) + assert response.status_code == HTTPStatus.OK + assert response.json['active_model'] == 'Qwen3-1.7B-Q4_K_M.gguf' + assert response.json['enabled'] is True + + with mock.patch('modules.ai.catalog.fetch_models_manifest', side_effect=RuntimeError('offline')): + request, response = await async_client.get('/api/ai/manage/catalog') + model = next(i for i in response.json['models'] if i['name'] == 'Qwen3-1.7B-Q4_K_M.gguf') + assert model['downloaded'] is True and model['active'] is True + assert response.json['disk_usage'] > 0 + + # Bad idle minutes. + request, response = await async_client.post('/api/ai/manage/settings', + content=json.dumps(dict(idle_unload_minutes=0))) + assert response.status_code == HTTPStatus.BAD_REQUEST diff --git a/wrolpi/contexts.py b/wrolpi/contexts.py index d961cdf2..e6c59f69 100644 --- a/wrolpi/contexts.py +++ b/wrolpi/contexts.py @@ -35,6 +35,7 @@ def attach_shared_contexts(app: Sanic): app.shared_ctx.download_cache_config = manager.dict() app.shared_ctx.map_pins_config = manager.dict() app.shared_ctx.flasher_config = manager.dict() + app.shared_ctx.ai_config = manager.dict() # Shared dicts. app.shared_ctx.uploaded_files = manager.dict() app.shared_ctx.status = manager.dict() @@ -126,6 +127,7 @@ def reset_shared_contexts(app: Sanic): app.shared_ctx.domains_config.clear() app.shared_ctx.archive_downloader_config.clear() app.shared_ctx.download_cache_config.clear() + app.shared_ctx.ai_config.clear() # Shared dicts. app.shared_ctx.uploaded_files.clear() app.shared_ctx.status.clear() @@ -313,3 +315,9 @@ def initialize_configs_contexts(app: Sanic): FLASHER_CONFIG.initialize(app.shared_ctx.flasher_config) except Exception as e: logger.error(f'Failed to initialize in-memory flasher config: {e}') + + try: + from modules.ai.config import AI_CONFIG + AI_CONFIG.initialize(app.shared_ctx.ai_config) + except Exception as e: + logger.error(f'Failed to initialize in-memory ai config: {e}') From 41bd8f7c45b9489a25bf51e737d2f314639e8af1 Mon Sep 17 00:00:00 2001 From: Roland Date: Tue, 25 Aug 2026 21:27:20 -0600 Subject: [PATCH 005/181] ai: register AIConfig in get_all_configs so it imports on startup Co-Authored-By: Claude Fable 5 --- wrolpi/common.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/wrolpi/common.py b/wrolpi/common.py index c9695d15..9f690ba4 100644 --- a/wrolpi/common.py +++ b/wrolpi/common.py @@ -1173,6 +1173,10 @@ def get_all_configs() -> Dict[str, ConfigFile]: if playlists_config := get_playlists_config(): all_configs[playlists_config.file_name] = playlists_config + from modules.ai.config import get_ai_config + if ai_config := get_ai_config(): + all_configs[ai_config.file_name] = ai_config + return all_configs From 485a9703ebb0b41eaa4dd94160efe04f5f5a65d8 Mon Sep 17 00:00:00 2001 From: Roland Date: Tue, 25 Aug 2026 21:36:00 -0600 Subject: [PATCH 006/181] ai: package llama-server for all three deployments, with idle unload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit install_llama_cpp.sh builds llama-server from the SHA-pinned v0.3.0 source tarball (single source of truth: on-device upgrade, Pi image, Live ISO, and the new docker/llm container all call it; version-marker makes re-runs no-ops). start_llama_server.sh refuses to start unless ai.yaml enables AI and the active model file exists — which also covers unmounted media. wrolpi-llm.service is installed but deliberately never enabled at boot: the Controller starts it on demand (registered in managed_services; the llm compose service follows the samba pattern), and the API's perpetual idle-unload worker asks the Controller to stop it after the configured idle minutes. llama-server is localhost-only natively, internal-network-only in docker, and never proxied by Caddy. Co-Authored-By: Claude Fable 5 --- controller/defaults.py | 10 ++ controller/lib/docker_services.py | 2 +- controller/test/test_defaults.py | 4 +- .../hooks/normal/9999-wrolpi.hook.chroot | 3 + docker-compose.yml | 17 +++ docker/llm/Dockerfile | 17 +++ docker/llm/entrypoint.sh | 5 + etc/raspberrypios/wrolpi-llm.service | 17 +++ modules/ai/__init__.py | 1 + modules/ai/controller_client.py | 12 +- modules/ai/service.py | 112 ++++++++++++++++++ modules/ai/test/test_service.py | 78 ++++++++++++ pi-gen/stage2/04-wrolpi/03-run-chroot.sh | 3 + scripts/start_llama_server.sh | 54 +++++++++ scripts/upgrade.sh | 4 + wrolpi/contexts.py | 3 + wrolpi/scripts/install_llama_cpp.sh | 65 ++++++++++ wrolpi/scripts/read_config_value.sh | 3 +- 18 files changed, 406 insertions(+), 4 deletions(-) create mode 100644 docker/llm/Dockerfile create mode 100755 docker/llm/entrypoint.sh create mode 100644 etc/raspberrypios/wrolpi-llm.service create mode 100644 modules/ai/service.py create mode 100644 modules/ai/test/test_service.py create mode 100755 scripts/start_llama_server.sh create mode 100755 wrolpi/scripts/install_llama_cpp.sh diff --git a/controller/defaults.py b/controller/defaults.py index 2e469170..ebd32065 100644 --- a/controller/defaults.py +++ b/controller/defaults.py @@ -78,6 +78,16 @@ DEFAULT_CONFIG = { "use_https": True, "description": "Kiwix/Zim server", }, + { + "name": "wrolpi-llm", + "systemd_name": "wrolpi-llm", + "port": 11435, + "viewable": False, + "use_https": False, + "description": "AI assistant (llama-server)", + # On-demand: started by the Controller when AI is enabled, stopped by the API when idle. + "kind": "persistent", + }, # System services { "name": "caddy", diff --git a/controller/lib/docker_services.py b/controller/lib/docker_services.py index 82459363..e01f76f7 100644 --- a/controller/lib/docker_services.py +++ b/controller/lib/docker_services.py @@ -27,7 +27,7 @@ except ImportError: CONTAINER_PREFIX = os.environ.get("COMPOSE_PROJECT_NAME", "wrolpi") # Containers that should not have a "View" button (non-HTTP services) -NON_VIEWABLE_CONTAINERS = {"redis", "memcached", "samba"} +NON_VIEWABLE_CONTAINERS = {"redis", "memcached", "samba", "llm"} # Containers that should use HTTPS HTTPS_CONTAINERS = {"web"} diff --git a/controller/test/test_defaults.py b/controller/test/test_defaults.py index 960721d3..6c8bbac4 100644 --- a/controller/test/test_defaults.py +++ b/controller/test/test_defaults.py @@ -57,7 +57,7 @@ class TestManagedServicesConfig: def test_managed_services_count(self): """Should have expected number of managed services.""" services = DEFAULT_CONFIG["managed_services"] - assert len(services) == 11 + assert len(services) == 12 @pytest.mark.parametrize("name,expected", [ ("wrolpi-api", @@ -68,6 +68,8 @@ class TestManagedServicesConfig: ("wrolpi-app-dev", {"port": 3000, "viewable": False, "show_only_when_running": True}), ("caddy", {"port": 443}), ("wrolpi-upgrade", {"port": None, "viewable": False, "show_only_when_running": True}), + ("wrolpi-llm", + {"systemd_name": "wrolpi-llm", "port": 11435, "viewable": False, "kind": "persistent"}), ]) def test_service_config(self, name, expected): """Should have service configured with expected values.""" diff --git a/debian-live-config/config/hooks/normal/9999-wrolpi.hook.chroot b/debian-live-config/config/hooks/normal/9999-wrolpi.hook.chroot index afd3c2e5..84c134f6 100644 --- a/debian-live-config/config/hooks/normal/9999-wrolpi.hook.chroot +++ b/debian-live-config/config/hooks/normal/9999-wrolpi.hook.chroot @@ -143,6 +143,9 @@ cp /opt/wrolpi/wrolpi/blobs/* /opt/wrolpi-blobs/ # systemd. /opt/wrolpi/wrolpi/scripts/install_map_tools.sh +# Bake in llama.cpp (local AI) so a fresh install does not compile it on first upgrade. +/opt/wrolpi/wrolpi/scripts/install_llama_cpp.sh + # Install WROLPi Help. /opt/wrolpi/scripts/install_help_service.sh diff --git a/docker-compose.yml b/docker-compose.yml index e719f961..aab5101c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,6 +42,8 @@ services: # Sanic 25.x wants 'spawn' but our code uses 'fork' for multiprocessing. # This tells Sanic to accept the existing start method. - SANIC_START_METHOD_SET=1 + # llama-server runs in the llm container (native installs use 127.0.0.1). + - LLAMA_URL=http://llm:11435 volumes: - './main.py:/opt/wrolpi/main.py' - './wrolpi:/opt/wrolpi/wrolpi' @@ -147,6 +149,21 @@ services: test: [ 'CMD-SHELL', 'curl http://127.0.0.1:80' ] interval: 1m + llm: # llama-server (local AI) - started/stopped by the controller on demand; the API stops it when idle. + build: + context: . + dockerfile: docker/llm/Dockerfile + restart: "no" + depends_on: + controller: + condition: service_healthy + volumes: + - ${MEDIA_DIRECTORY:-./test}:/media/wrolpi + - './docker/llm/entrypoint.sh:/entrypoint.sh' + healthcheck: + test: [ 'CMD-SHELL', 'curl http://127.0.0.1:11435/health' ] + interval: 1m + samba: # Samba file sharing - started/stopped by the controller based on configured shares. build: context: . diff --git a/docker/llm/Dockerfile b/docker/llm/Dockerfile new file mode 100644 index 00000000..24ef1f6e --- /dev/null +++ b/docker/llm/Dockerfile @@ -0,0 +1,17 @@ +FROM debian:13 +ENV DOCKER=true + +RUN apt-get update && apt-get install -y curl ca-certificates + +# Build llama-server from the pinned release (single source of truth with native installs). +COPY wrolpi/scripts/install_llama_cpp.sh /tmp/install_llama_cpp.sh +RUN /tmp/install_llama_cpp.sh && rm /tmp/install_llama_cpp.sh \ + && apt-get remove -y build-essential cmake && apt-get autoremove -y && apt-get clean + +# The same startup scripts the native install uses. +COPY scripts/start_llama_server.sh /opt/wrolpi/scripts/start_llama_server.sh +COPY wrolpi/scripts/read_config_value.sh /opt/wrolpi/wrolpi/scripts/read_config_value.sh + +COPY docker/llm/entrypoint.sh /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/llm/entrypoint.sh b/docker/llm/entrypoint.sh new file mode 100755 index 00000000..2e19fa40 --- /dev/null +++ b/docker/llm/entrypoint.sh @@ -0,0 +1,5 @@ +#! /usr/bin/env bash +# Bind all interfaces so the api container can reach llama-server on the internal docker network. +# The container publishes no host ports and is not proxied by Caddy. +export LLAMA_HOST=0.0.0.0 +exec /opt/wrolpi/scripts/start_llama_server.sh diff --git a/etc/raspberrypios/wrolpi-llm.service b/etc/raspberrypios/wrolpi-llm.service new file mode 100644 index 00000000..46208744 --- /dev/null +++ b/etc/raspberrypios/wrolpi-llm.service @@ -0,0 +1,17 @@ +[Unit] +Description=WROLPi AI (llama-server) +PartOf=wrolpi.target + +[Service] +ExecStart=/opt/wrolpi/scripts/start_llama_server.sh +Group=wrolpi +User=wrolpi +WorkingDirectory=/opt/wrolpi/ +Restart=on-failure +# The lowest priority of any WROLPi service; inference must never starve the API. +Nice=15 + +# On-demand service: deliberately NOT enabled at boot and NOT wanted by wrolpi.target. +# The Controller starts it when the user enables AI; the API stops it when idle. +[Install] +WantedBy=default.target diff --git a/modules/ai/__init__.py b/modules/ai/__init__.py index a34acee8..0fbb8d43 100644 --- a/modules/ai/__init__.py +++ b/modules/ai/__init__.py @@ -1,2 +1,3 @@ from modules.ai.api import ai_bp # noqa from modules.ai.downloader import ModelDownloader # noqa Instantiation registers the downloader. +from modules.ai import service # noqa Import registers the idle-unload perpetual worker. diff --git a/modules/ai/controller_client.py b/modules/ai/controller_client.py index bef142e3..383451d4 100644 --- a/modules/ai/controller_client.py +++ b/modules/ai/controller_client.py @@ -8,7 +8,7 @@ import os from typing import Tuple from urllib.parse import urlencode -from wrolpi.common import aiohttp_get, logger +from wrolpi.common import aiohttp_get, aiohttp_post, logger from wrolpi.vars import DOCKERIZED logger = logger.getChild(__name__) @@ -25,3 +25,13 @@ async def controller_get(path: str, params: dict = None, timeout: int = 10) -> T url = f'{url}?{urlencode(params)}' async with aiohttp_get(url, timeout=timeout) as response: return response.status, await response.json() + + +async def controller_post(path: str, json_: dict = None, timeout: int = 30) -> Tuple[int, dict]: + """POST a Controller endpoint, returning (status, JSON body). Raises on connection errors.""" + async with aiohttp_post(f'{CONTROLLER_URL}{path}', json_=json_ or {}, timeout=timeout) as response: + try: + body = await response.json() + except Exception: + body = {} + return response.status, body diff --git a/modules/ai/service.py b/modules/ai/service.py new file mode 100644 index 00000000..2299ee0d --- /dev/null +++ b/modules/ai/service.py @@ -0,0 +1,112 @@ +"""llama-server lifecycle: on-demand start and idle unload. + +The API owns the policy (this module); the Controller executes the mechanism through its +existing service start/stop endpoints — it runs as root natively and manages the `llm` +container in docker, so no sudoers changes are needed. + +Idle unload is mandatory on small devices: a swapped-to-death Pi is worse than a slow first +token. The chat handler records activity; the perpetual worker stops llama-server after the +configured idle minutes.""" +import asyncio +import os +import time + +from modules.ai.config import get_ai_config +from modules.ai.controller_client import controller_post +from wrolpi.api_utils import api_app, perpetual_signal +from wrolpi.common import aiohttp_get, logger +from wrolpi.vars import DOCKERIZED + +logger = logger.getChild(__name__) + +LLAMA_URL = os.environ.get('LLAMA_URL', 'http://127.0.0.1:11435') +# The Controller's name for the service: the systemd unit natively, the compose service in docker. +LLM_SERVICE_NAME = 'llm' if DOCKERIZED else 'wrolpi-llm' + +# How long to wait for llama-server to answer /health after a cold start (model load included). +STARTUP_TIMEOUT_SECONDS = 90 + + +def record_llm_activity(): + """Note that the LLM was just used; postpones the idle unload.""" + try: + api_app.shared_ctx.ai_llm_last_request.value = time.time() + except AttributeError: + # Shared contexts are absent in some unit tests. + pass + + +def get_last_llm_activity() -> float: + try: + return api_app.shared_ctx.ai_llm_last_request.value + except AttributeError: + return 0.0 + + +async def llama_healthy() -> bool: + try: + async with aiohttp_get(f'{LLAMA_URL}/health', timeout=5) as response: + return response.status == 200 + except Exception: + return False + + +async def start_llm_service() -> bool: + status, body = await controller_post(f'/api/services/{LLM_SERVICE_NAME}/start') + if status != 200: + logger.error(f'Controller failed to start {LLM_SERVICE_NAME}: HTTP {status} {body}') + return False + return True + + +async def stop_llm_service() -> bool: + status, body = await controller_post(f'/api/services/{LLM_SERVICE_NAME}/stop') + if status != 200: + logger.error(f'Controller failed to stop {LLM_SERVICE_NAME}: HTTP {status} {body}') + return False + return True + + +async def ensure_llama_running(timeout: int = STARTUP_TIMEOUT_SECONDS) -> bool: + """Start llama-server on demand and wait for it to answer /health. + + Returns True when llama-server is up. Cold-start latency (service start + model load) is + expected and acceptable.""" + if await llama_healthy(): + record_llm_activity() + return True + + logger.info(f'Asking the Controller to start {LLM_SERVICE_NAME}') + if not await start_llm_service(): + return False + + started = time.time() + while time.time() - started < timeout: + if await llama_healthy(): + record_llm_activity() + return True + await asyncio.sleep(1) + logger.error(f'llama-server did not become healthy within {timeout}s') + return False + + +@perpetual_signal(sleep=60) +async def perpetual_llm_idle_unload(): + """Stop llama-server after the configured idle minutes. Only acts after it was used.""" + try: + last_activity = get_last_llm_activity() + if not last_activity: + return + config = get_ai_config() + idle_seconds = config.idle_unload_minutes * 60 + if time.time() - last_activity < idle_seconds: + return + if not await llama_healthy(): + # Already stopped (or crashed); nothing to unload. + api_app.shared_ctx.ai_llm_last_request.value = 0.0 + return + logger.info(f'Stopping {LLM_SERVICE_NAME} after {config.idle_unload_minutes} idle minutes') + if await stop_llm_service(): + api_app.shared_ctx.ai_llm_last_request.value = 0.0 + except Exception as e: + logger.error('LLM idle unload worker failed', exc_info=e) diff --git a/modules/ai/test/test_service.py b/modules/ai/test/test_service.py new file mode 100644 index 00000000..28d74bf5 --- /dev/null +++ b/modules/ai/test/test_service.py @@ -0,0 +1,78 @@ +"""Tests for the llama-server lifecycle: on-demand start and idle unload.""" +import time +from unittest import mock + +import pytest + +from modules.ai import service + + +@pytest.mark.asyncio +async def test_ensure_llama_running_already_healthy(async_client): + """A healthy llama-server is used as-is and activity is recorded.""" + with mock.patch('modules.ai.service.llama_healthy', return_value=True), \ + mock.patch('modules.ai.service.start_llm_service') as start: + assert await service.ensure_llama_running() is True + start.assert_not_called() + assert service.get_last_llm_activity() > 0 + + +@pytest.mark.asyncio +async def test_ensure_llama_running_cold_start(async_client): + """An unhealthy llama-server is started via the Controller and polled until healthy.""" + health = iter([False, False, True]) + + async def fake_healthy(): + return next(health) + + with mock.patch('modules.ai.service.llama_healthy', side_effect=fake_healthy), \ + mock.patch('modules.ai.service.start_llm_service', return_value=True) as start, \ + mock.patch('modules.ai.service.asyncio.sleep'): + assert await service.ensure_llama_running(timeout=10) is True + start.assert_called_once() + + +@pytest.mark.asyncio +async def test_ensure_llama_running_start_fails(async_client): + """A Controller start failure is reported as False, not an exception.""" + with mock.patch('modules.ai.service.llama_healthy', return_value=False), \ + mock.patch('modules.ai.service.start_llm_service', return_value=False): + assert await service.ensure_llama_running(timeout=1) is False + + +@pytest.mark.asyncio +async def test_idle_unload(async_client, test_ai_config): + """llama-server is stopped only after the configured idle minutes.""" + from wrolpi.api_utils import api_app + from modules.ai.config import get_ai_config + + get_ai_config().idle_unload_minutes = 10 + + # Never used: nothing happens. + api_app.shared_ctx.ai_llm_last_request.value = 0.0 + with mock.patch('modules.ai.service.stop_llm_service') as stop: + await service.perpetual_llm_idle_unload() + stop.assert_not_called() + + # Recently used: not stopped. + api_app.shared_ctx.ai_llm_last_request.value = time.time() + with mock.patch('modules.ai.service.stop_llm_service') as stop, \ + mock.patch('modules.ai.service.llama_healthy', return_value=True): + await service.perpetual_llm_idle_unload() + stop.assert_not_called() + + # Idle past the threshold: stopped, and the activity marker resets. + api_app.shared_ctx.ai_llm_last_request.value = time.time() - 11 * 60 + with mock.patch('modules.ai.service.stop_llm_service', return_value=True) as stop, \ + mock.patch('modules.ai.service.llama_healthy', return_value=True): + await service.perpetual_llm_idle_unload() + stop.assert_called_once() + assert api_app.shared_ctx.ai_llm_last_request.value == 0.0 + + # Idle but already stopped: the marker resets without a stop call. + api_app.shared_ctx.ai_llm_last_request.value = time.time() - 11 * 60 + with mock.patch('modules.ai.service.stop_llm_service') as stop, \ + mock.patch('modules.ai.service.llama_healthy', return_value=False): + await service.perpetual_llm_idle_unload() + stop.assert_not_called() + assert api_app.shared_ctx.ai_llm_last_request.value == 0.0 diff --git a/pi-gen/stage2/04-wrolpi/03-run-chroot.sh b/pi-gen/stage2/04-wrolpi/03-run-chroot.sh index 9affc7f8..97851f89 100755 --- a/pi-gen/stage2/04-wrolpi/03-run-chroot.sh +++ b/pi-gen/stage2/04-wrolpi/03-run-chroot.sh @@ -45,6 +45,9 @@ git config --global --add safe.directory /opt/wrolpi # CPU-heavy step on weak hardware. /opt/wrolpi/wrolpi/scripts/install_map_tools.sh +# Bake in llama.cpp (local AI) so a fresh Pi does not compile it on first upgrade. +/opt/wrolpi/wrolpi/scripts/install_llama_cpp.sh + # Install Python requirements. Try multiple times because pypi may stop responding. python3 -m venv /opt/wrolpi/venv /opt/wrolpi/venv/bin/pip3 install -r /opt/wrolpi/requirements.txt || diff --git a/scripts/start_llama_server.sh b/scripts/start_llama_server.sh new file mode 100755 index 00000000..b1e786ba --- /dev/null +++ b/scripts/start_llama_server.sh @@ -0,0 +1,54 @@ +#! /usr/bin/env bash +# Start llama-server (WROLPi's local AI inference runtime) with the model selected in ai.yaml. +# +# AI is opt-in: this refuses to start unless ai.yaml enables it and the active model file exists +# in the media directory (which also covers the unmounted-media case). Started/stopped on demand +# by the Controller; the API's idle-unload worker stops it after inactivity. +set -u + +MEDIA_DIRECTORY="${MEDIA_DIRECTORY:-/media/wrolpi}" +PROJECT_DIR="${PROJECT_DIR:-/opt/wrolpi}" +AI_CONFIG="${MEDIA_DIRECTORY}/config/ai.yaml" +# Docker overrides the host so the api container can reach this container. +LLAMA_HOST="${LLAMA_HOST:-127.0.0.1}" +LLAMA_PORT="${LLAMA_PORT:-11435}" + +read_ai_config() { + MEDIA_DIRECTORY="${MEDIA_DIRECTORY}" WROLPI_CONFIG_FILE="${AI_CONFIG}" \ + "${PROJECT_DIR}/wrolpi/scripts/read_config_value.sh" "$@" +} + +[ ! -d "${MEDIA_DIRECTORY}" ] && echo "Cannot start llama-server without media directory mounted" && exit 1 +[ ! -f "${AI_CONFIG}" ] && echo "Cannot start llama-server without ${AI_CONFIG}" && exit 1 + +ENABLED="$(read_ai_config enabled false)" +if [ "${ENABLED}" != "true" ] && [ "${ENABLED}" != "True" ]; then + echo "AI is not enabled in ${AI_CONFIG}; refusing to start" + exit 1 +fi + +ACTIVE_MODEL="$(read_ai_config active_model)" +[ -z "${ACTIVE_MODEL}" ] && echo "No active_model in ${AI_CONFIG}; refusing to start" && exit 1 +# Defense in depth: the model must be a bare file name inside the models directory. +case "${ACTIVE_MODEL}" in + */*|*..*) + echo "Ignoring unsafe active_model '${ACTIVE_MODEL}'" >&2 + exit 1 + ;; +esac +MODEL="${MEDIA_DIRECTORY}/ai/models/${ACTIVE_MODEL}" +[ ! -f "${MODEL}" ] && echo "Model file does not exist: ${MODEL}" && exit 1 + +CONTEXT_SIZE="$(read_ai_config context_size 8192)" +case "${CONTEXT_SIZE}" in + ''|*[!0-9]*) CONTEXT_SIZE=8192 ;; +esac + +# --jinja enables tool-call chat templates; mmap is llama.cpp's default so cold RAM cost is low. +# Localhost only on native installs -- never exposed through Caddy. +exec llama-server \ + --host "${LLAMA_HOST}" \ + --port "${LLAMA_PORT}" \ + --jinja \ + -c "${CONTEXT_SIZE}" \ + -m "${MODEL}" diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh index 382e4a80..cbfde41e 100755 --- a/scripts/upgrade.sh +++ b/scripts/upgrade.sh @@ -258,6 +258,10 @@ rm -f /home/wrolpi/.pgpass # Migrate from old Leaflet/renderd/Apache2 map stack to MapLibre GL JS + PMTiles. /opt/wrolpi/scripts/install_protomaps.sh || echo "Protomaps migration failed." +# Install llama.cpp for the local AI assistant (version-guarded no-op once installed; +# the first build takes several minutes on a Raspberry Pi). +/opt/wrolpi/wrolpi/scripts/install_llama_cpp.sh || echo "llama.cpp install failed, continuing..." + # Download map fonts blob if missing. MAP_FONTS_BLOB=/opt/wrolpi-blobs/map-fonts.tar.gz if [[ ! -f ${MAP_FONTS_BLOB} || ! -s ${MAP_FONTS_BLOB} ]]; then diff --git a/wrolpi/contexts.py b/wrolpi/contexts.py index e6c59f69..644bf6a1 100644 --- a/wrolpi/contexts.py +++ b/wrolpi/contexts.py @@ -58,6 +58,9 @@ def attach_shared_contexts(app: Sanic): app.shared_ctx.download_manager_disabled.set() app.shared_ctx.download_manager_stopped.set() + # Monotonic-ish timestamp of the last chat/LLM request; the AI idle-unload worker reads it. + app.shared_ctx.ai_llm_last_request = multiprocessing.Value(ctypes.c_double, 0.0) + app.shared_ctx.single_tasks_started = multiprocessing.Event() app.shared_ctx.flags_initialized = multiprocessing.Event() diff --git a/wrolpi/scripts/install_llama_cpp.sh b/wrolpi/scripts/install_llama_cpp.sh new file mode 100755 index 00000000..c476e3a1 --- /dev/null +++ b/wrolpi/scripts/install_llama_cpp.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Install llama.cpp's llama-server, WROLPi's local AI inference runtime. +# +# This is the single source of truth, called by every place that provisions a +# WROLPi so a version bump only has to be made here: +# - scripts/upgrade.sh (on-device upgrade) +# - pi-gen/stage2/04-wrolpi/03-run-chroot.sh (Raspberry Pi image build) +# - debian-live-config/config/hooks/normal/9999-wrolpi.hook.chroot (Debian Live ISO build) +# - docker/llm/Dockerfile (Docker llm container) +# +# Built from the SHA-pinned source tarball (llama.cpp ships no generic Linux +# binaries for ARM). The build takes several minutes on a Raspberry Pi; the +# version marker makes re-runs no-ops until the pinned version changes. +# Run as root: installs into /usr/local/bin and apt-installs build dependencies. +set -e + +LLAMA_CPP_VERSION="v0.3.0" +# SHA256 of the source tarball -- arch-independent, one value covers every build. +LLAMA_CPP_SHA256="d94c02d86db22d68692f6bb5b3854763d5091e52142868dc7251995517c666d1" + +# llama-server's version output tracks build numbers, not release tags, so the +# installed release is recorded in a marker file. +MARKER_DIR=/usr/local/share/wrolpi +MARKER="${MARKER_DIR}/llama_cpp_version" + +install_llama_cpp() { + if [ -f "${MARKER}" ] && [ "$(cat "${MARKER}")" = "${LLAMA_CPP_VERSION}" ] \ + && command -v llama-server &>/dev/null; then + echo "llama.cpp ${LLAMA_CPP_VERSION} already installed" + return 0 + fi + + echo "Building llama.cpp ${LLAMA_CPP_VERSION}..." + apt-get install -y build-essential cmake + + local build_dir + build_dir=$(mktemp -d) + curl -fsSL "https://github.com/ggml-org/llama.cpp/archive/refs/tags/${LLAMA_CPP_VERSION}.tar.gz" \ + -o /tmp/llama_cpp.tar.gz + echo "${LLAMA_CPP_SHA256} /tmp/llama_cpp.tar.gz" | sha256sum -c - + tar -xz -C "${build_dir}" --strip-components=1 -f /tmp/llama_cpp.tar.gz + rm -f /tmp/llama_cpp.tar.gz + + # Static-ish single binary; GGML_NATIVE=OFF so an image built in a chroot/QEMU + # does not bake in the build host's CPU features. + (cd "${build_dir}" && + cmake -B build \ + -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_SHARED_LIBS=OFF \ + -DGGML_NATIVE=OFF \ + -DLLAMA_CURL=OFF \ + -DLLAMA_BUILD_TESTS=OFF \ + -DLLAMA_BUILD_EXAMPLES=OFF \ + -DLLAMA_BUILD_SERVER=ON && + cmake --build build --target llama-server -j"$(nproc)") + install -m 0755 "${build_dir}/build/bin/llama-server" /usr/local/bin/llama-server + rm -rf "${build_dir}" + + mkdir -p "${MARKER_DIR}" + echo "${LLAMA_CPP_VERSION}" > "${MARKER}" + llama-server --version || true + echo "llama.cpp ${LLAMA_CPP_VERSION} installed" +} + +install_llama_cpp diff --git a/wrolpi/scripts/read_config_value.sh b/wrolpi/scripts/read_config_value.sh index c9f55f7f..e57f0637 100755 --- a/wrolpi/scripts/read_config_value.sh +++ b/wrolpi/scripts/read_config_value.sh @@ -20,7 +20,8 @@ KEY="${1:?Usage: read_config_value.sh KEY [DEFAULT]}" DEFAULT="${2:-}" MEDIA_DIRECTORY="${MEDIA_DIRECTORY:-/media/wrolpi}" -CONFIG="${MEDIA_DIRECTORY}/config/wrolpi.yaml" +# Another top-level config file (e.g. ai.yaml) may be selected with WROLPI_CONFIG_FILE. +CONFIG="${WROLPI_CONFIG_FILE:-${MEDIA_DIRECTORY}/config/wrolpi.yaml}" value="" if [ -f "${CONFIG}" ]; then From f86058ee8a201ec7078ffd63b3ae09a7cd296c73 Mon Sep 17 00:00:00 2001 From: Roland Date: Tue, 25 Aug 2026 21:47:50 -0600 Subject: [PATCH 007/181] =?UTF-8?q?ai:=20the=20/api/chat=20agent=20loop=20?= =?UTF-8?q?=E2=80=94=20SSE=20streaming,=20modes,=20tool=20execution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool definitions are generated in-process from the /api/ai blueprint's own OpenAPI operations (operationId = tool name, description = tool description, dataclass bodies + declared parameters = one flat JSON schema) — the spec IS the catalog, nothing to drift. Manage endpoints never become tools. A mode is a server-enforced system prompt + tool allowlist (Help / Research / System per ai.md); the executor can only reach allowlisted /api/ai endpoints, path parameters are encoded and traversal-rejected so a crafted argument cannot escape an endpoint. POST /api/chat streams SSE (status/token/tool_call/tool_result/done/ error), starts llama-server on demand, validates tool args server-side with instructive errors the model can retry on, caps tool results with a paging hint, and bounds the loop by ai.yaml max_tool_calls plus a hard timeout. Caddy passes the stream unbuffered (flush_interval -1) in both deployments. Co-Authored-By: Claude Fable 5 --- docker/web/Caddyfile | 7 + etc/raspberrypios/Caddyfile | 7 + modules/ai/agent.py | 236 +++++++++++++++++++++++++++++++ modules/ai/api.py | 10 ++ modules/ai/chat.py | 200 ++++++++++++++++++++++++++ modules/ai/modes.py | 81 +++++++++++ modules/ai/schema.py | 12 +- modules/ai/test/test_agent.py | 135 ++++++++++++++++++ modules/ai/test/test_chat_api.py | 188 ++++++++++++++++++++++++ wrolpi/root_api.py | 2 + 10 files changed, 872 insertions(+), 6 deletions(-) create mode 100644 modules/ai/agent.py create mode 100644 modules/ai/chat.py create mode 100644 modules/ai/modes.py create mode 100644 modules/ai/test/test_agent.py create mode 100644 modules/ai/test/test_chat_api.py diff --git a/docker/web/Caddyfile b/docker/web/Caddyfile index 1d745032..b775e57a 100644 --- a/docker/web/Caddyfile +++ b/docker/web/Caddyfile @@ -51,6 +51,13 @@ } } + # AI chat streams Server-Sent Events; never buffer them. + handle /api/chat* { + reverse_proxy api:8081 { + flush_interval -1 + } + } + # Main API. handle /api/* { reverse_proxy api:8081 diff --git a/etc/raspberrypios/Caddyfile b/etc/raspberrypios/Caddyfile index 5a6f2484..e0a2a2fe 100644 --- a/etc/raspberrypios/Caddyfile +++ b/etc/raspberrypios/Caddyfile @@ -51,6 +51,13 @@ } } + # AI chat streams Server-Sent Events; never buffer them. + handle /api/chat* { + reverse_proxy 0.0.0.0:8081 { + flush_interval -1 + } + } + # Main API. handle /api/* { reverse_proxy 0.0.0.0:8081 diff --git a/modules/ai/agent.py b/modules/ai/agent.py new file mode 100644 index 00000000..bb3f379c --- /dev/null +++ b/modules/ai/agent.py @@ -0,0 +1,236 @@ +"""Tool definitions and tool execution for the agent loop. + +The /api/ai blueprint IS the tool catalog: tool definitions are generated from the blueprint's +own OpenAPI operations (in-process — the spec endpoint is not proxied by Caddy), so there is no +separate registry to drift. operationIds are the tool names; @openapi.description strings are +the tool descriptions; @validate dataclasses and @openapi.parameter declarations become the +JSON-schema parameters.""" +import dataclasses +import json +from typing import Dict, List, Optional, Tuple +from urllib.parse import quote, urlencode + +from sanic_ext.extensions.openapi.builders import OperationStore, SpecificationBuilder +from sanic_ext.utils.route import get_all_routes + +from wrolpi.api_utils import api_app +from wrolpi.common import aiohttp_get, aiohttp_post, logger +from wrolpi.vars import API_PORT + +from .lib import PAGE_SIZE + +logger = logger.getChild(__name__) + +AI_PATH_PREFIX = '/api/ai/' +# Manage endpoints are for the Manage tab, never the model. +EXCLUDED_PREFIX = '/api/ai/manage' + +# Tool results are capped so one result cannot blow a small context; content endpoints page. +TOOL_RESULT_MAX_CHARS = PAGE_SIZE + 1_000 + +_TYPE_MAP = {'integer': int, 'number': float, 'boolean': bool, 'string': str} + + +@dataclasses.dataclass +class ToolDefinition: + name: str + description: str + method: str # 'get' or 'post' + path: str # e.g. '/api/ai/videos/{file_group_id}/captions' + path_params: Dict[str, dict] # name -> JSON schema + query_params: Dict[str, dict] + body_params: Dict[str, dict] + + def parameters_schema(self) -> dict: + """One flat JSON-schema object; the executor routes each arg to its place.""" + properties = {**self.body_params, **self.query_params, **self.path_params} + required = sorted(self.path_params) + schema = dict(type='object', properties=properties) + if required: + schema['required'] = required + return schema + + def as_openai_tool(self) -> dict: + return dict(type='function', function=dict( + name=self.name, + description=self.description, + parameters=self.parameters_schema(), + )) + + +def _build_ai_spec() -> dict: + """Populate and serialize the OpenAPI spec for the AI blueprint. + + Replicates sanic-ext's build_spec startup hook (sanic_ext/extensions/openapi/blueprint.py) for + just our routes, so tool definitions work identically in production and under pytest.""" + spec = SpecificationBuilder() + store = OperationStore() + for uri, route_name, route_parameters, method_handlers, host in get_all_routes(api_app, '/docs'): + if not uri.startswith(AI_PATH_PREFIX): + continue + for method, handler in method_handlers: + if method in ('OPTIONS', 'HEAD', 'TRACE'): + continue + if handler not in store and (func := getattr(handler, '__func__', None)) and func in store: + handler = func + operation = store[handler] + if operation._exclude: + continue + operation._default['operationId'] = f'{method.lower()}~{route_name}' + for parameter in route_parameters: + if not any(p.fields['name'] == parameter.name for p in operation.parameters): + operation.parameter(parameter.name, parameter.cast, 'path') + operation._app = api_app + spec.operation(uri, method, operation) + return spec.build(api_app).serialize() + + +def _resolve_ref(spec: dict, schema: Optional[dict]) -> dict: + if not schema: + return {} + if ref := schema.get('$ref'): + name = ref.rsplit('/', 1)[-1] + return (spec.get('components', {}).get('schemas', {}) or {}).get(name, {}) + return schema + + +def _clean_property(schema: dict) -> dict: + """A lean JSON-schema property for the model: type (+items), nothing else.""" + schema = schema or {} + cleaned = {} + if type_ := schema.get('type'): + cleaned['type'] = type_ + if items := schema.get('items'): + cleaned['items'] = _clean_property(items) + return cleaned or {'type': 'string'} + + +_TOOL_DEFINITIONS: Optional[Dict[str, ToolDefinition]] = None + + +def get_ai_tool_definitions(refresh: bool = False) -> Dict[str, ToolDefinition]: + """All tools the agent loop can ever execute, keyed by name. Built lazily, cached. + + This map is also the hard allowlist: the executor can only reach /api/ai endpoints that + appear here, and manage endpoints never do.""" + global _TOOL_DEFINITIONS + if _TOOL_DEFINITIONS is not None and not refresh: + return _TOOL_DEFINITIONS + + spec = _build_ai_spec() + tools = {} + for path, operations in (spec.get('paths') or {}).items(): + if not path.startswith(AI_PATH_PREFIX) or path.startswith(EXCLUDED_PREFIX): + continue + for method, operation in operations.items(): + if method not in ('get', 'post'): + continue + name = operation.get('operationId') + description = operation.get('description') or operation.get('summary') or '' + if not name or '~' in name: + # Every tool endpoint must declare a deliberate @openapi.operation name. + logger.warning(f'AI endpoint without an operation name is not a tool: {method} {path}') + continue + + path_params, query_params = {}, {} + for parameter in operation.get('parameters') or []: + cleaned = _clean_property(parameter.get('schema')) + if parameter.get('in') == 'path': + path_params[parameter['name']] = cleaned + elif parameter.get('in') == 'query': + query_params[parameter['name']] = cleaned + + body_params = {} + content = (operation.get('requestBody') or {}).get('content') or {} + # sanic-ext serializes dataclass bodies under '*/*'. + body_schema = (content.get('application/json') or content.get('*/*') or {}).get('schema') + body_schema = _resolve_ref(spec, body_schema) + for prop_name, prop_schema in (body_schema.get('properties') or {}).items(): + body_params[prop_name] = _clean_property(_resolve_ref(spec, prop_schema)) + + tools[name] = ToolDefinition( + name=name, description=description, method=method, path=path, + path_params=path_params, query_params=query_params, body_params=body_params, + ) + + _TOOL_DEFINITIONS = tools + return tools + + +def get_mode_tools(mode_tools: tuple) -> List[dict]: + """OpenAI-format tool definitions for one mode's allowlist.""" + definitions = get_ai_tool_definitions() + return [definitions[name].as_openai_tool() for name in mode_tools if name in definitions] + + +async def call_ai_endpoint(method: str, path: str, query: dict, body: Optional[dict]) -> Tuple[int, dict]: + """Execute one blueprint endpoint over localhost. Patched in tests.""" + url = f'http://127.0.0.1:{API_PORT}{path}' + if query: + url = f'{url}?{urlencode(query)}' + if method == 'post': + async with aiohttp_post(url, json_=body or {}, timeout=60) as response: + return response.status, await response.json() + async with aiohttp_get(url, timeout=60) as response: + return response.status, await response.json() + + +def _validate_args(tool: ToolDefinition, args: dict) -> Optional[str]: + """Return a crisp, instructive error string for the model, or None when the args are usable.""" + known = {**tool.path_params, **tool.query_params, **tool.body_params} + if unknown := sorted(set(args) - set(known)): + return f'Unknown arguments {unknown}. {tool.name} accepts: {sorted(known)}.' + if missing := sorted(set(tool.path_params) - set(args)): + return f'Missing required arguments {missing} for {tool.name}.' + for name, value in args.items(): + expected = _TYPE_MAP.get(known[name].get('type')) + if expected in (int, float) and value is not None and not isinstance(value, (int, float)) \ + and not (isinstance(value, str) and value.lstrip('-').isdigit()): + return f'Argument {name} must be a {known[name]["type"]}, got {value!r}.' + # Path parameters land in the request URL; refuse anything that could escape the + # endpoint's own path (the allowlist must hold even against a crafted value). + if name in tool.path_params and isinstance(value, str): + segments = value.split('/') + if value.startswith('/') or '' in segments or '.' in segments or '..' in segments: + return f'Argument {name} must not contain path traversal.' + return None + + +async def execute_tool(mode_tools: tuple, name: str, args: dict) -> Tuple[bool, str]: + """Validate and execute one tool call. Returns (success, result text for the model). + + Every failure is a plain-language error string — small models retry well on crisp errors.""" + definitions = get_ai_tool_definitions() + if name not in definitions or name not in mode_tools: + available = sorted(set(mode_tools) & set(definitions)) + return False, f'Unknown tool {name!r}. Available tools: {available}.' + tool = definitions[name] + + args = args or {} + if error := _validate_args(tool, args): + return False, error + + path = tool.path + for param in tool.path_params: + # Encode everything but '/' (help-doc slugs contain it); traversal was rejected above. + path = path.replace(f'{{{param}}}', quote(str(args[param]), safe='/')) + # The substituted path must still be inside the endpoint the tool declares. + static_prefix = tool.path.split('{', 1)[0] + if not path.startswith(static_prefix) or '..' in path.split('/'): + return False, f'Arguments produced an invalid path for {tool.name}.' + query = {k: v for k, v in args.items() if k in tool.query_params and v is not None} + body = {k: v for k, v in args.items() if k in tool.body_params} if tool.method == 'post' else None + + try: + status, response = await call_ai_endpoint(tool.method, path, query, body) + except Exception as e: + logger.error(f'Tool {name} failed', exc_info=e) + return False, f'Tool {name} failed to execute; try different arguments or another tool.' + + text = json.dumps(response, default=str) + if len(text) > TOOL_RESULT_MAX_CHARS: + text = text[:TOOL_RESULT_MAX_CHARS] + '… (result truncated; use limit/offset to get less at a time)' + if status >= 400: + message = response.get('message') or response.get('error') or f'HTTP {status}' + return False, f'{tool.name} returned an error: {message}' + return True, text diff --git a/modules/ai/api.py b/modules/ai/api.py index 4a72ef94..7413d65e 100644 --- a/modules/ai/api.py +++ b/modules/ai/api.py @@ -117,6 +117,7 @@ async def get_video(_: Request, file_group_id: int): ' continue reading.', ) @openapi.operation('get_video_captions') +@openapi.parameter('offset', int, 'query') @openapi.response(HTTPStatus.OK, schema.AIPagedTextResponse) @openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) async def get_video_captions(request: Request, file_group_id: int): @@ -156,6 +157,7 @@ async def search_archives(_: Request, body: schema.AIArchiveSearchRequest): ' again with offset=next_offset to continue reading.', ) @openapi.operation('get_archive_text') +@openapi.parameter('offset', int, 'query') @openapi.response(HTTPStatus.OK, schema.AIPagedTextResponse) @openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) async def get_archive_text(request: Request, file_group_id: int): @@ -268,6 +270,8 @@ async def search_zims(request: Request, body: schema.AIZimSearchRequest): ' reading.', ) @openapi.operation('get_zim_entry') +@openapi.parameter('path', str, 'query') +@openapi.parameter('offset', int, 'query') @openapi.response(HTTPStatus.OK, schema.AIPagedTextResponse) @openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) async def get_zim_entry(request: Request, zim_id: int): @@ -289,6 +293,7 @@ async def get_zim_entry(request: Request, zim_id: int): ' name with search_archives.', ) @openapi.operation('list_collections') +@openapi.parameter('kind', str, 'query') async def list_collections(request: Request): kind = request.args.get('kind') collections = search_collections(request.ctx.session, kind=kind) @@ -366,6 +371,7 @@ async def search_help(_: Request, body: schema.AIHelpSearchRequest): ' pages are paged: request again with offset=next_offset to continue reading.', ) @openapi.operation('get_help_doc') +@openapi.parameter('offset', int, 'query') @openapi.response(HTTPStatus.OK, schema.AIPagedTextResponse) @openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) async def get_help_doc(request: Request, slug: str): @@ -466,6 +472,8 @@ async def ai_list_services(_: Request): ' Pass lines to read more history (at most 1000).', ) @openapi.operation('get_service_logs') +@openapi.parameter('lines', int, 'query') +@openapi.parameter('since', str, 'query') @openapi.response(HTTPStatus.BAD_GATEWAY, JSONErrorResponse) async def ai_get_service_logs(request: Request, name: str): try: @@ -584,6 +592,8 @@ async def manage_settings(_: Request, body: schema.AIManageSettingsRequest): ' request again with offset=next_offset to continue reading.', ) @openapi.operation('read_file') +@openapi.parameter('path', str, 'query') +@openapi.parameter('offset', int, 'query') @openapi.response(HTTPStatus.OK, schema.AIPagedTextResponse) @openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) async def read_file(request: Request): diff --git a/modules/ai/chat.py b/modules/ai/chat.py new file mode 100644 index 00000000..43166e08 --- /dev/null +++ b/modules/ai/chat.py @@ -0,0 +1,200 @@ +"""POST /api/chat — the agent loop, streamed over SSE. + +Events (each `event: ` with a JSON `data:` line): + status {message} e.g. "Loading the model…" + token {content} one streamed answer fragment + tool_call {tool, args} the model is using a tool + tool_result {tool, success} the tool finished + done {content} the complete final answer + error {message} + +The loop: build the mode's system prompt and tool definitions -> stream from llama-server -> +execute tool calls against /api/ai only (the mode allowlist is enforced server-side) -> repeat, +bounded by ai.yaml's max_tool_calls -> stream the final answer. + +A streaming response outlives the request middleware, so nothing here may use +request.ctx.session; tools execute through the blueprint, which manages its own sessions.""" +import dataclasses +import json +import time +from http import HTTPStatus +from typing import AsyncGenerator, List, Optional + +from sanic import Blueprint, Request +from sanic_ext import validate +from sanic_ext.extensions.openapi import openapi + +from wrolpi.common import aiohttp_post, logger +from wrolpi.errors import APIError, ValidationError + +from . import agent, service +from .config import get_ai_config +from .modes import MODE_SUGGESTIONS, MODES + +chat_bp = Blueprint('AIChat', url_prefix='/api/chat') + +logger = logger.getChild(__name__) + +# The whole conversation turn (all tool calls and answers) must finish within this. +CHAT_TIMEOUT_SECONDS = 600 +FALLBACK_MESSAGE = "I couldn't complete that request. Try rephrasing, or a more specific question." + + +class AIDisabled(APIError): + code = 'AI_DISABLED' + summary = 'AI is not enabled. Enable it and select a model on the AI page.' + status_code = HTTPStatus.CONFLICT + + +@dataclasses.dataclass +class ChatRequest: + mode: str = '' + messages: List[dict] = dataclasses.field(default_factory=list) + model: Optional[str] = None # reserved; llama-server runs the single active model + + +@chat_bp.get('/modes') +@openapi.description('The available chat modes with their suggestion chips.') +async def get_modes(_: Request): + from wrolpi.api_utils import json_response + modes = [dict(name=name, suggestions=MODE_SUGGESTIONS.get(name, [])) for name in MODES] + return json_response(dict(modes=modes)) + + +async def llama_chat_stream(messages: List[dict], tools: List[dict]) -> AsyncGenerator[dict, None]: + """Stream one /v1/chat/completions call from llama-server. + + Yields {'type': 'token', 'content': str} for answer fragments, then exactly one + {'type': 'stop', 'tool_calls': [...]} where tool_calls is empty for a final answer. + Patched in tests.""" + body = dict(messages=messages, stream=True) + if tools: + body['tools'] = tools + + tool_calls = {} # index -> {id, name, arguments} + async with aiohttp_post(f'{service.LLAMA_URL}/v1/chat/completions', json_=body, + timeout=CHAT_TIMEOUT_SECONDS) as response: + if response.status != 200: + raise RuntimeError(f'llama-server returned HTTP {response.status}') + async for raw_line in response.content: + line = raw_line.decode('UTF-8', errors='replace').strip() + if not line.startswith('data:'): + continue + data = line[5:].strip() + if data == '[DONE]': + break + try: + chunk = json.loads(data) + except ValueError: + continue + choice = (chunk.get('choices') or [{}])[0] + delta = choice.get('delta') or {} + if content := delta.get('content'): + yield dict(type='token', content=content) + for tool_call in delta.get('tool_calls') or []: + index = tool_call.get('index', 0) + entry = tool_calls.setdefault(index, dict(id=None, name='', arguments='')) + if tool_call.get('id'): + entry['id'] = tool_call['id'] + function = tool_call.get('function') or {} + if function.get('name'): + entry['name'] = function['name'] + if function.get('arguments'): + entry['arguments'] += function['arguments'] + + yield dict(type='stop', tool_calls=[tool_calls[i] for i in sorted(tool_calls)]) + + +async def _send_event(response, event: str, data: dict): + await response.send(f'event: {event}\ndata: {json.dumps(data, default=str)}\n\n') + + +@chat_bp.post('/') +@openapi.definition( + summary='Chat with the local AI assistant (SSE stream)', + body=ChatRequest, +) +@validate(ChatRequest) +async def post_chat(request: Request, body: ChatRequest): + if body.mode not in MODES: + raise ValidationError(f'Unknown mode: {body.mode!r}. Modes: {sorted(MODES)}') + if not body.messages: + raise ValidationError('messages must not be empty') + config = get_ai_config() + if not config.enabled or not config.active_model: + raise AIDisabled() + + mode = MODES[body.mode] + tools = agent.get_mode_tools(mode['tools']) + max_tool_calls = config.max_tool_calls + deadline = time.time() + CHAT_TIMEOUT_SECONDS + + response = await request.respond(content_type='text/event-stream', headers={ + 'Cache-Control': 'no-cache', + # Ask proxies not to buffer the stream. + 'X-Accel-Buffering': 'no', + }) + + try: + if not await service.llama_healthy(): + await _send_event(response, 'status', dict(message='Loading the model…')) + if not await service.ensure_llama_running(): + await _send_event(response, 'error', dict(message='The AI service could not be started.')) + return + + messages = [dict(role='system', content=mode['system_prompt'])] + messages += [dict(role=m.get('role', 'user'), content=m.get('content', '')) for m in body.messages] + + final_answer = [] + for _ in range(max_tool_calls + 1): + if time.time() > deadline: + await _send_event(response, 'error', dict(message=FALLBACK_MESSAGE)) + return + + service.record_llm_activity() + tool_calls = [] + final_answer = [] + async for event in llama_chat_stream(messages, tools): + if event['type'] == 'token': + final_answer.append(event['content']) + await _send_event(response, 'token', dict(content=event['content'])) + elif event['type'] == 'stop': + tool_calls = event['tool_calls'] + + if not tool_calls: + # A final answer. + content = ''.join(final_answer).strip() + await _send_event(response, 'done', dict(content=content or FALLBACK_MESSAGE)) + return + + # Execute the tool calls, then loop for the model's next step. + messages.append(dict(role='assistant', content=''.join(final_answer) or None, tool_calls=[ + dict(id=i['id'] or f'call_{n}', type='function', + function=dict(name=i['name'], arguments=i['arguments'])) + for n, i in enumerate(tool_calls) + ])) + for n, tool_call in enumerate(tool_calls): + name = tool_call['name'] + try: + args = json.loads(tool_call['arguments']) if tool_call['arguments'] else {} + except ValueError: + args = None + await _send_event(response, 'tool_call', dict(tool=name, args=args)) + if args is None: + success, result = False, 'The tool arguments were not valid JSON. Try again.' + else: + success, result = await agent.execute_tool(mode['tools'], name, args) + await _send_event(response, 'tool_result', dict(tool=name, success=success)) + messages.append(dict(role='tool', tool_call_id=tool_call['id'] or f'call_{n}', + content=result)) + + # Tool-call budget exhausted without a final answer. + await _send_event(response, 'error', dict(message=FALLBACK_MESSAGE)) + except Exception as e: + logger.error('Chat failed', exc_info=e) + try: + await _send_event(response, 'error', dict(message='The AI failed to answer. Check the logs.')) + except Exception: + pass + finally: + await response.eof() diff --git a/modules/ai/modes.py b/modules/ai/modes.py new file mode 100644 index 00000000..c4aa05f6 --- /dev/null +++ b/modules/ai/modes.py @@ -0,0 +1,81 @@ +"""Chat modes: a mode is a system prompt plus a tool allowlist, enforced server-side in the agent +loop — the model cannot opt out. Tool names are the /api/ai blueprint's operationIds.""" + +COMMON_PROMPT = ( + 'You are the WROLPi assistant, running locally on the user\'s own offline WROLPi server.' + ' WROLPi is an off-grid digital library: videos, archived web pages, ebooks, Zim encyclopedias,' + ' maps, and inventories. You have no internet access.' + ' Answer briefly and plainly. Use the provided tools to find real information before answering;' + ' never invent library content or system state.' +) + +MODES = { + 'help': dict( + system_prompt=( + f'{COMMON_PROMPT}' + ' Your job is to teach the user how to use WROLPi.' + ' Search the help documentation with search_help, read pages with get_help_doc, and answer' + ' from what the docs actually say. Always include the link of every help page you used.' + ' If the docs do not cover the question, say so.' + ), + tools=( + 'search_help', + 'get_help_doc', + 'get_system_status', + ), + ), + 'research': dict( + system_prompt=( + f'{COMMON_PROMPT}' + ' Your job is to be the librarian: find and summarize content from the user\'s library.' + ' Search first, then read captions, page text, or entries when the user wants detail.' + ' ALWAYS include the WROLPi link for every item you mention, exactly as the tools return it.' + ' Never give external URLs. When a search returns a large total, narrow the query instead' + ' of paging. If nothing matches, say so.' + ), + tools=( + 'search_all', + 'search_videos', + 'get_video', + 'get_video_captions', + 'search_archives', + 'get_archive_text', + 'search_docs', + 'get_doc', + 'list_zims', + 'search_zims', + 'get_zim_entry', + 'list_collections', + 'list_inventories', + 'get_inventory', + 'read_file', + ), + ), + 'system': dict( + system_prompt=( + f'{COMMON_PROMPT}' + ' Your job is to troubleshoot the user\'s WROLPi.' + ' Start every diagnosis with get_system_status; read a failing service\'s logs with' + ' get_service_logs; check drives with list_disks.' + ' You CANNOT execute commands or change anything — you can only read.' + ' Only suggest commands that appear in the help documentation: find them with search_help,' + ' quote them exactly, and include the help page link.' + ), + tools=( + 'get_system_status', + 'list_services', + 'get_service_logs', + 'list_disks', + 'search_help', + 'get_help_doc', + ), + ), +} + +# Suggestion chips shown by the Chat tab; small models do much better when the opening message +# matches what the mode's prompt was tuned for. +MODE_SUGGESTIONS = { + 'help': ['How do I download videos?', 'How do I add my own files?', 'What are Tags?'], + 'research': ['Find videos about canning', 'What do I have about gardening?', 'Summarize my newest archives'], + 'system': ['Why is my drive full?', 'Is everything running?', 'Why do downloads fail?'], +} diff --git a/modules/ai/schema.py b/modules/ai/schema.py index 7d7390db..6f6485d1 100644 --- a/modules/ai/schema.py +++ b/modules/ai/schema.py @@ -1,5 +1,5 @@ import dataclasses -from typing import Optional +from typing import List, Optional @dataclasses.dataclass @@ -7,8 +7,8 @@ class AISearchRequest: search_str: Optional[str] = None limit: int = 5 offset: int = 0 - mimetypes: Optional[list] = None - tag_names: Optional[list] = None + mimetypes: Optional[List[str]] = None + tag_names: Optional[List[str]] = None @dataclasses.dataclass @@ -17,7 +17,7 @@ class AIVideoSearchRequest: limit: int = 5 offset: int = 0 channel_id: Optional[int] = None - tag_names: Optional[list] = None + tag_names: Optional[List[str]] = None @dataclasses.dataclass @@ -26,7 +26,7 @@ class AIArchiveSearchRequest: limit: int = 5 offset: int = 0 domain: Optional[str] = None - tag_names: Optional[list] = None + tag_names: Optional[List[str]] = None @dataclasses.dataclass @@ -38,7 +38,7 @@ class AIDocSearchRequest: mimetype: Optional[str] = None limit: int = 5 offset: int = 0 - tag_names: Optional[list] = None + tag_names: Optional[List[str]] = None @dataclasses.dataclass diff --git a/modules/ai/test/test_agent.py b/modules/ai/test/test_agent.py new file mode 100644 index 00000000..f63e1526 --- /dev/null +++ b/modules/ai/test/test_agent.py @@ -0,0 +1,135 @@ +"""Tests for tool-definition generation and tool execution.""" +import json +from unittest import mock +from urllib.parse import urlencode + +import pytest + +from modules.ai import agent +from modules.ai.modes import MODES + + +@pytest.fixture +async def tool_definitions(async_client): + # Any request finalizes the router, which the spec builder requires. + await async_client.get('/api/ai/zims') + return agent.get_ai_tool_definitions(refresh=True) + + +@pytest.mark.asyncio +async def test_tool_definitions_from_spec(tool_definitions): + """Tool definitions are generated from the blueprint's own OpenAPI operations.""" + tools = tool_definitions + # Every tool any mode allowlists must exist — this guards allowlists against drift. + for mode_name, mode in MODES.items(): + for tool_name in mode['tools']: + assert tool_name in tools, f'Mode {mode_name} allowlists missing tool {tool_name}' + + # Every tool has a model-facing description. + for name, tool in tools.items(): + assert tool.description, f'Tool {name} has no description' + + # Manage endpoints are never tools. + assert not any(t.path.startswith('/api/ai/manage') for t in tools.values()) + assert 'manage_catalog' not in tools and 'manage_settings' not in tools + # The chat endpoint itself is not a tool. + assert not any(t.path.startswith('/api/chat') for t in tools.values()) + + # Body, query, and path parameters all appear in the flat schema. + schema = tools['search_videos'].parameters_schema() + assert schema['properties']['search_str'] == {'type': 'string'} + assert schema['properties']['tag_names'] == {'type': 'array', 'items': {'type': 'string'}} + schema = tools['get_video_captions'].parameters_schema() + assert schema['required'] == ['file_group_id'] + assert schema['properties']['offset'] == {'type': 'integer'} + + # OpenAI tool format. + openai_tool = tools['search_all'].as_openai_tool() + assert openai_tool['type'] == 'function' + assert openai_tool['function']['name'] == 'search_all' + + +def _endpoint_shim(async_client): + async def shim(method, path, query, body): + url = f'{path}?{urlencode(query)}' if query else path + if method == 'post': + request, response = await async_client.post(url, content=json.dumps(body or {})) + else: + request, response = await async_client.get(url) + return response.status_code, response.json + + return mock.patch('modules.ai.agent.call_ai_endpoint', side_effect=shim) + + +@pytest.mark.asyncio +async def test_execute_tool(async_client, tool_definitions, video_factory, test_session): + """Tools execute against the blueprint; every failure is an instructive error string.""" + video_factory(title='pressure canning') + test_session.commit() + research_tools = MODES['research']['tools'] + + with _endpoint_shim(async_client): + # A real search through the blueprint. + success, result = await agent.execute_tool(research_tools, 'search_videos', + dict(search_str='canning')) + assert success is True + result = json.loads(result) + assert result['total'] == 1 + + # Unknown tool. + success, error = await agent.execute_tool(research_tools, 'delete_everything', {}) + assert success is False and 'Unknown tool' in error + + # Mode allowlist: research mode cannot read service logs even though the tool exists. + success, error = await agent.execute_tool(research_tools, 'get_service_logs', + dict(name='wrolpi-api')) + assert success is False and 'Unknown tool' in error + + # Unknown argument names the accepted ones. + success, error = await agent.execute_tool(research_tools, 'search_videos', dict(query='x')) + assert success is False and 'accepts' in error and 'search_str' in error + + # Missing required path parameter. + success, error = await agent.execute_tool(research_tools, 'get_video_captions', {}) + assert success is False and 'Missing required' in error + + # Wrong type. + success, error = await agent.execute_tool(research_tools, 'get_video_captions', + dict(file_group_id='banana')) + assert success is False and 'must be a integer' in error + + # An endpoint error comes back as a message, not an exception. + success, error = await agent.execute_tool(research_tools, 'get_video_captions', + dict(file_group_id=123456)) + assert success is False and 'error' in error.lower() + + +@pytest.mark.asyncio +async def test_execute_tool_path_traversal(async_client, tool_definitions): + """A crafted path parameter cannot escape the tool's endpoint (allowlist bypass).""" + help_tools = MODES['help']['tools'] + with _endpoint_shim(async_client): + for slug in ('../../manage/catalog', '../manage/settings', '/etc/passwd', + 'a/../../files/refresh', 'a//b', 'a/./b'): + success, error = await agent.execute_tool(help_tools, 'get_help_doc', dict(slug=slug)) + assert success is False, f'traversal not refused: {slug}' + assert 'traversal' in error or 'invalid path' in error + + # Legitimate nested slugs keep working (404 from the endpoint is fine, no traversal error). + success, error = await agent.execute_tool(help_tools, 'get_help_doc', dict(slug='system/logs')) + assert 'traversal' not in error and 'invalid path' not in (error or '') + + +@pytest.mark.asyncio +async def test_execute_tool_result_truncated(async_client, tool_definitions, archive_factory, test_session): + """A huge tool result is capped with a paging hint.""" + archive_factory(domain='example.com', title='big page', contents='word ' * 5_000) + test_session.commit() + from modules.archive.models import Archive + archive = test_session.query(Archive).one() + + with _endpoint_shim(async_client): + success, result = await agent.execute_tool(MODES['research']['tools'], 'get_archive_text', + dict(file_group_id=archive.file_group_id)) + assert success is True + assert len(result) <= agent.TOOL_RESULT_MAX_CHARS + 100 diff --git a/modules/ai/test/test_chat_api.py b/modules/ai/test/test_chat_api.py new file mode 100644 index 00000000..cdb8b13f --- /dev/null +++ b/modules/ai/test/test_chat_api.py @@ -0,0 +1,188 @@ +"""End-to-end tests for /api/chat: SSE framing, the agent loop, and its guardrails. + +llama-server is scripted (llama_chat_stream is patched); tool calls execute against the real +/api/ai blueprint through the test client.""" +import json +from http import HTTPStatus +from unittest import mock +from urllib.parse import urlencode + +import pytest + +from modules.ai.config import get_ai_config + + +@pytest.fixture +async def ai_enabled(async_client, test_ai_config, test_directory): + """AI enabled with a (fake) downloaded model, llama-server 'healthy'.""" + models_dir = test_directory / 'ai/models' + models_dir.mkdir(parents=True) + (models_dir / 'test.gguf').write_bytes(b'GGUF') + config = get_ai_config() + config.active_model = 'test.gguf' + config.enabled = True + + with mock.patch('modules.ai.service.llama_healthy', return_value=True), \ + mock.patch('modules.ai.chat.service.ensure_llama_running', return_value=True): + yield config + + +def _llama_script(*turns): + """Patch llama_chat_stream with scripted turns. + + Each turn is a list of events ({'type': 'token'|'stop', ...}); turn N answers call N.""" + calls = dict(count=0, messages=[]) + + def factory(messages, tools): + calls['messages'].append(messages) + turn = turns[min(calls['count'], len(turns) - 1)] + calls['count'] += 1 + + async def generator(): + for event in turn: + yield event + + return generator() + + return mock.patch('modules.ai.chat.llama_chat_stream', side_effect=factory), calls + + +def _endpoint_shim(async_client): + async def shim(method, path, query, body): + url = f'{path}?{urlencode(query)}' if query else path + if method == 'post': + request, response = await async_client.post(url, content=json.dumps(body or {})) + else: + request, response = await async_client.get(url) + return response.status_code, response.json + + return mock.patch('modules.ai.agent.call_ai_endpoint', side_effect=shim) + + +def _parse_sse(text): + """[(event, data_dict), ...] from an SSE body.""" + events = [] + for block in text.split('\n\n'): + event = data = None + for line in block.splitlines(): + if line.startswith('event:'): + event = line[6:].strip() + elif line.startswith('data:'): + data = json.loads(line[5:].strip()) + if event: + events.append((event, data)) + return events + + +@pytest.mark.asyncio +async def test_chat_tool_call_then_answer(async_client, ai_enabled, video_factory, test_session): + """The loop executes a tool call against the blueprint, then streams the final answer.""" + video_factory(title='pressure canning') + test_session.commit() + + patch, calls = _llama_script( + [dict(type='stop', tool_calls=[ + dict(id='call_1', name='search_videos', arguments='{"search_str": "canning"}')])], + [dict(type='token', content='Found '), dict(type='token', content='one video.'), + dict(type='stop', tool_calls=[])], + ) + body = dict(mode='research', messages=[dict(role='user', content='Find videos about canning')]) + with patch, _endpoint_shim(async_client): + request, response = await async_client.post('/api/chat/', content=json.dumps(body)) + assert response.status_code == HTTPStatus.OK + + events = _parse_sse(response.text) + names = [e for e, _ in events] + assert names == ['tool_call', 'tool_result', 'token', 'token', 'done'] + tool_call = dict(events)['tool_call'] + assert tool_call == dict(tool='search_videos', args=dict(search_str='canning')) + assert dict(events)['tool_result'] == dict(tool='search_videos', success=True) + assert dict(events)['done'] == dict(content='Found one video.') + + # The second llama call saw the system prompt, the user message, and the tool result. + second_call_messages = calls['messages'][1] + assert second_call_messages[0]['role'] == 'system' + assert 'librarian' in second_call_messages[0]['content'] + assert second_call_messages[-1]['role'] == 'tool' + assert 'pressure canning' in second_call_messages[-1]['content'] + + +@pytest.mark.asyncio +async def test_chat_plain_answer(async_client, ai_enabled): + """A toolless answer streams tokens then done.""" + patch, _ = _llama_script( + [dict(type='token', content='Hello!'), dict(type='stop', tool_calls=[])], + ) + body = dict(mode='help', messages=[dict(role='user', content='hi')]) + with patch: + request, response = await async_client.post('/api/chat/', content=json.dumps(body)) + events = _parse_sse(response.text) + assert events == [('token', dict(content='Hello!')), ('done', dict(content='Hello!'))] + + +@pytest.mark.asyncio +async def test_chat_tool_loop_bounded(async_client, ai_enabled): + """A model that never stops calling tools hits max_tool_calls and gets the fallback.""" + get_ai_config().max_tool_calls = 2 + patch, calls = _llama_script( + [dict(type='stop', tool_calls=[dict(id='x', name='search_help', arguments='{"search_str": "a"}')])], + ) + body = dict(mode='help', messages=[dict(role='user', content='loop forever')]) + with patch, _endpoint_shim(async_client): + request, response = await async_client.post('/api/chat/', content=json.dumps(body)) + events = _parse_sse(response.text) + assert events[-1][0] == 'error' + assert calls['count'] == 3 # initial + max_tool_calls + + +@pytest.mark.asyncio +async def test_chat_invalid_tool_args(async_client, ai_enabled): + """Bad tool JSON and disallowed tools come back to the model as errors, not crashes.""" + patch, calls = _llama_script( + [dict(type='stop', tool_calls=[dict(id='x', name='search_help', arguments='{not json')])], + [dict(type='stop', tool_calls=[dict(id='y', name='list_disks', arguments='{}')])], + [dict(type='token', content='Sorry.'), dict(type='stop', tool_calls=[])], + ) + body = dict(mode='help', messages=[dict(role='user', content='hi')]) + with patch, _endpoint_shim(async_client): + request, response = await async_client.post('/api/chat/', content=json.dumps(body)) + events = _parse_sse(response.text) + results = [d for e, d in events if e == 'tool_result'] + assert results[0]['success'] is False # invalid JSON arguments + assert results[1]['success'] is False # list_disks is not a help-mode tool + assert events[-1] == ('done', dict(content='Sorry.')) + # The model saw instructive errors. + tool_messages = [m for m in calls['messages'][-1] if m['role'] == 'tool'] + assert 'not valid JSON' in tool_messages[0]['content'] + assert 'Unknown tool' in tool_messages[1]['content'] + + +@pytest.mark.asyncio +async def test_chat_validation(async_client, ai_enabled): + """Bad requests are refused before the stream starts.""" + request, response = await async_client.post( + '/api/chat/', content=json.dumps(dict(mode='nope', messages=[dict(role='user', content='x')]))) + assert response.status_code == HTTPStatus.BAD_REQUEST + + request, response = await async_client.post( + '/api/chat/', content=json.dumps(dict(mode='help', messages=[]))) + assert response.status_code == HTTPStatus.BAD_REQUEST + + +@pytest.mark.asyncio +async def test_chat_disabled(async_client, test_ai_config): + """Chat refuses when AI is disabled or no model is selected.""" + body = dict(mode='help', messages=[dict(role='user', content='x')]) + request, response = await async_client.post('/api/chat/', content=json.dumps(body)) + assert response.status_code == HTTPStatus.CONFLICT + assert response.json['code'] == 'AI_DISABLED' + + +@pytest.mark.asyncio +async def test_chat_modes_endpoint(async_client): + """The Chat tab can list modes and their suggestion chips.""" + request, response = await async_client.get('/api/chat/modes') + assert response.status_code == HTTPStatus.OK + modes = {m['name']: m for m in response.json['modes']} + assert set(modes) == {'help', 'research', 'system'} + assert modes['research']['suggestions'] diff --git a/wrolpi/root_api.py b/wrolpi/root_api.py index 736bdfe0..5fd2d82b 100644 --- a/wrolpi/root_api.py +++ b/wrolpi/root_api.py @@ -14,6 +14,7 @@ from vininfo import Vin from vininfo.details._base import VinDetails from modules.ai.api import ai_bp +from modules.ai.chat import chat_bp from modules.archive.api import archive_bp from modules.docs.api import docs_bp from modules.flasher.api import flasher_bp @@ -56,6 +57,7 @@ api_bp = Blueprint('RootAPI', url_prefix='/api') # Blueprints order here defines what order they are displayed in OpenAPI Docs. api_app.blueprint(api_bp) api_app.blueprint(ai_bp) +api_app.blueprint(chat_bp) api_app.blueprint(archive_bp) api_app.blueprint(collection_bp) # Unified collection endpoints api_app.blueprint(config_bp) From 0347c2901bfbce71fdf9d77860a665cc44d88187 Mon Sep 17 00:00:00 2001 From: Roland Date: Tue, 25 Aug 2026 21:56:36 -0600 Subject: [PATCH 008/181] =?UTF-8?q?ai:=20the=20AI=20nav=20page=20=E2=80=94?= =?UTF-8?q?=20Manage=20tab=20(model=20catalog,=20settings,=20service)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New AI nav item and /ai route with Chat and Manage tabs (Chat is a placeholder until the SSE chat lands). The Manage tab mirrors the Map/Zim download UX: the model catalog with the RAM-based tier recommendation and a Pi 4 performance hint, downloads through the download manager (progress on the Downloads page), active-model select, enable toggle, idle-unload minutes, and llama-server start/stop through the Controller (compose service name in docker, systemd unit natively). AiModelRow is in the theme-sample gallery. Co-Authored-By: Claude Fable 5 --- app/src/App.js | 2 + app/src/api.js | 30 ++++ app/src/components/Ai.js | 208 ++++++++++++++++++++++++++ app/src/components/Ai.test.js | 112 ++++++++++++++ app/src/components/Nav.js | 1 + app/src/components/ThemeSamplePage.js | 27 ++++ app/src/components/Vars.js | 1 + 7 files changed, 381 insertions(+) create mode 100644 app/src/components/Ai.js create mode 100644 app/src/components/Ai.test.js diff --git a/app/src/App.js b/app/src/App.js index 1e372a53..a9881881 100644 --- a/app/src/App.js +++ b/app/src/App.js @@ -31,6 +31,7 @@ import {ZimRoute} from "./components/Zim"; import {PlaylistsRoute} from "./components/Playlists"; import {DocsRoute} from "./components/Docs"; import {FlasherRoute} from "./components/Flasher"; +import {AiRoute} from "./components/Ai"; import ErrorBoundary from "./components/ErrorBoundary"; import {KeyboardShortcutsProvider} from "./components/KeyboardShortcutsProvider"; @@ -116,6 +117,7 @@ const router = createBrowserRouter(createRoutesFromElements(}/> }/> }/> + }/> )); export default function App() { diff --git a/app/src/api.js b/app/src/api.js index b06ef557..ddb05a2b 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -2438,3 +2438,33 @@ export async function setPlaylistTag(playlistId, tagName, directory) { toast({type: 'error', title: 'Error', description: message, time: 5000}); throw Error(message); } + +// --- AI assistant --- + +export async function getAiCatalog() { + const response = await apiGet(`${API_URI}/ai/manage/catalog`); + if (response.ok) { + return await response.json(); + } + const message = await getErrorMessage(response, 'Failed to fetch AI catalog.'); + toast({type: 'error', title: 'Error', description: message, time: 5000}); +} + +export async function postAiSettings(settings) { + const response = await apiPost(`${API_URI}/ai/manage/settings`, settings); + if (response.ok) { + return await response.json(); + } + const message = await getErrorMessage(response, 'Failed to save AI settings.'); + toast({type: 'error', title: 'Error', description: message, time: 5000}); + throw Error(message); +} + +export async function getChatModes() { + const response = await apiGet(`${API_URI}/chat/modes`); + if (response.ok) { + return (await response.json())['modes']; + } + const message = await getErrorMessage(response, 'Failed to fetch chat modes.'); + toast({type: 'error', title: 'Error', description: message, time: 5000}); +} diff --git a/app/src/components/Ai.js b/app/src/components/Ai.js new file mode 100644 index 00000000..7760b0e9 --- /dev/null +++ b/app/src/components/Ai.js @@ -0,0 +1,208 @@ +import React, {useContext, useEffect, useState} from 'react'; +import {Route, Routes} from 'react-router'; + +import {getAiCatalog, postAiSettings, postDownload} from '../api'; +import {getServiceStatus, startService, stopService} from '../api/controller'; +import {StatusContext} from '../contexts/contexts'; +import {APIButton, humanFileSize, InfoMessage, PageContainer, TabLinks, useTitle, WROLModeMessage} from './Common'; +import {SortableTable} from './SortableTable'; +import {Button, Divider, Group, Header, NumberInput, Select, Table, Text, Toggle, toast} from './ui'; +import {Downloaders} from './Vars'; + +// The Controller's name for llama-server: the compose service in docker, the systemd unit natively. +export const llmServiceName = (dockerized) => dockerized ? 'llm' : 'wrolpi-llm'; + +export function AiModelRow({model, onDownload}) { + const {name, tier, size, description, downloaded, active} = model; + + return + + {name}{active ? ' (active)' : ''} + {description && {description}} + + {tier} + {humanFileSize(size)} + + {downloaded + ? + : onDownload(model)}> + Download + } + + ; +} + +export function ManageAi() { + const [catalog, setCatalog] = useState(null); + const [serviceStatus, setServiceStatus] = useState(null); + const [pendingSave, setPendingSave] = useState(false); + const {status} = useContext(StatusContext); + const serviceName = llmServiceName(status?.dockerized); + + const fetchCatalog = async () => { + try { + const data = await getAiCatalog(); + if (data) { + setCatalog(data); + } + } catch (e) { + console.error(e); + } + }; + + const fetchService = async () => { + try { + setServiceStatus(await getServiceStatus(serviceName)); + } catch (e) { + // The service is unknown until AI is installed; not an error worth toasting. + setServiceStatus(null); + } + }; + + useEffect(() => { + fetchCatalog(); + fetchService(); + const interval = setInterval(() => { + fetchCatalog(); + fetchService(); + }, 30000); + return () => clearInterval(interval); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [serviceName]); + + const handleDownload = async (model) => { + await postDownload({ + urls: [model.url], + downloader: Downloaders.AiModel, + destination: 'ai/models', + }); + toast({ + type: 'success', + title: 'Download started', + description: 'Monitor progress on the Downloads page.', + time: 5000, + }); + }; + + const saveSettings = async (settings) => { + setPendingSave(true); + try { + await postAiSettings(settings); + await fetchCatalog(); + } finally { + setPendingSave(false); + } + }; + + const handleServiceButton = async () => { + if (serviceStatus?.status === 'running') { + await stopService(serviceName); + } else { + await startService(serviceName); + } + await fetchService(); + }; + + if (!catalog) { + return ; + } + + const {models, recommended_tier, total_ram, slow_hardware, disk_usage, enabled, active_model} = catalog; + const downloadedModels = models.filter(i => i.downloaded); + + const modelHeaders = [ + {key: 'name', text: 'Model', sortBy: i => i.name.toLowerCase()}, + {key: 'tier', text: 'Tier', sortBy: i => i.tier}, + {key: 'size', text: 'Size', sortBy: i => i.size || 0}, + {key: 'actions', text: '', sortBy: null}, + ]; + + return <> + + + {slow_hardware && + This hardware will run the assistant slowly. Answers may take minutes; the smallest + model is recommended. + } + +
AI Assistant
+ + saveSettings({enabled: e.target.checked})} + /> + setLocation(value || ROOT)} + allowDeselect={false} + /> + {!directory && setNewTab(e.currentTarget.checked)} + />} + + + + + + + ; +} + +/** The rows of the editor, one per node, children beneath their directory and indented. */ +function BookmarkTableRows({nodes, parentId, depth, editable, actions}) { + return nodes.map((node, index) => { + const directory = isBookmarkDirectory(node); + const {href} = directory ? {} : resolveBookmarkUrl(node.url); + return + + {String(index + 1).padStart(2, '0')} + + {directory + ? + : } + + + {directory && {node.name}} + {!directory && (href + ? + {node.name} + + : {node.name})} + {!directory && {node.url}} + + {editable && +
+
+
} +
+ {directory && } +
; + }); +} + +/** + * The bookmarks table, in the shape of the playlist items table. `actions` carries + * edit/move/remove so the gallery can render it with none of them wired. + */ +export function BookmarkTable({nodes, editable = true, actions}) { + return + + + # + Kind + Bookmark + {editable && } + + + + + +
; +} + +/** The "Add a bookmark" and "Add a directory" forms beneath the table. */ +export function BookmarkAddForms({nodes, onAddBookmark, onAddDirectory}) { + const [url, setUrl] = useState(''); + const [name, setName] = useState(''); + const [newTab, setNewTab] = useState(false); + const [location, setLocation] = useState(ROOT); + const [directoryName, setDirectoryName] = useState(''); + const [directoryLocation, setDirectoryLocation] = useState(ROOT); + const locations = locationOptions(nodes); + const parentOf = (value) => value === ROOT ? null : Number(value); + + // The form is cleared only once the server has accepted the node. A refused add has + // already shown its toast; the typed URL stays so the user can correct it. + const handleAddBookmark = async () => { + if (!url.trim() || !name.trim()) return; + try { + await onAddBookmark({name: name.trim(), url: url.trim(), new_tab: newTab, parent_id: parentOf(location)}); + } catch (e) { + return; + } + setUrl(''); + setName(''); + }; + + const handleAddDirectory = async () => { + if (!directoryName.trim()) return; + try { + await onAddDirectory({name: directoryName.trim(), parent_id: parentOf(directoryLocation)}); + } catch (e) { + return; + } + setDirectoryName(''); + }; + + return <> +
Add a bookmark
+
{ + e.preventDefault(); + handleAddBookmark(); + }}> + + setUrl(e.currentTarget.value)}/> + setName(e.currentTarget.value)}/> + setDirectoryLocation(value || ROOT)} + allowDeselect={false}/> + + +
+ ; +} + +export function BookmarksPage() { + useTitle('Bookmarks'); + const {bookmarks, refresh} = useBookmarks(); + // {node, directory, parentId} while the edit modal is open; a `key` remounts the form fresh. + const [modal, setModal] = useState(null); + const [modalKey, setModalKey] = useState(0); + const nodes = bookmarks || []; + + const actions = { + edit: (node, parentId) => { + setModalKey(k => k + 1); + setModal({node, directory: isBookmarkDirectory(node), parentId}); + }, + move: async (node, parentId, position) => { + try { + await moveBookmark(node.id, parentId, position); + await refresh(); + } catch (e) { + // Error toast already shown by the API client. + } + }, + remove: async (node) => { + try { + await deleteBookmark(node.id); + await refresh(); + } catch (e) { + } + }, + }; + + // These reject on failure (the API client has already shown the toast) so the add + // forms know to keep what was typed. + const handleAddBookmark = async (bookmark) => { + await addBookmark(bookmark); + await refresh(); + }; + + const handleAddDirectory = async (directory) => { + await addBookmarkDirectory(directory); + await refresh(); + }; + + const handleEditSave = async ({name, url, new_tab, parent_id}) => { + const {node, directory, parentId} = modal; + try { + await updateBookmark(node.id, directory ? {name} : {name, url, new_tab}); + } catch (e) { + return; // Nothing changed; the modal stays open with the toast beside it. + } + // The rename is committed even if the move is refused, so the nav must show it + // either way; only a fully successful edit closes the modal. + let moved = true; + if (parent_id !== parentId) { + try { + await moveBookmark(node.id, parent_id); + } catch (e) { + moved = false; + } + } + await refresh(); + if (moved) { + setModal(null); + } + }; + + return + +
Bookmarks
+

+ Bookmarks appear in the Bookmarks menu of the navigation bar. They can point at pages on this + WROLPi, at other services running beside it, or anywhere on the web. +

+ + {nodes.length === 0 + ? Add a bookmark below. + : } + + +
+ {modal && setModal(null)} + />} +
; +} diff --git a/app/src/components/Bookmarks.test.js b/app/src/components/Bookmarks.test.js new file mode 100644 index 00000000..60b12b3a --- /dev/null +++ b/app/src/components/Bookmarks.test.js @@ -0,0 +1,199 @@ +import React from 'react'; +import {screen} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {render} from '../test-utils'; +import {BookmarkAddForms, BookmarkNodeModal, BookmarkTable, directoryOptions, locationOptions} from './Bookmarks'; +import {resolveBookmarkUrl} from '../contexts/BookmarksContext'; + +const tree = [ + {id: 1, name: 'Channels', url: '/videos/channel', new_tab: false}, + { + id: 2, name: 'Services', children: [ + {id: 3, name: 'Jellyfin', url: ':8096/', new_tab: true}, + {id: 4, name: 'Radio', children: []}, + ] + }, +]; + +describe('resolveBookmarkUrl', () => { + const location = {protocol: 'https:', hostname: '10.0.0.2', host: '10.0.0.2'}; + + it.each([ + ['/videos', {internal: true, href: '/videos'}], + [' /videos/channel/3 ', {internal: true, href: '/videos/channel/3'}], + // A port on this host takes the page's scheme, or its own when it names one. + [':8096/web', {internal: false, href: 'https://10.0.0.2:8096/web'}], + [':8096', {internal: false, href: 'https://10.0.0.2:8096'}], + ['http://:8096/web', {internal: false, href: 'http://10.0.0.2:8096/web'}], + ['HTTPS://:8085', {internal: false, href: 'https://10.0.0.2:8085'}], + ['https://example.com/a?b=c', {internal: false, href: 'https://example.com/a?b=c'}], + ])('resolves %s', (url, expected) => { + expect(resolveBookmarkUrl(url, location)).toEqual(expected); + }); + + it.each([ + // Paths a browser takes off this host: network paths, backslashes, and control + // characters a URL parser strips before deciding. + '//evil.com', '///evil.com', '/\\evil.com', '/\\@evil.com', '/\t/evil.com', '/\n/evil.com', + // Port forms where the host would become credentials. + ':8096@evil.com', 'http://:8096@evil.com', ':8096\t@evil.com', ':8096/x@evil.com', ':8096\\evil.com', + // Not http(s). + 'javascript:alert(1)', 'data:text/html,hi', 'ftp://example.com', 'videos', '', + ])('refuses %j rather than guessing', (url) => { + expect(resolveBookmarkUrl(url, location)).toEqual({internal: false, href: null}); + }); + + it('brackets an IPv6 host for a port bookmark', () => { + // The browser reports an IPv6 hostname bracketed already. + const v6 = {protocol: 'https:', hostname: '[::1]', host: '[::1]'}; + expect(resolveBookmarkUrl(':8096/web', v6)).toEqual({internal: false, href: 'https://[::1]:8096/web'}); + expect(resolveBookmarkUrl(':8096', {protocol: 'http:', hostname: '::1', host: '[::1]'})) + .toEqual({internal: false, href: 'http://[::1]:8096'}); + }); +}); + +describe('directoryOptions', () => { + it('lists every directory with its path, and no bookmarks', () => { + expect(directoryOptions(tree)).toEqual([ + {value: '2', label: 'Services', id: 2}, + {value: '4', label: 'Services / Radio', id: 4}, + ]); + }); +}); + +describe('BookmarkTable', () => { + const actions = () => ({edit: jest.fn(), move: jest.fn(), remove: jest.fn()}); + + it('renders the tree with resolved links, numbered within each directory', () => { + render(); + const channels = screen.getByRole('link', {name: 'Channels'}); + expect(channels).toHaveAttribute('href', '/videos/channel'); + expect(channels).not.toHaveAttribute('target'); + const jellyfin = screen.getByRole('link', {name: 'Jellyfin'}); + expect(jellyfin).toHaveAttribute('href', `${window.location.protocol}//${window.location.hostname}:8096/`); + expect(jellyfin).toHaveAttribute('target', '_blank'); + expect(screen.getByText('Radio')).toBeInTheDocument(); + // Channels, Services, then Services' two children. + const numbers = screen.getAllByRole('row').slice(1).map(row => row.firstChild.textContent); + expect(numbers).toEqual(['01', '02', '01', '02']); + }); + + it('moves a node among its siblings and only within bounds', async () => { + const a = actions(); + render(); + // The first top-level node cannot move up; the last cannot move down. + expect(screen.getByRole('button', {name: 'Move Channels up'})).toBeDisabled(); + expect(screen.getByRole('button', {name: 'Move Services down'})).toBeDisabled(); + + await userEvent.click(screen.getByRole('button', {name: 'Move Channels down'})); + expect(a.move).toHaveBeenCalledWith(tree[0], null, 1); + // A child moves within its directory: parent 2, from index 1 to 0. + await userEvent.click(screen.getByRole('button', {name: 'Move Radio up'})); + expect(a.move).toHaveBeenCalledWith(tree[1].children[1], 2, 0); + }); + + it('shows a bookmark whose URL does not resolve as text, not a link', () => { + const nodes = [{id: 1, name: 'Bad', url: '//evil.com', new_tab: false}]; + render(); + expect(screen.queryByRole('link', {name: 'Bad'})).not.toBeInTheDocument(); + expect(screen.getByText('Bad')).toBeInTheDocument(); + }); + + it('edits with the parent the node lives in, and deletes a bookmark without asking', async () => { + const a = actions(); + render(); + await userEvent.click(screen.getByRole('button', {name: 'Edit Jellyfin'})); + expect(a.edit).toHaveBeenCalledWith(tree[1].children[0], 2); + await userEvent.click(screen.getByRole('button', {name: 'Delete Channels'})); + expect(a.remove).toHaveBeenCalledWith(tree[0]); + }); + + it('asks before deleting a directory, since its contents go with it', async () => { + const a = actions(); + render(); + await userEvent.click(screen.getByRole('button', {name: 'Delete Services'})); + expect(a.remove).not.toHaveBeenCalled(); + await userEvent.click(await screen.findByRole('button', {name: 'Delete'})); + expect(a.remove).toHaveBeenCalledWith(tree[1]); + }); +}); + +describe('BookmarkAddForms', () => { + it('adds a bookmark at the top level and clears the form', async () => { + const onAddBookmark = jest.fn(); + render(); + const adds = screen.getAllByRole('button', {name: 'Add'}); + expect(adds[0]).toBeDisabled(); + + await userEvent.type(screen.getByLabelText('Bookmark URL'), ':8123/'); + expect(adds[0]).toBeDisabled(); + await userEvent.type(screen.getByLabelText('Bookmark name'), 'Home Assistant'); + await userEvent.click(screen.getByLabelText('New tab')); + await userEvent.click(adds[0]); + + expect(onAddBookmark).toHaveBeenCalledWith({name: 'Home Assistant', url: ':8123/', new_tab: true, parent_id: null}); + expect(screen.getByLabelText('Bookmark URL')).toHaveValue(''); + }); + + it('keeps what was typed when the server refuses the bookmark', async () => { + const onAddBookmark = jest.fn().mockRejectedValue(new Error('refused')); + render(); + await userEvent.type(screen.getByLabelText('Bookmark URL'), '//evil.com'); + await userEvent.type(screen.getByLabelText('Bookmark name'), 'Oops'); + await userEvent.click(screen.getAllByRole('button', {name: 'Add'})[0]); + + expect(onAddBookmark).toHaveBeenCalled(); + expect(screen.getByLabelText('Bookmark URL')).toHaveValue('//evil.com'); + expect(screen.getByLabelText('Bookmark name')).toHaveValue('Oops'); + }); + + it('adds a directory', async () => { + const onAddDirectory = jest.fn(); + render(); + await userEvent.type(screen.getByLabelText('Directory name'), 'Tools'); + await userEvent.click(screen.getAllByRole('button', {name: 'Add'})[1]); + expect(onAddDirectory).toHaveBeenCalledWith({name: 'Tools', parent_id: null}); + }); +}); + +describe('BookmarkNodeModal', () => { + // Mantine's Select scrolls the highlighted option into view; jsdom has no layout. + beforeAll(() => { + Element.prototype.scrollIntoView = jest.fn(); + }); + + it('saves a new bookmark with its location and new-tab choice', async () => { + const onSave = jest.fn(); + render(); + const save = screen.getByRole('button', {name: 'Save'}); + expect(save).toBeDisabled(); + + await userEvent.type(screen.getByLabelText('Name'), 'Home Assistant'); + expect(save).toBeDisabled(); + await userEvent.type(screen.getByLabelText('URL'), ':8123/'); + await userEvent.click(screen.getByLabelText('Open in a new tab')); + await userEvent.click(save); + + expect(onSave).toHaveBeenCalledWith({name: 'Home Assistant', url: ':8123/', new_tab: true, parent_id: 2}); + }); + + it('a directory has no URL or new-tab choice', () => { + render(); + expect(screen.getByLabelText('Name')).toHaveValue('Services'); + expect(screen.queryByLabelText('URL')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Open in a new tab')).not.toBeInTheDocument(); + }); +}); + +describe('locationOptions', () => { + it('offers the top level and every directory to a new node', () => { + expect(locationOptions(tree).map(o => o.label)).toEqual(['Top level', 'Services', 'Services / Radio']); + }); + + it('never offers a directory itself, or its children', () => { + expect(locationOptions(tree, tree[1]).map(o => o.label)).toEqual(['Top level']); + expect(locationOptions(tree, tree[1].children[1]).map(o => o.label)).toEqual(['Top level', 'Services']); + }); +}); diff --git a/app/src/components/Nav.js b/app/src/components/Nav.js index 8b3c1117..188de708 100644 --- a/app/src/components/Nav.js +++ b/app/src/components/Nav.js @@ -21,6 +21,7 @@ import {HELP_VIEWER_URI, NAME, navColorHexMap} from "./Vars"; import {useOverflowNav} from "../hooks/useOverflowNav"; import {defaultNavColor, navBarStyle, useNavColors} from "../themes/navColors"; import _ from "lodash"; +import {BookmarksMenuContents, EDIT_BOOKMARKS_PATH} from "./Bookmarks"; function updateFavicon(colorName) { // Fall back to violet if invalid color @@ -64,12 +65,18 @@ const allLinks = [ // No `end` on these two: a tab stays current for the pages beneath it, and `end` would // drop the mark the moment either grows a sub-page. {to: '/more/calculators', text: 'Calculators', key: 'calculators'}, + // The user's bookmarks, read from BookmarksContext when rendered. No `to`: it is a + // menu, and its items are not sections of the app. + {text: 'Bookmarks', key: 'bookmarks', bookmarks: true}, {text: 'Flasher', to: '/flasher', key: 'flasher'}, {to: '/more/statistics', text: 'Statistics', key: 'statistics'}, ]; function MenuLink({link}) { // A top-level tab rendered directly in the bar (not inside a dropdown). + if (link.bookmarks) { + return + } if (link.links) { return } @@ -85,13 +92,31 @@ function MenuLink({link}) { } -function DropdownMenuItem({link}) { +function DropdownMenuItem({link, nested = true}) { // An item inside a Menu.Dropdown: the mobile hamburger menu, or the desktop - // "More" overflow menu. + // "More" overflow menu. `nested` allows flyout submenus, which the hamburger + // declines: a flyout opens on hover, and a phone has none. + if (link.bookmarks) { + if (nested) { + return + + {link.text} + + + + + + } + return + + {link.text} + + + + } if (link.links) { - // Mantine's Menu does not nest submenus without extra plumbing; a labelled, - // indented group stands in for one. No current data uses this path (no - // allLinks entry has its own `links`). + // A labelled, indented group stands in for a submenu here. No current data uses + // this path (no allLinks entry has its own `links`). return {link.text} {link.links.map(l => )} @@ -141,6 +166,11 @@ NavDropdownTrigger.displayName = 'NavDropdownTrigger'; * the hooks that would answer it cannot be called in a loop. */ export const isLinkActive = (pathname, link) => { + // The Bookmarks menu owns only its editor page: a bookmark pointing at /videos does + // not make the menu own the Videos section. + if (link.bookmarks) { + return pathname === EDIT_BOOKMARKS_PATH || pathname.startsWith(`${EDIT_BOOKMARKS_PATH}/`); + } // An off-site link (Help) is never the current section. if (!link.to || /^[a-z]+:\/\//i.test(link.to)) return false; const to = link.to.length > 1 ? link.to.replace(/\/+$/, '') : link.to; @@ -175,6 +205,24 @@ function DropdownLinks({link}) { } +/** + * The Bookmarks tab: a dropdown that opens on hover as well as on click, holding the + * user's bookmarks and the link to edit them. Marked current on the editor page only + * (see isLinkActive). + */ +function BookmarksDropdown({link}) { + const {pathname} = useLocation(); + const holdsCurrent = isLinkActive(pathname, link); + return + + + + + + + +} + function MobileMenu({links}) { // The mobile hamburger menu: an icon-only trigger holding every link. return @@ -182,7 +230,7 @@ function MobileMenu({links}) { - {links.map(link => )} + {links.map(link => )} } diff --git a/app/src/components/Nav.test.js b/app/src/components/Nav.test.js index 82cb7433..3e4f88a6 100644 --- a/app/src/components/Nav.test.js +++ b/app/src/components/Nav.test.js @@ -231,3 +231,91 @@ describe('NavBar current tab', () => { expect(ruleBody('\\.mantine-Menu-item\\.active')).toMatch(/background:|font-weight:/); }); }); + +describe('NavBar bookmarks', () => { + const {BookmarksContext} = require('../contexts/BookmarksContext'); + const bookmarks = [ + {id: 1, name: 'Channels', url: '/videos/channel', new_tab: false}, + {id: 2, name: 'Services', children: [{id: 3, name: 'Jellyfin', url: ':8096/', new_tab: true}]}, + ]; + const renderWithBookmarks = (route, nodes) => render(, { + route, + withMedia: true, + contexts: [ + [FileWorkerStatusContext, fileWorkerStatus], + [BookmarksContext, {bookmarks: nodes, error: null, refresh: jest.fn()}], + ], + }); + + it('sits between Calculators and Flasher', () => { + renderNav('/'); + const texts = [...document.querySelectorAll('#global_navbar .wrolpi-navbar-link')] + .map(el => el.textContent.trim()); + const at = texts.indexOf('Bookmarks'); + expect(at).toBeGreaterThan(0); + expect(texts[at - 1]).toBe('Calculators'); + expect(texts[at + 1]).toBe('Flasher'); + }); + + it('opens on click with the bookmarks, a directory flyout, and the edit link', async () => { + useRealRects(); + renderWithBookmarks('/', bookmarks); + + await userEvent.click(screen.getByRole('button', {name: /Bookmarks/})); + + const channels = await screen.findByRole('menuitem', {name: 'Channels'}); + expect(channels).toHaveAttribute('href', '/videos/channel'); + expect(channels).not.toHaveAttribute('target'); + // A directory is a submenu; its bookmarks appear once it is hovered. + expect(screen.queryByRole('menuitem', {name: 'Jellyfin'})).not.toBeInTheDocument(); + await userEvent.hover(screen.getByRole('menuitem', {name: 'Services'})); + // `hidden`: the flyout is placed by floating-ui, which jsdom cannot lay out, so it + // stays hidden to accessibility queries even though it has rendered. + const jellyfin = await screen.findByRole('menuitem', {name: 'Jellyfin', hidden: true}); + expect(jellyfin).toHaveAttribute('href', `${window.location.protocol}//${window.location.hostname}:8096/`); + expect(jellyfin).toHaveAttribute('target', '_blank'); + expect(screen.getByRole('menuitem', {name: 'Edit bookmarks', hidden: true})).toHaveAttribute('href', '/more/bookmarks'); + }); + + it('offers the edit link even with no bookmarks, so the feature can be found', async () => { + useRealRects(); + renderWithBookmarks('/', []); + + await userEvent.click(screen.getByRole('button', {name: /Bookmarks/})); + + expect(await screen.findByRole('menuitem', {name: 'Edit bookmarks'})).toBeInTheDocument(); + expect(screen.getAllByRole('menuitem')).toHaveLength(1); + }); + + it('is marked current on the editor page, and never for a bookmark pointing at a section', () => { + renderWithBookmarks('/more/bookmarks', bookmarks); + expect(screen.getByRole('button', {name: /Bookmarks/})).toHaveClass('active'); + expect(currentTab()).toEqual([]); + }); + + it('marks More when the editor page is folded inside it', () => { + useBarWidth(200); + renderWithBookmarks('/more/bookmarks', bookmarks); + expect(screen.getByRole('button', {name: /More/})).toHaveClass('active'); + }); + + it('does not own the section a bookmark points at', () => { + renderWithBookmarks('/videos/channel', bookmarks); + expect(screen.getByRole('button', {name: /Bookmarks/})).not.toHaveClass('active'); + expect(currentTab()).toEqual(['Videos']); + }); + + it('lists bookmarks as a labelled group in the mobile menu', async () => { + useViewport('mobile'); + useRealRects(); + renderWithBookmarks('/', bookmarks); + + await userEvent.click(screen.getByRole('button', {name: 'Menu'})); + + expect(await screen.findByRole('menuitem', {name: 'Channels'})).toBeInTheDocument(); + // No flyout on a phone: the directory's bookmarks are listed directly. + expect(screen.getByRole('menuitem', {name: 'Jellyfin'})).toBeInTheDocument(); + expect(screen.getByText('Services')).toBeInTheDocument(); + expect(screen.getByRole('menuitem', {name: 'Edit bookmarks'})).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/ThemeSamplePage.js b/app/src/components/ThemeSamplePage.js index 3f95df25..9e14efa1 100644 --- a/app/src/components/ThemeSamplePage.js +++ b/app/src/components/ThemeSamplePage.js @@ -48,6 +48,7 @@ import {paletteColorNames} from '../themes/mantine'; import {contrastingColor} from './Common'; import {navBarStyle, navColorNames, useNavColors} from '../themes/navColors'; import {IconMenu2} from '@tabler/icons-react'; +import {BookmarkAddForms, BookmarkMenuItems, BookmarkTable} from './Bookmarks'; /* * A gallery of every component in the library, in the current theme. @@ -156,6 +157,19 @@ export const NavBarSample = ({color}) => { // press adds a toast rather than replacing the first. Module scope, so it survives re-renders. let toastCounter = 0; +// Sample data for the bookmarks section; the editor's actions do nothing here. +const sampleBookmarks = [ + {id: 1, name: 'Channels', url: '/videos/channel', new_tab: false}, + {id: 2, name: 'Services', children: [ + {id: 3, name: 'Jellyfin', url: ':8096/', new_tab: true}, + {id: 4, name: 'Radio', children: [ + {id: 5, name: 'OpenWebRX', url: 'http://:8073/', new_tab: true}, + ]}, + ]}, + {id: 6, name: 'WROLPi.org', url: 'https://wrolpi.org', new_tab: true}, +]; +const noBookmarkActions = {edit: () => null, move: () => null, remove: () => null}; + export function ThemeSamplePage() { const {theme, mediaFilterEnabled} = useContext(ThemeContext); const [confirmOpen, setConfirmOpen] = useState(false); @@ -1078,6 +1092,41 @@ export function ThemeSamplePage() { +
+ +

+ The Bookmarks menu in the navigation bar, and the editor under More. A + directory is a flyout that opens beside the menu. In the mobile menu it is a + labelled, indented run of items instead. +

+ + + + + + + + + }>Edit bookmarks + + + + + + + + Bookmarks + + + + +
+ + null} onAddDirectory={() => null}/> +
+
+
+

Danger zone

diff --git a/app/src/contexts/BookmarksContext.js b/app/src/contexts/BookmarksContext.js new file mode 100644 index 00000000..e3d639e6 --- /dev/null +++ b/app/src/contexts/BookmarksContext.js @@ -0,0 +1,114 @@ +import React, {createContext, useCallback, useContext, useEffect, useState} from 'react'; +import {getBookmarks} from '../api'; + +/* + * The user's bookmarks, shared by the navigation bar and the editor page. + * + * A context rather than a prop: the bar and the page are far apart in the tree, and an + * edit on the page must show in the bar at once. `bookmarks` is null until the first + * fetch answers, so the bar can tell "not loaded yet" from "the user has none". + */ +export const BookmarksContext = createContext({bookmarks: null, error: null, refresh: async () => null}); + +export function BookmarksProvider({children}) { + const [bookmarks, setBookmarks] = useState(null); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + try { + setBookmarks(await getBookmarks()); + setError(null); + } catch (err) { + setError(err); + } + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + return + {children} + ; +} + +export function useBookmarks() { + return useContext(BookmarksContext); +} + +export const isBookmarkDirectory = (node) => Array.isArray(node.children); + +// Characters a URL parser strips or that split a URL in surprising places. See the +// same rule in wrolpi/bookmarks.py; the two must agree. +// eslint-disable-next-line no-control-regex -- matching control characters is the point +const FORBIDDEN = /[\x00-\x20\x7f\\]/; +const PORT_FORM = /^(?:(https?):\/\/)?:(\d{1,5})(?=$|[/?#])(.*)$/i; +const PORT_FORM_START = /^(?:https?:\/\/)?:/i; + +const NONE = {internal: false, href: null}; + +/** + * Where a bookmark URL really points, from the browser's point of view. + * + * Three forms are accepted, and only the first is an in-app link: + * `/videos/channel/3` a path on this WROLPi -> {internal: true, href: '/videos/channel/3'} + * `:8096/web` a port on this host, using the page's scheme + * `http://:8096/web` a port on this host, with its own scheme + * `https://example.com` anything else, verbatim + * + * The host-relative forms exist because a WROLPi is reached by LAN IP, by mDNS name, and + * by its hotspot address, and a service running beside it is reached the same way. A + * bookmark with the hostname written in would work from only one of them. + * + * Anything else, including a stored value the server would refuse today, resolves to a + * null href and is rendered as text rather than a link: the form each value claims is + * checked by parsing it, never by its prefix, because `//evil.com` starts like a path and + * `:8096@evil.com` starts like a port, and a browser takes both off this host. + */ +export function resolveBookmarkUrl(url, location = window.location) { + const value = (url || '').trim(); + if (!value || FORBIDDEN.test(value)) { + return NONE; + } + const origin = `${location.protocol}//${location.host}`; + + if (PORT_FORM_START.test(value)) { + const match = value.match(PORT_FORM); + if (!match || match[3].includes('@')) { + return NONE; + } + const [, scheme, port, tail] = match; + // An IPv6 literal has to be bracketed to sit before a port. Browsers report it + // bracketed already; tests and older code may not. + const bare = location.hostname.replace(/^\[|]$/g, ''); + const host = bare.includes(':') ? `[${bare}]` : bare; + const href = `${scheme ? `${scheme.toLowerCase()}:` : location.protocol}//${host}:${port}${tail}`; + const parsed = parse(href); + if (!parsed || parsed.hostname !== host || parsed.port !== port) { + return NONE; + } + return {internal: false, href}; + } + + if (value.startsWith('/')) { + const parsed = parse(value, origin); + if (value.startsWith('//') || !parsed || parsed.origin !== origin) { + return NONE; + } + return {internal: true, href: value}; + } + + const parsed = parse(value); + if (!parsed || (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')) { + return NONE; + } + return {internal: false, href: value}; +} + +function parse(value, base) { + try { + return new URL(value, base); + } catch { + return null; + } +} diff --git a/docker/help/wrolpi-help b/docker/help/wrolpi-help index 662a4dd7..c4362d80 160000 --- a/docker/help/wrolpi-help +++ b/docker/help/wrolpi-help @@ -1 +1 @@ -Subproject commit 662a4dd7b1cf66bb3ec517c69e99f70cb7a4941b +Subproject commit c4362d807977c2719dbe9a870ad86f4c14580431 diff --git a/wrolpi/bookmarks.py b/wrolpi/bookmarks.py new file mode 100644 index 00000000..b3e4a34a --- /dev/null +++ b/wrolpi/bookmarks.py @@ -0,0 +1,278 @@ +"""User bookmarks shown in the navigation bar. + +Bookmarks are config-only (no database table). The config holds a tree: a node with +`children` is a directory, a node with `url` is a bookmark. Every node has an integer `id` +so the API can address it without relying on names, which need not be unique. + +A bookmark `url` may take three forms: + * a path, `/videos/channel/3`, which the UI renders as an in-app link; + * a host-relative port, `:8096/web` or `http://:8096/web`, which the UI resolves against + whatever hostname the browser reached this WROLPi by (LAN IP, mDNS name, hotspot); + * an absolute URL, `https://example.com`. +""" +import re +from dataclasses import dataclass, field +from typing import List, Optional, Tuple +from urllib.parse import urlsplit + +from wrolpi.common import ConfigFile, logger +from wrolpi.errors import ValidationError +from wrolpi.switches import register_switch_handler, ActivateSwitchMethod + +logger = logger.getChild(__name__) + +# Characters that a URL parser strips or that split a URL in surprising places. Any of +# them in a bookmark is a sign of an attempt to smuggle a host past the checks below. +_FORBIDDEN = re.compile(r'[\x00-\x20\x7f\\]') +# A port on this host: an optional scheme, then `:` and the port, then the end of the URL +# or the start of a path, query or fragment. `:8096@evil.com` does not match, because +# `@` may not follow the port. +_PORT_FORM = re.compile(r'^(?:(https?)://)?:(\d{1,5})(?=$|[/?#])(.*)$', re.IGNORECASE) +_PORT_FORM_START = re.compile(r'^(?:https?://)?:', re.IGNORECASE) + +URL_HELP = 'Bookmark URL must be a path (/videos), a port (:8096/) or an absolute URL (https://)' + + +@dataclass +class BookmarksConfigValidator: + version: int = None + bookmarks: list = field(default_factory=list) + + +def is_directory(node: dict) -> bool: + return 'children' in node + + +def walk(nodes: List[dict], parent: Optional[dict] = None): + """Yield (node, parent, siblings) for every node in the tree, depth first.""" + for node in nodes: + yield node, parent, nodes + if is_directory(node): + yield from walk(node['children'], node) + + +def validate_url(url: str) -> str: + """Return `url` stripped, or raise if it is not one of the three forms. + + The forms are checked by parsing, not by prefix. A prefix check accepted `//evil.com` + as a path and `:8096@evil.com` as a port, both of which a browser takes off this host. + """ + url = (url or '').strip() + if not url or _FORBIDDEN.search(url): + raise ValidationError(URL_HELP) + + if _PORT_FORM_START.match(url): + # A port on this host. Everything after the port must be a path, query or + # fragment; `@` anywhere in it would turn the host into credentials. + match = _PORT_FORM.match(url) + if not match or not 0 < int(match.group(2)) < 65536 or '@' in match.group(3): + raise ValidationError(URL_HELP) + return url + + if url.startswith('/'): + # A path on this WROLPi. A second slash (`//evil.com`) is a network path, which + # leaves this host. `urlsplit` agrees only when the netloc comes back empty. + parts = urlsplit(url) + if url.startswith('//') or parts.scheme or parts.netloc: + raise ValidationError(URL_HELP) + return url + + parts = urlsplit(url) + if parts.scheme.lower() not in ('http', 'https') or not parts.netloc: + raise ValidationError(URL_HELP) + return url + + +def validate_name(name: str) -> str: + name = (name or '').strip() + if not name: + raise ValidationError('Name is required') + return name + + +def validate_tree(nodes) -> None: + """Raise ValidationError unless `nodes` is a well-formed bookmarks tree. + + Applied to every write, including a hand-edited config file and the config API, so + the nav never renders a URL that `validate_url` would have refused. + """ + if not isinstance(nodes, list): + raise ValidationError('bookmarks must be a list') + seen_ids = set() + # Iterative, with the objects on the path tracked, so a cyclic structure (which a + # YAML anchor can build) is rejected rather than recursed into forever. + stack = [(nodes, ())] + while stack: + siblings, path = stack.pop() + if id(siblings) in path: + raise ValidationError('bookmarks tree contains a cycle') + for node in siblings: + if not isinstance(node, dict): + raise ValidationError(f'Bookmark is not a mapping: {node!r}') + node_id = node.get('id') + if not isinstance(node_id, int) or isinstance(node_id, bool) or node_id in seen_ids: + raise ValidationError(f'Bookmark id is missing or duplicated: {node_id!r}') + seen_ids.add(node_id) + validate_name(node.get('name')) + has_url, has_children = 'url' in node, 'children' in node + if has_url == has_children: + raise ValidationError(f'Bookmark {node_id} must have a url or children, not both') + if has_url: + validate_url(node['url']) + if 'new_tab' in node and not isinstance(node['new_tab'], bool): + raise ValidationError(f'Bookmark {node_id} new_tab must be true or false') + else: + if not isinstance(node['children'], list): + raise ValidationError(f'Bookmark {node_id} children must be a list') + stack.append((node['children'], path + (id(siblings),))) + + +class BookmarksConfig(ConfigFile): + file_name = 'bookmarks.yaml' + default_config = dict( + version=0, + bookmarks=[], + ) + validator = BookmarksConfigValidator + + def validate(self, config: dict) -> bool: + # The dataclass validator sees only that `bookmarks` is a list. Every writer + # (the bookmarks API, the config API, import) comes through here, so the tree + # itself is checked too. + if not super().validate(config): + return False + if 'bookmarks' in config: + try: + validate_tree(config['bookmarks']) + except ValidationError as e: + logger.error(f'{self.file_name}: {e}') + return False + return True + + def import_config(self, file=None, send_events=False): + # An invalid file raises here, leaving successful_import False so save() will not + # overwrite it. A missing file is imported as empty, so the first save creates it. + super().import_config(file, send_events) + self.successful_import = True + + @property + def bookmarks(self) -> List[dict]: + # A deep copy: the tree is nested, and callers mutate what they get back before + # assigning it again through the setter. + from copy import deepcopy + return deepcopy(list(self._config.get('bookmarks', []))) + + @bookmarks.setter + def bookmarks(self, value: List[dict]): + self.update({'bookmarks': value}) + save_bookmarks_config.activate_switch() + + def _next_id(self, bookmarks: List[dict]) -> int: + ids = [node.get('id', 0) for node, _, _ in walk(bookmarks)] + return (max(ids) if ids else 0) + 1 + + @staticmethod + def _find(bookmarks: List[dict], node_id: int) -> Tuple[dict, Optional[dict], List[dict]]: + for node, parent, siblings in walk(bookmarks): + if node.get('id') == node_id: + return node, parent, siblings + raise ValidationError(f'Bookmark {node_id} not found') + + @staticmethod + def _container(bookmarks: List[dict], parent_id: Optional[int]) -> List[dict]: + """The list of children that `parent_id` holds; the root list when parent_id is None.""" + if parent_id is None: + return bookmarks + parent, _, _ = BookmarksConfig._find(bookmarks, parent_id) + if not is_directory(parent): + raise ValidationError(f'Bookmark {parent_id} is not a directory') + return parent['children'] + + @staticmethod + def _insert(container: List[dict], node: dict, position: Optional[int]): + if position is None or position >= len(container): + container.append(node) + else: + container.insert(max(position, 0), node) + + def add_bookmark(self, name: str, url: str, new_tab: bool = False, parent_id: int = None, + position: int = None) -> dict: + bookmarks = self.bookmarks + node = dict( + id=self._next_id(bookmarks), + name=validate_name(name), + url=validate_url(url), + new_tab=bool(new_tab), + ) + self._insert(self._container(bookmarks, parent_id), node, position) + self.bookmarks = bookmarks + return node + + def add_directory(self, name: str, parent_id: int = None, position: int = None) -> dict: + bookmarks = self.bookmarks + node = dict(id=self._next_id(bookmarks), name=validate_name(name), children=[]) + self._insert(self._container(bookmarks, parent_id), node, position) + self.bookmarks = bookmarks + return node + + def update_node(self, node_id: int, name: str = None, url: str = None, new_tab: bool = None) -> dict: + bookmarks = self.bookmarks + node, _, _ = self._find(bookmarks, node_id) + if name is not None: + node['name'] = validate_name(name) + if is_directory(node): + if url is not None or new_tab is not None: + raise ValidationError('A directory has no URL') + else: + if url is not None: + node['url'] = validate_url(url) + if new_tab is not None: + node['new_tab'] = bool(new_tab) + self.bookmarks = bookmarks + return node + + def delete_node(self, node_id: int) -> dict: + """Delete a bookmark, or a directory and everything in it.""" + bookmarks = self.bookmarks + node, _, siblings = self._find(bookmarks, node_id) + siblings.remove(node) + self.bookmarks = bookmarks + return node + + def move_node(self, node_id: int, parent_id: Optional[int], position: Optional[int] = None) -> dict: + """Move a node into `parent_id` (None = root) at `position` (None = end).""" + bookmarks = self.bookmarks + node, _, siblings = self._find(bookmarks, node_id) + if parent_id == node_id: + raise ValidationError('Cannot move a directory into itself') + if is_directory(node) and parent_id is not None: + descendants = {n['id'] for n, _, _ in walk(node['children'])} + if parent_id in descendants: + raise ValidationError('Cannot move a directory into its own child') + container = self._container(bookmarks, parent_id) + # Remove after resolving the container so an unknown parent leaves the tree untouched. + siblings.remove(node) + self._insert(container, node, position) + self.bookmarks = bookmarks + return node + + +BOOKMARKS_CONFIG: BookmarksConfig = BookmarksConfig() + +# Test override. +TEST_BOOKMARKS_CONFIG = None + + +def get_bookmarks_config() -> BookmarksConfig: + global TEST_BOOKMARKS_CONFIG + if isinstance(TEST_BOOKMARKS_CONFIG, ConfigFile): + return TEST_BOOKMARKS_CONFIG + return BOOKMARKS_CONFIG + + +@register_switch_handler('save_bookmarks_config') +def save_bookmarks_config(): + get_bookmarks_config().save() + + +save_bookmarks_config: ActivateSwitchMethod diff --git a/wrolpi/bookmarks_api.py b/wrolpi/bookmarks_api.py new file mode 100644 index 00000000..663e510e --- /dev/null +++ b/wrolpi/bookmarks_api.py @@ -0,0 +1,86 @@ +from dataclasses import dataclass +from http import HTTPStatus +from typing import Optional + +from sanic import Blueprint, Request, response +from sanic_ext import validate +from sanic_ext.extensions.openapi import openapi + +from wrolpi.api_utils import json_response +from wrolpi.bookmarks import get_bookmarks_config + +bookmarks_bp = Blueprint('Bookmarks', '/api/bookmarks') + + +@dataclass +class BookmarkRequest: + name: str + url: str + new_tab: bool = False + parent_id: Optional[int] = None + position: Optional[int] = None + + +@dataclass +class DirectoryRequest: + name: str + parent_id: Optional[int] = None + position: Optional[int] = None + + +@dataclass +class NodeUpdateRequest: + name: Optional[str] = None + url: Optional[str] = None + new_tab: Optional[bool] = None + + +@dataclass +class NodeMoveRequest: + parent_id: Optional[int] = None + position: Optional[int] = None + + +@bookmarks_bp.get('/') +@openapi.description('Get the bookmarks tree') +def get_bookmarks(_: Request): + return json_response(dict(bookmarks=get_bookmarks_config().bookmarks), HTTPStatus.OK) + + +@bookmarks_bp.post('/') +@openapi.description('Add a bookmark, optionally inside a directory') +@validate(BookmarkRequest) +async def post_bookmark(_: Request, body: BookmarkRequest): + node = get_bookmarks_config().add_bookmark(body.name, body.url, body.new_tab, body.parent_id, body.position) + return json_response(dict(bookmark=node), HTTPStatus.CREATED) + + +@bookmarks_bp.post('/directory') +@openapi.description('Add a bookmark directory, optionally inside another directory') +@validate(DirectoryRequest) +async def post_directory(_: Request, body: DirectoryRequest): + node = get_bookmarks_config().add_directory(body.name, body.parent_id, body.position) + return json_response(dict(bookmark=node), HTTPStatus.CREATED) + + +@bookmarks_bp.put('/') +@openapi.description('Rename a bookmark or directory; change a bookmark URL or new-tab setting') +@validate(NodeUpdateRequest) +async def put_node(_: Request, node_id: int, body: NodeUpdateRequest): + node = get_bookmarks_config().update_node(node_id, body.name, body.url, body.new_tab) + return json_response(dict(bookmark=node), HTTPStatus.OK) + + +@bookmarks_bp.post('//move') +@openapi.description('Move a bookmark or directory into a directory (null parent = top level) at a position') +@validate(NodeMoveRequest) +async def post_move(_: Request, node_id: int, body: NodeMoveRequest): + node = get_bookmarks_config().move_node(node_id, body.parent_id, body.position) + return json_response(dict(bookmark=node), HTTPStatus.OK) + + +@bookmarks_bp.delete('/') +@openapi.description('Delete a bookmark, or a directory and its contents') +async def delete_node(_: Request, node_id: int): + get_bookmarks_config().delete_node(node_id) + return response.empty(HTTPStatus.NO_CONTENT) diff --git a/wrolpi/common.py b/wrolpi/common.py index de392d04..434516e5 100644 --- a/wrolpi/common.py +++ b/wrolpi/common.py @@ -1169,6 +1169,10 @@ def get_all_configs() -> Dict[str, ConfigFile]: if flasher_config := get_flasher_config(): all_configs[flasher_config.file_name] = flasher_config + from wrolpi.bookmarks import get_bookmarks_config + if bookmarks_config := get_bookmarks_config(): + all_configs[bookmarks_config.file_name] = bookmarks_config + from wrolpi.collections.config import get_playlists_config if playlists_config := get_playlists_config(): all_configs[playlists_config.file_name] = playlists_config @@ -1269,6 +1273,16 @@ async def import_all_db_configs() -> dict[str, bool]: logger.warning(f'Failed to import map pins config: {e}') results['map_pins'] = False + # Bookmarks (YAML-only, no DB) + try: + from wrolpi.bookmarks import get_bookmarks_config + await asyncio.to_thread(get_bookmarks_config().import_config) + results['bookmarks'] = True + logger.debug('bookmarks config imported') + except Exception as e: + logger.warning(f'Failed to import bookmarks config: {e}') + results['bookmarks'] = False + # Flasher saved firmware configurations (YAML-only, no DB) try: from modules.flasher.config import get_flasher_config diff --git a/wrolpi/contexts.py b/wrolpi/contexts.py index 2f55cabc..86a74b90 100644 --- a/wrolpi/contexts.py +++ b/wrolpi/contexts.py @@ -37,6 +37,7 @@ def attach_shared_contexts(app: Sanic): app.shared_ctx.download_cache_config = manager.dict() app.shared_ctx.map_pins_config = manager.dict() app.shared_ctx.flasher_config = manager.dict() + app.shared_ctx.bookmarks_config = manager.dict() # Shared dicts. app.shared_ctx.uploaded_files = manager.dict() app.shared_ctx.status = manager.dict() @@ -317,3 +318,9 @@ def initialize_configs_contexts(app: Sanic): FLASHER_CONFIG.initialize(app.shared_ctx.flasher_config) except Exception as e: logger.error(f'Failed to initialize in-memory flasher config: {e}') + + try: + from wrolpi.bookmarks import BOOKMARKS_CONFIG + BOOKMARKS_CONFIG.initialize(app.shared_ctx.bookmarks_config) + except Exception as e: + logger.error(f'Failed to initialize in-memory bookmarks config: {e}') diff --git a/wrolpi/root_api.py b/wrolpi/root_api.py index 01d459c2..5b9b64e3 100644 --- a/wrolpi/root_api.py +++ b/wrolpi/root_api.py @@ -23,6 +23,7 @@ from modules.zim.api import zim_bp from wrolpi import flags, schema, dates from wrolpi import tags from wrolpi.api_utils import json_response, api_app +from wrolpi.bookmarks_api import bookmarks_bp from wrolpi.collections.api import collection_bp from wrolpi.common import logger, get_wrolpi_config, wrol_mode_enabled, get_media_directory, \ wrol_mode_check, native_only, disable_wrol_mode, enable_wrol_mode, get_global_statistics, url_strip_host, \ @@ -55,6 +56,7 @@ api_bp = Blueprint('RootAPI', url_prefix='/api') # Blueprints order here defines what order they are displayed in OpenAPI Docs. api_app.blueprint(api_bp) api_app.blueprint(archive_bp) +api_app.blueprint(bookmarks_bp) api_app.blueprint(collection_bp) # Unified collection endpoints api_app.blueprint(config_bp) api_app.blueprint(docs_bp) diff --git a/wrolpi/test/test_bookmarks.py b/wrolpi/test/test_bookmarks.py new file mode 100644 index 00000000..83634105 --- /dev/null +++ b/wrolpi/test/test_bookmarks.py @@ -0,0 +1,226 @@ +import pytest +import yaml + +from wrolpi.bookmarks import get_bookmarks_config +from wrolpi.common import get_media_directory +from wrolpi.errors import InvalidConfig + + +def read_config(): + return yaml.safe_load((get_media_directory() / 'config/bookmarks.yaml').read_text()) + + +async def test_bookmarks_tree_api(async_client, test_directory): + """Bookmarks and directories can be added, edited, moved and deleted, and are persisted to YAML.""" + request, response = await async_client.get('/api/bookmarks') + assert response.status == 200 + assert response.json['bookmarks'] == [] + + # A top-level bookmark; new_tab defaults to false. + request, response = await async_client.post('/api/bookmarks', json=dict(name='Videos', url='/videos')) + assert response.status == 201, response.json + videos = response.json['bookmark'] + assert videos == dict(id=1, name='Videos', url='/videos', new_tab=False) + + # A directory, then a bookmark inside it that opens in a new tab. + request, response = await async_client.post('/api/bookmarks/directory', json=dict(name='Services')) + assert response.status == 201 + services = response.json['bookmark'] + assert services == dict(id=2, name='Services', children=[]) + request, response = await async_client.post('/api/bookmarks', + json=dict(name='Jellyfin', url=':8096/', new_tab=True, + parent_id=services['id'])) + assert response.status == 201 + jellyfin = response.json['bookmark'] + assert jellyfin['id'] == 3 and jellyfin['new_tab'] is True + + request, response = await async_client.get('/api/bookmarks') + assert response.json['bookmarks'] == [ + dict(id=1, name='Videos', url='/videos', new_tab=False), + dict(id=2, name='Services', children=[ + dict(id=3, name='Jellyfin', url=':8096/', new_tab=True), + ]), + ] + # The tree was written to the config file. + assert read_config()['bookmarks'] == response.json['bookmarks'] + + # Rename and re-point a bookmark. + request, response = await async_client.put('/api/bookmarks/3', json=dict(name='Movies', url='http://:8096/web')) + assert response.status == 200 + assert response.json['bookmark'] == dict(id=3, name='Movies', url='http://:8096/web', new_tab=True) + + # A directory cannot take a URL. + request, response = await async_client.put('/api/bookmarks/2', json=dict(url='/videos')) + assert response.status == 400 + + # Move the top-level bookmark into the directory, at the front. + request, response = await async_client.post('/api/bookmarks/1/move', json=dict(parent_id=2, position=0)) + assert response.status == 200 + request, response = await async_client.get('/api/bookmarks') + assert [i['id'] for i in response.json['bookmarks']] == [2] + assert [i['id'] for i in response.json['bookmarks'][0]['children']] == [1, 3] + + # Move it back to the top level, at the end. + request, response = await async_client.post('/api/bookmarks/1/move', json=dict(parent_id=None)) + assert response.status == 200 + request, response = await async_client.get('/api/bookmarks') + assert [i['id'] for i in response.json['bookmarks']] == [2, 1] + + # Deleting a directory deletes its contents. + request, response = await async_client.delete('/api/bookmarks/2') + assert response.status == 204 + request, response = await async_client.get('/api/bookmarks') + assert response.json['bookmarks'] == [dict(id=1, name='Videos', url='/videos', new_tab=False)] + request, response = await async_client.post('/api/bookmarks', json=dict(name='Docs', url='/docs')) + assert response.json['bookmark']['id'] == 2 + assert read_config()['bookmarks'] == [ + dict(id=1, name='Videos', url='/videos', new_tab=False), + dict(id=2, name='Docs', url='/docs', new_tab=False), + ] + + # Unknown nodes are 400s, not crashes. + request, response = await async_client.delete('/api/bookmarks/999') + assert response.status == 400 + request, response = await async_client.put('/api/bookmarks/999', json=dict(name='x')) + assert response.status == 400 + + +async def test_bookmarks_validation(async_client, test_directory): + """Names are required and URLs must be a path, a port, or an absolute URL.""" + rejected = ( + '', 'videos', 'ftp://example.com', 'javascript:alert(1)', 'data:text/html,hi', '8096/', + # Paths that a browser takes off this host: network paths, backslash tricks, and + # control characters a URL parser strips before deciding. + '//evil.com', '///evil.com', '/\\evil.com', '/\\@evil.com', '/\t/evil.com', '/\n/evil.com', + # Port forms where the host becomes credentials, or where the port is not a port. + ':8096@evil.com', 'http://:8096@evil.com', ':8096\t@evil.com', ':8096/x@evil.com', ':0', ':70000', + ':8096\\evil.com', 'ftp://:8096/', + # An absolute URL with no host, or with a scheme other than http(s). + 'http://', 'https:///x', 'file:///etc/passwd', + ) + for url in rejected: + request, response = await async_client.post('/api/bookmarks', json=dict(name='x', url=url)) + assert response.status == 400, url + for url in ('/videos', '/videos?x=1#y', ':8096/', ':8096', ':8096?x=1', 'http://:8096/web', 'HTTP://:80/', + 'https://example.com', 'HTTP://EXAMPLE.COM', 'https://user@example.com/x'): + request, response = await async_client.post('/api/bookmarks', json=dict(name='x', url=url)) + assert response.status == 201, url + + request, response = await async_client.post('/api/bookmarks', json=dict(name=' ', url='/videos')) + assert response.status == 400 + request, response = await async_client.post('/api/bookmarks/directory', json=dict(name='')) + assert response.status == 400 + + # Whitespace is trimmed. + request, response = await async_client.post('/api/bookmarks', json=dict(name=' Map ', url=' /map ')) + assert response.json['bookmark'] == dict(id=11, name='Map', url='/map', new_tab=False) + + +async def test_bookmarks_move_guards(async_client, test_directory): + """A directory cannot be moved into itself or a descendant, and only directories hold children.""" + config = get_bookmarks_config() + outer = config.add_directory('Outer') + inner = config.add_directory('Inner', parent_id=outer['id']) + leaf = config.add_bookmark('Leaf', '/files', parent_id=inner['id']) + + request, response = await async_client.post(f'/api/bookmarks/{outer["id"]}/move', json=dict(parent_id=outer['id'])) + assert response.status == 400 + request, response = await async_client.post(f'/api/bookmarks/{outer["id"]}/move', json=dict(parent_id=inner['id'])) + assert response.status == 400 + # A bookmark is not a directory. + request, response = await async_client.post(f'/api/bookmarks/{inner["id"]}/move', json=dict(parent_id=leaf['id'])) + assert response.status == 400 + request, response = await async_client.post('/api/bookmarks', json=dict(name='x', url='/x', parent_id=leaf['id'])) + assert response.status == 400 + + # The failed moves left the tree untouched. + assert [i['id'] for i, _, _ in __import__('wrolpi.bookmarks', fromlist=['walk']).walk(config.bookmarks)] == \ + [outer['id'], inner['id'], leaf['id']] + + +def test_bookmarks_config_import(test_directory, test_wrolpi_config): + """A hand-edited bookmarks.yaml is imported as-is, including nested directories.""" + config_file = test_directory / 'config/bookmarks.yaml' + config_file.parent.mkdir(exist_ok=True) + config_file.write_text(yaml.dump(dict(version=0, bookmarks=[ + dict(id=1, name='Radio', children=[ + dict(id=2, name='OpenWebRX', url=':8073/', new_tab=True), + ]), + ]))) + + config = get_bookmarks_config() + config.initialize() + config.import_config() + assert config.successful_import is True + assert config.bookmarks[0]['children'][0]['name'] == 'OpenWebRX' + + # Later additions continue the id sequence past the hand-written ids. + node = config.add_bookmark('Docs', '/docs') + assert node['id'] == 3 + + +@pytest.mark.parametrize('bookmarks', [ + # A URL the bookmarks API would refuse. + [dict(id=1, name='x', url='javascript:alert(1)')], + [dict(id=1, name='x', url='//evil.com')], + [dict(id=1, name='x', url=':8096@evil.com')], + # Shape problems: duplicate ids, a missing id, a node that is both, a node that is neither. + [dict(id=1, name='x', url='/a'), dict(id=1, name='y', url='/b')], + [dict(name='x', url='/a')], + [dict(id=1, name='x', url='/a', children=[])], + [dict(id=1, name='x')], + [dict(id=1, name='x', children='nope')], + [dict(id=1, name='', url='/a')], + [dict(id=1, name='x', url='/a', new_tab='yes')], + ['not a node'], + 'not a list', +]) +def test_bookmarks_config_import_rejects_bad_tree(test_directory, test_wrolpi_config, bookmarks): + """A hand-edited file that the API would refuse is not imported, and is not overwritten.""" + config_file = test_directory / 'config/bookmarks.yaml' + config_file.parent.mkdir(exist_ok=True) + original = yaml.dump(dict(version=0, bookmarks=bookmarks)) + config_file.write_text(original) + + config = get_bookmarks_config() + with pytest.raises(InvalidConfig): + config.initialize() + with pytest.raises(InvalidConfig): + config.import_config() + assert config.successful_import is False + assert config.bookmarks == [] + # The in-memory tree is empty, and the API refuses to write it over the user's file. + with pytest.raises(RuntimeError): + config.save() + assert config_file.read_text() == original + + +def test_bookmarks_config_import_rejects_cycle(test_directory, test_wrolpi_config): + """A YAML anchor can make a directory contain itself; walking that would never end.""" + config_file = test_directory / 'config/bookmarks.yaml' + config_file.parent.mkdir(exist_ok=True) + config_file.write_text('version: 0\nbookmarks: &root\n- id: 1\n name: Loop\n children: *root\n') + + config = get_bookmarks_config() + with pytest.raises(InvalidConfig): + config.import_config() + assert config.successful_import is False + + +async def test_bookmarks_config_api_rejects_bad_tree(async_client, test_directory): + """The generic config API applies the same rules as the bookmarks API.""" + request, response = await async_client.post('/api/bookmarks', json=dict(name='ok', url='/videos')) + assert response.status == 201 + + bad = dict(version=1, bookmarks=[dict(id=1, name='x', url='javascript:alert(1)')]) + request, response = await async_client.post('/api/config?file_name=bookmarks.yaml', json=dict(config=bad)) + assert response.status == 400, response.json + request, response = await async_client.get('/api/bookmarks') + assert response.json['bookmarks'] == [dict(id=1, name='ok', url='/videos', new_tab=False)] + + good = dict(version=100, bookmarks=[dict(id=5, name='Docs', url='/docs', new_tab=True)]) + request, response = await async_client.post('/api/config?file_name=bookmarks.yaml', json=dict(config=good)) + assert response.status == 204, response.json + request, response = await async_client.get('/api/bookmarks') + assert response.json['bookmarks'] == good['bookmarks'] + assert read_config()['bookmarks'] == good['bookmarks'] From 5dda94492e98cd7932c25517aafac080eb0da96a Mon Sep 17 00:00:00 2001 From: lrnselfreliance <33263708+lrnselfreliance@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:59:51 -0600 Subject: [PATCH 035/181] videos: hide the transcoder's temporary output from the file refresh (#731) ffmpeg wrote `.transcode.` next to the source. A refresh that overlapped the transcode discovered that file, probed the half-written output (errors in the log), and created a FileGroup and Video for a path the final rename removed moments later. The temporary file is now `..transcode.`: every discovery path already skips hidden files, and staying in the same directory keeps the rename over the final path atomic. `transcode_tmp_path` is the single place the name is built. Co-authored-by: Claude Fable 5.1 --- modules/videos/test/test_transcode_job.py | 27 ++++++++++++++++++++++- modules/videos/transcode.py | 11 ++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/modules/videos/test/test_transcode_job.py b/modules/videos/test/test_transcode_job.py index d773f9a5..2d5f7bed 100644 --- a/modules/videos/test/test_transcode_job.py +++ b/modules/videos/test/test_transcode_job.py @@ -7,7 +7,7 @@ from unittest import mock import pytest from modules.videos.models import Video -from modules.videos.transcode import make_ffmpeg_progress_parser, transcode_video_job +from modules.videos.transcode import make_ffmpeg_progress_parser, transcode_video_job, transcode_tmp_path from wrolpi import jobs from wrolpi.cmd import CommandResult from wrolpi.files.models import FileGroup @@ -293,3 +293,28 @@ async def test_video_transcode_api_fragmented(async_client, test_session, video_ request, response = await async_client.post(f'/api/videos/{video.file_group_id}/transcode', content=json.dumps({'container': 'mkv', 'fragmented': True})) assert response.status == 400 + + +@pytest.mark.asyncio +async def test_transcode_temp_file_is_not_discovered_by_refresh(test_session, test_directory, async_client, + video_factory, refresh_files): + """A refresh that runs while ffmpeg is still writing the transcoder's output must not model it. + + The temporary file is renamed over the final path moments later; a FileGroup for it would point + at a file that no longer exists, and probing a half-written file logs errors. + """ + video_path = test_directory / 'videos/NO CHANNEL/movie.mp4' + video_path.parent.mkdir(parents=True, exist_ok=True) + video = video_factory(with_video_file=video_path) + test_session.commit() + + tmp_path = transcode_tmp_path(video_path, 'mp4') + shutil.copy(PROJECT_DIR / 'test/big_buck_bunny_720p_1mb.mp4', tmp_path) + assert tmp_path.is_file() + + await refresh_files() + + primary_paths = {str(i.primary_path) for i in test_session.query(FileGroup)} + assert str(video_path) in primary_paths + assert str(tmp_path) not in primary_paths, 'the transcoder temporary file was indexed' + assert not any(str(tmp_path) in str(p) for p in primary_paths) diff --git a/modules/videos/transcode.py b/modules/videos/transcode.py index 701c2440..3a9a970d 100644 --- a/modules/videos/transcode.py +++ b/modules/videos/transcode.py @@ -149,6 +149,15 @@ async def _acquire_transcode_lock(): return lock +def transcode_tmp_path(video_path: pathlib.Path, container: str) -> pathlib.Path: + """Where ffmpeg writes its output before it is renamed over the final path. + + Hidden (dot-prefixed) in the video's own directory: a refresh skips hidden files, so a refresh that + overlaps the transcode neither probes the half-written output nor creates a FileGroup for a file + that the rename removes moments later; and same-directory keeps the final rename atomic.""" + return video_path.with_name(f'.{video_path.stem}.transcode.{container}') + + async def transcode_video_file(video_path: pathlib.Path, target_vcodec: Optional[str] = None, target_acodec: Optional[str] = None, @@ -363,7 +372,7 @@ async def _transcode_video_file(video_path: pathlib.Path, target_vcodec: Optiona mux_args = () final_path = video_path.with_suffix(f'.{container}') - tmp_path = video_path.with_suffix(f'.transcode.{container}') + tmp_path = transcode_tmp_path(video_path, container) cmd = (FFMPEG_BIN, '-y', '-i', video_path, *map_args, From ae745f1a18d681f54df9d9f1bea78eef2dee99ba Mon Sep 17 00:00:00 2001 From: lrnselfreliance <33263708+lrnselfreliance@users.noreply.github.com> Date: Sat, 26 Sep 2026 15:06:43 -0600 Subject: [PATCH 036/181] app: search dropdown offers "Channels: No results" only after a search (#730) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useSuggestions started with channels: [] and reset to that on every keystroke, and the normalizer treats an empty list as "the server found none". So the global search dropdown showed a "Channels: No results" row on focus before anything was typed, and on every keystroke through the debounce and the request. Because that row counts as a result, the SearchBox's own "Searching…" state never appeared. The channel, domain, author and subject groups now start as null, meaning not fetched; an empty list only ever comes from the server. The count summary tolerates the null. Tests render the hook and check no Channels group exists before a search or while the request is pending, and that the "No results" row and real rows still appear after the response. Co-authored-by: Claude Fable 5.1 --- app/src/components/Search.js | 14 +-- app/src/components/SearchSuggestions.test.js | 105 +++++++++++++++++++ 2 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 app/src/components/SearchSuggestions.test.js diff --git a/app/src/components/Search.js b/app/src/components/Search.js index 96abca9a..fde58c6f 100644 --- a/app/src/components/Search.js +++ b/app/src/components/Search.js @@ -131,12 +131,14 @@ export const useSearch = (defaultLimit = 48, totalPages = 0, emptySearch = false } export function useSuggestions(searchStr, tagNames, filter, anyTag) { + // The general groups are null until the suggestion request returns: an empty list means the + // server found none, and the dropdown may say "No results" only for that. const defaultSuggestions = { fileGroups: [], - channels: [], - domains: [], - authors: [], - subjects: [], + channels: null, + domains: null, + authors: null, + subjects: null, zimsEstimates: [], } const [suggestions, setSuggestions] = React.useState(defaultSuggestions); @@ -401,8 +403,8 @@ export function useSearchSuggestions(defaultSearchStr, defaultTagNames, anyTag) zims: zimSum, otherSum: otherSum, mapPlaces: newSuggestions.mapPlaces || 0, - channels: newSuggestions.channels.length, - domains: newSuggestions.domains.length, + channels: newSuggestions.channels?.length, + domains: newSuggestions.domains?.length, authors: newSuggestions.authors?.length, subjects: newSuggestions.subjects?.length, tags: matchingTags?.length, diff --git a/app/src/components/SearchSuggestions.test.js b/app/src/components/SearchSuggestions.test.js new file mode 100644 index 00000000..798f459d --- /dev/null +++ b/app/src/components/SearchSuggestions.test.js @@ -0,0 +1,105 @@ +import React from 'react'; +import {act, render, screen} from '../test-utils'; +import {useSearchSuggestions} from './Search'; +import {TagsContext} from '../Tags'; +import {tagsContextFixture} from '../test-fixtures'; +import { + searchEstimateFiles, searchEstimateMap, searchEstimateOthers, searchEstimateZims, searchSuggestions, +} from '../api'; + +// The global search dropdown's groups are built from useSuggestions. A "Channels: No results" row +// may only appear once the suggestion request has come back empty, never before the user has +// typed, and never while the request is pending. + +jest.mock('../api', () => ({ + ...jest.requireActual('../api'), + searchSuggestions: jest.fn(), + searchEstimateFiles: jest.fn(), + searchEstimateMap: jest.fn(), + searchEstimateOthers: jest.fn(), + searchEstimateZims: jest.fn(), +})); + +const DEBOUNCE = 500; +const pending = () => new Promise(() => { +}); + +let hook; + +function Probe() { + hook = useSearchSuggestions('', [], false); + return
{JSON.stringify(hook.suggestionsResults)}
; +} + +const results = () => JSON.parse(screen.getByTestId('results').textContent); + +const renderProbe = () => render(, { + contexts: [[TagsContext, tagsContextFixture({fuzzyMatchTagsByName: () => []})]], +}); + +beforeEach(() => { + jest.useFakeTimers(); + searchEstimateFiles.mockReturnValue(pending()); + searchEstimateMap.mockReturnValue(pending()); + searchEstimateOthers.mockReturnValue(pending()); + searchEstimateZims.mockReturnValue(pending()); +}); + +afterEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); +}); + +test('no Channels group before anything has been searched', () => { + searchSuggestions.mockReturnValue(pending()); + renderProbe(); + expect(results().channels).toBeUndefined(); +}); + +test('no Channels group while the suggestion request is pending', () => { + searchSuggestions.mockReturnValue(pending()); + renderProbe(); + act(() => { + hook.setSearchStr('foo'); + }); + expect(results().channels).toBeUndefined(); + + act(() => { + jest.advanceTimersByTime(DEBOUNCE); + }); + expect(searchSuggestions).toHaveBeenCalledWith('foo'); + expect(results().channels).toBeUndefined(); + expect(hook.loading).toBe(true); +}); + +test('a Channels "No results" row once the server said there are none', async () => { + let resolve; + searchSuggestions.mockReturnValue(new Promise(res => resolve = res)); + renderProbe(); + act(() => { + hook.setSearchStr('foo'); + }); + act(() => { + jest.advanceTimersByTime(DEBOUNCE); + }); + await act(async () => { + resolve({channels: [], domains: [], authors: [], subjects: []}); + }); + expect(results().channels.results).toEqual([{title: 'No results'}]); +}); + +test('Channels rows once the server found some', async () => { + let resolve; + searchSuggestions.mockReturnValue(new Promise(res => resolve = res)); + renderProbe(); + act(() => { + hook.setSearchStr('foo'); + }); + act(() => { + jest.advanceTimersByTime(DEBOUNCE); + }); + await act(async () => { + resolve({channels: [{id: 3, name: 'Food'}], domains: [], authors: [], subjects: []}); + }); + expect(results().channels.results.map(i => i.title)).toEqual(['Food']); +}); From 6e251a009a36f0eceda60213f9540d0b44191a9b Mon Sep 17 00:00:00 2001 From: lrnselfreliance <33263708+lrnselfreliance@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:55:42 -0600 Subject: [PATCH 037/181] app: map pins and subscriptions tables distinguish pending from empty (#732) * app: map pins and subscriptions tables distinguish pending from empty Both lists were initialised to [], which SortableTable renders as "loaded and empty". The Pins tab therefore showed "No pins. Right-click on the map to add one." until the pins arrived, and the Manage tab's subscriptions table showed "No results" until the catalog did. A non-OK response (the api helpers return undefined) or a thrown request left the same messages up permanently. Invariants now enforced and tested: * null = pending, undefined = fetch failed, [] = confirmed empty, so SortableTable shows its placeholder while pending and each empty message appears only once the server confirmed the list is empty. * A failed fetch renders an error in place of the table. * Only the newest request for each list may write state, since edits, deletes, subscribes and unsubscribes refetch while the mount request may still be in flight. Co-Authored-By: Claude Fable 5.1 * app: the regions preview waits for the catalog "View All Regions" stayed enabled while the catalog was pending or failed, and the modal received a fresh `catalog || []` on every render. Its map effect depends on that array, and ManageMap re-renders on every status poll, so an open preview tore its WebGL map down and rebuilt it every three seconds, and a failed catalog was previewed as zero regions over the error message. The button is disabled until the catalog is an array, and the modal is mounted only then, so it receives the state array itself. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 --- app/src/components/Map.js | 69 ++++++++++--- app/src/components/MapTables.test.js | 148 +++++++++++++++++++++++++++ 2 files changed, 203 insertions(+), 14 deletions(-) create mode 100644 app/src/components/MapTables.test.js diff --git a/app/src/components/Map.js b/app/src/components/Map.js index 6e4bd932..3c5ed0d5 100644 --- a/app/src/components/Map.js +++ b/app/src/components/Map.js @@ -1,4 +1,4 @@ -import React, {useEffect, useState} from "react"; +import React, {useEffect, useRef, useState} from "react"; import { APIButton, ErrorMessage, @@ -284,11 +284,15 @@ function MapCatalogRow({item, subscribedRegions, fetchData}) { function ManageMap() { const [files, setFiles] = useState(null); - const [catalog, setCatalog] = useState([]); + // null = pending, undefined = fetch failed, [] = no regions in the catalog. + const [catalog, setCatalog] = useState(null); const [subscriptions, setSubscriptions] = useState([]); const [allRegionsOpen, setAllRegionsOpen] = useState(false); const {status} = React.useContext(StatusContext); const searchBuilding = status?.flags?.map_search_building; + // Only the newest subscriptions request may write state: a subscribe or unsubscribe refetches + // while the mount request may still be in flight. + const subscriptionsGen = useRef(0); const fetchFiles = async () => { try { @@ -303,14 +307,24 @@ function ManageMap() { }; const fetchSubscriptionData = async () => { + const gen = ++subscriptionsGen.current; try { const data = await fetchMapSubscriptions(); + if (gen !== subscriptionsGen.current) { + return; + } if (data) { setCatalog(data.catalog || []); setSubscriptions(data.subscriptions || []); + } else { + // The api helper already toasted the non-OK response. + setCatalog(undefined); } } catch (e) { console.error(e); + if (gen === subscriptionsGen.current) { + setCatalog(undefined); + } } }; @@ -397,6 +411,8 @@ function ManageMap() { ; } + const catalogLoaded = Array.isArray(catalog); + /* * A fragment, not a `PageContainer`. `MapRoute` already provides the page chrome -- a * `wrolpi-stack` wrapper with its own top margin -- so a second one put this page a further @@ -419,18 +435,26 @@ function ManageMap() {
Map Subscriptions
- + {/* A pending or failed catalog is not "zero regions": the preview waits for one. The + modal is mounted only then, so it receives the state array itself and its map is + not rebuilt by a fresh `[]` on every status tick. */} +
- setAllRegionsOpen(false)}/> + {catalogLoaded && setAllRegionsOpen(false)}/>} - + {catalog === undefined + ? Could not fetch map subscriptions + : }
How to get map files
@@ -497,17 +521,29 @@ function PinEditRow({pin, onSave, onCancel}) { } function MapPins() { - const [pins, setPins] = useState([]); + // null = pending, undefined = fetch failed, [] = no pins. The empty row invites the user to + // add one, so it may only appear for a confirmed empty list. + const [pins, setPins] = useState(null); const [filter, setFilter] = useState(''); const [editingId, setEditingId] = useState(null); const navigate = useNavigate(); + // Only the newest pins request may write state: an edit or delete refetches while the mount + // request may still be in flight. + const pinsGen = useRef(0); const fetchPins = async () => { + const gen = ++pinsGen.current; try { const data = await getMapPins(); - if (data) setPins(data.pins || []); + if (gen === pinsGen.current) { + // The api helper returns undefined on a non-OK response. + setPins(data ? data.pins || [] : undefined); + } } catch (e) { console.error(e); + if (gen === pinsGen.current) { + setPins(undefined); + } } }; @@ -627,8 +663,13 @@ function MapPins() { No pins. Right-click on the map to add one. ; + if (pins === undefined) { + return Could not fetch map pins; + } + const lowerFilter = filter.toLowerCase(); - const filteredPins = lowerFilter + // Pending (null) passes through so SortableTable shows its placeholder. + const filteredPins = lowerFilter && pins ? pins.filter(p => (p.label || '').toLowerCase().includes(lowerFilter) || `${p.lat.toFixed(4)}, ${p.lon.toFixed(4)}`.includes(lowerFilter) diff --git a/app/src/components/MapTables.test.js b/app/src/components/MapTables.test.js new file mode 100644 index 00000000..efbf0242 --- /dev/null +++ b/app/src/components/MapTables.test.js @@ -0,0 +1,148 @@ +import React from 'react'; +import {act, screen} from '@testing-library/react'; +import {Route, Routes} from 'react-router'; +import {renderWithProviders as render} from '../test-utils'; +import {MapRoute} from './Map'; + +// What the map's Pins and Manage tabs show while their lists are still loading, when the +// fetch failed, and when the server confirmed the list is empty. The empty messages invite +// the user to act ("Right-click on the map to add one"), so they may only appear for a +// confirmed empty list. + +jest.mock('maplibre-gl', () => ({ + __esModule: true, + default: {Map: class { + }, addProtocol: jest.fn(), Marker: class { + }}, +})); +jest.mock('pmtiles', () => ({ + __esModule: true, Protocol: class { + tile = jest.fn(); + }, +})); +jest.mock('protomaps-themes-base', () => ({__esModule: true, default: () => []})); +jest.mock('./MapViewer', () => ({__esModule: true, default: () => null})); + +jest.mock('../api', () => ({ + ...jest.requireActual('../api'), + getMapFiles: jest.fn(), + fetchMapSubscriptions: jest.fn(), + getMapPins: jest.fn(), +})); + +const api = require('../api'); + +const pending = () => new Promise(() => { +}); +const NO_PINS = /No pins\. Right-click/; +const NO_RESULTS = /^No results$/; + +// The pins page renders one table per breakpoint through `Media`, so the real media provider is +// needed; the queries below tolerate either variant being in the tree. +const renderAt = (route) => render( + }/>, + {route, withMedia: true}, +); + +beforeEach(() => { + jest.clearAllMocks(); + // The media provider reads matchMedia; the global stub's implementation is reset between + // tests, so give it one here. Only the computer breakpoint (1024px) matches, so the + // provider reports a desktop viewport and the pins page renders its desktop table. + window.matchMedia = jest.fn().mockImplementation(query => ({ + matches: query.includes('1024'), media: query, onchange: null, + addListener: jest.fn(), removeListener: jest.fn(), + addEventListener: jest.fn(), removeEventListener: jest.fn(), dispatchEvent: jest.fn(), + })); + api.getMapFiles.mockResolvedValue({files: []}); + api.fetchMapSubscriptions.mockResolvedValue({catalog: [], subscriptions: []}); + api.getMapPins.mockResolvedValue({pins: []}); +}); + +describe('the Pins tab', () => { + test('pending: shows a placeholder, not "No pins"', () => { + api.getMapPins.mockReturnValue(pending()); + const {container} = renderAt('/map/pins'); + expect(container.querySelector('.wrolpi-placeholder')).toBeInTheDocument(); + expect(screen.queryAllByText(NO_PINS)).toHaveLength(0); + }); + + test('failed: shows an error, not "No pins"', async () => { + // The api helper returns undefined on a non-OK response. + api.getMapPins.mockResolvedValue(undefined); + renderAt('/map/pins'); + await act(async () => { + }); + expect(screen.getByText(/Could not fetch map pins/)).toBeInTheDocument(); + expect(screen.queryAllByText(NO_PINS)).toHaveLength(0); + }); + + test('empty: shows "No pins"', async () => { + renderAt('/map/pins'); + await act(async () => { + }); + expect(screen.getAllByText(NO_PINS).length).toBeGreaterThan(0); + }); + + test('loaded: lists the pins', async () => { + api.getMapPins.mockResolvedValue({pins: [{id: 1, lat: 40.1, lon: -111.2, label: 'Camp', color: '#f00'}]}); + renderAt('/map/pins'); + await act(async () => { + }); + expect(screen.getAllByText('Camp').length).toBeGreaterThan(0); + expect(screen.queryAllByText(NO_PINS)).toHaveLength(0); + }); +}); + +describe('the Manage tab subscriptions table', () => { + test('pending: shows a placeholder, not "No results"', async () => { + api.fetchMapSubscriptions.mockReturnValue(pending()); + const {container} = renderAt('/map/manage'); + await act(async () => { + }); + // The files table has loaded (empty); the subscriptions table is still pending. + expect(container.querySelector('.wrolpi-placeholder')).toBeInTheDocument(); + expect(screen.queryByText(NO_RESULTS)).not.toBeInTheDocument(); + }); + + test('failed: shows an error, not "No results"', async () => { + api.fetchMapSubscriptions.mockResolvedValue(undefined); + renderAt('/map/manage'); + await act(async () => { + }); + expect(screen.getByText(/Could not fetch map subscriptions/)).toBeInTheDocument(); + expect(screen.queryByText(NO_RESULTS)).not.toBeInTheDocument(); + }); + + test('empty: shows "No results"', async () => { + renderAt('/map/manage'); + await act(async () => { + }); + expect(screen.getByText(NO_RESULTS)).toBeInTheDocument(); + }); + + // The "View All Regions" preview draws the catalog; a pending or failed catalog must not be + // previewed as zero regions. + test('pending: the regions preview is disabled', async () => { + api.fetchMapSubscriptions.mockReturnValue(pending()); + renderAt('/map/manage'); + await act(async () => { + }); + expect(screen.getByRole('button', {name: 'View All Regions'})).toBeDisabled(); + }); + + test('failed: the regions preview is disabled', async () => { + api.fetchMapSubscriptions.mockResolvedValue(undefined); + renderAt('/map/manage'); + await act(async () => { + }); + expect(screen.getByRole('button', {name: 'View All Regions'})).toBeDisabled(); + }); + + test('loaded: the regions preview is enabled', async () => { + renderAt('/map/manage'); + await act(async () => { + }); + expect(screen.getByRole('button', {name: 'View All Regions'})).toBeEnabled(); + }); +}); From 306386e80193a29cde8c73b4e9b6fcb1e633a500 Mon Sep 17 00:00:00 2001 From: lrnselfreliance <33263708+lrnselfreliance@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:05:02 -0600 Subject: [PATCH 038/181] app: Flasher saved configs and Statistics distinguish pending from empty (#733) The Flasher's saved configurations started as [] with no loading flag, and the Saved Firmwares tab is the default tab, so every load painted "No saved configurations yet." until the fetch returned. The api helper returned [] on a non-OK response too, so a failed fetch read as an empty list. useStatistics used useState() with no initial value, so the Statistics page's first render saw undefined and flashed "Failed to fetch statistics" for one frame before the effect set {} and the spinner appeared. Invariants now enforced and tested: * null = pending, undefined = fetch failed, [] / object = loaded. The api helper returns undefined (and toasts) on a non-OK response. * The Saved Firmwares tab shows a placeholder while pending, an error on failure, and "No saved configurations yet." only once the server confirmed there are none. Only the newest configs request may write state, since save and delete refetch. * The Statistics page shows its loader while pending and its error only for a failed fetch. Co-authored-by: Claude Fable 5.1 --- app/src/api.js | 4 +- app/src/components/Apps.js | 5 ++- app/src/components/Flasher.js | 31 +++++++++++--- app/src/components/Flasher.test.js | 25 ++++++++++++ app/src/components/StatisticsPage.test.js | 49 ++++++++++++++++++++++ app/src/hooks/customHooks.js | 8 ++-- app/src/hooks/customHooks.test.js | 50 ++++++++++++++++++++++- 7 files changed, 158 insertions(+), 14 deletions(-) create mode 100644 app/src/components/StatisticsPage.test.js diff --git a/app/src/api.js b/app/src/api.js index f80d9ced..57f5d255 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -1361,7 +1361,9 @@ export async function getFlasherConfigs() { const data = await response.json(); return data['configurations'] || []; } - return []; + // Undefined, not [], so the page can tell a failed fetch from an empty list. + const message = await getErrorMessage(response, 'Could not get saved firmware configurations'); + toast({type: 'error', title: 'Flasher Error', description: message, time: 5000}); } export async function saveFlasherConfig(name, files, eraseAll) { diff --git a/app/src/components/Apps.js b/app/src/components/Apps.js index 3360afac..9e547f3c 100644 --- a/app/src/components/Apps.js +++ b/app/src/components/Apps.js @@ -14,7 +14,8 @@ import {useStatistics} from "../hooks/customHooks"; import {CalculatorsPage} from "./Calculators"; import {BookmarksPage} from "./Bookmarks"; -function StatisticsPage() { +// `statistics` follows useStatistics: null = pending, undefined = fetch failed. +export function StatisticsPage() { useTitle('Statistics'); const {statistics} = useStatistics(); @@ -26,7 +27,7 @@ function StatisticsPage() { } - if (statistics['global_statistics']) { + if (statistics && statistics['global_statistics']) { let {global_statistics, file_statistics} = statistics; const { archive_count, diff --git a/app/src/components/Flasher.js b/app/src/components/Flasher.js index 9e6f1111..304c4374 100644 --- a/app/src/components/Flasher.js +++ b/app/src/components/Flasher.js @@ -3,7 +3,7 @@ import {Route, Routes} from "react-router"; import {ESPLoader, Transport} from "esptool-js"; import {useDropzone} from "react-dropzone"; import _ from "lodash"; -import {encodeMediaPath, humanFileSize, PageContainer, useTitle} from "./Common"; +import {encodeMediaPath, ErrorMessage, humanFileSize, PageContainer, useTitle} from "./Common"; import { Button, Checkbox, @@ -17,6 +17,7 @@ import { Message, Modal, Panel, + Placeholder, Progress, Select, Stack, @@ -297,8 +298,12 @@ export function FlasherPage() { // Which firmware-source tab is active (controlled so a file drop can switch to "Add from computer"). const [activeTab, setActiveTab] = useState(TAB_SAVED); - // Saved firmware configurations (flasher.yaml). - const [savedConfigs, setSavedConfigs] = useState([]); + // Saved firmware configurations (flasher.yaml). null = pending, undefined = fetch failed, + // [] = none saved; the "No saved configurations yet." message is only for the last. + const [savedConfigs, setSavedConfigs] = useState(null); + // Only the newest configs request may write state: a save or delete refetches while the mount + // request may still be in flight. + const savedConfigsGen = useRef(0); const [saveModalOpen, setSaveModalOpen] = useState(false); const [saveName, setSaveName] = useState(''); @@ -424,7 +429,19 @@ export function FlasherPage() { // Saved firmware configurations. const fetchSavedConfigs = React.useCallback(async () => { - setSavedConfigs(await getFlasherConfigs()); + const gen = ++savedConfigsGen.current; + try { + const configs = await getFlasherConfigs(); + if (gen === savedConfigsGen.current) { + // The api helper returns undefined on a non-OK response. + setSavedConfigs(configs ?? undefined); + } + } catch (e) { + console.error(e); + if (gen === savedConfigsGen.current) { + setSavedConfigs(undefined); + } + } }, []); useEffect(() => { @@ -812,7 +829,9 @@ export function FlasherPage() { Configure the firmware files and offsets, then save them as a named set to re-flash later (e.g. a Meshtastic T-Deck: firmware at 0x0, littlefs at 0xc90000).

- {savedConfigs.length > 0 + {savedConfigs === null && } + {savedConfigs === undefined && Could not fetch saved configurations.} + {Array.isArray(savedConfigs) && (savedConfigs.length > 0 ? {savedConfigs.map((configuration, index) =>
handleDeleteConfig(configuration.name)}/>
)}
- :

No saved configurations yet.

} + :

No saved configurations yet.

)} ; return
diff --git a/app/src/components/Flasher.test.js b/app/src/components/Flasher.test.js index 34ca2301..2873c8e7 100644 --- a/app/src/components/Flasher.test.js +++ b/app/src/components/Flasher.test.js @@ -265,6 +265,31 @@ describe('FlasherPage', () => { } }); + // The Saved Firmwares tab is the default tab. "No saved configurations yet." may only appear once the + // server confirmed there are none; a pending or failed fetch must not read as an empty list. + it('shows a placeholder, not "no saved configurations", while the configs load', () => { + Object.defineProperty(global.navigator, 'serial', {value: {}, configurable: true}); + getFlasherConfigs.mockReturnValue(new Promise(() => { + })); + const {container} = render(); + expect(container.querySelector('.wrolpi-placeholder')).toBeInTheDocument(); + expect(screen.queryAllByText('No saved configurations yet.')).toHaveLength(0); + }); + + it('shows an error, not "no saved configurations", when the configs fetch failed', async () => { + Object.defineProperty(global.navigator, 'serial', {value: {}, configurable: true}); + getFlasherConfigs.mockResolvedValue(undefined); + render(); + expect((await screen.findAllByText(/Could not fetch saved configurations/)).length).toBeGreaterThan(0); + expect(screen.queryAllByText('No saved configurations yet.')).toHaveLength(0); + }); + + it('shows "no saved configurations" once the server confirmed there are none', async () => { + Object.defineProperty(global.navigator, 'serial', {value: {}, configurable: true}); + render(); + expect((await screen.findAllByText('No saved configurations yet.')).length).toBeGreaterThan(0); + }); + // PageContainer renders both the mobile and desktop breakpoints (fresnel Media), so each label appears twice. it('warns when Web Serial is unavailable', () => { delete global.navigator.serial; diff --git a/app/src/components/StatisticsPage.test.js b/app/src/components/StatisticsPage.test.js new file mode 100644 index 00000000..8aaae49e --- /dev/null +++ b/app/src/components/StatisticsPage.test.js @@ -0,0 +1,49 @@ +import React from 'react'; +import {act, render, screen} from '../test-utils'; +import {StatisticsPage} from './Apps'; +import {getStatistics} from '../api'; + +// What the Statistics page shows while the numbers are still loading, when the fetch failed, and +// once they arrive. "Failed to fetch statistics" may only appear for a failed fetch. + +jest.mock('../api', () => ({ + ...jest.requireActual('../api'), + getStatistics: jest.fn(), +})); + +const FAILED = /Failed to fetch statistics/; +const STATS = { + global_statistics: {db_size: 1024}, + file_statistics: { + archive_count: 1, audio_count: 2, ebook_count: 3, image_count: 4, pdf_count: 5, total_count: 21, + video_count: 6, zip_count: 0, total_size: 4096, tagged_files: 7, tagged_zims: 0, tags_count: 2, + }, +}; + +beforeEach(() => getStatistics.mockReset()); + +test('pending: shows a loader, not the error', () => { + getStatistics.mockReturnValue(new Promise(() => { + })); + const {container} = render(); + expect(container.querySelector('.wrolpi-loading')).toBeInTheDocument(); + expect(screen.queryByText(FAILED)).not.toBeInTheDocument(); +}); + +test('failed: shows the error', async () => { + // The api helper toasts and returns undefined on a non-OK response. + getStatistics.mockResolvedValue(undefined); + render(); + await act(async () => { + }); + expect(screen.getByText(FAILED)).toBeInTheDocument(); +}); + +test('loaded: shows the numbers', async () => { + getStatistics.mockResolvedValue(STATS); + render(); + await act(async () => { + }); + expect(screen.getByText('All Files')).toBeInTheDocument(); + expect(screen.queryByText(FAILED)).not.toBeInTheDocument(); +}); diff --git a/app/src/hooks/customHooks.js b/app/src/hooks/customHooks.js index 8836aab2..94c1c03f 100644 --- a/app/src/hooks/customHooks.js +++ b/app/src/hooks/customHooks.js @@ -1697,12 +1697,15 @@ export const collectLocations = (inventories) => { }; export const useStatistics = () => { - const [statistics, setStatistics] = useState(); + // null = pending, undefined = fetch failed (the api helper returns undefined on a non-OK + // response), an object = loaded. Never undefined on the first render: the page shows its + // error for undefined. + const [statistics, setStatistics] = useState(null); const fetchFileStatistics = async () => { try { const s = await getStatistics(); - setStatistics(s); + setStatistics(s ?? undefined); } catch (e) { console.error(e); setStatistics(undefined); @@ -1710,7 +1713,6 @@ export const useStatistics = () => { } useEffect(() => { - setStatistics({}); fetchFileStatistics(); }, []); diff --git a/app/src/hooks/customHooks.test.js b/app/src/hooks/customHooks.test.js index 0df9eaab..987467af 100644 --- a/app/src/hooks/customHooks.test.js +++ b/app/src/hooks/customHooks.test.js @@ -1,8 +1,10 @@ import React from 'react'; import {act, renderHook} from '@testing-library/react'; import {render, screen} from '../test-utils'; -import {usePages, useDriveTemperature, useDriveHealth, useSearchChannels, useVideoExtras} from './customHooks'; -import {getVideoComments, getVideoDescription, searchChannels} from '../api'; +import { + usePages, useDriveTemperature, useDriveHealth, useSearchChannels, useStatistics, useVideoExtras, +} from './customHooks'; +import {getStatistics, getVideoComments, getVideoDescription, searchChannels} from '../api'; import {QueryContext, StatusContext} from '../contexts/contexts'; import {Paginator} from '../components/Common'; @@ -39,6 +41,7 @@ jest.mock('../api', () => ({ getVideoComments: jest.fn(), getVideoDescription: jest.fn(), searchChannels: jest.fn(), + getStatistics: jest.fn(), })); // Mock Media so Paginator renders both mobile + tablet variants synchronously. @@ -534,3 +537,46 @@ describe('useSearchChannels', () => { expect(result.current.loading).toBe(false); }); }); + +describe('useStatistics', () => { + // null = pending, undefined = fetch failed, an object = loaded. The Statistics page shows its + // error only for undefined, so the first render must not be undefined. + beforeEach(() => getStatistics.mockReset()); + + test('is pending (null) on the first render', () => { + getStatistics.mockReturnValue(new Promise(() => { + })); + const {result} = renderHook(() => useStatistics()); + expect(result.current.statistics).toBeNull(); + }); + + test('resolves to the statistics', async () => { + getStatistics.mockResolvedValue({global_statistics: {}, file_statistics: {}}); + const {result} = renderHook(() => useStatistics()); + await act(async () => { + }); + expect(result.current.statistics).toEqual({global_statistics: {}, file_statistics: {}}); + }); + + test('a non-OK response yields undefined', async () => { + getStatistics.mockResolvedValue(undefined); + const {result} = renderHook(() => useStatistics()); + await act(async () => { + }); + expect(result.current.statistics).toBeUndefined(); + }); + + test('a thrown request yields undefined', async () => { + getStatistics.mockRejectedValue(new Error('boom')); + const spy = jest.spyOn(console, 'error').mockImplementation(() => { + }); + try { + const {result} = renderHook(() => useStatistics()); + await act(async () => { + }); + expect(result.current.statistics).toBeUndefined(); + } finally { + spy.mockRestore(); + } + }); +}); From eead78007d0578c3bfbf2b4dea97a4884927b9cc Mon Sep 17 00:00:00 2001 From: lrnselfreliance <33263708+lrnselfreliance@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:25:46 -0600 Subject: [PATCH 039/181] app: status and settings contexts say whether their first poll landed (#734) Both contexts start as {} and nothing distinguished that from a server that reported nothing, so for up to one poll interval after a page load consumers rendered the negative case as fact: the Settings form appeared with blank values and an "undefined/config/wrolpi.yaml" path (the ready check `settings ? true : undefined` was always true), the Downloads toggle was clickable and read "Downloading Disabled", the upgrade panel said "up to date, vundefined", and the five hardware toggles on the Controller page claimed "not supported on this server". Invariants now enforced and tested: * StatusContext.loaded is true once a poll returned a real status; the API being down does not count. SettingsContext.loaded is true once settings were fetched, and `failed` when a fetch returned nothing before that; a failed refresh after a load keeps the last known values and `loaded`. * The Settings page shows its loader until loaded and its error only when failed. The upgrade panel shows a loader until status loaded. The Downloads toggle is disabled with a neutral label until then. * A subsystem toggle is disabled without claiming "unsupported" while status is unknown (undefined); "unsupported" (null) is reserved for a loaded status that lacks the field. A call in flight is unknown, not unsupported. The context fixtures declare loaded so existing specs render as before. Co-authored-by: Claude Fable 5.1 --- app/src/components/Common.js | 15 ++-- app/src/components/StatusLoaded.test.js | 69 ++++++++++++++++++ app/src/components/admin/Settings.js | 21 ++++-- app/src/contexts/contexts.js | 5 ++ app/src/hooks/customHooks.js | 51 ++++++++++--- app/src/hooks/customHooks.test.js | 93 +++++++++++++++++++++++- app/src/hooks/useSubsystemToggle.test.js | 2 +- app/src/test-fixtures.js | 4 + 8 files changed, 235 insertions(+), 25 deletions(-) create mode 100644 app/src/components/StatusLoaded.test.js diff --git a/app/src/components/Common.js b/app/src/components/Common.js index 9e93e039..3803a962 100644 --- a/app/src/components/Common.js +++ b/app/src/components/Common.js @@ -928,7 +928,10 @@ export function HelpHeader({ // why it cannot be used. `confirmStop` asks before switching off. function SubsystemToggle({label, on, onChange, unsupportedMessage, info = null, disabled = null, confirmStop = null}) { const [confirmOpen, setConfirmOpen] = React.useState(false); + // null = the status has loaded without this subsystem; undefined = the status has not loaded + // yet (or a call is in flight). Both disable the toggle; only the first claims unsupported. const unsupported = on === null; + const unknown = on === undefined; const handleChange = (checked) => { if (!checked && confirmStop) { @@ -961,11 +964,11 @@ function SubsystemToggle({label, on, onChange, unsupportedMessage, info = null, } - {popup && } + {popup && }
; } @@ -1065,9 +1068,10 @@ export function Toggle({label, checked, disabled, onChange, icon, popupContent = export function DisableDownloadsToggle() { const [pending, setPending] = React.useState(false); - const {status, fetchStatus} = React.useContext(StatusContext); + const {status, fetchStatus, loaded} = React.useContext(StatusContext); - const {downloads} = status ? status : {downloads: null}; + // Until the first status poll lands there is no downloads state to show, only a disabled toggle. + const downloads = loaded ? status?.downloads : null; const wrolModeEnabled = useWROLMode(); const setDownloads = async (enable) => { @@ -1082,8 +1086,9 @@ export function DisableDownloadsToggle() { } const on = downloads && downloads['disabled'] === false && downloads['stopped'] === false; + const label = downloads === null ? 'Downloading' : on === true ? 'Downloading Enabled' : 'Downloading Disabled'; return ({ + ...jest.requireActual('../api'), + getConfigs: jest.fn(() => new Promise(() => { + })), +})); + +const unloadedStatus = {status: {}, loaded: false}; + +describe('DisableDownloadsToggle before status has loaded', () => { + test('is disabled and does not claim downloading is disabled', () => { + render(, {status: unloadedStatus}); + expect(screen.getByTestId('toggle')).toBeDisabled(); + expect(screen.queryByText('Downloading Disabled')).not.toBeInTheDocument(); + }); + + test('once loaded, reads the downloads state', () => { + render(, {status: {status: {downloads: {disabled: true, stopped: false}}, loaded: true}}); + expect(screen.getByText('Downloading Disabled')).toBeInTheDocument(); + expect(screen.getByTestId('toggle')).toBeEnabled(); + }); +}); + +describe('HotspotToggle before status has loaded', () => { + test('is disabled without claiming the hotspot is unsupported', () => { + render(, {status: unloadedStatus}); + expect(screen.getByTestId('toggle')).toBeDisabled(); + expect(screen.queryByTestId('subsystem-popup')).not.toBeInTheDocument(); + }); + + test('once loaded without a hotspot field, is unsupported', () => { + render(, {status: {status: {}, loaded: true}}); + expect(screen.getByTestId('toggle')).toBeDisabled(); + expect(screen.getByTestId('subsystem-popup')).toBeInTheDocument(); + }); +}); + +describe('UpgradeSegment before status has loaded', () => { + test('does not claim the WROLPi is up to date', () => { + render(, {status: unloadedStatus}); + expect(screen.queryByText(/up to date/)).not.toBeInTheDocument(); + }); + + test('once loaded with no update, says so', () => { + render(, {status: {status: {update_available: false, version: '1.0'}, loaded: true}}); + expect(screen.getByText(/up to date/)).toBeInTheDocument(); + }); +}); + +describe('SettingsPage before settings have loaded', () => { + test('shows a loader, not a form over blank values', () => { + const {container} = render(, {settings: {settings: {}, loaded: false, failed: false}}); + expect(container.querySelector('.wrolpi-loading')).toBeInTheDocument(); + expect(screen.queryByText(/Any changes will be written/)).not.toBeInTheDocument(); + }); + + test('shows an error when the settings could not be fetched', () => { + render(, {settings: {settings: {}, loaded: false, failed: true}}); + expect(screen.getByText('Unable to fetch settings')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/admin/Settings.js b/app/src/components/admin/Settings.js index bceba925..be5e7ebe 100644 --- a/app/src/components/admin/Settings.js +++ b/app/src/components/admin/Settings.js @@ -101,8 +101,8 @@ export function ShutdownButton() { } -function UpgradeSegment() { - const {status, fetchStatus} = React.useContext(StatusContext); +export function UpgradeSegment() { + const {status, fetchStatus, loaded} = React.useContext(StatusContext); const dockerized = useDockerized(); const [upgrading, setUpgrading] = React.useState(false); const [checking, setChecking] = React.useState(false); @@ -144,6 +144,14 @@ function UpgradeSegment() {
; } + // Nothing is known until the first status poll lands; "up to date" is a claim about the server. + if (!loaded) { + return +
System Upgrade
+ +
; + } + // No update available if (!status?.update_available) { return @@ -325,7 +333,9 @@ export function SettingsPage() { const [isDevMode, setIsDevMode] = React.useState(false); // Get current settings from the API. - const {settings, saveSettings, fetchSettings} = React.useContext(SettingsContext); + const { + settings, saveSettings, fetchSettings, loaded: settingsLoaded, failed: settingsFailed, + } = React.useContext(SettingsContext); const {status} = React.useContext(StatusContext); // Used to track changes to the settings form between saves/loads. const [state, setState] = React.useState({}); @@ -463,7 +473,8 @@ export function SettingsPage() { React.useEffect(() => { console.debug('settings changed, replacing state...'); - setReady(settings ? true : undefined); + // true = show the form, false = still loading, undefined = could not fetch. + setReady(settingsLoaded ? true : settingsFailed ? undefined : false); setState({ // Only these settings can be changed on the SettingsPage. archive_destination: settings.archive_destination, @@ -490,7 +501,7 @@ export function SettingsPage() { playlists_destination: settings.playlists_destination, save_ffprobe_json: settings.save_ffprobe_json, }); - }, [JSON.stringify(settings)]); + }, [JSON.stringify(settings), settingsLoaded, settingsFailed]); const handleInputChange = async (name, value) => { setState({...state, [name]: value}); diff --git a/app/src/contexts/contexts.js b/app/src/contexts/contexts.js index 0e224ce7..619280a6 100644 --- a/app/src/contexts/contexts.js +++ b/app/src/contexts/contexts.js @@ -46,6 +46,8 @@ export const ThemeContext = React.createContext({ export const StatusContext = React.createContext({ status: {}, fetchStatus: null, + // True once the first status poll has landed. + loaded: false, }); export const SettingsContext = React.createContext({ @@ -53,6 +55,9 @@ export const SettingsContext = React.createContext({ fetchSettings: null, saveSettings: null, pending: false, + // True once settings have been fetched; `failed` when a fetch returned nothing before that. + loaded: false, + failed: false, }); // `useQuery` diff --git a/app/src/hooks/customHooks.js b/app/src/hooks/customHooks.js index 94c1c03f..af7f9384 100644 --- a/app/src/hooks/customHooks.js +++ b/app/src/hooks/customHooks.js @@ -1154,7 +1154,8 @@ export const useDownloads = () => { // Every hardware toggle works the same way: one field of the shared status object // says whether the subsystem is on, and starting/stopping it is a Controller call. -// `on` is null when the status is unknown, which the UI renders as unsupported. +// `on` is undefined until the status has loaded (unknown, the toggle is disabled), null once it +// has loaded without the field (unsupported on this server), and otherwise a boolean. const useSubsystemToggle = ({ statusField, onValue = 'on', @@ -1165,23 +1166,26 @@ const useSubsystemToggle = ({ errorDescription, refetchStatus = false, }) => { - const [on, setOn] = useState(null); - const {status, fetchStatus} = useContext(StatusContext); + const [on, setOn] = useState(undefined); + const {status, fetchStatus, loaded} = useContext(StatusContext); const value = status?.[statusField]; useEffect(() => { - if (value === onValue) { + if (!loaded) { + setOn(undefined); + } else if (value === onValue) { setOn(true); } else if (offValues.includes(value)) { setOn(false); } else { setOn(null); } - }, [value]); + }, [value, loaded]); const setSubsystem = async (enable) => { const previous = on; - setOn(null); + // Unknown while the call is in flight, not unsupported. + setOn(undefined); try { if (enable) { await start(); @@ -1331,8 +1335,16 @@ export const useSearchDirectories = (value) => { } export const useSettings = () => { + // `settings` is {} until the first fetch lands, and a consumer cannot tell that from real + // settings by looking at it. `loaded` is true once a fetch returned settings; `failed` is + // true when a fetch returned nothing before anything loaded. A failed refresh after a load + // keeps the last known good values and `loaded`. const [settings, setSettings] = useState({}); + const [loaded, setLoaded] = useState(false); + const [failed, setFailed] = useState(false); const [pending, setPending] = useState(false); + // Read by fetchSettings after an await, when the closed-over `loaded` may be stale. + const loadedRef = useRef(false); const fetchSettings = async () => { if (window.apiDown) { @@ -1343,9 +1355,17 @@ export const useSettings = () => { const newSettings = await getSettings(); if (newSettings) { setSettings(newSettings); + loadedRef.current = true; + setLoaded(true); + setFailed(false); + } else if (!loadedRef.current) { + setFailed(true); } } catch (e) { // Don't clear settings on error — keep the last known good values. + if (!loadedRef.current) { + setFailed(true); + } } finally { setPending(false); } @@ -1355,15 +1375,15 @@ export const useSettings = () => { fetchSettings(); }, []); - return {settings, pending, fetchSettings, saveSettings}; + return {settings, loaded, failed, pending, fetchSettings, saveSettings}; } export const useSettingsInterval = () => { - const {settings, pending, fetchSettings, saveSettings} = useSettings(); + const {settings, loaded, failed, pending, fetchSettings, saveSettings} = useSettings(); useRecurringTimeout(fetchSettings, 1000 * 10); - return {settings, pending, fetchSettings, saveSettings}; + return {settings, loaded, failed, pending, fetchSettings, saveSettings}; } export const useMediaDirectory = () => { @@ -1373,7 +1393,11 @@ export const useMediaDirectory = () => { } export const useStatus = () => { + // `status` is {} until the first poll lands, and a consumer cannot tell that from a server + // that reported nothing. `loaded` is true once a poll returned a real status; the API being + // down does not count. const [status, setStatus] = useState({}); + const [loaded, setLoaded] = useState(false); const fetchStatus = async () => { try { @@ -1398,6 +1422,9 @@ export const useStatus = () => { // Merge both responses - controller stats override app status for system info setStatus({...appStatus, ...controllerStats}); + if (!window.apiDown) { + setLoaded(true); + } } catch (e) { if (e instanceof ApiDownError) { // API is down, do not log this error. @@ -1417,15 +1444,15 @@ export const useStatus = () => { window.apiDown = false; }, []); - return {status, fetchStatus} + return {status, loaded, fetchStatus} } export const useStatusInterval = () => { - const {status, fetchStatus} = useStatus(); + const {status, loaded, fetchStatus} = useStatus(); useRecurringTimeout(fetchStatus, 1000 * 3); - return {status, fetchStatus}; + return {status, loaded, fetchStatus}; } export const StatusProvider = (props) => { diff --git a/app/src/hooks/customHooks.test.js b/app/src/hooks/customHooks.test.js index 987467af..9f26d925 100644 --- a/app/src/hooks/customHooks.test.js +++ b/app/src/hooks/customHooks.test.js @@ -2,9 +2,13 @@ import React from 'react'; import {act, renderHook} from '@testing-library/react'; import {render, screen} from '../test-utils'; import { - usePages, useDriveTemperature, useDriveHealth, useSearchChannels, useStatistics, useVideoExtras, + usePages, useDriveTemperature, useDriveHealth, useSearchChannels, useSettings, useStatistics, useStatus, + useVideoExtras, } from './customHooks'; -import {getStatistics, getVideoComments, getVideoDescription, searchChannels} from '../api'; +import { + ApiDownError, getSettings, getStatistics, getStatus, getVideoComments, getVideoDescription, searchChannels, +} from '../api'; +import {getControllerStats} from '../api/controller'; import {QueryContext, StatusContext} from '../contexts/contexts'; import {Paginator} from '../components/Common'; @@ -42,6 +46,13 @@ jest.mock('../api', () => ({ getVideoDescription: jest.fn(), searchChannels: jest.fn(), getStatistics: jest.fn(), + getStatus: jest.fn(), + getSettings: jest.fn(), +})); + +jest.mock('../api/controller', () => ({ + ...jest.requireActual('../api/controller'), + getControllerStats: jest.fn(), })); // Mock Media so Paginator renders both mobile + tablet variants synchronously. @@ -580,3 +591,81 @@ describe('useStatistics', () => { } }); }); + +describe('useStatus loaded', () => { + // `loaded` is false until the first poll returns a real status, so consumers can tell "not + // polled yet" from "the server reported nothing". + beforeEach(() => { + getStatus.mockReset(); + getControllerStats.mockReset(); + getControllerStats.mockResolvedValue({}); + }); + + test('is false before any poll', () => { + const {result} = renderHook(() => useStatus()); + expect(result.current.loaded).toBe(false); + expect(result.current.status).toEqual({}); + }); + + test('is true once a poll returned a status', async () => { + getStatus.mockResolvedValue({version: '1.0'}); + const {result} = renderHook(() => useStatus()); + await act(async () => { + await result.current.fetchStatus(); + }); + expect(result.current.loaded).toBe(true); + expect(result.current.status.version).toBe('1.0'); + }); + + test('stays false while the API is down', async () => { + getStatus.mockRejectedValue(new ApiDownError('down')); + const {result} = renderHook(() => useStatus()); + await act(async () => { + await result.current.fetchStatus(); + }); + expect(result.current.loaded).toBe(false); + }); +}); + +describe('useSettings loaded', () => { + beforeEach(() => { + getSettings.mockReset(); + window.apiDown = false; + }); + + test('is false before the fetch returns, then true', async () => { + let resolve; + getSettings.mockReturnValue(new Promise(res => resolve = res)); + const {result} = renderHook(() => useSettings()); + expect(result.current.loaded).toBe(false); + expect(result.current.failed).toBe(false); + await act(async () => { + resolve({media_directory: '/media/wrolpi'}); + }); + expect(result.current.loaded).toBe(true); + expect(result.current.settings.media_directory).toBe('/media/wrolpi'); + }); + + test('a fetch that returns nothing before anything loaded marks failed', async () => { + getSettings.mockResolvedValue(undefined); + const {result} = renderHook(() => useSettings()); + await act(async () => { + }); + expect(result.current.loaded).toBe(false); + expect(result.current.failed).toBe(true); + }); + + test('a failed refresh after a successful load keeps loaded and the last settings', async () => { + getSettings.mockResolvedValueOnce({media_directory: '/media/wrolpi'}); + const {result} = renderHook(() => useSettings()); + await act(async () => { + }); + getSettings.mockRejectedValue(new Error('boom')); + await act(async () => { + await result.current.fetchSettings(); + }); + expect(result.current.loaded).toBe(true); + expect(result.current.failed).toBe(false); + expect(result.current.settings.media_directory).toBe('/media/wrolpi'); + }); +}); diff --git a/app/src/hooks/useSubsystemToggle.test.js b/app/src/hooks/useSubsystemToggle.test.js index ec8c9be6..f84d5177 100644 --- a/app/src/hooks/useSubsystemToggle.test.js +++ b/app/src/hooks/useSubsystemToggle.test.js @@ -23,7 +23,7 @@ const controllerApi = require('../api/controller'); const fetchStatus = jest.fn(); const makeWrapper = (status) => ({children}) => ( - {children} + {children} ); beforeEach(() => { diff --git a/app/src/test-fixtures.js b/app/src/test-fixtures.js index e01259da..f8f8242f 100644 --- a/app/src/test-fixtures.js +++ b/app/src/test-fixtures.js @@ -188,9 +188,11 @@ export const themeContextFixture = (overrides = {}) => ({ ...overrides, }); +// `loaded` says the first poll has landed; the fixtures carry real values, so they are loaded. export const statusContextFixture = (overrides = {}) => ({ status: statusFixture(), fetchStatus: () => Promise.resolve(), + loaded: true, ...overrides, }); @@ -199,6 +201,8 @@ export const settingsContextFixture = (overrides = {}) => ({ fetchSettings: () => Promise.resolve(), saveSettings: () => Promise.resolve(), pending: false, + loaded: true, + failed: false, ...overrides, }); From 20541d71849cdbf5d7994f83d10a0b10cb4771d4 Mon Sep 17 00:00:00 2001 From: lrnselfreliance <33263708+lrnselfreliance@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:41:15 -0600 Subject: [PATCH 040/181] app: number fields no longer throw "e.preventDefault is not a function" (#735) `useForm`'s input handlers called `e.preventDefault()` unconditionally, but the Mantine-backed `NumberField` (Sleep between requests, Depth, Maximum Pages) hands them a bare `{target}` object because NumberInput emits a value, not an event. Every keystroke in those fields threw. `handleInputEvent` now only calls `preventDefault` when the argument actually has one, and `getInputProps`' wrapper no longer repeats the call. Audited every other `preventDefault` call site: all are bound to real DOM/React events or a guarded no-argument call. Co-authored-by: Claude Fable 5.1 --- app/src/components/Download.test.js | 28 ++++++++++++++++++++++-- app/src/hooks/useForm.js | 7 ++++-- app/src/hooks/useForm.test.js | 33 +++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/app/src/components/Download.test.js b/app/src/components/Download.test.js index 00492ff1..1493151e 100644 --- a/app/src/components/Download.test.js +++ b/app/src/components/Download.test.js @@ -1,8 +1,9 @@ import React from 'react'; import {render, screen, waitFor} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import {DestinationForm} from './Download'; -import {createTestForm} from '../test-utils'; +import {DepthInputForm, DestinationForm} from './Download'; +import {useForm} from '../hooks/useForm'; +import {createTestForm, renderWithProviders} from '../test-utils'; // Mock DirectorySearch component - simplified to avoid useState/useEffect warnings jest.mock('./Common', () => { @@ -251,3 +252,26 @@ describe('DestinationForm', () => { }); }); }); + +describe('NumberField', () => { + // Renders a NumberField against a real useForm: the Mantine NumberInput passes a value (not an + // event) to onChange, which used to reach `e.preventDefault()` and throw while typing. + function DepthHarness({onData}) { + const form = useForm({defaultFormData: {settings: {depth: ''}}, submitter: jest.fn()}); + onData(form.formData); + return ; + } + + it('typing into a NumberField updates the form without throwing', async () => { + let latest = null; + renderWithProviders( latest = d}/>); + + const input = screen.getByRole('textbox', {name: /Depth/}); + await userEvent.type(input, '3'); + + await waitFor(() => { + expect(latest.settings.depth).toBe(3); + }); + expect(input).toHaveValue('3'); + }); +}); diff --git a/app/src/hooks/useForm.js b/app/src/hooks/useForm.js index 092c1f5a..0c03ec7c 100644 --- a/app/src/hooks/useForm.js +++ b/app/src/hooks/useForm.js @@ -188,7 +188,9 @@ export function useForm({ }, 300), [validators]); const handleInputEvent = (e) => { - if (e) e.preventDefault(); + // Mantine inputs (NumberInput, etc.) hand back a value rather than a DOM event, so callers + // wrap it as a bare `{target: {...}}` object. Only a real event has `preventDefault`. + if (e && typeof e.preventDefault === 'function') e.preventDefault(); let {type, value} = e.target; let {path} = e.target.dataset; path = path || e.target.name; @@ -286,7 +288,8 @@ export function useForm({ const [customProps, inputAttrs] = getCustomProps({name, validator, path, type, required, afterChange}); const localHandleInputEvent = async (e) => { - if (e) e.preventDefault(); + // `handleInputEvent` already handles a real event's preventDefault, and tolerates the + // value-shaped objects Mantine-backed fields pass. const value = handleInputEvent(e); if (onChange) { await onChange(value); diff --git a/app/src/hooks/useForm.test.js b/app/src/hooks/useForm.test.js index efe4d67c..443f0411 100644 --- a/app/src/hooks/useForm.test.js +++ b/app/src/hooks/useForm.test.js @@ -465,6 +465,39 @@ describe('useForm', () => { expect(result.current.formData.sleep_requests).toBe(0.75); }); + it('handleInputEvent accepts a value-shaped event with no preventDefault (Mantine NumberInput)', () => { + // Mantine's NumberInput hands back the value, so NumberField wraps it in a bare + // {target} object. Typing must not throw "e.preventDefault is not a function". + const {result} = renderHook(() => useForm({ + defaultFormData: {settings: {sleep_requests: ''}}, + submitter: jest.fn(), + })); + + act(() => { + result.current.handleInputEvent({ + target: {type: 'number', value: 0.75, dataset: {path: 'settings.sleep_requests'}}, + }); + }); + + expect(result.current.formData.settings.sleep_requests).toBe(0.75); + }); + + it('getInputProps onChange accepts a value-shaped event with no preventDefault', async () => { + const {result} = renderHook(() => useForm({ + defaultFormData: {settings: {depth: ''}}, + submitter: jest.fn(), + })); + + await act(async () => { + const [inputProps] = result.current.getInputProps({name: 'depth', path: 'settings.depth', type: 'number'}); + await inputProps.onChange({ + target: {type: 'number', value: 3, dataset: {path: 'settings.depth'}}, + }); + }); + + expect(result.current.formData.settings.depth).toBe(3); + }); + it('handleInputEvent uses data-path attribute when available', () => { const {result} = renderHook(() => useForm({ defaultFormData: {nested: {field: ''}}, From 63ddad69e8c9916846fe85890decb6d1c1084100 Mon Sep 17 00:00:00 2001 From: lrnselfreliance <33263708+lrnselfreliance@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:47:32 -0600 Subject: [PATCH 041/181] app: api helpers report failure instead of returning an empty value (#736) Six helpers turned a non-OK response into an empty value, so their consumers rendered a server error as "nothing here": * getConfigBackups -> {dates: []}: "No backups available for this config." A thrown request also left "Loading backups..." forever. * getInventoryBackups -> []: "No backups yet." * getDownloaders -> {downloaders: []} on a throw, the error body on a non-OK response (which then crashed the .map): the RSS form's Downloader select sat empty. * searchMap -> {results: []}: "No map locations found". * getMapSearchStatus -> {indexed: [], missing: []}: "No maps are installed". * flasherSearch -> [null, null], then `|| []`: "No .bin firmware found". Invariants now enforced and tested: * On a non-OK response each helper returns undefined; an empty value only ever comes from the server. * Each consumer shows an error for a failed or thrown fetch and keeps its "none" message for a confirmed empty result. The Downloader select is disabled and says it is loading or could not fetch. A failed map status is unknown, never "no maps are installed". * The map search tab loads from the first render, and a query shorter than the two characters the request needs now says so instead of "No map locations found". Archive browsers and recent tags are left as they are: a failed fetch there hides an optional picker or suggestion list without claiming anything. Co-authored-by: Claude Opus 5.5 --- app/src/api.errorAsEmpty.test.js | 84 +++++++++++++++++++ app/src/api.js | 21 +++-- app/src/components/Download.js | 24 ++++-- app/src/components/DownloaderSelector.test.js | 43 ++++++++++ app/src/components/Flasher.js | 14 ++-- app/src/components/Flasher.test.js | 12 +++ app/src/components/MapSearchView.js | 34 ++++++-- app/src/components/MapSearchView.test.js | 61 ++++++++++++++ app/src/components/MapViewer.js | 9 +- .../components/admin/ConfigBackups.test.js | 43 ++++++++++ app/src/components/admin/Configs.js | 31 +++++-- .../inventory/InventoryImportModal.js | 22 +++-- .../inventory/InventoryImportModal.test.js | 21 +++++ 13 files changed, 375 insertions(+), 44 deletions(-) create mode 100644 app/src/api.errorAsEmpty.test.js create mode 100644 app/src/components/DownloaderSelector.test.js create mode 100644 app/src/components/MapSearchView.test.js create mode 100644 app/src/components/admin/ConfigBackups.test.js diff --git a/app/src/api.errorAsEmpty.test.js b/app/src/api.errorAsEmpty.test.js new file mode 100644 index 00000000..cb7c693c --- /dev/null +++ b/app/src/api.errorAsEmpty.test.js @@ -0,0 +1,84 @@ +import { + flasherSearch, getConfigBackups, getDownloaders, getInventoryBackups, getMapSearchStatus, searchMap, +} from './api'; + +// A helper that fails must say so. These used to turn a non-OK response into an empty value +// ([], {dates: []}, {results: []}, ...), so every consumer rendered "nothing here" for a server +// error. They now return undefined; an empty value only ever comes from the server. + +jest.mock('./components/ui', () => ({ + ...jest.requireActual('./components/ui'), + toast: jest.fn(), +})); + +const response = (ok, body = {}) => { + const r = { + ok, + status: ok ? 200 : 500, + json: async () => body, + text: async () => JSON.stringify(body), + }; + r.clone = () => r; + return r; +}; + +let originalFetch; +beforeEach(() => { + originalFetch = global.fetch; +}); +afterEach(() => { + global.fetch = originalFetch; +}); + +const failWith = () => { + global.fetch = jest.fn(() => Promise.resolve(response(false, {error: 'boom'}))); +}; +const succeedWith = (body) => { + global.fetch = jest.fn(() => Promise.resolve(response(true, body))); +}; + +describe('on a non-OK response, the helper returns undefined', () => { + test.each([ + ['getConfigBackups', () => getConfigBackups('wrolpi.yaml')], + ['getInventoryBackups', () => getInventoryBackups('food')], + ['getDownloaders', () => getDownloaders()], + ['searchMap', () => searchMap('camp')], + ['getMapSearchStatus', () => getMapSearchStatus()], + ['flasherSearch', () => flasherSearch(null, null)], + ])('%s', async (_name, call) => { + failWith(); + expect(await call()).toBeUndefined(); + }); +}); + +describe('a confirmed empty result is still empty', () => { + test('getConfigBackups', async () => { + succeedWith({dates: []}); + expect(await getConfigBackups('wrolpi.yaml')).toEqual({dates: []}); + }); + + test('getInventoryBackups', async () => { + succeedWith({dates: []}); + expect(await getInventoryBackups('food')).toEqual([]); + }); + + test('getDownloaders', async () => { + succeedWith({downloaders: []}); + expect(await getDownloaders()).toEqual({downloaders: []}); + }); + + test('searchMap', async () => { + succeedWith({results: [], total: 0}); + expect(await searchMap('camp')).toEqual({results: [], total: 0}); + }); + + test('getMapSearchStatus', async () => { + succeedWith({indexed: [], missing: []}); + expect(await getMapSearchStatus()).toEqual({indexed: [], missing: []}); + }); + + test('flasherSearch', async () => { + succeedWith({file_groups: [], totals: {file_groups: 0}}); + expect(await flasherSearch(null, null)).toEqual([[], 0]); + }); +}); diff --git a/app/src/api.js b/app/src/api.js index 57f5d255..907d6012 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -522,7 +522,7 @@ export async function getConfigBackups(fileName) { if (response.ok) { return await response.json(); } - return {dates: []}; + // Undefined, not {dates: []}, so the modal can tell a failed fetch from having no backups. } export async function postConfigBackupPreview(fileName, backupDate, mode) { @@ -720,7 +720,7 @@ export async function getInventoryBackups(slug) { if (response.ok) { return (await response.json())['dates']; } - return []; + // Undefined, not [], so the modal can tell a failed fetch from having no backups. } export async function postInventoryRestorePreview(slug, backupDate, mode) { @@ -1259,12 +1259,15 @@ export async function startDownloads() { } export async function getDownloaders() { + // Not toasting because this will happen often. Undefined on any failure, not an empty list, so + // the form can say the downloaders could not be fetched. try { const response = await apiGet(`${API_URI}/downloaders`); - // Not toasting because this will happen often. - return await response.json(); + if (response.ok) { + return await response.json(); + } } catch (e) { - return {downloaders: []}; + // The API is down; reported as undefined below. } } @@ -1351,7 +1354,7 @@ export async function flasherSearch(chip, path) { } const message = await getErrorMessage(response, 'Cannot search firmware. See server logs.'); toast({type: 'error', title: 'Unable to search firmware', description: message, time: 5000}); - return [null, null]; + // Undefined, not [null, null], so the picker can tell a failed search from finding nothing. } export async function getFlasherConfigs() { @@ -1658,7 +1661,8 @@ export async function searchMap(query, limit = 12, offset = 0, lat = null, lon = } const response = await apiGet(url); if (!response.ok) { - return {results: [], total: 0}; + // Undefined, not {results: []}, so a failed search is not shown as finding nothing. + return undefined; } return await response.json(); } @@ -1666,7 +1670,8 @@ export async function searchMap(query, limit = 12, offset = 0, lat = null, lon = export async function getMapSearchStatus() { const response = await apiGet(`${API_URI}/map/search/status`); if (!response.ok) { - return {indexed: [], missing: []}; + // Undefined, not {indexed: [], missing: []}, which read as "no maps are installed". + return undefined; } return await response.json(); } diff --git a/app/src/components/Download.js b/app/src/components/Download.js index f8f4bc5e..8f115071 100644 --- a/app/src/components/Download.js +++ b/app/src/components/Download.js @@ -247,28 +247,38 @@ export function DownloadFrequencySelector({ } export function DownloaderSelector({form, name = 'sub_downloader', path = 'sub_downloader'}) { - const [downloaders, setDownloaders] = React.useState([]); + // null = pending, undefined = could not be fetched, a list = loaded. Until loaded, the select is + // disabled and says why, rather than sitting empty as though there were no downloaders. + const [downloaders, setDownloaders] = React.useState(null); const fetchDownloaders = async () => { - let {downloaders: downloaders_} = await getDownloaders(); - downloaders_ = downloaders_.map((i) => { + const result = await getDownloaders(); + if (!result || !Array.isArray(result.downloaders)) { + setDownloaders(undefined); + return; + } + setDownloaders(result.downloaders.map((i) => { return {key: i.name, text: i.pretty_name || i.name, value: i.name} - }) - setDownloaders(downloaders_); + })); } React.useEffect(() => { fetchDownloaders(); }, []); + const placeholder = downloaders === null ? 'Loading downloaders…' + : downloaders === undefined ? 'Could not fetch downloaders' + : 'Select a downloader'; + return } diff --git a/app/src/components/DownloaderSelector.test.js b/app/src/components/DownloaderSelector.test.js new file mode 100644 index 00000000..7bc65ee5 --- /dev/null +++ b/app/src/components/DownloaderSelector.test.js @@ -0,0 +1,43 @@ +import React from 'react'; +import {act, render, screen} from '../test-utils'; +import {createTestForm} from '../test-utils'; +import {DownloaderSelector} from './Download'; +import {getDownloaders} from '../api'; + +// The RSS form's Downloader select is filled from the API. Until the list arrives, or if it could +// not be fetched, the select must say so rather than sit empty as though there were no downloaders. + +jest.mock('../api', () => ({ + ...jest.requireActual('../api'), + getDownloaders: jest.fn(), +})); + +const makeForm = () => createTestForm({}, { + overrides: {getSelectionProps: () => [{value: null, onChange: jest.fn(), disabled: false, error: null}]}, +}); + +beforeEach(() => getDownloaders.mockReset()); + +test('pending: the select says it is loading and is disabled', () => { + getDownloaders.mockReturnValue(new Promise(() => { + })); + render(); + const input = screen.getByPlaceholderText('Loading downloaders…'); + expect(input).toBeDisabled(); +}); + +test('failed: the select says the downloaders could not be fetched', async () => { + getDownloaders.mockResolvedValue(undefined); + render(); + await act(async () => { + }); + expect(screen.getByPlaceholderText('Could not fetch downloaders')).toBeDisabled(); +}); + +test('loaded: the select offers the downloaders', async () => { + getDownloaders.mockResolvedValue({downloaders: [{name: 'video', pretty_name: 'Videos'}]}); + render(); + await act(async () => { + }); + expect(screen.getByPlaceholderText('Select a downloader')).toBeEnabled(); +}); diff --git a/app/src/components/Flasher.js b/app/src/components/Flasher.js index 304c4374..542428cf 100644 --- a/app/src/components/Flasher.js +++ b/app/src/components/Flasher.js @@ -270,7 +270,8 @@ export function FlasherPage() { // Media-directory firmware picker. const [mediaFilter, setMediaFilter] = useState(''); - const [mediaResults, setMediaResults] = useState([]); + // null = not searched yet, undefined = the search failed, a list = the server's results. + const [mediaResults, setMediaResults] = useState(null); const [mediaLoading, setMediaLoading] = useState(false); // When set (e.g. "ESP32-S2"), the picker only shows firmware for that chip (detected from the device). const [deviceChip, setDeviceChip] = useState(null); @@ -393,14 +394,15 @@ export function FlasherPage() { const fetchMediaFirmware = React.useCallback(async (pathFilter, chip) => { setMediaLoading(true); try { - const [fileGroups] = await flasherSearch(chip || null, pathFilter || null); - setMediaResults(fileGroups || []); + const result = await flasherSearch(chip || null, pathFilter || null); + // The api helper returns undefined (and toasts) on a non-OK response. + setMediaResults(result ? result[0] || [] : undefined); } catch (e) { // Surface connectivity errors instead of showing the empty "no firmware found" state, which would // be indistinguishable from genuinely having no firmware. setError(e && e.message ? e.message : String(e)); setBootHint(false); - setMediaResults([]); + setMediaResults(undefined); } finally { setMediaLoading(false); } @@ -767,8 +769,10 @@ export function FlasherPage() { onChange={(e) => handleMediaFilterChange(e.currentTarget.value)} />
- {mediaLoading + {mediaLoading || mediaResults === null ? + : mediaResults === undefined + ? Could not search firmware. : (mediaResults.length === 0 ?

No .bin firmware found diff --git a/app/src/components/Flasher.test.js b/app/src/components/Flasher.test.js index 2873c8e7..7d69b48d 100644 --- a/app/src/components/Flasher.test.js +++ b/app/src/components/Flasher.test.js @@ -290,6 +290,18 @@ describe('FlasherPage', () => { expect((await screen.findAllByText('No saved configurations yet.')).length).toBeGreaterThan(0); }); + // "No .bin firmware found" may only appear once the server searched and found none. + it('shows an error, not "no firmware found", when the firmware search failed', async () => { + Object.defineProperty(global.navigator, 'serial', {value: {}, configurable: true}); + // The api helper returns undefined on a non-OK response. + flasherSearch.mockResolvedValue(undefined); + render(); + await waitFor(() => expect(flasherSearch).toHaveBeenCalled()); + switchTab('Choose from your WROLPi'); + expect((await screen.findAllByText(/Could not search firmware/)).length).toBeGreaterThan(0); + expect(screen.queryAllByText(/firmware found/)).toHaveLength(0); + }); + // PageContainer renders both the mobile and desktop breakpoints (fresnel Media), so each label appears twice. it('warns when Web Serial is unavailable', () => { delete global.navigator.serial; diff --git a/app/src/components/MapSearchView.js b/app/src/components/MapSearchView.js index 6df499ff..bd9ed8c8 100644 --- a/app/src/components/MapSearchView.js +++ b/app/src/components/MapSearchView.js @@ -2,7 +2,7 @@ import React, {useEffect, useState} from "react"; import {Link, useNavigate} from "react-router"; import {getMapSearchStatus, searchMap} from "../api"; import {Card, CardGroup, Header, Loading, Panel} from "./ui"; -import {Paginator} from "./Common"; +import {ErrorMessage, Paginator} from "./Common"; import {useLatestRequest, usePages} from "../hooks/customHooks"; import {QueryContext} from "../contexts/contexts"; import {MAP_VIEWER_URI} from "./Vars"; @@ -68,16 +68,24 @@ export function MapSearchView() { const {offset, limit, activePage, setPage, totalPages, setTotal} = usePages(12); // useLatestRequest discards stale responses and catches errors, so rapid pagination // can't show out-of-order results and a network failure can't leave the spinner stuck. - const {data, sendRequest, loading} = useLatestRequest(300); + // Loading from the first render: a query of two or more characters always sends a request (below), + // so this settles, and the shorter-query branches render before the loading check. + const {data, sendRequest, loading} = useLatestRequest(300, true); + // null = unknown (pending, or the status could not be fetched), otherwise whether any map is + // installed. Only a real status may claim that no maps are installed. const [hasMaps, setHasMaps] = useState(null); useEffect(() => { let cancelled = false; (async () => { - const status = await getMapSearchStatus(); - if (!cancelled) { - const installed = (status.indexed || []).length + (status.missing || []).length; - setHasMaps(installed > 0); + try { + const status = await getMapSearchStatus(); + if (!cancelled && status) { + const installed = (status.indexed || []).length + (status.missing || []).length; + setHasMaps(installed > 0); + } + } catch (e) { + console.error(e); } })(); return () => { @@ -106,12 +114,22 @@ export function MapSearchView() { ; } + if (!searchStr) { + return

Enter a search query to find map locations.
; + } + + if (searchStr.length < 2) { + return
Enter at least two characters to find map locations.
; + } + if (loading) { return ; } - if (!searchStr) { - return
Enter a search query to find map locations.
; + // Settled with no data: the request failed (the api helper returns undefined on a non-OK + // response, and useLatestRequest leaves null when it throws). + if (data == null) { + return Could not search the map.; } if (results.length === 0) { diff --git a/app/src/components/MapSearchView.test.js b/app/src/components/MapSearchView.test.js new file mode 100644 index 00000000..01e02884 --- /dev/null +++ b/app/src/components/MapSearchView.test.js @@ -0,0 +1,61 @@ +import React from 'react'; +import {render, screen} from '../test-utils'; +import {MapSearchView} from './MapSearchView'; +import {getMapSearchStatus, searchMap} from '../api'; + +// The map search tab says "No map locations found" only when the server searched and found none, +// and "No maps are installed" only when the server said so. A failed request is an error. + +jest.mock('../api', () => ({ + ...jest.requireActual('../api'), + getMapSearchStatus: jest.fn(), + searchMap: jest.fn(), +})); + +const NOT_FOUND = /No map locations found/; +const NO_MAPS = /No maps are installed/; +const FAILED = /Could not search the map/; + +const renderAt = (q) => render(, {query: {searchParams: new URLSearchParams({q})}}); + +beforeEach(() => { + getMapSearchStatus.mockReset(); + searchMap.mockReset(); + getMapSearchStatus.mockResolvedValue({indexed: ['utah'], missing: []}); +}); + +test('found nothing: says so', async () => { + searchMap.mockResolvedValue({results: [], total: 0}); + renderAt('camp'); + expect(await screen.findByText(NOT_FOUND)).toBeInTheDocument(); +}); + +test('a failed search shows an error, not "No map locations found"', async () => { + searchMap.mockResolvedValue(undefined); + renderAt('camp'); + expect(await screen.findByText(FAILED)).toBeInTheDocument(); + expect(screen.queryByText(NOT_FOUND)).not.toBeInTheDocument(); +}); + +test('a failed status check does not claim no maps are installed', async () => { + getMapSearchStatus.mockResolvedValue(undefined); + searchMap.mockResolvedValue({results: [], total: 0}); + renderAt('camp'); + expect(await screen.findByText(NOT_FOUND)).toBeInTheDocument(); + expect(screen.queryByText(NO_MAPS)).not.toBeInTheDocument(); +}); + +test('a thrown status check does not claim no maps are installed', async () => { + getMapSearchStatus.mockRejectedValue(new Error('down')); + searchMap.mockResolvedValue({results: [], total: 0}); + renderAt('camp'); + expect(await screen.findByText(NOT_FOUND)).toBeInTheDocument(); + expect(screen.queryByText(NO_MAPS)).not.toBeInTheDocument(); +}); + +test('the server saying no maps are installed is shown', async () => { + getMapSearchStatus.mockResolvedValue({indexed: [], missing: []}); + searchMap.mockResolvedValue({results: [], total: 0}); + renderAt('camp'); + expect(await screen.findByText(NO_MAPS)).toBeInTheDocument(); +}); diff --git a/app/src/components/MapViewer.js b/app/src/components/MapViewer.js index f00b519d..888bb78a 100644 --- a/app/src/components/MapViewer.js +++ b/app/src/components/MapViewer.js @@ -421,10 +421,13 @@ function MapSearch({map}) { lon = center.lng; } + // A failed search (undefined) shows no dropdown, same as finding nothing: this is an + // as-you-type suggestion list, not a results page. searchMap(q, 8, 0, lat, lon).then(data => { - setResults(data.results || []); - setShowResults((data.results || []).length > 0); - }); + const found = data?.results || []; + setResults(found); + setShowResults(found.length > 0); + }).catch(e => console.error(e)); }, [map]); const handleInput = useCallback((e) => { diff --git a/app/src/components/admin/ConfigBackups.test.js b/app/src/components/admin/ConfigBackups.test.js new file mode 100644 index 00000000..6dd8a4ad --- /dev/null +++ b/app/src/components/admin/ConfigBackups.test.js @@ -0,0 +1,43 @@ +import React from 'react'; +import {render, screen} from '../../test-utils'; +import {BackupsModal} from './Configs'; +import {getConfigBackups} from '../../api'; + +// The config backups modal says "No backups available" only once the server confirmed there are +// none; a failed fetch is an error, and a thrown one must not leave the loader up forever. + +jest.mock('../../api', () => ({ + ...jest.requireActual('../../api'), + getConfigBackups: jest.fn(), + postConfigBackupPreview: jest.fn(() => new Promise(() => { + })), +})); + +const NONE = /No backups available/; +const FAILED = /Could not fetch backups/; + +const renderModal = () => render( + , +); + +beforeEach(() => getConfigBackups.mockReset()); + +test('empty: says there are no backups', async () => { + getConfigBackups.mockResolvedValue({dates: []}); + renderModal(); + expect(await screen.findByText(NONE)).toBeInTheDocument(); +}); + +test('failed: shows an error, not "No backups available"', async () => { + getConfigBackups.mockResolvedValue(undefined); + renderModal(); + expect(await screen.findByText(FAILED)).toBeInTheDocument(); + expect(screen.queryByText(NONE)).not.toBeInTheDocument(); +}); + +test('thrown: shows an error, not a loader forever', async () => { + getConfigBackups.mockRejectedValue(new Error('down')); + renderModal(); + expect(await screen.findByText(FAILED)).toBeInTheDocument(); + expect(screen.queryByText(/Loading backups/)).not.toBeInTheDocument(); +}); diff --git a/app/src/components/admin/Configs.js b/app/src/components/admin/Configs.js index 106921eb..98cbe8ae 100644 --- a/app/src/components/admin/Configs.js +++ b/app/src/components/admin/Configs.js @@ -1,4 +1,4 @@ -import {APIButton} from "../Common"; +import {APIButton, ErrorMessage} from "../Common"; import {Button, Header, Icon, IconButton, Loader, Loading, Modal, Placeholder, Table, Tooltip} from "../ui"; import React, {useEffect, useState} from "react"; import {getConfigText, getConfigBackups, postConfigBackupImport, postConfigBackupPreview} from "../../api"; @@ -117,9 +117,11 @@ function BackupDateRow({date, previews, previewsLoaded, onSelect}) { ; } -function BackupsModal({open, onClose, fileName, fetchConfigs}) { +export function BackupsModal({open, onClose, fileName, fetchConfigs}) { const [dates, setDates] = useState([]); const [datesLoading, setDatesLoading] = useState(true); + // The backups could not be fetched; distinct from the server confirming there are none. + const [datesFailed, setDatesFailed] = useState(false); const [previews, setPreviews] = useState({}); const [previewsLoaded, setPreviewsLoaded] = useState({}); const [selectedAction, setSelectedAction] = useState(null); // {date, mode, preview} @@ -130,17 +132,30 @@ function BackupsModal({open, onClose, fileName, fetchConfigs}) { if (!open) return; let cancelled = false; setDatesLoading(true); + setDatesFailed(false); setDates([]); setPreviews({}); setPreviewsLoaded({}); setSelectedAction(null); setApplying(false); - getConfigBackups(fileName).then(result => { - if (cancelled) return; - setDates(result?.dates || []); - setDatesLoading(false); - }); + getConfigBackups(fileName) + .then(result => { + if (cancelled) return; + // The api helper returns undefined on a non-OK response. + if (result) { + setDates(result.dates || []); + } else { + setDatesFailed(true); + } + }) + .catch(e => { + console.error(e); + if (!cancelled) setDatesFailed(true); + }) + .finally(() => { + if (!cancelled) setDatesLoading(false); + }); return () => { cancelled = true; }; }, [open, fileName]); @@ -253,6 +268,8 @@ function BackupsModal({open, onClose, fileName, fetchConfigs}) { {datesLoading ? Loading backups... + : datesFailed + ? Could not fetch backups for this config. : dates.length === 0 ?

No backups available for this config.

:
diff --git a/app/src/components/inventory/InventoryImportModal.js b/app/src/components/inventory/InventoryImportModal.js index 7f7aa18d..ce20d27c 100644 --- a/app/src/components/inventory/InventoryImportModal.js +++ b/app/src/components/inventory/InventoryImportModal.js @@ -25,7 +25,7 @@ function ItemNameList({items}) { * `onChanged` refreshes the page's inventory state after a successful action. */ export function InventoryImportModal({open, onClose, slug, name, onChanged}) { - const [dates, setDates] = useState(null); // null while loading + const [dates, setDates] = useState(null); // null while loading, undefined if the fetch failed const [reimporting, setReimporting] = useState(false); const [selected, setSelected] = useState(null); // {date, mode, preview} confirmation view const [previewing, setPreviewing] = useState(null); // `${date}:${mode}` currently loading a preview @@ -41,11 +41,19 @@ export function InventoryImportModal({open, onClose, slug, name, onChanged}) { setReimporting(false); setApplying(false); setPreviewing(null); - getInventoryBackups(slug).then(result => { - if (!cancelled) { - setDates(result || []); - } - }); + getInventoryBackups(slug) + .then(result => { + if (!cancelled) { + // The api helper returns undefined on a non-OK response. + setDates(result === undefined ? undefined : result || []); + } + }) + .catch(e => { + console.error(e); + if (!cancelled) { + setDates(undefined); + } + }); return () => { cancelled = true; }; @@ -153,6 +161,8 @@ export function InventoryImportModal({open, onClose, slug, name, onChanged}) {

{dates === null ? Loading backups… + : dates === undefined + ?

Could not fetch backups for this inventory.

: dates.length === 0 ?

No backups yet. A backup is saved automatically whenever this inventory changes.

:
diff --git a/app/src/components/inventory/InventoryImportModal.test.js b/app/src/components/inventory/InventoryImportModal.test.js index 6ec7d604..751deecf 100644 --- a/app/src/components/inventory/InventoryImportModal.test.js +++ b/app/src/components/inventory/InventoryImportModal.test.js @@ -65,4 +65,25 @@ describe('InventoryImportModal', () => { renderModal(); await waitFor(() => expect(getInventoryBackups).toHaveBeenCalledWith('food-storage')); }); + + // "No backups yet" may only appear once the server confirmed there are none. + test('says there are no backups when the server returned none', async () => { + getInventoryBackups.mockResolvedValue([]); + renderModal(); + expect(await screen.findByText(/No backups yet/)).toBeInTheDocument(); + }); + + test('a failed backups fetch shows an error, not "No backups yet"', async () => { + getInventoryBackups.mockResolvedValue(undefined); + renderModal(); + expect(await screen.findByText(/Could not fetch backups/)).toBeInTheDocument(); + expect(screen.queryByText(/No backups yet/)).not.toBeInTheDocument(); + }); + + test('a thrown backups fetch shows an error, not a loader forever', async () => { + getInventoryBackups.mockRejectedValue(new Error('down')); + renderModal(); + expect(await screen.findByText(/Could not fetch backups/)).toBeInTheDocument(); + expect(screen.queryByText(/Loading backups/)).not.toBeInTheDocument(); + }); }); From 15d3792b0e2adaba0d03f8548ecf42c52d3660df Mon Sep 17 00:00:00 2001 From: lrnselfreliance <33263708+lrnselfreliance@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:49:00 -0600 Subject: [PATCH 042/181] videos: transcode can remove the audio stream (silent video) (#737) A video's audio stream can be dropped the same way its video stream could already be: a new REMOVE_AUDIO audio target maps only the video stream with -an, needs a video container, and is refused for audio files. Output verification insists the result has no audio stream. Removing both streams is refused by the validator, the API, resolve_output, and the modal: choosing Remove for one stream resets the other to Keep and withdraws the other's Remove option. Co-authored-by: Claude Fable 5.1 --- app/src/components/Vars.js | 3 + app/src/components/VideoEditMenu.js | 71 +++++++-- app/src/components/VideoEditMenu.test.js | 35 ++++- modules/videos/schema.py | 4 +- modules/videos/test/test_transcode_audio.py | 162 +++++++++++++++++++- modules/videos/transcode.py | 40 ++++- modules/videos/video/api.py | 6 +- 7 files changed, 291 insertions(+), 30 deletions(-) diff --git a/app/src/components/Vars.js b/app/src/components/Vars.js index 01631236..30b0ab1a 100644 --- a/app/src/components/Vars.js +++ b/app/src/components/Vars.js @@ -168,11 +168,14 @@ export const transcodeVideoCodecOptions = [ {value: 'h264', label: 'h264 (avc1)'}, {value: TRANSCODE_REMOVE_VIDEO, label: 'Remove video (audio only)'}, ]; +// REMOVE_AUDIO in modules/videos/transcode.py: drop the audio stream, the result is a silent video. +export const TRANSCODE_REMOVE_AUDIO = 'none'; export const transcodeAudioCodecOptions = [ {value: TRANSCODE_COPY, label: 'Keep (copy)'}, {value: 'aac', label: 'aac'}, {value: 'opus', label: 'opus'}, {value: 'mp3', label: 'mp3'}, + {value: TRANSCODE_REMOVE_AUDIO, label: 'Remove audio (silent video)'}, ]; // Containers for output with a video stream ... export const transcodeContainerOptions = [ diff --git a/app/src/components/VideoEditMenu.js b/app/src/components/VideoEditMenu.js index 4cf981a4..181c4156 100644 --- a/app/src/components/VideoEditMenu.js +++ b/app/src/components/VideoEditMenu.js @@ -8,6 +8,7 @@ import { import { audioContainerForCodec, TRANSCODE_COPY, + TRANSCODE_REMOVE_AUDIO, TRANSCODE_REMOVE_VIDEO, transcodeAudioCodecOptions, transcodeAudioContainerOptions, @@ -68,6 +69,41 @@ export function containerFor({audioOutput, audioCodec, video}) { return transcodeContainerOptions.some(i => i.value === current) ? current : 'mp4'; } +/** + * The video codec choices: Remove video is withdrawn once the audio is being removed (nothing + * would remain). + */ +export function videoCodecOptionsFor({removeAudio}) { + return removeAudio ? transcodeVideoCodecOptions.filter(i => i.value !== TRANSCODE_REMOVE_VIDEO) + : transcodeVideoCodecOptions; +} + +/** + * The audio codec choices: Remove audio is only for a video whose video stream is kept; an audio + * file has nothing else, and removing the video already chose the audio as the result. + */ +export function audioCodecOptionsFor({audioOnly, removeVideo}) { + return (audioOnly || removeVideo) ? transcodeAudioCodecOptions.filter(i => i.value !== TRANSCODE_REMOVE_AUDIO) + : transcodeAudioCodecOptions; +} + +/** + * The codec pair after one select changes. Both streams can never be removed (nothing would + * remain): choosing Remove for one stream puts the other back to Keep. + */ +export function chooseCodecs({videoCodec, audioCodec}, {video, audio}) { + let nextVideo = video !== undefined ? (video || TRANSCODE_COPY) : videoCodec; + let nextAudio = audio !== undefined ? (audio || TRANSCODE_COPY) : audioCodec; + if (nextVideo === TRANSCODE_REMOVE_VIDEO && nextAudio === TRANSCODE_REMOVE_AUDIO) { + if (video !== undefined) { + nextAudio = TRANSCODE_COPY; + } else { + nextVideo = TRANSCODE_COPY; + } + } + return {videoCodec: nextVideo, audioCodec: nextAudio}; +} + const statusKind = (status) => { if (status === 'complete') return 'complete'; if (status === 'running') return 'active'; @@ -214,6 +250,7 @@ export function TranscodeModal({ const current = currentCodecs(video); const removeVideo = videoCodec === TRANSCODE_REMOVE_VIDEO; + const removeAudio = !audioOnly && !removeVideo && audioCodec === TRANSCODE_REMOVE_AUDIO; // An audio file has no video stream to keep or remove; removing the video makes an audio file. const audioOutput = audioOnly || removeVideo; @@ -250,18 +287,20 @@ export function TranscodeModal({ }; }, [open]); - const changeVideoCodec = (value) => { - const next = value || TRANSCODE_COPY; - setVideoCodec(next); - setContainer(containerFor({audioOutput: audioOnly || next === TRANSCODE_REMOVE_VIDEO, audioCodec, video})); - }; - const changeAudioCodec = (value) => { - const next = value || TRANSCODE_COPY; - setAudioCodec(next); - setContainer(containerFor({audioOutput, audioCodec: next, video})); + // `chooseCodecs` keeps Remove video and Remove audio from ever being chosen together; the + // other select also withdraws its Remove option (see `videoCodecOptionsFor`/`audioCodecOptionsFor`). + const applyCodecs = (change) => { + const next = chooseCodecs({videoCodec, audioCodec}, change); + setVideoCodec(next.videoCodec); + setAudioCodec(next.audioCodec); + setContainer(containerFor({ + audioOutput: audioOnly || next.videoCodec === TRANSCODE_REMOVE_VIDEO, audioCodec: next.audioCodec, video, + })); }; + const changeVideoCodec = (value) => applyCodecs({video: value}); + const changeAudioCodec = (value) => applyCodecs({audio: value}); - const remuxOnly = !removeVideo && videoCodec === TRANSCODE_COPY && audioCodec === TRANSCODE_COPY; + const remuxOnly = !removeVideo && !removeAudio && videoCodec === TRANSCODE_COPY && audioCodec === TRANSCODE_COPY; const sameContainer = container === currentContainer(video); const containerOptions = audioOutput ? transcodeAudioContainerOptions : transcodeContainerOptions; // Fragments are an mp4 concept (m4a is an mp4). @@ -272,7 +311,8 @@ export function TranscodeModal({ try { const id = await transcodeVideo(fileGroupId, { video_codec: videoCodec === TRANSCODE_COPY ? null : videoCodec, - audio_codec: audioCodec === TRANSCODE_COPY ? null : audioCodec, + audio_codec: audioCodec === TRANSCODE_COPY || (removeVideo && audioCodec === TRANSCODE_REMOVE_AUDIO) + ? null : audioCodec, container, fragmented: canFragment && fragmented, }); @@ -292,6 +332,9 @@ export function TranscodeModal({ if (defaultsLoaded && removeVideo) { hint = `The video stream is removed; the result is an audio file (.${container}). ` + (audioCodec === TRANSCODE_COPY ? 'The audio is copied without re-encoding.' : `The audio is converted to ${audioCodec}.`); + } else if (defaultsLoaded && removeAudio) { + hint = `The audio stream is removed; the result is a silent video (.${container}). ` + + (videoCodec === TRANSCODE_COPY ? 'The video is copied without re-encoding.' : `The video is converted to ${videoCodec}.`); } else if (defaultsLoaded && remuxOnly) { const kept = audioOnly ? 'The audio is kept' : 'Both streams are kept'; if (sameContainer && canFragment && fragmented) { @@ -303,7 +346,7 @@ export function TranscodeModal({ hint = `${kept}: only the container changes to ${container} (a quick remux, no quality loss).`; } } - const buttonLabel = removeVideo ? 'Extract Audio' : remuxOnly ? 'Remux' : 'Transcode'; + const buttonLabel = removeVideo ? 'Extract Audio' : removeAudio ? 'Remove Audio' : remuxOnly ? 'Remux' : 'Transcode'; return {audioOnly ? 'Transcode Audio' : 'Transcode Video'} @@ -319,7 +362,7 @@ export function TranscodeModal({ {!showJob && <> {!audioOnly && { // Selecting it is exercised through containerFor (Mantine's dropdown is not driveable in jsdom). }); + test('the Remove audio option is offered for videos, not audio files, and never with Remove video', () => { + expect(transcodeAudioCodecOptions.some(i => i.value === TRANSCODE_REMOVE_AUDIO)).toBe(true); + const has = (options, value) => options.some(i => i.value === value); + expect(has(audioCodecOptionsFor({audioOnly: false, removeVideo: false}), TRANSCODE_REMOVE_AUDIO)).toBe(true); + expect(has(audioCodecOptionsFor({audioOnly: true, removeVideo: false}), TRANSCODE_REMOVE_AUDIO)).toBe(false); + expect(has(audioCodecOptionsFor({audioOnly: false, removeVideo: true}), TRANSCODE_REMOVE_AUDIO)).toBe(false); + // The real codecs and Keep are never withdrawn. + expect(audioCodecOptionsFor({audioOnly: true, removeVideo: false}).map(i => i.value)) + .toEqual([TRANSCODE_COPY, 'aac', 'opus', 'mp3']); + expect(has(videoCodecOptionsFor({removeAudio: false}), TRANSCODE_REMOVE_VIDEO)).toBe(true); + expect(has(videoCodecOptionsFor({removeAudio: true}), TRANSCODE_REMOVE_VIDEO)).toBe(false); + expect(has(videoCodecOptionsFor({removeAudio: true}), 'h264')).toBe(true); + }); + + test('Remove video and Remove audio can never be chosen together', () => { + const keepBoth = {videoCodec: TRANSCODE_COPY, audioCodec: TRANSCODE_COPY}; + // Removing one stream after the other was chosen puts the other back to Keep. + expect(chooseCodecs({videoCodec: TRANSCODE_REMOVE_VIDEO, audioCodec: 'aac'}, {audio: TRANSCODE_REMOVE_AUDIO})) + .toEqual({videoCodec: TRANSCODE_COPY, audioCodec: TRANSCODE_REMOVE_AUDIO}); + expect(chooseCodecs({videoCodec: 'h264', audioCodec: TRANSCODE_REMOVE_AUDIO}, {video: TRANSCODE_REMOVE_VIDEO})) + .toEqual({videoCodec: TRANSCODE_REMOVE_VIDEO, audioCodec: TRANSCODE_COPY}); + // Ordinary choices are untouched; a cleared select means Keep. + expect(chooseCodecs(keepBoth, {video: 'h264'})).toEqual({videoCodec: 'h264', audioCodec: TRANSCODE_COPY}); + expect(chooseCodecs({videoCodec: 'h264', audioCodec: 'aac'}, {audio: null})) + .toEqual({videoCodec: 'h264', audioCodec: TRANSCODE_COPY}); + expect(chooseCodecs(keepBoth, {audio: TRANSCODE_REMOVE_AUDIO})) + .toEqual({videoCodec: TRANSCODE_COPY, audioCodec: TRANSCODE_REMOVE_AUDIO}); + }); + test('WROL Mode disables starting a transcode', async () => { renderWithProviders( , diff --git a/modules/videos/schema.py b/modules/videos/schema.py index 3eac9834..f3a73d80 100644 --- a/modules/videos/schema.py +++ b/modules/videos/schema.py @@ -193,8 +193,8 @@ class VideoUpdateRequest: @dataclass class VideoTranscodeRequest: - video_codec: Optional[str] = None # A key of TRANSCODE_VIDEO_TARGETS, or None to copy the stream. - audio_codec: Optional[str] = None # A key of TRANSCODE_AUDIO_TARGETS, or None to copy the stream. + video_codec: Optional[str] = None # A key of TRANSCODE_VIDEO_TARGETS, REMOVE_VIDEO, or None to copy the stream. + audio_codec: Optional[str] = None # A key of TRANSCODE_AUDIO_TARGETS, REMOVE_AUDIO, or None to copy the stream. container: str = 'mp4' fragmented: bool = False # Fragmented mp4: quick start/seek for very long videos (mp4/m4a only). diff --git a/modules/videos/test/test_transcode_audio.py b/modules/videos/test/test_transcode_audio.py index 0abdcd3e..bfc24431 100644 --- a/modules/videos/test/test_transcode_audio.py +++ b/modules/videos/test/test_transcode_audio.py @@ -1,4 +1,5 @@ -"""Audio-only transcodes: removing the video stream from a video, and transcoding audio files.""" +"""Audio-only transcodes: removing the video stream from a video, and transcoding audio files. +Also the mirror: removing the audio stream from a video (`REMOVE_AUDIO`).""" import json import pathlib import shutil @@ -7,7 +8,7 @@ from unittest import mock import pytest from modules.videos.transcode import transcode_video_file, validate_transcode_request, verify_transcode_output, \ - REMOVE_VIDEO, TRANSCODE_AUDIO_CONTAINERS, default_audio_container, transcode_video_job + REMOVE_VIDEO, REMOVE_AUDIO, TRANSCODE_AUDIO_CONTAINERS, default_audio_container, transcode_video_job from wrolpi import jobs from wrolpi.cmd import CommandResult from wrolpi.vars import PROJECT_DIR @@ -263,3 +264,160 @@ async def test_transcode_api_remove_video_needs_audio_container(async_client, te 'container': 'm4a'})) assert response.status == 200, response.json jobs.cancel_job(response.json['job_id']) + + +# ------------------------------------------------------------------ remove audio + +def test_validate_remove_audio(): + """Removing the audio needs a video container; both streams cannot be removed.""" + validate_transcode_request(None, REMOVE_AUDIO, 'mp4') + validate_transcode_request('h264', REMOVE_AUDIO, 'mkv') + validate_transcode_request(None, REMOVE_AUDIO, 'mp4', fragmented=True) + with pytest.raises(ValueError, match='video container'): + validate_transcode_request(None, REMOVE_AUDIO, 'm4a') + with pytest.raises(ValueError, match='video container'): + validate_transcode_request(None, REMOVE_AUDIO, 'mp3') + with pytest.raises(ValueError, match='both'): + validate_transcode_request(REMOVE_VIDEO, REMOVE_AUDIO, 'mp4') + with pytest.raises(ValueError, match='both'): + validate_transcode_request(REMOVE_VIDEO, REMOVE_AUDIO, 'm4a') + + +def test_verify_remove_audio_output(): + """The output must have the video stream and must not have an audio stream.""" + source = make_probe('vp9', 'opus', 100.0) + verify_transcode_output(source, make_probe('h264', None, 100.0), 'h264', REMOVE_AUDIO, 'mp4') + verify_transcode_output(source, make_probe('vp9', None, 100.0), None, REMOVE_AUDIO, 'mp4') + with pytest.raises(RuntimeError, match='still has an audio stream'): + verify_transcode_output(source, make_probe('vp9', 'opus', 100.0), None, REMOVE_AUDIO, 'mp4') + with pytest.raises(RuntimeError, match='no video stream'): + verify_transcode_output(source, make_probe(None, None, 100.0), None, REMOVE_AUDIO, 'mp4') + # A silent source can have its (absent) audio removed; the result is unchanged. + verify_transcode_output(make_probe('vp9', None, 100.0), make_probe('vp9', None, 100.0), None, REMOVE_AUDIO, 'mp4') + + +@pytest.mark.asyncio +async def test_remove_audio(test_directory, async_client): + """Removing the audio: the video is mapped, `-an` drops the audio, no `-c:a`, the output keeps a + video container and the video stream is copied unless a target is given.""" + video_path = test_directory / 'movie.webm' + video_path.write_bytes(b'fake video data') + probes = FakeProbes(make_probe('vp9', 'opus', 10.0, 'matroska,webm'), make_probe('vp9', None, 10.0, 'matroska,webm')) + + with mock.patch('modules.videos.transcode.probe_for_verify', side_effect=probes), \ + mock.patch('modules.videos.transcode.run_command', side_effect=async_fake_ffmpeg) as mock_run: + result = await transcode_video_file(video_path, target_vcodec=None, target_acodec=REMOVE_AUDIO, + container='mkv') + + assert result == test_directory / 'movie.mkv' and result.is_file() + assert not video_path.exists() + cmd = mock_run.call_args_list[0][0][0] + assert '-an' in cmd + assert '-vn' not in cmd + assert '0:a:0?' not in cmd and '0:a:0' not in cmd + assert cmd[cmd.index('-map') + 1] == '0:V:0' + assert '-c:a' not in cmd + assert cmd[cmd.index('-c:v') + 1] == 'copy' + assert str(cmd[-1]).endswith('movie.transcode.mkv') + + +@pytest.mark.asyncio +async def test_remove_audio_with_video_target(test_directory, async_client): + video_path = test_directory / 'movie.webm' + video_path.write_bytes(b'fake video data') + probes = FakeProbes(make_probe('vp9', 'opus', 10.0, 'matroska,webm'), make_probe('h264', None, 10.0)) + + with mock.patch('modules.videos.transcode.probe_for_verify', side_effect=probes), \ + mock.patch('modules.videos.transcode.run_command', side_effect=async_fake_ffmpeg) as mock_run: + result = await transcode_video_file(video_path, target_vcodec='h264', target_acodec=REMOVE_AUDIO, + container='mp4') + + assert result == test_directory / 'movie.mp4' + cmd = mock_run.call_args_list[0][0][0] + assert '-an' in cmd + assert cmd[cmd.index('-c:v') + 1] == 'libx264' + assert '-c:a' not in cmd + assert '+faststart' in cmd + + +@pytest.mark.asyncio +async def test_remove_audio_from_audio_source_is_refused(test_directory, async_client): + """An audio file with its audio removed is nothing; ffmpeg is never run.""" + audio_path = test_directory / 'song.mp3' + audio_path.write_bytes(b'fake audio data') + probes = FakeProbes(make_probe(None, 'mp3', 10.0, 'mp3'), make_probe(None, 'mp3', 10.0, 'mp3')) + + with mock.patch('modules.videos.transcode.probe_for_verify', side_effect=probes), \ + mock.patch('modules.videos.transcode.run_command', side_effect=async_fake_ffmpeg) as mock_run: + with pytest.raises(RuntimeError, match='no video stream'): + await transcode_video_file(audio_path, target_acodec=REMOVE_AUDIO, container='mp4') + mock_run.assert_not_called() + assert audio_path.is_file() + + +def test_resolve_output_refuses_removing_both(): + """Even without `validate_transcode_request`, ffmpeg is never asked for nothing.""" + from modules.videos.transcode import resolve_output + with pytest.raises(RuntimeError, match='both'): + resolve_output(make_probe('h264', 'aac'), REMOVE_VIDEO, REMOVE_AUDIO, 'mp4') + with pytest.raises(RuntimeError, match='both'): + resolve_output(make_probe(None, 'aac'), REMOVE_VIDEO, REMOVE_AUDIO, 'mp3') + + +@pytest.mark.asyncio +async def test_remove_audio_job(test_session, test_directory, async_client, video_factory): + """The Job removes the audio in place: same FileGroup, still a video.""" + from modules.videos.models import Video + from wrolpi.files.models import FileGroup + + video = video_factory() + video.file_group.length = 5 + test_session.commit() + file_group_id = video.file_group_id + video_path = video.video_path + probes = FakeProbes(make_probe('h264', 'aac', 5.3), make_probe('h264', None, 5.3)) + + async def fake_run_command(cmd, stdout_callback=None, **kwargs): + if cmd[-1] != '-': + shutil.copy(PROJECT_DIR / 'test/big_buck_bunny_720p_1mb.mp4', cmd[-1]) + return CommandResult(return_code=0, cancelled=False, stdout=b'', stderr=b'', elapsed=1) + + with mock.patch('modules.videos.transcode.probe_for_verify', side_effect=probes), \ + mock.patch('wrolpi.cmd.run_command', side_effect=fake_run_command): + job_id = transcode_video_job.enqueue(file_group_id=file_group_id, video_codec=None, + audio_codec=REMOVE_AUDIO, container='mp4') + record = await jobs.wait_for_job(job_id) + + assert record['status'] == jobs.COMPLETE, record + assert video_path.with_suffix('.mp4').is_file() + + test_session.expire_all() + video = Video.find_by_file_group_id(test_session, file_group_id) + assert video.file_group.mimetype.startswith('video/') + assert test_session.query(FileGroup).count() == 1 + + +@pytest.mark.asyncio +async def test_transcode_api_remove_audio(async_client, test_session, video_factory, audio_factory): + """Remove-audio is queued for a video with a video container; refused on an audio file, with an + audio container, and together with remove-video.""" + video = video_factory() + audio = audio_factory() + test_session.commit() + + request, response = await async_client.post(f'/api/videos/{video.file_group_id}/transcode', + content=json.dumps({'audio_codec': REMOVE_AUDIO, 'container': 'mp4'})) + assert response.status == 200, response.json + assert 'Remove audio' in jobs.get_job(response.json['job_id'])['description'] + jobs.cancel_job(response.json['job_id']) + + request, response = await async_client.post(f'/api/videos/{video.file_group_id}/transcode', + content=json.dumps({'audio_codec': REMOVE_AUDIO, 'container': 'm4a'})) + assert response.status == 400, response.json + request, response = await async_client.post(f'/api/videos/{video.file_group_id}/transcode', + content=json.dumps({'video_codec': REMOVE_VIDEO, + 'audio_codec': REMOVE_AUDIO, 'container': 'mp4'})) + assert response.status == 400, response.json + request, response = await async_client.post(f'/api/videos/{audio.file_group_id}/transcode', + content=json.dumps({'audio_codec': REMOVE_AUDIO, 'container': 'mp4'})) + assert response.status == 400, response.json diff --git a/modules/videos/transcode.py b/modules/videos/transcode.py index 3a9a970d..c1df55b8 100644 --- a/modules/videos/transcode.py +++ b/modules/videos/transcode.py @@ -57,6 +57,8 @@ AUDIO_CONTAINER_CODECS = { } # `target_vcodec` value meaning "drop the video stream"; the output is an audio file. REMOVE_VIDEO = 'none' +# `target_acodec` value meaning "drop the audio stream"; the output is a silent video. +REMOVE_AUDIO = 'none' # ffprobe's `format_name` token for each container (mkv reports "matroska,webm"). CONTAINER_FORMAT_NAMES = {'mp4': 'mp4', 'mkv': 'matroska', 'm4a': 'm4a', 'ogg': 'ogg', 'mp3': 'mp3'} # `-movflags +faststart` is an mp4-muxer option; other muxers warn about it. @@ -184,7 +186,8 @@ async def transcode_video_file(video_path: pathlib.Path, raise RuntimeError('ffmpeg was not found') # No target for either stream is a remux: both streams are copied into the (new) container. # `target_vcodec=REMOVE_VIDEO` (or an audio-only source) makes the output an audio file; see - # `resolve_output`, which also settles an unsuitable container. + # `resolve_output`, which also settles an unsuitable container. `target_acodec=REMOVE_AUDIO` + # makes the output a silent video. lock = await _acquire_transcode_lock() try: @@ -216,8 +219,9 @@ def verify_transcode_output(source: dict, output: dict, target_vcodec: Optional[ complete rendition of the source before the source is replaced by it. Checks: the output parses (its index was written), it is in the requested container, it has a - video stream (and an audio stream when the source has one), each stream's codec is the target - or, when copied, the source's, and the duration matches within a tolerance. + video stream (and an audio stream when the source has one, unless the audio was removed), each + stream's codec is the target or, when copied, the source's, and the duration matches within a + tolerance. @raise RuntimeError: describing the first failed check. """ @@ -242,12 +246,17 @@ def verify_transcode_output(source: dict, output: dict, target_vcodec: Optional[ else: if not output_video: raise RuntimeError('Transcode output has no video stream') - if source_audio and not output_audio: + if target_acodec == REMOVE_AUDIO and output_audio: + raise RuntimeError(f'Transcode output still has an audio stream ({output_audio[0]})') + if source_audio and not output_audio and target_acodec != REMOVE_AUDIO: raise RuntimeError('Transcode output has no audio stream, but the source has one') expected_video = target_vcodec or (source_video[0] if source_video else None) if expected_video and output_video[0] != expected_video: raise RuntimeError(f'Transcode output video codec is {output_video[0]}, expected {expected_video}') - expected_audio = target_acodec or (source_audio[0] if source_audio else None) + if target_acodec == REMOVE_AUDIO: + expected_audio = None + else: + expected_audio = target_acodec or (source_audio[0] if source_audio else None) if output_audio and expected_audio and output_audio[0] != expected_audio: raise RuntimeError(f'Transcode output audio codec is {output_audio[0]}, expected {expected_audio}') @@ -307,8 +316,12 @@ def resolve_output(source_probe: dict, target_vcodec: Optional[str], target_acod source_video = get_stream_codec_names(source_probe, 'video') source_audio = get_stream_codec_names(source_probe, 'audio') + if target_vcodec == REMOVE_VIDEO and target_acodec == REMOVE_AUDIO: + raise RuntimeError('Cannot remove both the video and the audio; nothing would remain') if target_vcodec and target_vcodec != REMOVE_VIDEO and not source_video: raise RuntimeError(f'Cannot transcode video to {target_vcodec}: the file has no video stream') + if target_acodec == REMOVE_AUDIO and not source_video: + raise RuntimeError('Cannot remove the audio: the file has no video stream, nothing would remain') audio_only = target_vcodec == REMOVE_VIDEO or not source_video if not audio_only: @@ -354,16 +367,22 @@ async def _transcode_video_file(video_path: pathlib.Path, target_vcodec: Optiona source_probe = await probe_for_verify(video_path) audio_only, container = resolve_output(source_probe, target_vcodec, target_acodec, container) - audio_args = TRANSCODE_AUDIO_TARGETS[target_acodec] if target_acodec else ('-c:a', 'copy') if audio_only: # `-vn` also drops embedded cover art; the audio stream is required. map_args = ('-map', '0:a:0', '-vn') video_args = () + audio_args = TRANSCODE_AUDIO_TARGETS[target_acodec] if target_acodec else ('-c:a', 'copy') + elif target_acodec == REMOVE_AUDIO: + # `-an`: a silent video; only the video stream is mapped. + map_args = ('-map', '0:V:0', '-an') + video_args = TRANSCODE_VIDEO_TARGETS[target_vcodec] if target_vcodec else ('-c:v', 'copy') + audio_args = () else: # 0:V:0 excludes attached-picture streams (embedded thumbnails); 0:a:0? tolerates a video # with no audio stream. map_args = ('-map', '0:V:0', '-map', '0:a:0?') video_args = TRANSCODE_VIDEO_TARGETS[target_vcodec] if target_vcodec else ('-c:v', 'copy') + audio_args = TRANSCODE_AUDIO_TARGETS[target_acodec] if target_acodec else ('-c:a', 'copy') if container in FASTSTART_CONTAINERS and fragmented: mux_args = ('-movflags', FRAGMENTED_MOVFLAGS, '-frag_duration', str(FRAGMENT_DURATION_US)) elif container in FASTSTART_CONTAINERS: @@ -438,12 +457,15 @@ async def _transcode_video_file(video_path: pathlib.Path, target_vcodec: Optiona def validate_transcode_request(video_codec: Optional[str], audio_codec: Optional[str], container: str, fragmented: bool = False): """@raise ValueError: when the request cannot be transcoded. No codec at all is a remux; - `video_codec=REMOVE_VIDEO` drops the video stream and needs an audio container; `fragmented` + `video_codec=REMOVE_VIDEO` drops the video stream and needs an audio container; + `audio_codec=REMOVE_AUDIO` drops the audio stream and needs a video container; `fragmented` needs an mp4 container.""" if video_codec and video_codec != REMOVE_VIDEO and video_codec not in TRANSCODE_VIDEO_TARGETS: raise ValueError(f'Cannot transcode video to {video_codec!r}; supported: {sorted(TRANSCODE_VIDEO_TARGETS)}') - if audio_codec and audio_codec not in TRANSCODE_AUDIO_TARGETS: + if audio_codec and audio_codec != REMOVE_AUDIO and audio_codec not in TRANSCODE_AUDIO_TARGETS: raise ValueError(f'Cannot transcode audio to {audio_codec!r}; supported: {sorted(TRANSCODE_AUDIO_TARGETS)}') + if video_codec == REMOVE_VIDEO and audio_codec == REMOVE_AUDIO: + raise ValueError('Cannot remove both the video and the audio; nothing would remain') containers = TRANSCODE_CONTAINERS + TRANSCODE_AUDIO_CONTAINERS if container not in containers: raise ValueError(f'Unsupported container {container!r}; supported: {containers}') @@ -451,6 +473,8 @@ def validate_transcode_request(video_codec: Optional[str], audio_codec: Optional raise ValueError(f'A fragmented file must be mp4 (or m4a), not {container}') if video_codec == REMOVE_VIDEO and container not in TRANSCODE_AUDIO_CONTAINERS: raise ValueError(f'Removing the video needs an audio container ({TRANSCODE_AUDIO_CONTAINERS}), not {container}') + if audio_codec == REMOVE_AUDIO and container not in TRANSCODE_CONTAINERS: + raise ValueError(f'Removing the audio needs a video container ({TRANSCODE_CONTAINERS}), not {container}') if container in TRANSCODE_AUDIO_CONTAINERS: if video_codec and video_codec != REMOVE_VIDEO: raise ValueError(f'A video stream cannot be stored in {container}; remove the video or choose a video container') diff --git a/modules/videos/video/api.py b/modules/videos/video/api.py index c1ca769c..3f27ad65 100644 --- a/modules/videos/video/api.py +++ b/modules/videos/video/api.py @@ -12,7 +12,7 @@ from wrolpi.errors import InvalidOrderBy, InvalidJob from wrolpi.schema import JSONErrorResponse from . import lib from .. import schema -from ..transcode import transcode_video_job, validate_transcode_request, REMOVE_VIDEO +from ..transcode import transcode_video_job, validate_transcode_request, REMOVE_VIDEO, REMOVE_AUDIO video_bp = Blueprint('Video', '/api/videos') @@ -88,8 +88,12 @@ async def video_transcode(_: Request, file_group_id: int, body: schema.VideoTran is_audio = (video.file_group.mimetype or '').startswith('audio/') if is_audio and body.video_codec and body.video_codec != REMOVE_VIDEO: raise InvalidJob(f'This is an audio file; it has no video stream to transcode to {body.video_codec}') + if is_audio and body.audio_codec == REMOVE_AUDIO: + raise InvalidJob('This is an audio file; removing its audio would leave nothing') if body.video_codec == REMOVE_VIDEO and not is_audio: action = 'Extract audio from' + elif body.audio_codec == REMOVE_AUDIO: + action = 'Remove audio from' elif not body.video_codec and not body.audio_codec: action = 'Remux' else: From 4fea50943b086897f669001a75cc8708a791afb5 Mon Sep 17 00:00:00 2001 From: lrnselfreliance <33263708+lrnselfreliance@users.noreply.github.com> Date: Sun, 27 Sep 2026 08:23:03 -0600 Subject: [PATCH 043/181] map: clicking a POI shows its kind, detail, elevation and coordinates (#738) The Protomaps basemap keeps a few attributes per POI beyond the name the map already labels: kind, kind_detail and elevation. Clicking a POI now opens a popup with those, plus the feature's coordinates (or the click location when the feature has no point). The same POI appears at several min_zoom levels within one tile, so features are deduped by name and kind. Map popups (this one and the existing pin popup) now follow the active theme's tokens instead of MapLibre's fixed white, covering light, dark, night and amber. Co-authored-by: Claude Fable 5.1 --- app/src/App.css | 32 ++++++++++++ app/src/components/MapPoiPopup.test.js | 63 ++++++++++++++++++++++++ app/src/components/MapViewer.js | 24 +++++++++ app/src/components/mapPoiPopup.js | 68 ++++++++++++++++++++++++++ 4 files changed, 187 insertions(+) create mode 100644 app/src/components/MapPoiPopup.test.js create mode 100644 app/src/components/mapPoiPopup.js diff --git a/app/src/App.css b/app/src/App.css index d0f46346..2644e9fd 100644 --- a/app/src/App.css +++ b/app/src/App.css @@ -725,3 +725,35 @@ tr.drag-deselecting td { display: block; } } + +/* Map popups (POI details and pins) follow the active theme's tokens. */ +/* Scoped under .maplibregl-popup to outrank maplibre-gl.css, which loads after this file. */ +.maplibregl-popup .maplibregl-popup-content { + background: var(--panel); + color: var(--text); + border: 1px solid var(--border); + border-radius: 0.25rem; +} +.maplibregl-popup .maplibregl-popup-close-button { + color: var(--muted); +} +.maplibregl-popup .maplibregl-popup-close-button:hover { + background: var(--head); + color: var(--text); +} +.maplibregl-popup.maplibregl-popup-anchor-top .maplibregl-popup-tip, +.maplibregl-popup.maplibregl-popup-anchor-top-left .maplibregl-popup-tip, +.maplibregl-popup.maplibregl-popup-anchor-top-right .maplibregl-popup-tip { + border-bottom-color: var(--panel); +} +.maplibregl-popup.maplibregl-popup-anchor-bottom .maplibregl-popup-tip, +.maplibregl-popup.maplibregl-popup-anchor-bottom-left .maplibregl-popup-tip, +.maplibregl-popup.maplibregl-popup-anchor-bottom-right .maplibregl-popup-tip { + border-top-color: var(--panel); +} +.maplibregl-popup.maplibregl-popup-anchor-left .maplibregl-popup-tip { + border-right-color: var(--panel); +} +.maplibregl-popup.maplibregl-popup-anchor-right .maplibregl-popup-tip { + border-left-color: var(--panel); +} diff --git a/app/src/components/MapPoiPopup.test.js b/app/src/components/MapPoiPopup.test.js new file mode 100644 index 00000000..bb6d248a --- /dev/null +++ b/app/src/components/MapPoiPopup.test.js @@ -0,0 +1,63 @@ +import {poiPopupHtml} from './mapPoiPopup'; + +// Clicking a POI on the map opens a popup built from attributes already in the pmtiles +// file: the name, its category (kind), sub-category (kind_detail), and elevation. + +const feature = (properties) => ({properties}); + +test('name, kind, detail and elevation are all shown, humanized', () => { + const html = poiPopupHtml([feature({ + name: 'Flattop Mountain', kind: 'peak', kind_detail: 'volcano', elevation: 1070, + })]); + expect(html).toContain('Flattop Mountain'); + expect(html).toContain('Peak'); + expect(html).toContain('Volcano'); + expect(html).toContain('1070 m'); + expect(html).toContain('3510 ft'); +}); + +test('missing attributes are left out rather than shown as undefined', () => { + const html = poiPopupHtml([feature({name: 'Franz', kind: 'bakery'})]); + expect(html).toContain('Franz'); + expect(html).toContain('Bakery'); + expect(html).not.toContain('undefined'); + expect(html).not.toContain('Elevation'); +}); + +test('kind_detail of "none" is not shown', () => { + const html = poiPopupHtml([feature({name: 'West High', kind: 'school', kind_detail: 'none'})]); + expect(html).not.toContain('none'); + expect(html).not.toContain('None'); +}); + +test('the same feature at several min_zoom levels appears once', () => { + const html = poiPopupHtml([ + feature({name: 'Westchester Lagoon', kind: 'water', min_zoom: 12}), + feature({name: 'Westchester Lagoon', kind: 'water', min_zoom: 14}), + ]); + expect(html.match(/Westchester Lagoon/g)).toHaveLength(1); +}); + +test('feature properties are HTML escaped', () => { + const html = poiPopupHtml([feature({name: '', kind: 'a&b'})]); + expect(html).not.toContain(' { + const html = poiPopupHtml([{ + properties: {name: 'Peak Two', kind: 'peak'}, + geometry: {type: 'Point', coordinates: [-149.67301, 61.08571]}, + }], {lng: -149.5, lat: 61.0}); + expect(html).toContain('61.08571, -149.67301'); +}); + +test('a feature without a point falls back to the clicked location', () => { + const html = poiPopupHtml([feature({name: 'Lagoon', kind: 'water'})], {lng: -149.5, lat: 61.0}); + expect(html).toContain('61.00000, -149.50000'); +}); + +test('no features gives no popup', () => { + expect(poiPopupHtml([])).toBeNull(); +}); diff --git a/app/src/components/MapViewer.js b/app/src/components/MapViewer.js index 888bb78a..08469f6c 100644 --- a/app/src/components/MapViewer.js +++ b/app/src/components/MapViewer.js @@ -10,6 +10,7 @@ import {MAP_VIEWER_URI} from "./Vars"; import {Button, Checkbox, Header, Icon, Panel, TextInput, Toggle} from "./ui"; import {SettingsContext, ThemeContext} from "../contexts/contexts"; import {mapFlavor, mapSprite} from "../themes/names"; +import {poiPopupHtml} from "./mapPoiPopup"; // Terrain DEM file prefix for hillshade and contours. const TERRAIN_PREFIX = "terrain-"; @@ -675,6 +676,29 @@ export default function MapViewer() { map.on("click", () => setContextMenu(null)); map.on("movestart", () => setContextMenu(null)); + // Click a POI to see the details the pmtiles file already has (kind, detail, elevation). + const poiLayerIds = () => (map.getStyle()?.layers || []) + .map(l => l.id) + .filter(id => id === "pois" || id.endsWith(":pois")) + .filter(id => map.getLayer(id)); + map.on("click", (e) => { + if (destroyed) return; + const layers = poiLayerIds(); + if (!layers.length) return; + const html = poiPopupHtml(map.queryRenderedFeatures(e.point, {layers}), e.lngLat); + if (!html) return; + new maplibregl.Popup({offset: 8, maxWidth: "260px", className: "wrolpi-map-popup"}) + .setLngLat(e.lngLat) + .setHTML(html) + .addTo(map); + }); + map.on("mousemove", (e) => { + if (destroyed) return; + const layers = poiLayerIds(); + const hit = layers.length && map.queryRenderedFeatures(e.point, {layers}).length; + map.getCanvas().style.cursor = hit ? "pointer" : ""; + }); + // Long-press context menu (mobile). let longPressTimer = null; let touchStartPos = null; diff --git a/app/src/components/mapPoiPopup.js b/app/src/components/mapPoiPopup.js new file mode 100644 index 00000000..b744b71c --- /dev/null +++ b/app/src/components/mapPoiPopup.js @@ -0,0 +1,68 @@ +// Popup content for a clicked map POI, built only from attributes already in the pmtiles file. +// The Protomaps basemap keeps name, kind, kind_detail and elevation; there is no address, +// phone or opening hours to show. + +function escapeHtml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +// "american_football" -> "American football" +function humanize(value) { + const s = String(value).replace(/_/g, " ").trim(); + return s.charAt(0).toUpperCase() + s.slice(1); +} + +function formatCoordinates(lon, lat) { + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; + return `${lat.toFixed(5)}, ${lon.toFixed(5)}`; +} + +// Returns the popup HTML for the clicked POI features, or null when nothing useful was clicked. +// The same POI is often present at several min_zoom levels in one tile, so features are deduped +// by name and kind. Coordinates come from the feature's own point when it has one, otherwise +// from where the user clicked (clickLngLat is {lng, lat}). +export function poiPopupHtml(features, clickLngLat = null) { + const seen = new Set(); + const items = []; + for (const feature of features || []) { + const p = feature?.properties || {}; + const name = p.name || p["name:en"]; + if (!name && !p.kind) continue; + const key = `${name}|${p.kind}`; + if (seen.has(key)) continue; + seen.add(key); + + const rows = []; + if (p.kind) { + const detail = p.kind_detail && p.kind_detail !== "none" ? ` (${humanize(p.kind_detail)})` : ""; + rows.push(`${humanize(p.kind)}${detail}`); + } + if (p.elevation !== undefined && p.elevation !== null && p.elevation !== "" + && Number.isFinite(Number(p.elevation))) { + const meters = Math.round(Number(p.elevation)); + const feet = Math.round(meters * 3.28084); + rows.push(`Elevation: ${meters} m / ${feet} ft`); + } + const geometry = feature?.geometry; + const point = geometry?.type === "Point" && Array.isArray(geometry.coordinates) + ? geometry.coordinates : null; + const coordinates = point + ? formatCoordinates(Number(point[0]), Number(point[1])) + : clickLngLat && formatCoordinates(Number(clickLngLat.lng), Number(clickLngLat.lat)); + if (coordinates) rows.push(coordinates); + + items.push( + `
` + + `${escapeHtml(name || humanize(p.kind))}` + + rows.map(r => `
${escapeHtml(r)}`).join("") + + `
` + ); + } + if (!items.length) return null; + return `
${items.join("")}
`; +} From 20be5dfc2724a2b62cad7a3abe935b6162e463dc Mon Sep 17 00:00:00 2001 From: lrnselfreliance <33263708+lrnselfreliance@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:25:12 -0600 Subject: [PATCH 044/181] collections: channel and domain listings read trigger-maintained summary columns (#739) * collections: channel listing reads trigger columns and batch-loads tag/downloads Listing channel collections summed FileGroup sizes through a video join, which reads a random page of the file_group table per video, and lazy-loaded every collection's tag and downloads one query at a time. On a large library with a cold disk cache the listing took over two minutes, past the browser's 60 s timeout, so the Channels page showed an error. channel.video_count and channel.total_size are already maintained by triggers (wrolpi/schema_ddl.py), so read those. Tag and downloads are selectin-loaded, so the query count no longer grows with the number of collections. Co-Authored-By: Claude Fable 5.1 * collections: domain listing reads trigger-maintained summary columns Domain Collections summed file_group.size through an archive join on every listing, one random page read per archive (about 9 s from a cold disk cache on a large library). New triggers keep collection.item_count and collection.total_size current for Domain Collections, as the channel triggers already do for Channels, and the listing reads those columns. A migration installs the triggers and backfills existing Domain Collections. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 --- ...26_09_27_1200_domain_collection_summary.py | 45 +++++++ wrolpi/collections/lib.py | 50 +++----- wrolpi/collections/test/test_api.py | 115 ++++++++++++++++++ wrolpi/schema_ddl.py | 52 +++++++- wrolpi/test/test_db_bootstrap.py | 53 ++++++++ 5 files changed, 281 insertions(+), 34 deletions(-) create mode 100644 alembic/versions/2026_09_27_1200_domain_collection_summary.py diff --git a/alembic/versions/2026_09_27_1200_domain_collection_summary.py b/alembic/versions/2026_09_27_1200_domain_collection_summary.py new file mode 100644 index 00000000..405dbfe1 --- /dev/null +++ b/alembic/versions/2026_09_27_1200_domain_collection_summary.py @@ -0,0 +1,45 @@ +"""Maintain collection.item_count/total_size for Domain Collections with triggers. + +Listing Domain Collections summed file_group.size through an archive join on every request, +one random page read per archive. The new triggers keep the summary columns current +(as the channel triggers already do for Channels); this migration installs them and +backfills existing Domain Collections. + +Revision ID: 2026_09_27_1200 +Revises: 2026_09_20_1200 +""" +from alembic import op + +# revision identifiers, used by Alembic. +revision = '2026_09_27_1200' +down_revision = '2026_09_20_1200' +branch_labels = None +depends_on = None + +TRIGGERS = ( + 'archive_insert_collection_summary', + 'archive_delete_collection_summary', + 'archive_update_collection_summary', + 'file_group_size_collection_summary', +) + + +def upgrade(): + from wrolpi.schema_ddl import install_raw_ddl + + # Idempotent: only the new triggers are created. + install_raw_ddl(op.get_bind()) + + op.execute(''' + UPDATE collection SET + item_count = (SELECT COUNT(*) FROM archive WHERE collection_id = collection.id), + total_size = (SELECT COALESCE(SUM(fg.size), 0) FROM archive a + LEFT JOIN file_group fg ON a.file_group_id = fg.id + WHERE a.collection_id = collection.id) + WHERE kind = 'domain' + ''') + + +def downgrade(): + for name in TRIGGERS: + op.execute(f'DROP TRIGGER IF EXISTS {name}') diff --git a/wrolpi/collections/lib.py b/wrolpi/collections/lib.py index 50fcd5c6..03082b42 100644 --- a/wrolpi/collections/lib.py +++ b/wrolpi/collections/lib.py @@ -8,7 +8,7 @@ import pathlib from typing import List, Optional, Dict from sqlalchemy import func -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, selectinload from wrolpi import flags from wrolpi.common import logger, get_relative_to_media_directory, TRACE_LEVEL, background_task @@ -70,12 +70,12 @@ def get_collections(session: Session, kind: Optional[str] = None) -> List[dict]: Returns: List of collection dicts with statistics for each collection type """ - # Local imports to avoid circular import: collections -> archive/videos -> collections - from modules.archive import Archive - from modules.videos.models import Video, Channel - from wrolpi.files.models import FileGroup + # Local imports to avoid circular import: collections -> videos -> collections + from modules.videos.models import Channel - query = session.query(Collection) + # Serializing a Collection reads its tag and downloads; load them in two batch queries rather + # than one lazy query per Collection (thousands of Collections on a large library). + query = session.query(Collection).options(selectinload(Collection.tag), selectinload(Collection.downloads)) if kind: query = query.filter(Collection.kind == kind) @@ -86,40 +86,23 @@ def get_collections(session: Session, kind: Optional[str] = None) -> List[dict]: return [] # Separate collections by kind for batch processing - domain_ids = [c.id for c in collections if c.kind == 'domain'] channel_ids = [c.id for c in collections if c.kind == 'channel'] generic_ids = [c.id for c in collections if c.kind not in ('domain', 'channel')] - # Batch query: Get archive stats for all domain collections at once - domain_stats_map = {} - if domain_ids: - domain_stats = session.query( - Archive.collection_id, - func.count(Archive.id).label('archive_count'), - func.coalesce(func.sum(FileGroup.size), 0).label('size') - ).outerjoin( - FileGroup, FileGroup.id == Archive.file_group_id - ).filter( - Archive.collection_id.in_(domain_ids) - ).group_by(Archive.collection_id).all() - - domain_stats_map = {s.collection_id: s for s in domain_stats} - - # Batch query: Get channel info and video stats for all channel collections at once + # Batch query: Get channel info and video stats for all channel collections at once. + # `channel.video_count` and `channel.total_size` are maintained by triggers (see + # wrolpi/schema_ddl.py); summing FileGroup sizes here instead read a random page of the + # file_group table per video, which took minutes from a cold disk cache on a large library. channel_stats_map = {} if channel_ids: channel_stats = session.query( Channel.collection_id, Channel.id.label('channel_id'), - func.count(Video.id).label('video_count'), - func.coalesce(func.sum(FileGroup.size), 0).label('size') - ).outerjoin( - Video, Video.channel_id == Channel.id - ).outerjoin( - FileGroup, FileGroup.id == Video.file_group_id + Channel.video_count, + Channel.total_size.label('size'), ).filter( Channel.collection_id.in_(channel_ids) - ).group_by(Channel.collection_id, Channel.id).all() + ).all() channel_stats_map = {s.collection_id: s for s in channel_stats} @@ -151,9 +134,10 @@ def get_collections(session: Session, kind: Optional[str] = None) -> List[dict]: # Add type-specific statistics from batch query results if collection.kind == 'domain': - stats = domain_stats_map.get(collection.id) - data['archive_count'] = stats.archive_count if stats else 0 - data['size'] = int(stats.size) if stats else 0 + # `collection.item_count` and `collection.total_size` are maintained by the archive + # triggers (see wrolpi/schema_ddl.py); no join through file_group needed. + data['archive_count'] = collection.item_count + data['size'] = collection.total_size data['domain'] = data['name'] # Alias for backward compatibility elif collection.kind == 'channel': diff --git a/wrolpi/collections/test/test_api.py b/wrolpi/collections/test/test_api.py index ea788706..7554d566 100644 --- a/wrolpi/collections/test/test_api.py +++ b/wrolpi/collections/test/test_api.py @@ -2,6 +2,7 @@ from http import HTTPStatus import pytest +from sqlalchemy import text as sa_text from wrolpi.collections.models import Collection @@ -577,3 +578,117 @@ class TestCollectionTagging: # No conflict checking needed without a directory assert data['conflict'] is False assert data['conflict_message'] is None + + +@pytest.mark.asyncio +async def test_get_channel_collections_uses_summary_columns_and_batches_queries( + async_client, test_session, test_directory, channel_factory, video_factory, tag_factory): + """Listing channel collections reads the trigger-maintained channel summary columns and + loads downloads and tags in batches, so the query count does not grow with the channel count. + + A production DB was measured at 143 s cold for this listing: summing FileGroup sizes touched a + random page of the 2 GB file_group table per video, and each collection lazy-loaded its + downloads and tag one query at a time. + """ + from sqlalchemy import event + from wrolpi.collections import lib + from wrolpi.downloader import DownloadFrequency + + await tag_factory('one') + await tag_factory('two') + channels = [] + for i in range(6): + tag_name = ('one', 'two', None)[i % 3] + channel = channel_factory(name=f'Channel {i}', download_frequency=DownloadFrequency.weekly, tag_name=tag_name) + video_factory(channel_id=channel.id, with_video_file=True) + video_factory(channel_id=channel.id, with_video_file=True) + channels.append(channel) + test_session.commit() + test_session.expire_all() + + statements = [] + engine = test_session.get_bind() + + def before_cursor_execute(conn, cursor, statement, parameters, context, executemany): + statements.append(statement) + + event.listen(engine, 'before_cursor_execute', before_cursor_execute) + try: + collections = lib.get_collections(test_session, kind='channel') + finally: + event.remove(engine, 'before_cursor_execute', before_cursor_execute) + + assert len(collections) == 6 + for channel, data in zip(channels, sorted(collections, key=lambda i: i['name'])): + assert data['channel_id'] == channel.id + assert data['video_count'] == channel.video_count == 2 + assert data['total_size'] == channel.total_size > 0 + assert data['min_download_frequency'] == DownloadFrequency.weekly + assert data['tag_name'] == channel.tag_name + # BEGIN, collections, channel summaries, tags, downloads: fixed count regardless of channel count. + assert len(statements) <= 5, '\n'.join(statements) + assert not any('file_group' in s for s in statements), 'Channel sizes must come from channel.total_size' + + +@pytest.mark.asyncio +async def test_domain_collection_summary_columns_follow_archives( + async_client, test_session, test_directory, archive_factory): + """`collection.item_count` and `collection.total_size` of a Domain Collection are kept current + by triggers as Archives are added, resized, re-assigned and deleted, and the listing reads + them without joining file_group.""" + from sqlalchemy import event + from wrolpi.collections import lib + from wrolpi.files.models import FileGroup + + archive1 = archive_factory(domain='example.com', title='One', url='https://example.com/1') + archive2 = archive_factory(domain='example.com', title='Two', url='https://example.com/2') + archive3 = archive_factory(domain='other.org', title='Three', url='https://other.org/3') + test_session.commit() + + def summaries() -> dict: + test_session.expire_all() + return {c.name: (c.item_count, c.total_size) for c in + test_session.query(Collection).filter_by(kind='domain')} + + sizes = {i.file_group.id: i.file_group.size for i in (archive1, archive2, archive3)} + assert all(sizes.values()), 'archives must have a size for this test to mean anything' + assert summaries() == { + 'example.com': (2, sizes[archive1.file_group.id] + sizes[archive2.file_group.id]), + 'other.org': (1, sizes[archive3.file_group.id]), + } + + # A FileGroup's size changes (refresh found the files grew). + test_session.query(FileGroup).filter_by(id=archive1.file_group.id).update({'size': 1_000_000}) + test_session.commit() + assert summaries()['example.com'] == (2, 1_000_000 + sizes[archive2.file_group.id]) + + # An Archive moves to another Domain. + other = test_session.query(Collection).filter_by(name='other.org').one() + test_session.execute( + sa_text('UPDATE archive SET collection_id = :cid WHERE id = :aid'), + {'cid': other.id, 'aid': archive2.id}) + test_session.commit() + assert summaries() == { + 'example.com': (1, 1_000_000), + 'other.org': (2, sizes[archive3.file_group.id] + sizes[archive2.file_group.id]), + } + + # An Archive is deleted. + test_session.execute(sa_text('DELETE FROM archive WHERE id = :aid'), {'aid': archive1.id}) + test_session.commit() + assert summaries()['example.com'] == (0, 0) + + # The listing reads the columns: no file_group in any statement. + statements = [] + engine = test_session.get_bind() + listener = lambda conn, cursor, statement, *_: statements.append(statement) # noqa: E731 + event.listen(engine, 'before_cursor_execute', listener) + try: + collections = lib.get_collections(test_session, kind='domain') + finally: + event.remove(engine, 'before_cursor_execute', listener) + by_name = {c['name']: c for c in collections} + assert (by_name['example.com']['archive_count'], by_name['example.com']['size']) == (0, 0) + assert (by_name['other.org']['archive_count'], by_name['other.org']['size']) == \ + (2, sizes[archive3.file_group.id] + sizes[archive2.file_group.id]) + assert not any('file_group' in s for s in statements), '\n'.join(statements) diff --git a/wrolpi/schema_ddl.py b/wrolpi/schema_ddl.py index b309b012..4fbf1fb1 100644 --- a/wrolpi/schema_ddl.py +++ b/wrolpi/schema_ddl.py @@ -12,7 +12,8 @@ All statements are idempotent (IF NOT EXISTS). """ # Triggers maintaining the summary columns `channel.video_count`, `channel.total_size`, -# `channel.minimum_frequency` and `file_group.effective_datetime`. +# `channel.minimum_frequency`, `collection.item_count`/`collection.total_size` (domains) and +# `file_group.effective_datetime`. # # These are re-authored from the old PL/pgSQL triggers (see git history of alembic/versions/). # The old triggers had known bugs which are fixed here: @@ -70,6 +71,55 @@ TRIGGER_DDL = [ WHERE id = (SELECT channel_id FROM video WHERE file_group_id = new.id); END ''', + # collection.item_count + collection.total_size follow the Archives of a Domain Collection. + # (Channels keep their summary on the channel table; generic Collections count collection_item.) + ''' + CREATE TRIGGER IF NOT EXISTS archive_insert_collection_summary + AFTER INSERT ON archive WHEN new.collection_id IS NOT NULL + BEGIN + UPDATE collection SET + item_count = (SELECT COUNT(*) FROM archive WHERE collection_id = collection.id), + total_size = (SELECT COALESCE(SUM(fg.size), 0) FROM archive a + LEFT JOIN file_group fg ON a.file_group_id = fg.id + WHERE a.collection_id = collection.id) + WHERE id = new.collection_id; + END + ''', + ''' + CREATE TRIGGER IF NOT EXISTS archive_delete_collection_summary + AFTER DELETE ON archive WHEN old.collection_id IS NOT NULL + BEGIN + UPDATE collection SET + item_count = (SELECT COUNT(*) FROM archive WHERE collection_id = collection.id), + total_size = (SELECT COALESCE(SUM(fg.size), 0) FROM archive a + LEFT JOIN file_group fg ON a.file_group_id = fg.id + WHERE a.collection_id = collection.id) + WHERE id = old.collection_id; + END + ''', + ''' + CREATE TRIGGER IF NOT EXISTS archive_update_collection_summary + AFTER UPDATE OF collection_id, file_group_id ON archive + BEGIN + UPDATE collection SET + item_count = (SELECT COUNT(*) FROM archive WHERE collection_id = collection.id), + total_size = (SELECT COALESCE(SUM(fg.size), 0) FROM archive a + LEFT JOIN file_group fg ON a.file_group_id = fg.id + WHERE a.collection_id = collection.id) + WHERE id IN (old.collection_id, new.collection_id); + END + ''', + ''' + CREATE TRIGGER IF NOT EXISTS file_group_size_collection_summary + AFTER UPDATE OF size ON file_group WHEN old.size IS NOT new.size + BEGIN + UPDATE collection SET + total_size = (SELECT COALESCE(SUM(fg.size), 0) FROM archive a + LEFT JOIN file_group fg ON a.file_group_id = fg.id + WHERE a.collection_id = collection.id) + WHERE id = (SELECT collection_id FROM archive WHERE file_group_id = new.id); + END + ''', # channel.minimum_frequency follows the Downloads of the Channel's Collection. ''' CREATE TRIGGER IF NOT EXISTS download_insert_minimum_frequency diff --git a/wrolpi/test/test_db_bootstrap.py b/wrolpi/test/test_db_bootstrap.py index 1098b22b..eac08b5b 100644 --- a/wrolpi/test/test_db_bootstrap.py +++ b/wrolpi/test/test_db_bootstrap.py @@ -158,6 +158,59 @@ def test_drop_file_group_idempotency_preserves_fts(test_directory): assert len(hits) == 1 +def test_domain_collection_summary_migration_backfills(test_directory): + """The domain-summary migration installs the archive triggers and backfills the summary + columns of existing Domain Collections from their Archives.""" + import sqlite3 + from alembic.config import Config + from alembic import command + from wrolpi.db import get_db_uri, get_db_file + from wrolpi.vars import PROJECT_DIR + + (test_directory / 'config').mkdir(parents=True, exist_ok=True) + config = Config(str(PROJECT_DIR / 'alembic.ini')) + config.set_main_option('sqlalchemy.url', get_db_uri()) + command.upgrade(config, '2026_09_20_1200') + + db_file = get_db_file() + directory = str(test_directory) + triggers = {'archive_insert_collection_summary', 'archive_delete_collection_summary', + 'archive_update_collection_summary', 'file_group_size_collection_summary'} + with sqlite3.connect(db_file) as conn: + conn.execute('PRAGMA foreign_keys=ON') + # The baseline installs the current trigger DDL, so a fresh DB already has the archive + # triggers. A production DB created before them does not; emulate that. + for name in triggers: + conn.execute(f'DROP TRIGGER {name}') + for id_, name, kind in ((1, 'example.com', 'domain'), (2, 'empty.org', 'domain'), (3, 'chan', 'channel')): + conn.execute('INSERT INTO collection (id, name, kind, item_count, total_size) VALUES (?, ?, ?, 0, 0)', + (id_, name, kind)) + for fg_id, stem, size in ((10, 'one', 100), (11, 'two', 250)): + conn.execute( + 'INSERT INTO file_group (id, directory, primary_path, files, stem, size, indexed)' + ' VALUES (?,?,?,?,?,?,0)', + (fg_id, directory, str(test_directory / f'{stem}.html'), '[]', stem, size), + ) + conn.execute('INSERT INTO archive (collection_id, file_group_id) VALUES (1, ?)', (fg_id,)) + conn.commit() + # Nothing maintains the columns before the migration. + assert conn.execute('SELECT item_count, total_size FROM collection WHERE id = 1').fetchone() == (0, 0) + + command.upgrade(config, 'head') + + with sqlite3.connect(db_file) as conn: + assert conn.execute('SELECT item_count, total_size FROM collection WHERE id = 1').fetchone() == (2, 350) + assert conn.execute('SELECT item_count, total_size FROM collection WHERE id = 2').fetchone() == (0, 0) + assert conn.execute('SELECT item_count, total_size FROM collection WHERE id = 3').fetchone() == (0, 0) + installed = {r[0] for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type='trigger' AND name LIKE '%collection_summary'")} + assert installed == triggers + # The triggers are live after the migration. + conn.execute('DELETE FROM archive WHERE file_group_id = 11') + conn.commit() + assert conn.execute('SELECT item_count, total_size FROM collection WHERE id = 1').fetchone() == (1, 100) + + def test_bootstrap_lock(test_directory): """Only one process can hold the bootstrap lock.""" with db_bootstrap.bootstrap_lock() as acquired: From 5c78aa332dd1180f254ebda7a536efa663c9d805 Mon Sep 17 00:00:00 2001 From: lrnselfreliance <33263708+lrnselfreliance@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:53:23 -0600 Subject: [PATCH 045/181] collections: listing answers channel and download queries from covering indexes (#740) The listing still read every channel row (for the summary columns) and every download row of the listed Collections (for the minimum frequency). Both tables store yt-dlp info_json, so rows are tens of KB and each sits on its own scattered page; selecting fewer columns did not help because the seek per row is the cost. From a cold disk cache that was 5 s per table, 10+ s total, on a large library. Two covering indexes, channel (collection_id, video_count, total_size) and download (collection_id, frequency), answer both queries without touching a row. The listing no longer loads Download objects at all; it computes min_download_frequency with one grouped query and drops the unused `downloads` array from listing rows (the detail endpoints keep it; the list views only read min_download_frequency). Adds a models-vs-migrations test (alembic check) so an index or column added to a model without a migration, or vice versa, fails the suite. Co-authored-by: Claude Fable 5.1 --- ...6_09_28_1200_collection_listing_indexes.py | 29 +++++++++++++++ modules/videos/models.py | 2 ++ wrolpi/collections/lib.py | 33 ++++++++++------- wrolpi/collections/test/test_api.py | 12 ++++++- wrolpi/downloader.py | 2 ++ wrolpi/test/test_db_bootstrap.py | 36 +++++++++++++++++++ 6 files changed, 101 insertions(+), 13 deletions(-) create mode 100644 alembic/versions/2026_09_28_1200_collection_listing_indexes.py diff --git a/alembic/versions/2026_09_28_1200_collection_listing_indexes.py b/alembic/versions/2026_09_28_1200_collection_listing_indexes.py new file mode 100644 index 00000000..d8ca602b --- /dev/null +++ b/alembic/versions/2026_09_28_1200_collection_listing_indexes.py @@ -0,0 +1,29 @@ +"""Covering indexes for the Collection listing. + +Listing Collections read every channel row (for video_count/total_size) and every download +row of those Collections (for the minimum frequency). Both tables store yt-dlp info_json, +so their rows are tens of KB and each sits on its own scattered page; from a cold disk cache +that was 10+ seconds. These indexes answer both queries without touching a row. + +Revision ID: 2026_09_28_1200 +Revises: 2026_09_27_1200 +""" +from alembic import op + +# revision identifiers, used by Alembic. +revision = '2026_09_28_1200' +down_revision = '2026_09_27_1200' +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute('CREATE INDEX IF NOT EXISTS channel_collection_summary_idx' + ' ON channel (collection_id, video_count, total_size)') + op.execute('CREATE INDEX IF NOT EXISTS download_collection_frequency_idx' + ' ON download (collection_id, frequency)') + + +def downgrade(): + op.execute('DROP INDEX IF EXISTS channel_collection_summary_idx') + op.execute('DROP INDEX IF EXISTS download_collection_frequency_idx') diff --git a/modules/videos/models.py b/modules/videos/models.py index 750a4d5b..33b4012a 100644 --- a/modules/videos/models.py +++ b/modules/videos/models.py @@ -593,6 +593,8 @@ class Channel(ModelHelper, Base): __tablename__ = 'channel' __table_args__ = ( Index('channel_minimum_frequency_idx', 'minimum_frequency'), + # Covers the Collection listing's summary query; channel rows are wide (info_json). + Index('channel_collection_summary_idx', 'collection_id', 'video_count', 'total_size'), Index('channel_source_id', 'source_id'), Index('channel_total_size_idx', 'total_size'), Index('channel_video_count_idx', 'video_count'), diff --git a/wrolpi/collections/lib.py b/wrolpi/collections/lib.py index 03082b42..799ea8bd 100644 --- a/wrolpi/collections/lib.py +++ b/wrolpi/collections/lib.py @@ -8,7 +8,7 @@ import pathlib from typing import List, Optional, Dict from sqlalchemy import func -from sqlalchemy.orm import Session, selectinload +from sqlalchemy.orm import Session, selectinload, noload from wrolpi import flags from wrolpi.common import logger, get_relative_to_media_directory, TRACE_LEVEL, background_task @@ -73,9 +73,11 @@ def get_collections(session: Session, kind: Optional[str] = None) -> List[dict]: # Local imports to avoid circular import: collections -> videos -> collections from modules.videos.models import Channel - # Serializing a Collection reads its tag and downloads; load them in two batch queries rather - # than one lazy query per Collection (thousands of Collections on a large library). - query = session.query(Collection).options(selectinload(Collection.tag), selectinload(Collection.downloads)) + # Serializing a Collection reads its tag; load them in one batch query rather than one lazy + # query per Collection (thousands of Collections on a large library). Downloads are NOT + # loaded: download rows are wide (yt-dlp info_json) and the listing only needs the minimum + # frequency, which comes from a covering index below. + query = session.query(Collection).options(selectinload(Collection.tag), noload(Collection.downloads)) if kind: query = query.filter(Collection.kind == kind) @@ -85,6 +87,18 @@ def get_collections(session: Session, kind: Optional[str] = None) -> List[dict]: if not collections: return [] + # Batch query: minimum recurring download frequency per collection. Answered entirely by + # `download_collection_frequency_idx` (a covering index), so no download row is read. + from wrolpi.downloader import Download + collection_ids = [c.id for c in collections] + min_frequency_map = dict(session.query( + Download.collection_id, + func.min(Download.frequency), + ).filter( + Download.collection_id.in_(collection_ids), + Download.frequency > 0, + ).group_by(Download.collection_id).all()) + # Separate collections by kind for batch processing channel_ids = [c.id for c in collections if c.kind == 'channel'] generic_ids = [c.id for c in collections if c.kind not in ('domain', 'channel')] @@ -123,14 +137,9 @@ def get_collections(session: Session, kind: Optional[str] = None) -> List[dict]: result = [] for collection in collections: data = collection.__json__() - - # Compute minimum download frequency from all downloads - if collection.downloads: - # Filter to recurring downloads (frequency > 0) and get minimum - recurring_frequencies = [d.frequency for d in collection.downloads if d.frequency and d.frequency > 0] - data['min_download_frequency'] = min(recurring_frequencies) if recurring_frequencies else None - else: - data['min_download_frequency'] = None + # The listing does not carry each Collection's Downloads (see above); the detail endpoint does. + data.pop('downloads', None) + data['min_download_frequency'] = min_frequency_map.get(collection.id) # Add type-specific statistics from batch query results if collection.kind == 'domain': diff --git a/wrolpi/collections/test/test_api.py b/wrolpi/collections/test/test_api.py index 7554d566..2b5cb5e7 100644 --- a/wrolpi/collections/test/test_api.py +++ b/wrolpi/collections/test/test_api.py @@ -625,9 +625,19 @@ async def test_get_channel_collections_uses_summary_columns_and_batches_queries( assert data['total_size'] == channel.total_size > 0 assert data['min_download_frequency'] == DownloadFrequency.weekly assert data['tag_name'] == channel.tag_name - # BEGIN, collections, channel summaries, tags, downloads: fixed count regardless of channel count. + # The listing does not carry Downloads; the detail endpoint does. + assert 'downloads' not in data + # BEGIN, collections, min frequencies, channel summaries, tags: fixed count regardless of channel count. assert len(statements) <= 5, '\n'.join(statements) assert not any('file_group' in s for s in statements), 'Channel sizes must come from channel.total_size' + # channel and download rows are wide (info_json); the listing must be answered by covering indexes. + assert not any('info_json' in s for s in statements), 'No channel or download row may be loaded' + engine_plan = lambda sql: ' '.join(r[3] for r in test_session.execute(sa_text(f'EXPLAIN QUERY PLAN {sql}'))) # noqa + assert 'COVERING INDEX channel_collection_summary_idx' in engine_plan( + 'SELECT collection_id, id, video_count, total_size FROM channel WHERE collection_id IN (1, 2)') + assert 'COVERING INDEX download_collection_frequency_idx' in engine_plan( + 'SELECT collection_id, MIN(frequency) FROM download WHERE collection_id IN (1, 2) AND frequency > 0' + ' GROUP BY collection_id') @pytest.mark.asyncio diff --git a/wrolpi/downloader.py b/wrolpi/downloader.py index 36937f17..c60109ef 100644 --- a/wrolpi/downloader.py +++ b/wrolpi/downloader.py @@ -245,6 +245,8 @@ class Download(ModelHelper, Base): # noqa __tablename__ = 'download' # noqa __table_args__ = ( Index('idx_download_collection_id', 'collection_id'), + # Covers the Collection listing's min-frequency query; download rows are wide (info_json). + Index('download_collection_frequency_idx', 'collection_id', 'frequency'), Index('idx_download_last_download_attempt', 'last_download_attempt'), ) id = Column(Integer, primary_key=True) diff --git a/wrolpi/test/test_db_bootstrap.py b/wrolpi/test/test_db_bootstrap.py index eac08b5b..681df056 100644 --- a/wrolpi/test/test_db_bootstrap.py +++ b/wrolpi/test/test_db_bootstrap.py @@ -211,6 +211,42 @@ def test_domain_collection_summary_migration_backfills(test_directory): assert conn.execute('SELECT item_count, total_size FROM collection WHERE id = 1').fetchone() == (1, 100) +def test_migrations_match_models(test_directory): + """`alembic check`: a database migrated to head matches the SQLAlchemy models (tables, columns, + indexes). Fails when a model gains an index or column without a migration, or vice versa.""" + from alembic.autogenerate import compare_metadata + from alembic.config import Config + from alembic import command + from alembic.migration import MigrationContext + from wrolpi.common import Base + from wrolpi.db import get_db_uri, create_wrolpi_engine + from wrolpi.vars import PROJECT_DIR + + (test_directory / 'config').mkdir(parents=True, exist_ok=True) + config = Config(str(PROJECT_DIR / 'alembic.ini')) + config.set_main_option('sqlalchemy.url', get_db_uri()) + command.upgrade(config, 'head') + + # Compare with alembic/env.py's own exclusions (FTS tables), plus any table a test module + # registered on the shared metadata (wrolpi/test/test_dates.py defines one). + env_source = (PROJECT_DIR / 'alembic' / 'env.py').read_text() + namespace = {} + exec(env_source[env_source.index('def include_object'):env_source.index('def process_revision_directives')], + namespace) + model_tables = {name for name in Base.metadata.tables if not name.startswith('test_')} + + def include_object(object_, name, type_, reflected, compare_to): + if type_ == 'table' and name not in model_tables and not reflected: + return False + return namespace['include_object'](object_, name, type_, reflected, compare_to) + + engine = create_wrolpi_engine(get_db_uri()) + with engine.connect() as conn: + context = MigrationContext.configure(conn, opts={'include_object': include_object, 'render_as_batch': True}) + diffs = compare_metadata(context, Base.metadata) + assert not diffs, diffs + + def test_bootstrap_lock(test_directory): """Only one process can hold the bootstrap lock.""" with db_bootstrap.bootstrap_lock() as acquired: From 8d6f5b5cad5694722d843822becccc2c6160a0ef Mon Sep 17 00:00:00 2001 From: lrnselfreliance <33263708+lrnselfreliance@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:49:06 -0600 Subject: [PATCH 046/181] api: forked workers die on SIGTERM before Sanic installs their handlers (#743) A Sanic worker is forked from the manager and carries the manager's Python SIGTERM/SIGINT handlers until `_setup_system_signals` installs its own, which happens after the startup listeners (about two seconds with sixteen workers starting at once). When auto-reload fires a second time inside that window the manager terminates the half-started generation, each child runs `WorkerManager.shutdown_signal` instead of exiting, marks itself TERMINATED, and then finishes starting. The manager has already spawned replacements, so the old generation lives on: untracked, holding the listening socket, serving stale code, and holding ~220 MB each. Register an at-fork hook in the main process that resets both signals to their default action in the child. The perpetual process already did this by hand for itself; server workers now get the same treatment. Co-authored-by: Claude Fable 5.1 --- wrolpi/api_utils.py | 13 +++++++++++++ wrolpi/test/test_perpetual.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/wrolpi/api_utils.py b/wrolpi/api_utils.py index c07eebf6..c23cc2b2 100644 --- a/wrolpi/api_utils.py +++ b/wrolpi/api_utils.py @@ -2,6 +2,7 @@ import asyncio import json import logging import os +import signal from datetime import datetime, timezone, date from decimal import Decimal from functools import wraps @@ -23,6 +24,18 @@ logger = logger.getChild(__name__) Sanic.start_method = "fork" Sanic.START_METHOD_SET = True + +def _reset_inherited_signal_handlers(): + """A forked worker carries the manager's SIGTERM/SIGINT handlers until Sanic installs the worker's own. In + that window (startup listeners, ~2s with many workers) a signal must do the default thing, exit, rather than + run the manager's handler inside the child. Otherwise a second auto-reload leaves the half-started + generation alive, untracked, holding the listening socket.""" + for sig in (signal.SIGTERM, signal.SIGINT): + signal.signal(sig, signal.SIG_DFL) + + +os.register_at_fork(after_in_child=_reset_inherited_signal_handlers) + # The only Sanic App, this is imported all over. api_app = Sanic(name='api_app', log_config=LOGGING_CONFIG) diff --git a/wrolpi/test/test_perpetual.py b/wrolpi/test/test_perpetual.py index 233704c8..647f1ae6 100644 --- a/wrolpi/test/test_perpetual.py +++ b/wrolpi/test/test_perpetual.py @@ -369,3 +369,38 @@ def test_flag_context_manager_records_its_holder(async_client, flags_lock): assert api_app.shared_ctx.flag_holders['file_worker_modeling'] == os.getpid() assert flags.file_worker_modeling.holder_pid() is None assert not flags.file_worker_modeling.is_set() + + +def _sleep_then_exit_zero(): + # Stands in for a Sanic worker between fork and `_setup_system_signals`: no handlers of its own yet. + time.sleep(3) + + +def _parent_handler_that_returns(signum, frame): + # Stands in for `WorkerManager.shutdown_signal`: it returns, it does not exit. + pass + + +def test_forked_child_dies_on_sigterm_before_installing_handlers(): + """A forked worker inherits the manager's Python signal handlers. Until Sanic installs the worker's own, + SIGTERM must kill the child (the default action) rather than run the manager's handler inside it, which lets a + half-started worker survive a restart as an untracked orphan.""" + saved = {s: signal.getsignal(s) for s in (signal.SIGINT, signal.SIGTERM)} + try: + for sig in saved: + signal.signal(sig, _parent_handler_that_returns) + process = multiprocessing.get_context('fork').Process(target=_sleep_then_exit_zero) + process.start() + try: + time.sleep(0.3) + process.terminate() # What `WorkerProcess._terminate_now` sends on restart. + process.join(5) + assert process.exitcode == -signal.SIGTERM, \ + f'child survived SIGTERM (exitcode={process.exitcode}); it ran the inherited handler' + finally: + if process.is_alive(): + process.kill() + process.join(5) + finally: + for sig, handler in saved.items(): + signal.signal(sig, handler) From 57e647c5b4bbdd8eca964fdad5ef81dcc6c2d347 Mon Sep 17 00:00:00 2001 From: lrnselfreliance <33263708+lrnselfreliance@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:49:18 -0600 Subject: [PATCH 047/181] docker: web container reaps zombies; zim and help healthchecks can pass (#744) Caddy is PID 1 in the web container and reaps nothing. The healthcheck's busybox `wget --spider https://...` forks an `ssl_client` helper that is orphaned to caddy and stays a zombie, one per check. `init: true` runs tini as PID 1 so they are reaped. The zim healthcheck calls curl, which the debian:13 kiwix image does not install, so every check exited 127. The help healthcheck probed port 8086, which is Caddy's HTTPS proxy on the web container; mkdocs serves on port 80 inside its own container. Co-authored-by: Claude Fable 5.1 --- docker-compose.yml | 5 ++++- docker/zim/Dockerfile | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 6de09e1c..52098b48 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -130,6 +130,9 @@ services: # Set EXTRA_SANS to include the host's LAN IP in the TLS certificate. # Example: EXTRA_SANS=IP:10.0.0.5 - EXTRA_SANS=${EXTRA_SANS:-} + # Caddy is PID 1 and reaps nothing; busybox wget's ssl_client helper from the healthcheck is + # orphaned to it and stays a zombie (one per check). init runs tini as PID 1 to reap them. + init: true healthcheck: test: [ 'CMD-SHELL', 'wget --spider --no-check-certificate https://127.0.0.1:443 || exit 1' ] interval: 1m @@ -165,5 +168,5 @@ services: volumes: - './docker/help/wrolpi-help:/opt/wrolpi-help' healthcheck: - test: [ 'CMD-SHELL', 'curl http://127.0.0.1:8086' ] + test: [ 'CMD-SHELL', 'curl http://127.0.0.1:80' ] # mkdocs serves on 80; 8086 is Caddy's proxy. interval: 1m diff --git a/docker/zim/Dockerfile b/docker/zim/Dockerfile index 426d6b95..e771ba9f 100644 --- a/docker/zim/Dockerfile +++ b/docker/zim/Dockerfile @@ -1,7 +1,8 @@ FROM debian:13 ENV DOCKER=true -RUN apt update && apt-get install -y kiwix kiwix-tools +# curl is for the compose healthcheck. +RUN apt update && apt-get install -y kiwix kiwix-tools curl # Committed blobs (e.g. the default Zim served when the media directory has no # functioning zim files). From dd7269d1cac590d851984976e2a24343fc13b110 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:07:13 -0600 Subject: [PATCH 048/181] build(deps): update uvicorn requirement in /controller (#742) Updates the requirements on [uvicorn](https://github.com/Kludex/uvicorn) to permit the latest version. - [Release notes](https://github.com/Kludex/uvicorn/releases) - [Changelog](https://github.com/Kludex/uvicorn/blob/main/docs/release-notes.md) - [Commits](https://github.com/Kludex/uvicorn/compare/0.52.4...0.54.0) --- updated-dependencies: - dependency-name: uvicorn dependency-version: 0.54.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- controller/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/controller/requirements.txt b/controller/requirements.txt index 63c7d6e3..e3c595b8 100644 --- a/controller/requirements.txt +++ b/controller/requirements.txt @@ -3,7 +3,7 @@ # Web framework fastapi>=0.141.1 -uvicorn[standard]>=0.52.4 +uvicorn[standard]>=0.54.0 pydantic>=2.13.5 # Templates From c973a4106f723e6794f8db2f6f10b24b50a1e9eb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:07:17 -0600 Subject: [PATCH 049/181] build(deps): bump sanic from 24.12.0 to 24.12.1 in /docker/archive (#714) Bumps [sanic](https://github.com/sanic-org/sanic) from 24.12.0 to 24.12.1. - [Release notes](https://github.com/sanic-org/sanic/releases) - [Commits](https://github.com/sanic-org/sanic/compare/v24.12.0...v24.12.1) --- updated-dependencies: - dependency-name: sanic dependency-version: 24.12.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docker/archive/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/archive/requirements.txt b/docker/archive/requirements.txt index abadf1ee..542fab2e 100644 --- a/docker/archive/requirements.txt +++ b/docker/archive/requirements.txt @@ -1 +1 @@ -sanic==24.12.0 +sanic==24.12.1 From 5439fff87148155c46f5cf599cb3536d96096f06 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:07:22 -0600 Subject: [PATCH 050/181] build(deps): bump js-yaml in /app (#705) Bumps and [js-yaml](https://github.com/nodeca/js-yaml). These dependencies needed to be updated together. Updates `js-yaml` from 3.15.0 to 3.15.2 - [Changelog](https://github.com/nodeca/js-yaml/blob/3.15.2/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/3.15.0...3.15.2) Updates `js-yaml` from 4.3.0 to 4.3.2 - [Changelog](https://github.com/nodeca/js-yaml/blob/3.15.2/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/3.15.0...3.15.2) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 3.15.2 dependency-type: indirect - dependency-name: js-yaml dependency-version: 4.3.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- app/package-lock.json | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/app/package-lock.json b/app/package-lock.json index 0ff57ad5..b8638d33 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -3710,9 +3710,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "funding": [ { "type": "github", @@ -15275,9 +15275,9 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", - "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", + "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", "license": "MIT", "dependencies": { "argparse": "^1.0.7", @@ -25511,9 +25511,9 @@ } }, "js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "requires": { "argparse": "^2.0.1" } @@ -33681,9 +33681,9 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "js-yaml": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", - "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", + "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", "requires": { "argparse": "^1.0.7", "esprima": "^4.0.0" From 0837330bd74337365c62d7a5e7b19c5c61c63301 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:07:26 -0600 Subject: [PATCH 051/181] build(deps): bump colord from 2.9.3 to 2.10.0 in /app (#703) Bumps [colord](https://github.com/omgovich/colord) from 2.9.3 to 2.10.0. - [Release notes](https://github.com/omgovich/colord/releases) - [Changelog](https://github.com/omgovich/colord/blob/master/CHANGELOG.md) - [Commits](https://github.com/omgovich/colord/commits/v2.10) --- updated-dependencies: - dependency-name: colord dependency-version: 2.10.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- app/package-lock.json | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/app/package-lock.json b/app/package-lock.json index b8638d33..2e8d82ee 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -8627,9 +8627,10 @@ "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" }, "node_modules/colord": { - "version": "2.9.3", - "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", - "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==" + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.10.0.tgz", + "integrity": "sha512-AidJptpBJmjTclAp9BkLwJi0T93fo5epJnbaZslpg6QVzpHjAiveF55mE9AcUJiGMqRHgMDY8soMsQtuNYMHfw==", + "license": "MIT" }, "node_modules/colorette": { "version": "2.0.20", @@ -28971,9 +28972,9 @@ "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" }, "colord": { - "version": "2.9.3", - "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", - "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==" + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.10.0.tgz", + "integrity": "sha512-AidJptpBJmjTclAp9BkLwJi0T93fo5epJnbaZslpg6QVzpHjAiveF55mE9AcUJiGMqRHgMDY8soMsQtuNYMHfw==" }, "colorette": { "version": "2.0.20", From 71287c9a23c356bb18be4dc5be84fdc16fb809b9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:08:32 -0600 Subject: [PATCH 052/181] build(deps): bump the npm-dependencies group in /app with 7 updates (#741) Bumps the npm-dependencies group in /app with 7 updates: | Package | From | To | | --- | --- | --- | | [@tabler/icons-react](https://github.com/tabler/tabler-icons/tree/HEAD/packages/icons-react) | `3.46.0` | `3.48.0` | | [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.6` | `14.6.7` | | [esptool-js](https://github.com/espressif/esptool-js) | `0.6.1` | `0.7.0` | | [react-colorful](https://github.com/omgovich/react-colorful) | `5.8.0` | `5.8.1` | | [three](https://github.com/mrdoob/three.js) | `0.185.1` | `0.186.1` | | [@testing-library/dom](https://github.com/testing-library/dom-testing-library) | `10.4.1` | `10.4.2` | | [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.4.0` | `26.6.3` | Updates `@tabler/icons-react` from 3.46.0 to 3.48.0 - [Release notes](https://github.com/tabler/tabler-icons/releases) - [Commits](https://github.com/tabler/tabler-icons/commits/v3.48.0/packages/icons-react) Updates `@testing-library/user-event` from 14.6.6 to 14.6.7 - [Release notes](https://github.com/testing-library/user-event/releases) - [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md) - [Commits](https://github.com/testing-library/user-event/compare/v14.6.6...v14.6.7) Updates `esptool-js` from 0.6.1 to 0.7.0 - [Release notes](https://github.com/espressif/esptool-js/releases) - [Changelog](https://github.com/espressif/esptool-js/blob/main/CHANGELOG.md) - [Commits](https://github.com/espressif/esptool-js/compare/v0.6.1...v0.7.0) Updates `react-colorful` from 5.8.0 to 5.8.1 - [Release notes](https://github.com/omgovich/react-colorful/releases) - [Changelog](https://github.com/omgovich/react-colorful/blob/master/CHANGELOG.md) - [Commits](https://github.com/omgovich/react-colorful/commits) Updates `three` from 0.185.1 to 0.186.1 - [Release notes](https://github.com/mrdoob/three.js/releases) - [Commits](https://github.com/mrdoob/three.js/commits) Updates `@testing-library/dom` from 10.4.1 to 10.4.2 - [Release notes](https://github.com/testing-library/dom-testing-library/releases) - [Changelog](https://github.com/testing-library/dom-testing-library/blob/main/CHANGELOG.md) - [Commits](https://github.com/testing-library/dom-testing-library/compare/v10.4.1...v10.4.2) Updates `@types/node` from 26.4.0 to 26.6.3 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@tabler/icons-react" dependency-version: 3.48.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-dependencies - dependency-name: "@testing-library/user-event" dependency-version: 14.6.7 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: npm-dependencies - dependency-name: esptool-js dependency-version: 0.7.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-dependencies - dependency-name: react-colorful dependency-version: 5.8.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: npm-dependencies - dependency-name: three dependency-version: 0.186.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-dependencies - dependency-name: "@testing-library/dom" dependency-version: 10.4.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-dependencies - dependency-name: "@types/node" dependency-version: 26.6.3 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: npm-dependencies ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- app/package-lock.json | 139 +++++++++++++++++++++--------------------- app/package.json | 14 ++--- 2 files changed, 77 insertions(+), 76 deletions(-) diff --git a/app/package-lock.json b/app/package-lock.json index 2e8d82ee..276a1917 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -14,11 +14,11 @@ "@mantine/core": "^8.3.18", "@mantine/hooks": "^8.3.18", "@mantine/notifications": "^8.3.18", - "@tabler/icons-react": "^3.46.0", + "@tabler/icons-react": "^3.48.0", "@testing-library/react": "^13.4.0", - "@testing-library/user-event": "^14.6.6", + "@testing-library/user-event": "^14.6.7", "cypress": "^15.21.1", - "esptool-js": "^0.6.1", + "esptool-js": "^0.7.0", "lodash": "^4.18.1", "maplibre-gl": "^5.24.0", "mathjs": "^15.2.0", @@ -26,7 +26,7 @@ "protomaps-themes-base": "^4.5.0", "query-string": "^9.5.1", "react": "^18.2.0", - "react-colorful": "^5.8.0", + "react-colorful": "^5.8.1", "react-dom": "^18.2.0", "react-dropzone": "^14.2.3", "react-hotkeys-hook": "^5.3.3", @@ -34,14 +34,14 @@ "react-router": "^7.18.2", "react-scripts": "5.0.1", "react-stl-viewer": "^2.5.0", - "three": "^0.185", + "three": "^0.186", "web-vitals": "^5.3.0" }, "devDependencies": { - "@testing-library/dom": "^10.4.1", + "@testing-library/dom": "^10.4.2", "@testing-library/jest-dom": "^7.0.1", "@types/lodash": "^4.17.25", - "@types/node": "^26.4.0", + "@types/node": "^26.6.3", "@types/react": "^18.3.17", "@types/react-dom": "^18.3.5", "typescript": "^5.9.3", @@ -5975,9 +5975,9 @@ } }, "node_modules/@tabler/icons": { - "version": "3.46.0", - "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.46.0.tgz", - "integrity": "sha512-f2RYFl3fzPwj5WO82x6en0dmkjefxEfOm16D1ByM6cj/McNiwOkL4VaPUoP9VVIrXAD9WnTSVFr70px703b//A==", + "version": "3.48.0", + "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.48.0.tgz", + "integrity": "sha512-lQk06gVHNVBnJp0UOgtth54mcZBJM7rdYKsAxKOProj1bbSAx+/xiuOWfHlyvgdL3M+Y+I0HINrjHa3ygp8eXQ==", "license": "MIT", "funding": { "type": "github", @@ -5985,12 +5985,12 @@ } }, "node_modules/@tabler/icons-react": { - "version": "3.46.0", - "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.46.0.tgz", - "integrity": "sha512-CCm7xJWhDT2PH4ZIFkP6AgYKtVhq0gpYkjUN+GVh1AzmIQaa77OW0bQPBPQiTE0PsXMR9oSxFqA3qBglzPyrVQ==", + "version": "3.48.0", + "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.48.0.tgz", + "integrity": "sha512-OCeAlpaWHEMV+EsgdVeGQ3YuKfmfnThZhc++CaZ/ujYzi5+/DONDQlX4OGvH1ikYhIfmc0grI4otJgHy7QAvng==", "license": "MIT", "dependencies": { - "@tabler/icons": "3.46.0" + "@tabler/icons": "3.48.0" }, "funding": { "type": "github", @@ -6001,9 +6001,9 @@ } }, "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.10.4", @@ -6163,9 +6163,9 @@ } }, "node_modules/@testing-library/user-event": { - "version": "14.6.6", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.6.tgz", - "integrity": "sha512-Jbs9FpkkIDw8FgSc6kOVsOv8JuuqGAL7J4X1oot77JxAoDlkNn2GRkd0aYRVuQ+pVQAiHWVkE4rX/dkF5fBiCw==", + "version": "14.6.7", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz", + "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==", "license": "MIT", "engines": { "node": ">=12", @@ -6466,12 +6466,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.4.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz", - "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==", + "version": "26.6.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz", + "integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==", "license": "MIT", "dependencies": { - "undici-types": "~8.3.0" + "undici-types": "~8.9.0" } }, "node_modules/@types/node-forge": { @@ -11009,14 +11009,13 @@ } }, "node_modules/esptool-js": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/esptool-js/-/esptool-js-0.6.1.tgz", - "integrity": "sha512-WNgQTfaEIgHyEiT56pI5v7Tq6Pzjc2XaibLxAtWY4v3zE2Ofk5ImkJY5foEUr0JrdkfHWf6rNizAewN4/kSpHw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/esptool-js/-/esptool-js-0.7.0.tgz", + "integrity": "sha512-9wHuW/s9t4Vs6HKcr3pra3gbRVWwaN0eabMWodWH4KEJHEYZxgmWwZ3s0F4xcWVCZOc/L98bGZ4uWoZvR8KBKA==", "license": "Apache-2.0", "dependencies": { "atob-lite": "^2.0.0", - "pako": "^2.1.0", - "tslib": "^2.4.1" + "pako": "^2.1.0" } }, "node_modules/esquery": { @@ -18630,9 +18629,10 @@ } }, "node_modules/react-colorful": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.8.0.tgz", - "integrity": "sha512-Wy9OzPfjSN9bF12OB8N7UQvlsZ0I+7wHxpN+bV5BjNQGxOj6IiwkRjevJK9yOBjJWGQvAaf1OXtn8rUeEatAng==", + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.8.1.tgz", + "integrity": "sha512-oz68bhsnFWnpDf1ZR8daiQbYpXUnM2h2J6hl9Zg2rTpM/DU6vCqe1E+CpqmqLnJucMZetHZeifSAfJ+geN9lcA==", + "license": "MIT", "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" @@ -21147,9 +21147,10 @@ } }, "node_modules/three": { - "version": "0.185.1", - "resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz", - "integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==" + "version": "0.186.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.186.1.tgz", + "integrity": "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA==", + "license": "MIT" }, "node_modules/three-stdlib": { "version": "2.17.2", @@ -21507,9 +21508,10 @@ } }, "node_modules/undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==" + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.0", @@ -27021,22 +27023,22 @@ } }, "@tabler/icons": { - "version": "3.46.0", - "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.46.0.tgz", - "integrity": "sha512-f2RYFl3fzPwj5WO82x6en0dmkjefxEfOm16D1ByM6cj/McNiwOkL4VaPUoP9VVIrXAD9WnTSVFr70px703b//A==" + "version": "3.48.0", + "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.48.0.tgz", + "integrity": "sha512-lQk06gVHNVBnJp0UOgtth54mcZBJM7rdYKsAxKOProj1bbSAx+/xiuOWfHlyvgdL3M+Y+I0HINrjHa3ygp8eXQ==" }, "@tabler/icons-react": { - "version": "3.46.0", - "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.46.0.tgz", - "integrity": "sha512-CCm7xJWhDT2PH4ZIFkP6AgYKtVhq0gpYkjUN+GVh1AzmIQaa77OW0bQPBPQiTE0PsXMR9oSxFqA3qBglzPyrVQ==", + "version": "3.48.0", + "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.48.0.tgz", + "integrity": "sha512-OCeAlpaWHEMV+EsgdVeGQ3YuKfmfnThZhc++CaZ/ujYzi5+/DONDQlX4OGvH1ikYhIfmc0grI4otJgHy7QAvng==", "requires": { - "@tabler/icons": "3.46.0" + "@tabler/icons": "3.48.0" } }, "@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", "requires": { "@babel/code-frame": "7.29.0", "@babel/runtime": "^7.12.5", @@ -27149,9 +27151,9 @@ } }, "@testing-library/user-event": { - "version": "14.6.6", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.6.tgz", - "integrity": "sha512-Jbs9FpkkIDw8FgSc6kOVsOv8JuuqGAL7J4X1oot77JxAoDlkNn2GRkd0aYRVuQ+pVQAiHWVkE4rX/dkF5fBiCw==", + "version": "14.6.7", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz", + "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==", "requires": {} }, "@tootallnate/once": { @@ -27413,11 +27415,11 @@ "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" }, "@types/node": { - "version": "26.4.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz", - "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==", + "version": "26.6.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz", + "integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==", "requires": { - "undici-types": "~8.3.0" + "undici-types": "~8.9.0" } }, "@types/node-forge": { @@ -30662,13 +30664,12 @@ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" }, "esptool-js": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/esptool-js/-/esptool-js-0.6.1.tgz", - "integrity": "sha512-WNgQTfaEIgHyEiT56pI5v7Tq6Pzjc2XaibLxAtWY4v3zE2Ofk5ImkJY5foEUr0JrdkfHWf6rNizAewN4/kSpHw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/esptool-js/-/esptool-js-0.7.0.tgz", + "integrity": "sha512-9wHuW/s9t4Vs6HKcr3pra3gbRVWwaN0eabMWodWH4KEJHEYZxgmWwZ3s0F4xcWVCZOc/L98bGZ4uWoZvR8KBKA==", "requires": { "atob-lite": "^2.0.0", - "pako": "^2.1.0", - "tslib": "^2.4.1" + "pako": "^2.1.0" } }, "esquery": { @@ -35885,9 +35886,9 @@ } }, "react-colorful": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.8.0.tgz", - "integrity": "sha512-Wy9OzPfjSN9bF12OB8N7UQvlsZ0I+7wHxpN+bV5BjNQGxOj6IiwkRjevJK9yOBjJWGQvAaf1OXtn8rUeEatAng==", + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.8.1.tgz", + "integrity": "sha512-oz68bhsnFWnpDf1ZR8daiQbYpXUnM2h2J6hl9Zg2rTpM/DU6vCqe1E+CpqmqLnJucMZetHZeifSAfJ+geN9lcA==", "requires": {} }, "react-dev-utils": { @@ -37609,9 +37610,9 @@ "requires": {} }, "three": { - "version": "0.185.1", - "resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz", - "integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==" + "version": "0.186.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.186.1.tgz", + "integrity": "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA==" }, "three-stdlib": { "version": "2.17.2", @@ -37883,9 +37884,9 @@ } }, "undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==" + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==" }, "unicode-canonical-property-names-ecmascript": { "version": "2.0.0", diff --git a/app/package.json b/app/package.json index aadddfc5..5fbbe392 100644 --- a/app/package.json +++ b/app/package.json @@ -9,11 +9,11 @@ "@mantine/core": "^8.3.18", "@mantine/hooks": "^8.3.18", "@mantine/notifications": "^8.3.18", - "@tabler/icons-react": "^3.46.0", + "@tabler/icons-react": "^3.48.0", "@testing-library/react": "^13.4.0", - "@testing-library/user-event": "^14.6.6", + "@testing-library/user-event": "^14.6.7", "cypress": "^15.21.1", - "esptool-js": "^0.6.1", + "esptool-js": "^0.7.0", "lodash": "^4.18.1", "maplibre-gl": "^5.24.0", "mathjs": "^15.2.0", @@ -21,7 +21,7 @@ "protomaps-themes-base": "^4.5.0", "query-string": "^9.5.1", "react": "^18.2.0", - "react-colorful": "^5.8.0", + "react-colorful": "^5.8.1", "react-dom": "^18.2.0", "react-dropzone": "^14.2.3", "react-hotkeys-hook": "^5.3.3", @@ -29,7 +29,7 @@ "react-router": "^7.18.2", "react-scripts": "5.0.1", "react-stl-viewer": "^2.5.0", - "three": "^0.185", + "three": "^0.186", "web-vitals": "^5.3.0" }, "scripts": { @@ -58,10 +58,10 @@ ] }, "devDependencies": { - "@testing-library/dom": "^10.4.1", + "@testing-library/dom": "^10.4.2", "@testing-library/jest-dom": "^7.0.1", "@types/lodash": "^4.17.25", - "@types/node": "^26.4.0", + "@types/node": "^26.6.3", "@types/react": "^18.3.17", "@types/react-dom": "^18.3.5", "typescript": "^5.9.3", From ecd8ce64be58d5fa961e92f15453a8e65b1cd3e3 Mon Sep 17 00:00:00 2001 From: lrnselfreliance <33263708+lrnselfreliance@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:11:08 -0600 Subject: [PATCH 053/181] dependabot: hold alembic below 1.11 while SQLAlchemy stays on 1.3 (#747) alembic 1.20 requires SQLAlchemy>=2.0, so every grouped python update that included it failed to install (ResolutionImpossible). Ignore alembic >=1.11 and SQLAlchemy major bumps until the SQLAlchemy 2 migration is done. Co-authored-by: Claude Fable 5.1 --- .github/dependabot.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e1969755..d0fb177e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,6 +7,14 @@ updates: interval: "monthly" target-branch: "master" open-pull-requests-limit: 5 + ignore: + # alembic >=1.11 requires SQLAlchemy 2; requirements.txt pins SQLAlchemy 1.3. + # Grouped PRs containing it fail to install, so hold alembic until the + # SQLAlchemy 2 migration lands. + - dependency-name: "alembic" + versions: [">=1.11"] + - dependency-name: "SQLAlchemy" + update-types: ["version-update:semver-major"] groups: python-dependencies: patterns: From 9eb56967e4585772055ef02b15398ce4b86ca009 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:19:43 -0600 Subject: [PATCH 054/181] build(deps): bump brace-expansion in /app (#745) Bumps and [brace-expansion](https://github.com/juliangruber/brace-expansion). These dependencies needed to be updated together. Updates `brace-expansion` from 1.1.18 to 1.1.21 - [Release notes](https://github.com/juliangruber/brace-expansion/releases) - [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.18...v1.1.21) Updates `brace-expansion` from 2.1.4 to 2.1.7 - [Release notes](https://github.com/juliangruber/brace-expansion/releases) - [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.18...v1.1.21) --- updated-dependencies: - dependency-name: brace-expansion dependency-version: 1.1.21 dependency-type: indirect - dependency-name: brace-expansion dependency-version: 2.1.7 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- app/package-lock.json | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/app/package-lock.json b/app/package-lock.json index 276a1917..6680e2f0 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -8075,9 +8075,10 @@ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" }, "node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -11361,9 +11362,10 @@ } }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -28604,9 +28606,9 @@ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" }, "brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -30936,9 +30938,9 @@ }, "dependencies": { "brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "requires": { "balanced-match": "^1.0.0" } From 323b7e66df1c3039d3e8b1ac938737f2610c100e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:19:48 -0600 Subject: [PATCH 055/181] build(deps): bump fast-uri from 3.1.7 to 3.1.8 in /app (#746) Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.7 to 3.1.8. - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.7...v3.1.8) --- updated-dependencies: - dependency-name: fast-uri dependency-version: 3.1.8 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- app/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/package-lock.json b/app/package-lock.json index 6680e2f0..d8b89b58 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -11263,9 +11263,9 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" }, "node_modules/fast-uri": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", - "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "funding": [ { "type": "github", @@ -30871,9 +30871,9 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" }, "fast-uri": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", - "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==" + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==" }, "fastq": { "version": "1.15.0", From 3800f4d33219bf063c4d8c60b3c2f8dd0782ce51 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:19:52 -0600 Subject: [PATCH 056/181] build(deps): update fastapi requirement in /controller (#748) Updates the requirements on [fastapi](https://github.com/fastapi/fastapi) to permit the latest version. - [Release notes](https://github.com/fastapi/fastapi/releases) - [Commits](https://github.com/fastapi/fastapi/compare/0.141.1...0.142.2) --- updated-dependencies: - dependency-name: fastapi dependency-version: 0.142.2 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- controller/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/controller/requirements.txt b/controller/requirements.txt index e3c595b8..e2ca1a0f 100644 --- a/controller/requirements.txt +++ b/controller/requirements.txt @@ -2,7 +2,7 @@ # Separate from main WROLPi API to keep venvs independent # Web framework -fastapi>=0.141.1 +fastapi>=0.142.2 uvicorn[standard]>=0.54.0 pydantic>=2.13.5 From 3d0eb763379336b04b363f17d3d845698097fb0b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:21:14 -0600 Subject: [PATCH 057/181] build(deps): update pymupdf requirement from >=1.27.2.3 to >=1.28.2 (#756) Updates the requirements on [pymupdf](https://github.com/pymupdf/pymupdf) to permit the latest version. - [Release notes](https://github.com/pymupdf/pymupdf/releases) - [Changelog](https://github.com/pymupdf/PyMuPDF/blob/main/changes.txt) - [Commits](https://github.com/pymupdf/pymupdf/compare/1.27.2.3...1.28.2) --- updated-dependencies: - dependency-name: pymupdf dependency-version: 1.28.2 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 2ff0bab0..06be9bfd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ libzim==3.10.0 mock==5.2.0 psutil==7.2.2 pydantic>=2.0.0 -PyMuPDF>=1.27.2.3 +PyMuPDF>=1.28.2 pytest-asyncio==1.4.0 # pytest.ini sets `timeout = 30` and asks that it not be removed, but this was never declared # here -- so on any machine without it, and in CI, which installs only this file, the option was From 0d2431c41a29f11f4d227d5707b561462276d00f Mon Sep 17 00:00:00 2001 From: lrnselfreliance <33263708+lrnselfreliance@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:21:50 -0600 Subject: [PATCH 058/181] dependabot: move major-version exclusions into the config (#760) Comment-based ignores did not survive the last config change, and the SQLAlchemy rule only blocked majors while 1.4 (the 2.0 transition release) slipped into the python group. Hold SQLAlchemy <1.4, alembic <1.11, cachetools majors (mru_cache removed in 6), and the npm majors that each need a migration branch: maplibre-gl 6, react-dropzone 15+, React 19, TypeScript 6+, Mantine 9. Co-authored-by: Claude Fable 5.1 --- .github/dependabot.yml | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d0fb177e..dcb91909 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,12 +8,15 @@ updates: target-branch: "master" open-pull-requests-limit: 5 ignore: - # alembic >=1.11 requires SQLAlchemy 2; requirements.txt pins SQLAlchemy 1.3. - # Grouped PRs containing it fail to install, so hold alembic until the - # SQLAlchemy 2 migration lands. + # SQLAlchemy 1.4 is the 2.0 transition release and is not a drop-in + # change for this codebase; alembic >=1.11 requires SQLAlchemy 2. + # Hold both until the SQLAlchemy 2 migration lands. + - dependency-name: "SQLAlchemy" + versions: [">=1.4"] - dependency-name: "alembic" versions: [">=1.11"] - - dependency-name: "SQLAlchemy" + # cachetools 6 removed cachetools.func.mru_cache, still used by modules/zim. + - dependency-name: "cachetools" update-types: ["version-update:semver-major"] groups: python-dependencies: @@ -45,6 +48,29 @@ updates: interval: "monthly" target-branch: "master" open-pull-requests-limit: 5 + ignore: + # Major upgrades below need their own migration branch, not a bump PR. + # maplibre-gl 6: /map build depends on pinned babel/terser handling. + - dependency-name: "maplibre-gl" + versions: [">=6"] + # react-dropzone 15+ changed the accept API; 20 requires Node >=22. + - dependency-name: "react-dropzone" + versions: [">=15"] + # React 19: @react-three/fiber 8 pins react <19. + - dependency-name: "react" + update-types: ["version-update:semver-major"] + - dependency-name: "react-dom" + update-types: ["version-update:semver-major"] + - dependency-name: "@types/react" + update-types: ["version-update:semver-major"] + - dependency-name: "@types/react-dom" + update-types: ["version-update:semver-major"] + # TypeScript 6+/7 breaks the react-scripts toolchain. + - dependency-name: "typescript" + versions: [">=6"] + # Mantine 9: all @mantine packages move together with app code changes. + - dependency-name: "@mantine/*" + update-types: ["version-update:semver-major"] groups: npm-dependencies: patterns: From 5e7691448552178971a010e706069ef34330a182 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:22:15 -0600 Subject: [PATCH 059/181] build(deps): bump qs and express in /app (#758) Bumps [qs](https://github.com/ljharb/qs) and [express](https://github.com/expressjs/express). These dependencies needed to be updated together. Updates `qs` from 6.15.3 to 6.16.0 - [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md) - [Commits](https://github.com/ljharb/qs/compare/v6.15.3...v6.16.0) Updates `express` from 4.22.2 to 4.22.3 - [Release notes](https://github.com/expressjs/express/releases) - [Changelog](https://github.com/expressjs/express/blob/v4.22.3/History.md) - [Commits](https://github.com/expressjs/express/compare/v4.22.2...v4.22.3) --- updated-dependencies: - dependency-name: qs dependency-version: 6.16.0 dependency-type: indirect - dependency-name: express dependency-version: 4.22.3 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- app/package-lock.json | 45 +++++++++++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/app/package-lock.json b/app/package-lock.json index d8b89b58..00a362e0 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -11145,9 +11145,9 @@ } }, "node_modules/express": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", - "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz", + "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", @@ -11169,9 +11169,9 @@ "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", + "path-to-regexp": "~0.1.13", "proxy-addr": "~2.0.7", - "qs": "~6.15.1", + "qs": "~6.16.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -11208,6 +11208,22 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/express/node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -30766,9 +30782,9 @@ } }, "express": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", - "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz", + "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==", "requires": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -30789,9 +30805,9 @@ "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", + "path-to-regexp": "~0.1.13", "proxy-addr": "~2.0.7", - "qs": "~6.15.1", + "qs": "~6.16.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -30820,6 +30836,15 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "requires": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + } } } }, From a7103002b6135c8f623e2cc4a6caf794f9cc817f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:22:20 -0600 Subject: [PATCH 060/181] build(deps): bump nanoid from 3.3.16 to 3.3.19 in /app (#759) Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.16 to 3.3.19. - [Release notes](https://github.com/ai/nanoid/releases) - [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md) - [Commits](https://github.com/ai/nanoid/compare/3.3.16...3.3.19) --- updated-dependencies: - dependency-name: nanoid dependency-version: 3.3.19 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- app/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/package-lock.json b/app/package-lock.json index 00a362e0..46e23dcb 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -16337,9 +16337,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", "funding": [ { "type": "github", @@ -34457,9 +34457,9 @@ "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==" }, "nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==" + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==" }, "natural-compare": { "version": "1.4.0", From 920b619bccf932bfb437d2fde8b45f2bb67b4864 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:33:05 -0600 Subject: [PATCH 061/181] build(deps): bump the python-dependencies group with 12 updates (#763) Bumps the python-dependencies group with 12 updates: | Package | From | To | | --- | --- | --- | | [sqlalchemy](https://github.com/sqlalchemy/sqlalchemy) | `1.3.22` | `1.3.24` | | [beautifulsoup4](https://www.crummy.com/software/BeautifulSoup/bs4/) | `4.14.3` | `4.15.0` | | [cachetools](https://github.com/tkem/cachetools) | `5.5.0` | `5.5.2` | | [cryptography](https://github.com/pyca/cryptography) | `50.0.0` | `50.0.2` | | [feedparser](https://github.com/kurtmckee/feedparser) | `6.0.12` | `6.0.14` | | [jc](https://github.com/kellyjonbrazil/jc) | `1.25.6` | `1.26.0` | | [libzim](https://github.com/openzim/python-libzim) | `3.10.0` | `3.13.0` | | [pytest](https://github.com/pytest-dev/pytest) | `9.0.3` | `9.1.1` | | [python-dotenv](https://github.com/theskumar/python-dotenv) | `1.2.2` | `1.2.3` | | [pytz](https://github.com/stub42/pytz) | `2026.2` | `2026.4` | | [selenium](https://github.com/SeleniumHQ/Selenium) | `4.44.0` | `4.49.0` | | [tqdm](https://github.com/tqdm/tqdm) | `4.67.3` | `4.70.1` | Updates `sqlalchemy` from 1.3.22 to 1.3.24 - [Release notes](https://github.com/sqlalchemy/sqlalchemy/releases) - [Changelog](https://github.com/sqlalchemy/sqlalchemy/blob/main/CHANGES.rst) - [Commits](https://github.com/sqlalchemy/sqlalchemy/commits) Updates `beautifulsoup4` from 4.14.3 to 4.15.0 Updates `cachetools` from 5.5.0 to 5.5.2 - [Changelog](https://github.com/tkem/cachetools/blob/master/CHANGELOG.rst) - [Commits](https://github.com/tkem/cachetools/compare/v5.5.0...v5.5.2) Updates `cryptography` from 50.0.0 to 50.0.2 - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/50.0.0...50.0.2) Updates `feedparser` from 6.0.12 to 6.0.14 - [Release notes](https://github.com/kurtmckee/feedparser/releases) - [Changelog](https://github.com/kurtmckee/feedparser/blob/main/CHANGELOG.rst) - [Commits](https://github.com/kurtmckee/feedparser/compare/v6.0.12...v6.0.14) Updates `jc` from 1.25.6 to 1.26.0 - [Release notes](https://github.com/kellyjonbrazil/jc/releases) - [Changelog](https://github.com/kellyjonbrazil/jc/blob/master/CHANGELOG) - [Commits](https://github.com/kellyjonbrazil/jc/compare/v1.25.6...v1.26.0) Updates `libzim` from 3.10.0 to 3.13.0 - [Release notes](https://github.com/openzim/python-libzim/releases) - [Changelog](https://github.com/openzim/python-libzim/blob/main/CHANGELOG.md) - [Commits](https://github.com/openzim/python-libzim/compare/v3.10.0...v3.13.0) Updates `pytest` from 9.0.3 to 9.1.1 - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/9.0.3...9.1.1) Updates `python-dotenv` from 1.2.2 to 1.2.3 - [Release notes](https://github.com/theskumar/python-dotenv/releases) - [Changelog](https://github.com/theskumar/python-dotenv/blob/main/CHANGELOG.md) - [Commits](https://github.com/theskumar/python-dotenv/compare/v1.2.2...v1.2.3) Updates `pytz` from 2026.2 to 2026.4 - [Release notes](https://github.com/stub42/pytz/releases) - [Commits](https://github.com/stub42/pytz/compare/release_2026.2...release_2026.4) Updates `selenium` from 4.44.0 to 4.49.0 - [Release notes](https://github.com/SeleniumHQ/Selenium/releases) - [Commits](https://github.com/SeleniumHQ/Selenium/compare/selenium-4.44.0...selenium-4.49.0) Updates `tqdm` from 4.67.3 to 4.70.1 - [Release notes](https://github.com/tqdm/tqdm/releases) - [Commits](https://github.com/tqdm/tqdm/compare/v4.67.3...v4.70.1) --- updated-dependencies: - dependency-name: sqlalchemy dependency-version: 1.3.24 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-dependencies - dependency-name: beautifulsoup4 dependency-version: 4.15.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-dependencies - dependency-name: cachetools dependency-version: 5.5.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-dependencies - dependency-name: cryptography dependency-version: 50.0.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-dependencies - dependency-name: feedparser dependency-version: 6.0.14 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-dependencies - dependency-name: jc dependency-version: 1.26.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-dependencies - dependency-name: libzim dependency-version: 3.13.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-dependencies - dependency-name: pytest dependency-version: 9.1.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-dependencies - dependency-name: python-dotenv dependency-version: 1.2.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-dependencies - dependency-name: pytz dependency-version: '2026.4' dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-dependencies - dependency-name: selenium dependency-version: 4.49.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-dependencies - dependency-name: tqdm dependency-version: 4.70.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-dependencies ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/requirements.txt b/requirements.txt index 06be9bfd..eb150470 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,15 +1,15 @@ Pillow==12.3.0 PyYAML==6.0.3 -SQLAlchemy[asyncio]==1.3.22 +SQLAlchemy[asyncio]==1.3.24 aiohttp==3.14.3 alembic==1.10.4 -beautifulsoup4==4.14.3 -cachetools==5.5.0 -cryptography==50.0.0 +beautifulsoup4==4.15.0 +cachetools==5.5.2 +cryptography==50.0.2 ebooklib==0.20 -feedparser==6.0.12 -jc==1.25.6 -libzim==3.10.0 +feedparser==6.0.14 +jc==1.26.0 +libzim==3.13.0 mock==5.2.0 psutil==7.2.2 pydantic>=2.0.0 @@ -21,20 +21,20 @@ pytest-asyncio==1.4.0 # killed, with no indication of which test hung. pytest-timeout==2.4.0 pytest-xdist==3.8.0 -pytest==9.0.3 +pytest==9.1.1 py7zr>=1.1.3 python-docx==1.2.0 -python-dotenv==1.2.2 +python-dotenv==1.2.3 python-magic~=0.4.27 -pytz==2026.2 +pytz==2026.4 rarfile>=4.5 sanic-ext==25.12.0 sanic-testing==24.6.0 sanic==25.12.1 secretstorage==3.5.0 -selenium==4.44.0 +selenium==4.49.0 srt==3.5.3 -tqdm==4.67.3 +tqdm==4.70.1 vininfo[cli]==1.11.0 webvtt-py==0.5.1 yt-dlp[default,curl-cffi]>=2026.08.19 From 39cef45c5930966b3701bbab7b037f7d28ee9d38 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:33:11 -0600 Subject: [PATCH 062/181] build(deps): bump the npm-dependencies group in /app with 3 updates (#761) Bumps the npm-dependencies group in /app with 3 updates: [react](https://github.com/react/react/tree/HEAD/packages/react), [react-dom](https://github.com/react/react/tree/HEAD/packages/react-dom) and [react-dropzone](https://github.com/react-dropzone/react-dropzone). Updates `react` from 18.2.0 to 18.3.1 - [Release notes](https://github.com/react/react/releases) - [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md) - [Commits](https://github.com/react/react/commits/v18.3.1/packages/react) Updates `react-dom` from 18.2.0 to 18.3.1 - [Release notes](https://github.com/react/react/releases) - [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md) - [Commits](https://github.com/react/react/commits/v18.3.1/packages/react-dom) Updates `react-dropzone` from 14.2.3 to 14.4.1 - [Release notes](https://github.com/react-dropzone/react-dropzone/releases) - [Commits](https://github.com/react-dropzone/react-dropzone/compare/v14.2.3...v14.4.1) --- updated-dependencies: - dependency-name: react dependency-version: 18.3.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-dependencies - dependency-name: react-dom dependency-version: 18.3.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-dependencies - dependency-name: react-dropzone dependency-version: 14.4.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-dependencies ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- app/package-lock.json | 102 ++++++++++++++++++++++-------------------- app/package.json | 6 +-- 2 files changed, 57 insertions(+), 51 deletions(-) diff --git a/app/package-lock.json b/app/package-lock.json index 46e23dcb..00072fbb 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -25,10 +25,10 @@ "pmtiles": "^4.5.0", "protomaps-themes-base": "^4.5.0", "query-string": "^9.5.1", - "react": "^18.2.0", + "react": "^18.3.1", "react-colorful": "^5.8.1", - "react-dom": "^18.2.0", - "react-dropzone": "^14.2.3", + "react-dom": "^18.3.1", + "react-dropzone": "^14.4.1", "react-hotkeys-hook": "^5.3.3", "react-qr-code": "^2.2.0", "react-router": "^7.18.2", @@ -7483,9 +7483,10 @@ "license": "MIT" }, "node_modules/attr-accept": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz", - "integrity": "sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==", + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "license": "MIT", "engines": { "node": ">=4" } @@ -11358,11 +11359,12 @@ } }, "node_modules/file-selector": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.6.0.tgz", - "integrity": "sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", + "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", + "license": "MIT", "dependencies": { - "tslib": "^2.4.0" + "tslib": "^2.7.0" }, "engines": { "node": ">= 12" @@ -18620,9 +18622,10 @@ } }, "node_modules/react": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", - "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" }, @@ -18774,24 +18777,26 @@ } }, "node_modules/react-dom": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", - "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", - "scheduler": "^0.23.0" + "scheduler": "^0.23.2" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.3.1" } }, "node_modules/react-dropzone": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.3.tgz", - "integrity": "sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==", + "version": "14.4.1", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.4.1.tgz", + "integrity": "sha512-QDuV76v3uKbHiH34SpwifZ+gOLi1+RdsCO1kl5vxMT4wW8R82+sthjvBw4th3NHF/XX6FBsqDYZVNN+pnhaw0g==", + "license": "MIT", "dependencies": { - "attr-accept": "^2.2.2", - "file-selector": "^0.6.0", + "attr-accept": "^2.2.4", + "file-selector": "^2.1.0", "prop-types": "^15.8.1" }, "engines": { @@ -19901,9 +19906,10 @@ } }, "node_modules/scheduler": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", - "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" } @@ -28198,9 +28204,9 @@ "integrity": "sha512-LEeSAWeh2Gfa2FtlQE1shxQ8zi5F9GHarrGKz08TMdODD5T4eH6BMsvtnhbWZ+XQn+Gb6om/917ucvRu7l7ukw==" }, "attr-accept": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz", - "integrity": "sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==" + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==" }, "autoprefixer": { "version": "10.4.13", @@ -30947,11 +30953,11 @@ } }, "file-selector": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.6.0.tgz", - "integrity": "sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", + "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", "requires": { - "tslib": "^2.4.0" + "tslib": "^2.7.0" } }, "filelist": { @@ -35892,9 +35898,9 @@ } }, "react": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", - "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "requires": { "loose-envify": "^1.1.0" } @@ -36005,21 +36011,21 @@ } }, "react-dom": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", - "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "requires": { "loose-envify": "^1.1.0", - "scheduler": "^0.23.0" + "scheduler": "^0.23.2" } }, "react-dropzone": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.3.tgz", - "integrity": "sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==", + "version": "14.4.1", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.4.1.tgz", + "integrity": "sha512-QDuV76v3uKbHiH34SpwifZ+gOLi1+RdsCO1kl5vxMT4wW8R82+sthjvBw4th3NHF/XX6FBsqDYZVNN+pnhaw0g==", "requires": { - "attr-accept": "^2.2.2", - "file-selector": "^0.6.0", + "attr-accept": "^2.2.4", + "file-selector": "^2.1.0", "prop-types": "^15.8.1" } }, @@ -36736,9 +36742,9 @@ } }, "scheduler": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", - "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "requires": { "loose-envify": "^1.1.0" } diff --git a/app/package.json b/app/package.json index 5fbbe392..442229be 100644 --- a/app/package.json +++ b/app/package.json @@ -20,10 +20,10 @@ "pmtiles": "^4.5.0", "protomaps-themes-base": "^4.5.0", "query-string": "^9.5.1", - "react": "^18.2.0", + "react": "^18.3.1", "react-colorful": "^5.8.1", - "react-dom": "^18.2.0", - "react-dropzone": "^14.2.3", + "react-dom": "^18.3.1", + "react-dropzone": "^14.4.1", "react-hotkeys-hook": "^5.3.3", "react-qr-code": "^2.2.0", "react-router": "^7.18.2", From 9fc9929647bbdd1df21d894f5c8a17c69bb0422c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:33:23 -0600 Subject: [PATCH 063/181] build(deps): bump web-vitals from 5.3.0 to 6.2.2 in /app (#764) Bumps [web-vitals](https://github.com/GoogleChrome/web-vitals) from 5.3.0 to 6.2.2. - [Changelog](https://github.com/GoogleChrome/web-vitals/blob/main/CHANGELOG.md) - [Commits](https://github.com/GoogleChrome/web-vitals/compare/v5.3.0...v6.2.2) --- updated-dependencies: - dependency-name: web-vitals dependency-version: 6.2.2 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- app/package-lock.json | 15 ++++++++------- app/package.json | 2 +- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/app/package-lock.json b/app/package-lock.json index 00072fbb..644e3d93 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -35,7 +35,7 @@ "react-scripts": "5.0.1", "react-stl-viewer": "^2.5.0", "three": "^0.186", - "web-vitals": "^5.3.0" + "web-vitals": "^6.2.2" }, "devDependencies": { "@testing-library/dom": "^10.4.2", @@ -21887,9 +21887,10 @@ } }, "node_modules/web-vitals": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.3.0.tgz", - "integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==" + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.2.tgz", + "integrity": "sha512-oto5x6dLEgrRqfcWed+pZEUb2q6ikbFmqF54CRDhI/QGbn+qxC49b4C4OkbH+kb9C3a8shpFD3SgR9kRcs80ZQ==", + "license": "Apache-2.0" }, "node_modules/webidl-conversions": { "version": "6.1.0", @@ -38146,9 +38147,9 @@ } }, "web-vitals": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.3.0.tgz", - "integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==" + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.2.tgz", + "integrity": "sha512-oto5x6dLEgrRqfcWed+pZEUb2q6ikbFmqF54CRDhI/QGbn+qxC49b4C4OkbH+kb9C3a8shpFD3SgR9kRcs80ZQ==" }, "webidl-conversions": { "version": "6.1.0", diff --git a/app/package.json b/app/package.json index 442229be..1ae6c6b1 100644 --- a/app/package.json +++ b/app/package.json @@ -30,7 +30,7 @@ "react-scripts": "5.0.1", "react-stl-viewer": "^2.5.0", "three": "^0.186", - "web-vitals": "^5.3.0" + "web-vitals": "^6.2.2" }, "scripts": { "start": "react-scripts start", From 51d9c07784a96f4f9f7b5725d7a154d0111b77d9 Mon Sep 17 00:00:00 2001 From: lrnselfreliance <33263708+lrnselfreliance@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:34:52 -0600 Subject: [PATCH 064/181] dependabot: also hold cypress 16, react-router 8 and testing-library/react majors (#767) Each regenerated after the last config change and each needs its own migration: cypress 16 needs Node 22, react-router 8 touches ~40 files, and @testing-library/react 16 fails 15 menu/popover tests. Co-authored-by: Claude Fable 5.1 --- .github/dependabot.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index dcb91909..6db95985 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -71,6 +71,15 @@ updates: # Mantine 9: all @mantine packages move together with app code changes. - dependency-name: "@mantine/*" update-types: ["version-update:semver-major"] + # Cypress 16 requires Node >=22; CI and the app image run Node 20. + - dependency-name: "cypress" + versions: [">=16"] + # react-router 8 is a routing migration across ~40 files. + - dependency-name: "react-router" + update-types: ["version-update:semver-major"] + # @testing-library/react 14+ async act semantics break menu/popover tests. + - dependency-name: "@testing-library/react" + update-types: ["version-update:semver-major"] groups: npm-dependencies: patterns: From 6b79323d2c24d0804b8462c5440b82eac4379a62 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:43:52 -0600 Subject: [PATCH 065/181] build(deps-dev): bump webpack-dev-server from 5.2.6 to 6.0.0 in /app (#769) Bumps [webpack-dev-server](https://github.com/webpack/webpack-dev-server) from 5.2.6 to 6.0.0. - [Release notes](https://github.com/webpack/webpack-dev-server/releases) - [Changelog](https://github.com/webpack/webpack-dev-server/blob/main/CHANGELOG.md) - [Commits](https://github.com/webpack/webpack-dev-server/compare/v5.2.6...v6.0.0) --- updated-dependencies: - dependency-name: webpack-dev-server dependency-version: 6.0.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- app/package-lock.json | 2177 ++++++++++++++++++++++++++++------------- app/package.json | 2 +- 2 files changed, 1505 insertions(+), 674 deletions(-) diff --git a/app/package-lock.json b/app/package-lock.json index 644e3d93..11c9c902 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -45,7 +45,7 @@ "@types/react": "^18.3.17", "@types/react-dom": "^18.3.5", "typescript": "^5.9.3", - "webpack-dev-server": "^5.2.6" + "webpack-dev-server": "^6.0.0" } }, "node_modules/@acalcutt/maplibre-contour-pmtiles": { @@ -4533,7 +4533,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=10.0" @@ -4550,7 +4550,7 @@ "version": "17.67.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-17.67.0.tgz", "integrity": "sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=10.0" @@ -4567,7 +4567,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-1.0.0.tgz", "integrity": "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=10.0" @@ -4581,14 +4581,14 @@ } }, "node_modules/@jsonjoy.com/fs-core": { - "version": "4.68.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.68.1.tgz", - "integrity": "sha512-V5oZ4Gt9WJKyQef0n9cAd0N9qjSkIBm3E4MYsgNIWBk5aINCDPKxMPo1i29rBxqiT4Ixf1epklqV9VJMKIxwlw==", - "devOptional": true, + "version": "4.80.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.80.0.tgz", + "integrity": "sha512-qMKWshnyjbyhm+NbzqGE3w5y1mckFFEMj0aDPU2/JBNh1BzIjVy9esq9lBmRnNVmhMEkxwDzLZ8Gqh5G1P5Dag==", + "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-node-builtins": "4.68.1", - "@jsonjoy.com/fs-node-utils": "4.68.1", + "@jsonjoy.com/fs-node-builtins": "4.80.0", + "@jsonjoy.com/fs-node-utils": "4.80.0", "thingies": "^2.5.0" }, "engines": { @@ -4603,15 +4603,15 @@ } }, "node_modules/@jsonjoy.com/fs-fsa": { - "version": "4.68.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.68.1.tgz", - "integrity": "sha512-HCG72UioncuO7Gw09XNVG+S85e3cq2hrUC/mexBrsWsa3mI7eePkkqWie3uVYbtsb64OR9YGQs5SqaufDRYBcg==", - "devOptional": true, + "version": "4.80.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.80.0.tgz", + "integrity": "sha512-ZBEKl7J6dbkRCfmrFV48Re211A/FHqniTUhsWCsaRteNSOqMgkX3qF1UreVgwB9oSPm81JLAwhIi6Lb/NH/PoA==", + "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-core": "4.68.1", - "@jsonjoy.com/fs-node-builtins": "4.68.1", - "@jsonjoy.com/fs-node-utils": "4.68.1", + "@jsonjoy.com/fs-core": "4.80.0", + "@jsonjoy.com/fs-node-builtins": "4.80.0", + "@jsonjoy.com/fs-node-utils": "4.80.0", "thingies": "^2.5.0" }, "engines": { @@ -4626,18 +4626,18 @@ } }, "node_modules/@jsonjoy.com/fs-node": { - "version": "4.68.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.68.1.tgz", - "integrity": "sha512-R5D9mWtqdURzcOWj1vdXr3APCwX0xchtFT+kmW7fXLNDifWdDrnh26jSID8pdnUfFBxTyfHtFtTL/NWKzIH7kQ==", - "devOptional": true, + "version": "4.80.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.80.0.tgz", + "integrity": "sha512-yiBlUfkMMRFFGy8CD/h2zd5rPnrM6bjlF+nx2LRAxNV6hOSiuObQIUEE7m7y2VTMisjxCRtzINH94bFGySWL7g==", + "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-core": "4.68.1", - "@jsonjoy.com/fs-node-builtins": "4.68.1", - "@jsonjoy.com/fs-node-utils": "4.68.1", - "@jsonjoy.com/fs-print": "4.68.1", - "@jsonjoy.com/fs-snapshot": "4.68.1", - "glob-to-regex.js": "^1.0.0", + "@jsonjoy.com/fs-core": "4.80.0", + "@jsonjoy.com/fs-node-builtins": "4.80.0", + "@jsonjoy.com/fs-node-utils": "4.80.0", + "@jsonjoy.com/fs-print": "4.80.0", + "@jsonjoy.com/fs-snapshot": "4.80.0", + "glob-to-regex.js": "^1.3.1", "thingies": "^2.5.0" }, "engines": { @@ -4652,10 +4652,10 @@ } }, "node_modules/@jsonjoy.com/fs-node-builtins": { - "version": "4.68.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.68.1.tgz", - "integrity": "sha512-HK1BTksysokNZxNspqDH0yPaqN9YgR/AYIlYiIaU2Ys4BOk5CdybI7r6BgiZuiiPiV8n4sK/kZdice7Znpy2Kw==", - "devOptional": true, + "version": "4.80.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.80.0.tgz", + "integrity": "sha512-OIOIhqaWiwUySFMny4epIAoviXsyMaQQ63PTc3zH+5HEOvarl5hpUmZIhp2m5+OfyKAh5ylxXnQIgew1OgJDVw==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=10.0" @@ -4669,15 +4669,15 @@ } }, "node_modules/@jsonjoy.com/fs-node-to-fsa": { - "version": "4.68.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.68.1.tgz", - "integrity": "sha512-lpKmU4X9e/oh8GIuAI7EXaS5QiLNM3KD15CkdhfS6PYmrGvoJqKQcyEfnLgnnaGslh/PFUMYSIZBCf2ejJGw8g==", - "devOptional": true, + "version": "4.80.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.80.0.tgz", + "integrity": "sha512-ljeaR5xwtX1EyRmB0wvXANUMQZ3mjKcm7z8s2N8D8SLQ04CcAGtUhHFRZQXFb9mhgFyEVMydQGQtSOFS7pcrFQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-fsa": "4.68.1", - "@jsonjoy.com/fs-node-builtins": "4.68.1", - "@jsonjoy.com/fs-node-utils": "4.68.1" + "@jsonjoy.com/fs-fsa": "4.80.0", + "@jsonjoy.com/fs-node-builtins": "4.80.0", + "@jsonjoy.com/fs-node-utils": "4.80.0" }, "engines": { "node": ">=10.0" @@ -4691,14 +4691,14 @@ } }, "node_modules/@jsonjoy.com/fs-node-utils": { - "version": "4.68.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.68.1.tgz", - "integrity": "sha512-/GxfW1DWm9SCdkfbvqevLO/P5duobQfmKkHXxdMIDbcZMQeAgooAstIfZhkXpATzq9QbCQsnoWFM/dGHdZfndw==", - "devOptional": true, + "version": "4.80.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.80.0.tgz", + "integrity": "sha512-xwHeVVi3f+Khg/Fmb8i5qoYVmQ08QznE5TWhQokUmIZjz4KBPzU+zt/+bahBI6G1ghtqajt27lWuAciIxApuzA==", + "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-node-builtins": "4.68.1", - "glob-to-regex.js": "^1.0.1" + "@jsonjoy.com/fs-node-builtins": "4.80.0", + "glob-to-regex.js": "^1.3.1" }, "engines": { "node": ">=10.0" @@ -4712,13 +4712,13 @@ } }, "node_modules/@jsonjoy.com/fs-print": { - "version": "4.68.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.68.1.tgz", - "integrity": "sha512-oGeZOGPYKK9v1CgeVeEDsLomH1lCnslSpqUN5GmPzrmAVGQlsmsdcXNA2O4lV8Y4xkuSuynx2ITBkUHJVaTbow==", - "devOptional": true, + "version": "4.80.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.80.0.tgz", + "integrity": "sha512-1ocZLJw/m/xlq0moaIHO7HV9nHRVBUvdc2zMzhsJGkFOrqfwWvexfCv5cYCPPLRhP+9p3yunoi4Ep5YhRORC9Q==", + "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-node-utils": "4.68.1", + "@jsonjoy.com/fs-node-utils": "4.80.0", "tree-dump": "^1.1.0" }, "engines": { @@ -4733,14 +4733,14 @@ } }, "node_modules/@jsonjoy.com/fs-snapshot": { - "version": "4.68.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.68.1.tgz", - "integrity": "sha512-XZfP0FDZN32bbc4t2bZN2qRrYHg5AktJnzk22HRoKGK4BprrbNRH2k5ceSNS/kupKYcofCs+O841+xaAbjnxwQ==", - "devOptional": true, + "version": "4.80.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.80.0.tgz", + "integrity": "sha512-3AV23Q1H228KTtJKTUE0nNq8FCtxSvtENOLrEat80wkAJCKHuqQEv8yjhpS8Bei2OEFJAnluGXp9Ri2SWBlROw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@jsonjoy.com/buffers": "^17.65.0", - "@jsonjoy.com/fs-node-utils": "4.68.1", + "@jsonjoy.com/fs-node-utils": "4.80.0", "@jsonjoy.com/json-pack": "^17.65.0", "@jsonjoy.com/util": "^17.65.0" }, @@ -4759,7 +4759,7 @@ "version": "17.67.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-17.67.0.tgz", "integrity": "sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=10.0" @@ -4776,7 +4776,7 @@ "version": "17.67.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-17.67.0.tgz", "integrity": "sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=10.0" @@ -4793,7 +4793,7 @@ "version": "17.67.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-17.67.0.tgz", "integrity": "sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@jsonjoy.com/base64": "17.67.0", @@ -4820,7 +4820,7 @@ "version": "17.67.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-17.67.0.tgz", "integrity": "sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@jsonjoy.com/util": "17.67.0" @@ -4840,7 +4840,7 @@ "version": "17.67.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-17.67.0.tgz", "integrity": "sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@jsonjoy.com/buffers": "17.67.0", @@ -4861,7 +4861,7 @@ "version": "1.21.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.21.0.tgz", "integrity": "sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@jsonjoy.com/base64": "^1.1.2", @@ -4888,7 +4888,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=10.0" @@ -4905,7 +4905,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-1.0.2.tgz", "integrity": "sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@jsonjoy.com/codegen": "^1.0.0", @@ -4926,7 +4926,7 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.9.0.tgz", "integrity": "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@jsonjoy.com/buffers": "^1.0.0", @@ -4947,7 +4947,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=10.0" @@ -5172,7 +5172,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 16" @@ -5217,7 +5217,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz", "integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.8.0", @@ -5231,7 +5231,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz", "integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.8.0", @@ -5244,7 +5244,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz", "integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.8.0", @@ -5257,7 +5257,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz", "integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@peculiar/asn1-cms": "^2.8.0", @@ -5272,7 +5272,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz", "integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.8.0", @@ -5285,7 +5285,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz", "integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@peculiar/asn1-cms": "^2.8.0", @@ -5302,7 +5302,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz", "integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.8.0", @@ -5315,7 +5315,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@peculiar/utils": "^2.0.2", @@ -5327,7 +5327,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz", "integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.8.0", @@ -5340,7 +5340,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz", "integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.8.0", @@ -5353,7 +5353,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -5363,7 +5363,7 @@ "version": "1.14.3", "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@peculiar/asn1-cms": "^2.6.0", @@ -5382,107 +5382,6 @@ "node": ">=20.0.0" } }, - "node_modules/@pmmmwh/react-refresh-webpack-plugin": { - "version": "0.5.17", - "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.17.tgz", - "integrity": "sha512-tXDyE1/jzFsHXjhRZQ3hMl0IVhYe5qula43LDWIhVfjp9G/nT5OQY5AORVOrkEGAUltBJOfOWeETbmhm6kHhuQ==", - "license": "MIT", - "dependencies": { - "ansi-html": "^0.0.9", - "core-js-pure": "^3.23.3", - "error-stack-parser": "^2.0.6", - "html-entities": "^2.1.0", - "loader-utils": "^2.0.4", - "schema-utils": "^4.2.0", - "source-map": "^0.7.3" - }, - "engines": { - "node": ">= 10.13" - }, - "peerDependencies": { - "@types/webpack": "4.x || 5.x", - "react-refresh": ">=0.10.0 <1.0.0", - "sockjs-client": "^1.4.0", - "type-fest": ">=0.17.0 <5.0.0", - "webpack": ">=4.43.0 <6.0.0", - "webpack-dev-server": "3.x || 4.x || 5.x", - "webpack-hot-middleware": "2.x", - "webpack-plugin-serve": "0.x || 1.x" - }, - "peerDependenciesMeta": { - "@types/webpack": { - "optional": true - }, - "sockjs-client": { - "optional": true - }, - "type-fest": { - "optional": true - }, - "webpack-dev-server": { - "optional": true - }, - "webpack-hot-middleware": { - "optional": true - }, - "webpack-plugin-serve": { - "optional": true - } - } - }, - "node_modules/@pmmmwh/react-refresh-webpack-plugin/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@pmmmwh/react-refresh-webpack-plugin/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/@pmmmwh/react-refresh-webpack-plugin/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/@pmmmwh/react-refresh-webpack-plugin/node_modules/schema-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", - "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/@react-three/fiber": { "version": "8.16.5", "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.16.5.tgz", @@ -6555,13 +6454,6 @@ "@types/node": "*" } }, - "node_modules/@types/retry": { - "version": "0.12.2", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", - "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", - "devOptional": true, - "license": "MIT" - }, "node_modules/@types/semver": { "version": "7.3.13", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.13.tgz", @@ -7433,7 +7325,7 @@ "version": "3.0.10", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "dependencies": { "pvtsutils": "^1.3.6", @@ -8183,10 +8075,10 @@ } }, "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", - "devOptional": true, + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.1.tgz", + "integrity": "sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==", + "dev": true, "license": "MIT", "dependencies": { "run-applescript": "^7.0.0" @@ -8210,7 +8102,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=6.0.0" @@ -8819,10 +8711,14 @@ } }, "node_modules/core-js-pure": { - "version": "3.27.2", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.27.2.tgz", - "integrity": "sha512-Cf2jqAbXgWH3VVzjyaaFkY1EBazxugUepGymDoeteyYr9ByX51kD2jdHZlsEF/xnJMyN3Prua7mQuzwMg6Zc9A==", + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.50.0.tgz", + "integrity": "sha512-6GP3Pxz4IKyWjAfa747vIu/jilB5z29JWROLqH/b+pXVcpgh6tM06ZIBwSuglgVqzDYURhOK6oEzTrG0bCHitA==", "hasInstallScript": true, + "license": "MIT", + "engines": { + "node": "*" + }, "funding": { "type": "opencollective", "url": "https://opencollective.com/core-js" @@ -9537,11 +9433,12 @@ "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==" }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -9612,10 +9509,10 @@ } }, "node_modules/default-browser": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", - "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", - "devOptional": true, + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", + "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", + "dev": true, "license": "MIT", "dependencies": { "bundle-name": "^4.1.0", @@ -9632,7 +9529,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -10111,6 +10008,7 @@ "version": "2.1.4", "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "license": "MIT", "dependencies": { "stackframe": "^1.3.4" } @@ -11969,10 +11867,10 @@ } }, "node_modules/glob-to-regex.js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.2.0.tgz", - "integrity": "sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==", - "devOptional": true, + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.3.1.tgz", + "integrity": "sha512-zhWhMRsgnOym+1txhUmYJGD6eUsbUdrZv55gCYKuJX8t2p3+M8My23PD09bEIsreGUs5pnSa/idmcfp2/IlqGg==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=10.0" @@ -12482,6 +12380,13 @@ "node": ">= 6" } }, + "node_modules/httpxy": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/httpxy/-/httpxy-0.5.5.tgz", + "integrity": "sha512-uDjmnPyp1q4Sgzf3w+J/Fc6UqcCEj0x4Wjp7OqK5dGhNeDgpyrAmnS6ey8QWrX3SWDon2DMKf9sBa5X9+CVyMA==", + "dev": true, + "license": "MIT" + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -12494,7 +12399,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=10.18" @@ -12829,11 +12734,24 @@ "node": ">=0.10.0" } }, + "node_modules/is-in-ssh": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", + "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "is-docker": "^3.0.0" @@ -12852,7 +12770,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "is-docker": "cli.js" @@ -12908,7 +12826,7 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.2.tgz", "integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=16" @@ -12972,6 +12890,13 @@ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", @@ -16315,9 +16240,10 @@ "integrity": "sha512-Qi0VCU46t2IwfGv5KF0+D/t9cizcDug7qnNoy9Ggk7aucp0tssV8IwTMkBlDbm+VqAf3cdQHTCARKSsuS2MYFg==" }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" }, "node_modules/multicast-dns": { "version": "7.2.5", @@ -16733,18 +16659,16 @@ } }, "node_modules/p-retry": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz", - "integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==", - "devOptional": true, + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-8.0.1.tgz", + "integrity": "sha512-NAigyu8hfvJe7MsS18mnc4is0MZtau3QMdBklaJKK23oBQb6occO3UPM3vHmTckW8KhfyjMZaUOqdTFEYliHgg==", + "dev": true, "license": "MIT", "dependencies": { - "@types/retry": "0.12.2", - "is-network-error": "^1.0.0", - "retry": "^0.13.1" + "is-network-error": "^1.3.0" }, "engines": { - "node": ">=16.17" + "node": ">=22" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -17060,7 +16984,7 @@ "version": "3.4.0", "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", "integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "dependencies": { "@noble/hashes": "1.4.0", @@ -18276,6 +18200,19 @@ "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==" }, + "node_modules/powershell-utils": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.1.tgz", + "integrity": "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -18439,7 +18376,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -18449,7 +18386,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=16.0.0" @@ -19027,6 +18964,54 @@ } } }, + "node_modules/react-scripts/node_modules/@pmmmwh/react-refresh-webpack-plugin": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.17.tgz", + "integrity": "sha512-tXDyE1/jzFsHXjhRZQ3hMl0IVhYe5qula43LDWIhVfjp9G/nT5OQY5AORVOrkEGAUltBJOfOWeETbmhm6kHhuQ==", + "license": "MIT", + "dependencies": { + "ansi-html": "^0.0.9", + "core-js-pure": "^3.23.3", + "error-stack-parser": "^2.0.6", + "html-entities": "^2.1.0", + "loader-utils": "^2.0.4", + "schema-utils": "^4.2.0", + "source-map": "^0.7.3" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "@types/webpack": "4.x || 5.x", + "react-refresh": ">=0.10.0 <1.0.0", + "sockjs-client": "^1.4.0", + "type-fest": ">=0.17.0 <5.0.0", + "webpack": ">=4.43.0 <6.0.0", + "webpack-dev-server": "3.x || 4.x || 5.x", + "webpack-hot-middleware": "2.x", + "webpack-plugin-serve": "0.x || 1.x" + }, + "peerDependenciesMeta": { + "@types/webpack": { + "optional": true + }, + "sockjs-client": { + "optional": true + }, + "type-fest": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + }, + "webpack-hot-middleware": { + "optional": true + }, + "webpack-plugin-serve": { + "optional": true + } + } + }, "node_modules/react-scripts/node_modules/@types/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", @@ -19357,7 +19342,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, "node_modules/regenerate": { @@ -19775,11 +19760,39 @@ "node": ">=8" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -19945,7 +19958,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-5.5.0.tgz", "integrity": "sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@peculiar/x509": "^1.14.2", @@ -20029,11 +20042,6 @@ "node": ">= 0.8" } }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, "node_modules/serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", @@ -20044,20 +20052,25 @@ } }, "node_modules/serve-index": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz", - "integrity": "sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.2.tgz", + "integrity": "sha512-KDj11HScOaLmrPxl70KYNW1PksP4Nb/CLL2yvC+Qd2kHMPEEpfc4Re2e4FOay+bC/+XQl/7zAcWON3JVo5v3KQ==", + "license": "MIT", "dependencies": { - "accepts": "~1.3.4", + "accepts": "~1.3.8", "batch": "0.6.1", "debug": "2.6.9", "escape-html": "~1.0.3", - "http-errors": "~1.6.2", - "mime-types": "~2.1.17", - "parseurl": "~1.3.2" + "http-errors": "~1.8.0", + "mime-types": "~2.1.35", + "parseurl": "~1.3.3" }, "engines": { "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/serve-index/node_modules/debug": { @@ -20072,43 +20085,37 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/serve-index/node_modules/http-errors": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", - "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "license": "MIT", "dependencies": { "depd": "~1.1.2", - "inherits": "2.0.3", - "setprototypeof": "1.1.0", - "statuses": ">= 1.4.0 < 2" + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" }, "engines": { "node": ">= 0.6" } }, - "node_modules/serve-index/node_modules/inherits": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==" - }, "node_modules/serve-index/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, - "node_modules/serve-index/node_modules/setprototypeof": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", - "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==" - }, "node_modules/serve-index/node_modules/statuses": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -20485,7 +20492,8 @@ "node_modules/stackframe": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", - "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==" + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "license": "MIT" }, "node_modules/statuses": { "version": "2.0.1", @@ -21157,7 +21165,7 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.6.1.tgz", "integrity": "sha512-cV/CMGTK3M4MlnJ/0At6ismOw/A0EEniDNScajjz/Br3c1sqE72YD01rGpPTKwd27wAxI5Pr+6+0w8yofzFRYw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=10.18" @@ -21226,6 +21234,54 @@ "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==" }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tinyqueue": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", @@ -21316,7 +21372,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=10.0" @@ -21401,7 +21457,7 @@ "version": "4.10.0", "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "tslib": "^1.9.3" @@ -21414,7 +21470,7 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "devOptional": true, + "dev": true, "license": "0BSD" }, "node_modules/tunnel-agent": { @@ -21948,28 +22004,27 @@ } }, "node_modules/webpack-dev-middleware": { - "version": "7.4.5", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-7.4.5.tgz", - "integrity": "sha512-uxQ6YqGdE4hgDKNf7hUiPXOdtkXvBJXrfEGYSx7P7LC8hnUYGK70X6xQXUvXeNyBDDcsiQXpG2m3G9vxowaEuA==", - "devOptional": true, + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-8.3.0.tgz", + "integrity": "sha512-jFNNMB29ugmkEUFW9cpWNqcWFQNwuMTMVoRpKVAiz/5nh3gA4x9x6DRPhNHGc/SLre7pEP9yrAPDAmCvdCblKg==", + "dev": true, "license": "MIT", "dependencies": { - "colorette": "^2.0.10", - "memfs": "^4.43.1", - "mime-types": "^3.0.1", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "schema-utils": "^4.0.0" + "ansi-html-community": "^0.0.8", + "memfs": "^4.68.2", + "mime-types": "^3.0.2", + "range-parser": "^1.3.0", + "schema-utils": "^4.3.3" }, "engines": { - "node": ">= 18.12.0" + "node": ">= 20.9.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/webpack" }, "peerDependencies": { - "webpack": "^5.0.0" + "webpack": "^5.101.0" }, "peerDependenciesMeta": { "webpack": { @@ -21981,7 +22036,7 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -21994,11 +22049,29 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/webpack-dev-middleware/node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/webpack-dev-middleware/node_modules/ajv-keywords": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3" @@ -22011,27 +22084,27 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/webpack-dev-middleware/node_modules/memfs": { - "version": "4.68.1", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.68.1.tgz", - "integrity": "sha512-OD+IDRUvIxu3QHL+nFm9gdyugInD27FDJ+sl4B5QgomPHXMlbw+GP918P8VNKu2FkNlVeqBkpzkwROpamVifRw==", - "devOptional": true, + "version": "4.80.0", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.80.0.tgz", + "integrity": "sha512-N70W2isi44XdGn7uPo9NJ4zRukcdkwSFHlnYx42SWOH/+hzqNc8P7+tVoA2up4z7faHvNi1RSNCFjxoYQ71VPQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-core": "4.68.1", - "@jsonjoy.com/fs-fsa": "4.68.1", - "@jsonjoy.com/fs-node": "4.68.1", - "@jsonjoy.com/fs-node-builtins": "4.68.1", - "@jsonjoy.com/fs-node-to-fsa": "4.68.1", - "@jsonjoy.com/fs-node-utils": "4.68.1", - "@jsonjoy.com/fs-print": "4.68.1", - "@jsonjoy.com/fs-snapshot": "4.68.1", + "@jsonjoy.com/fs-core": "4.80.0", + "@jsonjoy.com/fs-fsa": "4.80.0", + "@jsonjoy.com/fs-node": "4.80.0", + "@jsonjoy.com/fs-node-builtins": "4.80.0", + "@jsonjoy.com/fs-node-to-fsa": "4.80.0", + "@jsonjoy.com/fs-node-utils": "4.80.0", + "@jsonjoy.com/fs-print": "4.80.0", + "@jsonjoy.com/fs-snapshot": "4.80.0", "@jsonjoy.com/json-pack": "^1.11.0", "@jsonjoy.com/util": "^1.9.0", - "glob-to-regex.js": "^1.0.1", + "glob-to-regex.js": "^1.3.1", "thingies": "^2.5.0", "tree-dump": "^1.0.3", "tslib": "^2.0.0" @@ -22045,7 +22118,7 @@ "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -22055,7 +22128,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "mime-db": "^1.54.0" @@ -22068,16 +22141,30 @@ "url": "https://opencollective.com/express" } }, + "node_modules/webpack-dev-middleware/node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/webpack-dev-middleware/node_modules/schema-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", - "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", - "devOptional": true, + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.5.0.tgz", + "integrity": "sha512-zJlMCZ0cAR5p/Y4oVpRoqioDMJcGxaXRrQ/4rP4WyR84vc5z/DolXdbvXeDpTwbtocDFr2rhPqHPErDCUtz2kA==", + "dev": true, "license": "MIT", "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", + "@types/json-schema": "^7.0.15", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", "ajv-keywords": "^5.1.0" }, "engines": { @@ -22089,53 +22176,50 @@ } }, "node_modules/webpack-dev-server": { - "version": "5.2.6", - "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.6.tgz", - "integrity": "sha512-HNLRmamRvVavZQ+avceZifmv8hmdUjg43t6MI4SqJDwFdW7RPQwH5vzGhDRZSX59SgfbeHhLnq3g+uooWo7pVw==", - "devOptional": true, + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-6.0.0.tgz", + "integrity": "sha512-q9SD4ItOGhZLeU6EGT10caDZdHjF50Pz1DtkRZZOPsfluMXOkacWKKOtSBSLVkPqKiF67eFUC0rI88U/tSFPEw==", + "dev": true, "license": "MIT", "dependencies": { "@types/bonjour": "^3.5.13", "@types/connect-history-api-fallback": "^1.5.4", - "@types/express": "^4.17.25", - "@types/express-serve-static-core": "^4.17.21", + "@types/express": "^5.0.6", + "@types/express-serve-static-core": "^5.1.1", "@types/serve-index": "^1.9.4", - "@types/serve-static": "^1.15.5", - "@types/sockjs": "^0.3.36", - "@types/ws": "^8.5.10", + "@types/serve-static": "^2.2.0", + "@types/ws": "^8.18.1", "ansi-html-community": "^0.0.8", - "bonjour-service": "^1.2.1", - "chokidar": "^3.6.0", - "colorette": "^2.0.10", + "bonjour-service": "^1.3.0", + "chokidar": "^5.0.0", "compression": "^1.8.1", "connect-history-api-fallback": "^2.0.0", - "express": "^4.22.1", - "graceful-fs": "^4.2.6", - "http-proxy-middleware": "^2.0.9", - "ipaddr.js": "^2.1.0", + "express": "^5.2.1", + "graceful-fs": "^4.2.11", + "http-proxy-middleware": "^4.1.1", + "ipaddr.js": "^2.3.0", "launch-editor": "^2.14.1", - "open": "^10.0.3", - "p-retry": "^6.2.0", - "schema-utils": "^4.2.0", + "open": "^11.0.0", + "p-retry": "^8.0.0", + "schema-utils": "^4.3.3", "selfsigned": "^5.5.0", - "serve-index": "^1.9.1", - "sockjs": "^0.3.24", - "spdy": "^4.0.2", - "webpack-dev-middleware": "^7.4.2", - "ws": "^8.18.0" + "serve-index": "^1.9.2", + "tinyglobby": "^0.2.15", + "webpack-dev-middleware": "^8.0.3", + "ws": "^8.20.0" }, "bin": { "webpack-dev-server": "bin/webpack-dev-server.js" }, "engines": { - "node": ">= 18.12.0" + "node": ">= 22.15.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/webpack" }, "peerDependencies": { - "webpack": "^5.0.0" + "webpack": "^5.101.0" }, "peerDependenciesMeta": { "webpack": { @@ -22146,11 +22230,61 @@ } } }, + "node_modules/webpack-dev-server/node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/webpack-dev-server/node_modules/@types/express-serve-static-core": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/webpack-dev-server/node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/webpack-dev-server/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/webpack-dev-server/node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -22167,7 +22301,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3" @@ -22176,36 +22310,90 @@ "ajv": "^8.8.2" } }, - "node_modules/webpack-dev-server/node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "devOptional": true, + "node_modules/webpack-dev-server/node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "dev": true, "license": "MIT", "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { - "node": ">= 8.10.0" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/webpack-dev-server/node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/webpack-dev-server/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/webpack-dev-server/node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" }, - "optionalDependencies": { - "fsevents": "~2.3.2" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/webpack-dev-server/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" } }, "node_modules/webpack-dev-server/node_modules/define-lazy-prop": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -22214,38 +22402,177 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/webpack-dev-server/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "devOptional": true, - "license": "ISC", + "node_modules/webpack-dev-server/node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, + "license": "MIT", "dependencies": { - "is-glob": "^4.0.1" + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" }, "engines": { - "node": ">= 6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/webpack-dev-server/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/webpack-dev-server/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webpack-dev-server/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/webpack-dev-server/node_modules/http-proxy-middleware": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-4.2.0.tgz", + "integrity": "sha512-ZA+oNOoM+GLoFTIzhkJptVQov73Srep2LBqhF8hG8CIPKO3nam1jonXVQ/QUH8RbwsmaaVz2SOJdzBNBHNtKbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "httpxy": "^0.5.4", + "is-glob": "^4.0.3", + "is-plain-obj": "^4.1.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": "^22.15.0 || ^24.0.0 || >=26.0.0" + } + }, + "node_modules/webpack-dev-server/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/webpack-dev-server/node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/webpack-dev-server/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "devOptional": true, + "dev": true, "license": "MIT" }, - "node_modules/webpack-dev-server/node_modules/open": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", - "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", - "devOptional": true, + "node_modules/webpack-dev-server/node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "dev": true, "license": "MIT", - "dependencies": { - "default-browser": "^5.2.1", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "wsl-utils": "^0.1.0" + "engines": { + "node": ">= 0.8" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/webpack-dev-server/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -22253,11 +22580,120 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/webpack-dev-server/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack-dev-server/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/webpack-dev-server/node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/webpack-dev-server/node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/webpack-dev-server/node_modules/open": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.4.tgz", + "integrity": "sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.5.1", + "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", + "is-inside-container": "^1.0.0", + "powershell-utils": "^0.2.1", + "wsl-utils": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/webpack-dev-server/node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/webpack-dev-server/node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/webpack-dev-server/node_modules/schema-utils": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@types/json-schema": "^7.0.9", @@ -22273,11 +22709,101 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/webpack-dev-server/node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/webpack-dev-server/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/webpack-dev-server/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webpack-dev-server/node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/webpack-dev-server/node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/webpack-dev-server/node_modules/ws": { "version": "8.21.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -22945,16 +23471,17 @@ } }, "node_modules/wsl-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", - "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", - "devOptional": true, + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-1.0.1.tgz", + "integrity": "sha512-c16PKLIRxBYpR3jSWsdS7SFp0COm5SOQdiFjY818dYFHJbMGuLBszpF07LBWmOS1uzrHa9VGdynsEgoeKy0oOQ==", + "dev": true, "license": "MIT", "dependencies": { - "is-wsl": "^3.1.0" + "is-wsl": "^3.1.0", + "powershell-utils": "^0.2.0" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -22964,7 +23491,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "is-inside-container": "^1.0.0" @@ -26141,107 +26668,107 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", - "devOptional": true, + "dev": true, "requires": {} }, "@jsonjoy.com/buffers": { "version": "17.67.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-17.67.0.tgz", "integrity": "sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw==", - "devOptional": true, + "dev": true, "requires": {} }, "@jsonjoy.com/codegen": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-1.0.0.tgz", "integrity": "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==", - "devOptional": true, + "dev": true, "requires": {} }, "@jsonjoy.com/fs-core": { - "version": "4.68.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.68.1.tgz", - "integrity": "sha512-V5oZ4Gt9WJKyQef0n9cAd0N9qjSkIBm3E4MYsgNIWBk5aINCDPKxMPo1i29rBxqiT4Ixf1epklqV9VJMKIxwlw==", - "devOptional": true, + "version": "4.80.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.80.0.tgz", + "integrity": "sha512-qMKWshnyjbyhm+NbzqGE3w5y1mckFFEMj0aDPU2/JBNh1BzIjVy9esq9lBmRnNVmhMEkxwDzLZ8Gqh5G1P5Dag==", + "dev": true, "requires": { - "@jsonjoy.com/fs-node-builtins": "4.68.1", - "@jsonjoy.com/fs-node-utils": "4.68.1", + "@jsonjoy.com/fs-node-builtins": "4.80.0", + "@jsonjoy.com/fs-node-utils": "4.80.0", "thingies": "^2.5.0" } }, "@jsonjoy.com/fs-fsa": { - "version": "4.68.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.68.1.tgz", - "integrity": "sha512-HCG72UioncuO7Gw09XNVG+S85e3cq2hrUC/mexBrsWsa3mI7eePkkqWie3uVYbtsb64OR9YGQs5SqaufDRYBcg==", - "devOptional": true, + "version": "4.80.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.80.0.tgz", + "integrity": "sha512-ZBEKl7J6dbkRCfmrFV48Re211A/FHqniTUhsWCsaRteNSOqMgkX3qF1UreVgwB9oSPm81JLAwhIi6Lb/NH/PoA==", + "dev": true, "requires": { - "@jsonjoy.com/fs-core": "4.68.1", - "@jsonjoy.com/fs-node-builtins": "4.68.1", - "@jsonjoy.com/fs-node-utils": "4.68.1", + "@jsonjoy.com/fs-core": "4.80.0", + "@jsonjoy.com/fs-node-builtins": "4.80.0", + "@jsonjoy.com/fs-node-utils": "4.80.0", "thingies": "^2.5.0" } }, "@jsonjoy.com/fs-node": { - "version": "4.68.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.68.1.tgz", - "integrity": "sha512-R5D9mWtqdURzcOWj1vdXr3APCwX0xchtFT+kmW7fXLNDifWdDrnh26jSID8pdnUfFBxTyfHtFtTL/NWKzIH7kQ==", - "devOptional": true, + "version": "4.80.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.80.0.tgz", + "integrity": "sha512-yiBlUfkMMRFFGy8CD/h2zd5rPnrM6bjlF+nx2LRAxNV6hOSiuObQIUEE7m7y2VTMisjxCRtzINH94bFGySWL7g==", + "dev": true, "requires": { - "@jsonjoy.com/fs-core": "4.68.1", - "@jsonjoy.com/fs-node-builtins": "4.68.1", - "@jsonjoy.com/fs-node-utils": "4.68.1", - "@jsonjoy.com/fs-print": "4.68.1", - "@jsonjoy.com/fs-snapshot": "4.68.1", - "glob-to-regex.js": "^1.0.0", + "@jsonjoy.com/fs-core": "4.80.0", + "@jsonjoy.com/fs-node-builtins": "4.80.0", + "@jsonjoy.com/fs-node-utils": "4.80.0", + "@jsonjoy.com/fs-print": "4.80.0", + "@jsonjoy.com/fs-snapshot": "4.80.0", + "glob-to-regex.js": "^1.3.1", "thingies": "^2.5.0" } }, "@jsonjoy.com/fs-node-builtins": { - "version": "4.68.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.68.1.tgz", - "integrity": "sha512-HK1BTksysokNZxNspqDH0yPaqN9YgR/AYIlYiIaU2Ys4BOk5CdybI7r6BgiZuiiPiV8n4sK/kZdice7Znpy2Kw==", - "devOptional": true, + "version": "4.80.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.80.0.tgz", + "integrity": "sha512-OIOIhqaWiwUySFMny4epIAoviXsyMaQQ63PTc3zH+5HEOvarl5hpUmZIhp2m5+OfyKAh5ylxXnQIgew1OgJDVw==", + "dev": true, "requires": {} }, "@jsonjoy.com/fs-node-to-fsa": { - "version": "4.68.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.68.1.tgz", - "integrity": "sha512-lpKmU4X9e/oh8GIuAI7EXaS5QiLNM3KD15CkdhfS6PYmrGvoJqKQcyEfnLgnnaGslh/PFUMYSIZBCf2ejJGw8g==", - "devOptional": true, + "version": "4.80.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.80.0.tgz", + "integrity": "sha512-ljeaR5xwtX1EyRmB0wvXANUMQZ3mjKcm7z8s2N8D8SLQ04CcAGtUhHFRZQXFb9mhgFyEVMydQGQtSOFS7pcrFQ==", + "dev": true, "requires": { - "@jsonjoy.com/fs-fsa": "4.68.1", - "@jsonjoy.com/fs-node-builtins": "4.68.1", - "@jsonjoy.com/fs-node-utils": "4.68.1" + "@jsonjoy.com/fs-fsa": "4.80.0", + "@jsonjoy.com/fs-node-builtins": "4.80.0", + "@jsonjoy.com/fs-node-utils": "4.80.0" } }, "@jsonjoy.com/fs-node-utils": { - "version": "4.68.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.68.1.tgz", - "integrity": "sha512-/GxfW1DWm9SCdkfbvqevLO/P5duobQfmKkHXxdMIDbcZMQeAgooAstIfZhkXpATzq9QbCQsnoWFM/dGHdZfndw==", - "devOptional": true, + "version": "4.80.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.80.0.tgz", + "integrity": "sha512-xwHeVVi3f+Khg/Fmb8i5qoYVmQ08QznE5TWhQokUmIZjz4KBPzU+zt/+bahBI6G1ghtqajt27lWuAciIxApuzA==", + "dev": true, "requires": { - "@jsonjoy.com/fs-node-builtins": "4.68.1", - "glob-to-regex.js": "^1.0.1" + "@jsonjoy.com/fs-node-builtins": "4.80.0", + "glob-to-regex.js": "^1.3.1" } }, "@jsonjoy.com/fs-print": { - "version": "4.68.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.68.1.tgz", - "integrity": "sha512-oGeZOGPYKK9v1CgeVeEDsLomH1lCnslSpqUN5GmPzrmAVGQlsmsdcXNA2O4lV8Y4xkuSuynx2ITBkUHJVaTbow==", - "devOptional": true, + "version": "4.80.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.80.0.tgz", + "integrity": "sha512-1ocZLJw/m/xlq0moaIHO7HV9nHRVBUvdc2zMzhsJGkFOrqfwWvexfCv5cYCPPLRhP+9p3yunoi4Ep5YhRORC9Q==", + "dev": true, "requires": { - "@jsonjoy.com/fs-node-utils": "4.68.1", + "@jsonjoy.com/fs-node-utils": "4.80.0", "tree-dump": "^1.1.0" } }, "@jsonjoy.com/fs-snapshot": { - "version": "4.68.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.68.1.tgz", - "integrity": "sha512-XZfP0FDZN32bbc4t2bZN2qRrYHg5AktJnzk22HRoKGK4BprrbNRH2k5ceSNS/kupKYcofCs+O841+xaAbjnxwQ==", - "devOptional": true, + "version": "4.80.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.80.0.tgz", + "integrity": "sha512-3AV23Q1H228KTtJKTUE0nNq8FCtxSvtENOLrEat80wkAJCKHuqQEv8yjhpS8Bei2OEFJAnluGXp9Ri2SWBlROw==", + "dev": true, "requires": { "@jsonjoy.com/buffers": "^17.65.0", - "@jsonjoy.com/fs-node-utils": "4.68.1", + "@jsonjoy.com/fs-node-utils": "4.80.0", "@jsonjoy.com/json-pack": "^17.65.0", "@jsonjoy.com/util": "^17.65.0" }, @@ -26250,21 +26777,21 @@ "version": "17.67.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-17.67.0.tgz", "integrity": "sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw==", - "devOptional": true, + "dev": true, "requires": {} }, "@jsonjoy.com/codegen": { "version": "17.67.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-17.67.0.tgz", "integrity": "sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q==", - "devOptional": true, + "dev": true, "requires": {} }, "@jsonjoy.com/json-pack": { "version": "17.67.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-17.67.0.tgz", "integrity": "sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w==", - "devOptional": true, + "dev": true, "requires": { "@jsonjoy.com/base64": "17.67.0", "@jsonjoy.com/buffers": "17.67.0", @@ -26280,7 +26807,7 @@ "version": "17.67.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-17.67.0.tgz", "integrity": "sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA==", - "devOptional": true, + "dev": true, "requires": { "@jsonjoy.com/util": "17.67.0" } @@ -26289,7 +26816,7 @@ "version": "17.67.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-17.67.0.tgz", "integrity": "sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew==", - "devOptional": true, + "dev": true, "requires": { "@jsonjoy.com/buffers": "17.67.0", "@jsonjoy.com/codegen": "17.67.0" @@ -26301,7 +26828,7 @@ "version": "1.21.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.21.0.tgz", "integrity": "sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==", - "devOptional": true, + "dev": true, "requires": { "@jsonjoy.com/base64": "^1.1.2", "@jsonjoy.com/buffers": "^1.2.0", @@ -26317,7 +26844,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", - "devOptional": true, + "dev": true, "requires": {} } } @@ -26326,7 +26853,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-1.0.2.tgz", "integrity": "sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==", - "devOptional": true, + "dev": true, "requires": { "@jsonjoy.com/codegen": "^1.0.0", "@jsonjoy.com/util": "^1.9.0" @@ -26336,7 +26863,7 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.9.0.tgz", "integrity": "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==", - "devOptional": true, + "dev": true, "requires": { "@jsonjoy.com/buffers": "^1.0.0", "@jsonjoy.com/codegen": "^1.0.0" @@ -26346,7 +26873,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", - "devOptional": true, + "dev": true, "requires": {} } } @@ -26515,7 +27042,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", - "devOptional": true + "dev": true }, "@nodelib/fs.scandir": { "version": "2.1.5", @@ -26544,7 +27071,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz", "integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==", - "devOptional": true, + "dev": true, "requires": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", @@ -26557,7 +27084,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz", "integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==", - "devOptional": true, + "dev": true, "requires": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", @@ -26569,7 +27096,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz", "integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==", - "devOptional": true, + "dev": true, "requires": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", @@ -26581,7 +27108,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz", "integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==", - "devOptional": true, + "dev": true, "requires": { "@peculiar/asn1-cms": "^2.8.0", "@peculiar/asn1-pkcs8": "^2.8.0", @@ -26595,7 +27122,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz", "integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==", - "devOptional": true, + "dev": true, "requires": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", @@ -26607,7 +27134,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz", "integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==", - "devOptional": true, + "dev": true, "requires": { "@peculiar/asn1-cms": "^2.8.0", "@peculiar/asn1-pfx": "^2.8.0", @@ -26623,7 +27150,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz", "integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==", - "devOptional": true, + "dev": true, "requires": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", @@ -26635,7 +27162,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", - "devOptional": true, + "dev": true, "requires": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", @@ -26646,7 +27173,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz", "integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==", - "devOptional": true, + "dev": true, "requires": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/utils": "^2.0.2", @@ -26658,7 +27185,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz", "integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==", - "devOptional": true, + "dev": true, "requires": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", @@ -26670,7 +27197,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", - "devOptional": true, + "dev": true, "requires": { "tslib": "^2.8.1" } @@ -26679,7 +27206,7 @@ "version": "1.14.3", "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", - "devOptional": true, + "dev": true, "requires": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-csr": "^2.6.0", @@ -26694,57 +27221,6 @@ "tsyringe": "^4.10.0" } }, - "@pmmmwh/react-refresh-webpack-plugin": { - "version": "0.5.17", - "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.17.tgz", - "integrity": "sha512-tXDyE1/jzFsHXjhRZQ3hMl0IVhYe5qula43LDWIhVfjp9G/nT5OQY5AORVOrkEGAUltBJOfOWeETbmhm6kHhuQ==", - "requires": { - "ansi-html": "^0.0.9", - "core-js-pure": "^3.23.3", - "error-stack-parser": "^2.0.6", - "html-entities": "^2.1.0", - "loader-utils": "^2.0.4", - "schema-utils": "^4.2.0", - "source-map": "^0.7.3" - }, - "dependencies": { - "ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "requires": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - } - }, - "ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "requires": { - "fast-deep-equal": "^3.1.3" - } - }, - "json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" - }, - "schema-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", - "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", - "requires": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - } - } - } - }, "@react-three/fiber": { "version": "8.16.5", "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.16.5.tgz", @@ -27521,12 +27997,6 @@ "@types/node": "*" } }, - "@types/retry": { - "version": "0.12.2", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", - "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", - "devOptional": true - }, "@types/semver": { "version": "7.3.13", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.13.tgz", @@ -28167,7 +28637,7 @@ "version": "3.0.10", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", - "devOptional": true, + "dev": true, "requires": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", @@ -28690,10 +29160,10 @@ "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==" }, "bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", - "devOptional": true, + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.1.tgz", + "integrity": "sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==", + "dev": true, "requires": { "run-applescript": "^7.0.0" } @@ -28707,7 +29177,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", - "devOptional": true + "dev": true }, "cachedir": { "version": "2.4.0", @@ -29141,9 +29611,9 @@ } }, "core-js-pure": { - "version": "3.27.2", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.27.2.tgz", - "integrity": "sha512-Cf2jqAbXgWH3VVzjyaaFkY1EBazxugUepGymDoeteyYr9ByX51kD2jdHZlsEF/xnJMyN3Prua7mQuzwMg6Zc9A==" + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.50.0.tgz", + "integrity": "sha512-6GP3Pxz4IKyWjAfa747vIu/jilB5z29JWROLqH/b+pXVcpgh6tM06ZIBwSuglgVqzDYURhOK6oEzTrG0bCHitA==" }, "core-util-is": { "version": "1.0.3", @@ -29629,11 +30099,11 @@ "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==" }, "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "requires": { - "ms": "2.1.2" + "ms": "^2.1.3" } }, "decimal.js": { @@ -29686,10 +30156,10 @@ "integrity": "sha512-z2wJZXrmeHdvYJp/Ux55wIjqo81G5Bp4c+oELTW+7ar6SogWHajt5a9gO3s3IDaGSAXjDk0vlQKN3rms8ab3og==" }, "default-browser": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", - "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", - "devOptional": true, + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", + "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", + "dev": true, "requires": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" @@ -29699,7 +30169,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", - "devOptional": true + "dev": true }, "default-gateway": { "version": "6.0.3", @@ -31367,10 +31837,10 @@ } }, "glob-to-regex.js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.2.0.tgz", - "integrity": "sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==", - "devOptional": true, + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.3.1.tgz", + "integrity": "sha512-zhWhMRsgnOym+1txhUmYJGD6eUsbUdrZv55gCYKuJX8t2p3+M8My23PD09bEIsreGUs5pnSa/idmcfp2/IlqGg==", + "dev": true, "requires": {} }, "glob-to-regexp": { @@ -31731,6 +32201,12 @@ "debug": "4" } }, + "httpxy": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/httpxy/-/httpxy-0.5.5.tgz", + "integrity": "sha512-uDjmnPyp1q4Sgzf3w+J/Fc6UqcCEj0x4Wjp7OqK5dGhNeDgpyrAmnS6ey8QWrX3SWDon2DMKf9sBa5X9+CVyMA==", + "dev": true + }, "human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -31740,7 +32216,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", - "devOptional": true + "dev": true }, "iconv-lite": { "version": "0.6.3", @@ -31952,11 +32428,17 @@ "is-extglob": "^2.1.1" } }, + "is-in-ssh": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", + "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", + "dev": true + }, "is-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "devOptional": true, + "dev": true, "requires": { "is-docker": "^3.0.0" }, @@ -31965,7 +32447,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "devOptional": true + "dev": true } } }, @@ -31997,7 +32479,7 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.2.tgz", "integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==", - "devOptional": true + "dev": true }, "is-number": { "version": "7.0.0", @@ -32032,6 +32514,12 @@ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" }, + "is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true + }, "is-regex": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", @@ -34445,9 +34933,9 @@ "integrity": "sha512-Qi0VCU46t2IwfGv5KF0+D/t9cizcDug7qnNoy9Ggk7aucp0tssV8IwTMkBlDbm+VqAf3cdQHTCARKSsuS2MYFg==" }, "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "multicast-dns": { "version": "7.2.5", @@ -34726,14 +35214,12 @@ } }, "p-retry": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz", - "integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==", - "devOptional": true, + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-8.0.1.tgz", + "integrity": "sha512-NAigyu8hfvJe7MsS18mnc4is0MZtau3QMdBklaJKK23oBQb6occO3UPM3vHmTckW8KhfyjMZaUOqdTFEYliHgg==", + "dev": true, "requires": { - "@types/retry": "0.12.2", - "is-network-error": "^1.0.0", - "retry": "^0.13.1" + "is-network-error": "^1.3.0" } }, "p-try": { @@ -34956,7 +35442,7 @@ "version": "3.4.0", "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", "integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==", - "devOptional": true, + "dev": true, "requires": { "@noble/hashes": "1.4.0", "asn1js": "^3.0.6", @@ -35645,6 +36131,12 @@ "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==" }, + "powershell-utils": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.1.tgz", + "integrity": "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==", + "dev": true + }, "prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -35779,7 +36271,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", - "devOptional": true, + "dev": true, "requires": { "tslib": "^2.8.1" } @@ -35788,7 +36280,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", - "devOptional": true + "dev": true }, "q": { "version": "1.5.1", @@ -36177,6 +36669,20 @@ "workbox-webpack-plugin": "^6.4.1" }, "dependencies": { + "@pmmmwh/react-refresh-webpack-plugin": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.17.tgz", + "integrity": "sha512-tXDyE1/jzFsHXjhRZQ3hMl0IVhYe5qula43LDWIhVfjp9G/nT5OQY5AORVOrkEGAUltBJOfOWeETbmhm6kHhuQ==", + "requires": { + "ansi-html": "^0.0.9", + "core-js-pure": "^3.23.3", + "error-stack-parser": "^2.0.6", + "html-entities": "^2.1.0", + "loader-utils": "^2.0.4", + "schema-utils": "^4.2.0", + "source-map": "^0.7.3" + } + }, "@types/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", @@ -36387,7 +36893,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "devOptional": true + "dev": true }, "regenerate": { "version": "1.4.2", @@ -36681,11 +37187,32 @@ } } }, + "router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "requires": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "dependencies": { + "path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "dev": true + } + } + }, "run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", - "devOptional": true + "dev": true }, "run-parallel": { "version": "1.2.0", @@ -36774,7 +37301,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-5.5.0.tgz", "integrity": "sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew==", - "devOptional": true, + "dev": true, "requires": { "@peculiar/x509": "^1.14.2", "pkijs": "^3.3.3" @@ -36842,11 +37369,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" - }, - "ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" } } }, @@ -36859,17 +37381,17 @@ } }, "serve-index": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz", - "integrity": "sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.2.tgz", + "integrity": "sha512-KDj11HScOaLmrPxl70KYNW1PksP4Nb/CLL2yvC+Qd2kHMPEEpfc4Re2e4FOay+bC/+XQl/7zAcWON3JVo5v3KQ==", "requires": { - "accepts": "~1.3.4", + "accepts": "~1.3.8", "batch": "0.6.1", "debug": "2.6.9", "escape-html": "~1.0.3", - "http-errors": "~1.6.2", - "mime-types": "~2.1.17", - "parseurl": "~1.3.2" + "http-errors": "~1.8.0", + "mime-types": "~2.1.35", + "parseurl": "~1.3.3" }, "dependencies": { "debug": { @@ -36886,31 +37408,22 @@ "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==" }, "http-errors": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", - "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", "requires": { "depd": "~1.1.2", - "inherits": "2.0.3", - "setprototypeof": "1.1.0", - "statuses": ">= 1.4.0 < 2" + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" } }, - "inherits": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==" - }, "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, - "setprototypeof": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", - "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==" - }, "statuses": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", @@ -37640,7 +38153,7 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.6.1.tgz", "integrity": "sha512-cV/CMGTK3M4MlnJ/0At6ismOw/A0EEniDNScajjz/Br3c1sqE72YD01rGpPTKwd27wAxI5Pr+6+0w8yofzFRYw==", - "devOptional": true, + "dev": true, "requires": {} }, "three": { @@ -37691,6 +38204,31 @@ "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==" }, + "tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "requires": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "dependencies": { + "fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "requires": {} + }, + "picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true + } + } + }, "tinyqueue": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", @@ -37762,7 +38300,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", - "devOptional": true, + "dev": true, "requires": {} }, "tree-kill": { @@ -37825,7 +38363,7 @@ "version": "4.10.0", "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", - "devOptional": true, + "dev": true, "requires": { "tslib": "^1.9.3" }, @@ -37834,7 +38372,7 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "devOptional": true + "dev": true } } }, @@ -38240,24 +38778,23 @@ } }, "webpack-dev-middleware": { - "version": "7.4.5", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-7.4.5.tgz", - "integrity": "sha512-uxQ6YqGdE4hgDKNf7hUiPXOdtkXvBJXrfEGYSx7P7LC8hnUYGK70X6xQXUvXeNyBDDcsiQXpG2m3G9vxowaEuA==", - "devOptional": true, + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-8.3.0.tgz", + "integrity": "sha512-jFNNMB29ugmkEUFW9cpWNqcWFQNwuMTMVoRpKVAiz/5nh3gA4x9x6DRPhNHGc/SLre7pEP9yrAPDAmCvdCblKg==", + "dev": true, "requires": { - "colorette": "^2.0.10", - "memfs": "^4.43.1", - "mime-types": "^3.0.1", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "schema-utils": "^4.0.0" + "ansi-html-community": "^0.0.8", + "memfs": "^4.68.2", + "mime-types": "^3.0.2", + "range-parser": "^1.3.0", + "schema-utils": "^4.3.3" }, "dependencies": { "ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "devOptional": true, + "dev": true, "requires": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -38265,11 +38802,20 @@ "require-from-string": "^2.0.2" } }, + "ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "requires": { + "ajv": "^8.0.0" + } + }, "ajv-keywords": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "devOptional": true, + "dev": true, "requires": { "fast-deep-equal": "^3.1.3" } @@ -38278,25 +38824,25 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "devOptional": true + "dev": true }, "memfs": { - "version": "4.68.1", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.68.1.tgz", - "integrity": "sha512-OD+IDRUvIxu3QHL+nFm9gdyugInD27FDJ+sl4B5QgomPHXMlbw+GP918P8VNKu2FkNlVeqBkpzkwROpamVifRw==", - "devOptional": true, + "version": "4.80.0", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.80.0.tgz", + "integrity": "sha512-N70W2isi44XdGn7uPo9NJ4zRukcdkwSFHlnYx42SWOH/+hzqNc8P7+tVoA2up4z7faHvNi1RSNCFjxoYQ71VPQ==", + "dev": true, "requires": { - "@jsonjoy.com/fs-core": "4.68.1", - "@jsonjoy.com/fs-fsa": "4.68.1", - "@jsonjoy.com/fs-node": "4.68.1", - "@jsonjoy.com/fs-node-builtins": "4.68.1", - "@jsonjoy.com/fs-node-to-fsa": "4.68.1", - "@jsonjoy.com/fs-node-utils": "4.68.1", - "@jsonjoy.com/fs-print": "4.68.1", - "@jsonjoy.com/fs-snapshot": "4.68.1", + "@jsonjoy.com/fs-core": "4.80.0", + "@jsonjoy.com/fs-fsa": "4.80.0", + "@jsonjoy.com/fs-node": "4.80.0", + "@jsonjoy.com/fs-node-builtins": "4.80.0", + "@jsonjoy.com/fs-node-to-fsa": "4.80.0", + "@jsonjoy.com/fs-node-utils": "4.80.0", + "@jsonjoy.com/fs-print": "4.80.0", + "@jsonjoy.com/fs-snapshot": "4.80.0", "@jsonjoy.com/json-pack": "^1.11.0", "@jsonjoy.com/util": "^1.9.0", - "glob-to-regex.js": "^1.0.1", + "glob-to-regex.js": "^1.3.1", "thingies": "^2.5.0", "tree-dump": "^1.0.3", "tslib": "^2.0.0" @@ -38306,72 +38852,118 @@ "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "devOptional": true + "dev": true }, "mime-types": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "devOptional": true, + "dev": true, "requires": { "mime-db": "^1.54.0" } }, + "range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "dev": true + }, "schema-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", - "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", - "devOptional": true, + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.5.0.tgz", + "integrity": "sha512-zJlMCZ0cAR5p/Y4oVpRoqioDMJcGxaXRrQ/4rP4WyR84vc5z/DolXdbvXeDpTwbtocDFr2rhPqHPErDCUtz2kA==", + "dev": true, "requires": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", + "@types/json-schema": "^7.0.15", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", "ajv-keywords": "^5.1.0" } } } }, "webpack-dev-server": { - "version": "5.2.6", - "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.6.tgz", - "integrity": "sha512-HNLRmamRvVavZQ+avceZifmv8hmdUjg43t6MI4SqJDwFdW7RPQwH5vzGhDRZSX59SgfbeHhLnq3g+uooWo7pVw==", - "devOptional": true, + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-6.0.0.tgz", + "integrity": "sha512-q9SD4ItOGhZLeU6EGT10caDZdHjF50Pz1DtkRZZOPsfluMXOkacWKKOtSBSLVkPqKiF67eFUC0rI88U/tSFPEw==", + "dev": true, "requires": { "@types/bonjour": "^3.5.13", "@types/connect-history-api-fallback": "^1.5.4", - "@types/express": "^4.17.25", - "@types/express-serve-static-core": "^4.17.21", + "@types/express": "^5.0.6", + "@types/express-serve-static-core": "^5.1.1", "@types/serve-index": "^1.9.4", - "@types/serve-static": "^1.15.5", - "@types/sockjs": "^0.3.36", - "@types/ws": "^8.5.10", + "@types/serve-static": "^2.2.0", + "@types/ws": "^8.18.1", "ansi-html-community": "^0.0.8", - "bonjour-service": "^1.2.1", - "chokidar": "^3.6.0", - "colorette": "^2.0.10", + "bonjour-service": "^1.3.0", + "chokidar": "^5.0.0", "compression": "^1.8.1", "connect-history-api-fallback": "^2.0.0", - "express": "^4.22.1", - "graceful-fs": "^4.2.6", - "http-proxy-middleware": "^2.0.9", - "ipaddr.js": "^2.1.0", + "express": "^5.2.1", + "graceful-fs": "^4.2.11", + "http-proxy-middleware": "^4.1.1", + "ipaddr.js": "^2.3.0", "launch-editor": "^2.14.1", - "open": "^10.0.3", - "p-retry": "^6.2.0", - "schema-utils": "^4.2.0", + "open": "^11.0.0", + "p-retry": "^8.0.0", + "schema-utils": "^4.3.3", "selfsigned": "^5.5.0", - "serve-index": "^1.9.1", - "sockjs": "^0.3.24", - "spdy": "^4.0.2", - "webpack-dev-middleware": "^7.4.2", - "ws": "^8.18.0" + "serve-index": "^1.9.2", + "tinyglobby": "^0.2.15", + "webpack-dev-middleware": "^8.0.3", + "ws": "^8.20.0" }, "dependencies": { + "@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "@types/express-serve-static-core": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "requires": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "requires": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + } + }, "ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "devOptional": true, + "dev": true, "requires": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -38383,65 +38975,247 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "devOptional": true, + "dev": true, "requires": { "fast-deep-equal": "^3.1.3" } }, - "chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "devOptional": true, + "body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "dev": true, "requires": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "fsevents": "~2.3.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "dependencies": { + "content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true + } } }, + "chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "requires": { + "readdirp": "^5.0.0" + } + }, + "content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "dev": true + }, + "cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true + }, "define-lazy-prop": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "devOptional": true + "dev": true }, - "glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "devOptional": true, + "express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, "requires": { - "is-glob": "^4.0.1" + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" } }, + "finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "requires": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + } + }, + "fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true + }, + "http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "requires": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + } + }, + "http-proxy-middleware": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-4.2.0.tgz", + "integrity": "sha512-ZA+oNOoM+GLoFTIzhkJptVQov73Srep2LBqhF8hG8CIPKO3nam1jonXVQ/QUH8RbwsmaaVz2SOJdzBNBHNtKbw==", + "dev": true, + "requires": { + "debug": "^4.4.3", + "httpxy": "^0.5.4", + "is-glob": "^4.0.3", + "is-plain-obj": "^4.1.0", + "micromatch": "^4.0.8" + } + }, + "iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + }, + "is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true + }, "json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "devOptional": true + "dev": true + }, + "media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "dev": true + }, + "merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true + }, + "mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true + }, + "mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "requires": { + "mime-db": "^1.54.0" + } + }, + "negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "dev": true, + "requires": { + "content-type": "^2.1.0" + }, + "dependencies": { + "content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true + } + } }, "open": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", - "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", - "devOptional": true, + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.4.tgz", + "integrity": "sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg==", + "dev": true, "requires": { - "default-browser": "^5.2.1", + "default-browser": "^5.5.1", "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", - "wsl-utils": "^0.1.0" + "powershell-utils": "^0.2.1", + "wsl-utils": "^1.0.0" } }, + "raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "requires": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + } + }, + "readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "dev": true + }, "schema-utils": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", - "devOptional": true, + "dev": true, "requires": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -38449,11 +39223,67 @@ "ajv-keywords": "^5.1.0" } }, + "send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "requires": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + } + }, + "serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "requires": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + } + }, + "statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true + }, + "type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "dev": true, + "requires": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "dependencies": { + "content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true + } + } + }, "ws": { "version": "8.21.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", - "devOptional": true, + "dev": true, "requires": {} } } @@ -38915,19 +39745,20 @@ "requires": {} }, "wsl-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", - "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", - "devOptional": true, + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-1.0.1.tgz", + "integrity": "sha512-c16PKLIRxBYpR3jSWsdS7SFp0COm5SOQdiFjY818dYFHJbMGuLBszpF07LBWmOS1uzrHa9VGdynsEgoeKy0oOQ==", + "dev": true, "requires": { - "is-wsl": "^3.1.0" + "is-wsl": "^3.1.0", + "powershell-utils": "^0.2.0" }, "dependencies": { "is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", - "devOptional": true, + "dev": true, "requires": { "is-inside-container": "^1.0.0" } diff --git a/app/package.json b/app/package.json index 1ae6c6b1..b9244e85 100644 --- a/app/package.json +++ b/app/package.json @@ -65,7 +65,7 @@ "@types/react": "^18.3.17", "@types/react-dom": "^18.3.5", "typescript": "^5.9.3", - "webpack-dev-server": "^5.2.6" + "webpack-dev-server": "^6.0.0" }, "jest": { "transformIgnorePatterns": [ From 8610f9c97ee456d069a5db696b2cdb68226885ca Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:44:22 -0600 Subject: [PATCH 066/181] build(deps): bump react-router in /app in the npm-dependencies group (#768) Bumps the npm-dependencies group in /app with 1 update: [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router). Updates `react-router` from 7.18.2 to 7.18.4 - [Release notes](https://github.com/remix-run/react-router/releases) - [Changelog](https://github.com/remix-run/react-router/blob/react-router@7.18.4/packages/react-router/CHANGELOG.md) - [Commits](https://github.com/remix-run/react-router/commits/react-router@7.18.4/packages/react-router) --- updated-dependencies: - dependency-name: react-router dependency-version: 7.18.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: npm-dependencies ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- app/package-lock.json | 15 ++++++++------- app/package.json | 2 +- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/app/package-lock.json b/app/package-lock.json index 11c9c902..70a8ecab 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -31,7 +31,7 @@ "react-dropzone": "^14.4.1", "react-hotkeys-hook": "^5.3.3", "react-qr-code": "^2.2.0", - "react-router": "^7.18.2", + "react-router": "^7.18.4", "react-scripts": "5.0.1", "react-stl-viewer": "^2.5.0", "three": "^0.186", @@ -18863,9 +18863,10 @@ } }, "node_modules/react-router": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", - "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", + "license": "MIT", "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" @@ -36599,9 +36600,9 @@ } }, "react-router": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", - "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", "requires": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" diff --git a/app/package.json b/app/package.json index b9244e85..adaf5e31 100644 --- a/app/package.json +++ b/app/package.json @@ -26,7 +26,7 @@ "react-dropzone": "^14.4.1", "react-hotkeys-hook": "^5.3.3", "react-qr-code": "^2.2.0", - "react-router": "^7.18.2", + "react-router": "^7.18.4", "react-scripts": "5.0.1", "react-stl-viewer": "^2.5.0", "three": "^0.186", From f37098cb812efcc978026734629f5c1f74c69802 Mon Sep 17 00:00:00 2001 From: lrnselfreliance <33263708+lrnselfreliance@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:50:24 -0600 Subject: [PATCH 067/181] tests: fix the two suites failing on master (#770) test_handle_reorganize_marks_job_failed_on_exception patched wrolpi.files.worker.shutil.move, but #719 moved handle_reorganize and its shutil.move call into wrolpi.files.reorganize_job. test_file_worker_refresh_inserts_new_files loaded FileGroups into the shared pytest session between refreshes. The refresh upserts by raw SQL, so the modeler then read the stale identity-map object and wrote its old file list back over the upsert. Production opens a fresh session per block; expire the test session before the second refresh. Co-authored-by: Claude Fable 5.1 --- wrolpi/collections/test/test_reorganize.py | 2 +- wrolpi/files/test/test_worker.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/wrolpi/collections/test/test_reorganize.py b/wrolpi/collections/test/test_reorganize.py index 538f27af..bc78954c 100644 --- a/wrolpi/collections/test/test_reorganize.py +++ b/wrolpi/collections/test/test_reorganize.py @@ -238,7 +238,7 @@ async def test_handle_reorganize_marks_job_failed_on_exception( ) file_worker._set_job_status(job_id, 'pending') - with mock.patch('wrolpi.files.worker.shutil.move', side_effect=OSError('disk full')): + with mock.patch('wrolpi.files.reorganize_job.shutil.move', side_effect=OSError('disk full')): await file_worker.handle_reorganize(task) assert file_worker.get_job_status(job_id) == 'failed' diff --git a/wrolpi/files/test/test_worker.py b/wrolpi/files/test/test_worker.py index cfa9690d..b59c1903 100644 --- a/wrolpi/files/test/test_worker.py +++ b/wrolpi/files/test/test_worker.py @@ -835,6 +835,11 @@ async def test_file_worker_refresh_inserts_new_files(async_client, test_session, # Now add a real video file that shares a stem with file1 # (must be a real video file so get_mimetype returns video/mp4) video_file_factory(test_directory / 'docs/file1.mp4') + # Under pytest every get_db_session() is this one shared session. The FileGroups queried + # above would otherwise sit stale in its identity map while the refresh upserts by raw + # SQL, and the modeler would then write that stale file list back over the upsert. + # Production opens a fresh session per block, so only the test needs this. + test_session.expire_all() # Refresh again - should detect the modified FileGroup task = FileTask(FileTaskType.refresh, [test_directory], count=4) From c28a9e0078dc79b6400152791afd094e28eafbe9 Mon Sep 17 00:00:00 2001 From: lrnselfreliance <33263708+lrnselfreliance@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:00:27 -0600 Subject: [PATCH 068/181] Revert "build(deps-dev): bump webpack-dev-server from 5.2.6 to 6.0.0 in /app (#769)" (#772) Cypress component testing resolves the top-level webpack-dev-server and only accepts major 5, so the bump crashed cypress.config.js on every run ("Unexpected major version of webpack-dev-server"). The react-scripts dev server was unaffected because it uses its own nested 4.x, which is what the pre-merge check looked at. Pin webpack-dev-server below 6 in dependabot.yml until Cypress supports it. Co-authored-by: Claude Fable 5.1 --- .github/dependabot.yml | 4 + app/package-lock.json | 2167 +++++++++++++--------------------------- app/package.json | 2 +- 3 files changed, 673 insertions(+), 1500 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6db95985..77b80b94 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -80,6 +80,10 @@ updates: # @testing-library/react 14+ async act semantics break menu/popover tests. - dependency-name: "@testing-library/react" update-types: ["version-update:semver-major"] + # Cypress component testing resolves the top-level webpack-dev-server and only + # accepts major 5; 6 crashes cypress.config.js ("Unexpected major version"). + - dependency-name: "webpack-dev-server" + versions: [">=6"] groups: npm-dependencies: patterns: diff --git a/app/package-lock.json b/app/package-lock.json index 70a8ecab..9e44866c 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -45,7 +45,7 @@ "@types/react": "^18.3.17", "@types/react-dom": "^18.3.5", "typescript": "^5.9.3", - "webpack-dev-server": "^6.0.0" + "webpack-dev-server": "^5.2.6" } }, "node_modules/@acalcutt/maplibre-contour-pmtiles": { @@ -4533,7 +4533,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=10.0" @@ -4550,7 +4550,7 @@ "version": "17.67.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-17.67.0.tgz", "integrity": "sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=10.0" @@ -4567,7 +4567,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-1.0.0.tgz", "integrity": "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=10.0" @@ -4581,14 +4581,14 @@ } }, "node_modules/@jsonjoy.com/fs-core": { - "version": "4.80.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.80.0.tgz", - "integrity": "sha512-qMKWshnyjbyhm+NbzqGE3w5y1mckFFEMj0aDPU2/JBNh1BzIjVy9esq9lBmRnNVmhMEkxwDzLZ8Gqh5G1P5Dag==", - "dev": true, + "version": "4.68.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.68.1.tgz", + "integrity": "sha512-V5oZ4Gt9WJKyQef0n9cAd0N9qjSkIBm3E4MYsgNIWBk5aINCDPKxMPo1i29rBxqiT4Ixf1epklqV9VJMKIxwlw==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-node-builtins": "4.80.0", - "@jsonjoy.com/fs-node-utils": "4.80.0", + "@jsonjoy.com/fs-node-builtins": "4.68.1", + "@jsonjoy.com/fs-node-utils": "4.68.1", "thingies": "^2.5.0" }, "engines": { @@ -4603,15 +4603,15 @@ } }, "node_modules/@jsonjoy.com/fs-fsa": { - "version": "4.80.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.80.0.tgz", - "integrity": "sha512-ZBEKl7J6dbkRCfmrFV48Re211A/FHqniTUhsWCsaRteNSOqMgkX3qF1UreVgwB9oSPm81JLAwhIi6Lb/NH/PoA==", - "dev": true, + "version": "4.68.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.68.1.tgz", + "integrity": "sha512-HCG72UioncuO7Gw09XNVG+S85e3cq2hrUC/mexBrsWsa3mI7eePkkqWie3uVYbtsb64OR9YGQs5SqaufDRYBcg==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-core": "4.80.0", - "@jsonjoy.com/fs-node-builtins": "4.80.0", - "@jsonjoy.com/fs-node-utils": "4.80.0", + "@jsonjoy.com/fs-core": "4.68.1", + "@jsonjoy.com/fs-node-builtins": "4.68.1", + "@jsonjoy.com/fs-node-utils": "4.68.1", "thingies": "^2.5.0" }, "engines": { @@ -4626,18 +4626,18 @@ } }, "node_modules/@jsonjoy.com/fs-node": { - "version": "4.80.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.80.0.tgz", - "integrity": "sha512-yiBlUfkMMRFFGy8CD/h2zd5rPnrM6bjlF+nx2LRAxNV6hOSiuObQIUEE7m7y2VTMisjxCRtzINH94bFGySWL7g==", - "dev": true, + "version": "4.68.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.68.1.tgz", + "integrity": "sha512-R5D9mWtqdURzcOWj1vdXr3APCwX0xchtFT+kmW7fXLNDifWdDrnh26jSID8pdnUfFBxTyfHtFtTL/NWKzIH7kQ==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-core": "4.80.0", - "@jsonjoy.com/fs-node-builtins": "4.80.0", - "@jsonjoy.com/fs-node-utils": "4.80.0", - "@jsonjoy.com/fs-print": "4.80.0", - "@jsonjoy.com/fs-snapshot": "4.80.0", - "glob-to-regex.js": "^1.3.1", + "@jsonjoy.com/fs-core": "4.68.1", + "@jsonjoy.com/fs-node-builtins": "4.68.1", + "@jsonjoy.com/fs-node-utils": "4.68.1", + "@jsonjoy.com/fs-print": "4.68.1", + "@jsonjoy.com/fs-snapshot": "4.68.1", + "glob-to-regex.js": "^1.0.0", "thingies": "^2.5.0" }, "engines": { @@ -4652,10 +4652,10 @@ } }, "node_modules/@jsonjoy.com/fs-node-builtins": { - "version": "4.80.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.80.0.tgz", - "integrity": "sha512-OIOIhqaWiwUySFMny4epIAoviXsyMaQQ63PTc3zH+5HEOvarl5hpUmZIhp2m5+OfyKAh5ylxXnQIgew1OgJDVw==", - "dev": true, + "version": "4.68.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.68.1.tgz", + "integrity": "sha512-HK1BTksysokNZxNspqDH0yPaqN9YgR/AYIlYiIaU2Ys4BOk5CdybI7r6BgiZuiiPiV8n4sK/kZdice7Znpy2Kw==", + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=10.0" @@ -4669,15 +4669,15 @@ } }, "node_modules/@jsonjoy.com/fs-node-to-fsa": { - "version": "4.80.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.80.0.tgz", - "integrity": "sha512-ljeaR5xwtX1EyRmB0wvXANUMQZ3mjKcm7z8s2N8D8SLQ04CcAGtUhHFRZQXFb9mhgFyEVMydQGQtSOFS7pcrFQ==", - "dev": true, + "version": "4.68.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.68.1.tgz", + "integrity": "sha512-lpKmU4X9e/oh8GIuAI7EXaS5QiLNM3KD15CkdhfS6PYmrGvoJqKQcyEfnLgnnaGslh/PFUMYSIZBCf2ejJGw8g==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-fsa": "4.80.0", - "@jsonjoy.com/fs-node-builtins": "4.80.0", - "@jsonjoy.com/fs-node-utils": "4.80.0" + "@jsonjoy.com/fs-fsa": "4.68.1", + "@jsonjoy.com/fs-node-builtins": "4.68.1", + "@jsonjoy.com/fs-node-utils": "4.68.1" }, "engines": { "node": ">=10.0" @@ -4691,14 +4691,14 @@ } }, "node_modules/@jsonjoy.com/fs-node-utils": { - "version": "4.80.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.80.0.tgz", - "integrity": "sha512-xwHeVVi3f+Khg/Fmb8i5qoYVmQ08QznE5TWhQokUmIZjz4KBPzU+zt/+bahBI6G1ghtqajt27lWuAciIxApuzA==", - "dev": true, + "version": "4.68.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.68.1.tgz", + "integrity": "sha512-/GxfW1DWm9SCdkfbvqevLO/P5duobQfmKkHXxdMIDbcZMQeAgooAstIfZhkXpATzq9QbCQsnoWFM/dGHdZfndw==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-node-builtins": "4.80.0", - "glob-to-regex.js": "^1.3.1" + "@jsonjoy.com/fs-node-builtins": "4.68.1", + "glob-to-regex.js": "^1.0.1" }, "engines": { "node": ">=10.0" @@ -4712,13 +4712,13 @@ } }, "node_modules/@jsonjoy.com/fs-print": { - "version": "4.80.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.80.0.tgz", - "integrity": "sha512-1ocZLJw/m/xlq0moaIHO7HV9nHRVBUvdc2zMzhsJGkFOrqfwWvexfCv5cYCPPLRhP+9p3yunoi4Ep5YhRORC9Q==", - "dev": true, + "version": "4.68.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.68.1.tgz", + "integrity": "sha512-oGeZOGPYKK9v1CgeVeEDsLomH1lCnslSpqUN5GmPzrmAVGQlsmsdcXNA2O4lV8Y4xkuSuynx2ITBkUHJVaTbow==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-node-utils": "4.80.0", + "@jsonjoy.com/fs-node-utils": "4.68.1", "tree-dump": "^1.1.0" }, "engines": { @@ -4733,14 +4733,14 @@ } }, "node_modules/@jsonjoy.com/fs-snapshot": { - "version": "4.80.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.80.0.tgz", - "integrity": "sha512-3AV23Q1H228KTtJKTUE0nNq8FCtxSvtENOLrEat80wkAJCKHuqQEv8yjhpS8Bei2OEFJAnluGXp9Ri2SWBlROw==", - "dev": true, + "version": "4.68.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.68.1.tgz", + "integrity": "sha512-XZfP0FDZN32bbc4t2bZN2qRrYHg5AktJnzk22HRoKGK4BprrbNRH2k5ceSNS/kupKYcofCs+O841+xaAbjnxwQ==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@jsonjoy.com/buffers": "^17.65.0", - "@jsonjoy.com/fs-node-utils": "4.80.0", + "@jsonjoy.com/fs-node-utils": "4.68.1", "@jsonjoy.com/json-pack": "^17.65.0", "@jsonjoy.com/util": "^17.65.0" }, @@ -4759,7 +4759,7 @@ "version": "17.67.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-17.67.0.tgz", "integrity": "sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=10.0" @@ -4776,7 +4776,7 @@ "version": "17.67.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-17.67.0.tgz", "integrity": "sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=10.0" @@ -4793,7 +4793,7 @@ "version": "17.67.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-17.67.0.tgz", "integrity": "sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@jsonjoy.com/base64": "17.67.0", @@ -4820,7 +4820,7 @@ "version": "17.67.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-17.67.0.tgz", "integrity": "sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@jsonjoy.com/util": "17.67.0" @@ -4840,7 +4840,7 @@ "version": "17.67.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-17.67.0.tgz", "integrity": "sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@jsonjoy.com/buffers": "17.67.0", @@ -4861,7 +4861,7 @@ "version": "1.21.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.21.0.tgz", "integrity": "sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@jsonjoy.com/base64": "^1.1.2", @@ -4888,7 +4888,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=10.0" @@ -4905,7 +4905,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-1.0.2.tgz", "integrity": "sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@jsonjoy.com/codegen": "^1.0.0", @@ -4926,7 +4926,7 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.9.0.tgz", "integrity": "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@jsonjoy.com/buffers": "^1.0.0", @@ -4947,7 +4947,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=10.0" @@ -5172,7 +5172,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 16" @@ -5217,7 +5217,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz", "integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.8.0", @@ -5231,7 +5231,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz", "integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.8.0", @@ -5244,7 +5244,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz", "integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.8.0", @@ -5257,7 +5257,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz", "integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@peculiar/asn1-cms": "^2.8.0", @@ -5272,7 +5272,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz", "integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.8.0", @@ -5285,7 +5285,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz", "integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@peculiar/asn1-cms": "^2.8.0", @@ -5302,7 +5302,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz", "integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.8.0", @@ -5315,7 +5315,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@peculiar/utils": "^2.0.2", @@ -5327,7 +5327,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz", "integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.8.0", @@ -5340,7 +5340,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz", "integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.8.0", @@ -5353,7 +5353,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -5363,7 +5363,7 @@ "version": "1.14.3", "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@peculiar/asn1-cms": "^2.6.0", @@ -5382,6 +5382,107 @@ "node": ">=20.0.0" } }, + "node_modules/@pmmmwh/react-refresh-webpack-plugin": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.17.tgz", + "integrity": "sha512-tXDyE1/jzFsHXjhRZQ3hMl0IVhYe5qula43LDWIhVfjp9G/nT5OQY5AORVOrkEGAUltBJOfOWeETbmhm6kHhuQ==", + "license": "MIT", + "dependencies": { + "ansi-html": "^0.0.9", + "core-js-pure": "^3.23.3", + "error-stack-parser": "^2.0.6", + "html-entities": "^2.1.0", + "loader-utils": "^2.0.4", + "schema-utils": "^4.2.0", + "source-map": "^0.7.3" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "@types/webpack": "4.x || 5.x", + "react-refresh": ">=0.10.0 <1.0.0", + "sockjs-client": "^1.4.0", + "type-fest": ">=0.17.0 <5.0.0", + "webpack": ">=4.43.0 <6.0.0", + "webpack-dev-server": "3.x || 4.x || 5.x", + "webpack-hot-middleware": "2.x", + "webpack-plugin-serve": "0.x || 1.x" + }, + "peerDependenciesMeta": { + "@types/webpack": { + "optional": true + }, + "sockjs-client": { + "optional": true + }, + "type-fest": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + }, + "webpack-hot-middleware": { + "optional": true + }, + "webpack-plugin-serve": { + "optional": true + } + } + }, + "node_modules/@pmmmwh/react-refresh-webpack-plugin/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@pmmmwh/react-refresh-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/@pmmmwh/react-refresh-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/@pmmmwh/react-refresh-webpack-plugin/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/@react-three/fiber": { "version": "8.16.5", "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.16.5.tgz", @@ -6454,6 +6555,13 @@ "@types/node": "*" } }, + "node_modules/@types/retry": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", + "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@types/semver": { "version": "7.3.13", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.13.tgz", @@ -7325,7 +7433,7 @@ "version": "3.0.10", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "pvtsutils": "^1.3.6", @@ -8075,10 +8183,10 @@ } }, "node_modules/bundle-name": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.1.tgz", - "integrity": "sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==", - "dev": true, + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "devOptional": true, "license": "MIT", "dependencies": { "run-applescript": "^7.0.0" @@ -8102,7 +8210,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=6.0.0" @@ -8711,14 +8819,10 @@ } }, "node_modules/core-js-pure": { - "version": "3.50.0", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.50.0.tgz", - "integrity": "sha512-6GP3Pxz4IKyWjAfa747vIu/jilB5z29JWROLqH/b+pXVcpgh6tM06ZIBwSuglgVqzDYURhOK6oEzTrG0bCHitA==", + "version": "3.27.2", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.27.2.tgz", + "integrity": "sha512-Cf2jqAbXgWH3VVzjyaaFkY1EBazxugUepGymDoeteyYr9ByX51kD2jdHZlsEF/xnJMyN3Prua7mQuzwMg6Zc9A==", "hasInstallScript": true, - "license": "MIT", - "engines": { - "node": "*" - }, "funding": { "type": "opencollective", "url": "https://opencollective.com/core-js" @@ -9433,12 +9537,11 @@ "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==" }, "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", "dependencies": { - "ms": "^2.1.3" + "ms": "2.1.2" }, "engines": { "node": ">=6.0" @@ -9509,10 +9612,10 @@ } }, "node_modules/default-browser": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", - "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", - "dev": true, + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "devOptional": true, "license": "MIT", "dependencies": { "bundle-name": "^4.1.0", @@ -9529,7 +9632,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -10008,7 +10111,6 @@ "version": "2.1.4", "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", - "license": "MIT", "dependencies": { "stackframe": "^1.3.4" } @@ -11867,10 +11969,10 @@ } }, "node_modules/glob-to-regex.js": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.3.1.tgz", - "integrity": "sha512-zhWhMRsgnOym+1txhUmYJGD6eUsbUdrZv55gCYKuJX8t2p3+M8My23PD09bEIsreGUs5pnSa/idmcfp2/IlqGg==", - "dev": true, + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.2.0.tgz", + "integrity": "sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==", + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=10.0" @@ -12380,13 +12482,6 @@ "node": ">= 6" } }, - "node_modules/httpxy": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/httpxy/-/httpxy-0.5.5.tgz", - "integrity": "sha512-uDjmnPyp1q4Sgzf3w+J/Fc6UqcCEj0x4Wjp7OqK5dGhNeDgpyrAmnS6ey8QWrX3SWDon2DMKf9sBa5X9+CVyMA==", - "dev": true, - "license": "MIT" - }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -12399,7 +12494,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10.18" @@ -12734,24 +12829,11 @@ "node": ">=0.10.0" } }, - "node_modules/is-in-ssh": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", - "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "is-docker": "^3.0.0" @@ -12770,7 +12852,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "is-docker": "cli.js" @@ -12826,7 +12908,7 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.2.tgz", "integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=16" @@ -12890,13 +12972,6 @@ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "dev": true, - "license": "MIT" - }, "node_modules/is-regex": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", @@ -16240,10 +16315,9 @@ "integrity": "sha512-Qi0VCU46t2IwfGv5KF0+D/t9cizcDug7qnNoy9Ggk7aucp0tssV8IwTMkBlDbm+VqAf3cdQHTCARKSsuS2MYFg==" }, "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/multicast-dns": { "version": "7.2.5", @@ -16659,16 +16733,18 @@ } }, "node_modules/p-retry": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-8.0.1.tgz", - "integrity": "sha512-NAigyu8hfvJe7MsS18mnc4is0MZtau3QMdBklaJKK23oBQb6occO3UPM3vHmTckW8KhfyjMZaUOqdTFEYliHgg==", - "dev": true, + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz", + "integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==", + "devOptional": true, "license": "MIT", "dependencies": { - "is-network-error": "^1.3.0" + "@types/retry": "0.12.2", + "is-network-error": "^1.0.0", + "retry": "^0.13.1" }, "engines": { - "node": ">=22" + "node": ">=16.17" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -16984,7 +17060,7 @@ "version": "3.4.0", "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", "integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "@noble/hashes": "1.4.0", @@ -18200,19 +18276,6 @@ "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==" }, - "node_modules/powershell-utils": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.1.tgz", - "integrity": "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -18376,7 +18439,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -18386,7 +18449,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=16.0.0" @@ -18965,54 +19028,6 @@ } } }, - "node_modules/react-scripts/node_modules/@pmmmwh/react-refresh-webpack-plugin": { - "version": "0.5.17", - "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.17.tgz", - "integrity": "sha512-tXDyE1/jzFsHXjhRZQ3hMl0IVhYe5qula43LDWIhVfjp9G/nT5OQY5AORVOrkEGAUltBJOfOWeETbmhm6kHhuQ==", - "license": "MIT", - "dependencies": { - "ansi-html": "^0.0.9", - "core-js-pure": "^3.23.3", - "error-stack-parser": "^2.0.6", - "html-entities": "^2.1.0", - "loader-utils": "^2.0.4", - "schema-utils": "^4.2.0", - "source-map": "^0.7.3" - }, - "engines": { - "node": ">= 10.13" - }, - "peerDependencies": { - "@types/webpack": "4.x || 5.x", - "react-refresh": ">=0.10.0 <1.0.0", - "sockjs-client": "^1.4.0", - "type-fest": ">=0.17.0 <5.0.0", - "webpack": ">=4.43.0 <6.0.0", - "webpack-dev-server": "3.x || 4.x || 5.x", - "webpack-hot-middleware": "2.x", - "webpack-plugin-serve": "0.x || 1.x" - }, - "peerDependenciesMeta": { - "@types/webpack": { - "optional": true - }, - "sockjs-client": { - "optional": true - }, - "type-fest": { - "optional": true - }, - "webpack-dev-server": { - "optional": true - }, - "webpack-hot-middleware": { - "optional": true - }, - "webpack-plugin-serve": { - "optional": true - } - } - }, "node_modules/react-scripts/node_modules/@types/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", @@ -19343,7 +19358,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "dev": true, + "devOptional": true, "license": "Apache-2.0" }, "node_modules/regenerate": { @@ -19761,39 +19776,11 @@ "node": ">=8" } }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/router/node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -19959,7 +19946,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-5.5.0.tgz", "integrity": "sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@peculiar/x509": "^1.14.2", @@ -20043,6 +20030,11 @@ "node": ">= 0.8" } }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, "node_modules/serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", @@ -20053,25 +20045,20 @@ } }, "node_modules/serve-index": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.2.tgz", - "integrity": "sha512-KDj11HScOaLmrPxl70KYNW1PksP4Nb/CLL2yvC+Qd2kHMPEEpfc4Re2e4FOay+bC/+XQl/7zAcWON3JVo5v3KQ==", - "license": "MIT", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz", + "integrity": "sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==", "dependencies": { - "accepts": "~1.3.8", + "accepts": "~1.3.4", "batch": "0.6.1", "debug": "2.6.9", "escape-html": "~1.0.3", - "http-errors": "~1.8.0", - "mime-types": "~2.1.35", - "parseurl": "~1.3.3" + "http-errors": "~1.6.2", + "mime-types": "~2.1.17", + "parseurl": "~1.3.2" }, "engines": { "node": ">= 0.8.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" } }, "node_modules/serve-index/node_modules/debug": { @@ -20086,37 +20073,43 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", - "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/serve-index/node_modules/http-errors": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", - "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", - "license": "MIT", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==", "dependencies": { "depd": "~1.1.2", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.1" + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" }, "engines": { "node": ">= 0.6" } }, + "node_modules/serve-index/node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==" + }, "node_modules/serve-index/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/serve-index/node_modules/setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==" + }, "node_modules/serve-index/node_modules/statuses": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "license": "MIT", "engines": { "node": ">= 0.6" } @@ -20493,8 +20486,7 @@ "node_modules/stackframe": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", - "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", - "license": "MIT" + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==" }, "node_modules/statuses": { "version": "2.0.1", @@ -21166,7 +21158,7 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.6.1.tgz", "integrity": "sha512-cV/CMGTK3M4MlnJ/0At6ismOw/A0EEniDNScajjz/Br3c1sqE72YD01rGpPTKwd27wAxI5Pr+6+0w8yofzFRYw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10.18" @@ -21235,54 +21227,6 @@ "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==" }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", - "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tinyqueue": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", @@ -21373,7 +21317,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=10.0" @@ -21458,7 +21402,7 @@ "version": "4.10.0", "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "tslib": "^1.9.3" @@ -21471,7 +21415,7 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true, + "devOptional": true, "license": "0BSD" }, "node_modules/tunnel-agent": { @@ -22005,27 +21949,28 @@ } }, "node_modules/webpack-dev-middleware": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-8.3.0.tgz", - "integrity": "sha512-jFNNMB29ugmkEUFW9cpWNqcWFQNwuMTMVoRpKVAiz/5nh3gA4x9x6DRPhNHGc/SLre7pEP9yrAPDAmCvdCblKg==", - "dev": true, + "version": "7.4.5", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-7.4.5.tgz", + "integrity": "sha512-uxQ6YqGdE4hgDKNf7hUiPXOdtkXvBJXrfEGYSx7P7LC8hnUYGK70X6xQXUvXeNyBDDcsiQXpG2m3G9vxowaEuA==", + "devOptional": true, "license": "MIT", "dependencies": { - "ansi-html-community": "^0.0.8", - "memfs": "^4.68.2", - "mime-types": "^3.0.2", - "range-parser": "^1.3.0", - "schema-utils": "^4.3.3" + "colorette": "^2.0.10", + "memfs": "^4.43.1", + "mime-types": "^3.0.1", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "schema-utils": "^4.0.0" }, "engines": { - "node": ">= 20.9.0" + "node": ">= 18.12.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/webpack" }, "peerDependencies": { - "webpack": "^5.101.0" + "webpack": "^5.0.0" }, "peerDependenciesMeta": { "webpack": { @@ -22037,7 +21982,7 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -22050,29 +21995,11 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/webpack-dev-middleware/node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, "node_modules/webpack-dev-middleware/node_modules/ajv-keywords": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3" @@ -22085,27 +22012,27 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/webpack-dev-middleware/node_modules/memfs": { - "version": "4.80.0", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.80.0.tgz", - "integrity": "sha512-N70W2isi44XdGn7uPo9NJ4zRukcdkwSFHlnYx42SWOH/+hzqNc8P7+tVoA2up4z7faHvNi1RSNCFjxoYQ71VPQ==", - "dev": true, + "version": "4.68.1", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.68.1.tgz", + "integrity": "sha512-OD+IDRUvIxu3QHL+nFm9gdyugInD27FDJ+sl4B5QgomPHXMlbw+GP918P8VNKu2FkNlVeqBkpzkwROpamVifRw==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-core": "4.80.0", - "@jsonjoy.com/fs-fsa": "4.80.0", - "@jsonjoy.com/fs-node": "4.80.0", - "@jsonjoy.com/fs-node-builtins": "4.80.0", - "@jsonjoy.com/fs-node-to-fsa": "4.80.0", - "@jsonjoy.com/fs-node-utils": "4.80.0", - "@jsonjoy.com/fs-print": "4.80.0", - "@jsonjoy.com/fs-snapshot": "4.80.0", + "@jsonjoy.com/fs-core": "4.68.1", + "@jsonjoy.com/fs-fsa": "4.68.1", + "@jsonjoy.com/fs-node": "4.68.1", + "@jsonjoy.com/fs-node-builtins": "4.68.1", + "@jsonjoy.com/fs-node-to-fsa": "4.68.1", + "@jsonjoy.com/fs-node-utils": "4.68.1", + "@jsonjoy.com/fs-print": "4.68.1", + "@jsonjoy.com/fs-snapshot": "4.68.1", "@jsonjoy.com/json-pack": "^1.11.0", "@jsonjoy.com/util": "^1.9.0", - "glob-to-regex.js": "^1.3.1", + "glob-to-regex.js": "^1.0.1", "thingies": "^2.5.0", "tree-dump": "^1.0.3", "tslib": "^2.0.0" @@ -22119,7 +22046,7 @@ "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -22129,7 +22056,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "mime-db": "^1.54.0" @@ -22142,30 +22069,16 @@ "url": "https://opencollective.com/express" } }, - "node_modules/webpack-dev-middleware/node_modules/range-parser": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", - "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/webpack-dev-middleware/node_modules/schema-utils": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.5.0.tgz", - "integrity": "sha512-zJlMCZ0cAR5p/Y4oVpRoqioDMJcGxaXRrQ/4rP4WyR84vc5z/DolXdbvXeDpTwbtocDFr2rhPqHPErDCUtz2kA==", - "dev": true, + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "devOptional": true, "license": "MIT", "dependencies": { - "@types/json-schema": "^7.0.15", - "ajv": "^8.20.0", - "ajv-formats": "^3.0.1", + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" }, "engines": { @@ -22177,50 +22090,53 @@ } }, "node_modules/webpack-dev-server": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-6.0.0.tgz", - "integrity": "sha512-q9SD4ItOGhZLeU6EGT10caDZdHjF50Pz1DtkRZZOPsfluMXOkacWKKOtSBSLVkPqKiF67eFUC0rI88U/tSFPEw==", - "dev": true, + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.6.tgz", + "integrity": "sha512-HNLRmamRvVavZQ+avceZifmv8hmdUjg43t6MI4SqJDwFdW7RPQwH5vzGhDRZSX59SgfbeHhLnq3g+uooWo7pVw==", + "devOptional": true, "license": "MIT", "dependencies": { "@types/bonjour": "^3.5.13", "@types/connect-history-api-fallback": "^1.5.4", - "@types/express": "^5.0.6", - "@types/express-serve-static-core": "^5.1.1", + "@types/express": "^4.17.25", + "@types/express-serve-static-core": "^4.17.21", "@types/serve-index": "^1.9.4", - "@types/serve-static": "^2.2.0", - "@types/ws": "^8.18.1", + "@types/serve-static": "^1.15.5", + "@types/sockjs": "^0.3.36", + "@types/ws": "^8.5.10", "ansi-html-community": "^0.0.8", - "bonjour-service": "^1.3.0", - "chokidar": "^5.0.0", + "bonjour-service": "^1.2.1", + "chokidar": "^3.6.0", + "colorette": "^2.0.10", "compression": "^1.8.1", "connect-history-api-fallback": "^2.0.0", - "express": "^5.2.1", - "graceful-fs": "^4.2.11", - "http-proxy-middleware": "^4.1.1", - "ipaddr.js": "^2.3.0", + "express": "^4.22.1", + "graceful-fs": "^4.2.6", + "http-proxy-middleware": "^2.0.9", + "ipaddr.js": "^2.1.0", "launch-editor": "^2.14.1", - "open": "^11.0.0", - "p-retry": "^8.0.0", - "schema-utils": "^4.3.3", + "open": "^10.0.3", + "p-retry": "^6.2.0", + "schema-utils": "^4.2.0", "selfsigned": "^5.5.0", - "serve-index": "^1.9.2", - "tinyglobby": "^0.2.15", - "webpack-dev-middleware": "^8.0.3", - "ws": "^8.20.0" + "serve-index": "^1.9.1", + "sockjs": "^0.3.24", + "spdy": "^4.0.2", + "webpack-dev-middleware": "^7.4.2", + "ws": "^8.18.0" }, "bin": { "webpack-dev-server": "bin/webpack-dev-server.js" }, "engines": { - "node": ">= 22.15.0" + "node": ">= 18.12.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/webpack" }, "peerDependencies": { - "webpack": "^5.101.0" + "webpack": "^5.0.0" }, "peerDependenciesMeta": { "webpack": { @@ -22231,61 +22147,11 @@ } } }, - "node_modules/webpack-dev-server/node_modules/@types/express": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", - "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^5.0.0", - "@types/serve-static": "^2" - } - }, - "node_modules/webpack-dev-server/node_modules/@types/express-serve-static-core": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", - "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/webpack-dev-server/node_modules/@types/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*" - } - }, - "node_modules/webpack-dev-server/node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/webpack-dev-server/node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -22302,7 +22168,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3" @@ -22311,90 +22177,36 @@ "ajv": "^8.8.2" } }, - "node_modules/webpack-dev-server/node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^2.0.0", - "debug": "^4.4.3", - "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", - "on-finished": "^2.4.1", - "qs": "^6.15.2", - "raw-body": "^3.0.2", - "type-is": "^2.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/webpack-dev-server/node_modules/body-parser/node_modules/content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/webpack-dev-server/node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "dev": true, + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "devOptional": true, "license": "MIT", "dependencies": { - "readdirp": "^5.0.0" + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" }, "engines": { - "node": ">= 20.19.0" + "node": ">= 8.10.0" }, "funding": { "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/webpack-dev-server/node_modules/content-disposition": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/webpack-dev-server/node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.6.0" + "optionalDependencies": { + "fsevents": "~2.3.2" } }, "node_modules/webpack-dev-server/node_modules/define-lazy-prop": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -22403,298 +22215,50 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/webpack-dev-server/node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "dev": true, - "license": "MIT", + "node_modules/webpack-dev-server/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "devOptional": true, + "license": "ISC", "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" + "is-glob": "^4.0.1" }, "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/webpack-dev-server/node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/webpack-dev-server/node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/webpack-dev-server/node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/webpack-dev-server/node_modules/http-proxy-middleware": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-4.2.0.tgz", - "integrity": "sha512-ZA+oNOoM+GLoFTIzhkJptVQov73Srep2LBqhF8hG8CIPKO3nam1jonXVQ/QUH8RbwsmaaVz2SOJdzBNBHNtKbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "httpxy": "^0.5.4", - "is-glob": "^4.0.3", - "is-plain-obj": "^4.1.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": "^22.15.0 || ^24.0.0 || >=26.0.0" - } - }, - "node_modules/webpack-dev-server/node_modules/iconv-lite": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", - "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/webpack-dev-server/node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 6" } }, "node_modules/webpack-dev-server/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, + "devOptional": true, "license": "MIT" }, - "node_modules/webpack-dev-server/node_modules/media-typer": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", - "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/webpack-dev-server/node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/webpack-dev-server/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/webpack-dev-server/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/webpack-dev-server/node_modules/negotiator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", - "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", - "dev": true, - "license": "MIT", - "dependencies": { - "content-type": "^2.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/webpack-dev-server/node_modules/negotiator/node_modules/content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/webpack-dev-server/node_modules/open": { - "version": "11.0.4", - "resolved": "https://registry.npmjs.org/open/-/open-11.0.4.tgz", - "integrity": "sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg==", - "dev": true, + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "devOptional": true, "license": "MIT", "dependencies": { - "default-browser": "^5.5.1", + "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", - "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", - "powershell-utils": "^0.2.1", - "wsl-utils": "^1.0.0" + "wsl-utils": "^0.1.0" }, "engines": { - "node": ">=20" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/webpack-dev-server/node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/webpack-dev-server/node_modules/readdirp": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", - "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/webpack-dev-server/node_modules/schema-utils": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/json-schema": "^7.0.9", @@ -22710,101 +22274,11 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/webpack-dev-server/node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/webpack-dev-server/node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/webpack-dev-server/node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/webpack-dev-server/node_modules/type-is": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", - "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", - "dev": true, - "license": "MIT", - "dependencies": { - "content-type": "^2.0.0", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/webpack-dev-server/node_modules/type-is/node_modules/content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/webpack-dev-server/node_modules/ws": { "version": "8.21.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -23472,17 +22946,16 @@ } }, "node_modules/wsl-utils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-1.0.1.tgz", - "integrity": "sha512-c16PKLIRxBYpR3jSWsdS7SFp0COm5SOQdiFjY818dYFHJbMGuLBszpF07LBWmOS1uzrHa9VGdynsEgoeKy0oOQ==", - "dev": true, + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "devOptional": true, "license": "MIT", "dependencies": { - "is-wsl": "^3.1.0", - "powershell-utils": "^0.2.0" + "is-wsl": "^3.1.0" }, "engines": { - "node": ">=20" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -23492,7 +22965,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "is-inside-container": "^1.0.0" @@ -26669,107 +26142,107 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", - "dev": true, + "devOptional": true, "requires": {} }, "@jsonjoy.com/buffers": { "version": "17.67.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-17.67.0.tgz", "integrity": "sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw==", - "dev": true, + "devOptional": true, "requires": {} }, "@jsonjoy.com/codegen": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-1.0.0.tgz", "integrity": "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==", - "dev": true, + "devOptional": true, "requires": {} }, "@jsonjoy.com/fs-core": { - "version": "4.80.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.80.0.tgz", - "integrity": "sha512-qMKWshnyjbyhm+NbzqGE3w5y1mckFFEMj0aDPU2/JBNh1BzIjVy9esq9lBmRnNVmhMEkxwDzLZ8Gqh5G1P5Dag==", - "dev": true, + "version": "4.68.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.68.1.tgz", + "integrity": "sha512-V5oZ4Gt9WJKyQef0n9cAd0N9qjSkIBm3E4MYsgNIWBk5aINCDPKxMPo1i29rBxqiT4Ixf1epklqV9VJMKIxwlw==", + "devOptional": true, "requires": { - "@jsonjoy.com/fs-node-builtins": "4.80.0", - "@jsonjoy.com/fs-node-utils": "4.80.0", + "@jsonjoy.com/fs-node-builtins": "4.68.1", + "@jsonjoy.com/fs-node-utils": "4.68.1", "thingies": "^2.5.0" } }, "@jsonjoy.com/fs-fsa": { - "version": "4.80.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.80.0.tgz", - "integrity": "sha512-ZBEKl7J6dbkRCfmrFV48Re211A/FHqniTUhsWCsaRteNSOqMgkX3qF1UreVgwB9oSPm81JLAwhIi6Lb/NH/PoA==", - "dev": true, + "version": "4.68.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.68.1.tgz", + "integrity": "sha512-HCG72UioncuO7Gw09XNVG+S85e3cq2hrUC/mexBrsWsa3mI7eePkkqWie3uVYbtsb64OR9YGQs5SqaufDRYBcg==", + "devOptional": true, "requires": { - "@jsonjoy.com/fs-core": "4.80.0", - "@jsonjoy.com/fs-node-builtins": "4.80.0", - "@jsonjoy.com/fs-node-utils": "4.80.0", + "@jsonjoy.com/fs-core": "4.68.1", + "@jsonjoy.com/fs-node-builtins": "4.68.1", + "@jsonjoy.com/fs-node-utils": "4.68.1", "thingies": "^2.5.0" } }, "@jsonjoy.com/fs-node": { - "version": "4.80.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.80.0.tgz", - "integrity": "sha512-yiBlUfkMMRFFGy8CD/h2zd5rPnrM6bjlF+nx2LRAxNV6hOSiuObQIUEE7m7y2VTMisjxCRtzINH94bFGySWL7g==", - "dev": true, + "version": "4.68.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.68.1.tgz", + "integrity": "sha512-R5D9mWtqdURzcOWj1vdXr3APCwX0xchtFT+kmW7fXLNDifWdDrnh26jSID8pdnUfFBxTyfHtFtTL/NWKzIH7kQ==", + "devOptional": true, "requires": { - "@jsonjoy.com/fs-core": "4.80.0", - "@jsonjoy.com/fs-node-builtins": "4.80.0", - "@jsonjoy.com/fs-node-utils": "4.80.0", - "@jsonjoy.com/fs-print": "4.80.0", - "@jsonjoy.com/fs-snapshot": "4.80.0", - "glob-to-regex.js": "^1.3.1", + "@jsonjoy.com/fs-core": "4.68.1", + "@jsonjoy.com/fs-node-builtins": "4.68.1", + "@jsonjoy.com/fs-node-utils": "4.68.1", + "@jsonjoy.com/fs-print": "4.68.1", + "@jsonjoy.com/fs-snapshot": "4.68.1", + "glob-to-regex.js": "^1.0.0", "thingies": "^2.5.0" } }, "@jsonjoy.com/fs-node-builtins": { - "version": "4.80.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.80.0.tgz", - "integrity": "sha512-OIOIhqaWiwUySFMny4epIAoviXsyMaQQ63PTc3zH+5HEOvarl5hpUmZIhp2m5+OfyKAh5ylxXnQIgew1OgJDVw==", - "dev": true, + "version": "4.68.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.68.1.tgz", + "integrity": "sha512-HK1BTksysokNZxNspqDH0yPaqN9YgR/AYIlYiIaU2Ys4BOk5CdybI7r6BgiZuiiPiV8n4sK/kZdice7Znpy2Kw==", + "devOptional": true, "requires": {} }, "@jsonjoy.com/fs-node-to-fsa": { - "version": "4.80.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.80.0.tgz", - "integrity": "sha512-ljeaR5xwtX1EyRmB0wvXANUMQZ3mjKcm7z8s2N8D8SLQ04CcAGtUhHFRZQXFb9mhgFyEVMydQGQtSOFS7pcrFQ==", - "dev": true, + "version": "4.68.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.68.1.tgz", + "integrity": "sha512-lpKmU4X9e/oh8GIuAI7EXaS5QiLNM3KD15CkdhfS6PYmrGvoJqKQcyEfnLgnnaGslh/PFUMYSIZBCf2ejJGw8g==", + "devOptional": true, "requires": { - "@jsonjoy.com/fs-fsa": "4.80.0", - "@jsonjoy.com/fs-node-builtins": "4.80.0", - "@jsonjoy.com/fs-node-utils": "4.80.0" + "@jsonjoy.com/fs-fsa": "4.68.1", + "@jsonjoy.com/fs-node-builtins": "4.68.1", + "@jsonjoy.com/fs-node-utils": "4.68.1" } }, "@jsonjoy.com/fs-node-utils": { - "version": "4.80.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.80.0.tgz", - "integrity": "sha512-xwHeVVi3f+Khg/Fmb8i5qoYVmQ08QznE5TWhQokUmIZjz4KBPzU+zt/+bahBI6G1ghtqajt27lWuAciIxApuzA==", - "dev": true, + "version": "4.68.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.68.1.tgz", + "integrity": "sha512-/GxfW1DWm9SCdkfbvqevLO/P5duobQfmKkHXxdMIDbcZMQeAgooAstIfZhkXpATzq9QbCQsnoWFM/dGHdZfndw==", + "devOptional": true, "requires": { - "@jsonjoy.com/fs-node-builtins": "4.80.0", - "glob-to-regex.js": "^1.3.1" + "@jsonjoy.com/fs-node-builtins": "4.68.1", + "glob-to-regex.js": "^1.0.1" } }, "@jsonjoy.com/fs-print": { - "version": "4.80.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.80.0.tgz", - "integrity": "sha512-1ocZLJw/m/xlq0moaIHO7HV9nHRVBUvdc2zMzhsJGkFOrqfwWvexfCv5cYCPPLRhP+9p3yunoi4Ep5YhRORC9Q==", - "dev": true, + "version": "4.68.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.68.1.tgz", + "integrity": "sha512-oGeZOGPYKK9v1CgeVeEDsLomH1lCnslSpqUN5GmPzrmAVGQlsmsdcXNA2O4lV8Y4xkuSuynx2ITBkUHJVaTbow==", + "devOptional": true, "requires": { - "@jsonjoy.com/fs-node-utils": "4.80.0", + "@jsonjoy.com/fs-node-utils": "4.68.1", "tree-dump": "^1.1.0" } }, "@jsonjoy.com/fs-snapshot": { - "version": "4.80.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.80.0.tgz", - "integrity": "sha512-3AV23Q1H228KTtJKTUE0nNq8FCtxSvtENOLrEat80wkAJCKHuqQEv8yjhpS8Bei2OEFJAnluGXp9Ri2SWBlROw==", - "dev": true, + "version": "4.68.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.68.1.tgz", + "integrity": "sha512-XZfP0FDZN32bbc4t2bZN2qRrYHg5AktJnzk22HRoKGK4BprrbNRH2k5ceSNS/kupKYcofCs+O841+xaAbjnxwQ==", + "devOptional": true, "requires": { "@jsonjoy.com/buffers": "^17.65.0", - "@jsonjoy.com/fs-node-utils": "4.80.0", + "@jsonjoy.com/fs-node-utils": "4.68.1", "@jsonjoy.com/json-pack": "^17.65.0", "@jsonjoy.com/util": "^17.65.0" }, @@ -26778,21 +26251,21 @@ "version": "17.67.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-17.67.0.tgz", "integrity": "sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw==", - "dev": true, + "devOptional": true, "requires": {} }, "@jsonjoy.com/codegen": { "version": "17.67.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-17.67.0.tgz", "integrity": "sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q==", - "dev": true, + "devOptional": true, "requires": {} }, "@jsonjoy.com/json-pack": { "version": "17.67.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-17.67.0.tgz", "integrity": "sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w==", - "dev": true, + "devOptional": true, "requires": { "@jsonjoy.com/base64": "17.67.0", "@jsonjoy.com/buffers": "17.67.0", @@ -26808,7 +26281,7 @@ "version": "17.67.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-17.67.0.tgz", "integrity": "sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA==", - "dev": true, + "devOptional": true, "requires": { "@jsonjoy.com/util": "17.67.0" } @@ -26817,7 +26290,7 @@ "version": "17.67.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-17.67.0.tgz", "integrity": "sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew==", - "dev": true, + "devOptional": true, "requires": { "@jsonjoy.com/buffers": "17.67.0", "@jsonjoy.com/codegen": "17.67.0" @@ -26829,7 +26302,7 @@ "version": "1.21.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.21.0.tgz", "integrity": "sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==", - "dev": true, + "devOptional": true, "requires": { "@jsonjoy.com/base64": "^1.1.2", "@jsonjoy.com/buffers": "^1.2.0", @@ -26845,7 +26318,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", - "dev": true, + "devOptional": true, "requires": {} } } @@ -26854,7 +26327,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-1.0.2.tgz", "integrity": "sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==", - "dev": true, + "devOptional": true, "requires": { "@jsonjoy.com/codegen": "^1.0.0", "@jsonjoy.com/util": "^1.9.0" @@ -26864,7 +26337,7 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.9.0.tgz", "integrity": "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==", - "dev": true, + "devOptional": true, "requires": { "@jsonjoy.com/buffers": "^1.0.0", "@jsonjoy.com/codegen": "^1.0.0" @@ -26874,7 +26347,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", - "dev": true, + "devOptional": true, "requires": {} } } @@ -27043,7 +26516,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", - "dev": true + "devOptional": true }, "@nodelib/fs.scandir": { "version": "2.1.5", @@ -27072,7 +26545,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz", "integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==", - "dev": true, + "devOptional": true, "requires": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", @@ -27085,7 +26558,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz", "integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==", - "dev": true, + "devOptional": true, "requires": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", @@ -27097,7 +26570,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz", "integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==", - "dev": true, + "devOptional": true, "requires": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", @@ -27109,7 +26582,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz", "integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==", - "dev": true, + "devOptional": true, "requires": { "@peculiar/asn1-cms": "^2.8.0", "@peculiar/asn1-pkcs8": "^2.8.0", @@ -27123,7 +26596,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz", "integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==", - "dev": true, + "devOptional": true, "requires": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", @@ -27135,7 +26608,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz", "integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==", - "dev": true, + "devOptional": true, "requires": { "@peculiar/asn1-cms": "^2.8.0", "@peculiar/asn1-pfx": "^2.8.0", @@ -27151,7 +26624,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz", "integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==", - "dev": true, + "devOptional": true, "requires": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", @@ -27163,7 +26636,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", - "dev": true, + "devOptional": true, "requires": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", @@ -27174,7 +26647,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz", "integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==", - "dev": true, + "devOptional": true, "requires": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/utils": "^2.0.2", @@ -27186,7 +26659,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz", "integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==", - "dev": true, + "devOptional": true, "requires": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", @@ -27198,7 +26671,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", - "dev": true, + "devOptional": true, "requires": { "tslib": "^2.8.1" } @@ -27207,7 +26680,7 @@ "version": "1.14.3", "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", - "dev": true, + "devOptional": true, "requires": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-csr": "^2.6.0", @@ -27222,6 +26695,57 @@ "tsyringe": "^4.10.0" } }, + "@pmmmwh/react-refresh-webpack-plugin": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.17.tgz", + "integrity": "sha512-tXDyE1/jzFsHXjhRZQ3hMl0IVhYe5qula43LDWIhVfjp9G/nT5OQY5AORVOrkEGAUltBJOfOWeETbmhm6kHhuQ==", + "requires": { + "ansi-html": "^0.0.9", + "core-js-pure": "^3.23.3", + "error-stack-parser": "^2.0.6", + "html-entities": "^2.1.0", + "loader-utils": "^2.0.4", + "schema-utils": "^4.2.0", + "source-map": "^0.7.3" + }, + "dependencies": { + "ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "requires": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + } + }, + "ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "requires": { + "fast-deep-equal": "^3.1.3" + } + }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "requires": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + } + } + } + }, "@react-three/fiber": { "version": "8.16.5", "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.16.5.tgz", @@ -27998,6 +27522,12 @@ "@types/node": "*" } }, + "@types/retry": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", + "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", + "devOptional": true + }, "@types/semver": { "version": "7.3.13", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.13.tgz", @@ -28638,7 +28168,7 @@ "version": "3.0.10", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", - "dev": true, + "devOptional": true, "requires": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", @@ -29161,10 +28691,10 @@ "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==" }, "bundle-name": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.1.tgz", - "integrity": "sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==", - "dev": true, + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "devOptional": true, "requires": { "run-applescript": "^7.0.0" } @@ -29178,7 +28708,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", - "dev": true + "devOptional": true }, "cachedir": { "version": "2.4.0", @@ -29612,9 +29142,9 @@ } }, "core-js-pure": { - "version": "3.50.0", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.50.0.tgz", - "integrity": "sha512-6GP3Pxz4IKyWjAfa747vIu/jilB5z29JWROLqH/b+pXVcpgh6tM06ZIBwSuglgVqzDYURhOK6oEzTrG0bCHitA==" + "version": "3.27.2", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.27.2.tgz", + "integrity": "sha512-Cf2jqAbXgWH3VVzjyaaFkY1EBazxugUepGymDoeteyYr9ByX51kD2jdHZlsEF/xnJMyN3Prua7mQuzwMg6Zc9A==" }, "core-util-is": { "version": "1.0.3", @@ -30100,11 +29630,11 @@ "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==" }, "debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", "requires": { - "ms": "^2.1.3" + "ms": "2.1.2" } }, "decimal.js": { @@ -30157,10 +29687,10 @@ "integrity": "sha512-z2wJZXrmeHdvYJp/Ux55wIjqo81G5Bp4c+oELTW+7ar6SogWHajt5a9gO3s3IDaGSAXjDk0vlQKN3rms8ab3og==" }, "default-browser": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", - "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", - "dev": true, + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "devOptional": true, "requires": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" @@ -30170,7 +29700,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", - "dev": true + "devOptional": true }, "default-gateway": { "version": "6.0.3", @@ -31838,10 +31368,10 @@ } }, "glob-to-regex.js": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.3.1.tgz", - "integrity": "sha512-zhWhMRsgnOym+1txhUmYJGD6eUsbUdrZv55gCYKuJX8t2p3+M8My23PD09bEIsreGUs5pnSa/idmcfp2/IlqGg==", - "dev": true, + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.2.0.tgz", + "integrity": "sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==", + "devOptional": true, "requires": {} }, "glob-to-regexp": { @@ -32202,12 +31732,6 @@ "debug": "4" } }, - "httpxy": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/httpxy/-/httpxy-0.5.5.tgz", - "integrity": "sha512-uDjmnPyp1q4Sgzf3w+J/Fc6UqcCEj0x4Wjp7OqK5dGhNeDgpyrAmnS6ey8QWrX3SWDon2DMKf9sBa5X9+CVyMA==", - "dev": true - }, "human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -32217,7 +31741,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", - "dev": true + "devOptional": true }, "iconv-lite": { "version": "0.6.3", @@ -32429,17 +31953,11 @@ "is-extglob": "^2.1.1" } }, - "is-in-ssh": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", - "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", - "dev": true - }, "is-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "dev": true, + "devOptional": true, "requires": { "is-docker": "^3.0.0" }, @@ -32448,7 +31966,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "dev": true + "devOptional": true } } }, @@ -32480,7 +31998,7 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.2.tgz", "integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==", - "dev": true + "devOptional": true }, "is-number": { "version": "7.0.0", @@ -32515,12 +32033,6 @@ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" }, - "is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "dev": true - }, "is-regex": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", @@ -34934,9 +34446,9 @@ "integrity": "sha512-Qi0VCU46t2IwfGv5KF0+D/t9cizcDug7qnNoy9Ggk7aucp0tssV8IwTMkBlDbm+VqAf3cdQHTCARKSsuS2MYFg==" }, "ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "multicast-dns": { "version": "7.2.5", @@ -35215,12 +34727,14 @@ } }, "p-retry": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-8.0.1.tgz", - "integrity": "sha512-NAigyu8hfvJe7MsS18mnc4is0MZtau3QMdBklaJKK23oBQb6occO3UPM3vHmTckW8KhfyjMZaUOqdTFEYliHgg==", - "dev": true, + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz", + "integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==", + "devOptional": true, "requires": { - "is-network-error": "^1.3.0" + "@types/retry": "0.12.2", + "is-network-error": "^1.0.0", + "retry": "^0.13.1" } }, "p-try": { @@ -35443,7 +34957,7 @@ "version": "3.4.0", "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", "integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==", - "dev": true, + "devOptional": true, "requires": { "@noble/hashes": "1.4.0", "asn1js": "^3.0.6", @@ -36132,12 +35646,6 @@ "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==" }, - "powershell-utils": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.1.tgz", - "integrity": "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==", - "dev": true - }, "prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -36272,7 +35780,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", - "dev": true, + "devOptional": true, "requires": { "tslib": "^2.8.1" } @@ -36281,7 +35789,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", - "dev": true + "devOptional": true }, "q": { "version": "1.5.1", @@ -36670,20 +36178,6 @@ "workbox-webpack-plugin": "^6.4.1" }, "dependencies": { - "@pmmmwh/react-refresh-webpack-plugin": { - "version": "0.5.17", - "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.17.tgz", - "integrity": "sha512-tXDyE1/jzFsHXjhRZQ3hMl0IVhYe5qula43LDWIhVfjp9G/nT5OQY5AORVOrkEGAUltBJOfOWeETbmhm6kHhuQ==", - "requires": { - "ansi-html": "^0.0.9", - "core-js-pure": "^3.23.3", - "error-stack-parser": "^2.0.6", - "html-entities": "^2.1.0", - "loader-utils": "^2.0.4", - "schema-utils": "^4.2.0", - "source-map": "^0.7.3" - } - }, "@types/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", @@ -36894,7 +36388,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "dev": true + "devOptional": true }, "regenerate": { "version": "1.4.2", @@ -37188,32 +36682,11 @@ } } }, - "router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "dev": true, - "requires": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "dependencies": { - "path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "dev": true - } - } - }, "run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", - "dev": true + "devOptional": true }, "run-parallel": { "version": "1.2.0", @@ -37302,7 +36775,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-5.5.0.tgz", "integrity": "sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew==", - "dev": true, + "devOptional": true, "requires": { "@peculiar/x509": "^1.14.2", "pkijs": "^3.3.3" @@ -37370,6 +36843,11 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" } } }, @@ -37382,17 +36860,17 @@ } }, "serve-index": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.2.tgz", - "integrity": "sha512-KDj11HScOaLmrPxl70KYNW1PksP4Nb/CLL2yvC+Qd2kHMPEEpfc4Re2e4FOay+bC/+XQl/7zAcWON3JVo5v3KQ==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz", + "integrity": "sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==", "requires": { - "accepts": "~1.3.8", + "accepts": "~1.3.4", "batch": "0.6.1", "debug": "2.6.9", "escape-html": "~1.0.3", - "http-errors": "~1.8.0", - "mime-types": "~2.1.35", - "parseurl": "~1.3.3" + "http-errors": "~1.6.2", + "mime-types": "~2.1.17", + "parseurl": "~1.3.2" }, "dependencies": { "debug": { @@ -37409,22 +36887,31 @@ "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==" }, "http-errors": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", - "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==", "requires": { "depd": "~1.1.2", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.1" + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" } }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==" + }, "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==" + }, "statuses": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", @@ -38154,7 +37641,7 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.6.1.tgz", "integrity": "sha512-cV/CMGTK3M4MlnJ/0At6ismOw/A0EEniDNScajjz/Br3c1sqE72YD01rGpPTKwd27wAxI5Pr+6+0w8yofzFRYw==", - "dev": true, + "devOptional": true, "requires": {} }, "three": { @@ -38205,31 +37692,6 @@ "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==" }, - "tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "requires": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "dependencies": { - "fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "requires": {} - }, - "picomatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", - "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true - } - } - }, "tinyqueue": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", @@ -38301,7 +37763,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", - "dev": true, + "devOptional": true, "requires": {} }, "tree-kill": { @@ -38364,7 +37826,7 @@ "version": "4.10.0", "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", - "dev": true, + "devOptional": true, "requires": { "tslib": "^1.9.3" }, @@ -38373,7 +37835,7 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true + "devOptional": true } } }, @@ -38779,23 +38241,24 @@ } }, "webpack-dev-middleware": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-8.3.0.tgz", - "integrity": "sha512-jFNNMB29ugmkEUFW9cpWNqcWFQNwuMTMVoRpKVAiz/5nh3gA4x9x6DRPhNHGc/SLre7pEP9yrAPDAmCvdCblKg==", - "dev": true, + "version": "7.4.5", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-7.4.5.tgz", + "integrity": "sha512-uxQ6YqGdE4hgDKNf7hUiPXOdtkXvBJXrfEGYSx7P7LC8hnUYGK70X6xQXUvXeNyBDDcsiQXpG2m3G9vxowaEuA==", + "devOptional": true, "requires": { - "ansi-html-community": "^0.0.8", - "memfs": "^4.68.2", - "mime-types": "^3.0.2", - "range-parser": "^1.3.0", - "schema-utils": "^4.3.3" + "colorette": "^2.0.10", + "memfs": "^4.43.1", + "mime-types": "^3.0.1", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "schema-utils": "^4.0.0" }, "dependencies": { "ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, + "devOptional": true, "requires": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -38803,20 +38266,11 @@ "require-from-string": "^2.0.2" } }, - "ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dev": true, - "requires": { - "ajv": "^8.0.0" - } - }, "ajv-keywords": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dev": true, + "devOptional": true, "requires": { "fast-deep-equal": "^3.1.3" } @@ -38825,25 +38279,25 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true + "devOptional": true }, "memfs": { - "version": "4.80.0", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.80.0.tgz", - "integrity": "sha512-N70W2isi44XdGn7uPo9NJ4zRukcdkwSFHlnYx42SWOH/+hzqNc8P7+tVoA2up4z7faHvNi1RSNCFjxoYQ71VPQ==", - "dev": true, + "version": "4.68.1", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.68.1.tgz", + "integrity": "sha512-OD+IDRUvIxu3QHL+nFm9gdyugInD27FDJ+sl4B5QgomPHXMlbw+GP918P8VNKu2FkNlVeqBkpzkwROpamVifRw==", + "devOptional": true, "requires": { - "@jsonjoy.com/fs-core": "4.80.0", - "@jsonjoy.com/fs-fsa": "4.80.0", - "@jsonjoy.com/fs-node": "4.80.0", - "@jsonjoy.com/fs-node-builtins": "4.80.0", - "@jsonjoy.com/fs-node-to-fsa": "4.80.0", - "@jsonjoy.com/fs-node-utils": "4.80.0", - "@jsonjoy.com/fs-print": "4.80.0", - "@jsonjoy.com/fs-snapshot": "4.80.0", + "@jsonjoy.com/fs-core": "4.68.1", + "@jsonjoy.com/fs-fsa": "4.68.1", + "@jsonjoy.com/fs-node": "4.68.1", + "@jsonjoy.com/fs-node-builtins": "4.68.1", + "@jsonjoy.com/fs-node-to-fsa": "4.68.1", + "@jsonjoy.com/fs-node-utils": "4.68.1", + "@jsonjoy.com/fs-print": "4.68.1", + "@jsonjoy.com/fs-snapshot": "4.68.1", "@jsonjoy.com/json-pack": "^1.11.0", "@jsonjoy.com/util": "^1.9.0", - "glob-to-regex.js": "^1.3.1", + "glob-to-regex.js": "^1.0.1", "thingies": "^2.5.0", "tree-dump": "^1.0.3", "tslib": "^2.0.0" @@ -38853,118 +38307,72 @@ "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true + "devOptional": true }, "mime-types": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "dev": true, + "devOptional": true, "requires": { "mime-db": "^1.54.0" } }, - "range-parser": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", - "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", - "dev": true - }, "schema-utils": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.5.0.tgz", - "integrity": "sha512-zJlMCZ0cAR5p/Y4oVpRoqioDMJcGxaXRrQ/4rP4WyR84vc5z/DolXdbvXeDpTwbtocDFr2rhPqHPErDCUtz2kA==", - "dev": true, + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "devOptional": true, "requires": { - "@types/json-schema": "^7.0.15", - "ajv": "^8.20.0", - "ajv-formats": "^3.0.1", + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } } } }, "webpack-dev-server": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-6.0.0.tgz", - "integrity": "sha512-q9SD4ItOGhZLeU6EGT10caDZdHjF50Pz1DtkRZZOPsfluMXOkacWKKOtSBSLVkPqKiF67eFUC0rI88U/tSFPEw==", - "dev": true, + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.6.tgz", + "integrity": "sha512-HNLRmamRvVavZQ+avceZifmv8hmdUjg43t6MI4SqJDwFdW7RPQwH5vzGhDRZSX59SgfbeHhLnq3g+uooWo7pVw==", + "devOptional": true, "requires": { "@types/bonjour": "^3.5.13", "@types/connect-history-api-fallback": "^1.5.4", - "@types/express": "^5.0.6", - "@types/express-serve-static-core": "^5.1.1", + "@types/express": "^4.17.25", + "@types/express-serve-static-core": "^4.17.21", "@types/serve-index": "^1.9.4", - "@types/serve-static": "^2.2.0", - "@types/ws": "^8.18.1", + "@types/serve-static": "^1.15.5", + "@types/sockjs": "^0.3.36", + "@types/ws": "^8.5.10", "ansi-html-community": "^0.0.8", - "bonjour-service": "^1.3.0", - "chokidar": "^5.0.0", + "bonjour-service": "^1.2.1", + "chokidar": "^3.6.0", + "colorette": "^2.0.10", "compression": "^1.8.1", "connect-history-api-fallback": "^2.0.0", - "express": "^5.2.1", - "graceful-fs": "^4.2.11", - "http-proxy-middleware": "^4.1.1", - "ipaddr.js": "^2.3.0", + "express": "^4.22.1", + "graceful-fs": "^4.2.6", + "http-proxy-middleware": "^2.0.9", + "ipaddr.js": "^2.1.0", "launch-editor": "^2.14.1", - "open": "^11.0.0", - "p-retry": "^8.0.0", - "schema-utils": "^4.3.3", + "open": "^10.0.3", + "p-retry": "^6.2.0", + "schema-utils": "^4.2.0", "selfsigned": "^5.5.0", - "serve-index": "^1.9.2", - "tinyglobby": "^0.2.15", - "webpack-dev-middleware": "^8.0.3", - "ws": "^8.20.0" + "serve-index": "^1.9.1", + "sockjs": "^0.3.24", + "spdy": "^4.0.2", + "webpack-dev-middleware": "^7.4.2", + "ws": "^8.18.0" }, "dependencies": { - "@types/express": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", - "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", - "dev": true, - "requires": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^5.0.0", - "@types/serve-static": "^2" - } - }, - "@types/express-serve-static-core": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", - "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", - "dev": true, - "requires": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "@types/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", - "dev": true, - "requires": { - "@types/http-errors": "*", - "@types/node": "*" - } - }, - "accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "dev": true, - "requires": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - } - }, "ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, + "devOptional": true, "requires": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -38976,247 +38384,65 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dev": true, + "devOptional": true, "requires": { "fast-deep-equal": "^3.1.3" } }, - "body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", - "dev": true, - "requires": { - "bytes": "^3.1.2", - "content-type": "^2.0.0", - "debug": "^4.4.3", - "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", - "on-finished": "^2.4.1", - "qs": "^6.15.2", - "raw-body": "^3.0.2", - "type-is": "^2.1.0" - }, - "dependencies": { - "content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "dev": true - } - } - }, "chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "dev": true, + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "devOptional": true, "requires": { - "readdirp": "^5.0.0" + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "fsevents": "~2.3.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" } }, - "content-disposition": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", - "dev": true - }, - "cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "dev": true - }, "define-lazy-prop": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "dev": true + "devOptional": true }, - "express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "dev": true, + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "devOptional": true, "requires": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" + "is-glob": "^4.0.1" } }, - "finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "dev": true, - "requires": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - } - }, - "fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "dev": true - }, - "http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "dev": true, - "requires": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - } - }, - "http-proxy-middleware": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-4.2.0.tgz", - "integrity": "sha512-ZA+oNOoM+GLoFTIzhkJptVQov73Srep2LBqhF8hG8CIPKO3nam1jonXVQ/QUH8RbwsmaaVz2SOJdzBNBHNtKbw==", - "dev": true, - "requires": { - "debug": "^4.4.3", - "httpxy": "^0.5.4", - "is-glob": "^4.0.3", - "is-plain-obj": "^4.1.0", - "micromatch": "^4.0.8" - } - }, - "iconv-lite": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", - "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", - "dev": true, - "requires": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - } - }, - "is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "dev": true - }, "json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, - "media-typer": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", - "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", - "dev": true - }, - "merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "dev": true - }, - "mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true - }, - "mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "dev": true, - "requires": { - "mime-db": "^1.54.0" - } - }, - "negotiator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", - "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", - "dev": true, - "requires": { - "content-type": "^2.1.0" - }, - "dependencies": { - "content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "dev": true - } - } + "devOptional": true }, "open": { - "version": "11.0.4", - "resolved": "https://registry.npmjs.org/open/-/open-11.0.4.tgz", - "integrity": "sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg==", - "dev": true, + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "devOptional": true, "requires": { - "default-browser": "^5.5.1", + "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", - "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", - "powershell-utils": "^0.2.1", - "wsl-utils": "^1.0.0" + "wsl-utils": "^0.1.0" } }, - "raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "dev": true, - "requires": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - } - }, - "readdirp": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", - "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", - "dev": true - }, "schema-utils": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", - "dev": true, + "devOptional": true, "requires": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -39224,67 +38450,11 @@ "ajv-keywords": "^5.1.0" } }, - "send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "dev": true, - "requires": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - } - }, - "serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "dev": true, - "requires": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - } - }, - "statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true - }, - "type-is": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", - "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", - "dev": true, - "requires": { - "content-type": "^2.0.0", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "dependencies": { - "content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "dev": true - } - } - }, "ws": { "version": "8.21.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", - "dev": true, + "devOptional": true, "requires": {} } } @@ -39746,20 +38916,19 @@ "requires": {} }, "wsl-utils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-1.0.1.tgz", - "integrity": "sha512-c16PKLIRxBYpR3jSWsdS7SFp0COm5SOQdiFjY818dYFHJbMGuLBszpF07LBWmOS1uzrHa9VGdynsEgoeKy0oOQ==", - "dev": true, + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "devOptional": true, "requires": { - "is-wsl": "^3.1.0", - "powershell-utils": "^0.2.0" + "is-wsl": "^3.1.0" }, "dependencies": { "is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", - "dev": true, + "devOptional": true, "requires": { "is-inside-container": "^1.0.0" } diff --git a/app/package.json b/app/package.json index adaf5e31..2e365c32 100644 --- a/app/package.json +++ b/app/package.json @@ -65,7 +65,7 @@ "@types/react": "^18.3.17", "@types/react-dom": "^18.3.5", "typescript": "^5.9.3", - "webpack-dev-server": "^6.0.0" + "webpack-dev-server": "^5.2.6" }, "jest": { "transformIgnorePatterns": [ From 1c8bbf1f106d699a3d625e507ee39f00ef216730 Mon Sep 17 00:00:00 2001 From: lrnselfreliance <33263708+lrnselfreliance@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:01:18 -0600 Subject: [PATCH 069/181] deps: cachetools 7 and the current web-vitals API (#771) cachetools 6 removed cachetools.func.mru_cache; the Zim search estimate cache was its only user, so it becomes an lru_cache and the pin moves to 7.2.0 with the Dependabot ignore removed. reportWebVitals still destructured the web-vitals 2.x names (getCLS, getFID, ...) which no longer exist, so it silently did nothing. Use the on* names, replace FID with INP, return the import promise, and accept any callable rather than instanceof Function. Co-authored-by: Claude Fable 5.1 --- .github/dependabot.yml | 3 --- app/src/reportWebVitals.js | 15 ++++++++------- app/src/reportWebVitals.test.js | 28 ++++++++++++++++++++++++++++ modules/zim/lib.py | 2 +- modules/zim/test/test_lib.py | 12 ++++++++++++ requirements.txt | 2 +- 6 files changed, 50 insertions(+), 12 deletions(-) create mode 100644 app/src/reportWebVitals.test.js diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 77b80b94..785d2aee 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -15,9 +15,6 @@ updates: versions: [">=1.4"] - dependency-name: "alembic" versions: [">=1.11"] - # cachetools 6 removed cachetools.func.mru_cache, still used by modules/zim. - - dependency-name: "cachetools" - update-types: ["version-update:semver-major"] groups: python-dependencies: patterns: diff --git a/app/src/reportWebVitals.js b/app/src/reportWebVitals.js index 14ba227b..e3768eef 100644 --- a/app/src/reportWebVitals.js +++ b/app/src/reportWebVitals.js @@ -1,11 +1,12 @@ const reportWebVitals = onPerfEntry => { - if (onPerfEntry && onPerfEntry instanceof Function) { - import('web-vitals').then(({getCLS, getFID, getFCP, getLCP, getTTFB}) => { - getCLS(onPerfEntry); - getFID(onPerfEntry); - getFCP(onPerfEntry); - getLCP(onPerfEntry); - getTTFB(onPerfEntry); + if (typeof onPerfEntry === 'function') { + // web-vitals 3+ renamed get* to on*; 5+ replaced FID with INP. + return import('web-vitals').then(({onCLS, onINP, onFCP, onLCP, onTTFB}) => { + onCLS(onPerfEntry); + onINP(onPerfEntry); + onFCP(onPerfEntry); + onLCP(onPerfEntry); + onTTFB(onPerfEntry); }); } }; diff --git a/app/src/reportWebVitals.test.js b/app/src/reportWebVitals.test.js new file mode 100644 index 00000000..31d24251 --- /dev/null +++ b/app/src/reportWebVitals.test.js @@ -0,0 +1,28 @@ +jest.mock('web-vitals', () => ({ + onCLS: jest.fn(), + onINP: jest.fn(), + onFCP: jest.fn(), + onLCP: jest.fn(), + onTTFB: jest.fn(), +})); + +import reportWebVitals from './reportWebVitals'; +import * as webVitals from 'web-vitals'; + +describe('reportWebVitals', () => { + test('subscribes the callback to every current web-vitals metric', async () => { + const callback = jest.fn(); + const pending = reportWebVitals(callback); + expect(pending).toBeDefined(); + await pending; + for (const name of ['onCLS', 'onINP', 'onFCP', 'onLCP', 'onTTFB']) { + expect(webVitals[name]).toHaveBeenCalledWith(callback); + } + }); + + test('does nothing without a function', async () => { + jest.clearAllMocks(); + expect(reportWebVitals(undefined)).toBeUndefined(); + expect(webVitals.onCLS).not.toHaveBeenCalled(); + }); +}); diff --git a/modules/zim/lib.py b/modules/zim/lib.py index 95761df2..667d5359 100644 --- a/modules/zim/lib.py +++ b/modules/zim/lib.py @@ -260,7 +260,7 @@ def search_all_zims(session: Session, search_str: str, tag_names: List[str] = No return all_results -@cachetools.func.mru_cache(maxsize=1_000) +@cachetools.func.lru_cache(maxsize=1_000) def get_estimates(search_str: str) -> List[int]: zims = Zims.get_all() estimates = [i.estimate(search_str) for i in zims] diff --git a/modules/zim/test/test_lib.py b/modules/zim/test/test_lib.py index b11532d1..c6b987dc 100644 --- a/modules/zim/test/test_lib.py +++ b/modules/zim/test/test_lib.py @@ -411,3 +411,15 @@ def test_get_custom_zims_directory(async_client, test_directory, test_wrolpi_con get_wrolpi_config().zims_destination = 'custom/zims' assert lib.get_zim_directory() == (test_directory / 'custom/zims') + + +def test_get_estimates_is_cached(): + """Repeated estimates for the same search string do not re-query the Zims.""" + from unittest import mock + from modules.zim import lib + + lib.get_estimates.cache_clear() + with mock.patch.object(lib.Zims, 'get_all', return_value=[]) as get_all: + assert lib.get_estimates('cached search') == [] + assert lib.get_estimates('cached search') == [] + assert get_all.call_count == 1 diff --git a/requirements.txt b/requirements.txt index eb150470..f7b838b4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ SQLAlchemy[asyncio]==1.3.24 aiohttp==3.14.3 alembic==1.10.4 beautifulsoup4==4.15.0 -cachetools==5.5.2 +cachetools==7.2.0 cryptography==50.0.2 ebooklib==0.20 feedparser==6.0.14 From d7deea668d0f73f785490cc3a3668c948abd4f40 Mon Sep 17 00:00:00 2001 From: Roland Date: Sat, 3 Oct 2026 11:53:21 -0600 Subject: [PATCH 070/181] Bump version to 0.29.0-beta Co-Authored-By: Claude Fable 5.1 --- wrolpi/version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wrolpi/version.txt b/wrolpi/version.txt index 205fb5e6..b08aae5b 100644 --- a/wrolpi/version.txt +++ b/wrolpi/version.txt @@ -1 +1 @@ -0.28.7-beta +0.29.0-beta From a2bef48c6c3716ec32d2c1180674e22d8b8b7a2f Mon Sep 17 00:00:00 2001 From: lrnselfreliance <33263708+lrnselfreliance@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:42:06 -0600 Subject: [PATCH 071/181] db: one request-scoped database handle per API request (#773) * db: one request-scoped database handle per API request Every API request now gets `request.ctx.db` (`wrolpi.db.RequestDB`): a Session plus raw cursors on that Session's connection, so a request needs at most one SQLite connection. Creating it does no I/O; requests that never touch the database never connect. `request.ctx.session` remains the handle's Session. A `RequestSession` binds itself to one lazily-opened Connection. An engine-bound Session releases its connection at every commit, and NullPool closes it, so a request that read and then wrote reconnected. Handlers write in `request.ctx.db.write()`: BEGIN IMMEDIATE, committed when the block exits, so the write lock is not held for the rest of the handler or by background work it starts. This replaces the handler-level `get_db_session(commit=True)` blocks (download create, collection item add/remove/reorder, channel create). The zim search handlers use the request's Session in their worker thread. Tests count the connections a request opens with production's per-call sessions (`count_request_connections`). Endpoints whose library functions still open their own connection are strict xfails, to be removed as those functions take the request's session. Co-Authored-By: Claude Opus 5.5 * tests: name the real extra connections in the request connection xfails Channel detail: get_channel already uses the request session; the extra connection is Channel.get_statistics opening a raw cursor (get_db_curs). Search: count_file_groups opens a cursor for the total, then handle_file_group_search_results opens a cursor and a session. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- modules/videos/channel/api.py | 13 +- modules/zim/api.py | 38 +++--- wrolpi/api_utils.py | 40 +++--- wrolpi/collections/api.py | 9 +- .../collections/test/test_playlist_items.py | 39 +++--- wrolpi/conftest.py | 62 ++++++++- wrolpi/db.py | 126 +++++++++++++++++- wrolpi/root_api.py | 13 +- wrolpi/test/test_db.py | 100 +++++++++++++- wrolpi/test/test_request_db.py | 112 ++++++++++++++++ 10 files changed, 455 insertions(+), 97 deletions(-) create mode 100644 wrolpi/test/test_request_db.py diff --git a/modules/videos/channel/api.py b/modules/videos/channel/api.py index 92e98ac7..5169132b 100644 --- a/modules/videos/channel/api.py +++ b/modules/videos/channel/api.py @@ -9,7 +9,6 @@ from sanic_ext.extensions.openapi import openapi from wrolpi.api_utils import json_response from wrolpi.common import logger, wrol_mode_check, get_media_directory, get_relative_to_media_directory -from wrolpi.db import get_db_session from wrolpi.events import Events from wrolpi.schema import JSONErrorResponse from wrolpi.vars import PYTEST @@ -54,12 +53,12 @@ def channel_get(request: Request, channel_id: int = None): def channel_post(request: Request, body: schema.ChannelPostRequest): body.directory = get_media_directory() / body.directory - # Not `request.ctx.session`: creating a Channel reads (the URL/name/directory conflict check) - # before it INSERTs, and a deferred transaction fails that lock upgrade *instantly* with - # "database is locked" if anything else wrote in between -- which is what a user creating a - # Channel while background workers saved configs hit. This session also commits before the - # refresh below is fired, so the refresh does not wait on a lock this request still holds. - with get_db_session(commit=True) as session: + # A write block: creating a Channel reads (the URL/name/directory conflict check) before it + # INSERTs, and a deferred transaction fails that lock upgrade *instantly* with "database is + # locked" if anything else wrote in between -- which is what a user creating a Channel while + # background workers saved configs hit. The block also commits before the refresh below is + # fired, so the refresh does not wait on a lock this request still holds. + with request.ctx.db.write() as session: channel = lib.create_channel(session, data=body, return_dict=False) channel_id, channel_name = channel.id, channel.name diff --git a/modules/zim/api.py b/modules/zim/api.py index 17403e66..d0ebae22 100644 --- a/modules/zim/api.py +++ b/modules/zim/api.py @@ -9,7 +9,6 @@ from sanic_ext.extensions.openapi import openapi from wrolpi import lang from wrolpi.api_utils import json_response from wrolpi.common import logger -from wrolpi.db import get_db_session from wrolpi.downloader import download_manager from wrolpi.events import Events from . import lib, schema @@ -65,13 +64,12 @@ async def post_set_zim_auto_search(request: Request, zim_id: int, body: schema.Z body=schema.ZimSearchRequest, ) @validate(schema.ZimSearchRequest) -async def search_all_zims(_: Request, body: schema.ZimSearchRequest): - # libzim searches are synchronous and slow; run off the event loop. The session is opened in the - # worker thread: a SQLAlchemy Session must not be shared across threads. +async def search_all_zims(request: Request, body: schema.ZimSearchRequest): + # libzim searches are synchronous and slow; run off the event loop. The request's Session is used + # in the worker thread; the handler awaits the thread, so the Session has one user at a time. def search(): - with get_db_session() as session: - return lib.search_all_zims(session, body.search_str, tag_names=body.tag_names, offset=body.offset, - limit=body.limit) + return lib.search_all_zims(request.ctx.session, body.search_str, tag_names=body.tag_names, + offset=body.offset, limit=body.limit) results = await asyncio.to_thread(search) return json_response({'zims': results}) @@ -84,11 +82,10 @@ async def search_all_zims(_: Request, body: schema.ZimSearchRequest): response=schema.ZimSearchResponse, ) @validate(schema.ZimSearchRequest) -async def search_zim(_: Request, zim_id: int, body: schema.ZimSearchRequest): +async def search_zim(request: Request, zim_id: int, body: schema.ZimSearchRequest): def search(): - with get_db_session() as session: - return lib.headline_zim(session, body.search_str, zim_id, tag_names=body.tag_names, offset=body.offset, - limit=body.limit) + return lib.headline_zim(request.ctx.session, body.search_str, zim_id, tag_names=body.tag_names, + offset=body.offset, limit=body.limit) headlines = await asyncio.to_thread(search) return json_response({'zim': headlines}) @@ -219,17 +216,14 @@ async def post_search_estimates(request: Request, body: schema.SearchEstimateReq return response.empty(HTTPStatus.BAD_REQUEST) def estimate(): - zims_estimates = list() - with get_db_session() as session: - if body.tag_names: - # Get actual count of entries tagged with the tag names. - counts = Zims.entries_with_tags(session, body.tag_names) - else: - # Get estimates using libzim. - counts = Zims.estimate(session, body.search_str) - for zim, count in counts.items(): - zims_estimates.append(dict(estimate=count, **zim.__json__())) - return zims_estimates + session = request.ctx.session + if body.tag_names: + # Get actual count of entries tagged with the tag names. + counts = Zims.entries_with_tags(session, body.tag_names) + else: + # Get estimates using libzim. + counts = Zims.estimate(session, body.search_str) + return [dict(estimate=count, **zim.__json__()) for zim, count in counts.items()] zims_estimates = await asyncio.to_thread(estimate) diff --git a/wrolpi/api_utils.py b/wrolpi/api_utils.py index c23cc2b2..195c119f 100644 --- a/wrolpi/api_utils.py +++ b/wrolpi/api_utils.py @@ -12,6 +12,7 @@ from pathlib import Path from sanic import response, HTTPResponse, Request, Sanic, SanicException from wrolpi.common import Base, get_media_directory, logger, LOGGING_CONFIG +from wrolpi.db import RequestDB from wrolpi.errors import APIError from wrolpi.perpetual import PERPETUAL_LOOPS, PER_WORKER_TASKS, PerpetualLoop, run_loop from wrolpi.vars import PYTEST @@ -141,37 +142,26 @@ api_app.error_handler.add(Exception, json_error_handler) @api_app.middleware('request') async def inject_session(request: Request): - """Inject a database session into the request context. + """Give the request its database handle, `request.ctx.db` (see `wrolpi.db.RequestDB`). - This session is deliberately *deferred*, even for a POST: it is committed by the response - middleware, so a writer would hold the SQLite write lock for the whole handler -- including the - slow parts (a chunk being written to disk, a subprocess) and including any background task the - handler fires before returning. A handler that writes should use `get_db_session(commit=True)`, - which begins as a writer and commits before the handler returns. + `request.ctx.session` is the handle's Session. It begins *deferred*, even for a POST, because it + is committed by the response middleware: a writer would hold the SQLite write lock for the whole + handler -- including the slow parts (a chunk being written to disk, a subprocess) and any + background task the handler fires before returning. A handler that writes does so in + `request.ctx.db.write()`, which begins as a writer and commits when its block exits. + + Nothing connects until the handler's first statement, so requests that never touch the database + cost nothing here. """ - from wrolpi.db import get_db_context - engine, session = get_db_context() - request.ctx.session = session - request.ctx._db_engine = engine + request.ctx.db = RequestDB() + request.ctx.session = request.ctx.db.session @api_app.middleware('response') async def cleanup_session(request: Request, response_: HTTPResponse): - """Cleanup session after request completes.""" - if hasattr(request.ctx, 'session'): - session = request.ctx.session - try: - if response_.status < 400: - session.commit() - else: - session.rollback() - except Exception: - session.rollback() - raise - finally: - # Don't close session during tests - the test_session fixture manages it. - if not PYTEST: - session.close() + """Commit (or, for an error response, roll back) the request's transaction and release its connection.""" + if hasattr(request.ctx, 'db'): + request.ctx.db.finish(ok=response_.status < 400) return response_ diff --git a/wrolpi/collections/api.py b/wrolpi/collections/api.py index 8e497bf7..0e3a786a 100644 --- a/wrolpi/collections/api.py +++ b/wrolpi/collections/api.py @@ -10,7 +10,6 @@ from sanic_ext.extensions.openapi import openapi from wrolpi.api_utils import json_response from wrolpi.common import wrol_mode_check -from wrolpi.db import get_db_session from wrolpi.errors import ValidationError from wrolpi.schema import JSONErrorResponse from . import lib, schema @@ -91,8 +90,8 @@ async def create_collection_endpoint(request: Request, body: schema.CollectionCr async def add_collection_item_endpoint(request: Request, collection_id: int, body: schema.AddItemRequest): """Append (or insert at ``position``) a file/zim/url item into the collection.""" try: - # Read-then-write, so the session must begin as a writer (see `get_db_session`). - with get_db_session(commit=True) as session: + # Read-then-write, so the transaction must begin as a writer (see `RequestDB.write`). + with request.ctx.db.write() as session: item, created = lib.add_collection_item( session, collection_id, item_kind=body.item_kind, file_group_id=body.file_group_id, zim_id=body.zim_id, zim_entry=body.zim_entry, @@ -114,7 +113,7 @@ async def add_collection_item_endpoint(request: Request, collection_id: int, bod async def remove_collection_item_endpoint(request: Request, collection_id: int, item_id: int): """Remove a single item by its id and close the ordering gap.""" try: - with get_db_session(commit=True) as session: + with request.ctx.db.write() as session: removed = lib.remove_collection_item(session, collection_id, item_id) except UnknownCollection as e: return json_response({'error': str(e)}, status=HTTPStatus.NOT_FOUND) @@ -139,7 +138,7 @@ async def reorder_collection_items_endpoint(request: Request, collection_id: int body: schema.ReorderItemsRequest): """Set the item order from a full list of item ids (used by drag-and-drop).""" try: - with get_db_session(commit=True) as session: + with request.ctx.db.write() as session: lib.reorder_collection_items(session, collection_id, body.item_ids) data = lib.get_collection_with_stats(session, collection_id) except UnknownCollection as e: diff --git a/wrolpi/collections/test/test_playlist_items.py b/wrolpi/collections/test/test_playlist_items.py index b25f417f..db221afb 100644 --- a/wrolpi/collections/test/test_playlist_items.py +++ b/wrolpi/collections/test/test_playlist_items.py @@ -4,6 +4,7 @@ from http import HTTPStatus import pytest from wrolpi.collections.models import Collection +from wrolpi.conftest import production_like_sessions, write_lock_held_briefly @pytest.mark.asyncio @@ -157,39 +158,33 @@ async def test_add_file_item(async_client, test_session, test_directory): @pytest.mark.asyncio -async def test_item_write_endpoints_use_write_intent_sessions(async_client, test_session): - """The item write endpoints (add/remove/reorder) each open their session with - `get_db_session(commit=True)` — the deferred `request.ctx.session` reads before its first - write, which SQLite fails instantly on concurrent writes (see `get_db_session`).""" - from unittest import mock - from wrolpi.collections import api as collections_api - from wrolpi.db import get_db_session +async def test_item_write_endpoints_wait_for_a_concurrent_writer(async_client, test_session): + """Add, reorder and remove each read before they write, so each must begin as a writer. + SQLite fails a deferred transaction's read-to-write lock upgrade *instantly* ("database is locked") + if another connection wrote meanwhile; a transaction that begins as a writer waits out the lock + (busy_timeout) instead. Here a concurrent writer holds the lock while each request starts.""" _, response = await async_client.post('/api/collections', json={'name': 'Locks'}) cid = response.json['collection']['id'] + test_session.commit() + db_file = test_session.get_bind().url.database - commit_kwargs = list() - - def recording_get_db_session(*args, **kwargs): - commit_kwargs.append(kwargs.get('commit', args[0] if args else False)) - return get_db_session(*args, **kwargs) - - with mock.patch.object(collections_api, 'get_db_session', recording_get_db_session): - _, response = await async_client.post(f'/api/collections/{cid}/items', - json={'item_kind': 'url', 'url': '/u/a', 'title': 'a'}) + with production_like_sessions(test_session): + with write_lock_held_briefly(db_file): + _, response = await async_client.post(f'/api/collections/{cid}/items', + json={'item_kind': 'url', 'url': '/u/a', 'title': 'a'}) assert response.status_code == HTTPStatus.CREATED, response.json item_id = response.json['item']['id'] - _, response = await async_client.put(f'/api/collections/{cid}/items/order', - json={'item_ids': [item_id]}) + with write_lock_held_briefly(db_file): + _, response = await async_client.put(f'/api/collections/{cid}/items/order', + json={'item_ids': [item_id]}) assert response.status_code == HTTPStatus.OK, response.json - _, response = await async_client.delete(f'/api/collections/{cid}/items/{item_id}') + with write_lock_held_briefly(db_file): + _, response = await async_client.delete(f'/api/collections/{cid}/items/{item_id}') assert response.status_code == HTTPStatus.NO_CONTENT - assert len(commit_kwargs) == 3, 'Each item write endpoint must open its own session' - assert all(commit is True for commit in commit_kwargs) - @pytest.mark.asyncio async def test_reorder_and_remove_items(async_client, test_session): diff --git a/wrolpi/conftest.py b/wrolpi/conftest.py index da6fbdcb..544b3666 100644 --- a/wrolpi/conftest.py +++ b/wrolpi/conftest.py @@ -45,7 +45,7 @@ from wrolpi.common import logger, await_background_tasks as await_background_tas from wrolpi.common import set_test_media_directory, Base, set_test_config from wrolpi.contexts import attach_shared_contexts, initialize_configs_contexts from wrolpi.dates import set_test_now -from wrolpi.db import create_wrolpi_engine +from wrolpi.db import create_wrolpi_engine, RequestSession from wrolpi.downloader import DownloadContext, DownloadManager, DownloadResult, Download, Downloader, \ downloads_manager_config_context, download_cache_config_context from wrolpi.errors import UnrecoverableDownloadError @@ -91,7 +91,8 @@ def test_session(test_directory) -> Generator[Session, Any, None]: return test_engine, session try: - with mock.patch('wrolpi.db._get_db_session', fake_get_db_session): + with mock.patch('wrolpi.db._get_db_session', fake_get_db_session), \ + mock.patch('wrolpi.db._get_request_session', fake_get_db_session): yield session finally: session.rollback() @@ -240,6 +241,11 @@ class AutoAwaitTestClient: """WebSocket connection (pass-through).""" return await self._client.websocket(*args, **kwargs) + @property + def unawaited(self) -> SanicASGITestClient: + """The underlying client: requests return without awaiting switches or background tasks.""" + return self._client + @property def sanic_app(self): """Access to the underlying Sanic app.""" @@ -460,7 +466,8 @@ def production_like_sessions(test_session) -> Generator[sessionmaker, Any, None] one `with` block stays attached for the next and exceptions never roll back. That convenience masks detached-object and transaction-rollback bugs that only bite production's per-call sessions. Use this to exercise that behavior: the yielded sessionmaker creates independent Sessions so a test can verify committed - DB state the way a separate worker would see it. + DB state the way a separate worker would see it. API requests get a fresh `RequestSession` each, as in + production. A dedicated NullPool engine is used and disposed on exit so its connections are released before the test DB is dropped during fixture teardown. @@ -474,8 +481,14 @@ def production_like_sessions(test_session) -> Generator[sessionmaker, Any, None] sessions.append(session) return engine, session + def request_factory(): + session = RequestSession(engine) + sessions.append(session) + return engine, session + try: - with mock.patch('wrolpi.db._get_db_session', factory): + with mock.patch('wrolpi.db._get_db_session', factory), \ + mock.patch('wrolpi.db._get_request_session', request_factory): yield maker finally: for session in sessions: @@ -488,6 +501,47 @@ def production_like_sessions(test_session) -> Generator[sessionmaker, Any, None] engine.dispose() +@contextlib.contextmanager +def count_connections(maker: sessionmaker) -> Generator[List[int], Any, None]: + """Record every SQLite connection opened on a `production_like_sessions` engine during the block. + + Yields a list that gains one entry per connection, so `len()` is the count. Each connection + costs a connect plus WROLPi's PRAGMAs (about 3 ms on a Raspberry Pi 4), so an API request should + open at most one.""" + engine = maker.kw['bind'] + opened = list() + + def on_connect(*_): + opened.append(1) + + sqlalchemy.event.listen(engine, 'connect', on_connect) + try: + yield opened + finally: + sqlalchemy.event.remove(engine, 'connect', on_connect) + + +async def count_request_connections(async_client: 'AutoAwaitTestClient', test_session: Session, method: str, + path: str, **kwargs) -> Tuple[Any, int]: + """Send one request with production's per-call sessions; return its response and how many SQLite + connections the request opened. + + The test's data must already be in `test_session` (it is committed here so other connections see + it). Switches and background tasks are awaited only after counting, so the count is the request's + own work: in tests, `background_task_listener` would otherwise run switches inside the request, + where production runs them in the switch worker.""" + test_session.commit() + with production_like_sessions(test_session) as maker: + with count_connections(maker) as opened, \ + mock.patch(f'{__name__}.await_switches_', mock.AsyncMock()): + _, response = await getattr(async_client.unawaited, method.lower())(path, **kwargs) + count = len(opened) + await await_switches_() + await await_background_tasks_() + test_session.expire_all() + return response, count + + @pytest.fixture def test_downloader(test_download_manager) -> Downloader: class TestDownloader(Downloader, ABC): diff --git a/wrolpi/db.py b/wrolpi/db.py index a1aa5d75..43a40cc0 100644 --- a/wrolpi/db.py +++ b/wrolpi/db.py @@ -174,17 +174,57 @@ def _get_db_session(): return engine, session +def _refuse_non_test_engine(engine: sqlalchemy.engine.Engine): + from wrolpi.common import is_tempfile + if PYTEST and not is_tempfile(engine.url.database or ''): + raise ValueError(f'Running tests, but a test database is not being used!! {engine.url=}') + + def get_db_context() -> Tuple[sqlalchemy.engine.Engine, Session]: """ Get a DB engine and session. """ - from wrolpi.common import is_tempfile local_engine, session = _get_db_session() - if PYTEST and not is_tempfile(local_engine.url.database or ''): - raise ValueError(f'Running tests, but a test database is not being used!! {local_engine.url=}') + _refuse_non_test_engine(local_engine) return local_engine, session +class RequestSession(Session): + """A Session that keeps one connection for its whole life, opened at its first statement. + + An engine-bound Session releases its connection at every commit, and NullPool then closes it, so a + request that commits and keeps working would reconnect (a connect plus PRAGMAs each time). This + Session binds itself to a single Connection, created lazily, so transactions after a commit reuse + it. `close()` releases the connection.""" + + def __init__(self, engine: sqlalchemy.engine.Engine, **kwargs): + super().__init__(**kwargs) + self._request_engine = engine + self._request_connection = None + + def get_bind(self, mapper=None, clause=None, **kwargs): + if self._request_connection is None: + self._request_connection = self._request_engine.connect() + return self._request_connection + + def close(self): + try: + super().close() + finally: + if self._request_connection is not None: + self._request_connection.close() + self._request_connection = None + + +def _get_request_session() -> Tuple[sqlalchemy.engine.Engine, Session]: + """Create the Session for one API request. + + This function allows the database to be wrapped during testing. See: wrolpi.conftest.test_session + """ + engine = get_engine() + return engine, RequestSession(engine) + + @contextmanager def get_db_session(commit: bool = False) -> Generator[Session, Any, None]: """ @@ -277,6 +317,86 @@ def get_db_curs(commit: bool = False) -> Generator[sqlite3.Cursor, Any, None]: connection.close() +def _session_has_connection(session: Session) -> bool: + """True if the Session's transaction has already connected (and so already emitted BEGIN).""" + # SQLAlchemy 1.3 has no public API for this; 2.0 replaces it with `session.in_transaction()`. + transaction = session.transaction + return transaction is not None and bool(transaction._connections) + + +class RequestDB: + """The database handle for one API request: `request.ctx.db`. + + One Session per request, and raw cursors (`curs()`) on that Session's connection, so a request + uses at most one SQLite connection. Creating the handle does no I/O; SQLAlchemy connects at the + first statement, so requests that never touch the database never connect. The response + middleware calls `finish()`. + + Reads begin deferred, so they never take the write lock. A handler that writes does so inside + `write()`, which begins the transaction as a writer and commits when the block exits; the write + lock is held only for that block, never for the rest of the handler or for background work the + handler starts afterwards. (See `get_db_session` for why a writer must take the lock at BEGIN.) + + Code that runs outside a request (workers, switches, perpetual loops) uses `get_db_session` and + `get_db_curs`; they open their own connection. + """ + + def __init__(self): + self.engine, self.session = _get_request_session() + _refuse_non_test_engine(self.engine) + + @contextmanager + def curs(self) -> Generator[sqlite3.Cursor, Any, None]: + """A raw `sqlite3.Cursor` on this request's connection, inside the Session's transaction. + + Pending ORM changes are flushed first, so raw SQL sees them. Writes through the cursor commit + or roll back with the request (or with the enclosing `write()` block). Rows are `sqlite3.Row`.""" + self.session.flush() + curs = self.session.connection().connection.cursor() + curs.row_factory = sqlite3.Row + try: + yield curs + finally: + curs.close() + + @contextmanager + def write(self) -> Generator[Session, Any, None]: + """Begin a write transaction (BEGIN IMMEDIATE) on the request's connection; commit on exit. + + If the request already read, its deferred transaction is committed first: SQLite cannot upgrade + a deferred transaction to a writer under `busy_timeout`, and its snapshot is stale for a writer + anyway. Any changes pending in the Session at that point are committed with it.""" + session = self.session + if _session_has_connection(session): + session.commit() + token = _immediate_txn.set(True) + try: + session.connection() + finally: + _immediate_txn.reset(token) + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + + def finish(self, ok: bool): + """End the request: commit if `ok`, otherwise roll back; then release the connection.""" + try: + if ok: + self.session.commit() + else: + self.session.rollback() + except Exception: + self.session.rollback() + raise + finally: + # In tests the shared `test_session` fixture owns the Session's lifecycle. + if not PYTEST: + self.session.close() + + def get_ranked_models(ranked_primary_keys: List, model: Type[Base], session: Session) -> List[Base]: """Get all objects whose primary keys are in the `ranked_primary_keys`, preserve their order.""" pkey = sqlalchemy.inspect(model).primary_key[0] diff --git a/wrolpi/root_api.py b/wrolpi/root_api.py index 5b9b64e3..54c5fdb9 100644 --- a/wrolpi/root_api.py +++ b/wrolpi/root_api.py @@ -30,7 +30,6 @@ from wrolpi.common import logger, get_wrolpi_config, wrol_mode_enabled, get_medi set_global_log_level, get_relative_to_media_directory, search_other_estimates, set_system_timezone from wrolpi.config_api import config_bp from wrolpi.dates import now -from wrolpi.db import get_db_session from wrolpi.downloader import download_manager from wrolpi.errors import WROLModeEnabled, InvalidConfig, ValidationError from wrolpi.events import get_events, Events @@ -369,12 +368,12 @@ async def post_download(request: Request, body: schema.DownloadRequest): sub_downloader_name=body.sub_downloader, reset_attempts=True, destination=body.destination, tag_names=body.tag_names, settings=body.settings, override_skip=True) - # Not `request.ctx.session`: creating a Download reads (the Tags, then any existing Download for - # each URL) before it INSERTs, and a deferred transaction fails that lock upgrade *instantly* - # with "database is locked" if anything else wrote in between -- which is what a user creating a - # download during a refresh hit. This session also commits before the handler returns, so the - # background dispatch `create_downloads` fires does not wait on a lock this request still holds. - with get_db_session(commit=True) as session: + # A write block: creating a Download reads (the Tags, then any existing Download for each URL) + # before it INSERTs, and a deferred transaction fails that lock upgrade *instantly* with "database + # is locked" if anything else wrote in between -- which is what a user creating a download during + # a refresh hit. The block also commits before the handler returns, so the background dispatch + # `create_downloads` fires does not wait on a lock this request still holds. + with request.ctx.db.write() as session: if body.frequency: download_manager.recurring_download(session, body.urls[0], body.frequency, collection_id=body.collection_id, **kwargs) diff --git a/wrolpi/test/test_db.py b/wrolpi/test/test_db.py index dd1a62f0..037958b1 100644 --- a/wrolpi/test/test_db.py +++ b/wrolpi/test/test_db.py @@ -3,8 +3,9 @@ import sqlite3 import pytest -from wrolpi.conftest import production_like_sessions, probe_write_lock_is_held -from wrolpi.db import get_db_session, _configure_sqlite_connection +from wrolpi.conftest import production_like_sessions, probe_write_lock_is_held, count_connections +from wrolpi.db import get_db_session, _configure_sqlite_connection, RequestDB, RequestSession +from wrolpi.tags import Tag class _FakeCursor: @@ -160,3 +161,98 @@ async def test_background_task_does_not_inherit_write_intent(test_session): assert held_during_child_read['held'] is False, \ 'a read session in a spawned task took the write lock, inheriting the parent write intent' + + +def test_request_db_does_no_io_until_used(test_session): + """Creating the request handle opens no connection; most endpoints never touch the database.""" + test_session.commit() + with production_like_sessions(test_session) as maker, count_connections(maker) as opened: + db = RequestDB() + db.finish(ok=True) + assert len(opened) == 0 + + +def test_request_db_cursor_shares_the_session_connection(test_session): + """`curs()` runs on the Session's connection and inside its transaction: one connection per request, + and raw SQL sees the request's own ORM changes.""" + test_session.commit() + with production_like_sessions(test_session) as maker, count_connections(maker) as opened: + db = RequestDB() + db.session.add(Tag(name='shared', color='#000000')) + with db.curs() as curs: + curs.execute("SELECT name FROM tag WHERE name = 'shared'") + assert [tuple(row) for row in curs.fetchall()] == [('shared',)] + db.finish(ok=False) + assert len(opened) == 1 + + # finish(ok=False) rolled the uncommitted Tag back. + assert test_session.query(Tag).filter_by(name='shared').count() == 0 + + +def test_request_db_write_holds_the_write_lock_only_inside_the_block(test_session): + """`write()` begins as a writer (BEGIN IMMEDIATE) and commits on exit, so the write lock is not held + for the rest of the handler, or by any background work the handler starts afterwards.""" + db_file = test_session.get_bind().url.database + test_session.commit() + with production_like_sessions(test_session) as maker, count_connections(maker) as opened: + db = RequestDB() + # A read first, as most write handlers do (validation, conflict checks). + db.session.query(Tag).count() + assert not probe_write_lock_is_held(db_file), 'a read must not take the write lock' + + with db.write() as session: + assert probe_write_lock_is_held(db_file), 'write() did not take the write lock at BEGIN' + session.add(Tag(name='written', color='#000000')) + + assert not probe_write_lock_is_held(db_file), 'write() held the lock after its block' + db.finish(ok=True) + assert len(opened) == 1, 'the write reused the request connection' + + assert test_session.query(Tag).filter_by(name='written').count() == 1 + + +def test_request_db_write_rolls_back_on_error(test_session): + """An exception inside `write()` rolls its changes back and releases the write lock.""" + db_file = test_session.get_bind().url.database + test_session.commit() + with production_like_sessions(test_session): + db = RequestDB() + with pytest.raises(ValueError): + with db.write() as session: + session.add(Tag(name='discarded', color='#000000')) + session.flush() + raise ValueError('handler failed') + assert not probe_write_lock_is_held(db_file) + db.finish(ok=False) + + assert test_session.query(Tag).filter_by(name='discarded').count() == 0 + + +def test_request_db_finish_commits_reads_and_writes(test_session): + """`finish(ok=True)` commits whatever the request's session changed (the existing response-middleware + contract for handlers that write through `request.ctx.session`).""" + test_session.commit() + with production_like_sessions(test_session): + db = RequestDB() + db.session.add(Tag(name='committed', color='#000000')) + db.finish(ok=True) + + assert test_session.query(Tag).filter_by(name='committed').count() == 1 + + +def test_request_session_keeps_one_connection_until_closed(test_session): + """A RequestSession reuses its connection across commits and releases it on close (production's + `RequestDB.finish`); a later statement reconnects.""" + test_session.commit() + with production_like_sessions(test_session) as maker, count_connections(maker) as opened: + session = RequestSession(maker.kw['bind']) + session.query(Tag).count() + session.commit() + session.query(Tag).count() + assert len(opened) == 1, 'the commit released the connection' + + session.close() + assert session._request_connection is None + session.query(Tag).count() + assert len(opened) == 2, 'a closed RequestSession did not reconnect' + session.close() diff --git a/wrolpi/test/test_request_db.py b/wrolpi/test/test_request_db.py new file mode 100644 index 00000000..c915e923 --- /dev/null +++ b/wrolpi/test/test_request_db.py @@ -0,0 +1,112 @@ +"""An API request uses at most one SQLite connection (see `wrolpi.db.RequestDB`). + +Each connection costs a connect plus WROLPi's PRAGMAs, about 3 ms on a Raspberry Pi 4. These tests count +the connections a request opens with production's per-call sessions; the shared `test_session` hides them. +""" +from http import HTTPStatus + +import pytest + +from wrolpi.conftest import count_request_connections + +# Library functions these endpoints call still open their own cursor or session, instead of using the +# request's. Remove an endpoint's mark when its count reaches one (strict: an unexpected pass fails). +OPENS_OWN_CONNECTION = pytest.mark.xfail( + strict=True, reason='a library function opens its own connection instead of using the request session') + + +@pytest.mark.asyncio +@pytest.mark.parametrize('method,path,body', [ + ('post', '/api/echo', {}), + ('get', '/api/settings', None), +]) +async def test_requests_without_database_work_open_no_connection(async_client, test_session, method, path, body): + kwargs = dict(json=body) if body is not None else dict() + response, count = await count_request_connections(async_client, test_session, method, path, **kwargs) + assert response.status_code == HTTPStatus.OK, response.body + assert count == 0 + + +@pytest.mark.asyncio +async def test_collection_item_writes_use_one_connection(async_client, test_session): + """The item endpoints read, then write in `RequestDB.write()`; both use the request's connection.""" + _, response = await async_client.post('/api/collections', json={'name': 'One Connection'}) + cid = response.json['collection']['id'] + + response, count = await count_request_connections( + async_client, test_session, 'post', f'/api/collections/{cid}/items', + json={'item_kind': 'url', 'url': '/u/a', 'title': 'a'}) + assert response.status_code == HTTPStatus.CREATED, response.body + assert count == 1 + item_id = response.json['item']['id'] + + response, count = await count_request_connections( + async_client, test_session, 'put', f'/api/collections/{cid}/items/order', json={'item_ids': [item_id]}) + assert response.status_code == HTTPStatus.OK, response.body + assert count == 1 + + response, count = await count_request_connections( + async_client, test_session, 'delete', f'/api/collections/{cid}/items/{item_id}') + assert response.status_code == HTTPStatus.NO_CONTENT, response.body + assert count == 1 + + +@pytest.mark.asyncio +async def test_create_download_uses_one_connection(async_client, test_session, test_downloader): + response, count = await count_request_connections( + async_client, test_session, 'post', '/api/download', + json={'urls': ['https://example.com/one-connection'], 'downloader': test_downloader.name}) + assert response.status_code == HTTPStatus.CREATED, response.body + assert count == 1 + + +@pytest.mark.asyncio +async def test_tags_use_one_connection(async_client, test_session, tag_factory): + await tag_factory() + response, count = await count_request_connections(async_client, test_session, 'get', '/api/tag') + assert response.status_code == HTTPStatus.OK, response.body + assert count == 1 + + +@pytest.mark.asyncio +async def test_zim_estimates_use_one_connection(async_client, test_session, zim_factory): + zim_factory('test zim') + response, count = await count_request_connections( + async_client, test_session, 'post', '/api/zim/search_estimates', json={'search_str': 'one'}) + assert response.status_code == HTTPStatus.OK, response.body + assert count == 1 + + +@OPENS_OWN_CONNECTION +@pytest.mark.asyncio +async def test_zim_search_uses_one_connection(async_client, test_session, zim_factory): + """`get_all_entries_tags` opens a cursor of its own.""" + zim_factory('test zim') + response, count = await count_request_connections( + async_client, test_session, 'post', '/api/zim/search', json={'search_str': 'one'}) + assert response.status_code == HTTPStatus.OK, response.body + assert count == 1 + + +@OPENS_OWN_CONNECTION +@pytest.mark.asyncio +@pytest.mark.parametrize('path', ['/api/files/search', '/api/videos/search']) +async def test_search_uses_one_connection(async_client, test_session, simple_channel, video_factory, path): + """`count_file_groups` opens a cursor for the total (skipped when the total is cached), then + `handle_file_group_search_results` opens another cursor and a separate session for the page.""" + video_factory(title='a video', channel_id=simple_channel.id) + response, count = await count_request_connections( + async_client, test_session, 'post', path, json={'search_str': 'video'}) + assert response.status_code == HTTPStatus.OK, response.body + assert count == 1 + + +@OPENS_OWN_CONNECTION +@pytest.mark.asyncio +async def test_channel_uses_one_connection(async_client, test_session, simple_channel): + """`get_channel` uses the request session, but `Channel.get_statistics` opens a raw cursor of its own + (`get_db_curs`).""" + response, count = await count_request_connections( + async_client, test_session, 'get', f'/api/videos/channels/{simple_channel.id}') + assert response.status_code == HTTPStatus.OK, response.body + assert count == 1 From bed25b429d9164b3ac183c505582f455abd8eb5d Mon Sep 17 00:00:00 2001 From: lrnselfreliance <33263708+lrnselfreliance@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:05:20 -0600 Subject: [PATCH 072/181] db: request-path library functions use the request's connection (#774) * db: request-path library functions use the request's connection Library functions an API request reaches now take the request's Session (first argument) instead of opening their own session or cursor, so a request needs one SQLite connection. Raw SQL runs through the new `wrolpi.db.session_curs(session)`, a cursor on the Session's connection; `RequestDB.curs()` uses it. - Search: files, videos, archives, flasher, and docs search share the request connection for the total, the page of ids, and the page's models (3 connections -> 1). - Zim search, including tag-filtered search and estimates (2 -> 1). - Channel detail (`Channel.get_statistics` reads on the Channel's own Session) and the channel list. - Video detail, edit, description, captions, comments and transcode. `get_video_for_app` and `update_video` no longer commit; their handlers use `request.ctx.db.write()`, so editing a Video and reading it back is one transaction. - Tags (list, overlaps, recent), file statistics and estimates, other estimates, bulk tag preview, the download list, retry, kill and the status summary, and docs/videos/archive statistics. Not converted: file operations that refresh files inside the request (delete, rename, upload, channel and collection tag/move) commit page by page in code shared with the file worker; the cached zim entry lookup; config import. The connection-count tests now assert one connection for every endpoint they cover (no xfails left), and cover the videos and archive statistics endpoints, which had no API test. Co-Authored-By: Claude Opus 5.5 * downloads: startup retries in a write session of its own start_single_tasks still called retry_downloads() without a Session after it began requiring one, so any start after a completed refresh raised TypeError there: incomplete downloads were not reset and the download_on_startup enable after it never ran. Startup is not a request, so DownloadManager.retry_downloads_on_startup() opens and commits its own short write transaction. Tested through the method, not start_single_tasks: importing main registers its startup listeners on the shared app, and every later test in that worker then runs them. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- main.py | 2 +- modules/archive/api.py | 8 +- modules/archive/lib.py | 16 +-- modules/archive/test/test_lib.py | 6 +- modules/docs/api.py | 7 +- modules/docs/lib.py | 165 ++++++++++++------------ modules/docs/test/test_lib.py | 14 +- modules/flasher/api.py | 4 +- modules/flasher/lib.py | 6 +- modules/flasher/test/test_flasher.py | 8 +- modules/videos/api.py | 4 +- modules/videos/channel/api.py | 4 +- modules/videos/channel/lib.py | 6 +- modules/videos/lib.py | 6 +- modules/videos/models.py | 15 ++- modules/videos/test/test_api.py | 2 +- modules/videos/test/test_lib.py | 4 +- modules/videos/test/test_video_edit.py | 24 ++-- modules/videos/video/api.py | 30 +++-- modules/videos/video/lib.py | 80 ++++++------ modules/zim/lib.py | 12 +- modules/zim/models.py | 4 +- modules/zim/test/test_lib.py | 2 +- wrolpi/common.py | 16 +-- wrolpi/db.py | 36 ++++-- wrolpi/downloader.py | 44 ++++--- wrolpi/files/api.py | 9 +- wrolpi/files/lib.py | 169 ++++++++++++------------- wrolpi/files/test/test_lib.py | 48 +++---- wrolpi/root_api.py | 35 ++--- wrolpi/tags.py | 14 +- wrolpi/test/test_api_fixtures.py | 2 +- wrolpi/test/test_downloader.py | 32 ++++- wrolpi/test/test_request_db.py | 139 +++++++++++++++++--- wrolpi/test/test_tags.py | 16 +-- 35 files changed, 567 insertions(+), 422 deletions(-) diff --git a/main.py b/main.py index 92cd3506..5fdf94ea 100755 --- a/main.py +++ b/main.py @@ -309,7 +309,7 @@ async def start_single_tasks(app: Sanic): if flags.refresh_complete.is_set(): # Set all downloads to new. - download_manager.retry_downloads() + download_manager.retry_downloads_on_startup() # Hotspot/throttle on startup are now handled by the Controller service. diff --git a/modules/archive/api.py b/modules/archive/api.py index e4ad570a..ee4db91b 100644 --- a/modules/archive/api.py +++ b/modules/archive/api.py @@ -27,8 +27,8 @@ logger = logger.getChild(__name__) @archive_bp.get('/statistics') @openapi.response(HTTPStatus.OK, schema.ArchiveStatisticsResponse) -async def statistics(_: Request): - ret = await lib.get_statistics() +async def statistics(request: Request): + ret = await lib.get_statistics(request.ctx.session) return json_response(ret, HTTPStatus.OK) @@ -55,14 +55,14 @@ archive_limit_limiter = api_param_limiter(100) @openapi.response(HTTPStatus.OK, schema.ArchiveSearchResponse) @openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) @validate(schema.ArchiveSearchRequest) -async def search_archives(_: Request, body: schema.ArchiveSearchRequest): +async def search_archives(request: Request, body: schema.ArchiveSearchRequest): search_str = body.search_str domain = body.domain limit = archive_limit_limiter(body.limit) offset = body.offset or 0 file_groups, total = await asyncio.to_thread( - lib.search_archives, search_str, domain, limit, offset, body.order_by, body.tag_names, + lib.search_archives, request.ctx.session, search_str, domain, limit, offset, body.order_by, body.tag_names, body.headline, body.deep) ret = dict(file_groups=file_groups, totals=dict(file_groups=total)) return json_response(ret) diff --git a/modules/archive/lib.py b/modules/archive/lib.py index 1838db5b..0b5af39a 100644 --- a/modules/archive/lib.py +++ b/modules/archive/lib.py @@ -27,7 +27,7 @@ from wrolpi.common import get_media_directory, get_relative_to_media_directory, escape_file_name, aiohttp_post, format_html_string, split_lines_by_length, get_html_soup, get_title_from_html, \ get_wrolpi_config, html_screenshot, html_file_screenshot, ConfigFile, trim_file_name from wrolpi.dates import now, Seconds -from wrolpi.db import get_db_session, get_db_curs +from wrolpi.db import get_db_session, get_db_curs, session_curs from wrolpi.errors import UnknownArchive, InvalidOrderBy, InvalidDatetime from wrolpi.events import Events from wrolpi.files.lib import handle_file_group_search_results, cached_search_total, \ @@ -1390,8 +1390,8 @@ NO_NULL_ORDERS = { } -def search_archives(search_str: str, domain: str, limit: int, offset: int, order: str, tag_names: List[str], - headline: bool = False, deep: bool = False) \ +def search_archives(session: Session, search_str: str, domain: str, limit: int, offset: int, order: str, + tag_names: List[str], headline: bool = False, deep: bool = False) \ -> Tuple[List[dict], int]: # Always filter FileGroups to Archives. wheres = [] @@ -1479,7 +1479,7 @@ def search_archives(search_str: str, domain: str, limit: int, offset: int, order if unfiltered: total = cached_search_total( search_filter_cache_key('archives'), - lambda: count_file_groups('SELECT COUNT(*) AS total FROM archive', dict()), + lambda: count_file_groups(session, 'SELECT COUNT(*) AS total FROM archive', dict()), ) else: if file_group_search: @@ -1499,9 +1499,9 @@ def search_archives(search_str: str, domain: str, limit: int, offset: int, order 'archives', search_str=search_str, domain=domain, tag_names=tag_names, deep=deep, null_filter=null_filter, ) - total = cached_search_total(cache_key, lambda: count_file_groups(count_stmt, params)) + total = cached_search_total(cache_key, lambda: count_file_groups(session, count_stmt, params)) - results, total = handle_file_group_search_results(stmt, params, total=total) + results, total = handle_file_group_search_results(session, stmt, params, total=total) if file_group_search and headline and results: # Highlight the plain titles like the FTS snippets above (Postgres used ts_headline). @@ -1657,9 +1657,9 @@ async def singlefile_to_archive(singlefile: bytes, destination: pathlib.Path = N return archive -async def get_statistics(): +async def get_statistics(session: Session): """Get statistics about Archives and Domain Collections.""" - with get_db_curs() as curs: + with session_curs(session) as curs: curs.execute(''' SELECT -- total archives diff --git a/modules/archive/test/test_lib.py b/modules/archive/test/test_lib.py index 6d1fc59d..de5bad0d 100644 --- a/modules/archive/test/test_lib.py +++ b/modules/archive/test/test_lib.py @@ -1071,6 +1071,7 @@ def test_search_archives_by_domain(test_directory, test_session, archive_factory # Search for archives in searchtest.com file_groups, total = search_archives( + test_session, search_str=None, domain='searchtest.com', limit=10, @@ -1085,6 +1086,7 @@ def test_search_archives_by_domain(test_directory, test_session, archive_factory # Search for other.com file_groups, total = search_archives( + test_session, search_str=None, domain='other.com', limit=10, @@ -1375,7 +1377,7 @@ async def test_get_statistics(test_session, archive_factory): from modules.archive.lib import get_statistics # Can get statistics in empty DB. - result = await get_statistics() + result = await get_statistics(test_session) assert 'statistics' in result assert 'archives' in result['statistics'] assert 'domains' in result['statistics'] @@ -1393,7 +1395,7 @@ async def test_get_statistics(test_session, archive_factory): archive4 = archive_factory() # No domain test_session.commit() - result = await get_statistics() + result = await get_statistics(test_session) stats = result['statistics'] # Should count all 4 archives. diff --git a/modules/docs/api.py b/modules/docs/api.py index b8f2b212..3178e7ed 100644 --- a/modules/docs/api.py +++ b/modules/docs/api.py @@ -20,8 +20,8 @@ logger = logger.getChild(__name__) @docs_bp.get('/statistics') @openapi.response(HTTPStatus.OK, schema.DocStatisticsResponse) -async def statistics(_: Request): - ret = get_statistics() +async def statistics(request: Request): + ret = get_statistics(request.ctx.session) return json_response(ret, HTTPStatus.OK) @@ -44,11 +44,12 @@ doc_limit_limiter = api_param_limiter(100) ) @openapi.response(HTTPStatus.OK, schema.DocSearchResponse) @validate(schema.DocSearchRequest) -async def search_docs(_: Request, body: schema.DocSearchRequest): +async def search_docs(request: Request, body: schema.DocSearchRequest): limit = doc_limit_limiter(body.limit) offset = body.offset or 0 file_groups, total = await asyncio.to_thread( _search_docs, + request.ctx.session, search_str=body.search_str, author=body.author, subject=body.subject, diff --git a/modules/docs/lib.py b/modules/docs/lib.py index 42ed3fd5..72d51de6 100644 --- a/modules/docs/lib.py +++ b/modules/docs/lib.py @@ -10,7 +10,6 @@ from sqlalchemy.orm import Session from wrolpi import fts from wrolpi.collections.models import Collection from wrolpi.collections.types import collection_type_registry -from wrolpi.db import get_db_session from wrolpi.errors import ValidationError from wrolpi.files.lib import split_path_stem_and_suffix, get_mimetype, cached_search_total, \ search_filter_cache_key @@ -293,24 +292,23 @@ def discover_calibre_cover(ebook_path: pathlib.Path): return cover -def get_statistics() -> dict: +def get_statistics(session: Session) -> dict: """Get doc statistics.""" from .models import Doc from wrolpi.files.models import FileGroup - with get_db_session() as session: - total = session.query(func.count(Doc.id)).scalar() or 0 - total_size = session.query(func.sum(Doc.size)).scalar() or 0 + total = session.query(func.count(Doc.id)).scalar() or 0 + total_size = session.query(func.sum(Doc.size)).scalar() or 0 - epub_count = session.query(func.count(Doc.id)).join(FileGroup).filter( - FileGroup.mimetype.startswith('application/epub')).scalar() or 0 - pdf_count = session.query(func.count(Doc.id)).join(FileGroup).filter( - FileGroup.mimetype == 'application/pdf').scalar() or 0 + epub_count = session.query(func.count(Doc.id)).join(FileGroup).filter( + FileGroup.mimetype.startswith('application/epub')).scalar() or 0 + pdf_count = session.query(func.count(Doc.id)).join(FileGroup).filter( + FileGroup.mimetype == 'application/pdf').scalar() or 0 - author_count = session.query(func.count(Collection.id)).filter( - Collection.kind == 'author').scalar() or 0 - subject_count = session.query(func.count(Collection.id)).filter( - Collection.kind == 'subject').scalar() or 0 + author_count = session.query(func.count(Collection.id)).filter( + Collection.kind == 'author').scalar() or 0 + subject_count = session.query(func.count(Collection.id)).filter( + Collection.kind == 'subject').scalar() or 0 return dict(statistics=dict( doc_count=total, @@ -368,7 +366,7 @@ def _doc_browse_sql(order_by: str) -> Optional[str]: ORDER BY {order} LIMIT :limit OFFSET :offset''' -def _search_docs(search_str=None, author=None, subject=None, language=None, mimetype=None, +def _search_docs(session: Session, search_str=None, author=None, subject=None, language=None, mimetype=None, limit=20, offset=0, order_by='published_datetime', tag_names=None, deep=False): from .models import Doc from wrolpi.files.models import FileGroup @@ -377,86 +375,85 @@ def _search_docs(search_str=None, author=None, subject=None, language=None, mime # the much smaller a/b/c columns. match = fts.translate_websearch(search_str, None if deep else fts.ABC_COLUMNS) if search_str else None - with get_db_session() as session: - query = session.query(FileGroup).join(Doc, Doc.file_group_id == FileGroup.id) + query = session.query(FileGroup).join(Doc, Doc.file_group_id == FileGroup.id) - fts_sq = None - if match is not None: - fts_sq = text('SELECT rowid AS id, -rank AS ts_rank' - ' FROM file_group_fts WHERE file_group_fts MATCH :fts_match') \ - .bindparams(fts_match=match) \ - .columns(column('id', Integer), column('ts_rank', Float)) \ - .alias('fts') - query = query.join(fts_sq, fts_sq.c.id == FileGroup.id) + fts_sq = None + if match is not None: + fts_sq = text('SELECT rowid AS id, -rank AS ts_rank' + ' FROM file_group_fts WHERE file_group_fts MATCH :fts_match') \ + .bindparams(fts_match=match) \ + .columns(column('id', Integer), column('ts_rank', Float)) \ + .alias('fts') + query = query.join(fts_sq, fts_sq.c.id == FileGroup.id) - if author: - query = query.filter(FileGroup.b_text.ilike(f'%{author}%')) + if author: + query = query.filter(FileGroup.b_text.ilike(f'%{author}%')) - if subject: - query = query.filter(Doc.subject.ilike(f'%{subject}%')) + if subject: + query = query.filter(Doc.subject.ilike(f'%{subject}%')) - if language: - query = query.filter(Doc.language == language) + if language: + query = query.filter(Doc.language == language) - if mimetype: - query = query.filter(FileGroup.mimetype.startswith(mimetype)) + if mimetype: + query = query.filter(FileGroup.mimetype.startswith(mimetype)) - if tag_names: - from wrolpi.tags import Tag, TagFile - tagged_fg_ids = session.query(TagFile.file_group_id) \ - .join(Tag, Tag.id == TagFile.tag_id) \ - .filter(Tag.name.in_(tag_names)) \ - .subquery() - query = query.filter(FileGroup.id.in_(tagged_fg_ids)) + if tag_names: + from wrolpi.tags import Tag, TagFile + tagged_fg_ids = session.query(TagFile.file_group_id) \ + .join(Tag, Tag.id == TagFile.tag_id) \ + .filter(Tag.name.in_(tag_names)) \ + .subquery() + query = query.filter(FileGroup.id.in_(tagged_fg_ids)) - unfiltered = not (search_str or author or subject or language or mimetype or tag_names) - if unfiltered: - total = cached_search_total( - search_filter_cache_key('docs'), - lambda: session.query(func.count(Doc.id)).scalar() or 0, - ) - else: - cache_key = search_filter_cache_key( - 'docs', search_str=search_str, author=author, subject=subject, - language=language, mimetype=mimetype, tag_names=tag_names, deep=deep, - ) - # Count before ORDER BY / LIMIT; cache by filters not page. - total = cached_search_total(cache_key, lambda q=query: q.count()) + unfiltered = not (search_str or author or subject or language or mimetype or tag_names) + if unfiltered: + total = cached_search_total( + search_filter_cache_key('docs'), + lambda: session.query(func.count(Doc.id)).scalar() or 0, + ) + else: + cache_key = search_filter_cache_key( + 'docs', search_str=search_str, author=author, subject=subject, + language=language, mimetype=mimetype, tag_names=tag_names, deep=deep, + ) + # Count before ORDER BY / LIMIT; cache by filters not page. + total = cached_search_total(cache_key, lambda q=query: q.count()) - # Ordering. - if order_by == 'rank' and fts_sq is not None: - query = query.order_by( - desc(fts_sq.c.ts_rank), - desc(FileGroup.id), - ) - elif order_by == 'published_datetime': - query = query.order_by(nullslast(desc(FileGroup.published_datetime)), desc(FileGroup.id)) - elif order_by == 'size': - query = query.order_by(nullslast(desc(Doc.size))) - elif order_by == 'title': - query = query.order_by(nullslast(asc(FileGroup.a_text))) - else: - query = query.order_by(desc(FileGroup.id)) + # Ordering. + if order_by == 'rank' and fts_sq is not None: + query = query.order_by( + desc(fts_sq.c.ts_rank), + desc(FileGroup.id), + ) + elif order_by == 'published_datetime': + query = query.order_by(nullslast(desc(FileGroup.published_datetime)), desc(FileGroup.id)) + elif order_by == 'size': + query = query.order_by(nullslast(desc(Doc.size))) + elif order_by == 'title': + query = query.order_by(nullslast(asc(FileGroup.a_text))) + else: + query = query.order_by(desc(FileGroup.id)) - # `size` sorts on a doc column and `title` has no file_group index, so neither may fall back to `id`. - browse_sql = None - if unfiltered and order_by not in ('size', 'title'): - browse_sql = _doc_browse_sql(order_by if order_by in DOC_BROWSE_ORDERS else 'id') - if browse_sql: - fg_ids = [i for i, in session.execute(text(browse_sql), dict(limit=limit, offset=offset)).fetchall()] - by_id = {fg.id: fg for fg in session.query(FileGroup).filter(FileGroup.id.in_(fg_ids))} if fg_ids else {} - file_groups = [by_id[i] for i in fg_ids] - else: - file_groups = query.offset(offset).limit(limit).all() - fg_ids = [fg.id for fg in file_groups] - results = [fg.__json__() for fg in file_groups] + # `size` sorts on a doc column and `title` has no file_group index, so neither may fall back to `id`. + browse_sql = None + if unfiltered and order_by not in ('size', 'title'): + browse_sql = _doc_browse_sql(order_by if order_by in DOC_BROWSE_ORDERS else 'id') + if browse_sql: + fg_ids = [i for i, in session.execute(text(browse_sql), dict(limit=limit, offset=offset)).fetchall()] + by_id = {fg.id: fg for fg in session.query(FileGroup).filter(FileGroup.id.in_(fg_ids))} if fg_ids else {} + file_groups = [by_id[i] for i in fg_ids] + else: + file_groups = query.offset(offset).limit(limit).all() + fg_ids = [fg.id for fg in file_groups] + results = [fg.__json__() for fg in file_groups] - if search_str and fg_ids: - hints = _fetch_section_hints(session, fg_ids, search_str) - for r in results: - hint = hints.get(r['id']) - if hint: - r['section_hint'] = hint + if search_str and fg_ids: + hints = _fetch_section_hints(session, fg_ids, search_str) + for r in results: + hint = hints.get(r['id']) + if hint: + r['section_hint'] = hint return results, total diff --git a/modules/docs/test/test_lib.py b/modules/docs/test/test_lib.py index 56df294b..f83bd1cf 100644 --- a/modules/docs/test/test_lib.py +++ b/modules/docs/test/test_lib.py @@ -293,10 +293,10 @@ async def test_search_docs_attaches_section_hint_epub(test_session, test_directo ]) # "mullen" is only in the document body (d_text), so a deep search is required to match it. - results, total = _search_docs(search_str='mullen', mimetype='application/epub') + results, total = _search_docs(test_session, search_str='mullen', mimetype='application/epub') assert total == 0, 'Document contents are not searched unless deep=True' - results, total = _search_docs(search_str='mullen', mimetype='application/epub', deep=True) + results, total = _search_docs(test_session, search_str='mullen', mimetype='application/epub', deep=True) assert total == 1 assert results[0]['id'] == fg.id hint = results[0]['section_hint'] @@ -318,7 +318,7 @@ async def test_search_docs_attaches_section_hint_pdf(test_session, test_director (3, 'Page 3', 'here is where mullen shows up'), ]) - results, total = _search_docs(search_str='mullen', mimetype='application/pdf', deep=True) + results, total = _search_docs(test_session, search_str='mullen', mimetype='application/pdf', deep=True) assert total == 1 assert results[0]['id'] == fg.id hint = results[0]['section_hint'] @@ -334,7 +334,7 @@ async def test_search_docs_no_hint_when_no_query(test_session, test_directory): 'gamma.pdf', 'application/pdf', [ (1, 'Page 1', 'whatever'), ]) - results, _ = _search_docs(mimetype='application/pdf') + results, _ = _search_docs(test_session, mimetype='application/pdf') assert results and results[0]['id'] == fg.id assert 'section_hint' not in results[0] @@ -360,15 +360,15 @@ async def test_search_docs_browse_walks_file_group_date_index(test_session, test assert 'TEMP B-TREE' not in plan, plan # Newest first, NULL published dates last, same as the filtered ORM path. - results, total = _search_docs() + results, total = _search_docs(test_session, ) assert total == 3 assert [i['id'] for i in results] == [docs[2].file_group_id, docs[1].file_group_id, docs[0].file_group_id] # Paging still works on the fast path. - results, total = _search_docs(limit=1, offset=1) + results, total = _search_docs(test_session, limit=1, offset=1) assert total == 3 assert [i['id'] for i in results] == [docs[1].file_group_id] # Filtered searches are unaffected. - results, total = _search_docs(mimetype='application/pdf') + results, total = _search_docs(test_session, mimetype='application/pdf') assert total == 3 diff --git a/modules/flasher/api.py b/modules/flasher/api.py index 8209cc24..10304a09 100644 --- a/modules/flasher/api.py +++ b/modules/flasher/api.py @@ -19,10 +19,10 @@ flasher_bp = Blueprint('Flasher', '/api/flasher') body=schema.FlasherSearchRequest, ) @validate(schema.FlasherSearchRequest) -async def post_flasher_search(_: Request, body: schema.FlasherSearchRequest): +async def post_flasher_search(request: Request, body: schema.FlasherSearchRequest): # Reading many firmware headers touches the disk; run off the event loop. file_groups, total = await asyncio.to_thread( - lib.search_esp_firmware, body.chip, body.path, body.limit) + lib.search_esp_firmware, request.ctx.session, body.chip, body.path, body.limit) return json_response(dict(file_groups=file_groups, totals=dict(file_groups=total)), HTTPStatus.OK) diff --git a/modules/flasher/lib.py b/modules/flasher/lib.py index 1c71bb1b..405f435d 100644 --- a/modules/flasher/lib.py +++ b/modules/flasher/lib.py @@ -8,6 +8,8 @@ import functools import pathlib from typing import List, Optional, Tuple +from sqlalchemy.orm import Session + from wrolpi.common import get_media_directory, logger logger = logger.getChild(__name__) @@ -100,7 +102,7 @@ def _resolve_media_path(primary_path) -> pathlib.Path: return path if path.is_absolute() else get_media_directory() / path -def search_esp_firmware(chip: Optional[str] = None, path: Optional[str] = None, +def search_esp_firmware(session: Session, chip: Optional[str] = None, path: Optional[str] = None, limit: int = 1000) -> Tuple[List[dict], int]: """Search for ``.bin`` firmware, annotated with each file's detected chip/kind. @@ -114,7 +116,7 @@ def search_esp_firmware(chip: Optional[str] = None, path: Optional[str] = None, """ from wrolpi.files.lib import search_files - file_groups, _ = search_files(None, limit, 0, suffix='.bin', path=path) + file_groups, _ = search_files(session, None, limit, 0, suffix='.bin', path=path) results = [] for file_group in file_groups: diff --git a/modules/flasher/test/test_flasher.py b/modules/flasher/test/test_flasher.py index 5f601a3c..b70f2356 100644 --- a/modules/flasher/test/test_flasher.py +++ b/modules/flasher/test/test_flasher.py @@ -59,20 +59,20 @@ async def test_search_esp_firmware_filters_by_chip(async_client, test_session, m return sorted(pathlib.Path(r['primary_path']).name for r in results) # Filter to ESP32-S3: only the two S3 images, never the non-ESP file. - s3, total = search_esp_firmware(chip='ESP32-S3') + s3, total = search_esp_firmware(test_session, chip='ESP32-S3') assert total == 2 assert names(s3) == ['s3-app.bin', 's3-factory.bin'] assert {r['esp_chip'] for r in s3} == {'ESP32-S3'} assert {r['esp_kind'] for r in s3} == {'app', 'factory'} # Filter to ESP32-S2: just the one. - s2, total = search_esp_firmware(chip='ESP32-S2') + s2, total = search_esp_firmware(test_session, chip='ESP32-S2') assert total == 1 assert names(s2) == ['s2-app.bin'] # No chip: every .bin, including non-ESP parts (partition tables, littlefs, boot_app0) annotated with a null # chip so a full flash set can still be assembled from the picker. - all_bin, total = search_esp_firmware() + all_bin, total = search_esp_firmware(test_session) assert total == 5 assert 'littlefs.bin' in names(all_bin) littlefs = next(r for r in all_bin if pathlib.Path(r['primary_path']).name == 'littlefs.bin') @@ -80,7 +80,7 @@ async def test_search_esp_firmware_filters_by_chip(async_client, test_session, m assert littlefs['esp_kind'] == 'not_esp_image' # Filtering to a specific chip still returns only that chip's ESP images (non-ESP parts are shown unfiltered). - _, total = search_esp_firmware(chip='ESP32-H2') + _, total = search_esp_firmware(test_session, chip='ESP32-H2') assert total == 0 diff --git a/modules/videos/api.py b/modules/videos/api.py index 8e5ca667..d31ea5d4 100644 --- a/modules/videos/api.py +++ b/modules/videos/api.py @@ -27,8 +27,8 @@ logger = logger.getChild(__name__) @content_bp.get('/statistics') @openapi.response(HTTPStatus.OK, schema.VideosStatisticsResponse) -async def statistics(_: Request): - ret = await lib.get_statistics() +async def statistics(request: Request): + ret = await lib.get_statistics(request.ctx.session) return json_response(ret, HTTPStatus.OK) diff --git a/modules/videos/channel/api.py b/modules/videos/channel/api.py index 5169132b..77efa479 100644 --- a/modules/videos/channel/api.py +++ b/modules/videos/channel/api.py @@ -26,8 +26,8 @@ logger = logger.getChild(__name__) @channel_bp.get('/') @openapi.description('Get a list of all Channels') @openapi.response(HTTPStatus.OK, schema.ChannelsResponse) -async def get_channels(_: Request): - channels = await lib.get_minimal_channels() +async def get_channels(request: Request): + channels = await lib.get_minimal_channels(request.ctx.session) return json_response({'channels': channels}) diff --git a/modules/videos/channel/lib.py b/modules/videos/channel/lib.py index 738d64f5..86f48db1 100644 --- a/modules/videos/channel/lib.py +++ b/modules/videos/channel/lib.py @@ -9,7 +9,7 @@ from wrolpi import flags from wrolpi.collections import Collection from wrolpi.common import logger, \ get_media_directory, wrol_mode_check, background_task -from wrolpi.db import get_db_curs, get_db_session +from wrolpi.db import get_db_session, session_curs from wrolpi.downloader import save_downloads_config, download_manager, Download from wrolpi.errors import APIError, ValidationError, FileWorkerConflict from wrolpi.events import Events @@ -23,11 +23,11 @@ from ..models import Channel, Video logger = logger.getChild(__name__) -async def get_minimal_channels() -> List[dict]: +async def get_minimal_channels(session: Session) -> List[dict]: """ Get the minimum amount of information necessary about all channels. """ - with get_db_curs() as curs: + with session_curs(session) as curs: # Get all channels, even if they don't have videos. Also get the minimum frequency download because this is the # one that will consume the most resources. stmt = ''' diff --git a/modules/videos/lib.py b/modules/videos/lib.py index 072d1e5e..6d34bd91 100644 --- a/modules/videos/lib.py +++ b/modules/videos/lib.py @@ -19,7 +19,7 @@ from wrolpi.captions import extract_captions from wrolpi.common import ConfigFile, extract_domain, logger, \ escape_file_name, get_media_directory, background_task, Base, get_wrolpi_config, trim_file_name from wrolpi.dates import Seconds, from_timestamp -from wrolpi.db import get_db_curs, get_db_session +from wrolpi.db import get_db_curs, get_db_session, session_curs from wrolpi.downloader import Download, download_manager from wrolpi.errors import UnknownDirectory from wrolpi.events import Events @@ -1029,8 +1029,8 @@ def get_channel_source_id(url: str) -> str: return channel_info.get('channel_id') or channel_info['uploader_id'] -async def get_statistics(): - with get_db_curs() as curs: +async def get_statistics(session: Session): + with session_curs(session) as curs: curs.execute(''' SELECT -- total videos diff --git a/modules/videos/models.py b/modules/videos/models.py index 33b4012a..3e3d87ee 100644 --- a/modules/videos/models.py +++ b/modules/videos/models.py @@ -10,7 +10,7 @@ from modules.videos.errors import UnknownVideo, UnknownChannel from wrolpi.captions import read_captions, read_captions_with_timestamps from wrolpi.common import Base, ModelHelper, logger, get_media_directory, get_relative_to_media_directory, \ background_task -from wrolpi.db import get_db_curs, get_db_session +from wrolpi.db import get_db_curs, get_db_session, session_curs from wrolpi.downloader import Download from wrolpi.files.lib import split_path_stem_and_suffix from wrolpi.files.worker import file_worker @@ -177,7 +177,7 @@ class Video(ModelHelper, Base): """ session = Session.object_session(self) - with get_db_curs() as curs: + with session_curs(session) as curs: if self.file_group.published_datetime: # Get videos next to this Video's upload date. stmt = ''' @@ -964,9 +964,14 @@ class Channel(ModelHelper, Base): d['downloads'] = self.downloads return d - def get_statistics(self): - """Get statistics about this channel.""" - with get_db_curs() as curs: + def get_statistics(self) -> dict: + """Get statistics about this channel. + + Read on the Channel's own Session, so during a request this uses the request's connection.""" + session = Session.object_session(self) + if session is None: + raise RuntimeError(f'{self} is not in a session!') + with session_curs(session) as curs: stmt = ''' SELECT SUM(size) AS "size", MAX(size) AS "largest_video", diff --git a/modules/videos/test/test_api.py b/modules/videos/test/test_api.py index 5f74702d..9fe82716 100644 --- a/modules/videos/test/test_api.py +++ b/modules/videos/test/test_api.py @@ -680,7 +680,7 @@ async def test_search_videos_default_page_pagination(test_session, video_factory # The fast path must return exactly what the generic query returns for the same request: # same rows, same order (NULL placement, id tiebreaker, interleaved mimetypes), same paging. def ids(order, limit, offset): - results, total = lib.search_videos(order=order, limit=limit, offset=offset) + results, total = lib.search_videos(test_session, order=order, limit=limit, offset=offset) return [i['id'] for i in results], total for order in lib.INDEXED_DATE_ORDERS: diff --git a/modules/videos/test/test_lib.py b/modules/videos/test/test_lib.py index 27765f41..2fd3bd6c 100644 --- a/modules/videos/test/test_lib.py +++ b/modules/videos/test/test_lib.py @@ -167,7 +167,7 @@ def test_validate_video_directory_beats_info_json_channel(test_session, test_dir @pytest.mark.asyncio async def test_get_statistics(test_session, video_factory, channel_factory): # Can get statistics in empty DB. - await get_statistics() + await get_statistics(test_session) channel1 = channel_factory() channel2 = channel_factory() @@ -176,7 +176,7 @@ async def test_get_statistics(test_session, video_factory, channel_factory): video_factory(channel_id=channel2.id) video_factory() - result = await get_statistics() + result = await get_statistics(test_session) assert 'statistics' in result assert 'videos' in result['statistics'] assert 'channels' in result['statistics'] diff --git a/modules/videos/test/test_video_edit.py b/modules/videos/test/test_video_edit.py index 095b7db7..f8012ec9 100644 --- a/modules/videos/test/test_video_edit.py +++ b/modules/videos/test/test_video_edit.py @@ -20,7 +20,8 @@ def test_update_video_title(test_session, test_directory, video_factory): test_session.commit() assert video.file_group.title == 'Old Title' - update_video(video.file_group_id, ' New Title ') + update_video(test_session, video.file_group_id, ' New Title ') + test_session.commit() test_session.expire_all() video = Video.find_by_file_group_id(test_session, video.file_group_id) @@ -39,7 +40,8 @@ def test_update_video_title_creates_info_json(test_session, test_directory, vide test_session.commit() assert video.info_json_path is None - update_video(video.file_group_id, 'Brand New') + update_video(test_session, video.file_group_id, 'Brand New') + test_session.commit() test_session.expire_all() video = Video.find_by_file_group_id(test_session, video.file_group_id) @@ -57,7 +59,8 @@ def test_update_video_description(test_session, test_directory, video_factory): test_session.commit() assert video.file_group.c_text == 'old words' - update_video(video.file_group_id, description='new words\nsecond line') + update_video(test_session, video.file_group_id, description='new words\nsecond line') + test_session.commit() test_session.expire_all() video = Video.find_by_file_group_id(test_session, video.file_group_id) @@ -68,7 +71,8 @@ def test_update_video_description(test_session, test_directory, video_factory): assert info_json['description'] == 'old words', 'The original is kept' assert info_json['wrolpi']['custom_description'] == 'new words\nsecond line' - update_video(video.file_group_id, description='') + update_video(test_session, video.file_group_id, description='') + test_session.commit() test_session.expire_all() video = Video.find_by_file_group_id(test_session, video.file_group_id) assert video.get_description() is None @@ -80,14 +84,14 @@ def test_update_video_nothing(test_session, video_factory): video = video_factory(with_info_json={'title': 'Old'}) test_session.commit() with pytest.raises(ValidationError): - update_video(video.file_group_id) + update_video(test_session, video.file_group_id) def test_update_video_title_empty(test_session, video_factory): video = video_factory(with_info_json={'title': 'Old'}) test_session.commit() with pytest.raises(ValidationError): - update_video(video.file_group_id, ' ') + update_video(test_session, video.file_group_id, ' ') test_session.expire_all() assert Video.find_by_file_group_id(test_session, video.file_group_id).file_group.title == 'Old' @@ -108,7 +112,8 @@ async def test_custom_title_survives_refresh(test_session, test_directory, async video = video_factory(with_info_json={'title': 'Original'}) test_session.commit() - update_video(video.file_group_id, title='Custom', description='custom words') + update_video(test_session, video.file_group_id, title='Custom', description='custom words') + test_session.commit() video_path = video.video_path file_group_id = video.file_group_id @@ -174,7 +179,7 @@ async def test_update_video_wrol_mode(test_session, video_factory, wrol_mode_fix await wrol_mode_fixture(True) try: with pytest.raises(WROLModeEnabled): - update_video(video.file_group_id, title='Nope') + update_video(test_session, video.file_group_id, title='Nope') finally: await wrol_mode_fixture(False) @@ -194,7 +199,8 @@ async def test_edit_creates_info_json_that_survives_refresh(test_session, test_d video_path = video.video_path file_group_id = video.file_group_id - update_video(file_group_id, title='Custom', description='my words') + update_video(test_session, file_group_id, title='Custom', description='my words') + test_session.commit() test_session.expire_all() video = Video.find_by_file_group_id(test_session, file_group_id) assert video.info_json_path and video.info_json_path.is_file() diff --git a/modules/videos/video/api.py b/modules/videos/video/api.py index 3f27ad65..06aecf60 100644 --- a/modules/videos/video/api.py +++ b/modules/videos/video/api.py @@ -25,7 +25,9 @@ logger = logger.getChild(__name__) @openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) def video_get(request: Request, file_group_id: int): skip_viewed = request.args.get('skip_viewed', '').lower() == 'true' - video, previous_video, next_video = lib.get_video_for_app(file_group_id, skip_viewed=skip_viewed) + # Marking the Video viewed is a write. + with request.ctx.db.write() as session: + video, previous_video, next_video = lib.get_video_for_app(session, file_group_id, skip_viewed=skip_viewed) return json_response({'file_group': video, 'prev': previous_video, 'next': next_video}) @@ -37,9 +39,10 @@ def video_get(request: Request, file_group_id: int): @openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) @validate(schema.VideoUpdateRequest) @wrol_mode_check -async def video_update(_: Request, file_group_id: int, body: schema.VideoUpdateRequest): - lib.update_video(file_group_id, title=body.title, description=body.description) - video, previous_video, next_video = lib.get_video_for_app(file_group_id) +async def video_update(request: Request, file_group_id: int, body: schema.VideoUpdateRequest): + with request.ctx.db.write() as session: + lib.update_video(session, file_group_id, title=body.title, description=body.description) + video, previous_video, next_video = lib.get_video_for_app(session, file_group_id) return json_response({'file_group': video, 'prev': previous_video, 'next': next_video}) @@ -47,8 +50,8 @@ async def video_update(_: Request, file_group_id: int, body: schema.VideoUpdateR @openapi.description('Get Video comments') @openapi.response(HTTPStatus.OK, schema.VideoCommentsResponse) @openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) -def video_get_comments(_: Request, file_group_id: int): - video = lib.get_video(file_group_id) +def video_get_comments(request: Request, file_group_id: int): + video = lib.get_video(request.ctx.session, file_group_id) return json_response({'comments': video.get_comments()}) @@ -56,8 +59,8 @@ def video_get_comments(_: Request, file_group_id: int): @openapi.description('Get Video description') @openapi.response(HTTPStatus.OK, schema.VideoDescriptionResponse) @openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) -def video_get_description(_: Request, file_group_id: int): - video = lib.get_video(file_group_id) +def video_get_description(request: Request, file_group_id: int): + video = lib.get_video(request.ctx.session, file_group_id) return json_response({'description': video.get_description()}) @@ -65,8 +68,8 @@ def video_get_description(_: Request, file_group_id: int): @openapi.description('Get Video captions') @openapi.response(HTTPStatus.OK, schema.VideoCaptionsResponse) @openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) -def video_get_captions(_: Request, file_group_id: int): - video = lib.get_video(file_group_id) +def video_get_captions(request: Request, file_group_id: int): + video = lib.get_video(request.ctx.session, file_group_id) return json_response({'captions': video.get_caption_chunks()}) @@ -78,13 +81,13 @@ def video_get_captions(_: Request, file_group_id: int): @openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) @validate(schema.VideoTranscodeRequest) @wrol_mode_check -async def video_transcode(_: Request, file_group_id: int, body: schema.VideoTranscodeRequest): +async def video_transcode(request: Request, file_group_id: int, body: schema.VideoTranscodeRequest): try: validate_transcode_request(body.video_codec, body.audio_codec, body.container, body.fragmented) except ValueError as e: raise InvalidJob(str(e)) # Raises UnknownVideo (404) before anything is queued. - video = lib.get_video(file_group_id) + video = lib.get_video(request.ctx.session, file_group_id) is_audio = (video.file_group.mimetype or '').startswith('audio/') if is_audio and body.video_codec and body.video_codec != REMOVE_VIDEO: raise InvalidJob(f'This is an audio file; it has no video stream to transcode to {body.video_codec}') @@ -117,13 +120,14 @@ async def video_transcode(_: Request, file_group_id: int, body: schema.VideoTran @openapi.response(HTTPStatus.OK, schema.VideoSearchResponse) @openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) @validate(schema.VideoSearchRequest) -async def search_videos(_: Request, body: schema.VideoSearchRequest): +async def search_videos(request: Request, body: schema.VideoSearchRequest): if body.order_by not in lib.VIDEO_ORDERS: raise InvalidOrderBy('Invalid order by') # Synchronous SQLite query; keep it off the event loop. file_groups, videos_total = await asyncio.to_thread( lib.search_videos, + request.ctx.session, body.search_str, body.offset, body.limit, diff --git a/modules/videos/video/lib.py b/modules/videos/video/lib.py index dfad5d54..e31f4fbe 100644 --- a/modules/videos/video/lib.py +++ b/modules/videos/video/lib.py @@ -11,7 +11,7 @@ from modules.videos.models import Video, Channel from wrolpi import fts from wrolpi.common import logger, limit_concurrent, wrol_mode_check from wrolpi.dates import now -from wrolpi.db import get_db_session, get_db_curs +from wrolpi.db import get_db_session, session_curs from wrolpi.downloader import download_manager from wrolpi.files.lib import handle_file_group_search_results, cached_search_total, \ search_filter_cache_key, count_file_groups @@ -24,34 +24,32 @@ from ..lib import get_yt_dlp_http_headers, get_yt_dlp_sleep_opts logger.getChild(__name__) -def get_video_for_app(file_group_id: int, skip_viewed: bool = False) -> Tuple[dict, Optional[dict], Optional[dict]]: +def get_video_for_app(session: Session, file_group_id: int, skip_viewed: bool = False) \ + -> Tuple[dict, Optional[dict], Optional[dict]]: """ - Get a Video by its FileGroup ID, with its prev/next videos. Mark the Video as viewed. + Get a Video by its FileGroup ID, with its prev/next videos. Mark the Video as viewed (a write; the + caller commits). """ - with get_db_session(commit=True) as session: - video = Video.find_by_file_group_id(session, file_group_id) - if not skip_viewed: - video.file_group.set_viewed() - previous_video, next_video = video.get_surrounding_videos() - - video = video.__json__() - previous_video = previous_video.__json__() if previous_video and previous_video.file_group else None - next_video = next_video.__json__() if next_video and next_video.file_group else None + video = Video.find_by_file_group_id(session, file_group_id) + if not skip_viewed: + video.file_group.set_viewed() + previous_video, next_video = video.get_surrounding_videos() + video = video.__json__() + previous_video = previous_video.__json__() if previous_video and previous_video.file_group else None + next_video = next_video.__json__() if next_video and next_video.file_group else None return video, previous_video, next_video -def get_video(file_group_id: int) -> Video: +def get_video(session: Session, file_group_id: int) -> Video: """ Get a Video by its FileGroup ID. """ - with get_db_session() as session: - video = Video.find_by_file_group_id(session, file_group_id) - return video + return Video.find_by_file_group_id(session, file_group_id) @wrol_mode_check -def update_video(file_group_id: int, title: str = None, description: str = None) -> Video: +def update_video(session: Session, file_group_id: int, title: str = None, description: str = None) -> Video: """Edit a Video's details. The files are the source of truth, so the change is written to the Video's .info.json (created if missing) and the Video is validated again from it. Only the fields given (not None) are changed; an empty description clears it. @@ -60,6 +58,8 @@ def update_video(file_group_id: int, title: str = None, description: str = None) over yt-dlp's own keys: the original title and description stay in the file, and a metadata re-download carries the section over. + The caller commits. + @raise UnknownVideo: if the Video can not be found @raise ValidationError: when nothing is given, or the title is empty """ @@ -77,17 +77,16 @@ def update_video(file_group_id: int, title: str = None, description: str = None) if description is not None: changes['custom_description'] = description - with get_db_session(commit=True) as session: - video = Video.find_by_file_group_id(session, file_group_id) - if video.info_json_path is None or not video.info_json_path.is_file(): - # No info json yet: create one holding only the wrolpi section. - video.replace_info_json({'wrolpi': changes}, clean=False) - else: - video.file_group.update_wrolpi_json(changes) - # Re-derive the title (and search text) from the file just written. - video.validate(session) - video.flush() - return video + video = Video.find_by_file_group_id(session, file_group_id) + if video.info_json_path is None or not video.info_json_path.is_file(): + # No info json yet: create one holding only the wrolpi section. + video.replace_info_json({'wrolpi': changes}, clean=False) + else: + video.file_group.update_wrolpi_json(changes) + # Re-derive the title (and search text) from the file just written. + video.validate(session) + video.flush() + return video VIDEO_ORDERS = { @@ -132,10 +131,10 @@ INDEXED_DATE_ORDERS = ('published_datetime', '-published_datetime') VIDEO_MIMETYPE_PREFIXES = ('video/', 'audio/') -def _video_mimetypes() -> List[str]: +def _video_mimetypes(session: Session) -> List[str]: """Every distinct video/* and audio/* mimetype in the library (a handful; served by the mimetype index).""" mimetypes = [] - with get_db_curs() as curs: + with session_curs(session) as curs: for prefix in VIDEO_MIMETYPE_PREFIXES: # '0' is the character after '/', so this is the half-open range of the prefix. curs.execute('SELECT DISTINCT mimetype FROM file_group WHERE mimetype >= ? AND mimetype < ?', @@ -144,17 +143,17 @@ def _video_mimetypes() -> List[str]: return mimetypes -def _count_videos() -> int: +def _count_videos(session: Session) -> int: ranges = ' OR '.join(f'(mimetype >= :p{i} AND mimetype < :e{i})' for i in range(len(VIDEO_MIMETYPE_PREFIXES))) params = dict() for i, prefix in enumerate(VIDEO_MIMETYPE_PREFIXES): params[f'p{i}'], params[f'e{i}'] = prefix, prefix[:-1] + '0' - with get_db_curs() as curs: + with session_curs(session) as curs: curs.execute(f'SELECT COUNT(*) FROM file_group WHERE {ranges}', params) return curs.fetchone()[0] -def _search_videos_by_date(order: str, limit: int, offset: int) -> Tuple[List[dict], int]: +def _search_videos_by_date(session: Session, order: str, limit: int, offset: int) -> Tuple[List[dict], int]: """The unfiltered Videos page, ordered by date, without visiting every video. The generic query (`mimetype LIKE 'video/%' OR ...`) plus a windowed COUNT used to visit every @@ -163,7 +162,7 @@ def _search_videos_by_date(order: str, limit: int, offset: int) -> Tuple[List[di then merged. Results must be identical to the generic query's (same ORDER BY, including the id tiebreaker and NULL placement). The total is a separate, cached count.""" order_by = VIDEO_ORDERS[order] - mimetypes = _video_mimetypes() + mimetypes = _video_mimetypes(session) if not mimetypes: return [], 0 @@ -183,11 +182,12 @@ def _search_videos_by_date(order: str, limit: int, offset: int) -> Tuple[List[di '''.strip() logger.debug(f'{stmt} {params}') - total = cached_search_total(search_filter_cache_key('videos'), _count_videos) - return handle_file_group_search_results(stmt, params, total=total) + total = cached_search_total(search_filter_cache_key('videos'), lambda: _count_videos(session)) + return handle_file_group_search_results(session, stmt, params, total=total) def search_videos( + session: Session, search_str: str = None, offset: int = None, limit: int = VIDEO_QUERY_LIMIT, @@ -212,7 +212,7 @@ def search_videos( offset = int(offset) if offset else 0 if not search_str and not tag_names and not channel_id and not censored and order in INDEXED_DATE_ORDERS: # The Videos page with no filters, in date order. - return _search_videos_by_date(order, limit, offset) + return _search_videos_by_date(session, order, limit, offset) params = dict(search_str=search_str, offset=offset) if channel_id: @@ -280,7 +280,7 @@ def search_videos( unfiltered = (not search_str and not tag_names and not channel_id and not censored and not null_filter) if unfiltered: - total = cached_search_total(search_filter_cache_key('videos'), _count_videos) + total = cached_search_total(search_filter_cache_key('videos'), lambda: _count_videos(session)) else: if fts_search: fts_count = fts.file_group_search(search_str, deep=deep) @@ -303,9 +303,9 @@ def search_videos( 'videos', search_str=search_str, channel_id=channel_id, tag_names=tag_names, censored=censored, deep=deep, null_filter=null_filter, ) - total = cached_search_total(cache_key, lambda: count_file_groups(count_stmt, count_params)) + total = cached_search_total(cache_key, lambda: count_file_groups(session, count_stmt, count_params)) - results, total = handle_file_group_search_results(stmt, params, total=total) + results, total = handle_file_group_search_results(session, stmt, params, total=total) return results, total diff --git a/modules/zim/lib.py b/modules/zim/lib.py index 667d5359..30365e1c 100644 --- a/modules/zim/lib.py +++ b/modules/zim/lib.py @@ -22,7 +22,7 @@ from wrolpi.common import register_modeler, logger, extract_html_text, extract_h from wrolpi.switches import register_switch_handler from wrolpi.dates import Seconds from wrolpi.events import Events -from wrolpi.db import get_db_session, get_db_curs +from wrolpi.db import get_db_session, session_curs from wrolpi.downloader import DownloadFrequency from wrolpi.files.lib import split_file_name_words from wrolpi.files.modeler import run_modeler_loop, skip_clause @@ -112,7 +112,7 @@ async def zim_modeler(progress_callback: Callable[[int], None] = None): ) -def get_all_entries_tags(): +def get_all_entries_tags(session: Session): """Returns a dict of dicts which contain the names of all Tags of each Entry's path. Example: @@ -130,7 +130,7 @@ def get_all_entries_tags(): LEFT JOIN tag_zim tz on t.id = tz.tag_id GROUP BY 1, 2 \ ''' - with get_db_curs() as curs: + with session_curs(session) as curs: curs.execute(stmt) entries = dict() for zim_id, zim_entry, tag_names in curs.fetchall(): @@ -144,8 +144,8 @@ def get_all_entries_tags(): return entries -def get_entries_tags(paths: List[str], zim_id: int): - all_entries_tags = get_all_entries_tags() +def get_entries_tags(session: Session, paths: List[str], zim_id: int): + all_entries_tags = get_all_entries_tags(session) entries = dict() for path in paths: zim_tag_entries = all_entries_tags.get(zim_id, dict()) @@ -187,7 +187,7 @@ def headline_zim(session: Session, search_str: str, zim_id: int, tag_names: List articles = [bytes(i.get_item().content).decode('UTF-8') for i in entries] articles = [extract_html_text(i) for i in articles] - entries_tag_names = get_entries_tags([i.path for i in entries], zim_id) + entries_tag_names = get_entries_tags(session, [i.path for i in entries], zim_id) # Always get headlines even if there is no search_str. Non-matches get the leading text of each article. headlines = extract_headlines(articles, search_str) diff --git a/modules/zim/models.py b/modules/zim/models.py index 49b568c2..a6cdab08 100644 --- a/modules/zim/models.py +++ b/modules/zim/models.py @@ -14,7 +14,7 @@ from modules.zim.errors import UnknownZimEntry, UnknownZimTagEntry, UnknownZim from wrolpi import dates, tags from wrolpi.common import Base, logger, get_relative_to_media_directory, ModelHelper from wrolpi.dates import TZDateTime -from wrolpi.db import get_db_curs +from wrolpi.db import session_curs from wrolpi.downloader import Download, download_manager from wrolpi.files.models import FileGroup from wrolpi.media_path import MediaPathType @@ -249,7 +249,7 @@ class Zim(Base, ModelHelper): @staticmethod def _entries_with_tags_process(limit: int, offset: int, params: dict, session: Session, tags_sub_select: str): - with get_db_curs() as curs: + with session_curs(session) as curs: curs.execute(tags_sub_select, params) # sqlite3.Row is not a tuple; convert for the `tuple_(...).in_()` below. zim_ids_entries: List[Tuple[int, str]] = [tuple(row) for row in curs.fetchall()] diff --git a/modules/zim/test/test_lib.py b/modules/zim/test/test_lib.py index c6b987dc..3b103cb4 100644 --- a/modules/zim/test/test_lib.py +++ b/modules/zim/test/test_lib.py @@ -64,7 +64,7 @@ async def test_zim_get_entries_tags(async_client, test_session, test_zim, tag_fa test_zim.tag_entry(test_session, tag1.name, 'two') test_session.commit() - assert lib.get_entries_tags(['one', 'two', 'home'], 1) \ + assert lib.get_entries_tags(test_session, ['one', 'two', 'home'], 1) \ == dict(one=['tag1', 'tag2'], two=['tag1'], home=[]) diff --git a/wrolpi/common.py b/wrolpi/common.py index 434516e5..42ba5acd 100644 --- a/wrolpi/common.py +++ b/wrolpi/common.py @@ -2825,10 +2825,9 @@ def extract_headlines(entries: List[str], search_str: str) -> List[Tuple[str, fl return fts.headline_texts(entries, search_str, tokens=8) -async def search_other_estimates(tag_names: List[str]) -> dict: +async def search_other_estimates(session: Session, tag_names: List[str]) -> dict: """Estimate other things that are Tagged.""" from sqlalchemy import func - from wrolpi.db import get_db_session from wrolpi.collections.models import Collection from wrolpi.tags import Tag from modules.videos.models import Channel @@ -2838,13 +2837,12 @@ async def search_other_estimates(tag_names: List[str]) -> dict: channel_count=0, ) - with get_db_session() as session: - # TODO handle multiple tags - channel_count = session.query(func.count(Channel.id)) \ - .join(Collection, Collection.id == Channel.collection_id) \ - .join(Tag, Tag.id == Collection.tag_id) \ - .filter(Tag.name == tag_names[0]) \ - .scalar() + # TODO handle multiple tags + channel_count = session.query(func.count(Channel.id)) \ + .join(Collection, Collection.id == Channel.collection_id) \ + .join(Tag, Tag.id == Collection.tag_id) \ + .filter(Tag.name == tag_names[0]) \ + .scalar() others = dict( channel_count=channel_count, diff --git a/wrolpi/db.py b/wrolpi/db.py index 43a40cc0..5bf1d48f 100644 --- a/wrolpi/db.py +++ b/wrolpi/db.py @@ -13,7 +13,7 @@ import sqlite3 import threading import types from contextlib import contextmanager -from typing import Tuple, List, Union, Type, Generator, Any +from typing import Tuple, List, Union, Type, Generator, Any, ContextManager import sqlalchemy import sqlalchemy.exc @@ -317,6 +317,23 @@ def get_db_curs(commit: bool = False) -> Generator[sqlite3.Cursor, Any, None]: connection.close() +@contextmanager +def session_curs(session: Session) -> Generator[sqlite3.Cursor, Any, None]: + """A raw `sqlite3.Cursor` on `session`'s connection, inside the Session's transaction. + + Use it for raw SQL in code that is handed a Session, so the SQL shares that Session's connection + instead of opening another (as `get_db_curs` does). Pending ORM changes are flushed first, so raw + SQL sees them. Writes through the cursor commit or roll back with the Session. Rows are + `sqlite3.Row`.""" + session.flush() + curs = session.connection().connection.cursor() + curs.row_factory = sqlite3.Row + try: + yield curs + finally: + curs.close() + + def _session_has_connection(session: Session) -> bool: """True if the Session's transaction has already connected (and so already emitted BEGIN).""" # SQLAlchemy 1.3 has no public API for this; 2.0 replaces it with `session.in_transaction()`. @@ -345,19 +362,10 @@ class RequestDB: self.engine, self.session = _get_request_session() _refuse_non_test_engine(self.engine) - @contextmanager - def curs(self) -> Generator[sqlite3.Cursor, Any, None]: - """A raw `sqlite3.Cursor` on this request's connection, inside the Session's transaction. - - Pending ORM changes are flushed first, so raw SQL sees them. Writes through the cursor commit - or roll back with the request (or with the enclosing `write()` block). Rows are `sqlite3.Row`.""" - self.session.flush() - curs = self.session.connection().connection.cursor() - curs.row_factory = sqlite3.Row - try: - yield curs - finally: - curs.close() + def curs(self) -> ContextManager[sqlite3.Cursor]: + """A raw `sqlite3.Cursor` on this request's connection (see `session_curs`). Its writes commit or + roll back with the request, or with the enclosing `write()` block.""" + return session_curs(self.session) @contextmanager def write(self) -> Generator[Session, Any, None]: diff --git a/wrolpi/downloader.py b/wrolpi/downloader.py index c60109ef..40cff8e0 100644 --- a/wrolpi/downloader.py +++ b/wrolpi/downloader.py @@ -35,7 +35,7 @@ from wrolpi.common import Base, ModelHelper, logger, wrol_mode_check, zig_zag, C wrol_mode_enabled, background_task, get_absolute_media_path, timer, aiohttp_get, \ get_download_info, trim_file_name, get_wrolpi_config, TRACE_LEVEL, normalize_domain from wrolpi.dates import TZDateTime, now, Seconds -from wrolpi.db import get_db_session, get_db_curs +from wrolpi.db import get_db_session, session_curs from wrolpi.errors import InvalidDownload, UnrecoverableDownloadError, BotBlockedDownloadError, UnknownDownload, \ ValidationError, DownloadError from wrolpi.events import Events @@ -1307,16 +1307,20 @@ class DownloadManager: raise TimeoutError('Downloads never finished!') @staticmethod - def retry_downloads(reset_attempts: bool = False): - """Set any incomplete Downloads to `new` so they will be retried. + def retry_downloads(session: Session, reset_attempts: bool = False): + """Set any incomplete Downloads to `new` so they will be retried. The caller commits. @param reset_attempts: Will set `download.attempts` to 0 if True. """ values = dict(status=DownloadStatus.new, attempts=0) if reset_attempts else dict(status=DownloadStatus.new) + session.query(Download) \ + .filter(Download.status.in_((DownloadStatus.pending, DownloadStatus.deferred, DownloadStatus.failed))) \ + .update(values, synchronize_session=False) + + def retry_downloads_on_startup(self): + """Startup is not a request: retry incomplete Downloads in a short write transaction of its own.""" with get_db_session(commit=True) as session: - session.query(Download) \ - .filter(Download.status.in_((DownloadStatus.pending, DownloadStatus.deferred, DownloadStatus.failed))) \ - .update(values, synchronize_session=False) + self.retry_downloads(session) def get_new_downloads(self, session: Session) -> Generator[Download, None, None]: """ @@ -1450,17 +1454,16 @@ class DownloadManager: return download - def kill_download(self, download_id: int): - """Fail a Download. If it is pending, kill the Downloader so the download stops.""" + def kill_download(self, session: Session, download_id: int): + """Fail a Download. If it is pending, kill the Downloader so the download stops. The caller commits.""" logger.info(f'Killing Download: {download_id}') download_manager_data = api_app.shared_ctx.download_manager_data.copy() download_manager_data['killed_downloads'] = download_manager_data['killed_downloads'] + [download_id, ] api_app.shared_ctx.download_manager_data.update(download_manager_data) - with get_db_session(commit=True) as session: - if download := Download.get_by_id(session, download_id): - download.error = 'User stopped this download' - download.fail() + if download := Download.get_by_id(session, download_id): + download.error = 'User stopped this download' + download.fail() @staticmethod def unkill_download(download_id: int): @@ -1518,10 +1521,10 @@ class DownloadManager: download[key] = parse_db_datetime(download[key]) return download - def get_fe_downloads(self): + def get_fe_downloads(self, session: Session): """Get downloads for the Frontend. Uses raw SQL for faster result.""" # Use custom SQL because SQLAlchemy is slow. - with get_db_curs() as curs: + with session_curs(session) as curs: stmt = f''' SELECT collection_id, @@ -1598,18 +1601,17 @@ class DownloadManager: ) return data - def get_summary(self) -> dict: + def get_summary(self, session: Session) -> dict: """ Get a summary of what Downloads are happening as well as the status of the DownloadManager. """ from sqlalchemy import func - with get_db_session() as session: - pending_downloads, recurring_downloads = session.query( - func.count(Download.id).filter(Download.status == DownloadStatus.pending), - func.count(Download.id).filter(Download.frequency != None), # noqa - ).one() - daily_limit_reached = self.daily_limit_reached(session) + pending_downloads, recurring_downloads = session.query( + func.count(Download.id).filter(Download.status == DownloadStatus.pending), + func.count(Download.id).filter(Download.frequency != None), # noqa + ).one() + daily_limit_reached = self.daily_limit_reached(session) summary = dict( pending=pending_downloads, recurring=recurring_downloads, diff --git a/wrolpi/files/api.py b/wrolpi/files/api.py index 5cef5593..dad326cb 100644 --- a/wrolpi/files/api.py +++ b/wrolpi/files/api.py @@ -151,10 +151,11 @@ async def worker_status(request: Request): body=schema.FilesSearchRequest, ) @validate(schema.FilesSearchRequest) -async def post_search_files(_: Request, body: schema.FilesSearchRequest): +async def post_search_files(request: Request, body: schema.FilesSearchRequest): with timer('Searching all files', 'info', logger__=logger): + # The handler awaits the thread, so the request's Session has one user at a time. file_groups, total = await asyncio.to_thread( - lib.search_files, body.search_str, body.limit, body.offset, body.mimetypes, body.model, + lib.search_files, request.ctx.session, body.search_str, body.limit, body.offset, body.mimetypes, body.model, body.tag_names, body.headline, body.months, body.from_year, body.to_year, body.any_tag, body.order, body.url, body.suffix, body.path, body.deep) return json_response(dict(file_groups=file_groups, totals=dict(file_groups=total))) @@ -524,8 +525,8 @@ async def post_unignore_directory(request: Request, body: schema.Directory): body=schema.BulkTagPreviewRequest, ) @validate(schema.BulkTagPreviewRequest) -async def post_bulk_tag_preview(_: Request, body: schema.BulkTagPreviewRequest): - preview = lib.get_bulk_tag_preview(body.paths) +async def post_bulk_tag_preview(request: Request, body: schema.BulkTagPreviewRequest): + preview = lib.get_bulk_tag_preview(request.ctx.session, body.paths) return json_response(preview.__json__()) diff --git a/wrolpi/files/lib.py b/wrolpi/files/lib.py index ce034a14..d6f954da 100644 --- a/wrolpi/files/lib.py +++ b/wrolpi/files/lib.py @@ -30,7 +30,7 @@ from wrolpi.common import get_media_directory, wrol_mode_check, logger, \ get_wrolpi_config, \ unique_by_predicate, get_paths_in_media_directory, TRACE_LEVEL, get_relative_to_media_directory, strip_surrogates from wrolpi.dates import now, from_timestamp, months_selector_to_where, date_range_to_where -from wrolpi.db import get_db_session, get_db_curs, values_clause +from wrolpi.db import get_db_session, get_db_curs, values_clause, session_curs from wrolpi.downloader import download_manager, Download from wrolpi.errors import InvalidFile, UnknownDirectory, UnknownFile, UnknownTag, FileConflict, FileGroupIsTagged, \ NoPrimaryFile, InvalidDirectory, IgnoredDirectoryError, UnsupportedArchive, InvalidArchiveMember @@ -1149,15 +1149,15 @@ def search_filter_cache_key(corpus: str, **filters) -> str: return json.dumps(payload, sort_keys=True, default=str) -def count_file_groups(statement: str, params: dict) -> int: +def count_file_groups(session: Session, statement: str, params: dict) -> int: """Run a `SELECT COUNT(*) AS total ...` statement and return the integer.""" - with get_db_curs() as curs: + with session_curs(session) as curs: curs.execute(statement, params) row = curs.fetchone() return int(row['total']) -def search_files(search_str: str, limit: int, offset: int, mimetypes: List[str] = None, model: str = None, +def search_files(session: Session, search_str: str, limit: int, offset: int, mimetypes: List[str] = None, model: str = None, tag_names: List[str] = None, headline: bool = False, months: List[int] = None, from_year: int = None, to_year: int = None, any_tag: bool = False, order: str = None, url: str = None, suffix: str = None, path: str = None, deep: bool = False) -> \ @@ -1294,12 +1294,13 @@ def search_files(search_str: str, limit: int, offset: int, mimetypes: List[str] any_tag=any_tag, url=url, suffix=suffix, path=path, deep=deep, viewed_only=viewed_only, ) - total = cached_search_total(cache_key, lambda: count_file_groups(count_stmt, count_params)) - results, total = handle_file_group_search_results(stmt, params, total=total) + total = cached_search_total(cache_key, lambda: count_file_groups(session, count_stmt, count_params)) + results, total = handle_file_group_search_results(session, stmt, params, total=total) return results, total -def handle_file_group_search_results(statement: str, params: dict, total: int = None) -> Tuple[List[dict], int]: +def handle_file_group_search_results(session: Session, statement: str, params: dict, total: int = None) \ + -> Tuple[List[dict], int]: """ Execute the provided SQL statement and fetch the Files returned. @@ -1310,7 +1311,7 @@ def handle_file_group_search_results(statement: str, params: dict, total: int = See: `search` """ - with get_db_curs() as curs: + with session_curs(session) as curs: curs.execute(statement, params) results = [dict(i) for i in curs.fetchall()] if total is None: @@ -1327,56 +1328,55 @@ def handle_file_group_search_results(statement: str, params: dict, total: int = d_headline=i.get('d_headline'), ) for i in results] - with get_db_session() as session: - from modules.archive.models import Archive - from modules.videos.models import Video - results = session.query(FileGroup, Video, Archive) \ - .filter(FileGroup.id.in_(ordered_ids)) \ - .outerjoin(Video, Video.file_group_id == FileGroup.id) \ - .outerjoin(Archive, Archive.file_group_id == FileGroup.id) - # Order FileGroups by their location in ordered_ids. Use dict for O(1) lookups instead of O(n) list.index(). - id_to_order = {id_: idx for idx, id_ in enumerate(ordered_ids)} - file_groups = sorted(results, key=lambda i: id_to_order.get(i[0].id, 0)) - results = list() - for extra, (file_group, video, archive) in zip_longest(extras, file_groups): - if video: - results.append(video.__json__()) - elif archive: - results.append(archive.__json__()) - else: - results.append(file_group.__json__()) - # Preserve the ts_ranks, if any. - if extra: - results[-1]['ts_rank'] = extra['ts_rank'] - results[-1]['title_headline'] = extra['title_headline'] - results[-1]['b_headline'] = extra['b_headline'] - results[-1]['c_headline'] = extra['c_headline'] - results[-1]['d_headline'] = extra['d_headline'] + from modules.archive.models import Archive + from modules.videos.models import Video + results = session.query(FileGroup, Video, Archive) \ + .filter(FileGroup.id.in_(ordered_ids)) \ + .outerjoin(Video, Video.file_group_id == FileGroup.id) \ + .outerjoin(Archive, Archive.file_group_id == FileGroup.id) + # Order FileGroups by their location in ordered_ids. Use dict for O(1) lookups instead of O(n) list.index(). + id_to_order = {id_: idx for idx, id_ in enumerate(ordered_ids)} + file_groups = sorted(results, key=lambda i: id_to_order.get(i[0].id, 0)) + results = list() + for extra, (file_group, video, archive) in zip_longest(extras, file_groups): + if video: + results.append(video.__json__()) + elif archive: + results.append(archive.__json__()) + else: + results.append(file_group.__json__()) + # Preserve the ts_ranks, if any. + if extra: + results[-1]['ts_rank'] = extra['ts_rank'] + results[-1]['title_headline'] = extra['title_headline'] + results[-1]['b_headline'] = extra['b_headline'] + results[-1]['c_headline'] = extra['c_headline'] + results[-1]['d_headline'] = extra['d_headline'] - search_str = params.get('search_str') if isinstance(params, dict) else None + search_str = params.get('search_str') if isinstance(params, dict) else None - # When headlines were requested, compute the title headlines in Python (the FTS5 snippet - # function only works on indexed columns; `title` is not one). - if search_str and any(extra and extra.get('headlines') for extra in extras): - from wrolpi import fts - titles = [result.get('title') or '' for result in results] - for result, (headline_text, rank) in zip(results, fts.headline_texts(titles, search_str)): - if rank > 0: - result['title_headline'] = headline_text + # When headlines were requested, compute the title headlines in Python (the FTS5 snippet + # function only works on indexed columns; `title` is not one). + if search_str and any(extra and extra.get('headlines') for extra in extras): + from wrolpi import fts + titles = [result.get('title') or '' for result in results] + for result, (headline_text, rank) in zip(results, fts.headline_texts(titles, search_str)): + if rank > 0: + result['title_headline'] = headline_text - # When searching by text, attach a `section_hint` to any doc-modeled result so - # the UI can deep-link into the matching EPUB chapter or PDF page. - if search_str: - from modules.docs.lib import _fetch_section_hints - # `r['id']` is the FileGroup primary key; _fetch_section_hints filters - # on `file_group_id`, so this list is file-group ids (not doc.id). - fg_ids = [r['id'] for r in results if r.get('model') == 'doc'] - if fg_ids: - hints = _fetch_section_hints(session, fg_ids, search_str) - for r in results: - hint = hints.get(r['id']) - if hint: - r['section_hint'] = hint + # When searching by text, attach a `section_hint` to any doc-modeled result so + # the UI can deep-link into the matching EPUB chapter or PDF page. + if search_str: + from modules.docs.lib import _fetch_section_hints + # `r['id']` is the FileGroup primary key; _fetch_section_hints filters + # on `file_group_id`, so this list is file-group ids (not doc.id). + fg_ids = [r['id'] for r in results if r.get('model') == 'doc'] + if fg_ids: + hints = _fetch_section_hints(session, fg_ids, search_str) + for r in results: + hint = hints.get(r['id']) + if hint: + r['section_hint'] = hint return results, total @@ -1458,8 +1458,8 @@ def split_file_name_words(name: str) -> str: return name -def get_file_statistics(): - with get_db_curs() as curs: +def get_file_statistics(session: Session): + with session_curs(session) as curs: curs.execute(''' SELECT -- All items in file_group.files are real individual files. @@ -1573,7 +1573,7 @@ def mimetypes_to_sql_wheres(wheres: List[str], params: dict, mimetypes: List[str return wheres, params -async def search_file_suggestion_count(search_str: str, tag_names: List[str], mimetypes: List[str], +async def search_file_suggestion_count(session: Session, search_str: str, tag_names: List[str], mimetypes: List[str], months: List[int] = None, from_year: int = None, to_year: int = None, any_tag: bool = False) -> dict: """ @@ -1615,7 +1615,7 @@ async def search_file_suggestion_count(search_str: str, tag_names: List[str], mi ''' logger.debug(f'search_file_suggestion_count: {stmt} {params}') - with get_db_curs() as curs: + with session_curs(session) as curs: if deep_match: curs.execute(stmt, {**params, 'fts_match': deep_match}) deep_estimate = int(curs.fetchone()['estimate']) @@ -2078,7 +2078,7 @@ class BulkTagPreview: ) -def get_bulk_tag_preview(paths: List[str]) -> BulkTagPreview: +def get_bulk_tag_preview(session: Session, paths: List[str]) -> BulkTagPreview: """Get preview information for bulk tagging operation. Returns the count of files that will be tagged and the tags that are shared by ALL files. @@ -2113,34 +2113,33 @@ def get_bulk_tag_preview(paths: List[str]) -> BulkTagPreview: # First, get all FileGroups and their tags # We query by stem prefix because unique_files may contain non-primary files # (e.g., .readability.json) but FileGroups are stored with primary_path (e.g., .html) - with get_db_session() as session: - from sqlalchemy import or_ - stems = [split_path_stem_and_suffix(f, full=True)[0] for f in unique_files] - # Chunk the OR'd LIKEs to stay within SQLite's expression-tree depth limit of 1000. - file_groups = [] - for idx in range(0, len(stems), 500): - chunk = stems[idx:idx + 500] - file_groups.extend(session.query(FileGroup).filter( - or_(*[FileGroup.primary_path.like(f'{stem}%') for stem in chunk]) - ).all()) + from sqlalchemy import or_ + stems = [split_path_stem_and_suffix(f, full=True)[0] for f in unique_files] + # Chunk the OR'd LIKEs to stay within SQLite's expression-tree depth limit of 1000. + file_groups = [] + for idx in range(0, len(stems), 500): + chunk = stems[idx:idx + 500] + file_groups.extend(session.query(FileGroup).filter( + or_(*[FileGroup.primary_path.like(f'{stem}%') for stem in chunk]) + ).all()) - if not file_groups: - # No FileGroups exist yet, no shared tags - return BulkTagPreview(file_count=file_count, shared_tag_names=[]) + if not file_groups: + # No FileGroups exist yet, no shared tags + return BulkTagPreview(file_count=file_count, shared_tag_names=[]) - # Get tag names for each FileGroup - tag_sets = [] - for fg in file_groups: - tag_sets.append(set(fg.tag_names)) + # Get tag names for each FileGroup + tag_sets = [] + for fg in file_groups: + tag_sets.append(set(fg.tag_names)) - # Find intersection of all tag sets (tags shared by ALL files) - if tag_sets: - shared_tags = tag_sets[0] - for tag_set in tag_sets[1:]: - shared_tags = shared_tags.intersection(tag_set) - shared_tag_names = sorted(list(shared_tags)) - else: - shared_tag_names = [] + # Find intersection of all tag sets (tags shared by ALL files) + if tag_sets: + shared_tags = tag_sets[0] + for tag_set in tag_sets[1:]: + shared_tags = shared_tags.intersection(tag_set) + shared_tag_names = sorted(list(shared_tags)) + else: + shared_tag_names = [] return BulkTagPreview(file_count=file_count, shared_tag_names=shared_tag_names) diff --git a/wrolpi/files/test/test_lib.py b/wrolpi/files/test/test_lib.py index 95f29d1d..be49fa27 100644 --- a/wrolpi/files/test/test_lib.py +++ b/wrolpi/files/test/test_lib.py @@ -750,19 +750,19 @@ async def test_files_indexer(async_client, test_session, make_files_structure, t assert video_file.mimetype == 'video/mp4' and video_file.indexer == indexers.DefaultIndexer # File are indexed by their titles and contents. Contents (d_text) require a deep search. - files, total = lib.search_files('file', 10, 0) + files, total = lib.search_files(test_session, 'file', 10, 0) assert total == 5, 'All files contain "file" in their file name. The associated video file is hidden.' - files, total = lib.search_files('image', 10, 0) + files, total = lib.search_files(test_session, 'image', 10, 0) assert total == 1 and files[0]['title'] == 'an image file.jpeg', 'The image file title contains "image".' - files, total = lib.search_files('contents', 10, 0, deep=True) + files, total = lib.search_files(test_session, 'contents', 10, 0, deep=True) assert total == 1 and files[0]['title'] == 'a text file.txt', 'The text file contains "contents".' - files, total = lib.search_files('contents', 10, 0) + files, total = lib.search_files(test_session, 'contents', 10, 0) assert total == 0, 'File contents are not searched unless deep=True.' - files, total = lib.search_files('video', 10, 0) + files, total = lib.search_files(test_session, 'video', 10, 0) assert total == 1 and {i['title'] for i in files} == {'a video file'}, 'The video file contains "video".' - files, total = lib.search_files('yawn', 10, 0, deep=True) + files, total = lib.search_files(test_session, 'yawn', 10, 0, deep=True) assert total == 1 and files[0]['title'] == 'a video file', 'The video file captions contain "yawn".' - files, total = lib.search_files('bunny', 10, 0, deep=True) + files, total = lib.search_files(test_session, 'bunny', 10, 0, deep=True) assert total == 1 and {i['title'] for i in files} == {'a zip file.zip'}, \ 'The zip file contains a file with "bunny" in the title.' @@ -774,7 +774,7 @@ async def test_files_indexer(async_client, test_session, make_files_structure, t text_path.write_text('new text contents') await refresh_files() await await_background_tasks() - files, total = lib.search_files('new', 10, 0, deep=True) + files, total = lib.search_files(test_session, 'new', 10, 0, deep=True) assert total == 1 @@ -1238,29 +1238,29 @@ async def test_file_search_date_range(async_client, test_session, example_pdf, e await refresh_files() doc, pdf = test_session.query(FileGroup).order_by(FileGroup.primary_path).all() - files, total = lib.search_files('', 10, 0) + files, total = lib.search_files(test_session, '', 10, 0) assert total == 2, 'Should only be 2 files.' # Past the last page still reports the true total (count is a separate query). - files, total = lib.search_files('', 10, 10) + files, total = lib.search_files(test_session, '', 10, 10) assert files == [] assert total == 2 # PDF was published in December. - files, total = lib.search_files('', 10, 0, months=[12, ]) + files, total = lib.search_files(test_session, '', 10, 0, months=[12, ]) assert total == 1, 'Only PDF should be from December 2022' assert files[0]['id'] == pdf.id # PDF was published in 2022. - files, total = lib.search_files('', 10, 0, from_year=2022, to_year=2022) + files, total = lib.search_files(test_session, '', 10, 0, from_year=2022, to_year=2022) assert total == 1, 'Only PDF should be from December 2022' assert files[0]['id'] == pdf.id # PDF was NOT published in 2023. - files, total = lib.search_files('', 10, 0, from_year=2023) + files, total = lib.search_files(test_session, '', 10, 0, from_year=2023) assert total == 0, 'No example files are published in 2023' - files, total = lib.search_files('', 10, 0, to_year=2022) + files, total = lib.search_files(test_session, '', 10, 0, to_year=2022) assert total == 1 assert files[0]['id'] == pdf.id @@ -1358,7 +1358,7 @@ async def test_get_bulk_tag_preview_files(async_client, test_session, make_files test_session.commit() # Preview for all three files - preview = lib.get_bulk_tag_preview(['foo.txt', 'bar.txt', 'baz.txt']) + preview = lib.get_bulk_tag_preview(test_session, ['foo.txt', 'bar.txt', 'baz.txt']) assert preview.file_count == 3 assert 'shared' in preview.shared_tag_names assert 'only_foo' not in preview.shared_tag_names # Not shared by all @@ -1374,7 +1374,7 @@ async def test_get_bulk_tag_preview_directory(test_session, make_files_structure }) # Preview for the directory - should find all files recursively - preview = lib.get_bulk_tag_preview(['mydir/']) + preview = lib.get_bulk_tag_preview(test_session, ['mydir/']) assert preview.file_count == 3 @@ -1426,7 +1426,7 @@ async def test_get_bulk_tag_preview_many_files(test_session, make_files_structur stems exceeded SQLite's expression-tree depth limit ("Expression tree is too large").""" make_files_structure({f'mydir/file{i}.txt': str(i) for i in range(1200)}) - preview = lib.get_bulk_tag_preview(['mydir/']) + preview = lib.get_bulk_tag_preview(test_session, ['mydir/']) assert preview.file_count == 1200 @@ -1459,14 +1459,14 @@ async def test_get_bulk_tag_preview_multi_file_filegroup(async_client, test_sess test_session.commit() # Preview for the directory should find the FileGroup and its tag - preview = lib.get_bulk_tag_preview(['archive/']) + preview = lib.get_bulk_tag_preview(test_session, ['archive/']) assert preview.file_count == 1, f'Expected 1 FileGroup, got {preview.file_count}' assert 'my_tag' in preview.shared_tag_names, f'Expected my_tag in shared tags, got {preview.shared_tag_names}' def test_get_bulk_tag_preview_empty(test_session, test_directory): """get_bulk_tag_preview returns 0 for empty or non-existent paths.""" - preview = lib.get_bulk_tag_preview([]) + preview = lib.get_bulk_tag_preview(test_session, []) assert preview.file_count == 0 assert preview.shared_tag_names == [] @@ -1758,21 +1758,21 @@ async def test_file_search_url(test_session, make_files_structure, refresh_files test_session.commit() # Search by full URL - files, total = lib.search_files('', 10, 0, url='https://example.com/foo') + files, total = lib.search_files(test_session, '', 10, 0, url='https://example.com/foo') assert total == 1 assert files[0]['id'] == foo_fg.id # Search by domain (partial match) - files, total = lib.search_files('', 10, 0, url='example.com') + files, total = lib.search_files(test_session, '', 10, 0, url='example.com') assert total == 2 assert {f['id'] for f in files} == {foo_fg.id, baz_fg.id} # Case insensitive search - files, total = lib.search_files('', 10, 0, url='EXAMPLE.COM') + files, total = lib.search_files(test_session, '', 10, 0, url='EXAMPLE.COM') assert total == 2 # No results for non-matching URL - files, total = lib.search_files('', 10, 0, url='nonexistent.com') + files, total = lib.search_files(test_session, '', 10, 0, url='nonexistent.com') assert total == 0 @@ -1790,7 +1790,7 @@ async def test_file_search_url_with_null(test_session, make_files_structure, ref # bar_fg.url remains None test_session.commit() - files, total = lib.search_files('', 10, 0, url='example.com') + files, total = lib.search_files(test_session, '', 10, 0, url='example.com') assert total == 1 assert files[0]['id'] == foo_fg.id diff --git a/wrolpi/root_api.py b/wrolpi/root_api.py index 54c5fdb9..16848326 100644 --- a/wrolpi/root_api.py +++ b/wrolpi/root_api.py @@ -439,8 +439,8 @@ async def restart_download(request: Request, download_id: int): @api_bp.get('/download') @openapi.description('Get all Downloads so they can be displayed to the User.') -async def get_downloads(_: Request): - data = download_manager.get_fe_downloads() +async def get_downloads(request: Request): + data = download_manager.get_fe_downloads(request.ctx.session) # Convert `destination` to relative. for download in data['once_downloads']: @@ -455,8 +455,9 @@ async def get_downloads(_: Request): @api_bp.post('/download//kill') @openapi.description('Kill a download. It will be stopped if it is pending.') -async def kill_download(_: Request, download_id: int): - download_manager.kill_download(download_id) +async def kill_download(request: Request, download_id: int): + with request.ctx.db.write() as session: + download_manager.kill_download(session, download_id) return response.empty() @@ -493,8 +494,9 @@ async def clear_failed(request: Request): @api_bp.post('/download/retry_once') @openapi.description('Retry failed once-downloads') -async def retry_once(_: Request): - download_manager.retry_downloads(reset_attempts=True) +async def retry_once(request: Request): + with request.ctx.db.write() as session: + download_manager.retry_downloads(session, reset_attempts=True) return response.empty() @@ -563,7 +565,7 @@ async def get_status(request: Request): downloads = dict() if flags.db_up.is_set(): try: - downloads = download_manager.get_summary() + downloads = download_manager.get_summary(request.ctx.session) except Exception as e: logger.debug('Unable to get download status', exc_info=e) @@ -605,9 +607,9 @@ async def get_status(request: Request): @openapi.definition( description='Get summary statistics of all files', ) -async def get_statistics(_): +async def get_statistics(request: Request): # Full-table aggregates take many seconds cold on a large library; keep them off the event loop. - file_statistics = await asyncio.to_thread(get_file_statistics) + file_statistics = await asyncio.to_thread(get_file_statistics, request.ctx.session) global_statistics = await asyncio.to_thread(get_global_statistics) return json_response({ 'file_statistics': file_statistics, @@ -634,13 +636,13 @@ async def feed(request: Request, query: schema.EventsRequest): description='Get a list of all Tags', ) async def get_tags_request(request: Request): - tags_ = tags.get_tags() + tags_ = tags.get_tags(request.ctx.session) tag_names = request.args.getlist('tag_names') result = dict(tags=tags_) if tag_names: limit = request.args.get('limit') limit = int(limit) if limit is not None else None - result['overlapping_tag_names'] = tags.get_overlapping_tags(tag_names, limit=limit) + result['overlapping_tag_names'] = tags.get_overlapping_tags(request.ctx.session, tag_names, limit=limit) return json_response(result) @@ -648,8 +650,8 @@ async def get_tags_request(request: Request): @openapi.definition( description='Get the most recently used tags', ) -async def get_recent_tags_request(_: Request): - tag_names = tags.get_recent_tags() +async def get_recent_tags_request(request: Request): + tag_names = tags.get_recent_tags(request.ctx.session) return json_response(dict(tag_names=tag_names)) @@ -801,9 +803,10 @@ async def post_search_suggestions(request: Request, body: schema.SearchSuggestio body=schema.SearchFileEstimateRequest, validate=True, ) -async def post_search_file_estimates(_: Request, body: schema.SearchFileEstimateRequest): +async def post_search_file_estimates(request: Request, body: schema.SearchFileEstimateRequest): """Used by the Global search to suggest FileGroup count to the user.""" counts = await search_file_suggestion_count( + request.ctx.session, body.search_str, body.tag_names, body.mimetypes, @@ -826,9 +829,9 @@ async def post_search_file_estimates(_: Request, body: schema.SearchFileEstimate body=schema.SearchOtherEstimateRequest, validate=True, ) -async def post_search_other_estimates(_: Request, body: schema.SearchOtherEstimateRequest): +async def post_search_other_estimates(request: Request, body: schema.SearchOtherEstimateRequest): """Used by the Global search to suggest FileGroup count to the user.""" - others = await search_other_estimates(body.tag_names) + others = await search_other_estimates(request.ctx.session, body.tag_names) ret = dict(others=others) return json_response(ret) diff --git a/wrolpi/tags.py b/wrolpi/tags.py index 52e60d54..4db779ba 100644 --- a/wrolpi/tags.py +++ b/wrolpi/tags.py @@ -14,7 +14,7 @@ from wrolpi import dates, flags from wrolpi.common import ModelHelper, Base, logger, ConfigFile, get_media_directory, background_task, \ get_relative_to_media_directory, is_valid_hex_color, walk, INVALID_FILE_CHARS, get_wrolpi_config from wrolpi.dates import TZDateTime -from wrolpi.db import get_db_curs, get_db_session, named_placeholders +from wrolpi.db import get_db_session, named_placeholders, session_curs from wrolpi.downloader import save_downloads_config from wrolpi.errors import UnknownTag, UsedTag, InvalidTag, FileWorkerConflict, NoPrimaryFile from wrolpi.events import Events @@ -844,8 +844,8 @@ def sync_tags_directory(): raise -def get_tags() -> List[dict]: - with get_db_curs() as curs: +def get_tags(session: Session) -> List[dict]: + with session_curs(session) as curs: curs.execute(''' SELECT t.id, t.name, @@ -862,8 +862,8 @@ def get_tags() -> List[dict]: return tags -def get_recent_tags(limit: int = 5) -> List[str]: - with get_db_curs() as curs: +def get_recent_tags(session: Session, limit: int = 5) -> List[str]: + with session_curs(session) as curs: curs.execute(''' SELECT t.name FROM (SELECT tag_id, MAX(created_at) AS latest @@ -881,7 +881,7 @@ def get_recent_tags(limit: int = 5) -> List[str]: return [row['name'] for row in curs.fetchall()] -def get_overlapping_tags(tag_names: Union[str, List[str]], limit: int = 5) -> List[str]: +def get_overlapping_tags(session: Session, tag_names: Union[str, List[str]], limit: int = 5) -> List[str]: if isinstance(tag_names, str): tag_names = [tag_names] @@ -890,7 +890,7 @@ def get_overlapping_tags(tag_names: Union[str, List[str]], limit: int = 5) -> Li params = dict(tag_count=len(tag_names), limit=limit) tag_names_placeholders = named_placeholders('tag_name', tag_names, params) - with get_db_curs() as curs: + with session_curs(session) as curs: curs.execute(f''' WITH target_tags AS (SELECT id FROM tag WHERE name IN ({tag_names_placeholders})), file_matching AS ( diff --git a/wrolpi/test/test_api_fixtures.py b/wrolpi/test/test_api_fixtures.py index d565995b..b68c47fd 100644 --- a/wrolpi/test/test_api_fixtures.py +++ b/wrolpi/test/test_api_fixtures.py @@ -203,7 +203,7 @@ async def test_downloads_summary_fixture_matches_the_download_manager(async_clie """ from wrolpi.downloader import download_manager - declared = set(download_manager.get_summary()) + declared = set(download_manager.get_summary(test_session)) source = TEST_FIXTURES_JS.read_text() block = re.search(r'^ downloads: \{(.*?)\n },', source, re.S | re.M) diff --git a/wrolpi/test/test_downloader.py b/wrolpi/test/test_downloader.py index 00810417..0f54b6a3 100644 --- a/wrolpi/test/test_downloader.py +++ b/wrolpi/test/test_downloader.py @@ -1265,7 +1265,8 @@ async def test_retry_downloads_includes_failed(test_session, test_download_manag d4.complete() # completed download should not be retried test_session.commit() - test_download_manager.retry_downloads(reset_attempts=True) + test_download_manager.retry_downloads(test_session, reset_attempts=True) + test_session.commit() test_session.refresh(d1) test_session.refresh(d2) @@ -1281,6 +1282,23 @@ async def test_retry_downloads_includes_failed(test_session, test_download_manag assert d4.status == 'complete' # unchanged +@pytest.mark.asyncio +async def test_retry_downloads_on_startup(test_session, test_download_manager, test_downloader): + """Startup is not a request; it retries incomplete Downloads in a write transaction it commits itself.""" + failed = test_download_manager.create_download(test_session, 'https://example.com/failed', test_downloader.name) + failed.fail() + complete = test_download_manager.create_download(test_session, 'https://example.com/done', test_downloader.name) + complete.complete() + test_session.commit() + + with production_like_sessions(test_session): + test_download_manager.retry_downloads_on_startup() + + test_session.expire_all() + assert failed.status == 'new' + assert complete.status == 'complete' + + @pytest.mark.asyncio async def test_batch_retry_downloads(test_session, test_download_manager, test_downloader): """Test retrying specific downloads by their IDs.""" @@ -2016,26 +2034,26 @@ async def test_summary_daily_limit_reached(test_session, test_download_manager, name = test_downloader.name # No limits configured. - assert test_download_manager.get_summary()['daily_limit_reached'] is False + assert test_download_manager.get_summary(test_session)['daily_limit_reached'] is False config.download_daily_limit_per_domain = 2 # No due downloads => not reached, even though limits are configured. _make_processed_download(test_session, name, 'https://example.com/1') _make_processed_download(test_session, name, 'https://example.com/2') test_session.commit() - assert test_download_manager.get_summary()['daily_limit_reached'] is False + assert test_download_manager.get_summary(test_session)['daily_limit_reached'] is False # A due download for the capped domain => reached. blocked = Download(url='https://example.com/new', downloader=name, status='new') test_session.add(blocked) test_session.commit() - assert test_download_manager.get_summary()['daily_limit_reached'] is True + assert test_download_manager.get_summary(test_session)['daily_limit_reached'] is True # A due download for another domain can still dispatch => not reached. allowed = Download(url='https://rumble.com/new', downloader=name, status='new') test_session.add(allowed) test_session.commit() - assert test_download_manager.get_summary()['daily_limit_reached'] is False + assert test_download_manager.get_summary(test_session)['daily_limit_reached'] is False # Recurring downloads are never gated by daily limits and must not count as "due". test_session.delete(allowed) @@ -2043,7 +2061,7 @@ async def test_summary_daily_limit_reached(test_session, test_download_manager, frequency=DownloadFrequency.daily) test_session.add(recurring) test_session.commit() - assert test_download_manager.get_summary()['daily_limit_reached'] is True + assert test_download_manager.get_summary(test_session)['daily_limit_reached'] is True # Global limit blocks everything, even a fresh domain. config.download_daily_limit_per_domain = None @@ -2051,7 +2069,7 @@ async def test_summary_daily_limit_reached(test_session, test_download_manager, fresh = Download(url='https://wikipedia.org/new', downloader=name, status='new') test_session.add(fresh) test_session.commit() - assert test_download_manager.get_summary()['daily_limit_reached'] is True + assert test_download_manager.get_summary(test_session)['daily_limit_reached'] is True @pytest.mark.asyncio diff --git a/wrolpi/test/test_request_db.py b/wrolpi/test/test_request_db.py index c915e923..9f7ca61a 100644 --- a/wrolpi/test/test_request_db.py +++ b/wrolpi/test/test_request_db.py @@ -7,13 +7,9 @@ from http import HTTPStatus import pytest +from wrolpi import flags from wrolpi.conftest import count_request_connections -# Library functions these endpoints call still open their own cursor or session, instead of using the -# request's. Remove an endpoint's mark when its count reaches one (strict: an unexpected pass fails). -OPENS_OWN_CONNECTION = pytest.mark.xfail( - strict=True, reason='a library function opens its own connection instead of using the request session') - @pytest.mark.asyncio @pytest.mark.parametrize('method,path,body', [ @@ -61,9 +57,19 @@ async def test_create_download_uses_one_connection(async_client, test_session, t @pytest.mark.asyncio -async def test_tags_use_one_connection(async_client, test_session, tag_factory): - await tag_factory() - response, count = await count_request_connections(async_client, test_session, 'get', '/api/tag') +@pytest.mark.parametrize('path', ['/api/tag', '/api/tag?tag_names={name}', '/api/tag/recent']) +async def test_tags_use_one_connection(async_client, test_session, tag_factory, path): + """The Tags, their overlaps, and the recent Tags are read on the request's connection.""" + tag = await tag_factory() + response, count = await count_request_connections( + async_client, test_session, 'get', path.format(name=tag.name)) + assert response.status_code == HTTPStatus.OK, response.body + assert count == 1 + + +@pytest.mark.asyncio +async def test_channel_list_uses_one_connection(async_client, test_session, simple_channel): + response, count = await count_request_connections(async_client, test_session, 'get', '/api/videos/channels') assert response.status_code == HTTPStatus.OK, response.body assert count == 1 @@ -77,10 +83,9 @@ async def test_zim_estimates_use_one_connection(async_client, test_session, zim_ assert count == 1 -@OPENS_OWN_CONNECTION @pytest.mark.asyncio async def test_zim_search_uses_one_connection(async_client, test_session, zim_factory): - """`get_all_entries_tags` opens a cursor of its own.""" + """The zim's entries' tags are read on the request's connection.""" zim_factory('test zim') response, count = await count_request_connections( async_client, test_session, 'post', '/api/zim/search', json={'search_str': 'one'}) @@ -88,25 +93,119 @@ async def test_zim_search_uses_one_connection(async_client, test_session, zim_fa assert count == 1 -@OPENS_OWN_CONNECTION @pytest.mark.asyncio -@pytest.mark.parametrize('path', ['/api/files/search', '/api/videos/search']) -async def test_search_uses_one_connection(async_client, test_session, simple_channel, video_factory, path): - """`count_file_groups` opens a cursor for the total (skipped when the total is cached), then - `handle_file_group_search_results` opens another cursor and a separate session for the page.""" +@pytest.mark.parametrize('path,body', [ + ('/api/files/search', {'search_str': 'video'}), + ('/api/videos/search', {'search_str': 'video'}), + ('/api/archive/search', {'search_str': 'video'}), + ('/api/flasher/search', {}), +]) +async def test_search_uses_one_connection(async_client, test_session, simple_channel, video_factory, path, body): + """The search total, the page of ids, and the models for that page share the request's connection.""" video_factory(title='a video', channel_id=simple_channel.id) - response, count = await count_request_connections( - async_client, test_session, 'post', path, json={'search_str': 'video'}) + response, count = await count_request_connections(async_client, test_session, 'post', path, json=body) assert response.status_code == HTTPStatus.OK, response.body assert count == 1 -@OPENS_OWN_CONNECTION @pytest.mark.asyncio async def test_channel_uses_one_connection(async_client, test_session, simple_channel): - """`get_channel` uses the request session, but `Channel.get_statistics` opens a raw cursor of its own - (`get_db_curs`).""" + """The Channel and its statistics are read on the request's connection.""" response, count = await count_request_connections( async_client, test_session, 'get', f'/api/videos/channels/{simple_channel.id}') assert response.status_code == HTTPStatus.OK, response.body assert count == 1 + + +@pytest.mark.asyncio +async def test_zim_tag_searches_use_one_connection(async_client, test_session, test_zim, tag_factory): + """Searching a Zim's tagged entries reads the tags on the request's connection.""" + tag = await tag_factory() + test_zim.tag_entry(test_session, tag.name, 'one') + + response, count = await count_request_connections( + async_client, test_session, 'post', f'/api/zim/search/{test_zim.id}', json={'tag_names': [tag.name]}) + assert response.status_code == HTTPStatus.OK, response.body + assert count == 1 + + response, count = await count_request_connections( + async_client, test_session, 'post', '/api/zim/search_estimates', json={'tag_names': [tag.name]}) + assert response.status_code == HTTPStatus.OK, response.body + assert count == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize('suffix', ['', '/description', '/captions', '/comments']) +async def test_video_reads_use_one_connection(async_client, test_session, simple_channel, video_factory, suffix): + """A Video, its neighbors, and its details are read on the request's connection.""" + video_factory(title='first', channel_id=simple_channel.id) + video = video_factory(title='second', channel_id=simple_channel.id) + response, count = await count_request_connections( + async_client, test_session, 'get', f'/api/videos/{video.file_group_id}{suffix}') + assert response.status_code == HTTPStatus.OK, response.body + assert count == 1 + + +@pytest.mark.asyncio +async def test_video_edit_uses_one_connection(async_client, test_session, simple_channel, video_factory): + video = video_factory(title='before', channel_id=simple_channel.id) + response, count = await count_request_connections( + async_client, test_session, 'put', f'/api/videos/{video.file_group_id}', json={'title': 'after'}) + assert response.status_code == HTTPStatus.OK, response.body + assert response.json['file_group']['title'] == 'after' + assert count == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize('method,path,body', [ + ('get', '/api/statistics', None), + ('post', '/api/search_file_estimates', {'search_str': 'video'}), + ('post', '/api/search_other_estimates', {'tag_names': ['{tag}']}), + ('post', '/api/files/bulk_tag/preview', {'paths': ['{video_dir}']}), + ('get', '/api/download', None), + ('post', '/api/download/retry_once', None), + ('get', '/api/docs/statistics', None), + ('get', '/api/videos/statistics', None), + ('get', '/api/archive/statistics', None), + ('post', '/api/docs/search', {'search_str': 'video'}), +]) +async def test_summaries_use_one_connection(async_client, test_session, test_directory, tag_factory, simple_channel, + video_factory, test_download_manager, method, path, body): + """Statistics, estimates, previews and lists each read on the request's connection.""" + tag = await tag_factory() + video = video_factory(title='a video', channel_id=simple_channel.id) + video_dir = str(video.video_path.parent.relative_to(test_directory)) + if body is not None: + body = {k: [i.format(tag=tag.name, video_dir=video_dir) for i in v] if isinstance(v, list) else v + for k, v in body.items()} + kwargs = dict(json=body) if body is not None else dict() + response, count = await count_request_connections(async_client, test_session, method, path, **kwargs) + assert response.status_code < 300, response.body + assert count == 1 + + +@pytest.mark.asyncio +async def test_status_uses_one_connection(async_client, test_session, test_download_manager, flags_lock): + """The UI polls the status; its download summary is read on the request's connection.""" + flags.db_up.set() + try: + response, count = await count_request_connections(async_client, test_session, 'get', '/api/status') + finally: + flags.db_up.clear() + assert response.status_code == HTTPStatus.OK, response.body + assert 'pending' in response.json['downloads'], 'the download summary was not read' + assert count == 1 + + +@pytest.mark.asyncio +async def test_kill_download_uses_one_connection(async_client, test_session, test_downloader): + from wrolpi.downloader import Download + await async_client.post('/api/download', json={'urls': ['https://example.com/kill'], + 'downloader': test_downloader.name}) + download = test_session.query(Download).filter_by(url='https://example.com/kill').one() + + response, count = await count_request_connections( + async_client, test_session, 'post', f'/api/download/{download.id}/kill') + assert response.status_code < 300, response.body + assert count == 1 + assert test_session.query(Download).filter_by(id=download.id).one().error == 'User stopped this download' diff --git a/wrolpi/test/test_tags.py b/wrolpi/test/test_tags.py index 123e5f6e..91bb6669 100644 --- a/wrolpi/test/test_tags.py +++ b/wrolpi/test/test_tags.py @@ -730,11 +730,11 @@ async def test_get_recent_tags(test_session, make_files_structure, tag_factory, tf3.created_at = base test_session.commit() - result = tags.get_recent_tags(limit=5) + result = tags.get_recent_tags(test_session, limit=5) assert result == ['gamma', 'beta', 'alpha'] # Limit is respected. - result = tags.get_recent_tags(limit=2) + result = tags.get_recent_tags(test_session, limit=2) assert result == ['gamma', 'beta'] @@ -757,7 +757,7 @@ async def test_get_overlapping_tags(test_session, make_files_structure, tag_fact file_groups[1].add_tag(test_session, tag2.id) test_session.commit() - result = tags.get_overlapping_tags('alpha') + result = tags.get_overlapping_tags(test_session, 'alpha') # beta overlaps with alpha twice, gamma once. assert result == ['beta', 'gamma'] @@ -766,7 +766,7 @@ async def test_get_overlapping_tags(test_session, make_files_structure, tag_fact async def test_get_overlapping_tags_empty(test_session, tag_factory): """No overlaps returns an empty list.""" await tag_factory('lonely') - result = tags.get_overlapping_tags('lonely') + result = tags.get_overlapping_tags(test_session, 'lonely') assert result == [] @@ -814,11 +814,11 @@ async def test_get_overlapping_tags_multiple(test_session, make_files_structure, test_session.commit() # Overlapping with both alpha AND beta: gamma (file a), delta (file b). - result = tags.get_overlapping_tags(['alpha', 'beta']) + result = tags.get_overlapping_tags(test_session, ['alpha', 'beta']) assert set(result) == {'gamma', 'delta'} # Overlapping with both alpha AND gamma: beta (file a only, since file c has no beta). - result = tags.get_overlapping_tags(['alpha', 'gamma']) + result = tags.get_overlapping_tags(test_session, ['alpha', 'gamma']) assert 'beta' in result @@ -842,11 +842,11 @@ async def test_get_overlapping_tags_no_limit(test_session, make_files_structure, test_session.commit() # Default limit of 5 returns only 5. - result = tags.get_overlapping_tags('alpha') + result = tags.get_overlapping_tags(test_session, 'alpha') assert len(result) == 5 # No limit returns all 10. - result = tags.get_overlapping_tags('alpha', limit=None) + result = tags.get_overlapping_tags(test_session, 'alpha', limit=None) assert len(result) == 10 From 8713a6136bddfd4879eb05eb65707d25538dec57 Mon Sep 17 00:00:00 2001 From: lrnselfreliance <33263708+lrnselfreliance@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:25:05 -0600 Subject: [PATCH 073/181] Reset file worker status when a synchronous refresh is cancelled (#775) * Reset file worker status when a synchronous refresh is cancelled refresh_sync runs inside request handlers. When Sanic cancelled one on response timeout, the phase flag cleared as its `with` block unwound but the shared status did not, so the worker reported 'modeling' forever while nothing ran. Reset to idle on cancellation. Deleting outdated Zims also ran the global modelers inside the request, which is what timed out. Skip post-processing there and update the outdated_zims flag directly, the only cleanup step it relied on. Co-Authored-By: Claude Opus 5.5 * tests: observe the modeling flag in the cancelled refresh_sync test Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- modules/zim/lib.py | 6 ++++-- modules/zim/test/test_restart_kiwix.py | 26 ++++++++++++++++++++++++++ wrolpi/files/test/test_worker.py | 20 ++++++++++++++++++++ wrolpi/files/worker.py | 9 ++++++--- 4 files changed, 56 insertions(+), 5 deletions(-) diff --git a/modules/zim/lib.py b/modules/zim/lib.py index 30365e1c..0a746854 100644 --- a/modules/zim/lib.py +++ b/modules/zim/lib.py @@ -586,8 +586,10 @@ async def remove_outdated_zim_files(path: pathlib.Path = None) -> int: deleted_count += 1 if deleted_count: - # Refresh synchronously to clean up DB before returning. - await file_worker.refresh_sync(list(outdated)) + # Refresh synchronously to clean up DB before returning. Deleting needs no modeling, and the + # global modelers would work through the whole unindexed backlog inside this request. + await file_worker.refresh_sync(list(outdated), post_processing=False) + flag_outdated_zim_files() # Trigger Kiwix restart so the deleted zim files are no longer served. restart_kiwix_handler.activate_switch() diff --git a/modules/zim/test/test_restart_kiwix.py b/modules/zim/test/test_restart_kiwix.py index e66fdec9..c7e72841 100644 --- a/modules/zim/test/test_restart_kiwix.py +++ b/modules/zim/test/test_restart_kiwix.py @@ -7,6 +7,7 @@ from modules.zim import lib from modules.zim.models import Zim from wrolpi.api_utils import api_app from wrolpi.files.models import FileGroup +from wrolpi.files.worker import file_worker @pytest.mark.asyncio @@ -145,3 +146,28 @@ async def test_remove_outdated_zim_files_no_restart_when_nothing_deleted(async_c assert deleted_count == 0 switches = dict(api_app.shared_ctx.switches) if api_app.shared_ctx.switches else {} assert 'restart_kiwix' not in switches + + +@pytest.mark.asyncio +async def test_remove_outdated_zim_files_skips_post_processing(async_client, test_session, test_directory, + zim_path_factory): + """Deleting outdated Zims must not run the global modelers inside the request. + + `DELETE /api/zim/outdated` awaits this; modeling the whole unindexed backlog there exceeded the + response timeout and the cancelled request stranded the file worker status.""" + zim_dir = test_directory / 'zims' + zim_dir.mkdir(exist_ok=True) + test_zim_bytes = zim_path_factory().read_bytes() + paths = [zim_dir / 'wikipedia_en_all_maxi_2020-01.zim', zim_dir / 'wikipedia_en_all_maxi_2020-02.zim'] + for path in paths: + path.write_bytes(test_zim_bytes) + await file_worker.refresh_sync(paths) + assert test_session.query(FileGroup).count() == 2 + + with mock.patch('wrolpi.files.worker.apply_modelers') as apply_modelers: + deleted_count = await lib.remove_outdated_zim_files(zim_dir) + + assert deleted_count == 1 + apply_modelers.assert_not_called() + # The deleted Zim is still removed from the DB. + assert test_session.query(FileGroup).count() == 1 diff --git a/wrolpi/files/test/test_worker.py b/wrolpi/files/test/test_worker.py index b59c1903..8ef1ae08 100644 --- a/wrolpi/files/test/test_worker.py +++ b/wrolpi/files/test/test_worker.py @@ -7,6 +7,7 @@ from unittest import mock import pytest from sqlalchemy import text +from wrolpi import flags from wrolpi.conftest import await_switches from wrolpi.dates import from_timestamp from wrolpi.vars import IS_MACOS @@ -637,6 +638,25 @@ async def test_refresh_sync_records_error_status_on_failure(async_client, test_s assert 'boom' in (file_worker.status['error'] or '') +@pytest.mark.asyncio +async def test_refresh_sync_resets_status_when_cancelled(async_client, test_session, test_directory, + make_files_structure, flags_lock): + """A cancelled synchronous refresh must not strand the status at its last phase. + + `refresh_sync` runs inside request handlers, which Sanic cancels on response timeout. The + phase flags clear when their `with` block unwinds, but the shared status does not, so the + status endpoint kept reporting 'modeling' while nothing ran. + """ + paths = make_files_structure(['docs/file1.txt']) + + with mock.patch('wrolpi.files.worker.apply_modelers', side_effect=asyncio.CancelledError): + with pytest.raises(asyncio.CancelledError): + await file_worker.refresh_sync(paths) + + assert file_worker.status['status'] == 'idle' + assert not flags.file_worker_modeling.is_set() + + @pytest.mark.asyncio async def test_process_queue_resets_status_on_handler_error(async_client, test_session, test_directory, make_files_structure): diff --git a/wrolpi/files/worker.py b/wrolpi/files/worker.py index 99890a5c..ac4e989d 100644 --- a/wrolpi/files/worker.py +++ b/wrolpi/files/worker.py @@ -346,9 +346,9 @@ class FileWorker(FileMoveMixin, FileReorganizeMixin): # `_upsert_file_groups`/`_delete_file_groups`/`_apply_post_processing` all advance the # shared status (upserting/deleting/modeling/indexing/cleanup). Unlike the queued # handlers this path has no `process_queue` wrapper to reset it, so reset here or the UI - # is stranded at the last phase (e.g. 'cleanup' after deleting files). Mirror - # `process_queue`: reset to idle only on success, leave a terminal 'error' status on - # failure (and re-raise so the synchronous caller still sees it). + # is stranded at the last phase (e.g. 'cleanup' after deleting files). Reset to idle on + # success or cancellation, leave a terminal 'error' status on failure (and re-raise so the + # synchronous caller still sees it). try: result = await self._refresh_files_directly(paths) self._cleanup_modified_models(result.modified) @@ -361,6 +361,9 @@ class FileWorker(FileMoveMixin, FileReorganizeMixin): # unindexed files and could exceed Sanic's RESPONSE_TIMEOUT. await self._apply_post_processing(is_global_refresh=False) except asyncio.CancelledError: + # Sanic cancels the request handler on response timeout. Nothing is running anymore, so + # report idle; the unfinished modeling is picked up by the next refresh. + self.reset_status() raise except Exception as e: logger.error(f'refresh_sync failed for {len(paths)} paths', exc_info=e) From 1f687fa82546ae768eabbd0f161f9e04ca18a93a Mon Sep 17 00:00:00 2001 From: lrnselfreliance <33263708+lrnselfreliance@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:04:11 -0600 Subject: [PATCH 074/181] Move entry tags to the current Zim when deleting outdated Zims (#776) Deleting a Zim cascade-deletes its TagZimEntry rows, and the next tags save writes them out of the config, so every outdated-Zim delete lost its entry tags. The config-import migration only runs at startup, too late to help. Move each outdated Zim's tags to the current Zim of the same name before deleting, then save the tags config. Co-authored-by: Claude Opus 5.5 --- modules/zim/lib.py | 43 +++++++++++++++++++++++++++++++++--- modules/zim/test/test_lib.py | 40 +++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/modules/zim/lib.py b/modules/zim/lib.py index 0a746854..0ef95e8c 100644 --- a/modules/zim/lib.py +++ b/modules/zim/lib.py @@ -15,7 +15,7 @@ from modules.zim import kiwix from modules.zim.errors import UnknownZim, UnknownZimSubscription from modules.zim.kiwix import KIWIX_CATALOG from modules.zim.models import Zim, Zims, TagZimEntry, ZimSubscription -from wrolpi import flags +from wrolpi import flags, tags from wrolpi.cmd import run_command from wrolpi.common import register_modeler, logger, extract_html_text, extract_headlines, get_media_directory, walk, \ register_refresh_cleanup, background_task, get_wrolpi_config, unique_by_predicate, aiohttp_post @@ -576,9 +576,46 @@ def find_outdated_zim_files(path: pathlib.Path = None) -> Tuple[List[pathlib.Pat return sorted(outdated_files), sorted(current_files) +def migrate_outdated_zim_tags(outdated: List[pathlib.Path], current: List[pathlib.Path]) -> int: + """Move the entry tags of each outdated Zim to the current Zim of the same name. + + Must run before the outdated Zims are deleted; deleting a Zim cascade-deletes its TagZimEntry rows. + + Returns the count of TagZimEntry rows moved.""" + current_by_name = {parse_name(i)[0]: i for i in current} + moved = 0 + with get_db_session(commit=True) as session: + for outdated_path in outdated: + current_path = current_by_name.get(parse_name(outdated_path)[0]) + outdated_zim = Zim.get_by_path(session, outdated_path) + current_zim = Zim.get_by_path(session, current_path) if current_path else None + if not outdated_zim or not current_zim: + continue + + for tag_zim_entry in session.query(TagZimEntry).filter_by(zim_id=outdated_zim.id).all(): + if TagZimEntry.get_by_primary_keys(tag_zim_entry.tag_id, current_zim.id, tag_zim_entry.zim_entry, + session): + # The current Zim already has this tag on this entry. + session.delete(tag_zim_entry) + else: + tag_zim_entry.zim_id = current_zim.id + moved += 1 + # A later outdated Zim may have the same tagged entry. + session.flush() + + if moved: + logger.info(f'Migrated {moved} Zim entry tags from outdated Zims') + return moved + + async def remove_outdated_zim_files(path: pathlib.Path = None) -> int: - """Deletes all old Zim files.""" - outdated, _ = find_outdated_zim_files(path) + """Deletes all old Zim files. Their entry tags are moved to the current Zim of the same name.""" + outdated, current = find_outdated_zim_files(path) + if outdated: + migrate_outdated_zim_tags(outdated, current) + # Save the migrated tags; the config is the source of truth. + tags.save_tags_config.activate_switch() + logger.info(f'Deleting {len(outdated)} outdated Zim files: {outdated}') deleted_count = 0 for file in outdated: diff --git a/modules/zim/test/test_lib.py b/modules/zim/test/test_lib.py index 3b103cb4..961c896f 100644 --- a/modules/zim/test/test_lib.py +++ b/modules/zim/test/test_lib.py @@ -293,6 +293,46 @@ async def test_zim_tag_migration(await_switches, test_session, test_directory, z ] + +@pytest.mark.asyncio +async def test_remove_outdated_zim_files_migrates_tags(await_switches, test_session, test_directory, zim_factory, + tag_factory, test_tags_config): + """Deleting outdated Zims moves their entry tags to the newest Zim of the same name. + + Deleting the Zim cascade-deletes its TagZimEntry rows, and the next tags save writes them out of the config, + so the tags must be moved before the files are deleted.""" + zim1 = zim_factory('wikipedia_en_all_maxi_2020-01.zim') # Outdated. + zim2 = zim_factory('wikipedia_en_all_maxi_2020-02.zim') # Outdated. + zim3 = zim_factory('wikipedia_en_all_maxi_2020-03.zim') # The latest Zim. + other = zim_factory('wikibooks_en_all_maxi_2020-01.zim') # A different Zim, not outdated. + tag1, tag2 = await tag_factory('tag1'), await tag_factory('tag2') + zim1.tag_entry(test_session, tag1.name, 'home') + zim1.tag_entry(test_session, tag2.name, 'one') + zim2.tag_entry(test_session, tag2.name, 'two') + zim3.tag_entry(test_session, tag2.name, 'one') # Already on the latest Zim; must not be duplicated. + other.tag_entry(test_session, tag1.name, 'home') + test_session.commit() + zim3_path, other_path = zim3.path, other.path + await await_switches() + + assert await lib.remove_outdated_zim_files(test_directory) == 2 + await await_switches() + + test_session.expire_all() + tag_zim_entries = sorted((i.tag.name, i.zim.path, i.zim_entry) for i in test_session.query(TagZimEntry)) + assert tag_zim_entries == [ + ('tag1', other_path, 'home'), + ('tag1', zim3_path, 'home'), + ('tag2', zim3_path, 'one'), + ('tag2', zim3_path, 'two'), + ] + + # The tags config was saved with the migrated tags, so a later import keeps them. + config_text = tags.get_tags_config().get_file().read_text() + assert 'wikipedia_en_all_maxi_2020-01.zim' not in config_text + assert 'wikipedia_en_all_maxi_2020-02.zim' not in config_text + assert 'wikipedia_en_all_maxi_2020-03.zim' in config_text + @pytest.mark.parametrize( 'url,expected', [ From cfbed9e4d7b507d5312145b0325c3e16fb2e2ea3 Mon Sep 17 00:00:00 2001 From: lrnselfreliance <33263708+lrnselfreliance@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:28:08 -0600 Subject: [PATCH 075/181] db: serializers read only loaded data (#777) * db: serializers read only loaded data Serializing a model must not query. A lazy load there is a hidden query per row (search serialized 24 rows with 55 statements), and would be a `MissingGreenlet` error under an async Session. - `wrolpi.db.no_db_access(reason)` and `@serializer` (on every model `__json__`, and around `json_response`): an engine `before_cursor_execute` listener raises `UnexpectedQuery` during tests, and in production logs once per serializer and lets the query run. Tests record each trip as well, and the autouse `no_unexpected_queries` fixture fails a test even when the code caught the error (Video's serializer was swallowing its per-row loads of the deferred `ffprobe_json`). - Relationships serializers read load with their parent: `selectin` for FileGroup.tag_files and Collection.downloads; `joined` for TagFile.tag, Collection.tag, Channel.collection, Video.channel, Archive.collection, ZimSubscription.download, CollectionItem.zim, and the file_group of Video, Archive, Doc, Zim and CollectionItem. - Search and the Video lookups the video page uses load the deferred `ffprobe_json` (`undefer`). - `wrolpi/test/test_serializers.py`: every serialized model, freshly loaded, serializes without SQL. Search: 55 -> 5 statements for 24 videos (31 -> 5 for archives), and a few ms faster locally. Not in this change: guarding the `dict()` serializers and `expire_on_commit=False`, which go together (about 50 serialize-after-commit sites, and 40 tests that read values changed elsewhere after a commit). Co-Authored-By: Claude Opus 5.5 * db: serializer loads per query; the guard ignores transaction control The query guard reported the engine's own BEGIN (emitted through the same event as the first statement on a Session with no open transaction) instead of the lazy load after it, and in production that BEGIN used up the one warning per serializer. Transaction-control statements are now ignored. Mapper-level eager loads reached background code: every Video/Archive load in the modelers (one per file, inside the write transaction) joined file_group/channel/collection/tag and selectin-loaded the Collection's Download rows with their yt-dlp info_json. A second refresh of 100 videos took 0.40 s instead of 0.16 s. Serializer needs are now declared per model (`json_options()`) and added only by the queries whose results are serialized; Download.info_json is deferred there (no serializer reads it). Mapper defaults keep only narrow joins nearly every use needs: TagFile.tag, Collection.tag and Channel.collection. A second refresh is back to master's statement count and time; search still drops from 55 to 3 statements. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- modules/archive/models.py | 10 ++- modules/docs/lib.py | 7 +- modules/docs/models.py | 10 ++- modules/videos/channel/lib.py | 5 +- modules/videos/models.py | 40 +++++++--- modules/videos/test/test_video.py | 3 + modules/videos/video/lib.py | 2 +- modules/zim/lib.py | 2 +- modules/zim/models.py | 18 ++++- pytest.ini | 2 + wrolpi/api_utils.py | 5 +- wrolpi/collections/api.py | 4 + wrolpi/collections/lib.py | 10 ++- wrolpi/collections/models.py | 21 +++++- .../collections/test/test_playlist_items.py | 73 ++++++++++--------- wrolpi/conftest.py | 14 ++++ wrolpi/db.py | 58 +++++++++++++++ wrolpi/downloader.py | 3 +- wrolpi/files/lib.py | 7 +- wrolpi/files/models.py | 11 ++- wrolpi/tags.py | 6 +- wrolpi/test/test_db.py | 67 ++++++++++++++++- wrolpi/test/test_serializers.py | 63 ++++++++++++++++ wrolpi/test/test_tags.py | 2 + 24 files changed, 371 insertions(+), 72 deletions(-) create mode 100644 wrolpi/test/test_serializers.py diff --git a/modules/archive/models.py b/modules/archive/models.py index 475df06f..a6604918 100644 --- a/modules/archive/models.py +++ b/modules/archive/models.py @@ -5,13 +5,14 @@ from typing import Iterable, List, Optional import pytz from sqlalchemy import Column, Integer, ForeignKey, BigInteger, Index -from sqlalchemy.orm import relationship, Session +from sqlalchemy.orm import relationship, Session, joinedload from wrolpi import dates from wrolpi.collections import Collection from wrolpi.common import ModelHelper, Base, logger, get_title_from_html, get_wrolpi_config, get_media_directory, \ strip_surrogates from wrolpi.dates import now +from wrolpi.db import serializer from wrolpi.errors import UnknownArchive from wrolpi.files.models import FileGroup from wrolpi.tags import TagFile @@ -39,6 +40,13 @@ class Archive(Base, ModelHelper): file_group_id = Column(BigInteger, ForeignKey('file_group.id', ondelete='CASCADE'), unique=True, nullable=False) file_group: FileGroup = relationship('FileGroup') + @staticmethod + def json_options() -> tuple: + """Loader options for a query whose Archives will be serialized: `__json__` reads the FileGroup and its Tags, + and the domain (the Collection's name).""" + return joinedload(Archive.file_group).selectinload(FileGroup.tag_files), joinedload(Archive.collection) + + @serializer def __json__(self) -> dict: d = self.file_group.__json__() d['archive'] = dict( diff --git a/modules/docs/lib.py b/modules/docs/lib.py index 72d51de6..d28db9fe 100644 --- a/modules/docs/lib.py +++ b/modules/docs/lib.py @@ -340,7 +340,7 @@ def _doc_response(doc) -> dict: def _get_doc(session, file_group_id: int): from .models import Doc - doc = session.query(Doc).filter_by(file_group_id=file_group_id).one_or_none() + doc = session.query(Doc).options(*Doc.json_options()).filter_by(file_group_id=file_group_id).one_or_none() if not doc: raise ValidationError(f'Doc with file_group_id {file_group_id} not found') return doc @@ -375,7 +375,7 @@ def _search_docs(session: Session, search_str=None, author=None, subject=None, l # the much smaller a/b/c columns. match = fts.translate_websearch(search_str, None if deep else fts.ABC_COLUMNS) if search_str else None - query = session.query(FileGroup).join(Doc, Doc.file_group_id == FileGroup.id) + query = session.query(FileGroup).options(*FileGroup.json_options()).join(Doc, Doc.file_group_id == FileGroup.id) fts_sq = None if match is not None: @@ -441,7 +441,8 @@ def _search_docs(session: Session, search_str=None, author=None, subject=None, l browse_sql = _doc_browse_sql(order_by if order_by in DOC_BROWSE_ORDERS else 'id') if browse_sql: fg_ids = [i for i, in session.execute(text(browse_sql), dict(limit=limit, offset=offset)).fetchall()] - by_id = {fg.id: fg for fg in session.query(FileGroup).filter(FileGroup.id.in_(fg_ids))} if fg_ids else {} + by_id = {fg.id: fg for fg in session.query(FileGroup).options(*FileGroup.json_options()) + .filter(FileGroup.id.in_(fg_ids))} if fg_ids else {} file_groups = [by_id[i] for i in fg_ids] else: file_groups = query.offset(offset).limit(limit).all() diff --git a/modules/docs/models.py b/modules/docs/models.py index ff5d08ac..ba2e0f62 100644 --- a/modules/docs/models.py +++ b/modules/docs/models.py @@ -2,10 +2,10 @@ import pathlib from typing import Optional from sqlalchemy import Column, Integer, BigInteger, ForeignKey, String, Text, Index, or_ -from sqlalchemy.orm import relationship, Session +from sqlalchemy.orm import relationship, Session, joinedload from wrolpi.common import ModelHelper, Base -from wrolpi.db import get_db_session +from wrolpi.db import get_db_session, serializer from wrolpi.files.models import FileGroup EPUB_MIMETYPE = 'application/epub' @@ -65,6 +65,12 @@ class Doc(ModelHelper, Base): path = str(self.file_group.primary_path) if self.file_group else 'None' return f'' + @staticmethod + def json_options() -> tuple: + """Loader options for a query whose Docs will be serialized: `__json__` reads the FileGroup and its Tags.""" + return joinedload(Doc.file_group).selectinload(FileGroup.tag_files), + + @serializer def __json__(self) -> dict: d = self.file_group.__json__() if d.get('data') and d['data'].get('doc_path'): diff --git a/modules/videos/channel/lib.py b/modules/videos/channel/lib.py index 86f48db1..f32d31c1 100644 --- a/modules/videos/channel/lib.py +++ b/modules/videos/channel/lib.py @@ -208,6 +208,7 @@ async def search_channels_by_name(session: Session, name: str, limit: int = 5, name_order = (func.lower(func.replace(Collection.name, ' ', '')).asc(), Collection.name.desc()) if order_by_video_count: stmt = session.query(Channel, func.count(Video.id).label('video_count')) \ + .options(*Channel.json_options()) \ .join(Collection) \ .filter(or_( Collection.name.ilike(f'%{name}%'), @@ -220,6 +221,7 @@ async def search_channels_by_name(session: Session, name: str, limit: int = 5, channels = [i[0] for i in stmt] else: stmt = session.query(Channel) \ + .options(*Channel.json_options()) \ .join(Collection) \ .filter(or_( Collection.name.ilike(f'%{name}%'), @@ -319,5 +321,6 @@ async def tag_channel(session: Session, tag_name: str | None, directory: pathlib async def search_channels(session: Session, tag_names: List[str]) -> List[Channel]: """Search Tagged Channels.""" - channels = session.query(Channel).join(Collection).join(Tag).filter(Tag.name.in_(tag_names)).all() + channels = session.query(Channel).options(*Channel.json_options()) \ + .join(Collection).join(Tag).filter(Tag.name.in_(tag_names)).all() return channels diff --git a/modules/videos/models.py b/modules/videos/models.py index 3e3d87ee..b8591ef4 100644 --- a/modules/videos/models.py +++ b/modules/videos/models.py @@ -3,14 +3,15 @@ import pathlib from typing import Optional, Dict, List from sqlalchemy import Column, Integer, String, Boolean, Date, ForeignKey, BigInteger, Index, text, JSON -from sqlalchemy.orm import relationship, Session, deferred +from sqlalchemy.orm import relationship, Session, deferred, undefer, joinedload, selectinload from sqlalchemy.orm.collections import InstrumentedList from modules.videos.errors import UnknownVideo, UnknownChannel from wrolpi.captions import read_captions, read_captions_with_timestamps from wrolpi.common import Base, ModelHelper, logger, get_media_directory, get_relative_to_media_directory, \ background_task -from wrolpi.db import get_db_curs, get_db_session, session_curs +from wrolpi.db import get_db_curs, get_db_session, session_curs, serializer +from wrolpi.collections.models import Collection from wrolpi.downloader import Download from wrolpi.files.lib import split_path_stem_and_suffix from wrolpi.files.worker import file_worker @@ -57,6 +58,14 @@ class Video(ModelHelper, Base): return f'
- handleDelete(pin.id)} - /> + + {solarButton(pin)} + handleDelete(pin.id)} + /> + ; }; diff --git a/app/src/components/MapTables.test.js b/app/src/components/MapTables.test.js index efbf0242..5755b228 100644 --- a/app/src/components/MapTables.test.js +++ b/app/src/components/MapTables.test.js @@ -1,5 +1,5 @@ import React from 'react'; -import {act, screen} from '@testing-library/react'; +import {act, fireEvent, screen} from '@testing-library/react'; import {Route, Routes} from 'react-router'; import {renderWithProviders as render} from '../test-utils'; import {MapRoute} from './Map'; @@ -92,6 +92,16 @@ describe('the Pins tab', () => { expect(screen.getAllByText('Camp').length).toBeGreaterThan(0); expect(screen.queryAllByText(NO_PINS)).toHaveLength(0); }); + + test('a pin opens the Solar calculator at its location', async () => { + api.getMapPins.mockResolvedValue({pins: [{id: 1, lat: 40.12346, lon: -111.2, label: 'Camp', color: '#f00'}]}); + renderAt('/map/pins'); + await act(async () => { + }); + fireEvent.click(screen.getAllByRole('button', {name: 'Solar estimate for Camp'})[0]); + expect(window.location.pathname).toBe('/more/calculators'); + expect(window.location.search).toBe('?calc=solar&lat=40.1235&lon=-111.2'); + }); }); describe('the Manage tab subscriptions table', () => { diff --git a/app/src/components/MapViewer.js b/app/src/components/MapViewer.js index 08469f6c..dc37b2ae 100644 --- a/app/src/components/MapViewer.js +++ b/app/src/components/MapViewer.js @@ -1,5 +1,5 @@ import React, {useCallback, useContext, useEffect, useRef, useState} from "react"; -import {useSearchParams} from "react-router"; +import {useNavigate, useSearchParams} from "react-router"; import maplibregl from "maplibre-gl"; import "maplibre-gl/dist/maplibre-gl.css"; import {Protocol} from "pmtiles"; @@ -11,6 +11,7 @@ import {Button, Checkbox, Header, Icon, Panel, TextInput, Toggle} from "./ui"; import {SettingsContext, ThemeContext} from "../contexts/contexts"; import {mapFlavor, mapSprite} from "../themes/names"; import {poiPopupHtml} from "./mapPoiPopup"; +import {solarCalculatorPath} from "./calculators/solar"; // Terrain DEM file prefix for hillshade and contours. const TERRAIN_PREFIX = "terrain-"; @@ -531,6 +532,7 @@ export default function MapViewer() { const [mapReady, setMapReady] = useState(false); const [error, setError] = useState(null); const [contextMenu, setContextMenu] = useState(null); + const navigate = useNavigate(); const [addingPin, setAddingPin] = useState(null); // {lat, lon} when pin dialog is open const markersRef = useRef([]); const pinsVisibleRef = useRef(true); @@ -772,12 +774,16 @@ export default function MapViewer() { } mapRef.current = null; setMapReady(false); - // Clean up map params from URL so they don't leak to other pages. + // Clean up map params from URL so they don't leak to other pages. Only while the URL is + // still a map page: after navigating away, lat/lon belong to the new page (the Solar + // calculator, for one). const url = new URL(window.location); - url.searchParams.delete("lat"); - url.searchParams.delete("lon"); - url.searchParams.delete("z"); - window.history.replaceState(null, '', url.pathname + url.search); + if (url.pathname.startsWith("/map")) { + url.searchParams.delete("lat"); + url.searchParams.delete("lon"); + url.searchParams.delete("z"); + window.history.replaceState(null, '', url.pathname + url.search); + } }; }, []); // eslint-disable-line react-hooks/exhaustive-deps @@ -923,6 +929,12 @@ export default function MapViewer() { setContextMenu(null); }, [contextMenu]); + const handleSolar = useCallback(() => { + if (!contextMenu) return; + setContextMenu(null); + navigate(solarCalculatorPath(contextMenu.lat, contextMenu.lon)); + }, [contextMenu, navigate]); + const handleSetDefaultLocation = useCallback(async () => { const map = mapRef.current; if (!map) return; @@ -999,6 +1011,14 @@ export default function MapViewer() { > Add Pin Here
+
e.target.style.background = "#f0f0f0"} + onMouseLeave={e => e.target.style.background = "transparent"} + onClick={handleSolar} + > + Calculate Solar Performance +
e.target.style.background = "#f0f0f0"} diff --git a/app/src/components/MapViewer.test.js b/app/src/components/MapViewer.test.js new file mode 100644 index 00000000..2afce0f4 --- /dev/null +++ b/app/src/components/MapViewer.test.js @@ -0,0 +1,45 @@ +import React from 'react'; +import {renderWithProviders as render} from '../test-utils'; +import MapViewer from './MapViewer'; + +// MapLibre needs WebGL, which jsdom lacks; these tests only cover what the viewer does to the URL. +jest.mock('maplibre-gl', () => ({ + __esModule: true, + default: {Map: class { + }, addProtocol: jest.fn(), Marker: class { + }, Popup: class { + }}, +})); +jest.mock('maplibre-gl/dist/maplibre-gl.css', () => ({})); +jest.mock('pmtiles', () => ({ + __esModule: true, Protocol: class { + tile = jest.fn(); + }, +})); +jest.mock('protomaps-themes-base', () => ({__esModule: true, default: () => []})); +jest.mock('@acalcutt/maplibre-contour-pmtiles', () => ({__esModule: true, default: {}})); +jest.mock('../api', () => ({ + ...jest.requireActual('../api'), + // Never resolves: the map never initializes, which these tests do not need. + getMapFiles: jest.fn(() => new Promise(() => { + })), + getMapPins: jest.fn(() => new Promise(() => { + })), +})); + +describe('the map position in the URL', () => { + test('is removed when the map closes', () => { + const {unmount} = render(, {route: '/map?lat=40&lon=-105&z=8&layer=a'}); + unmount(); + expect(window.location.pathname).toBe('/map'); + expect(window.location.search).toBe('?layer=a'); + }); + + test('is not removed from the page the map navigated to', () => { + // "Calculate Solar Performance" navigates to a page whose own lat/lon must survive the map closing. + const {unmount} = render(, {route: '/map?lat=40&lon=-105&z=8'}); + window.history.pushState({}, '', '/more/calculators?calc=solar&lat=40.0154&lon=-105.2705'); + unmount(); + expect(window.location.search).toBe('?calc=solar&lat=40.0154&lon=-105.2705'); + }); +}); diff --git a/app/src/components/calculators/SolarCalculator.js b/app/src/components/calculators/SolarCalculator.js new file mode 100644 index 00000000..c0e1ca42 --- /dev/null +++ b/app/src/components/calculators/SolarCalculator.js @@ -0,0 +1,625 @@ +import React from "react"; +import {useSearchParams} from "react-router"; +import { + Accordion, + Button, + Grid, + Group, + Header, + IconButton, + Loader, + Message, + NumberInput, + Panel, + Select, + Statistic, + StatisticGroup, + Table, + Tabs, + TextInput, + Toggle, +} from "../ui"; +import {InfoPopup, roundDigits} from "../Common"; +import { + DEFAULT_LOSSES, + defaultAzimuth, + estimateProduction, + formatMonthly, + INVERTER_NOMINAL_EFFICIENCY, + LOSS_CATEGORIES, + MODULE_TYPES, + MONTH_NAMES, + MOUNTINGS, + optimalTilt, + parseCoordinate, + parseMonthly, + totalLoss, + TRACKING, +} from "./solar"; +import { + CLIMATOLOGY_ACKNOWLEDGMENT, + CLIMATOLOGY_CHANGES, + CLIMATOLOGY_DATA_REFERENCE, + CLIMATOLOGY_LICENSE, + lookupClimatology, + useClimatology, +} from "./solarData"; +import {SolarOffGrid} from "./SolarOffGrid"; + +// Every input lives in the URL query so the Share button (which shares window.location.href) shares +// the whole configuration. Values equal to their default are left out to keep the URL, and its QR +// code, small. Keys are short and must stay stable; shared links depend on them. +const DEFAULT_PANEL_WATTS = 400; +const DEFAULT_PANEL_COUNT = 1; + +const COMPASS = [ + {value: '0', label: 'N'}, {value: '45', label: 'NE'}, {value: '90', label: 'E'}, {value: '135', label: 'SE'}, + {value: '180', label: 'S'}, {value: '225', label: 'SW'}, {value: '270', label: 'W'}, {value: '315', label: 'NW'}, +]; + +const fToC = f => (f - 32) * 5 / 9; +const cToF = c => c * 9 / 5 + 32; + +const numberOr = (value, fallback) => { + if (value === null || value === undefined || value === '') { + return fallback; + } + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +}; + +const fmt = (value, digits = 2) => (Number.isFinite(value) ? `${roundDigits(value, digits)}` : '—'); + +// Read and write the calculator's query parameters. Writes replace the history entry, so typing does +// not fill the Back button with one entry per keystroke. +export function useSolarParams() { + const [searchParams, setSearchParams] = useSearchParams(); + + const get = key => searchParams.get(key); + + // `updates` maps keys to values; null, undefined and '' remove the key. + const set = updates => setSearchParams(prev => { + const next = new URLSearchParams(prev); + Object.entries(updates).forEach(([key, value]) => { + if (value === null || value === undefined || value === '') { + next.delete(key); + } else { + next.set(key, `${value}`); + } + }); + return next; + }, {replace: true}); + + // Store a number, or remove it when it equals the default. + const setNumber = (key, value, fallback) => + set({[key]: value === '' || Number(value) === fallback ? null : value}); + + return {searchParams, get, set, setNumber}; +} + +// Turn the query parameters into the model's inputs. Invalid values fall back to defaults, so a +// hand-edited or truncated link never crashes the page. +export function systemFromParams(get) { + const latitude = parseCoordinate(get('lat'), 'lat'); + const longitude = parseCoordinate(get('lon'), 'lon'); + const fahrenheit = get('tu') === 'f'; + + // Own sunlight data replaces the NASA data when present. + const ownData = get('ghi') !== null; + const ghi = parseMonthly(get('ghi')).map(v => (v !== null && v >= 0 ? v : null)); + const temperatureInput = parseMonthly(get('temp')); + const temperature = temperatureInput.map(v => (v === null ? null : (fahrenheit ? fToC(v) : v))); + + const panelWatts = Math.max(0, numberOr(get('pw'), DEFAULT_PANEL_WATTS)); + const panelCount = Math.max(0, Math.round(numberOr(get('pn'), DEFAULT_PANEL_COUNT))); + const kwp = panelWatts * panelCount / 1000; + + const tracking = TRACKING[get('track')] ? get('track') : 'fixed'; + const mounting = MOUNTINGS[get('mount')] ? get('mount') : 'open'; + const gamma = numberOr(get('gamma'), MODULE_TYPES.standard.gamma); + const defaultTilt = latitude === null ? 0 : Math.round(Math.abs(latitude)); + const tilt = Math.min(90, Math.max(0, numberOr(get('tilt'), defaultTilt))); + const azimuth = numberOr(get('az'), defaultAzimuth(latitude ?? 0)); + // Null means "use the monthly albedo from the data", which includes seasonal snow. + const albedoInput = numberOr(get('alb'), null); + const albedo = albedoInput === null ? null : Math.min(1, Math.max(0, albedoInput)); + + const lossValues = parseMonthly(get('losses')); + const losses = LOSS_CATEGORIES.map((c, i) => { + const v = lossValues[i]; + return v !== null && v >= 0 && v <= 100 ? v : c.default; + }); + + const inverterOn = get('inv') === '1'; + const acRating = numberOr(get('iac'), roundDigits(kwp / 1.2, 2)); + const efficiency = numberOr(get('ieff'), INVERTER_NOMINAL_EFFICIENCY); + + return { + latitude, longitude, fahrenheit, ownData, ghi, temperatureInput, temperature, panelWatts, panelCount, + kwp, tracking, mounting, gamma, defaultTilt, tilt, azimuth, albedo, losses, inverterOn, acRating, + efficiency, + }; +} + +// The sunlight inputs for the model: the user's own values, or the NASA data at the location. +export function sunlightFor(s, grid) { + if (s.ownData) { + return {ghi: s.ghi, diffuse: null, temperature: s.temperature, albedo: null}; + } + if (!grid || s.latitude === null || s.longitude === null) { + return null; + } + return lookupClimatology(grid, s.latitude, s.longitude); +} + +// What stops an estimate, or an empty list when it can run. +export function missingInputs(s, sunlight) { + const missing = []; + if (s.latitude === null) { + missing.push('a latitude'); + } + if (!s.ownData && s.longitude === null) { + missing.push('a longitude'); + } + if (s.ownData && s.ghi.some(v => v === null)) { + missing.push('sunlight for all 12 months'); + } + if (!(s.kwp > 0)) { + missing.push('panel watts and count'); + } + if (missing.length === 0 && (!sunlight || sunlight.ghi.some(v => v === null))) { + missing.push('a location with sunlight data'); + } + return missing; +} + +const modelInputs = (s, sunlight) => ({ + latitude: s.latitude, + ghi: sunlight.ghi, + diffuse: sunlight.diffuse, + temperature: sunlight.temperature, + albedo: s.albedo ?? sunlight.albedo ?? undefined, + kwp: s.kwp, + tilt: s.tilt, + azimuth: s.azimuth, + tracking: s.tracking, + mounting: s.mounting, + gamma: s.gamma, + losses: s.losses, + inverter: s.inverterOn ? {acRating: s.acRating, efficiency: s.efficiency} : null, +}); + +const compassLabel = azimuth => { + const exact = COMPASS.find(c => Number(c.value) === azimuth); + return exact ? exact.label : `${azimuth}°`; +}; + +// Small explanatory text at the top of a collapsed section. +const Hint = ({children}) =>

{children}

; + +// NASA POWER's requested acknowledgment, the license, and how WROLPi changed the data (CC BY 4.0). +function DataAttribution() { + return
+
Data attribution
+

{CLIMATOLOGY_ACKNOWLEDGMENT} {CLIMATOLOGY_DATA_REFERENCE}

+

License: {CLIMATOLOGY_LICENSE}

+

{CLIMATOLOGY_CHANGES}

+
; +} + +const ESTIMATE_INFO = 'A typical-year estimate from monthly averages. Real years vary; expect roughly ±10–15% ' + + 'over a year and more in any single month. Shading from trees and buildings is not modeled beyond the ' + + 'Shading loss.'; + +// Browsers only offer geolocation to pages served over HTTPS. +export const geolocationAvailable = () => Boolean(window.isSecureContext && navigator.geolocation); + +const GEOLOCATION_ERRORS = { + 1: 'Location permission was denied. Allow it in your browser\'s settings for this site.', + 2: 'Your device could not find its location. Phones with GPS work offline; most computers need the Internet ' + + 'for this.', + 3: 'Finding your location took too long. Phones with GPS work offline; most computers need the Internet for ' + + 'this.', +}; + +function LocationSection({get, set, s, climatology}) { + const [locating, setLocating] = React.useState(false); + const [geoError, setGeoError] = React.useState(null); + const latError = get('lat') && s.latitude === null ? 'Latitude must be between -90 and 90' : null; + const lonError = get('lon') && s.longitude === null ? 'Longitude must be between -180 and 180' : null; + + const locate = () => { + setLocating(true); + setGeoError(null); + navigator.geolocation.getCurrentPosition( + ({coords}) => { + setLocating(false); + // 4 decimals (about 11 m) is far finer than the 1° data and keeps the shared URL short. + set({lat: Number(coords.latitude.toFixed(4)), lon: Number(coords.longitude.toFixed(4))}); + }, + error => { + setLocating(false); + setGeoError(GEOLOCATION_ERRORS[error.code] || error.message); + }, + {enableHighAccuracy: false, timeout: 20000, maximumAge: 10 * 60 * 1000}, + ); + }; + + // The data's attribution is at the bottom of the page; this line only reports the data's state. + let source = null; + if (s.ownData) { + source = 'Using your own sunlight data.'; + } else if (climatology.loading) { + source = ; + } else if (climatology.error) { + source = + Enter your own monthly sunlight under "Use my own sunlight data" below. + ; + } + + return <> +
1. Location
+ + + + + + set({lat: e.target.value})}/> + + + set({lon: e.target.value})}/> + + + {geoError && {geoError}} + {source &&
{source}
} + ; +} + +function PanelsSection({setNumber, s}) { + return <> +
2. Your panels
+ + + setNumber('pw', v, DEFAULT_PANEL_WATTS)}/> + + + setNumber('pn', v, DEFAULT_PANEL_COUNT)}/> + + + + {fmt(s.kwp * 1000, 0)} W total ({fmt(s.kwp)} kW) + + + + ; +} + +function Results({result, s}) { + const unit = s.inverterOn ? 'kWh AC' : 'kWh DC'; + const rows = result.months.map(m => + {m.month} + {fmt(m.energyDaily)} + {fmt(m.energy, 0)} + {fmt(m.poa)} + {fmt(m.dayLength, 1)} h + ); + + return +
Estimate
+ + + + + + + + + + Month + {unit}/day + {unit}/month + Sun hours on panel + Day length + + + {rows} +
+
; +} + +function AngleSection({setNumber, s, sunlight, ready}) { + const compass = COMPASS.find(c => Number(c.value) === s.azimuth)?.value || null; + const applyOptimal = goal => setNumber('tilt', optimalTilt(modelInputs(s, sunlight), goal), s.defaultTilt); + const azimuthDefault = defaultAzimuth(s.latitude ?? 0); + + if (s.tracking !== 'fixed') { + return + A tracking array turns to follow the sun, so it has no fixed angle. Change Tracking under Panel details + to set one. + ; + } + + return <> + + Leave these alone unless your panels are already mounted. The default (tilted to your latitude, facing + the equator) is close to the best for the year. "Best for winter" suits off-grid systems that must get + through the darkest month. + + + + setNumber('tilt', v, s.defaultTilt)}/> + + + + + + + + setNumber('az', v, azimuthDefault)}/> + ({value, label: m.label})), + {value: 'custom', label: 'Custom (from datasheet)'}, + ]} + value={moduleType} allowDeselect={false} + onChange={v => v !== 'custom' && setNumber('gamma', MODULE_TYPES[v].gamma, + MODULE_TYPES.standard.gamma)}/> + + + setNumber('gamma', v, MODULE_TYPES.standard.gamma)}/> + + + ({value, label: t.label}))} + value={s.tracking} allowDeselect={false} + onChange={v => set({track: v === 'fixed' ? null : v})}/> + + + set({alb: v === '' ? null : v})}/> + + + ; +} + +function LossesSection({set, s}) { + const setLoss = (index, value) => { + const next = [...s.losses]; + next[index] = value === '' ? LOSS_CATEGORIES[index].default : Number(value); + const isDefault = next.every((v, i) => v === DEFAULT_LOSSES[i]); + set({losses: isDefault ? null : formatMonthly(next)}); + }; + + return <> + + Real systems lose some power to dirt, wiring, shading and more. These are PVWatts' defaults. Raise Shading + if trees or buildings shade your panels, or Snow if snow sits on them for weeks. + + + {LOSS_CATEGORIES.map((c, i) => + setLoss(i, v)}/> + )} + + + ; +} + +function InverterSection({set, setNumber, s}) { + return <> + + Leave this off if your panels charge batteries through a charge controller. Turn it on to see AC output + from a grid-tie or hybrid inverter. + + set({inv: e.currentTarget.checked ? '1' : null})}/> + {s.inverterOn && + + setNumber('iac', v, roundDigits(s.kwp / 1.2, 2))}/> + + + setNumber('ieff', v, INVERTER_NOMINAL_EFFICIENCY)}/> + + } + ; +} + +function OwnDataSection({set, s, nasa}) { + const unit = s.fahrenheit ? '°F' : '°C'; + + // Start from the NASA values, when available, so the user edits rather than types all 24. + const enable = on => { + if (!on) { + set({ghi: null, temp: null}); + return; + } + const ghi = nasa ? nasa.ghi.map(v => (v === null ? '' : roundDigits(v, 2))) : Array(12).fill(''); + const temp = nasa + ? nasa.temperature.map(v => (v === null ? '' : roundDigits(s.fahrenheit ? cToF(v) : v, 1))) + : Array(12).fill(''); + // An all-blank list is still written for sunlight, so the section stays on for typing. + set({ghi: formatMonthly(ghi), temp: nasa ? formatMonthly(temp) : null}); + }; + + // Convert the temperatures with the unit, so they stay the same physical temperatures. + const switchUnits = toFahrenheit => { + const temps = s.temperatureInput.map(v => (v === null ? null + : roundDigits(toFahrenheit ? cToF(v) : fToC(v), 1))); + set({tu: toFahrenheit ? 'f' : null, temp: temps.some(v => v !== null) ? formatMonthly(temps) : null}); + }; + + const setMonth = (key, values, index, value) => { + const next = [...values]; + next[index] = value === '' ? null : value; + set({[key]: formatMonthly(next)}); + }; + + const rows = MONTH_NAMES.map((name, i) => + {name} + + setMonth('ghi', s.ghi, i, v)}/> + + + setMonth('temp', s.temperatureInput, i, v)}/> + + ); + + return <> + + Only needed if you have better local measurements than the built-in data, for example in mountains, where + a 1° grid cell (about 69 miles across) can average very different terrain. Sunlight is global horizontal + irradiance in kWh/m²/day, the same as "peak sun hours" on flat ground. Blank temperatures use 20 °C. + + + enable(e.currentTarget.checked)}/> + {s.ownData && switchUnits(e.currentTarget.checked)}/>} + + {s.ownData && + + + Month + Sunlight (kWh/m²/day) + Avg. temp ({unit}) + + + {rows} +
} + ; +} + +export function SolarCalculator() { + const {searchParams, get, set, setNumber} = useSolarParams(); + const climatology = useClimatology(); + const queryString = searchParams.toString(); + // eslint-disable-next-line react-hooks/exhaustive-deps + const s = React.useMemo(() => systemFromParams(get), [queryString]); + const nasa = React.useMemo( + () => (climatology.grid && s.latitude !== null && s.longitude !== null + ? lookupClimatology(climatology.grid, s.latitude, s.longitude) : null), + [climatology.grid, s.latitude, s.longitude]); + const sunlight = s.ownData ? sunlightFor(s, null) : nasa; + const missing = missingInputs(s, sunlight); + const ready = missing.length === 0; + const result = React.useMemo( + () => (ready ? estimateProduction(modelInputs(s, sunlight)) : null), + // eslint-disable-next-line react-hooks/exhaustive-deps + [s, sunlight, ready]); + // Off-grid sizing works from DC production per kW of panels, whatever the inverter setting. + const worstPerKwp = React.useMemo( + () => (ready ? estimateProduction({...modelInputs(s, sunlight), kwp: 1, inverter: null}).worst : null), + // eslint-disable-next-line react-hooks/exhaustive-deps + [s, sunlight, ready]); + const tab = get('tab') === 'offgrid' ? 'offgrid' : 'estimate'; + + const angleTitle = s.tracking === 'fixed' + ? `Panel angle: ${s.tilt}° tilt, facing ${compassLabel(s.azimuth)}` + : 'Panel angle: follows the sun'; + const detailsTitle = `Panel details: ${TRACKING[s.tracking].label.toLowerCase()}, ` + + `${MOUNTINGS[s.mounting].label.toLowerCase()}`; + const waitingForData = climatology.loading && !s.ownData; + + return
+
Solar
+

+ Estimate what solar panels will produce over a typical year. Enter a location and your panels; everything + else has sensible defaults. Settings are saved in the page address, so you can bookmark or share them. +

+ + + + + set({tab: v === 'offgrid' ? v : null})} keepMounted={false} + style={{marginTop: '1.5em'}}> + + Estimate + Off-grid sizing + + + {ready && } + {!ready && !waitingForData &&
+ Enter {missing.join(', ')} to see an estimate. +
} +
+ + + +
+ +
Fine-tune (optional)
+ + + {angleTitle} + + + + + + {detailsTitle} + + + + System losses: {fmt(totalLoss(s.losses))}% + + + + Inverter: {s.inverterOn ? 'on (AC output)' : 'off (DC output)'} + + + + Use my own sunlight data: {s.ownData ? 'on' : 'off'} + + + + + +
; +} diff --git a/app/src/components/calculators/SolarCalculator.test.js b/app/src/components/calculators/SolarCalculator.test.js new file mode 100644 index 00000000..74f8799f --- /dev/null +++ b/app/src/components/calculators/SolarCalculator.test.js @@ -0,0 +1,256 @@ +import React from "react"; +import {fireEvent, screen, within} from "@testing-library/react"; +import {renderWithProviders} from "../../test-utils"; +import {missingInputs, SolarCalculator, sunlightFor, systemFromParams} from "./SolarCalculator"; +import {CalculatorsPage} from "../Calculators"; +import {DEFAULT_LOSSES, estimateProduction, MODULE_TYPES} from "./solar"; +import {decodeClimatology, forgetClimatology, lookupClimatology} from "./solarData"; +import {mockClimatologyFetch, SUNNY_GHI, uniformClimatology} from "./solarData.fixtures"; +import {roundDigits} from "../Common"; + +const BASE = '/more/calculators?calc=solar'; +const DENVER = `${BASE}&lat=40&lon=-105`; + +const params = query => { + const searchParams = new URLSearchParams(query); + return key => searchParams.get(key); +}; + +const currentParams = () => new URLSearchParams(window.location.search); + +// The estimate appears once the data file has loaded and decoded, which can be slow on a busy machine. +const findEstimate = (unit = 'DC') => screen.findByText(`kWh ${unit} per year`, {}, {timeout: 5000}); + +// The annual estimate the page should show for a location, from the same uniform test data. +const expectedAnnual = (overrides = {}) => { + const grid = decodeClimatology(uniformClimatology()); + const sunlight = lookupClimatology(grid, 40, -105); + return estimateProduction({latitude: 40, kwp: 0.4, tilt: 40, azimuth: 180, ...sunlight, ...overrides}).annual; +}; + +beforeEach(() => { + forgetClimatology(); + mockClimatologyFetch(); +}); + +describe('systemFromParams', () => { + test('defaults when the URL is empty', () => { + const s = systemFromParams(params('')); + expect(s.latitude).toBeNull(); + expect(s.kwp).toBeCloseTo(0.4, 6); + expect(s.tracking).toBe('fixed'); + expect(s.mounting).toBe('open'); + expect(s.gamma).toBe(MODULE_TYPES.standard.gamma); + expect(s.losses).toEqual(DEFAULT_LOSSES); + expect(s.inverterOn).toBe(false); + expect(s.ownData).toBe(false); + expect(s.albedo).toBeNull(); + }); + + test('tilt and azimuth default from the latitude', () => { + expect(systemFromParams(params('lat=40.4')).tilt).toBe(40); + expect(systemFromParams(params('lat=40.4')).azimuth).toBe(180); + expect(systemFromParams(params('lat=-33.9')).tilt).toBe(34); + expect(systemFromParams(params('lat=-33.9')).azimuth).toBe(0); + }); + + test('invalid values fall back instead of crashing', () => { + const s = systemFromParams(params('lat=999&track=bogus&mount=x&tilt=abc&losses=1,2,abc&alb=7&pn=-3')); + expect(s.latitude).toBeNull(); + expect(s.tracking).toBe('fixed'); + expect(s.mounting).toBe('open'); + expect(s.tilt).toBe(0); + expect(s.losses.slice(0, 3)).toEqual([1, 2, DEFAULT_LOSSES[2]]); + expect(s.albedo).toBe(1); + expect(s.kwp).toBe(0); + }); + + test('own Fahrenheit temperatures are converted to Celsius', () => { + const s = systemFromParams(params('ghi=5&tu=f&temp=32,212')); + expect(s.ownData).toBe(true); + expect(s.temperature[0]).toBeCloseTo(0, 6); + expect(s.temperature[1]).toBeCloseTo(100, 6); + expect(s.temperatureInput[0]).toBe(32); + }); +}); + +describe('sunlight and missing inputs', () => { + const grid = decodeClimatology(uniformClimatology()); + + test('the NASA data is used unless the user gave their own', () => { + const nasa = sunlightFor(systemFromParams(params('lat=40&lon=-105')), grid); + expect(nasa.ghi[5]).toBeCloseTo(SUNNY_GHI[5], 1); + expect(nasa.diffuse[5]).toBeCloseTo(SUNNY_GHI[5] * 0.3, 1); + + const own = sunlightFor(systemFromParams(params(`lat=40&lon=-105&ghi=${Array(12).fill(1).join(',')}`)), grid); + expect(own.ghi).toEqual(Array(12).fill(1)); + expect(own.diffuse).toBeNull(); + }); + + test('a location is enough with the NASA data', () => { + const s = systemFromParams(params('lat=40&lon=-105')); + expect(missingInputs(s, sunlightFor(s, grid))).toEqual([]); + const noLon = systemFromParams(params('lat=40')); + expect(missingInputs(noLon, sunlightFor(noLon, grid))).toEqual(['a longitude']); + }); + + test('own data needs all 12 months but no longitude', () => { + const s = systemFromParams(params('lat=40&ghi=1,2,3')); + expect(missingInputs(s, sunlightFor(s, grid))).toEqual(['sunlight for all 12 months']); + }); +}); + +describe('SolarCalculator', () => { + test('is linked from the Engineering group of the calculators page', () => { + renderWithProviders(, {route: '/more/calculators'}); + const engineering = screen.getByRole('heading', {name: 'Engineering'}).closest('.wrolpi-header').parentElement; + const link = within(engineering).getByRole('link', {name: /Solar/}); + expect(link).toHaveAttribute('href', '/more/calculators?calc=solar'); + }); + + test('asks for a location', async () => { + renderWithProviders(, {route: BASE}); + expect(await screen.findByText('Enter a latitude, a longitude to see an estimate.', {}, {timeout: 5000})) + .toBeInTheDocument(); + }); + + test('a location alone gives an estimate from the built-in data', async () => { + renderWithProviders(, {route: DENVER}); + expect(await findEstimate()).toBeInTheDocument(); + expect(screen.getByText(`${roundDigits(expectedAnnual(), 0)}`)).toBeInTheDocument(); + // The attribution is only at the bottom of the page. + expect(screen.queryByText(/Sunlight and temperature/)).not.toBeInTheDocument(); + expect(global.fetch).toHaveBeenCalledWith(expect.stringMatching(/\/data\/solar-climatology\.bin$/)); + }); + + test('the data attribution is at the bottom of the page', async () => { + renderWithProviders(, {route: DENVER}); + await findEstimate(); + const heading = screen.getByRole('heading', {name: 'Data attribution'}); + const section = screen.getByRole('region', {name: 'Data attribution'}); + expect(within(section).getByText(/Prediction Of Worldwide Energy Resources/)).toBeInTheDocument(); + expect(within(section).getByText(/WROLPi modified the data/)).toBeInTheDocument(); + expect(within(section).getByText(/CC BY 4\.0/)).toBeInTheDocument(); + // After everything else, including the last Fine-tune section. + const lastControl = screen.getByText('Use my own sunlight data: off'); + expect(lastControl.compareDocumentPosition(heading) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + test('typing writes the URL and leaves defaults out', async () => { + renderWithProviders(, {route: BASE}); + fireEvent.change(screen.getByLabelText('Latitude'), {target: {value: '40°0\'54"N'}}); + expect(currentParams().get('lat')).toBe('40°0\'54"N'); + expect(currentParams().get('calc')).toBe('solar'); + + fireEvent.change(screen.getByLabelText('Panels'), {target: {value: '4'}}); + expect(currentParams().get('pn')).toBe('4'); + fireEvent.change(screen.getByLabelText('Panels'), {target: {value: '1'}}); + expect(currentParams().has('pn')).toBe(false); + }); + + describe('use my current location', () => { + const withGeolocation = getCurrentPosition => { + Object.defineProperty(window, 'isSecureContext', {value: true, configurable: true}); + Object.defineProperty(navigator, 'geolocation', {value: {getCurrentPosition}, configurable: true}); + }; + + afterEach(() => { + delete window.isSecureContext; + delete navigator.geolocation; + }); + + test('fills in the location, rounded to 4 decimals', async () => { + withGeolocation(success => success({coords: {latitude: 39.7392358, longitude: -104.990251}})); + renderWithProviders(, {route: BASE}); + fireEvent.click(screen.getByRole('button', {name: 'Use my current location'})); + expect(currentParams().get('lat')).toBe('39.7392'); + expect(currentParams().get('lon')).toBe('-104.9903'); + expect(screen.getByLabelText('Latitude')).toHaveValue('39.7392'); + expect(await findEstimate()).toBeInTheDocument(); + }); + + test('explains a denied permission', () => { + withGeolocation((success, failure) => failure({code: 1, message: 'User denied Geolocation'})); + renderWithProviders(, {route: BASE}); + fireEvent.click(screen.getByRole('button', {name: 'Use my current location'})); + expect(screen.getByText('Could not get your location')).toBeInTheDocument(); + expect(screen.getByText(/Location permission was denied/)).toBeInTheDocument(); + expect(currentParams().has('lat')).toBe(false); + }); + + test('is disabled without HTTPS', () => { + Object.defineProperty(window, 'isSecureContext', {value: false, configurable: true}); + renderWithProviders(, {route: BASE}); + expect(screen.getByRole('button', {name: 'Use my current location'})).toBeDisabled(); + }); + }); + + test('an invalid latitude is flagged', () => { + renderWithProviders(, {route: `${BASE}&lat=95`}); + expect(screen.getByText('Latitude must be between -90 and 90')).toBeInTheDocument(); + }); + + test('the inverter switches the results to AC', async () => { + renderWithProviders(, {route: DENVER}); + await findEstimate(); + fireEvent.click(screen.getByText('Inverter: off (DC output)')); + fireEvent.click(screen.getByLabelText('Include an inverter (AC output)')); + expect(currentParams().get('inv')).toBe('1'); + expect(screen.getByText('kWh AC per year')).toBeInTheDocument(); + }); + + test('best-for-the-year replaces a poor tilt', async () => { + renderWithProviders(, {route: `${DENVER}&tilt=10`}); + await findEstimate(); + expect(screen.getByText('Panel angle: 10° tilt, facing S')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Panel angle: 10° tilt, facing S')); + fireEvent.click(await screen.findByRole('button', {name: 'Best for the year'})); + // A result of 40 equals the default (the latitude), so it is left out of the URL. + const tilt = Number(currentParams().get('tilt') ?? 40); + expect(tilt).toBeGreaterThanOrEqual(30); + expect(tilt).toBeLessThanOrEqual(44); + }); + + test('own sunlight data starts from the built-in values and replaces them', async () => { + renderWithProviders(, {route: DENVER}); + await findEstimate(); + fireEvent.click(screen.getByText('Use my own sunlight data: off')); + fireEvent.click(screen.getByLabelText('Use my own sunlight data')); + + const ghi = currentParams().get('ghi').split(',').map(Number); + ghi.forEach((v, i) => expect(v).toBeCloseTo(SUNNY_GHI[i], 1)); + expect(screen.getByText('Using your own sunlight data.')).toBeInTheDocument(); + expect(screen.getByLabelText('Jun sunlight')).toHaveValue(`${ghi[5]}`); + + // Halving June's sunlight lowers the estimate. + fireEvent.change(screen.getByLabelText('Jun sunlight'), {target: {value: `${ghi[5] / 2}`}}); + expect(currentParams().get('ghi').split(',')[5]).toBe(`${ghi[5] / 2}`); + + fireEvent.click(screen.getByLabelText('Use my own sunlight data')); + expect(currentParams().has('ghi')).toBe(false); + expect(currentParams().has('temp')).toBe(false); + }); + + test('switching to Fahrenheit converts your temperatures instead of relabeling them', async () => { + const temps = '0,10,,,,,,,,,,'; + renderWithProviders(, + {route: `${DENVER}&ghi=${SUNNY_GHI.join(',')}&temp=${temps}`}); + await findEstimate(); + fireEvent.click(screen.getByText('Use my own sunlight data: on')); + fireEvent.click(screen.getByLabelText('Fahrenheit')); + expect(currentParams().get('tu')).toBe('f'); + expect(currentParams().get('temp')).toBe('32,50,,,,,,,,,,'); + expect(screen.getByLabelText('Jan temperature')).toHaveValue('32'); + fireEvent.click(screen.getByLabelText('Fahrenheit')); + expect(currentParams().has('tu')).toBe(false); + expect(currentParams().get('temp')).toBe(temps); + }); + + test('a failed download explains how to continue', async () => { + forgetClimatology(); + global.fetch = jest.fn(() => Promise.resolve({ok: false, status: 404})); + renderWithProviders(, {route: DENVER}); + expect(await screen.findByText('Sunlight data could not be loaded', {}, {timeout: 5000})).toBeInTheDocument(); + expect(screen.getByText('Enter a location with sunlight data to see an estimate.')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/calculators/SolarOffGrid.js b/app/src/components/calculators/SolarOffGrid.js new file mode 100644 index 00000000..4f6c2202 --- /dev/null +++ b/app/src/components/calculators/SolarOffGrid.js @@ -0,0 +1,350 @@ +import React from "react"; +import {Button, Grid, Group, Header, IconButton, Loader, Message, NumberInput, Panel, Select, Statistic, + StatisticGroup, Table, TextInput, Toggle} from "../ui"; +import {roundDigits} from "../Common"; +import { + BATTERIES, + batteryWarnings, + CONTROLLERS, + controllerSizing, + dailyLoadWh, + defaultColdest, + formatLoads, + leadAcidCapacityFactor, + loadWh, + parseLoads, + requiredKwp, + sizeBattery, + SYSTEM_VOLTAGES, +} from "./offgrid"; + +// Defaults are typical of a 400 W residential panel; the user should copy their panel's label. +const PANEL_DEFAULTS = {voc: 37, vmp: 31, isc: 13.8, bvoc: -0.27}; +const DEFAULT_LOAD_INVERTER_EFFICIENCY = 90; +const DEFAULT_DAYS = 2; +const DEFAULT_CONTROLLER_MAX_VOLTAGE = 100; +// Above this, a higher system voltage needs a much smaller controller and thinner wire. +const HIGH_CURRENT = 60; + +const numberOr = (value, fallback) => { + if (value === null || value === undefined || value === '') { + return fallback; + } + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +}; + +const fmt = (value, digits = 0) => (Number.isFinite(value) ? `${roundDigits(value, digits)}` : '—'); + +const Hint = ({children}) =>

{children}

; + +// The off-grid inputs, read from the URL like the rest of the calculator. Invalid values fall back. +export function offGridFromParams(get, monthlyTemperatures) { + const battery = BATTERIES[get('bat')] ? get('bat') : 'lfp'; + const systemVoltage = SYSTEM_VOLTAGES.includes(Number(get('sv'))) ? Number(get('sv')) : 12; + const controller = CONTROLLERS[get('ctl')] ? get('ctl') : 'mppt'; + const coldestMonthly = (monthlyTemperatures || []).filter(Number.isFinite).length + ? Math.min(...monthlyTemperatures.filter(Number.isFinite)) : null; + const defaultTmin = defaultColdest(monthlyTemperatures); + return { + loads: parseLoads(get('load')), + acLoads: get('ac') !== '0', + loadInverterEfficiency: numberOr(get('leff'), DEFAULT_LOAD_INVERTER_EFFICIENCY), + battery, + systemVoltage, + dod: numberOr(get('dod'), BATTERIES[battery].dod), + days: Math.max(0, numberOr(get('aut'), DEFAULT_DAYS)), + outdoors: get('bout') === '1', + controller, + voc: numberOr(get('voc'), PANEL_DEFAULTS.voc), + vmp: numberOr(get('vmp'), PANEL_DEFAULTS.vmp), + isc: numberOr(get('isc'), PANEL_DEFAULTS.isc), + // Always negative for silicon panels; a missing minus sign must not hide the cold-weather rise. + betaVoc: -Math.abs(numberOr(get('bvoc'), PANEL_DEFAULTS.bvoc)), + series: Math.max(1, Math.round(numberOr(get('ns'), 1))), + controllerMaxVoltage: numberOr(get('cmax'), DEFAULT_CONTROLLER_MAX_VOLTAGE), + coldestMonthly, + defaultTmin, + coldest: numberOr(get('tmin'), defaultTmin), + }; +} + +/** + * Everything the off-grid tab shows, from its inputs. + * + * @param {Object} o offGridFromParams() + * @param {Object} worst the worst month for a 1 kW array: {month, energyDaily} + * @param {number} panelWatts + * @param {number} panelCount + */ +export function offGridResults(o, worst, panelWatts, panelCount) { + const devicesWh = dailyLoadWh(o.loads); + // AC devices draw through an inverter, so the batteries supply more than the devices use. + const batteryLoadWh = o.acLoads && o.loadInverterEfficiency > 0 + ? devicesWh / (o.loadInverterEfficiency / 100) : devicesWh; + const capacityFactor = o.outdoors && o.battery === 'lead' ? leadAcidCapacityFactor(o.coldestMonthly) : 1; + const bank = sizeBattery({ + loadWh: batteryLoadWh, days: o.days, dod: o.dod, voltage: o.systemVoltage, capacityFactor, + }); + + const sizing = controllerSizing({ + panelCount, panelWatts, series: o.series, voc: o.voc, vmp: o.vmp, isc: o.isc, betaVoc: o.betaVoc, + coldest: o.coldest, controller: o.controller, controllerMaxVoltage: o.controllerMaxVoltage, + systemVoltage: o.systemVoltage, + }); + + // A PWM controller wastes part of every panel's output, so more panels are needed. + const worstPerKwp = worst ? worst.energyDaily * sizing.pwmFraction : null; + const kwp = requiredKwp(batteryLoadWh, worstPerKwp, BATTERIES[o.battery].efficiency); + const panelsNeeded = kwp && panelWatts > 0 ? Math.ceil(kwp * 1000 / panelWatts) : null; + const yourArrayWh = worstPerKwp !== null + ? worstPerKwp * (panelCount * panelWatts / 1000) * 1000 * BATTERIES[o.battery].efficiency / 100 : null; + + return { + devicesWh, batteryLoadWh, bank, kwp, panelsNeeded, sizing, + coverage: batteryLoadWh > 0 && yourArrayWh !== null ? yourArrayWh / batteryLoadWh : null, + warnings: batteryWarnings({battery: o.battery, outdoors: o.outdoors, coldestMonthly: o.coldestMonthly}), + }; +} + +function LoadsSection({o, r, set, setNumber}) { + const rows = o.loads.length ? o.loads : [{name: '', watts: '', hours: ''}]; + const write = next => set({load: next.some(row => row.name || row.watts || row.hours) ? formatLoads(next) : null}); + const update = (index, field, value) => write(rows.map((row, i) => (i === index ? {...row, [field]: value} : row))); + const remove = index => write(rows.filter((_, i) => i !== index)); + + return <> +
Your devices
+ + List what you will run and for how long each day. The watts are on the device's label or power supply; + a refrigerator cycles on and off, so use about a third of its rating for 24 hours. + + + + + Device + Watts + Hours per day + Wh per day + + + + + {rows.map((row, i) => + + update(i, 'name', e.target.value)}/> + + + update(i, 'watts', v)}/> + + + update(i, 'hours', v)}/> + + {fmt(loadWh(row))} + + remove(i)}/> + + )} + +
+ + + {fmt(r.devicesWh)} Wh per day + + + set({ac: e.currentTarget.checked ? null : '0'})}/> + {o.acLoads && setNumber('leff', v, DEFAULT_LOAD_INVERTER_EFFICIENCY)}/>} + + ; +} + +function BatterySection({o, set, setNumber}) { + return <> +
Batteries
+ + Days without sun is how long the batteries must carry your devices through cloudy weather. Two or three + days is typical. Larger systems (over about 1,500 W of panels) usually use 24 or 48 volts. + + + + ({value: `${v}`, label: `${v} V`}))} + value={`${o.systemVoltage}`} allowDeselect={false} + onChange={v => set({sv: v === '12' ? null : v})}/> + + + setNumber('aut', v, DEFAULT_DAYS)}/> + + + setNumber('dod', v, BATTERIES[o.battery].dod)}/> + + + set({bout: e.currentTarget.checked ? '1' : null})}/> + + + ; +} + +function NeedsSection({o, r, worst, s, setNumber}) { + if (!(r.devicesWh > 0)) { + return
+ List your devices above to size the system. +
; + } + const coverage = r.coverage; + return +
What you need
+ + + + + + +

+ Sized for {worst.month}, your darkest month, when each kW of panels makes about{' '} + {fmt(worst.energyDaily, 1)} kWh a day at the angle chosen under Fine-tune. + {o.controller === 'pwm' && r.sizing.pwmFraction > 0 && ` The PWM controller uses only ` + + `${fmt(r.sizing.pwmFraction * 100)}% of the panels' power, so more panels are needed.`} + {o.controller === 'pwm' && r.sizing.pwmFraction === 0 && ' These panels cannot charge this battery ' + + 'through a PWM controller; see the warning below.'} +

+ {coverage !== null &&

+ Your {s.panelCount} panel{s.panelCount === 1 ? '' : 's'} would supply about{' '} + {fmt(Math.min(coverage, 9.99) * 100)}% of your use in {worst.month}.{' '} + {r.panelsNeeded !== s.panelCount && r.panelsNeeded > 0 && + } +

} + {r.warnings.map(w => {w})} +
; +} + +function ControllerSection({o, r, set, setNumber, temperaturesKnown}) { + const z = r.sizing; + const panelField = (key, label, value, fallback, step, max) => + setNumber(key, v, fallback)}/> + ; + + return <> +
Charge controller and wiring
+ + Copy these from the label on the back of your panel. Panels make a higher voltage when cold; too many in + series can exceed the controller's limit on a cold morning and destroy it. The coldest morning starts + 25 °C below your coldest monthly average, because averages hide cold snaps; enter your area's record + low if you know it. + + + {panelField('voc', 'Open-circuit voltage, Voc (V)', o.voc, PANEL_DEFAULTS.voc, 0.1)} + {panelField('vmp', 'Max-power voltage, Vmp (V)', o.vmp, PANEL_DEFAULTS.vmp, 0.1)} + {panelField('isc', 'Short-circuit current, Isc (A)', o.isc, PANEL_DEFAULTS.isc, 0.1)} + {panelField('bvoc', 'Voc temperature coefficient (%/°C)', o.betaVoc, PANEL_DEFAULTS.bvoc, 0.01, 0)} + +