From 0dd48dc92a3085d67f5545d91c8af12ef93ab325 Mon Sep 17 00:00:00 2001 From: codebude Date: Thu, 21 May 2026 22:03:50 +0200 Subject: [PATCH 001/165] First version of devops cli --- backend/pyproject.toml | 10 + backend/uv.lock | 30 +- cli/pyproject.toml | 33 + cli/src/llc/__init__.py | 3 + cli/src/llc/__main__.py | 3 + cli/src/llc/_gh.py | 60 ++ cli/src/llc/_git.py | 117 +++ cli/src/llc/_interactive.py | 27 + cli/src/llc/main.py | 49 ++ cli/src/llc/pr.py | 97 +++ cli/src/llc/sync.py | 42 ++ cli/src/llc/tag.py | 125 ++++ cli/tests/conftest.py | 23 + cli/tests/test_gh.py | 50 ++ cli/tests/test_git.py | 160 ++++ cli/tests/test_interactive.py | 18 + cli/tests/test_main.py | 23 + cli/tests/test_pr.py | 97 +++ cli/tests/test_sync.py | 26 + cli/tests/test_tag.py | 25 + pyproject.toml | 26 + uv.lock | 1306 +++++++++++++++++++++++++++++++++ 22 files changed, 2349 insertions(+), 1 deletion(-) create mode 100644 cli/pyproject.toml create mode 100644 cli/src/llc/__init__.py create mode 100644 cli/src/llc/__main__.py create mode 100644 cli/src/llc/_gh.py create mode 100644 cli/src/llc/_git.py create mode 100644 cli/src/llc/_interactive.py create mode 100644 cli/src/llc/main.py create mode 100644 cli/src/llc/pr.py create mode 100644 cli/src/llc/sync.py create mode 100644 cli/src/llc/tag.py create mode 100644 cli/tests/conftest.py create mode 100644 cli/tests/test_gh.py create mode 100644 cli/tests/test_git.py create mode 100644 cli/tests/test_interactive.py create mode 100644 cli/tests/test_main.py create mode 100644 cli/tests/test_pr.py create mode 100644 cli/tests/test_sync.py create mode 100644 cli/tests/test_tag.py create mode 100644 pyproject.toml create mode 100644 uv.lock diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 9c96d1e..727e7f8 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -23,6 +23,16 @@ dependencies = [ "browserforge>=1.2.4", ] +[tool.uv] +package = true + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["app"] + [dependency-groups] dev = [ "httpx>=0.28.1", diff --git a/backend/uv.lock b/backend/uv.lock index cefc4dc..9cdb5ef 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "librislog-backend" version = "0.0.0.dev0" -source = { virtual = "." } +source = { editable = "." } dependencies = [ { name = "alembic" }, { name = "authlib" }, @@ -502,6 +502,8 @@ dev = [ { name = "pytest" }, { name = "pytest-anyio" }, { name = "pytest-cov" }, + { name = "rich" }, + { name = "typer" }, ] [package.metadata] @@ -531,6 +533,8 @@ dev = [ { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-anyio", specifier = ">=0.0.0" }, { name = "pytest-cov", specifier = ">=7.1.0" }, + { name = "rich", specifier = ">=13.9.4" }, + { name = "typer", specifier = ">=0.15.2" }, ] [[package]] @@ -940,6 +944,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/56/97c0d4e05e9e0c7d712642ddbaf176d723bf5590b29a3b571cf1038cd06b/scrapling-0.4.8-py3-none-any.whl", hash = "sha256:ea6e5f13760740489544cf0f72e69014260e1658d19cf2bc337b82ac91d45782", size = 158559, upload-time = "2026-05-11T02:00:46.704Z" }, ] +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + [[package]] name = "sqlalchemy" version = "2.0.49" @@ -1001,6 +1014,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/90/39a85a4b63c84213e78b3c17d22e1bf45328acf8ebb33ef93be30d0a3911/tld-0.13.2-py2.py3-none-any.whl", hash = "sha256:9b8fdbdb880e7ba65b216a4937f2c94c49a7226723783d5838fc958ac76f4e0c", size = 296743, upload-time = "2026-03-06T23:50:32.465Z" }, ] +[[package]] +name = "typer" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" diff --git a/cli/pyproject.toml b/cli/pyproject.toml new file mode 100644 index 0000000..7d04945 --- /dev/null +++ b/cli/pyproject.toml @@ -0,0 +1,33 @@ +[project] +name = "llc" +version = "0.1.0" +description = "Developer CLI for the librislog project" +requires-python = ">=3.14" +dependencies = [ + "questionary>=2.0.0", + "typer>=0.25.1", + "rich>=13.9.4", +] + +[project.scripts] +llc = "llc.main:app" + +[tool.uv] +package = true + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/llc"] + +[dependency-groups] +dev = [ + "pytest>=9.0.3", + "pytest-mock>=3.14.0", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] diff --git a/cli/src/llc/__init__.py b/cli/src/llc/__init__.py new file mode 100644 index 0000000..b48abe5 --- /dev/null +++ b/cli/src/llc/__init__.py @@ -0,0 +1,3 @@ +from llc.main import app + +__all__ = ["app"] diff --git a/cli/src/llc/__main__.py b/cli/src/llc/__main__.py new file mode 100644 index 0000000..aeb06b5 --- /dev/null +++ b/cli/src/llc/__main__.py @@ -0,0 +1,3 @@ +from llc.main import app + +app() diff --git a/cli/src/llc/_gh.py b/cli/src/llc/_gh.py new file mode 100644 index 0000000..0d362b8 --- /dev/null +++ b/cli/src/llc/_gh.py @@ -0,0 +1,60 @@ +import json +import subprocess + + +class GhError(Exception): + pass + + +def _run_gh(args: list[str], *, interactive: bool = False) -> subprocess.CompletedProcess: + if interactive: + try: + return subprocess.run(["gh", *args], check=True) + except FileNotFoundError: + raise GhError("GitHub CLI (gh) is not installed or not on PATH") + except subprocess.CalledProcessError as exc: + raise GhError(str(exc)) + try: + return subprocess.run( + ["gh", *args], + capture_output=True, + text=True, + check=True, + ) + except FileNotFoundError: + raise GhError("GitHub CLI (gh) is not installed or not on PATH") + except subprocess.CalledProcessError as exc: + msg = exc.stderr.strip() if exc.stderr else str(exc) + raise GhError(msg) + + +def check_gh() -> None: + try: + subprocess.run(["gh", "auth", "status"], capture_output=True, check=True) + except FileNotFoundError: + raise GhError("GitHub CLI (gh) is not installed") + except subprocess.CalledProcessError: + raise GhError("GitHub CLI (gh) is not authenticated — run `gh auth login`") + + +def list_open_prs() -> list[dict]: + result = _run_gh([ + "pr", "list", "--state", "open", + "--json", "number,title,headRefName,baseRefName,author", + "--limit", "100", + ]) + return json.loads(result.stdout) + + +def create_pr(*, base: str, head: str) -> None: + _run_gh([ + "pr", "create", + "--base", base, + "--head", head, + "--fill", + "--assignee", "@me", + ], interactive=True) + + +def merge_pr(pr_number: int) -> None: + _run_gh(["pr", "merge", str(pr_number), "-m"], interactive=True) diff --git a/cli/src/llc/_git.py b/cli/src/llc/_git.py new file mode 100644 index 0000000..6eb16fd --- /dev/null +++ b/cli/src/llc/_git.py @@ -0,0 +1,117 @@ +import subprocess +from pathlib import Path + + +class GitError(Exception): + pass + + +def _ensure_git_repo() -> None: + """Check CWD is inside a git repository or raise GitError.""" + try: + subprocess.run( + ["git", "rev-parse", "--is-inside-work-tree"], + capture_output=True, + check=True, + cwd=Path.cwd(), + ) + except FileNotFoundError: + raise GitError("git is not installed or not on PATH") + except subprocess.CalledProcessError: + raise GitError("Not inside a git repository") + + +def _run_git(args: list[str], *, interactive: bool = False) -> subprocess.CompletedProcess: + if interactive: + try: + return subprocess.run(["git", *args], check=True) + except FileNotFoundError: + raise GitError("git is not installed or not on PATH") + except subprocess.CalledProcessError as exc: + raise GitError(str(exc)) + try: + return subprocess.run( + ["git", *args], + capture_output=True, + text=True, + check=True, + ) + except FileNotFoundError: + raise GitError("git is not installed or not on PATH") + except subprocess.CalledProcessError as exc: + msg = exc.stderr.strip() if exc.stderr else str(exc) + raise GitError(msg) + + +def has_uncommitted_changes() -> bool: + result = _run_git(["status", "--porcelain"]) + return bool(result.stdout.strip()) + + +def current_branch() -> str: + result = _run_git(["branch", "--show-current"]) + return result.stdout.strip() + + +def local_branches() -> list[str]: + result = _run_git(["branch", "--format=%(refname:short)"]) + return [b.strip() for b in result.stdout.strip().splitlines() if b.strip()] + + +def remote_origin_branches() -> list[str]: + result = _run_git(["branch", "-r", "--format=%(refname:short)"]) + branches = [b.strip() for b in result.stdout.strip().splitlines() if b.strip()] + prefix = "origin/" + return [b[len(prefix):] for b in branches if b.startswith(prefix) and b != "origin/HEAD"] + + +def fetch_tags(pattern: str = "v*") -> list[str]: + result = _run_git(["tag", "-l", pattern, "--sort=-version:refname"]) + return [t.strip() for t in result.stdout.strip().splitlines() if t.strip()] + + +def checkout(branch: str) -> None: + _run_git(["checkout", branch], interactive=True) + + +def pull(branch: str) -> None: + _run_git(["pull", "origin", branch], interactive=True) + + +def tag(tagname: str) -> None: + _run_git(["tag", tagname]) + + +def push_tag(tagname: str) -> None: + _run_git(["push", "origin", tagname], interactive=True) + + +def merge(remote_branch: str) -> None: + _run_git(["merge", f"origin/{remote_branch}"], interactive=True) + + +def push() -> None: + _run_git(["push"], interactive=True) + + +def fetch() -> None: + _run_git(["fetch", "origin"]) + + +def tag_exists(tagname: str) -> bool: + try: + _run_git(["rev-parse", "-q", "--verify", f"refs/tags/{tagname}"]) + return True + except GitError: + return False + + +def get_upstream_branch() -> str | None: + try: + result = _run_git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]) + upstream = result.stdout.strip() + if upstream.startswith("origin/"): + return upstream[len("origin/"):] + return None + except GitError: + return None diff --git a/cli/src/llc/_interactive.py b/cli/src/llc/_interactive.py new file mode 100644 index 0000000..02229eb --- /dev/null +++ b/cli/src/llc/_interactive.py @@ -0,0 +1,27 @@ +import questionary +from rich.console import Console + +console = Console() + + +def confirm(prompt_text: str, *, default: bool = True) -> bool: + result = questionary.confirm(prompt_text, default=default).ask() + if result is None: + return False + return result + + +def prompt_text(prompt_text: str, *, default: str | None = None) -> str | None: + return questionary.text(prompt_text, default=default or "").ask() + + +def select_from_list( + items: list[str], + *, + title: str = "Select an option", + preselect: str | None = None, +) -> str | None: + if not items: + console.print("[yellow]No items to select.[/yellow]") + return None + return questionary.select(title, choices=items, default=preselect).ask() diff --git a/cli/src/llc/main.py b/cli/src/llc/main.py new file mode 100644 index 0000000..685b1f3 --- /dev/null +++ b/cli/src/llc/main.py @@ -0,0 +1,49 @@ +import typer + +app = typer.Typer( + name="ll", + help="LibrisLog developer CLI", + rich_markup_mode="rich", + pretty_exceptions_show_locals=False, +) + +pr_app = typer.Typer( + name="pr", + help="Manage pull requests (create, merge, list)", + rich_markup_mode="rich", +) +tag_app = typer.Typer( + name="tag", + help="Manage version tags", + rich_markup_mode="rich", +) +app.add_typer(pr_app) +app.add_typer(tag_app) + + +@pr_app.command("create") +def pr_create(): + """Create a pull request with interactive branch selection.""" + from llc.pr import cmd_create + cmd_create() + + +@pr_app.command("merge") +def pr_merge(): + """Merge an open pull request.""" + from llc.pr import cmd_merge + cmd_merge() + + +@tag_app.command("create") +def tag_create(): + """Create and push a new version tag.""" + from llc.tag import cmd_create + cmd_create() + + +@app.command() +def sync(): + """Sync current branch with an origin branch.""" + from llc.sync import cmd_sync + cmd_sync() diff --git a/cli/src/llc/pr.py b/cli/src/llc/pr.py new file mode 100644 index 0000000..eacb293 --- /dev/null +++ b/cli/src/llc/pr.py @@ -0,0 +1,97 @@ +import click +import typer +import llc._git +import llc._gh +import llc._interactive +from llc._interactive import console + + +def cmd_create() -> None: + try: + llc._git.current_branch() + except Exception: + console.print("[red]Not inside a git repository.[/red]") + raise typer.Exit(code=1) + + try: + llc._gh.check_gh() + except Exception as exc: + console.print(f"[red]{exc}[/red]") + raise typer.Exit(code=1) + + try: + if llc._git.has_uncommitted_changes(): + if llc._interactive.confirm("Uncommitted changes found. Commit first?", default=True): + console.print("[yellow]Please commit your changes manually, then re-run.[/yellow]") + raise typer.Exit() + except click.exceptions.Exit: + raise + except Exception: + console.print("[red]Failed to check for uncommitted changes.[/red]") + raise typer.Exit(code=1) + + try: + branches = llc._git.remote_origin_branches() + cur = llc._git.current_branch() + except Exception: + console.print("[red]Failed to list remote branches.[/red]") + raise typer.Exit(code=1) + + if cur not in branches: + branches.append(cur) + + head = llc._interactive.select_from_list(branches, title="Select head branch", preselect=cur) + if head is None: + console.print("[yellow]Cancelled.[/yellow]") + raise typer.Exit() + + base_candidates = [b for b in branches if b != head] + base_preselect = "main" if head == "develop" else "develop" + base = llc._interactive.select_from_list(base_candidates, title="Select base branch", preselect=base_preselect) + if base is None: + console.print("[yellow]Cancelled.[/yellow]") + raise typer.Exit() + + console.print(f"Creating PR: [bold]{head}[/bold] → [bold]{base}[/bold]") + try: + llc._gh.create_pr(base=base, head=head) + console.print("[green]PR created successfully![/green]") + except Exception as exc: + console.print(f"[red]PR creation failed: {exc}[/red]") + raise typer.Exit(code=1) + + +def cmd_merge() -> None: + try: + llc._gh.check_gh() + except Exception as exc: + console.print(f"[red]{exc}[/red]") + raise typer.Exit(code=1) + + try: + prs = llc._gh.list_open_prs() + except Exception as exc: + console.print(f"[red]Failed to list PRs: {exc}[/red]") + raise typer.Exit(code=1) + + if not prs: + console.print("[yellow]No open pull requests.[/yellow]") + raise typer.Exit() + + pr_lines = [f"#{pr['number']} — {pr['title']} ({pr['headRefName']} → {pr['baseRefName']})" for pr in prs] + selected_line = llc._interactive.select_from_list(pr_lines, title="Open Pull Requests") + if selected_line is None: + console.print("[yellow]Cancelled.[/yellow]") + raise typer.Exit() + + idx = pr_lines.index(selected_line) + selected_pr = prs[idx] + pr_number = selected_pr["number"] + + console.print(f"Merging PR #[bold]{pr_number}[/bold]: {selected_pr['title']}") + try: + llc._gh.merge_pr(pr_number) + console.print(f"[green]PR #{pr_number} merged![/green]") + except Exception as exc: + console.print(f"[red]PR merge failed: {exc}[/red]") + raise typer.Exit(code=1) diff --git a/cli/src/llc/sync.py b/cli/src/llc/sync.py new file mode 100644 index 0000000..a3c194b --- /dev/null +++ b/cli/src/llc/sync.py @@ -0,0 +1,42 @@ +import typer +import llc._git +import llc._interactive +from llc._interactive import console + + +def cmd_sync() -> None: + try: + llc._git.fetch() + except Exception: + console.print("[red]Failed to fetch from origin.[/red]") + raise typer.Exit(code=1) + + cur = llc._git.current_branch() + console.print(f"Current branch: [bold]{cur}[/bold]") + + try: + remotes = llc._git.remote_origin_branches() + except Exception: + console.print("[red]Failed to list remote branches.[/red]") + raise typer.Exit(code=1) + + candidates = [b for b in remotes if b != cur] + upstream = llc._git.get_upstream_branch() + + target = llc._interactive.select_from_list( + candidates, + title="Select origin branch to merge into current", + preselect=upstream, + ) + if target is None: + console.print("[yellow]Cancelled.[/yellow]") + raise typer.Exit() + + try: + console.print(f"Merging [bold]origin/{target}[/bold] into [bold]{cur}[/bold]...") + llc._git.merge(target) + llc._git.push() + console.print(f"[green]Branch {cur} synced with origin/{target}![/green]") + except Exception as exc: + console.print(f"[red]Sync failed: {exc}[/red]") + raise typer.Exit(code=1) diff --git a/cli/src/llc/tag.py b/cli/src/llc/tag.py new file mode 100644 index 0000000..baad087 --- /dev/null +++ b/cli/src/llc/tag.py @@ -0,0 +1,125 @@ +import re + +import typer +import llc._git +import llc._interactive +from llc._interactive import console + + +def _parse_tag(tag: str) -> tuple[int, int, int, int | None]: + m = re.match(r"^v(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$", tag) + if not m: + raise ValueError(f"Invalid semver tag: {tag}") + return (int(m[1]), int(m[2]), int(m[3]), int(m[4]) if m[4] else None) + + +def _compute_bump(version: tuple[int, int, int, int | None], bump_type: str) -> str: + major, minor, patch, rc = version + if bump_type == "major": + return f"v{major + 1}.0.0" + elif bump_type == "minor": + return f"v{major}.{minor + 1}.0" + else: + return f"v{major}.{minor}.{patch + 1}" + + +def cmd_create() -> None: + try: + original_branch = llc._git.current_branch() + except Exception: + console.print("[red]Not inside a git repository.[/red]") + raise typer.Exit(code=1) + + try: + branches = llc._git.local_branches() + except Exception: + console.print("[red]Failed to list local branches.[/red]") + raise typer.Exit(code=1) + + branch = llc._interactive.select_from_list( + branches, + title="Select branch to tag", + preselect=original_branch, + ) + if branch is None: + console.print("[yellow]Cancelled.[/yellow]") + raise typer.Exit() + + try: + tags = llc._git.fetch_tags("v*") + except Exception: + console.print("[red]Failed to fetch tags.[/red]") + raise typer.Exit(code=1) + + version_tags = [t for t in tags if re.match(r"^v\d+\.\d+\.\d+(-rc\.\d+)?$", t)] + + if not version_tags: + console.print("[yellow]No semantic version tags found on this branch.[/yellow]") + new_version = llc._interactive.prompt_text("Enter version tag (e.g. v0.1.0)") + else: + latest = version_tags[0] + parsed = _parse_tag(latest) + console.print(f"Latest tag: [bold]{latest}[/bold]") + + major_v = _compute_bump(parsed, "major") + minor_v = _compute_bump(parsed, "minor") + patch_v = _compute_bump(parsed, "patch") + + bumps: dict[str, str] = { + f"Major bump ({major_v})": major_v, + f"Minor bump ({minor_v})": minor_v, + f"Patch bump ({patch_v})": patch_v, + } + choices = list(bumps.keys()) + ["Enter custom"] + choice = llc._interactive.select_from_list(choices, title="Select bump type") + if choice is None: + console.print("[yellow]Cancelled.[/yellow]") + raise typer.Exit() + + if choice == "Enter custom": + new_version = llc._interactive.prompt_text("Enter version tag") + else: + new_version = bumps[choice] + + if new_version is None or not new_version.strip(): + console.print("[yellow]Cancelled.[/yellow]") + raise typer.Exit() + + new_version = new_version.strip() + + if not re.match(r"^v\d+\.\d+\.\d+(-rc\.\d+)?$", new_version): + console.print(f"[red]Invalid version format: {new_version}. Expected format like v1.2.3 or v1.2.3-rc.1[/red]") + raise typer.Exit(code=1) + + if llc._git.tag_exists(new_version): + console.print(f"[yellow]Tag {new_version} already exists.[/yellow]") + if not llc._interactive.confirm(f"Overwrite tag [bold]{new_version}[/bold]?", default=False): + console.print("[yellow]Cancelled.[/yellow]") + raise typer.Exit() + + if not llc._interactive.confirm( + f"Create and push tag [bold]{new_version}[/bold] on [bold]{branch}[/bold]?", + default=True, + ): + console.print("[yellow]Cancelled.[/yellow]") + raise typer.Exit() + + try: + console.print(f"Checking out [bold]{branch}[/bold]...") + llc._git.checkout(branch) + console.print(f"Pulling latest [bold]{branch}[/bold]...") + llc._git.pull(branch) + console.print(f"Creating tag [bold]{new_version}[/bold]...") + llc._git.tag(new_version) + console.print(f"Pushing tag [bold]{new_version}[/bold]...") + llc._git.push_tag(new_version) + console.print(f"Restoring [bold]{original_branch}[/bold]...") + llc._git.checkout(original_branch) + console.print(f"[green]Tag {new_version} created and pushed![/green]") + except Exception as exc: + console.print(f"[red]Tag operation failed: {exc}[/red]") + try: + llc._git.checkout(original_branch) + except Exception: + pass + raise typer.Exit(code=1) diff --git a/cli/tests/conftest.py b/cli/tests/conftest.py new file mode 100644 index 0000000..6250bc3 --- /dev/null +++ b/cli/tests/conftest.py @@ -0,0 +1,23 @@ +import subprocess +from collections.abc import Generator + +import pytest +from typer.testing import CliRunner + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +@pytest.fixture +def mock_subprocess(mocker) -> Generator: + def _mock(stdout: str = "", stderr: str = "", returncode: int = 0): + return mocker.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess( + args=[], returncode=returncode, + stdout=stdout, stderr=stderr, + ), + ) + return _mock diff --git a/cli/tests/test_gh.py b/cli/tests/test_gh.py new file mode 100644 index 0000000..9da3397 --- /dev/null +++ b/cli/tests/test_gh.py @@ -0,0 +1,50 @@ +import subprocess + +import pytest + +from llc._gh import GhError, check_gh, list_open_prs, create_pr, merge_pr + + +class TestCheckGh: + def test_passes_when_authenticated(self, mocker): + mocker.patch("subprocess.run", return_value=subprocess.CompletedProcess(args=[], returncode=0)) + check_gh() + + def test_raises_when_not_installed(self, mocker): + mocker.patch("subprocess.run", side_effect=FileNotFoundError()) + with pytest.raises(GhError, match="not installed"): + check_gh() + + def test_raises_when_not_authenticated(self, mocker): + mocker.patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, [])) + with pytest.raises(GhError, match="not authenticated"): + check_gh() + + +class TestListOpenPRs: + def test_parses_json_output(self, mock_subprocess): + mock_subprocess(stdout='[{"number":1,"title":"Fix","headRefName":"f","baseRefName":"d","author":{"login":"user"}}]') + prs = list_open_prs() + assert len(prs) == 1 + assert prs[0]["number"] == 1 + + def test_returns_empty_list(self, mock_subprocess): + mock_subprocess(stdout="[]") + assert list_open_prs() == [] + + +class TestCreatePR: + def test_calls_correct_command(self, mocker): + mock = mocker.patch("subprocess.run") + create_pr(base="main", head="feat/foo") + cmd = mock.call_args[0][0] + assert cmd[:5] == ["gh", "pr", "create", "--base", "main"] + assert cmd[5:] == ["--head", "feat/foo", "--fill", "--assignee", "@me"] + + +class TestMergePR: + def test_calls_correct_command(self, mocker): + mock = mocker.patch("subprocess.run") + merge_pr(42) + cmd = mock.call_args[0][0] + assert cmd == ["gh", "pr", "merge", "42", "-m"] diff --git a/cli/tests/test_git.py b/cli/tests/test_git.py new file mode 100644 index 0000000..13cffb5 --- /dev/null +++ b/cli/tests/test_git.py @@ -0,0 +1,160 @@ +import subprocess + +import pytest + +from llc._git import ( + GitError, + has_uncommitted_changes, + current_branch, + local_branches, + remote_origin_branches, + fetch_tags, + checkout, + pull, + tag, + push_tag, + merge, + push, + fetch, + tag_exists, + get_upstream_branch, +) + + +class TestHasUncommittedChanges: + def test_no_changes(self, mock_subprocess): + mock_subprocess(stdout="") + assert not has_uncommitted_changes() + + def test_unstaged_changes(self, mock_subprocess): + mock_subprocess(stdout=" M src/file.py\n?? new.py\n") + assert has_uncommitted_changes() + + def test_staged_changes(self, mock_subprocess): + mock_subprocess(stdout="M src/file.py\n") + assert has_uncommitted_changes() + + +class TestCurrentBranch: + def test_returns_branch_name(self, mock_subprocess): + mock_subprocess(stdout="main\n") + assert current_branch() == "main" + + def test_strips_whitespace(self, mock_subprocess): + mock_subprocess(stdout=" feature/foo \n") + assert current_branch() == "feature/foo" + + +class TestLocalBranches: + def test_returns_list(self, mock_subprocess): + mock_subprocess(stdout="main\ndevelop\nfeature/foo\n") + assert local_branches() == ["main", "develop", "feature/foo"] + + def test_empty(self, mock_subprocess): + mock_subprocess(stdout="") + assert local_branches() == [] + + +class TestRemoteOriginBranches: + def test_filters_origin_prefix(self, mock_subprocess): + mock_subprocess( + stdout="origin/HEAD\norigin/main\norigin/develop\norigin/feat/x\n" + ) + assert remote_origin_branches() == ["main", "develop", "feat/x"] + + def test_empty(self, mock_subprocess): + mock_subprocess(stdout="") + assert remote_origin_branches() == [] + + +class TestFetchTags: + def test_returns_sorted_tags(self, mock_subprocess): + mock_subprocess(stdout="v2.0.0\nv1.3.0\nv1.2.3\n") + assert fetch_tags("v*") == ["v2.0.0", "v1.3.0", "v1.2.3"] + + def test_empty(self, mock_subprocess): + mock_subprocess(stdout="") + assert fetch_tags("v*") == [] + + +class TestCheckout: + def test_calls_without_capture(self, mocker): + mock = mocker.patch("subprocess.run") + checkout("develop") + assert "capture_output" not in mock.call_args.kwargs + + +class TestPull: + def test_calls_correct_args(self, mocker): + mock = mocker.patch("subprocess.run") + pull("main") + cmd = mock.call_args[0][0] + assert cmd == ["git", "pull", "origin", "main"] + + +class TestTag: + def test_calls_correct_args(self, mock_subprocess): + mock_run = mock_subprocess() + tag("v1.0.0") + assert mock_run.call_args[0][0] == ["git", "tag", "v1.0.0"] + + +class TestPushTag: + def test_calls_interactive(self, mocker): + mock = mocker.patch("subprocess.run") + push_tag("v1.0.0") + assert mock.call_args[0][0] == ["git", "push", "origin", "v1.0.0"] + + +class TestMerge: + def test_calls_with_origin_prefix(self, mocker): + mock = mocker.patch("subprocess.run") + merge("develop") + cmd = mock.call_args[0][0] + assert cmd == ["git", "merge", "origin/develop"] + + +class TestPush: + def test_calls_interactive(self, mocker): + mock = mocker.patch("subprocess.run") + push() + assert mock.call_args[0][0] == ["git", "push"] + + +class TestFetch: + def test_calls_correct_args(self, mock_subprocess): + mock_run = mock_subprocess() + fetch() + assert mock_run.call_args[0][0] == ["git", "fetch", "origin"] + + +class TestTagExists: + def test_exists(self, mocker): + mocker.patch("subprocess.run", return_value=subprocess.CompletedProcess(args=[], returncode=0)) + assert tag_exists("v1.0.0") + + def test_not_exists(self, mocker): + mocker.patch("subprocess.run", side_effect=subprocess.CalledProcessError(128, ["git"])) + assert not tag_exists("v1.0.0") + + +class TestGetUpstreamBranch: + def test_returns_stripped(self, mock_subprocess): + mock_subprocess(stdout="origin/develop\n") + assert get_upstream_branch() == "develop" + + def test_no_upstream(self, mocker): + mocker.patch("subprocess.run", side_effect=subprocess.CalledProcessError(128, ["git"])) + assert get_upstream_branch() is None + + +class TestGitError: + def test_raises_on_file_not_found(self, mocker): + mocker.patch("subprocess.run", side_effect=FileNotFoundError()) + with pytest.raises(GitError, match="git is not installed"): + current_branch() + + def test_raises_on_nonzero_exit(self, mocker): + mocker.patch("subprocess.run", side_effect=subprocess.CalledProcessError(128, ["git"], stderr="fatal: not a git repository")) + with pytest.raises(GitError, match="not a git repository"): + current_branch() diff --git a/cli/tests/test_interactive.py b/cli/tests/test_interactive.py new file mode 100644 index 0000000..c89db43 --- /dev/null +++ b/cli/tests/test_interactive.py @@ -0,0 +1,18 @@ +from llc._interactive import select_from_list + + +class TestSelectFromList: + def test_returns_selected_item(self, mocker): + mocker.patch("questionary.select", return_value=mocker.MagicMock(ask=lambda: "bar")) + items = ["foo", "bar", "baz"] + result = select_from_list(items) + assert result == "bar" + + def test_returns_none_on_empty_list(self, mocker): + result = select_from_list([]) + assert result is None + + def test_returns_none_on_cancel(self, mocker): + mocker.patch("questionary.select", return_value=mocker.MagicMock(ask=lambda: None)) + result = select_from_list(["foo", "bar"]) + assert result is None diff --git a/cli/tests/test_main.py b/cli/tests/test_main.py new file mode 100644 index 0000000..b4e8356 --- /dev/null +++ b/cli/tests/test_main.py @@ -0,0 +1,23 @@ +from llc.main import app + + +class TestHelp: + def test_help_returns_zero(self, runner): + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "LibrisLog" in result.stdout + + def test_pr_help(self, runner): + result = runner.invoke(app, ["pr", "--help"]) + assert result.exit_code == 0 + assert "create" in result.stdout + + def test_tag_help(self, runner): + result = runner.invoke(app, ["tag", "--help"]) + assert result.exit_code == 0 + assert "create" in result.stdout + + def test_sync_help(self, runner): + result = runner.invoke(app, ["sync", "--help"]) + assert result.exit_code == 0 + assert "Sync" in result.stdout diff --git a/cli/tests/test_pr.py b/cli/tests/test_pr.py new file mode 100644 index 0000000..09cd063 --- /dev/null +++ b/cli/tests/test_pr.py @@ -0,0 +1,97 @@ +import pytest +from llc.main import app + + +def _patch_pr(mocker, **kwargs): + """Apply common patches for PR tests and return the create_pr mock.""" + mocker.patch("llc._gh.check_gh") + for key, value in kwargs.items(): + mocker.patch(key, value) + + +class TestPRCreate: + def test_without_changes(self, runner, mocker): + _patch_pr(mocker) + mocker.patch("llc._git.has_uncommitted_changes", return_value=False) + mocker.patch("llc._git.remote_origin_branches", return_value=["main", "develop"]) + mocker.patch("llc._git.current_branch", return_value="my-feature") + mocker.patch("llc._interactive.select_from_list", side_effect=["my-feature", "develop"]) + mock_create = mocker.patch("llc._gh.create_pr") + + result = runner.invoke(app, ["pr", "create"]) + assert result.exit_code == 0 + mock_create.assert_called_once_with(base="develop", head="my-feature") + + def test_with_uncommitted_changes_yes(self, runner, mocker): + _patch_pr(mocker) + mocker.patch("llc._git.current_branch") + mocker.patch("llc._git.has_uncommitted_changes", return_value=True) + mocker.patch("llc._interactive.confirm", return_value=True) + + result = runner.invoke(app, ["pr", "create"]) + assert result.exit_code == 0 + assert "commit" in result.stdout.lower() + + def test_with_uncommitted_changes_no(self, runner, mocker): + _patch_pr(mocker) + mocker.patch("llc._git.has_uncommitted_changes", return_value=True) + mocker.patch("llc._interactive.confirm", return_value=False) + mocker.patch("llc._git.remote_origin_branches", return_value=["main", "develop"]) + mocker.patch("llc._git.current_branch", return_value="my-feature") + mocker.patch("llc._interactive.select_from_list", side_effect=["my-feature", "develop"]) + mock_create = mocker.patch("llc._gh.create_pr") + + result = runner.invoke(app, ["pr", "create"]) + assert result.exit_code == 0 + mock_create.assert_called_once() + + def test_preselects_main_when_head_is_develop(self, runner, mocker): + _patch_pr(mocker) + mocker.patch("llc._git.has_uncommitted_changes", return_value=False) + mocker.patch("llc._git.remote_origin_branches", return_value=["main", "develop", "feature"]) + mocker.patch("llc._git.current_branch", return_value="develop") + mocker.patch("llc._interactive.select_from_list", side_effect=["develop", "main"]) + mock_create = mocker.patch("llc._gh.create_pr") + + result = runner.invoke(app, ["pr", "create"]) + assert result.exit_code == 0 + mock_create.assert_called_once_with(base="main", head="develop") + + def test_not_in_git_repo(self, runner, mocker): + mocker.patch("llc._git.current_branch", side_effect=Exception("Not a git repo")) + + result = runner.invoke(app, ["pr", "create"]) + assert result.exit_code == 1 + assert "git" in result.stdout.lower() + + def test_gh_not_installed(self, runner, mocker): + mocker.patch("llc._git.current_branch") + mocker.patch("llc._gh.check_gh", side_effect=Exception("gh is not installed")) + + result = runner.invoke(app, ["pr", "create"]) + assert result.exit_code == 1 + + +class TestPRMerge: + def test_no_open_prs(self, runner, mocker): + _patch_pr(mocker) + mocker.patch("llc._gh.list_open_prs", return_value=[]) + + result = runner.invoke(app, ["pr", "merge"]) + assert result.exit_code == 0 + assert "No open" in result.stdout + + def test_selects_and_merges(self, runner, mocker): + _patch_pr(mocker) + mocker.patch( + "llc._gh.list_open_prs", + return_value=[ + {"number": 1, "title": "Fix bug", "headRefName": "fix", "baseRefName": "develop"}, + ], + ) + mocker.patch("llc._interactive.select_from_list", return_value="#1 — Fix bug (fix → develop)") + mock_merge = mocker.patch("llc._gh.merge_pr") + + result = runner.invoke(app, ["pr", "merge"]) + assert result.exit_code == 0 + mock_merge.assert_called_once_with(1) diff --git a/cli/tests/test_sync.py b/cli/tests/test_sync.py new file mode 100644 index 0000000..2358287 --- /dev/null +++ b/cli/tests/test_sync.py @@ -0,0 +1,26 @@ +from llc.main import app + + +class TestSync: + def test_basic_sync(self, runner, mocker): + mocker.patch("llc._git.fetch") + mocker.patch("llc._git.current_branch", return_value="my-feature") + mocker.patch("llc._git.remote_origin_branches", return_value=["main", "develop"]) + mocker.patch("llc._git.get_upstream_branch", return_value="develop") + mocker.patch("llc._interactive.select_from_list", return_value="develop") + mock_merge = mocker.patch("llc._git.merge") + mock_push = mocker.patch("llc._git.push") + + result = runner.invoke(app, ["sync"]) + assert result.exit_code == 0 + mock_merge.assert_called_once_with("develop") + mock_push.assert_called_once() + + def test_cancelled(self, runner, mocker): + mocker.patch("llc._git.fetch") + mocker.patch("llc._git.current_branch", return_value="my-feature") + mocker.patch("llc._git.remote_origin_branches", return_value=["main", "develop"]) + mocker.patch("llc._interactive.select_from_list", return_value=None) + + result = runner.invoke(app, ["sync"]) + assert result.exit_code == 0 diff --git a/cli/tests/test_tag.py b/cli/tests/test_tag.py new file mode 100644 index 0000000..18f95e9 --- /dev/null +++ b/cli/tests/test_tag.py @@ -0,0 +1,25 @@ +import pytest +from llc.tag import _parse_tag, _compute_bump + + +class TestParseTag: + def test_parses_full_semver(self): + assert _parse_tag("v1.2.3") == (1, 2, 3, None) + + def test_parses_with_rc(self): + assert _parse_tag("v1.2.3-rc.4") == (1, 2, 3, 4) + + def test_invalid_raises(self): + with pytest.raises(ValueError): + _parse_tag("abc") + + +class TestComputeBump: + def test_major_bump(self): + assert _compute_bump((1, 2, 3, None), "major") == "v2.0.0" + + def test_minor_bump(self): + assert _compute_bump((1, 2, 3, None), "minor") == "v1.3.0" + + def test_patch_bump(self): + assert _compute_bump((1, 2, 3, None), "patch") == "v1.2.4" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5b83bef --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "librislog" +version = "0.0.0" +requires-python = ">=3.14" +dependencies = [ + "librislog-backend", + "llc", +] + +[tool.uv.sources] +librislog-backend = { workspace = true } +llc = { workspace = true } + +[tool.uv.workspace] +members = ["backend/", "cli/"] + +[dependency-groups] +dev = [ + "pytest>=9.0.3", + "pytest-anyio>=0.0.0", + "pytest-cov>=7.1.0", + "pytest-mock>=3.14.0", +] + +[tool.uv] +package = false diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..5c59759 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1306 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[manifest] +members = [ + "librislog", + "librislog-backend", + "llc", +] + +[[package]] +name = "alembic" +version = "1.18.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "apify-fingerprint-datapoints" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/f1/b74f95767581372ab849c8b13e384b62f60d034584892c60c4a3442d9312/apify_fingerprint_datapoints-0.13.0.tar.gz", hash = "sha256:263141c19e9bc90a821e6b4e2b845925f17e0b8fbd53a897fc71546bd50df7f1", size = 934827, upload-time = "2026-05-04T09:08:45.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/58/8402442bf6af5a3a8068fe5431c42ea4f73c1eb18f621f9bf7c5de80caf5/apify_fingerprint_datapoints-0.13.0-py3-none-any.whl", hash = "sha256:0213d42297be19e8035202b41fb2e840a1e5d79874c99c882a5027a7d0b1a0eb", size = 761652, upload-time = "2026-05-04T09:08:43.347Z" }, +] + +[[package]] +name = "authlib" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "joserfc" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, +] + +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" }, + { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" }, + { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" }, + { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" }, + { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" }, + { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" }, + { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, + { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, + { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, + { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, +] + +[[package]] +name = "browserforge" +version = "1.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "apify-fingerprint-datapoints" }, + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/6f/8975af88d203efd70cc69477ebac702babef38201d04621c9583f2508f25/browserforge-1.2.4.tar.gz", hash = "sha256:05686473793769856ebd3528c69071f5be0e511260993e8b2ba839863711a0c4", size = 36700, upload-time = "2026-02-03T02:52:09.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/35/ce962f738ae28ffce6293e7607b129075633e6bb185a5ab87e49246eedc2/browserforge-1.2.4-py3-none-any.whl", hash = "sha256:fb1c14e62ac09de221dcfc73074200269f697596c642cb200ceaab1127a17542", size = 37890, upload-time = "2026-02-03T02:52:08.745Z" }, +] + +[[package]] +name = "cachetools" +version = "7.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/c1/67cfb86aa21144796ff51068326d467fbef8ee42f8d08a3a8a926106cf0c/cachetools-7.1.3.tar.gz", hash = "sha256:135cfe944bc3c1e805505f65dae0bef375a2f96261171ab66c79ef77d0bda39d", size = 45780, upload-time = "2026-05-18T18:21:03.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/52/8ff5c1a3b2e821ced9b2998fba3ee29aa4525c0bf51e5ee55dd6f61a4ed5/cachetools-7.1.3-py3-none-any.whl", hash = "sha256:9876787e2346e20584d5cca236cb5d49d04e7193de91646f230725b2e1e8b804", size = 16763, upload-time = "2026-05-18T18:21:02.386Z" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "click" +version = "8.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload-time = "2026-05-17T00:47:58.425Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" }, + { url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" }, + { url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" }, + { url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" }, + { url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" }, + { url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" }, + { url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" }, + { url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" }, + { url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" }, + { url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" }, + { url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" }, + { url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" }, + { url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" }, + { url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" }, + { url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" }, + { url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" }, + { url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" }, + { url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" }, + { url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" }, + { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" }, +] + +[[package]] +name = "cryptography" +version = "48.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, + { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, + { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, + { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, + { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, + { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, + { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, + { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, +] + +[[package]] +name = "cssselect" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/2e/cdfd8b01c37cbf4f9482eefd455853a3cf9c995029a46acd31dfaa9c1dd6/cssselect-1.4.0.tar.gz", hash = "sha256:fdaf0a1425e17dfe8c5cf66191d211b357cf7872ae8afc4c6762ddd8ac47fc92", size = 40589, upload-time = "2026-01-29T07:00:26.701Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/0c/7bb51e3acfafd16c48875bf3db03607674df16f5b6ef8d056586af7e2b8b/cssselect-1.4.0-py3-none-any.whl", hash = "sha256:c0ec5c0191c8ee39fcc8afc1540331d8b55b0183478c50e9c8a79d44dbceb1d8", size = 18540, upload-time = "2026-01-29T07:00:24.994Z" }, +] + +[[package]] +name = "curl-cffi" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "cffi" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/5b/89fcfebd3e5e85134147ac99e9f2b2271165fd4d71984fc65da5f17819b7/curl_cffi-0.15.0.tar.gz", hash = "sha256:ea0c67652bf6893d34ee0f82c944f37e488f6147e9421bef1771cc6545b02ded", size = 196437, upload-time = "2026-04-03T11:12:31.525Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/42/54ddd442c795f30ce5dd4e49f87ce77505958d3777cd96a91567a3975d2a/curl_cffi-0.15.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:bda66404010e9ed743b1b83c20c86f24fe21a9a6873e17479d6e67e29d8ded28", size = 2795267, upload-time = "2026-04-03T11:11:46.48Z" }, + { url = "https://files.pythonhosted.org/packages/83/2d/3915e238579b3c5a92cead5c79130c3b8d20caaba7616cc4d894650e1d6b/curl_cffi-0.15.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a25620d9bf989c9c029a7d1642999c4c265abb0bad811deb2f77b0b5b2b12e5b", size = 2573544, upload-time = "2026-04-03T11:11:47.951Z" }, + { url = "https://files.pythonhosted.org/packages/2a/b3/9d2f1057749a1b07ba1989db3c1503ce8bed998310bae9aea2c43aa64f20/curl_cffi-0.15.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:582e570aa2586b96ed47cf4a17586b9a3c462cbe43f780487c3dc245c6ef1527", size = 10515369, upload-time = "2026-04-03T11:11:50.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1d/6d10dded5ce3fd8157e558ebd97d09e551b77a62cdc1c31e93d0a633cee5/curl_cffi-0.15.0-cp310-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:838e48212447d9c81364b04707a5c861daf08f8320f9ecb3406a8919d1d5c3b3", size = 10160045, upload-time = "2026-04-03T11:11:52.664Z" }, + { url = "https://files.pythonhosted.org/packages/5c/12/c70b835487ace3b9ba1502631912e3440082b8ae3a162f60b59cb0b6444d/curl_cffi-0.15.0-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b6c847d86283b07ae69bb72c82eb8a59242277142aa35b89850f89e792a02fc", size = 11090433, upload-time = "2026-04-03T11:11:55.049Z" }, + { url = "https://files.pythonhosted.org/packages/ea/0d/78edcc4f71934225db99df68197a107386d59080742fc7bf6bb4d007924f/curl_cffi-0.15.0-cp310-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e5e69eee735f659287e2c84444319d68a1fa68dd37abf228943a4074864283a", size = 10479178, upload-time = "2026-04-03T11:11:57.685Z" }, + { url = "https://files.pythonhosted.org/packages/5b/84/1e101c1acb1ea2f0b4992f5c3024f596d8e21db0d53540b9d583f673c4e7/curl_cffi-0.15.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa1323950224db24f4c510d010b3affa02196ca853fb424191fa917a513d3f4b", size = 10317051, upload-time = "2026-04-03T11:12:00.295Z" }, + { url = "https://files.pythonhosted.org/packages/28/42/8ef236b22a6c23d096c85a1dc507efe37bfdfc7a2f8a4b34efb590197369/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:41f80170ba844009273b2660da1964ec31e99e5719d16b3422ada87177e32e13", size = 11299660, upload-time = "2026-04-03T11:12:02.791Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/56aeb055d962da87a1be0d74c6c644e251c7e88129b5471dc44ac724e678/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1977e1e12cfb5c11352cbb74acef1bed24eb7d226dab61ca57c168c21acd4d61", size = 11945049, upload-time = "2026-04-03T11:12:05.912Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8c/2abf99a38d6340d66cf0557e0c750ef3f8883dfc5d450087e01c85861343/curl_cffi-0.15.0-cp310-abi3-win_amd64.whl", hash = "sha256:5a0c1896a0d5a5ac1eb89cd24b008d2b718dd1df6fd2f75451b59ca66e49e572", size = 1661649, upload-time = "2026-04-03T11:12:07.948Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/dfd54f2240d3a9b96d77bacc62b97813b35e2aa8ecf5cd5013c683f1ba96/curl_cffi-0.15.0-cp310-abi3-win_arm64.whl", hash = "sha256:a6d57f8389273a3a1f94370473c74897467bcc36af0a17336989780c507fa43d", size = 1410741, upload-time = "2026-04-03T11:12:10.073Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/c24df8a4fc22fa84070dcd94abeba43c15e08cc09e35869565c0bad196fd/curl_cffi-0.15.0-cp313-abi3-android_24_arm64_v8a.whl", hash = "sha256:4682dc38d4336e0eb0b185374db90a760efde63cbea994b4e63f3521d44c4c92", size = 7190427, upload-time = "2026-04-03T11:12:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/11/56/132225cb3491d07cc6adcce5fe395e059bde87c68cff1ef87a31c88c7819/curl_cffi-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:967ad7355bd8e9586f8c2d02eaa99953747549e7ea4a9b25cd53353e6b67fe6d", size = 2795723, upload-time = "2026-04-03T11:12:13.668Z" }, + { url = "https://files.pythonhosted.org/packages/07/8f/f4f83cd303bef7e8f1749512e5dd157e7e5d08b0a36c8211f9640a2757bf/curl_cffi-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7e63539d0d839d0a8c5eacf86229bc68c57803547f35e0db7ee0986328b478c3", size = 2573739, upload-time = "2026-04-03T11:12:15.08Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5c/643d65c7fc9acd742876aa55c2d7823c438cb7665810acd2e66c9976c4d9/curl_cffi-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08c799b89740b9bc49c09fbc3d5907f13ac1f845ca52620507ef9466d4639dd5", size = 10521046, upload-time = "2026-04-03T11:12:17.034Z" }, + { url = "https://files.pythonhosted.org/packages/7f/0b/9b8037113c93f4c5323096163471fa7c35c7676c3f608eeaf1287cd99d58/curl_cffi-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b7a92767a888ee90147e18964b396d8435ff42737030d6fb00824ffd6094805", size = 11096115, upload-time = "2026-04-03T11:12:19.694Z" }, + { url = "https://files.pythonhosted.org/packages/5f/96/fff2fcbd924ef4042e0d67379f751a8a4e3186a91e75e35a4cf218b306ee/curl_cffi-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:829cc357061ecb99cc2d406301f609a039e05665322f5c025ec67c38b0dc49ce", size = 11305346, upload-time = "2026-04-03T11:12:22.151Z" }, + { url = "https://files.pythonhosted.org/packages/53/1b/304b253a45ab28691c8c5e8cca1e6cbb9cf8e46dfceae4648dd536f75e73/curl_cffi-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:408d6f14e346841cd889c2e0962832bb235ba3b6749ebf609f347f747da5e60f", size = 11949834, upload-time = "2026-04-03T11:12:24.986Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ff/4723d92f08259c707a974aba27a08d0a822b9555e35ca581bf18d055a364/curl_cffi-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b624c7ce087bfda967a013ed0a64702a525444e5b6e97d23534d567ccc6525aa", size = 1702771, upload-time = "2026-04-03T11:12:28.201Z" }, + { url = "https://files.pythonhosted.org/packages/59/8c/36bbe06d66fa2b765e4a07199f643a59a9cd1a754207a96335402a9520f4/curl_cffi-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0b6c0543b993996670e9e4b78e305a2d60809d5681903ffb5568e21a387434d3", size = 1466312, upload-time = "2026-04-03T11:12:30.054Z" }, +] + +[[package]] +name = "fastapi" +version = "0.136.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" }, + { url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", size = 667767, upload-time = "2026-05-20T14:09:12.15Z" }, + { url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", size = 470786, upload-time = "2026-05-20T14:01:42.064Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" }, + { url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" }, + { url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/47/f8/8e8e8417b7bf28639a5a56356ef934d0375e1d0c70a57e04d7701e870ffe/greenlet-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", size = 236862, upload-time = "2026-05-20T13:09:10.498Z" }, + { url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" }, + { url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" }, + { url = "https://files.pythonhosted.org/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", size = 656607, upload-time = "2026-05-20T14:09:13.949Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", size = 488287, upload-time = "2026-05-20T14:01:43.143Z" }, + { url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" }, + { url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" }, + { url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" }, + { url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" }, + { url = "https://files.pythonhosted.org/packages/dc/74/807a047255bf1e09303627c46dc043dca596b6958a354d904f32ab382005/greenlet-3.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0", size = 672962, upload-time = "2026-05-20T14:09:15.532Z" }, + { url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" }, + { url = "https://files.pythonhosted.org/packages/76/32/19d4e13225193c29b13e308015223f7d75fd3d8623d49dd19040d2ce8ec1/greenlet-3.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc", size = 476047, upload-time = "2026-05-20T14:01:44.39Z" }, + { url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" }, + { url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ae/4e623a7e6d4d2a5f4cb8e4c82de4169fc637942caae68d6e676b8a128ac5/greenlet-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6", size = 236853, upload-time = "2026-05-20T13:15:37.301Z" }, + { url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" }, + { url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" }, + { url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9d/1dcdf7b95ab3cf8c7b6d7277c18a5e167312f2b362ddfcc5d5e6d8d84b43/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c", size = 659998, upload-time = "2026-05-20T14:09:16.912Z" }, + { url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/c4959664fc231d587d66d8e81f2095e98056ba1954beafdcbe635e251052/greenlet-3.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62", size = 494470, upload-time = "2026-05-20T14:01:45.611Z" }, + { url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" }, + { url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" }, + { url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", size = 239590, upload-time = "2026-05-20T13:13:37.382Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "joserfc" +version = "1.6.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/dc/5f768c2e391e9afabe5d18e3221346deb5fb6338565f1ccc9e7c6d7befdd/joserfc-1.6.5.tar.gz", hash = "sha256:1482a7db78fb4602e44ed89e51b599d052e091288c7c532c5b694e20149dec48", size = 231881, upload-time = "2026-05-06T04:58:13.408Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/3b/ad1cb22e75c963b1f07c8a2329bf47227ce7e4361df5eb2fb101b2ce33ef/joserfc-1.6.5-py3-none-any.whl", hash = "sha256:e9878a0f8243fe7b95e11fdda81374ca9f7a689e302751579d3dfdeec559675e", size = 70464, upload-time = "2026-05-06T04:58:11.668Z" }, +] + +[[package]] +name = "librislog" +version = "0.0.0" +source = { virtual = "." } +dependencies = [ + { name = "librislog-backend" }, + { name = "llc" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-anyio" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, +] + +[package.metadata] +requires-dist = [ + { name = "librislog-backend", editable = "backend" }, + { name = "llc", editable = "cli" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.0.3" }, + { name = "pytest-anyio", specifier = ">=0.0.0" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, + { name = "pytest-mock", specifier = ">=3.14.0" }, +] + +[[package]] +name = "librislog-backend" +version = "0.0.0.dev0" +source = { editable = "backend" } +dependencies = [ + { name = "alembic" }, + { name = "authlib" }, + { name = "browserforge" }, + { name = "cachetools" }, + { name = "cryptography" }, + { name = "curl-cffi" }, + { name = "fastapi" }, + { name = "httpx" }, + { name = "itsdangerous" }, + { name = "passlib", extra = ["bcrypt"] }, + { name = "playwright" }, + { name = "pycountry" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "scrapling" }, + { name = "sqlmodel" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-anyio" }, + { name = "pytest-cov" }, +] + +[package.metadata] +requires-dist = [ + { name = "alembic", specifier = ">=1.18.4" }, + { name = "authlib", specifier = ">=1.6.5" }, + { name = "browserforge", specifier = ">=1.2.4" }, + { name = "cachetools", specifier = ">=5.3.3" }, + { name = "cryptography", specifier = ">=46.0.3" }, + { name = "curl-cffi", specifier = ">=0.15.0" }, + { name = "fastapi", specifier = ">=0.136.1" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "itsdangerous", specifier = ">=2.2.0" }, + { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, + { name = "playwright", specifier = ">=1.55.0" }, + { name = "pycountry", specifier = ">=24.6.1" }, + { name = "pydantic-settings", specifier = ">=2.14.1" }, + { name = "python-multipart", specifier = ">=0.0.28" }, + { name = "scrapling", specifier = ">=0.4.8" }, + { name = "sqlmodel", specifier = ">=0.0.38" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.46.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pytest", specifier = ">=9.0.3" }, + { name = "pytest-anyio", specifier = ">=0.0.0" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, +] + +[[package]] +name = "llc" +version = "0.1.0" +source = { editable = "cli" } +dependencies = [ + { name = "questionary" }, + { name = "rich" }, + { name = "typer" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-mock" }, +] + +[package.metadata] +requires-dist = [ + { name = "questionary", specifier = ">=2.0.0" }, + { name = "rich", specifier = ">=13.9.4" }, + { name = "typer", specifier = ">=0.25.1" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.0.3" }, + { name = "pytest-mock", specifier = ">=3.14.0" }, +] + +[[package]] +name = "lxml" +version = "6.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" }, + { url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" }, + { url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" }, + { url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" }, + { url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" }, + { url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" }, + { url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" }, + { url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" }, + { url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" }, + { url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" }, + { url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" }, + { url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" }, + { url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" }, + { url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" }, + { url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" }, + { url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" }, + { url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" }, + { url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" }, + { url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" }, + { url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" }, + { url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" }, + { url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" }, + { url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" }, +] + +[[package]] +name = "mako" +version = "1.3.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "passlib" +version = "1.7.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" }, +] + +[package.optional-dependencies] +bcrypt = [ + { name = "bcrypt" }, +] + +[[package]] +name = "playwright" +version = "1.60.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/f0/832bd9677194908da118064eef20082f2791e3d18215cc6d9391ee2c5a67/playwright-1.60.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:6a8cd0fec171fb3089e95e898c8bc8a6f35dea0b78b399e12fcc19427e91b1d7", size = 43474635, upload-time = "2026-05-18T12:00:31.969Z" }, + { url = "https://files.pythonhosted.org/packages/59/7b/e1d32ae8a3ed937ec2be3721c5f728b13d731a0b7c6442e0b3bec5094ac0/playwright-1.60.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:39b5420ba6145045b69ced4c5c47d4d9fe5bddfc8ff816c518913afcb25ec7a5", size = 42261327, upload-time = "2026-05-18T12:00:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/d7/bc/23de499ded6411c188a20c5a0dea6f0cd4ed5d2b3cc6042a5dbd3ed609aa/playwright-1.60.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:2581d0e6a3392c71f91b27460c7fd093356818dc430f48153896c8aeeaef7705", size = 43474636, upload-time = "2026-05-18T12:00:39.294Z" }, + { url = "https://files.pythonhosted.org/packages/22/7b/1d679f4fced4ea94efadd17103856d8c565384f68382a1681264e46f5925/playwright-1.60.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:1c2bfae7884fb3fb05b853290eab8f343d524e5016f2f1def702acbbdf14c93e", size = 47467220, upload-time = "2026-05-18T12:00:43.179Z" }, + { url = "https://files.pythonhosted.org/packages/84/c2/1528d267d4442bd2c6b8eaeab819dd52c2030bf80e89293f0ba1f687473b/playwright-1.60.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43e66564125ee31b07a58cefb21e256d62d67d8d1713e6858df7a3019d8ed353", size = 47154856, upload-time = "2026-05-18T12:00:46.715Z" }, + { url = "https://files.pythonhosted.org/packages/bb/4e/b008b6440a7a1624378041da94829956d4b8f7ab9ef5aad22d0dc3f2e26d/playwright-1.60.0-py3-none-win32.whl", hash = "sha256:ec94e416ea320711e0ad4bf185dcbf41833672961e90773e1885255d7db7b7e7", size = 37902157, upload-time = "2026-05-18T12:00:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/55/f0/0541524133104f9cc20bf900870ff4a736b76a23483f3a55295ddfa58409/playwright-1.60.0-py3-none-win_amd64.whl", hash = "sha256:9566821ce6030a1f9e7146a24e19355ab0d98805fd0f9be50bb3d8fef1750c02", size = 37902159, upload-time = "2026-05-18T12:00:53.728Z" }, + { url = "https://files.pythonhosted.org/packages/80/c8/210f282d278e4709cdd71b12a31af45a30a22ab3207b387e29b37e478713/playwright-1.60.0-py3-none-win_arm64.whl", hash = "sha256:6e4f6700a4c2250efff8e690a81d66e3855754fb587b6b87cf5c784014f91537", size = 34037981, upload-time = "2026-05-18T12:00:57.584Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "pycountry" +version = "26.2.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/061b9e7a48b85cfd69f33c33d2ef784a531c359399ad764243399673c8f5/pycountry-26.2.16.tar.gz", hash = "sha256:5b6027d453fcd6060112b951dd010f01f168b51b4bf8a1f1fc8c95c8d94a0801", size = 7711342, upload-time = "2026-02-17T03:42:52.367Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/42/7703bd45b62fecd44cd7d3495423097e2f7d28bc2e99e7c1af68892ab157/pycountry-26.2.16-py3-none-any.whl", hash = "sha256:115c4baf7cceaa30f59a4694d79483c9167dbce7a9de4d3d571c5f3ea77c305a", size = 8044600, upload-time = "2026-02-17T03:42:49.777Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, +] + +[[package]] +name = "pyee" +version = "13.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-anyio" +version = "0.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/44/a02e5877a671b0940f21a7a0d9704c22097b123ed5cdbcca9cab39f17acc/pytest-anyio-0.0.0.tar.gz", hash = "sha256:b41234e9e9ad7ea1dbfefcc1d6891b23d5ef7c9f07ccf804c13a9cc338571fd3", size = 1560, upload-time = "2021-06-29T22:57:30.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/25/bd6493ae85d0a281b6a0f248d0fdb1d9aa2b31f18bcd4a8800cf397d8209/pytest_anyio-0.0.0-py2.py3-none-any.whl", hash = "sha256:dc8b5c4741cb16ff90be37fddd585ca943ed12bbeb563de7ace6cd94441d8746", size = 1999, upload-time = "2021-06-29T22:57:29.158Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.29" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/fe/70bd71a6738b09a0bdf6480ca6436b167469ca4578b2a0efbe390b4b0e70/python_multipart-0.0.29.tar.gz", hash = "sha256:643e93849196645e2dbdd81a0f8829a23123ad7f797a84a364c6fb3563f18904", size = 45678, upload-time = "2026-05-17T17:29:47.654Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/cb/769cfc37177252872a45a71f3fbdde9d51b471a3f3c14bfe95dde3407386/python_multipart-0.0.29-py3-none-any.whl", hash = "sha256:2ddcc971cef266225f54f552d8fa10bcfbb1f14446caec199060daac59ff2d69", size = 29640, upload-time = "2026-05-17T17:29:45.69Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "questionary" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "scrapling" +version = "0.4.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cssselect" }, + { name = "lxml" }, + { name = "orjson" }, + { name = "tld" }, + { name = "typing-extensions" }, + { name = "w3lib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/03/91b75381298493758eac3eb326621e5b04c8510cc96a3b7ad0c86a405db3/scrapling-0.4.8.tar.gz", hash = "sha256:04fc55fffcfb10e099b7d9be385876ae796c23c756e28be4dd79971873bd8e72", size = 157004, upload-time = "2026-05-11T02:00:48.571Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/56/97c0d4e05e9e0c7d712642ddbaf176d723bf5590b29a3b571cf1038cd06b/scrapling-0.4.8-py3-none-any.whl", hash = "sha256:ea6e5f13760740489544cf0f72e69014260e1658d19cf2bc337b82ac91d45782", size = 158559, upload-time = "2026-05-11T02:00:46.704Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.49" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e", size = 2156356, upload-time = "2026-04-03T16:53:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a7/5f476227576cb8644650eff68cc35fa837d3802b997465c96b8340ced1e2/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a", size = 3276486, upload-time = "2026-04-03T17:07:46.9Z" }, + { url = "https://files.pythonhosted.org/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066", size = 3281479, upload-time = "2026-04-03T17:12:32.226Z" }, + { url = "https://files.pythonhosted.org/packages/91/68/bb406fa4257099c67bd75f3f2261b129c63204b9155de0d450b37f004698/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187", size = 3226269, upload-time = "2026-04-03T17:07:48.678Z" }, + { url = "https://files.pythonhosted.org/packages/67/84/acb56c00cca9f251f437cb49e718e14f7687505749ea9255d7bd8158a6df/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401", size = 3248260, upload-time = "2026-04-03T17:12:34.381Z" }, + { url = "https://files.pythonhosted.org/packages/56/19/6a20ea25606d1efd7bd1862149bb2a22d1451c3f851d23d887969201633f/sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5", size = 2118463, upload-time = "2026-04-03T17:05:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5", size = 2144204, upload-time = "2026-04-03T17:05:48.694Z" }, + { url = "https://files.pythonhosted.org/packages/1f/33/95e7216df810c706e0cd3655a778604bbd319ed4f43333127d465a46862d/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977", size = 3565474, upload-time = "2026-04-03T16:58:35.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/a4/ed7b18d8ccf7f954a83af6bb73866f5bc6f5636f44c7731fbb741f72cc4f/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01", size = 3530567, upload-time = "2026-04-03T17:06:04.587Z" }, + { url = "https://files.pythonhosted.org/packages/73/a3/20faa869c7e21a827c4a2a42b41353a54b0f9f5e96df5087629c306df71e/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61", size = 3474282, upload-time = "2026-04-03T16:58:37.131Z" }, + { url = "https://files.pythonhosted.org/packages/b7/50/276b9a007aa0764304ad467eceb70b04822dc32092492ee5f322d559a4dc/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a", size = 3480406, upload-time = "2026-04-03T17:06:07.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/c3/c80fcdb41905a2df650c2a3e0337198b6848876e63d66fe9188ef9003d24/sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158", size = 2149151, upload-time = "2026-04-03T17:02:07.281Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9f1a62feab6ed368aff068524ff414f26a6daebc7361861035ae00b05530/sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7", size = 2184178, upload-time = "2026-04-03T17:02:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" }, +] + +[[package]] +name = "sqlmodel" +version = "0.0.38" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/0d/26ec1329960ea9430131fe63f63a95ea4cb8971d49c891ff7e1f3255421c/sqlmodel-0.0.38.tar.gz", hash = "sha256:d583ec237b14103809f74e8630032bc40ab68cd6b754a610f0813c56911a547b", size = 86710, upload-time = "2026-04-02T21:03:55.571Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/c7/10c60af0607ab6fa136264f7f39d205932218516226d38585324ffda705d/sqlmodel-0.0.38-py3-none-any.whl", hash = "sha256:84e3fa990a77395461ded72a6c73173438ce8449d5c1c4d97fbff1b1df692649", size = 27294, upload-time = "2026-04-02T21:03:56.406Z" }, +] + +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + +[[package]] +name = "tld" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5d/76b4383ac4e5b5e254e50c09807b3e13820bed6d6c11cd540264988d6802/tld-0.13.2.tar.gz", hash = "sha256:d983fa92b9d717400742fca844e29d5e18271079c7bcfabf66d01b39b4a14345", size = 467175, upload-time = "2026-03-06T23:50:34.498Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/90/39a85a4b63c84213e78b3c17d22e1bf45328acf8ebb33ef93be30d0a3911/tld-0.13.2-py2.py3-none-any.whl", hash = "sha256:9b8fdbdb880e7ba65b216a4937f2c94c49a7226723783d5838fc958ac76f4e0c", size = 296743, upload-time = "2026-03-06T23:50:32.465Z" }, +] + +[[package]] +name = "typer" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.47.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/b1/8e7077a8641086aea449e1b5752a570f1b5906c64e0a33cd6d93b63a066b/uvicorn-0.47.0.tar.gz", hash = "sha256:7c9a0ea1a9414106bbab7324609c162d8fa0cdcdcb703060987269d77c7bb533", size = 90582, upload-time = "2026-05-14T18:16:54.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/41/ac2dfdbc1f60c7af4f994c7a335cfa7040c01642b605d65f611cecc2a1e4/uvicorn-0.47.0-py3-none-any.whl", hash = "sha256:2c5715bc12d1892d84752049f400cd1c3cb018514967fdfeb97640443a6a9432", size = 71301, upload-time = "2026-05-14T18:16:51.762Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "w3lib" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/91/b2eb59c2cf243de5de1e91c963655df78c015509f51297685a8c86a27b8c/w3lib-2.4.1.tar.gz", hash = "sha256:8dd69ee39ff6398d708c793abc779c334a69bac7cee1cdf71736c669ed6be864", size = 48494, upload-time = "2026-03-20T09:50:27.477Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/c3/f8b216cbd742e5b84c40f045204c764ccb7524d2aeab021054ec69446b0a/w3lib-2.4.1-py3-none-any.whl", hash = "sha256:40930132907e68de906a5b89331ab8c8ff4f01bd35b5539ef7896017d814138d", size = 21695, upload-time = "2026-03-20T09:50:26.187Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] From 05b8a76a11d1d2d38ced539b39b847a009fb0de4 Mon Sep 17 00:00:00 2001 From: codebude Date: Thu, 21 May 2026 22:24:14 +0200 Subject: [PATCH 002/165] More tools for devops cli --- cli/src/llc/_git.py | 8 ++++++ cli/src/llc/main.py | 48 ++++++++++++++++++++++++++++++++ cli/src/llc/pr.py | 22 +++++++++++++++ cli/src/llc/tag.py | 40 +++++++++++++++++++++++++++ cli/src/llc/test.py | 67 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 185 insertions(+) create mode 100644 cli/src/llc/test.py diff --git a/cli/src/llc/_git.py b/cli/src/llc/_git.py index 6eb16fd..5aa6a5c 100644 --- a/cli/src/llc/_git.py +++ b/cli/src/llc/_git.py @@ -98,6 +98,14 @@ def fetch() -> None: _run_git(["fetch", "origin"]) +def delete_tag(tagname: str) -> None: + _run_git(["tag", "-d", tagname]) + + +def delete_remote_tag(tagname: str) -> None: + _run_git(["push", "origin", "--delete", tagname], interactive=True) + + def tag_exists(tagname: str) -> bool: try: _run_git(["rev-parse", "-q", "--verify", f"refs/tags/{tagname}"]) diff --git a/cli/src/llc/main.py b/cli/src/llc/main.py index 685b1f3..402d6a0 100644 --- a/cli/src/llc/main.py +++ b/cli/src/llc/main.py @@ -17,8 +17,21 @@ help="Manage version tags", rich_markup_mode="rich", ) +test_app = typer.Typer( + name="test", + help="Run test suites", + rich_markup_mode="rich", +) app.add_typer(pr_app) app.add_typer(tag_app) +app.add_typer(test_app) + + +@pr_app.command("list") +def pr_list(): + """List open pull requests.""" + from llc.pr import cmd_list + cmd_list() @pr_app.command("create") @@ -42,6 +55,41 @@ def tag_create(): cmd_create() +@tag_app.command("delete") +def tag_delete(): + """Delete a tag locally and remotely.""" + from llc.tag import cmd_delete + cmd_delete() + + +@test_app.command("backend") +def test_backend(): + """Run backend pytest with coverage.""" + from llc.test import cmd_backend + cmd_backend() + + +@test_app.command("cli") +def test_cli(): + """Run CLI pytest.""" + from llc.test import cmd_cli + cmd_cli() + + +@test_app.command("frontend") +def test_frontend(): + """Run frontend vitest with coverage.""" + from llc.test import cmd_frontend + cmd_frontend() + + +@test_app.command("all") +def test_all(): + """Run all test suites and print coverage summary.""" + from llc.test import cmd_all + cmd_all() + + @app.command() def sync(): """Sync current branch with an origin branch.""" diff --git a/cli/src/llc/pr.py b/cli/src/llc/pr.py index eacb293..3285525 100644 --- a/cli/src/llc/pr.py +++ b/cli/src/llc/pr.py @@ -95,3 +95,25 @@ def cmd_merge() -> None: except Exception as exc: console.print(f"[red]PR merge failed: {exc}[/red]") raise typer.Exit(code=1) + + +def cmd_list() -> None: + try: + llc._gh.check_gh() + except Exception as exc: + console.print(f"[red]{exc}[/red]") + raise typer.Exit(code=1) + + try: + prs = llc._gh.list_open_prs() + except Exception as exc: + console.print(f"[red]Failed to list PRs: {exc}[/red]") + raise typer.Exit(code=1) + + if not prs: + console.print("[yellow]No open pull requests.[/yellow]") + raise typer.Exit() + + for pr in prs: + author = pr.get("author", {}).get("login", "?") + console.print(f" #[bold]{pr['number']}[/bold] — {pr['title']} ({author})") diff --git a/cli/src/llc/tag.py b/cli/src/llc/tag.py index baad087..7bbd124 100644 --- a/cli/src/llc/tag.py +++ b/cli/src/llc/tag.py @@ -4,6 +4,7 @@ import llc._git import llc._interactive from llc._interactive import console +from llc._git import GitError def _parse_tag(tag: str) -> tuple[int, int, int, int | None]: @@ -123,3 +124,42 @@ def cmd_create() -> None: except Exception: pass raise typer.Exit(code=1) + + +def cmd_delete() -> None: + try: + tags = llc._git.fetch_tags("v*") + except Exception: + console.print("[red]Failed to fetch tags.[/red]") + raise typer.Exit(code=1) + + recent = tags[:5] + choices = recent + ["Enter tag name manually"] + preselect = recent[0] if recent else None + choice = llc._interactive.select_from_list( + choices, title="Select tag to delete", preselect=preselect + ) + if choice is None: + console.print("[yellow]Cancelled.[/yellow]") + raise typer.Exit() + + if choice == "Enter tag name manually": + tagname = llc._interactive.prompt_text("Enter tag name") + if not tagname or not tagname.strip(): + console.print("[yellow]Cancelled.[/yellow]") + raise typer.Exit() + tagname = tagname.strip() + else: + tagname = choice + + try: + llc._git.delete_tag(tagname) + console.print(f"[green]Deleted local tag {tagname}.[/green]") + except GitError: + console.print(f"[yellow]Local tag {tagname} not found or could not be deleted. Proceeding...[/yellow]") + + try: + llc._git.delete_remote_tag(tagname) + console.print(f"[green]Deleted remote tag {tagname}.[/green]") + except GitError as exc: + console.print(f"[yellow]Remote tag {tagname} not found or could not be deleted: {exc}. Proceeding...[/yellow]") diff --git a/cli/src/llc/test.py b/cli/src/llc/test.py new file mode 100644 index 0000000..898956a --- /dev/null +++ b/cli/src/llc/test.py @@ -0,0 +1,67 @@ +import subprocess +from pathlib import Path + +import typer +from llc._interactive import console + +_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent +_BACKEND = _PROJECT_ROOT / "backend" +_CLI = _PROJECT_ROOT / "cli" +_FRONTEND = _PROJECT_ROOT / "frontend" + + +def cmd_backend() -> None: + console.print("[bold]Running backend tests with coverage...[/bold]") + code = subprocess.call(["uv", "run", "pytest"], cwd=str(_BACKEND)) + if code != 0: + raise typer.Exit(code=code) + + +def cmd_cli() -> None: + console.print("[bold]Running CLI tests...[/bold]") + code = subprocess.call(["uv", "run", "pytest"], cwd=str(_CLI)) + if code != 0: + raise typer.Exit(code=code) + + +def cmd_frontend() -> None: + console.print("[bold]Running frontend tests with coverage...[/bold]") + code = subprocess.call(["npm", "run", "test:coverage"], cwd=str(_FRONTEND)) + if code != 0: + raise typer.Exit(code=code) + + +def cmd_all() -> None: + console.print("[bold]Running all test suites...[/bold]\n") + + suites = [ + ("Backend", ["uv", "run", "pytest"], _BACKEND), + ("CLI", ["uv", "run", "pytest"], _CLI), + ("Frontend", ["npm", "run", "test:coverage"], _FRONTEND), + ] + + results: dict[str, int] = {} + for name, cmd, cwd in suites: + console.print(f"[bold]=== {name} ===[/bold]") + try: + r = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + results[name] = r.returncode + print(r.stdout) + if r.stderr: + print(r.stderr) + except Exception as exc: + console.print(f"[red]{name}: failed to run — {exc}[/red]") + results[name] = 1 + print() + + console.print("[bold]=== Summary ===[/bold]") + any_failed = False + for name, code in results.items(): + if code == 0: + console.print(f" [green]{name}: PASSED[/green]") + else: + console.print(f" [red]{name}: FAILED (exit code {code})[/red]") + any_failed = True + + if any_failed: + raise typer.Exit(code=1) From ca86ece781e85f520382835bdf755db195bbaeac Mon Sep 17 00:00:00 2001 From: codebude Date: Thu, 21 May 2026 23:02:57 +0200 Subject: [PATCH 003/165] Add Docker build & publish workflow to GHCR --- .github/workflows/docker.yml | 100 +++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 .github/workflows/docker.yml diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..7b923e7 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,100 @@ +name: Docker Build & Publish + +on: + push: + branches: [develop] + release: + types: [published] + workflow_dispatch: + inputs: + branch: + description: "Branch to build from" + required: false + default: develop + +env: + REGISTRY: ghcr.io + +jobs: + build-and-push: + runs-on: ubuntu-latest + strategy: + matrix: + service: + - name: frontend + image: librislog + dockerfile: ./frontend/Dockerfile + context: ./frontend + - name: backend + image: librislog-api + dockerfile: ./backend/Dockerfile + context: ./backend + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.inputs.branch || github.ref }} + + - name: Derive version + id: version + run: | + if [ "${{ github.event_name }}" = "release" ]; then + VERSION="${{ github.event.release.tag_name }}" + else + VERSION="$(git describe --tags --always 2>/dev/null || echo 'v0.0.0-dev')" + fi + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "sha_short=${{ github.sha[0:7] }}" >> "$GITHUB_OUTPUT" + + - name: Sanitize version for Docker tag + id: sanitize + run: | + VERSION="${{ steps.version.outputs.version }}" + SANITIZED="$(echo "$VERSION" | sed 's/[^a-zA-Z0-9_.-]/-/g')" + SANITIZED="$(echo "$SANITIZED" | sed 's/^[^a-zA-Z0-9_]\+//')" + echo "sanitized_tag=${SANITIZED:0:128}" >> "$GITHUB_OUTPUT" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate image tags + id: tags + run: | + IMAGE="${{ env.REGISTRY }}/${{ github.repository }}/${{ matrix.service.image }}" + SHA_SHORT="${{ steps.version.outputs.sha_short }}" + + if [ "${{ github.event_name }}" = "release" ]; then + SANITIZED="${{ steps.sanitize.outputs.sanitized_tag }}" + TAGS="${IMAGE}:${SANITIZED},${IMAGE}:latest" + else + TAGS="${IMAGE}:develop,${IMAGE}:${SHA_SHORT}" + fi + + echo "tags=${TAGS}" >> "$GITHUB_OUTPUT" + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: ${{ matrix.service.context }} + file: ${{ matrix.service.dockerfile }} + push: true + platforms: linux/amd64 + tags: ${{ steps.tags.outputs.tags }} + build-args: | + APP_VERSION=${{ steps.version.outputs.version }} + GIT_SHA=${{ github.sha }} + ${{ matrix.service.name == 'frontend' && 'PUBLIC_DEFAULT_LOCALE=en' || '' }} + cache-from: type=gha + cache-to: type=gha,mode=max From 7ae59c9881a7be2bb79e9a0974d35143f4bea1a6 Mon Sep 17 00:00:00 2001 From: codebude Date: Thu, 21 May 2026 23:07:43 +0200 Subject: [PATCH 004/165] Github workflow - iteration 1 --- .github/workflows/docker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 7b923e7..d08b152 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -49,7 +49,7 @@ jobs: VERSION="$(git describe --tags --always 2>/dev/null || echo 'v0.0.0-dev')" fi echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - echo "sha_short=${{ github.sha[0:7] }}" >> "$GITHUB_OUTPUT" + echo "sha_short=$(echo '${{ github.sha }}' | cut -c1-7)" >> "$GITHUB_OUTPUT" - name: Sanitize version for Docker tag id: sanitize From be114b61f2ca216e494167e2ed0b3a6ca5dbaefd Mon Sep 17 00:00:00 2001 From: codebude Date: Thu, 21 May 2026 23:14:03 +0200 Subject: [PATCH 005/165] Github workflow - bumped action versions --- .github/workflows/docker.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index d08b152..e41af7f 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -35,7 +35,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 ref: ${{ github.event.inputs.branch || github.ref }} @@ -60,10 +60,10 @@ jobs: echo "sanitized_tag=${SANITIZED:0:128}" >> "$GITHUB_OUTPUT" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.repository_owner }} @@ -85,7 +85,7 @@ jobs: echo "tags=${TAGS}" >> "$GITHUB_OUTPUT" - name: Build and push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: ${{ matrix.service.context }} file: ${{ matrix.service.dockerfile }} From 52d982c6613c9045a812afd97753f665b74ba972 Mon Sep 17 00:00:00 2001 From: codebude Date: Fri, 22 May 2026 00:04:23 +0200 Subject: [PATCH 006/165] Fix version presentation to be compliant with main and dev builds --- backend/app/main.py | 7 +++++++ frontend/src/routes/+layout.svelte | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/backend/app/main.py b/backend/app/main.py index 9f91551..f6419aa 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10,6 +10,7 @@ from starlette.middleware.sessions import SessionMiddleware from starlette.responses import Response +from app._build_info import __git_sha__, __version__ from app.config import settings from app.logging_config import configure_logging from app.routers import admin, auth, books, cover_candidates, covers, data, docs, health, import_, oidc, profile, progress, statistics, users @@ -61,9 +62,15 @@ async def lifespan(app: FastAPI): pass +if __git_sha__ != "unknown" and __version__.find(__git_sha__[:7]) == -1: + _display_version = f"{__version__} ({__git_sha__[:7]})" +else: + _display_version = __version__ + app = FastAPI( title="LibrisLog API", description="Backend API for LibrisLog.", + version=_display_version, lifespan=lifespan, openapi_url="/api/openapi.json", docs_url=None, diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 78f83dd..df758fa 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -194,7 +194,7 @@ {/each}
- {version}{#if gitSha && gitSha !== 'unknown'} ({gitSha.slice(0, 7)}){/if} + {version}{#if gitSha && gitSha !== 'unknown' && !version.includes(gitSha.slice(0, 7))} ({gitSha.slice(0, 7)}){/if}
From 71112bd54b0700c1a7183297b221a92e1b3e98bb Mon Sep 17 00:00:00 2001 From: codebude Date: Fri, 22 May 2026 00:14:32 +0200 Subject: [PATCH 007/165] Fixed language selctor on login page --- frontend/src/lib/i18n/index.ts | 31 +++++++++++++++++++++++++- frontend/src/routes/login/+page.svelte | 12 ++++++++-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/i18n/index.ts b/frontend/src/lib/i18n/index.ts index 876a725..151d1e3 100644 --- a/frontend/src/lib/i18n/index.ts +++ b/frontend/src/lib/i18n/index.ts @@ -5,6 +5,7 @@ export const SUPPORTED_LOCALES = ['en', 'de'] as const; export type AppLocale = (typeof SUPPORTED_LOCALES)[number]; const DEFAULT_LOCALE: AppLocale = 'en'; +const LOCALE_STORAGE_KEY = 'librislog_locale'; const envLocale = (import.meta.env.PUBLIC_DEFAULT_LOCALE as string | undefined)?.toLowerCase(); const configuredDefaultLocale: AppLocale = isSupportedLocale(envLocale) ? envLocale : DEFAULT_LOCALE; @@ -20,6 +21,24 @@ function isSupportedLocale(value: string | null | undefined): value is AppLocale return !!value && (SUPPORTED_LOCALES as readonly string[]).includes(value); } +function loadStoredLocale(): AppLocale | null { + try { + const stored = localStorage.getItem(LOCALE_STORAGE_KEY); + if (isSupportedLocale(stored)) return stored; + } catch { + // localStorage unavailable (SSR, private mode, etc.) + } + return null; +} + +function storeLocale(value: AppLocale) { + try { + localStorage.setItem(LOCALE_STORAGE_KEY, value); + } catch { + // localStorage unavailable + } +} + export async function setupI18n() { if (initialized) { await waitLocale(); @@ -33,7 +52,10 @@ export async function setupI18n() { initialLocale = settings.language; } } catch { - // unauthenticated route before cookie-based login + const stored = loadStoredLocale(); + if (stored) { + initialLocale = stored; + } } init({ @@ -42,6 +64,13 @@ export async function setupI18n() { }); initialized = true; + + locale.subscribe((value) => { + if (isSupportedLocale(value)) { + storeLocale(value); + } + }); + await waitLocale(); } diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index 9ca1a7c..45f3d4a 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -8,6 +8,7 @@ let email = $state(''); let password = $state(''); let selectedLanguage = $state('en'); + let languageChanged = $state(false); let loading = $state(false); let error = $state(''); let oidcEnabled = $state(false); @@ -35,6 +36,7 @@ const next = (event.currentTarget as HTMLSelectElement).value as AppLocale; if (!SUPPORTED_LOCALES.includes(next)) return; selectedLanguage = next; + languageChanged = true; setLocale(next); } @@ -48,11 +50,17 @@ csrfToken.set(csrf.csrf_token); const settings = await api.profile.getSettings(); const detected = detectTimezone(); - const update: { language: string; timezone?: string } = { language: selectedLanguage }; + const update: { language?: string; timezone?: string } = {}; + if (languageChanged) update.language = selectedLanguage; if (settings.timezone === 'UTC') update.timezone = detected; await api.profile.updateSettings(update); setTimezone(settings.timezone === 'UTC' ? detected : settings.timezone); - setLocale(selectedLanguage); + const localeToSet: AppLocale = languageChanged + ? selectedLanguage + : SUPPORTED_LOCALES.includes(settings.language as AppLocale) + ? (settings.language as AppLocale) + : 'en'; + setLocale(localeToSet); await goto('/'); } catch (e: unknown) { error = e instanceof Error ? e.message : $_('auth.loginFailed'); From d0bec85309510ae826e4463fd6d3991493623ee6 Mon Sep 17 00:00:00 2001 From: codebude Date: Fri, 22 May 2026 00:33:56 +0200 Subject: [PATCH 008/165] Fix barcode scanner rendering --- .../src/lib/components/AddBookModal.test.ts | 37 ++-- .../src/lib/components/BarcodeScanner.svelte | 166 ++++++++++-------- 2 files changed, 116 insertions(+), 87 deletions(-) diff --git a/frontend/src/lib/components/AddBookModal.test.ts b/frontend/src/lib/components/AddBookModal.test.ts index d9e32a6..7877f47 100644 --- a/frontend/src/lib/components/AddBookModal.test.ts +++ b/frontend/src/lib/components/AddBookModal.test.ts @@ -27,24 +27,27 @@ vi.mock('$lib/toasts', () => ({ } })); -// Mock html5-qrcode for BarcodeScanner -vi.mock('html5-qrcode', () => ({ - Html5Qrcode: class MockHtml5Qrcode { - async start() {} - async stop() {} - clear() {} - static async getCameras() { - return []; +// Mock html5-qrcode subpath imports for BarcodeScanner +vi.mock('html5-qrcode/esm/core', () => { + const BaseLoggger = class { + log() {} warn() {} logError() {} logErrors() {} + }; + return { + Html5QrcodeSupportedFormats: { + EAN_13: 9, EAN_8: 10, UPC_A: 14, UPC_E: 15, CODE_128: 5, QR_CODE: 0 + }, + BaseLoggger + }; +}); + +vi.mock('html5-qrcode/esm/code-decoder', () => { + const Html5QrcodeShim = class { + constructor() { + this.decodeAsync = function () { return Promise.resolve({ text: '' }); }; } - }, - Html5QrcodeSupportedFormats: { - EAN_13: 1, - EAN_8: 2, - UPC_A: 3, - UPC_E: 4, - CODE_128: 5 - } -})); + }; + return { Html5QrcodeShim }; +}); describe('AddBookModal', () => { beforeEach(() => { diff --git a/frontend/src/lib/components/BarcodeScanner.svelte b/frontend/src/lib/components/BarcodeScanner.svelte index acac85a..4e413b5 100644 --- a/frontend/src/lib/components/BarcodeScanner.svelte +++ b/frontend/src/lib/components/BarcodeScanner.svelte @@ -1,7 +1,8 @@
@@ -204,7 +217,8 @@ {#if isOpen}
    {#each suggestions as suggestion, i}
  • { const input = screen.getByRole('textbox'); expect(input).toBeDisabled(); }); + + it('uses fixed positioning to avoid overflow clipping', async () => { + const fetchSuggestions = vi.fn(async () => ['fantasy', 'fiction']); + render(TagInput, { props: { value: '', fetchSuggestions } }); + + const input = screen.getByRole('textbox'); + await fireEvent.input(input, { target: { value: 'f' } }); + await vi.advanceTimersByTimeAsync(300); + + await waitFor(() => { + const lb = screen.getByRole('listbox'); + expect(lb.getAttribute('style')).toContain('position: fixed'); + }); + const listbox = screen.getByRole('listbox'); + const style = listbox.getAttribute('style') || ''; + expect(style).toContain('top:'); + expect(style).toContain('left:'); + expect(style).toContain('width:'); + }); + + it('uses position fixed to avoid overflow clipping', async () => { + const fetchSuggestions = vi.fn(async () => ['fantasy', 'fiction']); + render(TagInput, { props: { value: '', fetchSuggestions } }); + + const input = screen.getByRole('textbox'); + await fireEvent.input(input, { target: { value: 'f' } }); + await vi.advanceTimersByTimeAsync(300); + + await waitFor(() => { + const lb = screen.getByRole('listbox'); + expect(lb.getAttribute('style')).toContain('position: fixed'); + }); + const listbox = screen.getByRole('listbox'); + const style = listbox.getAttribute('style') || ''; + expect(style).toContain('top:'); + expect(style).toContain('left:'); + expect(style).toContain('width:'); + }); }); From eb181f73e0b4d2aa4df7a206b8828ae419e9963b Mon Sep 17 00:00:00 2001 From: codebude Date: Fri, 22 May 2026 10:15:38 +0200 Subject: [PATCH 033/165] Refactored cover candidate representation/styling --- .../components/AutoSearchCoverModal.svelte | 61 ++++++++++++------- .../components/AutoSearchCoverModal.test.ts | 11 ++-- 2 files changed, 45 insertions(+), 27 deletions(-) diff --git a/frontend/src/lib/components/AutoSearchCoverModal.svelte b/frontend/src/lib/components/AutoSearchCoverModal.svelte index e3ac1f5..e0a008f 100644 --- a/frontend/src/lib/components/AutoSearchCoverModal.svelte +++ b/frontend/src/lib/components/AutoSearchCoverModal.svelte @@ -20,6 +20,24 @@ let imageResolutionMap = $state>({}); + const available = $derived(candidates.filter((c) => c.available)); + + function resolutionKey(candidate: CoverCandidate): string { + return `${candidate.source}:${candidate.url}`; + } + + function resolutionScore(candidate: CoverCandidate): number { + const detected = imageResolutionMap[resolutionKey(candidate)]; + if (detected) { + const parts = detected.split('x'); + return parseInt(parts[0], 10) * parseInt(parts[1], 10); + } + if (candidate.width && candidate.height) return candidate.width * candidate.height; + return 0; + } + + const sorted = $derived.by(() => [...available].sort((a, b) => resolutionScore(b) - resolutionScore(a))); + function close() { onCancel?.(); } @@ -32,9 +50,8 @@ } function resolutionLabel(candidate: CoverCandidate): string { - const key = `${candidate.source}:${candidate.url}`; - const fromImage = imageResolutionMap[key]; - if (fromImage) return fromImage; + const detected = imageResolutionMap[resolutionKey(candidate)]; + if (detected) return detected; if (!candidate.width || !candidate.height) return 'n/a'; return `${candidate.width}x${candidate.height}`; } @@ -44,7 +61,7 @@ if (!target?.naturalWidth || !target?.naturalHeight) return; imageResolutionMap = { ...imageResolutionMap, - [`${candidate.source}:${candidate.url}`]: `${target.naturalWidth}x${target.naturalHeight}` + [resolutionKey(candidate)]: `${target.naturalWidth}x${target.naturalHeight}` }; } @@ -69,33 +86,33 @@

    {$_('book.autoSearchInfo')}

    - {#if candidates.length === 0} + {#if sorted.length === 0}
    {$_('book.autoSearchNoCandidates')}
    {:else}
    - {#each candidates.filter((candidate) => candidate.available) as candidate (candidate.source + candidate.url)} + {#each sorted as candidate (resolutionKey(candidate))} {/each} diff --git a/frontend/src/lib/components/AutoSearchCoverModal.test.ts b/frontend/src/lib/components/AutoSearchCoverModal.test.ts index 6cca347..df6e4e4 100644 --- a/frontend/src/lib/components/AutoSearchCoverModal.test.ts +++ b/frontend/src/lib/components/AutoSearchCoverModal.test.ts @@ -75,7 +75,8 @@ describe('AutoSearchCoverModal', () => { ); await fireEvent.click(imgButtons[0]); expect(onSelect).toHaveBeenCalledOnce(); - expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ source: 'AbeBooks' })); + // First card is sorted by resolution descending: OpenLibrary (400x600) before AbeBooks (200x300) + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ source: 'OpenLibrary' })); }); it('calls onCancel when close button clicked', async () => { @@ -113,7 +114,7 @@ describe('AutoSearchCoverModal', () => { render(AutoSearchCoverModal, { props: { open: true, loading: false, candidates: candidateNoMeta, error: null, onCancel, onSelect } }); - expect(document.body.textContent).toContain('n/a - n/a'); + expect(document.body.textContent).toContain('n/a'); }); it('shows KB filesize label', () => { @@ -152,12 +153,12 @@ describe('AutoSearchCoverModal', () => { props: { open: true, loading: false, candidates: candidateWithLoad, error: null, onCancel, onSelect } }); const img = document.querySelector('img'); - // Simulate image load with zero natural dimensions + // Simulate image load with zero natural dimensions — resolution stays hidden const loadEvent = new Event('load'); Object.defineProperty(img!, 'naturalWidth', { value: 0 }); Object.defineProperty(img!, 'naturalHeight', { value: 0 }); await fireEvent(img!, loadEvent); - // Resolution should still show n/a since natural dimensions are 0 - expect(document.body.textContent).toContain('n/a'); + // Filesize is still shown + expect(document.body.textContent).toContain('1000 B'); }); }); From b056c3c60c086a9accd5d2ab46281a985e4a0bdb Mon Sep 17 00:00:00 2001 From: codebude Date: Fri, 22 May 2026 11:02:24 +0200 Subject: [PATCH 034/165] Touch-capable tool tips for barcharts on stats page --- frontend/package-lock.json | 18 +++++++ frontend/package.json | 1 + frontend/src/lib/components/BarChart.svelte | 52 ++++++++++++++++++- .../components/CalendarCellRenderer.svelte | 15 ++++++ 4 files changed, 85 insertions(+), 1 deletion(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4fa2063..0f77064 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,6 +23,7 @@ "@sveltejs/vite-plugin-svelte": "^7.0.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/svelte": "^5.3.1", + "@types/d3-scale": "^4.0.9", "@types/d3-shape": "^3.1.8", "@types/node": "^25.7.0", "@vitest/coverage-v8": "^4.1.7", @@ -1158,6 +1159,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, "node_modules/@types/d3-shape": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", @@ -1168,6 +1179,13 @@ "@types/d3-path": "*" } }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7b4ea7f..4eb79e4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ "@sveltejs/vite-plugin-svelte": "^7.0.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/svelte": "^5.3.1", + "@types/d3-scale": "^4.0.9", "@types/d3-shape": "^3.1.8", "@types/node": "^25.7.0", "@vitest/coverage-v8": "^4.1.7", diff --git a/frontend/src/lib/components/BarChart.svelte b/frontend/src/lib/components/BarChart.svelte index d8b03ff..b04341a 100644 --- a/frontend/src/lib/components/BarChart.svelte +++ b/frontend/src/lib/components/BarChart.svelte @@ -1,5 +1,6 @@ + + {#if data.length === 0}

    {emptyText}

    {:else} -
    + {/if} diff --git a/frontend/src/lib/components/CalendarCellRenderer.svelte b/frontend/src/lib/components/CalendarCellRenderer.svelte index cf10bd2..46eca26 100644 --- a/frontend/src/lib/components/CalendarCellRenderer.svelte +++ b/frontend/src/lib/components/CalendarCellRenderer.svelte @@ -1,5 +1,6 @@ {#each cells as cell} @@ -33,5 +45,8 @@ if (cell.data?.pages !== undefined) ctx.tooltip.show(e, cell.data); }} onpointerleave={() => ctx.tooltip.hide()} + onclick={(e) => { + if (cell.data?.pages !== undefined) ctx.tooltip.show(e, cell.data); + }} /> {/each} From 974568469c6fbef1745cf03dae7cd614681a113f Mon Sep 17 00:00:00 2001 From: codebude Date: Fri, 22 May 2026 12:01:03 +0200 Subject: [PATCH 035/165] Added pan and zoom to statistic barcharts --- frontend/src/lib/components/BarChart.svelte | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/lib/components/BarChart.svelte b/frontend/src/lib/components/BarChart.svelte index b04341a..b93862f 100644 --- a/frontend/src/lib/components/BarChart.svelte +++ b/frontend/src/lib/components/BarChart.svelte @@ -9,6 +9,7 @@ color = 'primary', emptyText = 'No data', height = 200, + transform = { mode: 'domain', axis: 'x' }, }: { labels: string[]; data: number[]; @@ -16,6 +17,7 @@ color: string; emptyText?: string; height?: number; + transform?: Record; } = $props(); let isTouchDevice = $state(false); @@ -81,6 +83,7 @@ const series = $derived([ { key: 'default', value: 'value' as const, color: resolveColor(color), label }, ]); + @@ -98,6 +101,7 @@ {series} {height} bandPadding={0.3} + {transform} props={{ xAxis: { tickSpacing: 80 }, bars: { strokeWidth: 0, stroke: 'none' } From c0f5b314a8550ca386039e0894aa24693996de81 Mon Sep 17 00:00:00 2001 From: codebude Date: Fri, 22 May 2026 12:28:39 +0200 Subject: [PATCH 036/165] Touch-related fixes for charts on statistics page --- frontend/src/lib/components/BarChart.svelte | 52 +++++++++++++------ .../components/CalendarCellRenderer.svelte | 12 ++++- .../src/lib/components/CalendarHeatmap.svelte | 4 +- 3 files changed, 49 insertions(+), 19 deletions(-) diff --git a/frontend/src/lib/components/BarChart.svelte b/frontend/src/lib/components/BarChart.svelte index b93862f..509d825 100644 --- a/frontend/src/lib/components/BarChart.svelte +++ b/frontend/src/lib/components/BarChart.svelte @@ -84,6 +84,17 @@ { key: 'default', value: 'value' as const, color: resolveColor(color), label }, ]); + const touchStyles = 'touch-action: none; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none'; + const enhancedTransform = $derived( + transform ? { ...transform, style: touchStyles } : undefined + ); + + let chartKey = $state(0); + + function resetZoom() { + chartKey++; + } + @@ -93,21 +104,32 @@

    {emptyText}

    {:else} -
  • (open = false)}>{$_('user.profile')}
  • +
  • (open = false)}>{$_('user.about')}
{/if} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index bc8129e..6fa0929 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -176,6 +176,10 @@ return `${$_('app.title')} - ${$_('admin.title')}`; } + if ($page.url.pathname.startsWith('/about')) { + return `${$_('app.title')} - ${$_('user.about')}`; + } + if ($page.url.pathname.startsWith('/login')) { return `${$_('app.title')} - ${$_('auth.login')}`; } diff --git a/frontend/src/routes/about/+page.svelte b/frontend/src/routes/about/+page.svelte new file mode 100644 index 0000000..91bfa60 --- /dev/null +++ b/frontend/src/routes/about/+page.svelte @@ -0,0 +1,133 @@ + + +
+

{$_('about.title')}

+ +
+
+
+ LibrisLog +
+

LibrisLog

+

{displayVersion}

+
+
+

+ {$_('about.description')} +

+
+
+ +
+
+

{$_('about.author')}

+
+
+ RH +
+
+

Raffael Herrmann

+ github.com/codebude +
+
+
+
+ +
+
+

{$_('about.technologies')}

+ +
+

{$_('about.frontend')}

+
+ {#each frontendDeps as [name, ver]} + {name} + {/each} +
+
+ +
+

{$_('about.backend')}

+
+ {#each backendDeps as dep} + {dep.name} + {/each} +
+
+ +
+

Dev Tools

+
+ {#each devDeps as [name, ver]} + {name} + {/each} +
+
+
+
+ +
+
+

{$_('about.thankYou')}

+

+ {$_('about.thankYouText')} +

+
+
+
From 3cd986f2f09da870a642455d089af30a31cb61bd Mon Sep 17 00:00:00 2001 From: codebude Date: Fri, 22 May 2026 19:47:48 +0200 Subject: [PATCH 050/165] Implemented themeswitcher --- ...47b_add_theme_and_custom_theme_to_user_.py | 34 ++++++ backend/app/models.py | 4 +- backend/app/routers/profile.py | 9 +- backend/app/schemas.py | 20 +++- backend/tests/test_auth_profile_users.py | 14 +++ frontend/src/app.css | 4 +- frontend/src/lib/api.ts | 2 +- frontend/src/lib/components/UserMenu.svelte | 52 ++++++++- frontend/src/lib/i18n/locales/de.json | 8 ++ frontend/src/lib/i18n/locales/en.json | 8 ++ frontend/src/lib/stores/theme.test.ts | 69 +++++++++++ frontend/src/lib/stores/theme.ts | 109 ++++++++++++++++++ frontend/src/lib/types.ts | 2 + frontend/src/routes/+layout.svelte | 12 ++ frontend/src/routes/profile/+page.svelte | 59 ++++++++++ 15 files changed, 400 insertions(+), 6 deletions(-) create mode 100644 backend/alembic/versions/bfe919c8b47b_add_theme_and_custom_theme_to_user_.py create mode 100644 frontend/src/lib/stores/theme.test.ts create mode 100644 frontend/src/lib/stores/theme.ts diff --git a/backend/alembic/versions/bfe919c8b47b_add_theme_and_custom_theme_to_user_.py b/backend/alembic/versions/bfe919c8b47b_add_theme_and_custom_theme_to_user_.py new file mode 100644 index 0000000..874579e --- /dev/null +++ b/backend/alembic/versions/bfe919c8b47b_add_theme_and_custom_theme_to_user_.py @@ -0,0 +1,34 @@ +"""add theme and custom_theme to user_settings + +Revision ID: bfe919c8b47b +Revises: b5c3d9a2e1f4 +Create Date: 2026-05-22 15:22:58.100122 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'bfe919c8b47b' +down_revision: Union[str, Sequence[str], None] = 'b5c3d9a2e1f4' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('usersettings', sa.Column('theme', sa.String(length=20), nullable=False, server_default='light')) + op.add_column('usersettings', sa.Column('custom_theme', sa.String(length=30), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('usersettings', 'custom_theme') + op.drop_column('usersettings', 'theme') + # ### end Alembic commands ### diff --git a/backend/app/models.py b/backend/app/models.py index 49f95af..1809416 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -126,12 +126,14 @@ class User(SQLModel, table=True): class UserSettings(SQLModel, table=True): - """Per-user settings such as language and timezone.""" + """Per-user settings such as language, timezone, and theme.""" id: Optional[int] = Field(default=None, primary_key=True) user_id: int = Field(foreign_key="user.id", unique=True, index=True) language: str = Field(default="en", max_length=10) timezone: str = Field(default="UTC", max_length=64) + theme: str = Field(default="light", max_length=20) + custom_theme: Optional[str] = Field(default=None, max_length=30) class ApiKey(SQLModel, table=True): diff --git a/backend/app/routers/profile.py b/backend/app/routers/profile.py index 7fae1e6..e14c9f7 100644 --- a/backend/app/routers/profile.py +++ b/backend/app/routers/profile.py @@ -95,6 +95,8 @@ def get_settings( language=settings.language, timezone=settings.timezone, quote_service_enabled=app_settings.dashboard_quote_enabled, + theme=settings.theme, + custom_theme=settings.custom_theme, ) @@ -110,7 +112,10 @@ def update_settings( ).first() if not settings: settings = UserSettings(user_id=current_user.id, language="en") - settings.sqlmodel_update(body.model_dump(exclude_unset=True)) + update_data = body.model_dump(exclude_unset=True) + settings.sqlmodel_update(update_data) + if settings.theme != 'custom': + settings.custom_theme = None session.add(settings) session.commit() session.refresh(settings) @@ -119,6 +124,8 @@ def update_settings( language=settings.language, timezone=settings.timezone, quote_service_enabled=app_settings.dashboard_quote_enabled, + theme=settings.theme, + custom_theme=settings.custom_theme, ) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index d1423bc..901a842 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -4,7 +4,7 @@ from datetime import datetime from typing import Literal -from pydantic import ConfigDict +from pydantic import ConfigDict, field_validator from sqlmodel import Field, SQLModel from app.models import ReadingStatus, UserRole @@ -297,12 +297,30 @@ class UserSettingsRead(SQLModel): language: str timezone: str quote_service_enabled: bool + theme: str + custom_theme: Optional[str] = None class UserSettingsUpdate(SQLModel): """User settings update request.""" language: Optional[str] = None timezone: Optional[str] = None + theme: Optional[str] = None + custom_theme: Optional[str] = None + + @field_validator('theme') + @classmethod + def validate_theme(cls, v: Optional[str]) -> Optional[str]: + if v is not None and v not in ('light', 'dark', 'custom'): + raise ValueError('theme must be one of: light, dark, custom') + return v + + @field_validator('custom_theme') + @classmethod + def validate_custom_theme(cls, v: Optional[str]) -> Optional[str]: + if v is not None and v.strip() == '': + return None + return v class ConfirmationPhrase(SQLModel): diff --git a/backend/tests/test_auth_profile_users.py b/backend/tests/test_auth_profile_users.py index a9640f4..de6de8a 100644 --- a/backend/tests/test_auth_profile_users.py +++ b/backend/tests/test_auth_profile_users.py @@ -219,6 +219,8 @@ def test_profile_settings_get_and_update(client: TestClient) -> None: assert current.status_code == 200 assert current.json()["language"] == "en" assert current.json()["timezone"] == "UTC" + assert current.json()["theme"] == "light" + assert current.json()["custom_theme"] is None updated = client.patch("/api/profile/settings", json={"language": "de"}) assert updated.status_code == 200 @@ -229,6 +231,16 @@ def test_profile_settings_get_and_update(client: TestClient) -> None: assert tz_updated.status_code == 200 assert tz_updated.json()["timezone"] == "Europe/Berlin" + theme_updated = client.patch("/api/profile/settings", json={"theme": "dark"}) + assert theme_updated.status_code == 200 + assert theme_updated.json()["theme"] == "dark" + assert theme_updated.json()["custom_theme"] is None + + custom_updated = client.patch("/api/profile/settings", json={"theme": "custom", "custom_theme": "dracula"}) + assert custom_updated.status_code == 200 + assert custom_updated.json()["theme"] == "custom" + assert custom_updated.json()["custom_theme"] == "dracula" + def test_profile_api_key_lifecycle(client: TestClient) -> None: listed = client.get("/api/profile/api-keys") @@ -274,6 +286,8 @@ def test_users_create_creates_user_settings(client: TestClient, session: Session assert settings is not None assert settings.language == "en" assert settings.timezone == "UTC" + assert settings.theme == "light" + assert settings.custom_theme is None def test_users_create_rejects_duplicate_email(client: TestClient) -> None: resp = client.post( diff --git a/frontend/src/app.css b/frontend/src/app.css index b521be6..bd299de 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -1,5 +1,7 @@ @import "tailwindcss"; -@plugin "daisyui"; +@plugin "daisyui" { + themes: all; +} @import "layerchart/daisyui-5.css"; html { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 6a7423a..6f6fad7 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -136,7 +136,7 @@ export const api = { return request('/profile/settings'); }, - updateSettings(data: { language?: string; timezone?: string }): Promise { + updateSettings(data: { language?: string; timezone?: string; theme?: string; custom_theme?: string | null }): Promise { return request('/profile/settings', { method: 'PATCH', body: JSON.stringify(data) diff --git a/frontend/src/lib/components/UserMenu.svelte b/frontend/src/lib/components/UserMenu.svelte index 56063c3..dc3d56e 100644 --- a/frontend/src/lib/components/UserMenu.svelte +++ b/frontend/src/lib/components/UserMenu.svelte @@ -3,15 +3,37 @@ import { api } from '$lib/api'; import { broadcastLogout, currentUser, csrfToken } from '$lib/stores/auth'; import { _ } from '$lib/i18n'; + import { cycleTheme, applyThemeToDocument, saveThemeToStorage, getThemeMode, getThemeIcon, getCustomTheme, getThemeVersion } from '$lib/stores/theme'; let { floating = true }: { floating?: boolean } = $props(); let open = $state(false); + let themeIcon = $state(getThemeIcon()); + let themeMode = $state(getThemeMode()); + let themeVersion = $state(getThemeVersion()); const user = $derived($currentUser); const initials = $derived( user ? `${user.firstname.charAt(0)}${user.lastname.charAt(0)}`.toUpperCase() : '??' ); + const themeLabel = $derived.by(() => { + void themeVersion; + switch (themeMode) { + case 'light': return $_('settings.themeLight'); + case 'dark': return $_('settings.themeDark'); + case 'custom': return getCustomTheme() ? getCustomTheme()!.charAt(0).toUpperCase() + getCustomTheme()!.slice(1) : $_('settings.themeCustom'); + } + }); + + function onMenuToggle() { + if (!open) { + themeIcon = getThemeIcon(); + themeMode = getThemeMode(); + themeVersion = getThemeVersion(); + } + open = !open; + } + async function logout() { try { await api.auth.logout(); @@ -24,13 +46,33 @@ open = false; await goto('/login'); } + + async function toggleTheme() { + cycleTheme(); + themeIcon = getThemeIcon(); + themeMode = getThemeMode(); + themeVersion = getThemeVersion(); + applyThemeToDocument(); + saveThemeToStorage(); + if ($currentUser) { + try { + const mode = getThemeMode(); + await api.profile.updateSettings({ + theme: mode, + custom_theme: mode === 'custom' ? getCustomTheme() : null, + }); + } catch { + // silent fail — localStorage is primary + } + } + }
+ +
  • {/if} diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json index 1bf1396..28d9ebc 100644 --- a/frontend/src/lib/i18n/locales/de.json +++ b/frontend/src/lib/i18n/locales/de.json @@ -73,6 +73,8 @@ "search": "Suchen", "searchBooks": "Bücher suchen...", "save": "Speichern", + "saved": "Gespeichert", + "saveFailed": "Speichern fehlgeschlagen", "edit": "Bearbeiten", "cancel": "Abbrechen", "confirm": "Bestätigen?", @@ -205,6 +207,11 @@ "timezoneDetected": "Erkannt: {tz}", "timezoneSelected": "Ausgewählt: {tz}", "timezoneInvalid": "Bitte wählen Sie eine gültige Zeitzone aus der Liste.", + "themeTitle": "Design", + "themeLight": "Hell", + "themeDark": "Dunkel", + "themeCustom": "Anpassen", + "themeSelect": "Wähle ein benutzerdefiniertes Design", "timezonePlaceholder": "Zeitzone suchen...", "apiDocsTitle": "API-Dokumentation", "apiDocsHelp": "Erkunde und teste Backend-Endpunkte direkt in der App.", @@ -259,6 +266,7 @@ "menu": "Benutzermenü", "profile": "Profil", "about": "Über", + "theme": "Design", "logout": "Abmelden", "apiKeys": "API-Schlüssel", "keyDescription": "Beschreibung (optional)", diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 66f4f24..16b0f1f 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -73,6 +73,8 @@ "search": "Search", "searchBooks": "Search books...", "save": "Save", + "saved": "Saved", + "saveFailed": "Save failed", "edit": "Edit", "cancel": "Cancel", "confirm": "Confirm?", @@ -205,6 +207,11 @@ "timezoneDetected": "Detected: {tz}", "timezoneSelected": "Selected: {tz}", "timezoneInvalid": "Please select a valid timezone from the list.", + "themeTitle": "Theme", + "themeLight": "Light", + "themeDark": "Dark", + "themeCustom": "Customize", + "themeSelect": "Select a custom theme", "timezonePlaceholder": "Search timezone...", "apiDocsTitle": "API Documentation", "apiDocsHelp": "Explore and test backend endpoints directly from the app.", @@ -259,6 +266,7 @@ "menu": "User menu", "profile": "Profile", "about": "About", + "theme": "Theme", "logout": "Logout", "apiKeys": "API Keys", "keyDescription": "Description (optional)", diff --git a/frontend/src/lib/stores/theme.test.ts b/frontend/src/lib/stores/theme.test.ts new file mode 100644 index 0000000..f9294a2 --- /dev/null +++ b/frontend/src/lib/stores/theme.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + setThemeMode, getThemeMode, setCustomTheme, getCustomTheme, + getEffectiveTheme, cycleTheme, loadThemeFromStorage, saveThemeToStorage, + applyThemeToDocument, DAISYUI_THEMES +} from './theme'; + +describe('theme store', () => { + beforeEach(() => { + setThemeMode('light'); + setCustomTheme(null); + localStorage.clear(); + }); + + it('defaults to light mode', () => { + expect(getThemeMode()).toBe('light'); + expect(getEffectiveTheme()).toBe('light'); + }); + + it('cycles through modes', () => { + expect(cycleTheme()).toBe('dark'); + expect(cycleTheme()).toBe('custom'); + expect(cycleTheme()).toBe('light'); + }); + + it('uses custom theme when in custom mode', () => { + setThemeMode('custom'); + setCustomTheme('dracula'); + expect(getEffectiveTheme()).toBe('dracula'); + }); + + it('falls back to dracula when custom theme is not set', () => { + setThemeMode('custom'); + setCustomTheme(null); + expect(getEffectiveTheme()).toBe('dracula'); + }); + + it('rejects invalid custom themes', () => { + setCustomTheme('invalid'); + expect(getCustomTheme()).toBeNull(); + }); + + it('persists to and loads from localStorage', () => { + setThemeMode('dark'); + setCustomTheme('nord'); + saveThemeToStorage(); + + setThemeMode('light'); + setCustomTheme(null); + + loadThemeFromStorage(); + expect(getThemeMode()).toBe('dark'); + expect(getCustomTheme()).toBe('nord'); + }); + + it('applyThemeToDocument sets data-theme on html', () => { + setThemeMode('dark'); + applyThemeToDocument(); + expect(document.documentElement.dataset.theme).toBe('dark'); + }); + + it('contains all expected daisyui themes', () => { + expect(DAISYUI_THEMES).toContain('dracula'); + expect(DAISYUI_THEMES).toContain('nord'); + expect(DAISYUI_THEMES).toContain('sunset'); + expect(DAISYUI_THEMES).not.toContain('light'); + expect(DAISYUI_THEMES).not.toContain('dark'); + }); +}); diff --git a/frontend/src/lib/stores/theme.ts b/frontend/src/lib/stores/theme.ts new file mode 100644 index 0000000..b294fa5 --- /dev/null +++ b/frontend/src/lib/stores/theme.ts @@ -0,0 +1,109 @@ +export type ThemeMode = 'light' | 'dark' | 'custom'; + +const DAISYUI_THEMES = [ + 'cupcake', 'bumblebee', 'emerald', 'corporate', 'synthwave', 'retro', + 'cyberpunk', 'valentine', 'halloween', 'garden', 'forest', 'aqua', + 'lofi', 'pastel', 'fantasy', 'wireframe', 'black', 'luxury', 'dracula', + 'cmyk', 'autumn', 'business', 'acid', 'lemonade', 'night', 'coffee', + 'winter', 'dim', 'nord', 'sunset', 'caramellatte', 'abyss', 'silk' +] as const; + +export type DaisyUITheme = (typeof DAISYUI_THEMES)[number]; + +export const THEME_MODE_KEY = 'librislog_theme_mode'; +export const CUSTOM_THEME_KEY = 'librislog_custom_theme'; + +let _themeMode: ThemeMode = 'light'; +let _customTheme: DaisyUITheme | null = null; +let _version = 0; + +export function getThemeMode(): ThemeMode { + return _themeMode; +} + +export function setThemeMode(mode: ThemeMode) { + _themeMode = mode; +} + +export function getCustomTheme(): DaisyUITheme | null { + return _customTheme; +} + +export function getThemeVersion(): number { + return _version; +} + +export function setCustomTheme(theme: DaisyUITheme | string | null) { + if (theme && DAISYUI_THEMES.includes(theme as DaisyUITheme)) { + _customTheme = theme as DaisyUITheme; + } else { + _customTheme = null; + } + _version++; +} + +const VALID_MODES: ThemeMode[] = ['light', 'dark', 'custom']; + +export function sanitizeThemeMode(raw: string): ThemeMode { + return VALID_MODES.includes(raw as ThemeMode) ? (raw as ThemeMode) : 'light'; +} + +export function getEffectiveTheme(): string { + if (_themeMode === 'custom' && _customTheme) { + return _customTheme; + } + if (_themeMode === 'custom') { + return 'dracula'; + } + return _themeMode; +} + +export function cycleTheme(): ThemeMode { + const order: ThemeMode[] = ['light', 'dark', 'custom']; + const idx = order.indexOf(_themeMode); + _themeMode = order[(idx + 1) % order.length]; + return _themeMode; +} + +export function loadThemeFromStorage() { + try { + const storedMode = localStorage.getItem(THEME_MODE_KEY) as ThemeMode | null; + if (storedMode && ['light', 'dark', 'custom'].includes(storedMode)) { + _themeMode = storedMode; + } + const storedCustom = localStorage.getItem(CUSTOM_THEME_KEY); + if (storedCustom && DAISYUI_THEMES.includes(storedCustom as DaisyUITheme)) { + _customTheme = storedCustom as DaisyUITheme; + } + } catch { + // localStorage unavailable + } +} + +export function saveThemeToStorage() { + try { + localStorage.setItem(THEME_MODE_KEY, _themeMode); + if (_customTheme) { + localStorage.setItem(CUSTOM_THEME_KEY, _customTheme); + } else { + localStorage.removeItem(CUSTOM_THEME_KEY); + } + } catch { + // localStorage unavailable + } +} + +export function applyThemeToDocument() { + const effective = getEffectiveTheme(); + document.documentElement.dataset.theme = effective; +} + +export function getThemeIcon(): string { + switch (_themeMode) { + case 'light': return '☀️'; + case 'dark': return '🌙'; + case 'custom': return '🎨'; + } +} + +export { DAISYUI_THEMES }; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 28b58da..20fc4c7 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -183,6 +183,8 @@ export interface UserSettings { language: string; timezone: string; quote_service_enabled: boolean; + theme: string; + custom_theme: string | null; } export interface ApiKeyMeta { diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 6fa0929..5ebcc19 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -9,11 +9,17 @@ import { currentUser, csrfToken, loadAuthFromStorage, initAuthSync } from '$lib/stores/auth'; import { _, setupI18n } from '$lib/i18n'; import { setTimezone, setQuoteServiceEnabled } from '$lib/stores/timezone'; + import { loadThemeFromStorage, applyThemeToDocument, setThemeMode, setCustomTheme, saveThemeToStorage, sanitizeThemeMode, THEME_MODE_KEY } from '$lib/stores/theme'; import { version, gitSha } from '$lib/version'; import { toasts } from '$lib/toasts'; let { children } = $props(); + if (typeof window !== 'undefined') { + loadThemeFromStorage(); + applyThemeToDocument(); + } + let addBookOpen = $state(false); let i18nReady = $state(false); let authReady = $state(false); @@ -93,6 +99,12 @@ const settings = await api.profile.getSettings(); setTimezone(settings.timezone); setQuoteServiceEnabled(settings.quote_service_enabled); + if (!localStorage.getItem(THEME_MODE_KEY) && settings.theme) { + setThemeMode(sanitizeThemeMode(settings.theme)); + setCustomTheme(settings.custom_theme); + applyThemeToDocument(); + saveThemeToStorage(); + } } catch { csrfToken.set(null); window.location.href = '/login'; diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte index 3b7488d..f9f2bbb 100644 --- a/frontend/src/routes/profile/+page.svelte +++ b/frontend/src/routes/profile/+page.svelte @@ -6,6 +6,7 @@ import { _, SUPPORTED_LOCALES, setLocale } from '$lib/i18n'; import { getPasswordChecks, passwordChecksPassed, passwordPattern } from '$lib/password'; import { getTimezone, setTimezone, detectTimezone } from '$lib/stores/timezone'; + import { getThemeMode, setThemeMode, getCustomTheme, setCustomTheme, applyThemeToDocument, saveThemeToStorage, sanitizeThemeMode, DAISYUI_THEMES } from '$lib/stores/theme'; import { toasts } from '$lib/toasts'; import type { ApiKeyMeta, OidcConfig, OidcLinkStatus } from '$lib/types'; @@ -26,6 +27,9 @@ let oidcLink = $state({ linked: false, provider_name: null, oidc_email: null, oidc_name: null }); let oidcLoading = $state(false); let oidcMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null); + let themeMode = $state(getThemeMode()); + let customTheme = $state(getCustomTheme() ?? 'dracula'); + let themeMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null); let resetDataConfirmation = $state(''); let resetDataMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null); let deleteAccountConfirmation = $state(''); @@ -107,6 +111,12 @@ language = settings.language; timezone = settings.timezone; setTimezone(settings.timezone); + themeMode = sanitizeThemeMode(settings.theme); + customTheme = settings.custom_theme ?? 'dracula'; + setThemeMode(themeMode); + setCustomTheme(customTheme); + applyThemeToDocument(); + saveThemeToStorage(); keys = await api.profile.listApiKeys(); oidcConfig = await api.oidc.config(); if (oidcConfig.enabled) { @@ -166,6 +176,34 @@ setTimezone(timezone); } + $effect(() => { + if (typeof window !== 'undefined') { + setCustomTheme(customTheme); + if (getThemeMode() === 'custom') { + applyThemeToDocument(); + saveThemeToStorage(); + } + } + }); + + async function saveTheme() { + themeMessage = null; + setThemeMode('custom'); + setCustomTheme(customTheme); + themeMode = 'custom'; + applyThemeToDocument(); + saveThemeToStorage(); + try { + await api.profile.updateSettings({ + theme: 'custom', + custom_theme: customTheme, + }); + themeMessage = { type: 'success', text: $_('common.saved') }; + } catch (e: unknown) { + themeMessage = { type: 'error', text: e instanceof Error ? e.message : $_('common.saveFailed') }; + } + } + async function createKey() { const result = await api.profile.createApiKey({ description: description || null }); createdKey = result.key; @@ -379,6 +417,26 @@
    +
    +
    +

    {$_('settings.themeTitle')}

    + {#if themeMessage} +
    + {themeMessage.text} +
    + {/if} + + + +
    +
    +

    {$_('user.apiKeys')}

    @@ -530,6 +588,7 @@
  • {$_('user.profile')}
  • {$_('settings.languageTitle')}
  • {$_('settings.timezone')}
  • +
  • {$_('settings.themeTitle')}
  • {$_('user.apiKeys')}
  • {$_('profile.dataManagement.title')}
  • {#if oidcConfig.enabled} From c3662d7453fc1f72be7810c9414f5ca01553d3d6 Mon Sep 17 00:00:00 2001 From: codebude Date: Fri, 22 May 2026 20:08:23 +0200 Subject: [PATCH 051/165] Improve alert look and feel by add close and auto-close features --- frontend/src/lib/components/Alert.svelte | 30 ++++++++++ .../components/AutoSearchCoverModal.svelte | 5 +- .../src/lib/components/BarcodeScanner.svelte | 1 + frontend/src/lib/constants.ts | 1 + frontend/src/routes/admin/+page.svelte | 5 +- .../routes/auth/oidc/callback/+page.svelte | 5 +- .../auth/oidc/link-callback/+page.svelte | 5 +- frontend/src/routes/login/+page.svelte | 5 +- frontend/src/routes/profile/+page.svelte | 55 ++++++++++--------- frontend/src/routes/setup/+page.svelte | 5 +- 10 files changed, 85 insertions(+), 32 deletions(-) create mode 100644 frontend/src/lib/components/Alert.svelte create mode 100644 frontend/src/lib/constants.ts diff --git a/frontend/src/lib/components/Alert.svelte b/frontend/src/lib/components/Alert.svelte new file mode 100644 index 0000000..e77a0da --- /dev/null +++ b/frontend/src/lib/components/Alert.svelte @@ -0,0 +1,30 @@ + + +
    + {@render children?.()} + {#if onClose} + + {/if} +
    diff --git a/frontend/src/lib/components/AutoSearchCoverModal.svelte b/frontend/src/lib/components/AutoSearchCoverModal.svelte index e0a008f..2dd1c64 100644 --- a/frontend/src/lib/components/AutoSearchCoverModal.svelte +++ b/frontend/src/lib/components/AutoSearchCoverModal.svelte @@ -1,4 +1,5 @@ -
    +
    + {#if type === 'success' && onClose && duration} +
    + {/if} {@render children?.()} {#if onClose} {/if}
    + + diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json index 28d9ebc..fdc5136 100644 --- a/frontend/src/lib/i18n/locales/de.json +++ b/frontend/src/lib/i18n/locales/de.json @@ -342,6 +342,9 @@ "cannotDeleteOwnAccountHere": "Das eigene Konto kann hier nicht gelöscht werden. Verwenden Sie Profil > Gefahrenbereich.", "importMalformedEvent": "Während des Imports wurde ein fehlerhaftes Server-Ereignis empfangen.", "importUnsupportedContentType": "Nicht unterstützter Upload-Inhaltstyp. Bitte CSV- oder JSON-Dateien verwenden.", + "emailAlreadyRegistered": "Diese E-Mail-Adresse ist bereits registriert.", + "userNotFound": "Benutzer nicht gefunden.", + "cannotChangeOwnRole": "Sie können Ihre eigene Admin-Rolle nicht ändern.", "authorRequired": "Autor ist erforderlich.", "pageCountRequired": "Seitenanzahl ist erforderlich.", "importTempFileCreateFailed": "Temporäre Importdatei konnte nicht erstellt werden. Bitte erneut versuchen.", diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 16b0f1f..7ceb93a 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -342,6 +342,9 @@ "cannotDeleteOwnAccountHere": "You cannot delete your own account here. Use Profile > Danger Zone.", "importMalformedEvent": "Received malformed server event during import.", "importUnsupportedContentType": "Unsupported upload content type. Use CSV or JSON files.", + "emailAlreadyRegistered": "This email address is already registered.", + "userNotFound": "User not found.", + "cannotChangeOwnRole": "You cannot change your own admin role.", "authorRequired": "Author is required.", "pageCountRequired": "Page count is required.", "importTempFileCreateFailed": "Could not create a temporary import file. Please try again.", diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 8a87296..3d14d75 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -31,6 +31,26 @@ const isAdmin = $derived($currentUser?.role === 'admin'); + const BACKEND_ERROR_MAP: Record = { + 'Email already registered': 'error.emailAlreadyRegistered', + 'User not found': 'error.userNotFound', + 'Cannot change your own admin role': 'error.cannotChangeOwnRole', + }; + + function localizeAdminError(e: unknown, fallbackAction: string): string { + if (e instanceof Error) { + if (e.message.startsWith('error.')) { + return $_(e.message); + } + const mappedKey = BACKEND_ERROR_MAP[e.message]; + if (mappedKey) { + return $_(mappedKey); + } + return e.message; + } + return $_('common.actionFailed', { values: { action: fallbackAction } }); + } + async function loadUsers() { if (!isAdmin) return; users = await api.users.list(); @@ -55,7 +75,12 @@ adminError = $_('auth.passwordComplexityError'); return; } - await api.users.create({ firstname, lastname, email, password, role }); + try { + await api.users.create({ firstname, lastname, email, password, role }); + } catch (e: unknown) { + adminError = localizeAdminError(e, 'create'); + return; + } firstname = ''; lastname = ''; email = ''; @@ -99,13 +124,18 @@ return; } adminError = ''; - await api.users.update(editingUserId, { - firstname: editFirstname, - lastname: editLastname, - email: editEmail, - role: editRole, - password: editPassword.trim() ? editPassword : undefined - }); + try { + await api.users.update(editingUserId, { + firstname: editFirstname, + lastname: editLastname, + email: editEmail, + role: editRole, + password: editPassword.trim() ? editPassword : undefined + }); + } catch (e: unknown) { + adminError = localizeAdminError(e, 'update'); + return; + } editingUserId = null; editPassword = ''; showEditPassword = false; @@ -129,11 +159,7 @@ await loadUsers(); adminError = ''; } catch (e: unknown) { - if (e instanceof Error && e.message.startsWith('error.')) { - adminError = $_(e.message); - } else { - adminError = e instanceof Error ? e.message : $_('common.actionFailed', { values: { action: 'delete' } }); - } + adminError = localizeAdminError(e, 'delete'); } } diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte index 8f700f1..05382a4 100644 --- a/frontend/src/routes/profile/+page.svelte +++ b/frontend/src/routes/profile/+page.svelte @@ -449,7 +449,7 @@
    {#if createdKey} - (createdKey = null)}> + (createdKey = null)} duration={0}>
    {$_('user.newKeyShownOnce')}
    From 7e29aee633543b408772d3f4c4c75df21a35acb6 Mon Sep 17 00:00:00 2001 From: codebude Date: Fri, 22 May 2026 22:20:42 +0200 Subject: [PATCH 053/165] Consolidate localize error functions --- .../src/lib/components/BackupRestore.svelte | 15 ++------- frontend/src/lib/errors.ts | 31 +++++++++++++++++++ frontend/src/routes/admin/+page.svelte | 27 +++------------- frontend/src/routes/profile/+page.svelte | 15 ++------- 4 files changed, 41 insertions(+), 47 deletions(-) diff --git a/frontend/src/lib/components/BackupRestore.svelte b/frontend/src/lib/components/BackupRestore.svelte index fab4a0f..65df82e 100644 --- a/frontend/src/lib/components/BackupRestore.svelte +++ b/frontend/src/lib/components/BackupRestore.svelte @@ -2,6 +2,7 @@ import { _ } from '$lib/i18n'; import { api } from '$lib/api'; import { toasts } from '$lib/toasts'; + import { localizeError } from '$lib/errors'; let backupInProgress = $state(false); let restoreFile = $state(null); @@ -29,16 +30,6 @@ } } - function localizeError(err: unknown, fallback: string): string { - if (err instanceof Error) { - if (err.message.startsWith('error.')) { - return $_(err.message); - } - return err.message; - } - return fallback; - } - async function validateAndConfirmRestore() { if (!restoreFile) return; try { @@ -50,7 +41,7 @@ toasts.add(validation.error || $_('admin.restore.invalidBackup'), 'error'); } } catch (err: unknown) { - toasts.add(localizeError(err, $_('admin.restore.validationFailed')), 'error'); + toasts.add(localizeError(err, $_, $_('admin.restore.validationFailed')), 'error'); } } @@ -63,7 +54,7 @@ toasts.add($_('admin.restore.success', { values: { books: String(result.restored_books) } }), 'success'); setTimeout(() => window.location.reload(), 2000); } catch (err: unknown) { - toasts.add(localizeError(err, $_('admin.restore.failed')), 'error'); + toasts.add(localizeError(err, $_, $_('admin.restore.failed')), 'error'); } finally { restoreInProgress = false; restoreFile = null; diff --git a/frontend/src/lib/errors.ts b/frontend/src/lib/errors.ts index d1fc4ec..8d97cbb 100644 --- a/frontend/src/lib/errors.ts +++ b/frontend/src/lib/errors.ts @@ -1,3 +1,34 @@ +const BACKEND_ERROR_MAP: Record = { + 'Email already registered': 'error.emailAlreadyRegistered', + 'User not found': 'error.userNotFound', + 'Cannot change your own admin role': 'error.cannotChangeOwnRole', +}; + +export function localizeBackendError(err: unknown): string { + if (err instanceof Error) { + if (err.message.startsWith('error.')) { + return err.message; + } + const mappedKey = BACKEND_ERROR_MAP[err.message]; + if (mappedKey) { + return mappedKey; + } + return err.message; + } + return 'Unknown error'; +} + +export function localizeError(err: unknown, translate: (key: string) => string, fallback: string): string { + const localized = localizeBackendError(err); + if (localized.startsWith('error.')) { + return translate(localized); + } + if (localized !== 'Unknown error') { + return localized; + } + return fallback; +} + export function shouldShowActionToast(message: string): boolean { return message !== 'Missing API key' && message !== 'Not authenticated'; } diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 3d14d75..43e7207 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -5,6 +5,7 @@ import { currentUser } from '$lib/stores/auth'; import { getPasswordChecks, passwordChecksPassed, passwordPattern } from '$lib/password'; import { isValidEmailFormat } from '$lib/validation'; + import { localizeBackendError } from '$lib/errors'; import type { User, UserRole } from '$lib/types'; import { _ } from '$lib/i18n'; import BackupRestore from '$lib/components/BackupRestore.svelte'; @@ -31,26 +32,6 @@ const isAdmin = $derived($currentUser?.role === 'admin'); - const BACKEND_ERROR_MAP: Record = { - 'Email already registered': 'error.emailAlreadyRegistered', - 'User not found': 'error.userNotFound', - 'Cannot change your own admin role': 'error.cannotChangeOwnRole', - }; - - function localizeAdminError(e: unknown, fallbackAction: string): string { - if (e instanceof Error) { - if (e.message.startsWith('error.')) { - return $_(e.message); - } - const mappedKey = BACKEND_ERROR_MAP[e.message]; - if (mappedKey) { - return $_(mappedKey); - } - return e.message; - } - return $_('common.actionFailed', { values: { action: fallbackAction } }); - } - async function loadUsers() { if (!isAdmin) return; users = await api.users.list(); @@ -78,7 +59,7 @@ try { await api.users.create({ firstname, lastname, email, password, role }); } catch (e: unknown) { - adminError = localizeAdminError(e, 'create'); + adminError = $_(localizeBackendError(e)); return; } firstname = ''; @@ -133,7 +114,7 @@ password: editPassword.trim() ? editPassword : undefined }); } catch (e: unknown) { - adminError = localizeAdminError(e, 'update'); + adminError = $_(localizeBackendError(e)); return; } editingUserId = null; @@ -159,7 +140,7 @@ await loadUsers(); adminError = ''; } catch (e: unknown) { - adminError = localizeAdminError(e, 'delete'); + adminError = $_(localizeBackendError(e)); } } diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte index 05382a4..3919abd 100644 --- a/frontend/src/routes/profile/+page.svelte +++ b/frontend/src/routes/profile/+page.svelte @@ -9,6 +9,7 @@ import { getThemeMode, setThemeMode, getCustomTheme, setCustomTheme, applyThemeToDocument, saveThemeToStorage, sanitizeThemeMode, DAISYUI_THEMES } from '$lib/stores/theme'; import Alert from '$lib/components/Alert.svelte'; import { toasts } from '$lib/toasts'; + import { localizeError } from '$lib/errors'; import type { ApiKeyMeta, OidcConfig, OidcLinkStatus } from '$lib/types'; let firstname = $state(''); @@ -259,16 +260,6 @@ } } - function localizeError(err: unknown, fallback: string): string { - if (err instanceof Error) { - if (err.message.startsWith('error.')) { - return $_(err.message); - } - return err.message; - } - return fallback; - } - async function confirmResetData() { if (resetDataConfirmation.trim() !== $_('profile.dangerZone.resetData.confirmationPhrase')) { return; @@ -299,7 +290,7 @@ window.location.href = '/dashboard'; }, 1500); } catch (e: unknown) { - const message = localizeError(e, $_('profile.dangerZone.resetData.failed')); + const message = localizeError(e, $_, $_('profile.dangerZone.resetData.failed')); resetDataMessage = { type: 'error', text: message }; toasts.add(message, 'error'); } finally { @@ -321,7 +312,7 @@ window.location.href = '/login'; }, 1000); } catch (e: unknown) { - const message = localizeError(e, $_('profile.dangerZone.deleteAccount.failed')); + const message = localizeError(e, $_, $_('profile.dangerZone.deleteAccount.failed')); deleteAccountMessage = { type: 'error', text: message }; toasts.add(message, 'error'); } finally { From 1b23b1c39a4c290f36f2a41fdd2cbf211fa7cf6c Mon Sep 17 00:00:00 2001 From: codebude Date: Fri, 22 May 2026 22:22:38 +0200 Subject: [PATCH 054/165] Fix semtantic error in profile page --- frontend/src/routes/profile/+page.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte index 3919abd..294665b 100644 --- a/frontend/src/routes/profile/+page.svelte +++ b/frontend/src/routes/profile/+page.svelte @@ -417,9 +417,9 @@ {themeMessage.text} {/if} - + +
    api.books.suggestions.authors(q)} /> api.books.suggestions.publishers(q)} />
    - api.books.suggestions.tags(q)} /> + api.books.suggestions.tags(q)} /> diff --git a/frontend/src/lib/components/BackupRestore.svelte b/frontend/src/lib/components/BackupRestore.svelte index 65df82e..7ef23f2 100644 --- a/frontend/src/lib/components/BackupRestore.svelte +++ b/frontend/src/lib/components/BackupRestore.svelte @@ -101,6 +101,7 @@ {:else} { diff --git a/frontend/src/lib/components/BookDetailDialog.svelte b/frontend/src/lib/components/BookDetailDialog.svelte index 471da73..3ccbebf 100644 --- a/frontend/src/lib/components/BookDetailDialog.svelte +++ b/frontend/src/lib/components/BookDetailDialog.svelte @@ -304,6 +304,7 @@ { e.preventDefault(); save(); }}> api.books.suggestions.authors(q)} @@ -367,7 +368,7 @@
    {#if createdKey} @@ -516,6 +520,7 @@

    {$_('profile.dangerZone.resetData.warning')}

    @@ -544,6 +549,7 @@

    {$_('profile.dangerZone.deleteAccount.warning')}

    From be2400b5888c726d2c16251299a9ba9063ccf6a0 Mon Sep 17 00:00:00 2001 From: codebude Date: Sat, 23 May 2026 13:54:22 +0200 Subject: [PATCH 058/165] Refactored chart rendering to use chart.js instead of layerchart for better mobile/touch support --- .gitignore | 4 +- frontend/package-lock.json | 725 ++---------------- frontend/package.json | 9 +- frontend/src/app.css | 1 - frontend/src/lib/chartjs/register.ts | 29 + frontend/src/lib/chartjs/theme.ts | 138 ++++ frontend/src/lib/components/BarChart.svelte | 252 +++--- .../lib/components/BookDetailDialog.svelte | 107 ++- .../lib/components/BookDetailDialog.test.ts | 4 + .../components/CalendarCellRenderer.svelte | 63 -- .../components/CalendarCellRenderer.test.ts | 80 -- .../src/lib/components/CalendarHeatmap.svelte | 231 ++++-- frontend/src/lib/components/UserMenu.svelte | 7 + frontend/src/lib/i18n/locales/de.json | 4 +- frontend/src/lib/i18n/locales/en.json | 4 +- frontend/src/lib/stores/theme.ts | 2 + frontend/src/lib/test/setup.ts | 1 + frontend/src/routes/+layout.svelte | 1 + frontend/src/routes/statistics/+page.svelte | 55 +- 19 files changed, 696 insertions(+), 1021 deletions(-) create mode 100644 frontend/src/lib/chartjs/register.ts create mode 100644 frontend/src/lib/chartjs/theme.ts delete mode 100644 frontend/src/lib/components/CalendarCellRenderer.svelte delete mode 100644 frontend/src/lib/components/CalendarCellRenderer.test.ts diff --git a/.gitignore b/.gitignore index 927829d..6905c3d 100644 --- a/.gitignore +++ b/.gitignore @@ -226,4 +226,6 @@ __marimo__/ !frontend/src/lib cookies.txt profile-snapshot -.plan/ \ No newline at end of file +.plan/ +node_modules/ +/*.png \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0f77064..be408df 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,10 +9,14 @@ "version": "0.0.1", "dependencies": { "@tailwindcss/vite": "^4.3.0", + "chart.js": "^4.5.1", + "chartjs-chart-matrix": "^3.0.4", + "chartjs-plugin-zoom": "^2.2.0", "daisyui": "^5.5.19", "dayjs": "^1.11.20", + "hammerjs": "^2.0.8", "html5-qrcode": "^2.3.8", - "layerchart": "^2.0.0-next.64", + "svelte-chartjs": "^4.0.1", "svelte-i18n": "^4.0.1", "tailwindcss": "^4.3.0" }, @@ -23,8 +27,7 @@ "@sveltejs/vite-plugin-svelte": "^7.0.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/svelte": "^5.3.1", - "@types/d3-scale": "^4.0.9", - "@types/d3-shape": "^3.1.8", + "@types/hammerjs": "^2.0.46", "@types/node": "^25.7.0", "@vitest/coverage-v8": "^4.1.7", "happy-dom": "^17.6.3", @@ -127,21 +130,6 @@ "node": ">=18" } }, - "node_modules/@dagrejs/dagre": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-2.0.4.tgz", - "integrity": "sha512-J6vCWTNpicHF4zFlZG1cS5DkGzMr9941gddYkakjrg3ZNev4bbqEgLHFTWiFrcJm7UCRu7olO3K6IRDd9gSGhA==", - "license": "MIT", - "dependencies": { - "@dagrejs/graphlib": "3.0.4" - } - }, - "node_modules/@dagrejs/graphlib": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-3.0.4.tgz", - "integrity": "sha512-HxZ7fCvAwTLCWCO0WjDkzAFQze8LdC6iOpKbetDKHIuDfIgMlIzYzqZ4nxwLlclQX+3ZVeZ1K2OuaOE2WWcyOg==", - "license": "MIT" - }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -173,31 +161,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@floating-ui/core": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", - "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", - "license": "MIT", - "dependencies": { - "@floating-ui/utils": "^0.2.11" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", - "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", - "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.7.5", - "@floating-ui/utils": "^0.2.11" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", - "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", - "license": "MIT" - }, "node_modules/@formatjs/ecma402-abstract": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz", @@ -294,48 +257,11 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@layerstack/svelte-actions": { - "version": "1.0.1-next.18", - "resolved": "https://registry.npmjs.org/@layerstack/svelte-actions/-/svelte-actions-1.0.1-next.18.tgz", - "integrity": "sha512-gxPzCnJ1c9LTfWtRqLUzefCx+k59ZpxDUQ2XB+LokveZQPe7IDSOwHaBOEMlaGoGrtwc3Ft8dSZq+2WT2o9u/g==", - "license": "MIT", - "dependencies": { - "@floating-ui/dom": "^1.7.0", - "@layerstack/utils": "2.0.0-next.18", - "d3-scale": "^4.0.2" - } - }, - "node_modules/@layerstack/svelte-state": { - "version": "0.1.0-next.23", - "resolved": "https://registry.npmjs.org/@layerstack/svelte-state/-/svelte-state-0.1.0-next.23.tgz", - "integrity": "sha512-7O4umv+gXwFfs3/vjzFWYHNXGwYnnjBapWJ5Y+9u99F4eVk6rh4ocNwqkqQNkpMZ5tUJBlRTWjPE1So6+hEzIg==", - "license": "MIT", - "dependencies": { - "@layerstack/utils": "2.0.0-next.18" - } - }, - "node_modules/@layerstack/tailwind": { - "version": "2.0.0-next.21", - "resolved": "https://registry.npmjs.org/@layerstack/tailwind/-/tailwind-2.0.0-next.21.tgz", - "integrity": "sha512-Qgp2EpmEHmjtura8MQzWicR6ztBRSsRvddakFtx9ShrLMz6jWzd6bCMVVRu44Q3ZOrtXmSu4QxjCZWu1ytvuPg==", - "license": "MIT", - "dependencies": { - "@layerstack/utils": "^2.0.0-next.18", - "clsx": "^2.1.1", - "d3-array": "^3.2.4", - "tailwind-merge": "^3.2.0" - } - }, - "node_modules/@layerstack/utils": { - "version": "2.0.0-next.18", - "resolved": "https://registry.npmjs.org/@layerstack/utils/-/utils-2.0.0-next.18.tgz", - "integrity": "sha512-EYILHpfBRYMMEahajInu9C2AXQom5IcAEdtCeucD3QIl/fdDgRbtzn6/8QW9ewumfyNZetdUvitOksmI1+gZYQ==", - "license": "MIT", - "dependencies": { - "d3-array": "^3.2.4", - "d3-time": "^3.1.0", - "d3-time-format": "^4.1.0" - } + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", @@ -368,7 +294,7 @@ "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@rolldown/binding-android-arm64": { @@ -641,7 +567,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@sveltejs/acorn-typescript": { @@ -677,7 +603,7 @@ "version": "2.59.1", "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.59.1.tgz", "integrity": "sha512-d8OON70AphLdDesuTIl//M2O6fRTIicX8aYv8vhCiYEhTTI2OboKqey0Hu1A4VFhqwgqtq0vKDmPFGkw8kKmgw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -719,7 +645,7 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz", "integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "deepmerge": "^4.3.1", @@ -1133,56 +1059,6 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/d3-array": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", - "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", - "license": "MIT" - }, - "node_modules/@types/d3-contour": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", - "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", - "license": "MIT", - "dependencies": { - "@types/d3-array": "*", - "@types/geojson": "*" - } - }, - "node_modules/@types/d3-path": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", - "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/d3-scale": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", - "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/d3-time": "*" - } - }, - "node_modules/@types/d3-shape": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", - "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/d3-path": "*" - } - }, - "node_modules/@types/d3-time": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", - "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", "dev": true, "license": "MIT" }, @@ -1199,10 +1075,10 @@ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, - "node_modules/@types/geojson": { - "version": "7946.0.16", - "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", - "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "node_modules/@types/hammerjs": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", + "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==", "license": "MIT" }, "node_modules/@types/node": { @@ -1477,6 +1353,40 @@ "node": ">=18" } }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/chartjs-chart-matrix": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/chartjs-chart-matrix/-/chartjs-chart-matrix-3.0.4.tgz", + "integrity": "sha512-thkswkjZEtmZph+JUU65GjSxfAIKkLedVAhKz6umIs8zO+y+gHIuzovEtS1FqRXzubMXCX2RcglbQjHsL8g0Xw==", + "license": "MIT", + "peerDependencies": { + "chart.js": ">=3.0.0" + } + }, + "node_modules/chartjs-plugin-zoom": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chartjs-plugin-zoom/-/chartjs-plugin-zoom-2.2.0.tgz", + "integrity": "sha512-in6kcdiTlP6npIVLMd4zXZ08PDUXC52gZ4FAy5oyjk1zX3gKarXMAof7B9eFiisf9WOC3bh2saHg+J5WtLXZeA==", + "license": "MIT", + "dependencies": { + "@types/hammerjs": "^2.0.45", + "hammerjs": "^2.0.8" + }, + "peerDependencies": { + "chart.js": ">=3.2.0" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -1518,15 +1428,6 @@ "node": ">=6" } }, - "node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1538,7 +1439,7 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -1564,334 +1465,6 @@ "node": ">=0.12" } }, - "node_modules/d3-array": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "license": "ISC", - "dependencies": { - "internmap": "1 - 2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-chord": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", - "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", - "license": "ISC", - "dependencies": { - "d3-path": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-contour": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", - "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", - "license": "ISC", - "dependencies": { - "d3-array": "^3.2.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-delaunay": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", - "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", - "license": "ISC", - "dependencies": { - "delaunator": "5" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-dispatch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", - "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-dsv": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", - "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", - "license": "ISC", - "dependencies": { - "commander": "7", - "iconv-lite": "0.6", - "rw": "1" - }, - "bin": { - "csv2json": "bin/dsv2json.js", - "csv2tsv": "bin/dsv2dsv.js", - "dsv2dsv": "bin/dsv2dsv.js", - "dsv2json": "bin/dsv2json.js", - "json2csv": "bin/json2dsv.js", - "json2dsv": "bin/json2dsv.js", - "json2tsv": "bin/json2dsv.js", - "tsv2csv": "bin/dsv2dsv.js", - "tsv2json": "bin/dsv2json.js" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-force": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", - "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-quadtree": "1 - 3", - "d3-timer": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-format": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", - "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-geo": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", - "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", - "license": "ISC", - "dependencies": { - "d3-array": "2.5.0 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-geo-voronoi": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/d3-geo-voronoi/-/d3-geo-voronoi-2.1.0.tgz", - "integrity": "sha512-kqE4yYuOjPbKdBXG0xztCacPwkVSK2REF1opSNrnqqtXJmNcM++UbwQ8SxvwP6IQTj9RvIjjK4qeiVsEfj0Z2Q==", - "license": "ISC", - "dependencies": { - "d3-array": "3", - "d3-delaunay": "6", - "d3-geo": "3", - "d3-tricontour": "1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-hierarchy": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", - "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-interpolate-path": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/d3-interpolate-path/-/d3-interpolate-path-2.3.0.tgz", - "integrity": "sha512-tZYtGXxBmbgHsIc9Wms6LS5u4w6KbP8C09a4/ZYc4KLMYYqub57rRBUgpUr2CIarIrJEpdAWWxWQvofgaMpbKQ==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-quadtree": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", - "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-random": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", - "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-sankey": { - "version": "0.12.3", - "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", - "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "1 - 2", - "d3-shape": "^1.2.0" - } - }, - "node_modules/d3-sankey/node_modules/d3-array": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", - "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", - "license": "BSD-3-Clause", - "dependencies": { - "internmap": "^1.0.0" - } - }, - "node_modules/d3-sankey/node_modules/d3-path": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", - "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-sankey/node_modules/d3-shape": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", - "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-path": "1" - } - }, - "node_modules/d3-sankey/node_modules/internmap": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", - "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", - "license": "ISC" - }, - "node_modules/d3-scale": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", - "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "license": "ISC", - "dependencies": { - "d3-array": "2.10.0 - 3", - "d3-format": "1 - 3", - "d3-interpolate": "1.2.0 - 3", - "d3-time": "2.1.1 - 3", - "d3-time-format": "2 - 4" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-scale-chromatic": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", - "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3", - "d3-interpolate": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-shape": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", - "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "license": "ISC", - "dependencies": { - "d3-path": "^3.1.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-tile": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/d3-tile/-/d3-tile-1.0.0.tgz", - "integrity": "sha512-79fnTKpPMPDS5xQ0xuS9ir0165NEwwkFpe/DSOmc2Gl9ldYzKKRDWogmTTE8wAJ8NA7PMapNfEcyKhI9Lxdu5Q==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-time": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", - "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "license": "ISC", - "dependencies": { - "d3-array": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", - "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "license": "ISC", - "dependencies": { - "d3-time": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-tricontour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/d3-tricontour/-/d3-tricontour-1.1.0.tgz", - "integrity": "sha512-G7gHKj89n2owmkGb6WX6ixcnQ0Kf/0wpa9VIh9DGdbHu8wdrlaHU4ir3/bFNERl8N8nn4G7e7qbtBG8N9caihQ==", - "license": "ISC", - "dependencies": { - "d3-delaunay": "6", - "d3-scale": "4" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/daisyui": { "version": "5.5.19", "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.19.tgz", @@ -1922,19 +1495,11 @@ "node": ">=0.10.0" } }, - "node_modules/delaunator": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", - "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", - "license": "ISC", - "dependencies": { - "robust-predicates": "^3.0.2" - } - }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -2156,6 +1721,15 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/hammerjs": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz", + "integrity": "sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/happy-dom": { "version": "17.6.3", "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-17.6.3.tgz", @@ -2193,18 +1767,6 @@ "integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==", "license": "Apache-2.0" }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -2215,15 +1777,6 @@ "node": ">=8" } }, - "node_modules/internmap": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/intl-messageformat": { "version": "10.7.18", "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz", @@ -2310,52 +1863,12 @@ "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, - "node_modules/layerchart": { - "version": "2.0.0-next.64", - "resolved": "https://registry.npmjs.org/layerchart/-/layerchart-2.0.0-next.64.tgz", - "integrity": "sha512-vkD8CBPQOFslC8AGgHvTc1lXOJYuj+Fzimjle7Gxu/GUXQ83m68G+H3zhMTB61Q8aXEu4ytz4vVuwuOB+5JD+Q==", - "license": "MIT", - "dependencies": { - "@dagrejs/dagre": "^2.0.4", - "@layerstack/svelte-actions": "1.0.1-next.18", - "@layerstack/svelte-state": "0.1.0-next.23", - "@layerstack/tailwind": "2.0.0-next.21", - "@layerstack/utils": "2.0.0-next.18", - "@types/d3-contour": "^3.0.6", - "d3-array": "^3.2.4", - "d3-chord": "^3.0.1", - "d3-color": "^3.1.0", - "d3-contour": "^4.0.2", - "d3-delaunay": "^6.0.4", - "d3-dsv": "^3.0.1", - "d3-force": "^3.0.0", - "d3-geo": "^3.1.1", - "d3-geo-voronoi": "^2.1.0", - "d3-hierarchy": "^3.1.2", - "d3-interpolate": "^3.0.1", - "d3-interpolate-path": "^2.3.0", - "d3-path": "^3.1.0", - "d3-quadtree": "^3.0.1", - "d3-random": "^3.0.1", - "d3-sankey": "^0.12.3", - "d3-scale": "^4.0.2", - "d3-scale-chromatic": "^3.1.0", - "d3-shape": "^3.2.0", - "d3-tile": "^1.0.0", - "d3-time": "^3.1.0", - "memoize": "^10.2.0", - "runed": "^0.37.1" - }, - "peerDependencies": { - "svelte": "^5.0.0" - } - }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -2636,6 +2149,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, "license": "MIT", "bin": { "lz-string": "bin/bin.js" @@ -2678,21 +2192,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/memoize": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/memoize/-/memoize-10.2.0.tgz", - "integrity": "sha512-DeC6b7QBrZsRs3Y02A6A7lQyzFbsQbqgjI6UW0GigGWV+u1s25TycMr0XHZE4cJce7rY/vyw2ctMQqfDkIhUEA==", - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sindresorhus/memoize?sponsor=1" - } - }, "node_modules/memoizee": { "version": "0.4.17", "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.17.tgz", @@ -2712,18 +2211,6 @@ "node": ">=0.12" } }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -2747,7 +2234,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -2781,7 +2268,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "devOptional": true, + "dev": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" @@ -2891,12 +2378,6 @@ "node": ">=8" } }, - "node_modules/robust-predicates": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", - "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", - "license": "Unlicense" - }, "node_modules/rolldown": { "version": "1.0.0-rc.18", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.18.tgz", @@ -2930,40 +2411,6 @@ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18" } }, - "node_modules/runed": { - "version": "0.37.1", - "resolved": "https://registry.npmjs.org/runed/-/runed-0.37.1.tgz", - "integrity": "sha512-MeFY73xBW8IueWBm012nNFIGy19WUGPLtknavyUPMpnyt350M47PhGSGrGoSLbidwn+Zlt/O0cp8/OZE3LASWA==", - "funding": [ - "https://github.com/sponsors/huntabyte", - "https://github.com/sponsors/tglide" - ], - "license": "MIT", - "dependencies": { - "dequal": "^2.0.3", - "esm-env": "^1.0.0", - "lz-string": "^1.5.0" - }, - "peerDependencies": { - "@sveltejs/kit": "^2.21.0", - "svelte": "^5.7.0", - "zod": "^4.1.0" - }, - "peerDependenciesMeta": { - "@sveltejs/kit": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/rw": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", - "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", - "license": "BSD-3-Clause" - }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -2976,12 +2423,6 @@ "node": ">=6" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, "node_modules/semver": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", @@ -2999,7 +2440,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/siginfo": { @@ -3013,7 +2454,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@polka/url": "^1.0.0-next.24", @@ -3100,6 +2541,16 @@ "node": ">=18" } }, + "node_modules/svelte-chartjs": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/svelte-chartjs/-/svelte-chartjs-4.0.1.tgz", + "integrity": "sha512-4z+0J+w/6ADH2Cy+/AnVek2HxRrznQ7dJfWTybc9BHm9//DCb1BmLrSE3NGDRDLj+kwJbKw2o1tPLBE3CmdHmw==", + "license": "MIT", + "peerDependencies": { + "chart.js": "^3.5.0 || ^4.0.0", + "svelte": "^5.0.0" + } + }, "node_modules/svelte-check": { "version": "4.4.8", "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.8.tgz", @@ -3554,16 +3005,6 @@ "@esbuild/win32-x64": "0.19.12" } }, - "node_modules/tailwind-merge": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", - "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/dcastil" - } - }, "node_modules/tailwindcss": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", @@ -3653,7 +3094,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3675,7 +3116,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -3773,7 +3214,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", - "devOptional": true, + "dev": true, "license": "MIT", "workspaces": [ "tests/deps/*", diff --git a/frontend/package.json b/frontend/package.json index 4eb79e4..51323d8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,8 +21,7 @@ "@sveltejs/vite-plugin-svelte": "^7.0.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/svelte": "^5.3.1", - "@types/d3-scale": "^4.0.9", - "@types/d3-shape": "^3.1.8", + "@types/hammerjs": "^2.0.46", "@types/node": "^25.7.0", "@vitest/coverage-v8": "^4.1.7", "happy-dom": "^17.6.3", @@ -34,10 +33,14 @@ }, "dependencies": { "@tailwindcss/vite": "^4.3.0", + "chart.js": "^4.5.1", + "chartjs-chart-matrix": "^3.0.4", + "chartjs-plugin-zoom": "^2.2.0", "daisyui": "^5.5.19", "dayjs": "^1.11.20", + "hammerjs": "^2.0.8", "html5-qrcode": "^2.3.8", - "layerchart": "^2.0.0-next.64", + "svelte-chartjs": "^4.0.1", "svelte-i18n": "^4.0.1", "tailwindcss": "^4.3.0" } diff --git a/frontend/src/app.css b/frontend/src/app.css index bd299de..942e51e 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -2,7 +2,6 @@ @plugin "daisyui" { themes: all; } -@import "layerchart/daisyui-5.css"; html { scroll-behavior: smooth; diff --git a/frontend/src/lib/chartjs/register.ts b/frontend/src/lib/chartjs/register.ts new file mode 100644 index 0000000..eaf87a6 --- /dev/null +++ b/frontend/src/lib/chartjs/register.ts @@ -0,0 +1,29 @@ +import { + Chart as ChartJS, + Title, + Tooltip, + Legend, + BarElement, + LineElement, + PointElement, + CategoryScale, + LinearScale +} from 'chart.js'; +import zoomPlugin from 'chartjs-plugin-zoom'; +import { MatrixController, MatrixElement } from 'chartjs-chart-matrix'; + +ChartJS.register( + Title, + Tooltip, + Legend, + BarElement, + LineElement, + PointElement, + CategoryScale, + LinearScale, + zoomPlugin, + MatrixController, + MatrixElement +); + +export { ChartJS }; diff --git a/frontend/src/lib/chartjs/theme.ts b/frontend/src/lib/chartjs/theme.ts new file mode 100644 index 0000000..afb0780 --- /dev/null +++ b/frontend/src/lib/chartjs/theme.ts @@ -0,0 +1,138 @@ +const colorCache = new Map(); + +const VAR_MAP: Record = { + primary: '--color-primary', + secondary: '--color-secondary', + accent: '--color-accent', + info: '--color-info', + success: '--color-success', + warning: '--color-warning', + error: '--color-error', + 'base-100': '--color-base-100', + 'base-200': '--color-base-200', + 'base-300': '--color-base-300', + 'base-content': '--color-base-content', +}; + +export function getCssColor(varName: string, fallback = '#999'): string { + if (typeof window === 'undefined') return fallback; + const cached = colorCache.get(varName); + if (cached) return cached; + + const el = document.createElement('div'); + el.style.color = `var(${varName})`; + el.style.position = 'absolute'; + el.style.visibility = 'hidden'; + el.style.pointerEvents = 'none'; + document.body.appendChild(el); + try { + const computed = getComputedStyle(el).color; + const result = computed || fallback; + colorCache.set(varName, result); + return result; + } finally { + document.body.removeChild(el); + } +} + +export function invalidateColorCache(): void { + colorCache.clear(); +} + +export function resolveDaisyColor(name: string): string { + const varName = VAR_MAP[name]; + if (!varName) { + console.warn(`resolveDaisyColor: unknown color "${name}", falling back to primary`); + return 'var(--color-primary)'; + } + return `var(${varName})`; +} + +export function getDaisyColorRgb(name: string): string { + const varName = VAR_MAP[name]; + if (!varName) { + return getCssColor('--color-primary', 'rgb(0, 0, 0)'); + } + return getCssColor(varName, 'rgb(0, 0, 0)'); +} + +function oklchToRgb(l: number, c: number, h: number): [number, number, number] { + const hr = (h * Math.PI) / 180; + const a = c * Math.cos(hr); + const b = c * Math.sin(hr); + + const lm = l + 0.3963377774 * a + 0.2158037573 * b; + const mm = l - 0.1055613458 * a - 0.0638541728 * b; + const sm = l - 0.0894841775 * a - 1.291485548 * b; + + const l3 = lm * lm * lm; + const m3 = mm * mm * mm; + const s3 = sm * sm * sm; + + let r = 4.0767416621 * l3 - 3.3077115391 * m3 + 0.2309699203 * s3; + let g = -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3; + let bl = -0.0041960863 * l3 - 0.7034186147 * m3 + 1.707614701 * s3; + + const toSrgb = (v: number): number => { + v = Math.max(0, Math.min(1, v)); + return v <= 0.0031308 ? 12.92 * v : 1.055 * Math.pow(v, 1 / 2.4) - 0.055; + }; + + return [ + Math.round(toSrgb(r) * 255), + Math.round(toSrgb(g) * 255), + Math.round(toSrgb(bl) * 255), + ]; +} + +function parseColor(c: string): [number, number, number] { + if (c.startsWith('#')) { + const hex = c.replace('#', ''); + if (hex.length === 3) { + return [ + parseInt(hex[0] + hex[0], 16), + parseInt(hex[1] + hex[1], 16), + parseInt(hex[2] + hex[2], 16), + ]; + } + return [ + parseInt(hex.slice(0, 2), 16), + parseInt(hex.slice(2, 4), 16), + parseInt(hex.slice(4, 6), 16), + ]; + } + + const oklch = c.match(/oklch\(\s*([\d.]+)%?\s+([\d.]+)\s+([\d.]+)/); + if (oklch) { + const l = parseFloat(oklch[1]); + const c = parseFloat(oklch[2]); + const h = parseFloat(oklch[3]); + return oklchToRgb(l, c, h); + } + + const rgb = c.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); + if (rgb) return [+rgb[1], +rgb[2], +rgb[3]]; + + return [0, 0, 0]; +} + +export function mixDaisyColors( + fromVar: string, + toVar: string, + t: number +): string { + if (typeof window === 'undefined') return 'transparent'; + t = Math.max(0, Math.min(1, t)); + + const from = getCssColor(fromVar, '#e5e7eb'); + const to = getCssColor(toVar, '#3b82f6'); + + const [r1, g1, b1] = parseColor(from); + const [r2, g2, b2] = parseColor(to); + + const r = Math.round(r1 + (r2 - r1) * t); + const g = Math.round(g1 + (g2 - g1) * t); + const b = Math.round(b1 + (b2 - b1) * t); + + return `rgb(${r}, ${g}, ${b})`; +} diff --git a/frontend/src/lib/components/BarChart.svelte b/frontend/src/lib/components/BarChart.svelte index 9cbb0b6..212b637 100644 --- a/frontend/src/lib/components/BarChart.svelte +++ b/frontend/src/lib/components/BarChart.svelte @@ -1,150 +1,116 @@ - - {#if data.length === 0} -
    -

    {emptyText}

    -
    +
    +

    {emptyText}

    +
    {:else} - + {/if} diff --git a/frontend/src/lib/components/BookDetailDialog.svelte b/frontend/src/lib/components/BookDetailDialog.svelte index 3ccbebf..fc480b9 100644 --- a/frontend/src/lib/components/BookDetailDialog.svelte +++ b/frontend/src/lib/components/BookDetailDialog.svelte @@ -8,8 +8,10 @@ import { toasts } from '$lib/toasts'; import { formatLanguageCode } from '$lib/utils/language'; import StarRating from './StarRating.svelte'; - import { LineChart as LayerLineChart } from 'layerchart'; - import { curveCatmullRom } from 'd3-shape'; + import { Line } from 'svelte-chartjs'; + import '$lib/chartjs/register'; + import { getDaisyColorRgb } from '$lib/chartjs/theme'; + import type { ChartData, ChartOptions } from 'chart.js'; const tz = getTimezone(); @@ -167,11 +169,11 @@ }); const lineChartData = $derived.by(() => { - if (uniqueDays.length < 1) return []; + if (uniqueDays.length < 1) return { labels: [] as string[], data: [] as number[] }; const oldestEntry = uniqueDays[0]; const useStartDate = !!book?.date_started && formatDate(book.date_started, tz) < formatDate(oldestEntry.created_at, tz); const rawStart = useStartDate ? book.date_started : (book?.date_added ?? null); - if (!rawStart) return []; + if (!rawStart) return { labels: [] as string[], data: [] as number[] }; const virtualEntry: ReadingProgressEntry = { id: 0, book_id: book?.id ?? 0, @@ -180,10 +182,74 @@ updated_at: rawStart }; const entries = [virtualEntry, ...uniqueDays]; - return entries.map((e) => ({ - date: formatDate(e.created_at, tz), - page: e.page - })); + return { + labels: entries.map((e) => formatDate(e.created_at, tz)), + data: entries.map((e) => e.page), + }; + }); + + const lineChartConfig = $derived>({ + labels: lineChartData.labels, + datasets: [ + { + label: $_('book.currentPage'), + data: lineChartData.data, + borderColor: getDaisyColorRgb('primary'), + backgroundColor: getDaisyColorRgb('primary'), + tension: 0.4, + pointRadius: 4, + pointHoverRadius: 6, + fill: false, + }, + ], + }); + + const lineChartOptions = $derived>({ + responsive: true, + maintainAspectRatio: false, + animation: { duration: 0 }, + plugins: { + legend: { display: false }, + tooltip: { + mode: 'index' as const, + intersect: false, + }, + }, + scales: { + x: { + grid: { display: false }, + ticks: { + maxTicksLimit: 6, + color: getDaisyColorRgb('base-content'), + }, + }, + y: { + beginAtZero: true, + max: Math.max(...lineChartData.data, book?.page_count ?? 1), + grid: { + color: getDaisyColorRgb('base-200'), + }, + ticks: { + color: getDaisyColorRgb('base-content'), + }, + }, + }, + }); + + let lineChart = $state | null>(null); + + $effect(() => { + const _ = getDaisyColorRgb('base-content'); + const __ = getDaisyColorRgb('base-200'); + const ___ = getDaisyColorRgb('primary'); + if (lineChart && lineChart.options.scales && lineChart.data.datasets[0]) { + lineChart.data.datasets[0].borderColor = getDaisyColorRgb('primary'); + lineChart.data.datasets[0].backgroundColor = getDaisyColorRgb('primary'); + if (lineChart.options.scales.x?.ticks) lineChart.options.scales.x.ticks.color = getDaisyColorRgb('base-content'); + if (lineChart.options.scales.y?.ticks) lineChart.options.scales.y.ticks.color = getDaisyColorRgb('base-content'); + if (lineChart.options.scales.y?.grid) lineChart.options.scales.y.grid.color = getDaisyColorRgb('base-200'); + lineChart.update('none'); + } }); $effect(() => { @@ -334,23 +400,14 @@ {/if}
    - {#if lineChartData.length >= 2} -
    -
    {$_('book.progressGraph')}
    -
    - d.page), book?.page_count ?? 1)]} - props={{ xAxis: { tickSpacing: 80 }, spline: { curve: curveCatmullRom } }} - /> -
    -
    - {/if} + {#if lineChartData.data.length >= 2} +
    +
    {$_('book.progressGraph')}
    +
    + +
    +
    + {/if}
    {$_('book.notes')}
    diff --git a/frontend/src/lib/components/BookDetailDialog.test.ts b/frontend/src/lib/components/BookDetailDialog.test.ts index 52d5b4f..fb2c30c 100644 --- a/frontend/src/lib/components/BookDetailDialog.test.ts +++ b/frontend/src/lib/components/BookDetailDialog.test.ts @@ -3,6 +3,10 @@ import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/sv import { writable } from 'svelte/store'; import BookDetailDialog from './BookDetailDialog.svelte'; +vi.mock('svelte-chartjs', () => ({ + Line: vi.fn().mockImplementation(() => ({ default: {} })), +})); + const mockProgressList = vi.fn(async () => []); const mockProgressCreate = vi.fn(async (_bookId: number, _page: number) => ({ id: 1, book_id: _bookId, page: _page, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' })); const mockProgressDelete = vi.fn(async () => {}); diff --git a/frontend/src/lib/components/CalendarCellRenderer.svelte b/frontend/src/lib/components/CalendarCellRenderer.svelte deleted file mode 100644 index 2e154dd..0000000 --- a/frontend/src/lib/components/CalendarCellRenderer.svelte +++ /dev/null @@ -1,63 +0,0 @@ - - -{#each cells as cell} - { - (e.target as Element | null)?.releasePointerCapture?.(e.pointerId); - }} - onpointermove={(e) => { - pinnedCell = null; - if (cell.data?.pages !== undefined) ctx.tooltip.show(e, cell.data); - }} - onpointerleave={() => { - if (pinnedCell !== cell.data?.date) ctx.tooltip.hide(); - }} - onclick={(e) => { - if (cell.data?.pages !== undefined) { - pinnedCell = cell.data.date; - ctx.tooltip.show(e, cell.data); - } - }} - /> -{/each} diff --git a/frontend/src/lib/components/CalendarCellRenderer.test.ts b/frontend/src/lib/components/CalendarCellRenderer.test.ts deleted file mode 100644 index e0eadfe..0000000 --- a/frontend/src/lib/components/CalendarCellRenderer.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, fireEvent } from '@testing-library/svelte'; -import CalendarCellRenderer from './CalendarCellRenderer.svelte'; - -const mockTooltip = { show: vi.fn(), hide: vi.fn() }; - -vi.mock('layerchart', async () => { - const { default: MockRect } = await import('$lib/test/mocks/Rect.svelte'); - return { - Rect: MockRect, - getChartContext: vi.fn(() => ({ - tooltip: mockTooltip - })) - }; -}); - -describe('CalendarCellRenderer', () => { - const cells = [ - { x: 0, y: 0, data: { date: '2024-01-01', pages: 10 } }, - { x: 1, y: 0, data: { date: '2024-01-02', pages: 0 } }, - { x: 2, y: 0, data: { date: '2024-01-03' } } - ]; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('renders rects for each cell', () => { - render(CalendarCellRenderer, { - props: { cells, cellSize: [20, 20], maxPages: 20 } - }); - expect(document.querySelectorAll('[role="gridcell"]')).toHaveLength(3); - }); - - it('handles zero and undefined pages', () => { - render(CalendarCellRenderer, { - props: { cells, cellSize: [20, 20], maxPages: 20 } - }); - const rects = document.querySelectorAll('[role="gridcell"]'); - expect(rects).toHaveLength(3); - }); - - it('uses maxPages of 1', () => { - render(CalendarCellRenderer, { - props: { - cells: [{ x: 0, y: 0, data: { date: '2024-01-01', pages: 5 } }], - cellSize: [20, 20], - maxPages: 1 - } - }); - expect(document.querySelector('[role="gridcell"]')).toBeInTheDocument(); - }); - - it('shows tooltip on pointermove for cell with pages', async () => { - render(CalendarCellRenderer, { - props: { cells, cellSize: [20, 20], maxPages: 20 } - }); - const rects = document.querySelectorAll('[role="gridcell"]'); - await fireEvent.pointerMove(rects[0]); - expect(mockTooltip.show).toHaveBeenCalledOnce(); - }); - - it('does not show tooltip on pointermove for cell without pages', async () => { - render(CalendarCellRenderer, { - props: { cells, cellSize: [20, 20], maxPages: 20 } - }); - const rects = document.querySelectorAll('[role="gridcell"]'); - await fireEvent.pointerMove(rects[2]); // cell with no pages - expect(mockTooltip.show).not.toHaveBeenCalled(); - }); - - it('hides tooltip on pointerleave', async () => { - render(CalendarCellRenderer, { - props: { cells, cellSize: [20, 20], maxPages: 20 } - }); - const rects = document.querySelectorAll('[role="gridcell"]'); - await fireEvent.pointerLeave(rects[0]); - expect(mockTooltip.hide).toHaveBeenCalledOnce(); - }); -}); diff --git a/frontend/src/lib/components/CalendarHeatmap.svelte b/frontend/src/lib/components/CalendarHeatmap.svelte index 122ba63..6a68dd4 100644 --- a/frontend/src/lib/components/CalendarHeatmap.svelte +++ b/frontend/src/lib/components/CalendarHeatmap.svelte @@ -1,7 +1,10 @@ {#if data.length === 0} @@ -35,43 +192,7 @@

    {emptyText}

    {:else} -
    - { - const [yr, mo, dy] = d.date.split('-').map(Number); - return new Date(yr, mo - 1, dy); - }} - c={(d: DailyPages) => d.pages} - cRange={['var(--color-base-200)', 'var(--color-primary)']} - tooltipContext={true} - height={140} - padding={{ top: 24, right: 0, bottom: 0, left: 40 }} - > - - - {#snippet children({ cells, cellSize })} - - {/snippet} - - - - - {#snippet children({ data })} - {#if data?.pages !== undefined} - - - - - {/if} - {/snippet} - - + {/if} diff --git a/frontend/src/lib/components/UserMenu.svelte b/frontend/src/lib/components/UserMenu.svelte index dc3d56e..e53a779 100644 --- a/frontend/src/lib/components/UserMenu.svelte +++ b/frontend/src/lib/components/UserMenu.svelte @@ -99,3 +99,10 @@ {/if}
    + + diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json index 3745a9c..39bd2b2 100644 --- a/frontend/src/lib/i18n/locales/de.json +++ b/frontend/src/lib/i18n/locales/de.json @@ -43,8 +43,10 @@ "pagesOver": "Seiten an", "daysLabel": "Tagen", "avgPerDay": "Ø:", + "pagesPerDay": "Seiten/Tag", "loading": "Lade Statistiken...", - "noData": "Noch keine Daten verfügbar. Fange an, Bücher zu lesen und zu erfassen, um Statistiken zu sehen!" + "noData": "Noch keine Daten verfügbar. Fange an, Bücher zu lesen und zu erfassen, um Statistiken zu sehen!", + "resetZoom": "Zoom zurücksetzen" }, "dashboard": { "title": "Lese-Dashboard", diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 27180c7..187f9e0 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -43,8 +43,10 @@ "pagesOver": "pages over", "daysLabel": "days", "avgPerDay": "Avg:", + "pagesPerDay": "pages/day", "loading": "Loading statistics...", - "noData": "No data available yet. Start reading and tracking books to see statistics!" + "noData": "No data available yet. Start reading and tracking books to see statistics!", + "resetZoom": "Reset zoom" }, "dashboard": { "title": "Reading Dashboard", diff --git a/frontend/src/lib/stores/theme.ts b/frontend/src/lib/stores/theme.ts index b294fa5..8a19ce3 100644 --- a/frontend/src/lib/stores/theme.ts +++ b/frontend/src/lib/stores/theme.ts @@ -1,4 +1,5 @@ export type ThemeMode = 'light' | 'dark' | 'custom'; +import { invalidateColorCache } from '$lib/chartjs/theme'; const DAISYUI_THEMES = [ 'cupcake', 'bumblebee', 'emerald', 'corporate', 'synthwave', 'retro', @@ -96,6 +97,7 @@ export function saveThemeToStorage() { export function applyThemeToDocument() { const effective = getEffectiveTheme(); document.documentElement.dataset.theme = effective; + invalidateColorCache(); } export function getThemeIcon(): string { diff --git a/frontend/src/lib/test/setup.ts b/frontend/src/lib/test/setup.ts index 4b08cd7..e70c63b 100644 --- a/frontend/src/lib/test/setup.ts +++ b/frontend/src/lib/test/setup.ts @@ -1,5 +1,6 @@ import { vi } from 'vitest'; import '@testing-library/jest-dom/vitest'; +import '$lib/chartjs/register'; // --- Polyfill crypto.randomUUID for happy-dom --- if (typeof crypto !== 'undefined' && !crypto.randomUUID) { diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 5ebcc19..629474c 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,5 +1,6 @@ diff --git a/frontend/src/lib/components/BookDetailDialog.svelte b/frontend/src/lib/components/BookDetailDialog.svelte index fc480b9..a5e1ec6 100644 --- a/frontend/src/lib/components/BookDetailDialog.svelte +++ b/frontend/src/lib/components/BookDetailDialog.svelte @@ -38,6 +38,10 @@ let deletingEntry = $state(null); let pendingDeleteEntry = $state(null); + const progressPercent = $derived( + book?.page_count && currentPage > 0 ? Math.round((currentPage / book.page_count) * 100) : 0 + ); + const STATUS_LABEL_KEYS: Record = { want_to_read: 'status.want_to_read', currently_reading: 'status.currently_reading', @@ -355,7 +359,7 @@
    -
    +
    {$_('book.readingProgress')}
    {#if !book.page_count} @@ -366,8 +370,24 @@ {$_('common.loadingEllipsis')}
    {:else} -
    - +
    + {progressPercent}% + {currentPage} / {book.page_count} {$_('book.pages')} +
    + + + +
    +
    - / - {book.page_count} - + / {book.page_count} +
    - - {/if}
    @@ -411,7 +419,7 @@
    {$_('book.notes')}
    -
    +
    {book.notes ?? '-'}
    @@ -424,7 +432,7 @@ {@const displayBlurb = blurbExpanded || !isTruncated ? book.blurb : book.blurb.slice(0, MAX_BLURB_LENGTH) + '...'} -
    +
    {displayBlurb} {#if isTruncated} diff --git a/frontend/src/lib/components/UserMenu.svelte b/frontend/src/lib/components/UserMenu.svelte index e53a779..9cd847c 100644 --- a/frontend/src/lib/components/UserMenu.svelte +++ b/frontend/src/lib/components/UserMenu.svelte @@ -83,26 +83,19 @@ {#if open} {/if}
    - - diff --git a/frontend/src/lib/stores/theme.test.ts b/frontend/src/lib/stores/theme.test.ts index f9294a2..9b61696 100644 --- a/frontend/src/lib/stores/theme.test.ts +++ b/frontend/src/lib/stores/theme.test.ts @@ -63,6 +63,7 @@ describe('theme store', () => { expect(DAISYUI_THEMES).toContain('dracula'); expect(DAISYUI_THEMES).toContain('nord'); expect(DAISYUI_THEMES).toContain('sunset'); + expect(DAISYUI_THEMES).toContain('librislog'); expect(DAISYUI_THEMES).not.toContain('light'); expect(DAISYUI_THEMES).not.toContain('dark'); }); diff --git a/frontend/src/lib/stores/theme.ts b/frontend/src/lib/stores/theme.ts index 8a19ce3..7c7c22e 100644 --- a/frontend/src/lib/stores/theme.ts +++ b/frontend/src/lib/stores/theme.ts @@ -6,7 +6,8 @@ const DAISYUI_THEMES = [ 'cyberpunk', 'valentine', 'halloween', 'garden', 'forest', 'aqua', 'lofi', 'pastel', 'fantasy', 'wireframe', 'black', 'luxury', 'dracula', 'cmyk', 'autumn', 'business', 'acid', 'lemonade', 'night', 'coffee', - 'winter', 'dim', 'nord', 'sunset', 'caramellatte', 'abyss', 'silk' + 'winter', 'dim', 'nord', 'sunset', 'caramellatte', 'abyss', 'silk', + 'librislog' ] as const; export type DaisyUITheme = (typeof DAISYUI_THEMES)[number]; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 6c04b45..998b304 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -212,6 +212,9 @@ {pageTitle()} + + + {#if !i18nReady || !authReady} @@ -236,7 +239,7 @@ {#each NAV_ITEMS as item} {item.icon}{$_(item.labelKey)} @@ -276,7 +279,7 @@
    -
    +
    {@render children()}
    diff --git a/frontend/src/routes/dashboard/+page.svelte b/frontend/src/routes/dashboard/+page.svelte index 8239205..3a8c67f 100644 --- a/frontend/src/routes/dashboard/+page.svelte +++ b/frontend/src/routes/dashboard/+page.svelte @@ -120,10 +120,6 @@ } } - function tagCloudSize(count: number): number { - return Math.min(1.2, 0.8 + count * 0.08); - } - function applyTagCloudSearch(tag: string) { window.scrollTo({ top: 0, behavior: 'smooth' }); if (searchQuery === tag) { @@ -291,9 +287,9 @@
    -
    +
    -

    {$_('dashboard.title')}

    +

    {$_('dashboard.title')}

    {$_('dashboard.subtitle')}

    @@ -381,7 +377,7 @@
    {#if quoteEnabled} -
    +

    {$_('dashboard.quoteTitle')}

    {#if quoteLoading} @@ -389,10 +385,10 @@ {:else if quote}

    "{quote.quote}"

    {#if quote.author} -

    - {quote.author}

    +

    - {quote.author}

    {/if} {:else} -

    {$_('dashboard.quoteUnavailable')}

    +

    {$_('dashboard.quoteUnavailable')}

    {/if}
    @@ -429,7 +425,7 @@ {:else if currentlyReading.length === 0}

    {$_('dashboard.noCurrentlyReading')}

    {:else} -
    +
    {#each currentlyReading as book (book.id)} {/each} @@ -450,7 +446,7 @@ {:else if nextToRead.length === 0}

    {$_('dashboard.noNextToRead')}

    {:else} -
    +
    {#each nextToRead as book (book.id)} {/each} @@ -460,21 +456,20 @@
    {#if tagCloud.length > 0} -
    +
    -

    {$_('dashboard.popularTags')}

    +

    {$_('dashboard.popularTags')}

    {#each tagCloud as entry (entry.tag)} {/each}
    diff --git a/frontend/src/routes/library/+page.svelte b/frontend/src/routes/library/+page.svelte index 85594b8..ab4c5d2 100644 --- a/frontend/src/routes/library/+page.svelte +++ b/frontend/src/routes/library/+page.svelte @@ -326,7 +326,7 @@ {/each}
    -
    +

    {$_(STATUS_LABEL_KEYS[activeStatus])}

    {#if syncing} @@ -432,8 +432,8 @@ {/if} {:else}
    {#each books as book (book.id)} diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte index b338a6b..1a3f33f 100644 --- a/frontend/src/routes/profile/+page.svelte +++ b/frontend/src/routes/profile/+page.svelte @@ -333,7 +333,7 @@

    {$_('user.profile')}

    -
    +
    { e.preventDefault(); saveProfile(); }}>

    {$_('user.profile')}

    {#if profileMessage} @@ -375,7 +375,7 @@
    -
    +

    {$_('settings.languageTitle')}

    { value = ''; onSearch?.(''); }} aria-label={$_('common.search')} - >✕ + > {/if} diff --git a/frontend/src/lib/components/Toaster.svelte b/frontend/src/lib/components/Toaster.svelte index 3171792..f2a607e 100644 --- a/frontend/src/lib/components/Toaster.svelte +++ b/frontend/src/lib/components/Toaster.svelte @@ -1,6 +1,7 @@ - -
    +
    {#if label} {label} {/if} diff --git a/frontend/src/lib/components/TagInput.svelte b/frontend/src/lib/components/TagInput.svelte index 7965358..6fb67d3 100644 --- a/frontend/src/lib/components/TagInput.svelte +++ b/frontend/src/lib/components/TagInput.svelte @@ -189,7 +189,7 @@ }); -
    +
    {$_('book.tags')}
    From a81f64f945b2c05ca4d668fefa9e90370598d2a0 Mon Sep 17 00:00:00 2001 From: codebude Date: Sun, 24 May 2026 21:32:03 +0200 Subject: [PATCH 072/165] Replace barcode svg with lucide icon --- frontend/src/lib/components/AddBookModal.svelte | 2 +- frontend/src/lib/components/BookDrawer.svelte | 13 ++----------- frontend/src/lib/components/ImportSearch.svelte | 12 ++---------- 3 files changed, 5 insertions(+), 22 deletions(-) diff --git a/frontend/src/lib/components/AddBookModal.svelte b/frontend/src/lib/components/AddBookModal.svelte index 2bae7ce..b3fddfe 100644 --- a/frontend/src/lib/components/AddBookModal.svelte +++ b/frontend/src/lib/components/AddBookModal.svelte @@ -8,7 +8,7 @@ import CoverPicker from './CoverPicker.svelte'; import TagInput from './TagInput.svelte'; import SuggestionInput from './SuggestionInput.svelte'; - import { X } from '@lucide/svelte'; + import { ScanBarcode, X } from '@lucide/svelte'; let { open = $bindable(false), diff --git a/frontend/src/lib/components/BookDrawer.svelte b/frontend/src/lib/components/BookDrawer.svelte index bbeb82e..b65fe66 100644 --- a/frontend/src/lib/components/BookDrawer.svelte +++ b/frontend/src/lib/components/BookDrawer.svelte @@ -13,7 +13,7 @@ import DateConflictDialog from './DateConflictDialog.svelte'; import AutoSearchCoverModal from './AutoSearchCoverModal.svelte'; import BarcodeScanner from './BarcodeScanner.svelte'; - import { X } from '@lucide/svelte'; + import { ScanBarcode, X } from '@lucide/svelte'; let { book = $bindable(null), @@ -377,16 +377,7 @@ title={$_('import.scanIsbn')} aria-label={$_('import.scanIsbn')} > - +
    diff --git a/frontend/src/lib/components/ImportSearch.svelte b/frontend/src/lib/components/ImportSearch.svelte index 868431e..7267b97 100644 --- a/frontend/src/lib/components/ImportSearch.svelte +++ b/frontend/src/lib/components/ImportSearch.svelte @@ -4,6 +4,7 @@ import { api } from '$lib/api'; import { _ } from '$lib/i18n'; import { toasts } from '$lib/toasts'; + import { ScanBarcode } from '@lucide/svelte'; let { onImport, @@ -262,16 +263,7 @@ title={$_('import.scanIsbn')} aria-label={$_('import.scanIsbn')} > - + {$_('import.scan')} {/if} From a3d7e74f0910b9b4e8f5575c1e824deea6373870 Mon Sep 17 00:00:00 2001 From: codebude Date: Sun, 24 May 2026 21:32:26 +0200 Subject: [PATCH 073/165] Added barcode scanner to manual book import page --- frontend/src/lib/components/AddBookModal.svelte | 13 ++++++++++++- frontend/src/lib/components/AddBookModal.test.ts | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/AddBookModal.svelte b/frontend/src/lib/components/AddBookModal.svelte index b3fddfe..f655487 100644 --- a/frontend/src/lib/components/AddBookModal.svelte +++ b/frontend/src/lib/components/AddBookModal.svelte @@ -148,7 +148,18 @@ /> { expect(screen.getByLabelText(/Title/)).toBeInTheDocument(); expect(screen.getByLabelText(/Subtitle/)).toBeInTheDocument(); expect(screen.getByText(/Author/)).toBeInTheDocument(); - expect(screen.getByLabelText(/ISBN/)).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: 'ISBN' })).toBeInTheDocument(); expect(screen.getByText(/Publisher/)).toBeInTheDocument(); expect(screen.getByLabelText(/Year/)).toBeInTheDocument(); expect(screen.getByLabelText(/Pages/)).toBeInTheDocument(); From 38de62d96a1629581d07ea857b07c74a5e2b9d7c Mon Sep 17 00:00:00 2001 From: codebude Date: Sun, 24 May 2026 22:00:38 +0200 Subject: [PATCH 074/165] Fixed camera access on mobile devices --- .../src/lib/components/BarcodeScanner.svelte | 29 ++++++++++--------- .../src/lib/components/ImportSearch.svelte | 1 + 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/frontend/src/lib/components/BarcodeScanner.svelte b/frontend/src/lib/components/BarcodeScanner.svelte index 601a403..831b2aa 100644 --- a/frontend/src/lib/components/BarcodeScanner.svelte +++ b/frontend/src/lib/components/BarcodeScanner.svelte @@ -98,6 +98,7 @@ } }); } catch { + if (!navigator.mediaDevices) throw new Error($_('scanner.noCamera')); const devices = await navigator.mediaDevices.enumerateDevices(); const cameras = devices.filter((d) => d.kind === 'videoinput'); if (!cameras.length) throw new Error($_('scanner.noCamera')); @@ -115,16 +116,7 @@ decoder = new Html5QrcodeShim(SUPPORTED_FORMATS, true, false, new BaseLoggger(false)); scanCanvas = document.createElement('canvas'); - await new Promise((resolve) => { - const check = () => { - if (videoEl) { - resolve(); - } else { - requestAnimationFrame(check); - } - }; - requestAnimationFrame(check); - }); + await waitForVideoEl(); videoEl!.srcObject = mediaStream; await videoEl!.play(); @@ -139,13 +131,24 @@ } } + async function waitForVideoEl(): Promise { + const timeout = Date.now() + 5000; + while (!videoEl && Date.now() < timeout) { + await new Promise((r) => setTimeout(r, 50)); + } + if (!videoEl) throw new Error($_('scanner.startError')); + } + $effect(() => { - if (open && !stream && !starting) { + if (open && !stream && !starting && !scannerError) { void startScanner(); return; } - if (!open && stream) { - void stopScanner(); + if (!open) { + scannerError = null; + if (stream) { + void stopScanner(); + } } }); diff --git a/frontend/src/lib/components/ImportSearch.svelte b/frontend/src/lib/components/ImportSearch.svelte index 7267b97..25103e7 100644 --- a/frontend/src/lib/components/ImportSearch.svelte +++ b/frontend/src/lib/components/ImportSearch.svelte @@ -36,6 +36,7 @@ onMount(async () => { cameraSupported = typeof navigator !== 'undefined' && + window.isSecureContext && !!navigator.mediaDevices && typeof navigator.mediaDevices.getUserMedia === 'function'; await refreshImportedLookups(); From a7a7941d7144c9da42bee9507dd63a59cae33cb5 Mon Sep 17 00:00:00 2001 From: codebude Date: Sun, 24 May 2026 22:17:28 +0200 Subject: [PATCH 075/165] Refine dashboard layout --- frontend/src/routes/dashboard/+page.svelte | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/routes/dashboard/+page.svelte b/frontend/src/routes/dashboard/+page.svelte index 58d3f84..02b3015 100644 --- a/frontend/src/routes/dashboard/+page.svelte +++ b/frontend/src/routes/dashboard/+page.svelte @@ -381,7 +381,7 @@ import { X } from '@lucide/svelte';
    {#if quoteEnabled} -
    +

    {$_('dashboard.quoteTitle')}

    {#if quoteLoading} @@ -389,16 +389,16 @@ import { X } from '@lucide/svelte'; {:else if quote}

    "{quote.quote}"

    {#if quote.author} -

    - {quote.author}

    +

    - {quote.author}

    {/if} {:else} -

    {$_('dashboard.quoteUnavailable')}

    +

    {$_('dashboard.quoteUnavailable')}

    {/if}
    {/if} -
    +
    {$_('dashboard.totalBooks')}
    {stats.total_books}
    From 5a1ac3f34940195712dd8394a36081b5b15201fa Mon Sep 17 00:00:00 2001 From: codebude Date: Sun, 24 May 2026 22:18:19 +0200 Subject: [PATCH 076/165] Refine book progress chart layout --- frontend/src/lib/components/BookDetailDialog.svelte | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/lib/components/BookDetailDialog.svelte b/frontend/src/lib/components/BookDetailDialog.svelte index 62ab25d..e2f3ac3 100644 --- a/frontend/src/lib/components/BookDetailDialog.svelte +++ b/frontend/src/lib/components/BookDetailDialog.svelte @@ -230,7 +230,6 @@ }, y: { beginAtZero: true, - max: Math.max(...lineChartData.data, book?.page_count ?? 1), grid: { color: getDaisyColorRgb('base-200'), }, From 7d9928eb269dd3616784b8d88d2a2ea2674ec8b3 Mon Sep 17 00:00:00 2001 From: codebude Date: Sun, 24 May 2026 22:38:56 +0200 Subject: [PATCH 077/165] Refine book progress chart layout - pt.2 --- frontend/src/lib/components/BookDetailDialog.svelte | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/lib/components/BookDetailDialog.svelte b/frontend/src/lib/components/BookDetailDialog.svelte index e2f3ac3..bd23b2e 100644 --- a/frontend/src/lib/components/BookDetailDialog.svelte +++ b/frontend/src/lib/components/BookDetailDialog.svelte @@ -230,6 +230,8 @@ }, y: { beginAtZero: true, + suggestedMax: book?.page_count ?? 1, + grace: '5%', grid: { color: getDaisyColorRgb('base-200'), }, From d1775ce687282163ae2d19eb9d89871cbc61c09b Mon Sep 17 00:00:00 2001 From: codebude Date: Sun, 24 May 2026 23:06:48 +0200 Subject: [PATCH 078/165] Bind progress log viewer deletion task to progress slider --- frontend/src/lib/components/BookDetailDialog.svelte | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/BookDetailDialog.svelte b/frontend/src/lib/components/BookDetailDialog.svelte index bd23b2e..93f1abb 100644 --- a/frontend/src/lib/components/BookDetailDialog.svelte +++ b/frontend/src/lib/components/BookDetailDialog.svelte @@ -115,9 +115,11 @@ try { await api.books.progress.delete(book.id, entryId); progressEntries = progressEntries.filter((e) => e.id !== entryId); - if (progressEntries.length > 0 && latestDbPage === entryId) { + if (progressEntries.length > 0) { + currentPage = progressEntries[0].page; latestDbPage = progressEntries[0].page; - } else if (progressEntries.length === 0) { + } else { + currentPage = 0; latestDbPage = 0; } } catch (e: unknown) { From ada31240572e8383005c58345520b43948038ba3 Mon Sep 17 00:00:00 2001 From: codebude Date: Mon, 25 May 2026 00:11:11 +0200 Subject: [PATCH 079/165] Fix theme switcher and dynamic chart.js theme changing --- frontend/src/lib/components/BarChart.svelte | 124 ++++++------- .../lib/components/BookDetailDialog.svelte | 113 ++++++------ .../src/lib/components/CalendarHeatmap.svelte | 167 +++++++++--------- frontend/src/lib/stores/theme.ts | 24 +++ frontend/src/routes/login/+page.svelte | 16 ++ frontend/src/routes/profile/+page.svelte | 13 +- 6 files changed, 260 insertions(+), 197 deletions(-) diff --git a/frontend/src/lib/components/BarChart.svelte b/frontend/src/lib/components/BarChart.svelte index 212b637..7605fe4 100644 --- a/frontend/src/lib/components/BarChart.svelte +++ b/frontend/src/lib/components/BarChart.svelte @@ -2,6 +2,8 @@ import '$lib/chartjs/register'; import { Bar } from 'svelte-chartjs'; import { getDaisyColorRgb } from '$lib/chartjs/theme'; + import { themeApplyCount } from '$lib/stores/theme'; + import { onMount } from 'svelte'; import type { Chart as ChartJS, ChartData, ChartOptions } from 'chart.js'; let { @@ -23,6 +25,7 @@ } = $props(); let chart = $state | null>(null); + let _themeSignal = $state(0); $effect(() => { if (chart) { @@ -30,77 +33,76 @@ } }); - const chartData = $derived>({ - labels, - datasets: [ - { - label, - data, - backgroundColor: getDaisyColorRgb(color), - borderColor: 'transparent', - borderWidth: 0, - borderRadius: 4, - barPercentage: 0.7, - }, - ], + onMount(() => { + return themeApplyCount.subscribe((n: number) => { + _themeSignal = n; + }); }); - const options = $derived>({ - responsive: true, - maintainAspectRatio: false, - animation: { duration: 0 }, - plugins: { - legend: { display: false }, - tooltip: { - enabled: true, - mode: 'index' as const, - intersect: false, - }, - zoom: { - pan: { + const chartData = $derived.by>(() => { + void _themeSignal; + return { + labels, + datasets: [ + { + label, + data, + backgroundColor: getDaisyColorRgb(color), + borderColor: 'transparent', + borderWidth: 0, + borderRadius: 4, + barPercentage: 0.7, + }, + ], + }; + }); + + const options = $derived.by>(() => { + void _themeSignal; + return { + responsive: true, + maintainAspectRatio: false, + animation: { duration: 0 }, + plugins: { + legend: { display: false }, + tooltip: { enabled: true, - mode: 'x' as const, + mode: 'index' as const, + intersect: false, }, zoom: { - wheel: { enabled: true }, - pinch: { enabled: true }, - mode: 'x' as const, + pan: { + enabled: true, + mode: 'x' as const, + }, + zoom: { + wheel: { enabled: true }, + pinch: { enabled: true }, + mode: 'x' as const, + }, }, }, - }, - scales: { - x: { - grid: { display: false }, - ticks: { - maxRotation: 45, - minRotation: 45, - autoSkip: true, - color: getDaisyColorRgb('base-content'), + scales: { + x: { + grid: { display: false }, + ticks: { + maxRotation: 45, + minRotation: 45, + autoSkip: true, + color: getDaisyColorRgb('base-content'), + }, }, - }, - y: { - beginAtZero: true, - grid: { - color: getDaisyColorRgb('base-200'), - }, - ticks: { - color: getDaisyColorRgb('base-content'), + y: { + beginAtZero: true, + grid: { + color: getDaisyColorRgb('base-200'), + }, + ticks: { + color: getDaisyColorRgb('base-content'), + }, }, }, - }, - }); - - $effect(() => { - const _ = getDaisyColorRgb('base-content'); - const __ = getDaisyColorRgb('base-200'); - const ___ = getDaisyColorRgb(color); - if (chart && chart.options.scales && chart.data.datasets[0]) { - chart.data.datasets[0].backgroundColor = getDaisyColorRgb(color); - if (chart.options.scales.x?.ticks) chart.options.scales.x.ticks.color = getDaisyColorRgb('base-content'); - if (chart.options.scales.y?.ticks) chart.options.scales.y.ticks.color = getDaisyColorRgb('base-content'); - if (chart.options.scales.y?.grid) chart.options.scales.y.grid.color = getDaisyColorRgb('base-200'); - chart.update('none'); - } + }; }); diff --git a/frontend/src/lib/components/BookDetailDialog.svelte b/frontend/src/lib/components/BookDetailDialog.svelte index 93f1abb..4c42cbe 100644 --- a/frontend/src/lib/components/BookDetailDialog.svelte +++ b/frontend/src/lib/components/BookDetailDialog.svelte @@ -12,6 +12,8 @@ import { X } from '@lucide/svelte'; import '$lib/chartjs/register'; import { getDaisyColorRgb } from '$lib/chartjs/theme'; + import { themeApplyCount } from '$lib/stores/theme'; + import { onMount } from 'svelte'; import type { ChartData, ChartOptions } from 'chart.js'; const tz = getTimezone(); @@ -195,69 +197,68 @@ }; }); - const lineChartConfig = $derived>({ - labels: lineChartData.labels, - datasets: [ - { - label: $_('book.currentPage'), - data: lineChartData.data, - borderColor: getDaisyColorRgb('primary'), - backgroundColor: getDaisyColorRgb('primary'), - tension: 0.4, - pointRadius: 4, - pointHoverRadius: 6, - fill: false, - }, - ], + let lineChart = $state | null>(null); + let _themeSignal = $state(0); + + onMount(() => { + return themeApplyCount.subscribe((n: number) => { + _themeSignal = n; + }); }); - const lineChartOptions = $derived>({ - responsive: true, - maintainAspectRatio: false, - animation: { duration: 0 }, - plugins: { - legend: { display: false }, - tooltip: { - mode: 'index' as const, - intersect: false, - }, - }, - scales: { - x: { - grid: { display: false }, - ticks: { - maxTicksLimit: 6, - color: getDaisyColorRgb('base-content'), + const lineChartConfig = $derived.by>(() => { + void _themeSignal; + return { + labels: lineChartData.labels, + datasets: [ + { + label: $_('book.currentPage'), + data: lineChartData.data, + borderColor: getDaisyColorRgb('primary'), + backgroundColor: getDaisyColorRgb('primary'), + tension: 0.4, + pointRadius: 4, + pointHoverRadius: 6, + fill: false, + }, + ], + }; + }); + + const lineChartOptions = $derived.by>(() => { + void _themeSignal; + return { + responsive: true, + maintainAspectRatio: false, + animation: { duration: 0 }, + plugins: { + legend: { display: false }, + tooltip: { + mode: 'index' as const, + intersect: false, }, }, - y: { - beginAtZero: true, - suggestedMax: book?.page_count ?? 1, - grace: '5%', - grid: { - color: getDaisyColorRgb('base-200'), + scales: { + x: { + grid: { display: false }, + ticks: { + maxTicksLimit: 6, + color: getDaisyColorRgb('base-content'), + }, }, - ticks: { - color: getDaisyColorRgb('base-content'), + y: { + beginAtZero: true, + suggestedMax: book?.page_count ?? 1, + grace: '5%', + grid: { + color: getDaisyColorRgb('base-200'), + }, + ticks: { + color: getDaisyColorRgb('base-content'), + }, }, }, - }, - }); - - let lineChart = $state | null>(null); - - $effect(() => { - const _ = getDaisyColorRgb('base-content'); - const __ = getDaisyColorRgb('base-200'); - const ___ = getDaisyColorRgb('primary'); - if (lineChart && lineChart.options.scales && lineChart.data.datasets[0]) { - lineChart.data.datasets[0].borderColor = getDaisyColorRgb('primary'); - lineChart.data.datasets[0].backgroundColor = getDaisyColorRgb('primary'); - if (lineChart.options.scales.x?.ticks) lineChart.options.scales.x.ticks.color = getDaisyColorRgb('base-content'); - if (lineChart.options.scales.y?.ticks) lineChart.options.scales.y.ticks.color = getDaisyColorRgb('base-content'); - if (lineChart.options.scales.y?.grid) lineChart.options.scales.y.grid.color = getDaisyColorRgb('base-200'); - lineChart.update('none'); - } + }; }); $effect(() => { diff --git a/frontend/src/lib/components/CalendarHeatmap.svelte b/frontend/src/lib/components/CalendarHeatmap.svelte index 6a68dd4..ba47737 100644 --- a/frontend/src/lib/components/CalendarHeatmap.svelte +++ b/frontend/src/lib/components/CalendarHeatmap.svelte @@ -3,6 +3,8 @@ import { Chart } from 'svelte-chartjs'; import { mixDaisyColors, getDaisyColorRgb } from '$lib/chartjs/theme'; import { locale } from '$lib/i18n'; + import { themeApplyCount } from '$lib/stores/theme'; + import { onMount } from 'svelte'; import type { DailyPages } from '$lib/types'; import type { Chart as ChartJS, ChartData, ChartOptions, ScriptableContext, TooltipItem } from 'chart.js'; @@ -28,6 +30,13 @@ } let chart = $state | null>(null); + let _themeSignal = $state(0); + + onMount(() => { + return themeApplyCount.subscribe((n: number) => { + _themeSignal = n; + }); + }); const matrixData = $derived.by(() => { const pagesByDate = new Map(); @@ -66,31 +75,34 @@ return { points: result, maxPages, monthLabels }; }); - const chartData = $derived>({ - datasets: [ - { - label: 'Pages', - data: matrixData.points, - backgroundColor: (ctx: ScriptableContext<'matrix'>) => { - const raw = ctx.raw as { v: number } | undefined; - const v = raw?.v ?? 0; - if (v <= 0) return mixDaisyColors('--color-base-200', '--color-primary', 0); - return mixDaisyColors('--color-base-200', '--color-primary', Math.min(v / matrixData.maxPages, 1)); - }, - borderColor: 'transparent', - borderWidth: 1, - width: ({ chart }: { chart: { chartArea?: { width: number } } }) => { - const area = chart.chartArea; - if (!area) return 10; - return Math.max((area.width / 54) - 2, 2); - }, - height: ({ chart }: { chart: { chartArea?: { height: number } } }) => { - const area = chart.chartArea; - if (!area) return 10; - return Math.max((area.height / 8) - 2, 2); + const chartData = $derived.by>(() => { + void _themeSignal; + return { + datasets: [ + { + label: 'Pages', + data: matrixData.points, + backgroundColor: (ctx: ScriptableContext<'matrix'>) => { + const raw = ctx.raw as { v: number } | undefined; + const v = raw?.v ?? 0; + if (v <= 0) return mixDaisyColors('--color-base-200', '--color-primary', 0); + return mixDaisyColors('--color-base-200', '--color-primary', Math.min(v / matrixData.maxPages, 1)); + }, + borderColor: 'transparent', + borderWidth: 1, + width: ({ chart }: { chart: { chartArea?: { width: number } } }) => { + const area = chart.chartArea; + if (!area) return 10; + return Math.max((area.width / 54) - 2, 2); + }, + height: ({ chart }: { chart: { chartArea?: { height: number } } }) => { + const area = chart.chartArea; + if (!area) return 10; + return Math.max((area.height / 8) - 2, 2); + }, }, - }, - ], + ], + }; }); const monthLabelPlugin = { @@ -124,67 +136,64 @@ } }; - const options = $derived>({ - responsive: true, - maintainAspectRatio: false, - animation: { duration: 0 }, - layout: { - padding: { - top: 28, - } - }, - plugins: { - legend: { display: false }, - // @ts-expect-error custom plugin - monthLabels: {}, - tooltip: { - callbacks: { - title: (items: TooltipItem<'matrix'>[]) => { - const raw = items[0]?.raw as { date: string } | undefined; - if (!raw) return ''; - const [yr, mo, dy] = raw.date.split('-').map(Number); - const appLocale = $locale ?? 'en'; - return new Date(yr, mo - 1, dy).toLocaleDateString(appLocale, { - weekday: 'short', - month: 'short', - day: 'numeric', - year: 'numeric', - }); - }, - label: (item: TooltipItem<'matrix'>) => { - const raw = item.raw as { v: number } | undefined; - return `Pages: ${raw?.v ?? 0}`; + const options = $derived.by>(() => { + void _themeSignal; + return { + responsive: true, + maintainAspectRatio: false, + animation: { duration: 0 }, + layout: { + padding: { + top: 28, + } + }, + plugins: { + legend: { display: false }, + monthLabels: {}, + tooltip: { + callbacks: { + title: (items: TooltipItem<'matrix'>[]) => { + const raw = items[0]?.raw as { date: string } | undefined; + if (!raw) return ''; + const [yr, mo, dy] = raw.date.split('-').map(Number); + const appLocale = $locale ?? 'en'; + return new Date(yr, mo - 1, dy).toLocaleDateString(appLocale, { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', + }); + }, + label: (item: TooltipItem<'matrix'>) => { + const raw = item.raw as { v: number } | undefined; + return `Pages: ${raw?.v ?? 0}`; + }, }, }, }, - }, - scales: { - x: { - type: 'linear', - offset: true, - grid: { display: false }, - ticks: { display: false }, - border: { display: false }, - }, - y: { - type: 'linear', - offset: true, - min: -0.5, - max: 6.5, - reverse: true, - grid: { display: false }, - ticks: { display: false }, - border: { display: false }, + scales: { + x: { + type: 'linear', + offset: true, + grid: { display: false }, + ticks: { display: false }, + border: { display: false }, + }, + y: { + type: 'linear', + offset: true, + min: -0.5, + max: 6.5, + reverse: true, + grid: { display: false }, + ticks: { display: false }, + border: { display: false }, + }, }, - }, + }; }); - $effect(() => { - const _ = getDaisyColorRgb('base-content'); - if (chart) { - chart.update('none'); - } - }); + {#if data.length === 0} diff --git a/frontend/src/lib/stores/theme.ts b/frontend/src/lib/stores/theme.ts index 57228a1..59ff785 100644 --- a/frontend/src/lib/stores/theme.ts +++ b/frontend/src/lib/stores/theme.ts @@ -95,10 +95,15 @@ export function saveThemeToStorage() { } } +import { writable } from 'svelte/store'; + +export const themeApplyCount = writable(0); + export function applyThemeToDocument() { const effective = getEffectiveTheme(); document.documentElement.dataset.theme = effective; invalidateColorCache(); + themeApplyCount.update(n => n + 1); } export function getThemeIcon(): string { @@ -110,4 +115,23 @@ export function getThemeIcon(): string { return 'Sun'; } +/** Restore-point for profile page so the preview can be reverted on navigation */ +let _restorePoint: { mode: ThemeMode; custom: DaisyUITheme | null } | null = null; + +export function saveRestorePoint(): void { + _restorePoint = { mode: _themeMode, custom: _customTheme }; +} + +export function clearRestorePoint(): void { + _restorePoint = null; +} + +export function restoreFromPoint(): boolean { + if (!_restorePoint) return false; + _themeMode = _restorePoint.mode; + _customTheme = _restorePoint.custom; + _restorePoint = null; + return true; +} + export { DAISYUI_THEMES }; diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index dc8e268..a08bc17 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -5,6 +5,10 @@ import { currentUser, csrfToken } from '$lib/stores/auth'; import { _, locale, setLocale, SUPPORTED_LOCALES, type AppLocale } from '$lib/i18n'; import { setTimezone, detectTimezone } from '$lib/stores/timezone'; + import { + setThemeMode, setCustomTheme, applyThemeToDocument, saveThemeToStorage, + sanitizeThemeMode, THEME_MODE_KEY, CUSTOM_THEME_KEY + } from '$lib/stores/theme'; let email = $state(''); let password = $state(''); @@ -56,6 +60,18 @@ if (settings.timezone === 'UTC') update.timezone = detected; await api.profile.updateSettings(update); setTimezone(settings.timezone === 'UTC' ? detected : settings.timezone); + + if (settings.theme) { + const dbMode = sanitizeThemeMode(settings.theme); + const storedMode = localStorage.getItem(THEME_MODE_KEY); + const storedCustom = localStorage.getItem(CUSTOM_THEME_KEY); + if (!storedMode || storedMode !== dbMode || storedCustom !== (settings.custom_theme ?? null)) { + setThemeMode(dbMode); + setCustomTheme(settings.custom_theme); + applyThemeToDocument(); + saveThemeToStorage(); + } + } const localeToSet: AppLocale = languageChanged ? selectedLanguage : SUPPORTED_LOCALES.includes(settings.language as AppLocale) diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte index 0532bcb..97ee1b3 100644 --- a/frontend/src/routes/profile/+page.svelte +++ b/frontend/src/routes/profile/+page.svelte @@ -6,7 +6,7 @@ import { _, SUPPORTED_LOCALES, setLocale } from '$lib/i18n'; import { getPasswordChecks, passwordChecksPassed, passwordPattern } from '$lib/password'; import { getTimezone, setTimezone, detectTimezone } from '$lib/stores/timezone'; - import { getThemeMode, setThemeMode, getCustomTheme, setCustomTheme, applyThemeToDocument, saveThemeToStorage, sanitizeThemeMode, DAISYUI_THEMES } from '$lib/stores/theme'; + import { getThemeMode, setThemeMode, getCustomTheme, setCustomTheme, applyThemeToDocument, saveThemeToStorage, sanitizeThemeMode, restoreFromPoint, saveRestorePoint, clearRestorePoint, DAISYUI_THEMES } from '$lib/stores/theme'; import Alert from '$lib/components/Alert.svelte'; import { toasts } from '$lib/toasts'; import { localizeError } from '$lib/errors'; @@ -122,6 +122,7 @@ } applyThemeToDocument(); saveThemeToStorage(); + saveRestorePoint(); keys = await api.profile.listApiKeys(); oidcConfig = await api.oidc.config(); if (oidcConfig.enabled) { @@ -133,6 +134,15 @@ void load(); }); + $effect(() => { + return () => { + if (restoreFromPoint()) { + applyThemeToDocument(); + saveThemeToStorage(); + } + }; + }); + async function saveProfile() { profileMessage = null; const nextPassword = password.trim(); @@ -199,6 +209,7 @@ themeMode = 'custom'; applyThemeToDocument(); saveThemeToStorage(); + clearRestorePoint(); try { await api.profile.updateSettings({ theme: 'custom', From 976fd535d661432c6eac6f0ca3bd799cbf1a496e Mon Sep 17 00:00:00 2001 From: codebude Date: Mon, 25 May 2026 00:19:51 +0200 Subject: [PATCH 080/165] Updated README.md and about page --- README.md | 12 ++++++------ frontend/src/routes/about/+page.svelte | 2 ++ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index db94057..9b4e206 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ ![Svelte](https://img.shields.io/badge/svelte-5-%23FF3E00?logo=svelte) ![FastAPI](https://img.shields.io/badge/FastAPI-0.136-%23009688?logo=fastapi) ![License](https://img.shields.io/badge/license-MIT-green) -![Backend tests](https://img.shields.io/badge/tests-628_✔️-2ea44f?logo=pytest) -![Frontend tests](https://img.shields.io/badge/tests-290_✔️-2ea44f?logo=vitest) +![Backend tests](https://img.shields.io/badge/tests-633_✔️-2ea44f?logo=pytest) +![Frontend tests](https://img.shields.io/badge/tests-296_✔️-2ea44f?logo=vitest) ## AI-Assisted Development Disclaimer @@ -142,14 +142,14 @@ The dev server proxies `/api` requests to `http://localhost:8000`. ## Testing -### Backend (pytest, 628 tests) +### Backend (pytest, 633 tests) ```bash cd backend uv run pytest # runs tests with coverage ``` -### Frontend (Vitest, 290 tests) +### Frontend (Vitest, 296 tests) ```bash cd frontend @@ -205,7 +205,7 @@ librislog/ │ │ ├── isbn_utils.py # ISBN-10/13 conversion │ │ └── user_deletion.py # Account deletion │ ├── alembic/ # Database migrations -│ ├── tests/ # 628 pytest tests +│ ├── tests/ # 633 pytest tests │ └── Dockerfile ├── frontend/ │ ├── src/ @@ -215,7 +215,7 @@ librislog/ │ │ │ ├── toasts.ts # Toast notification store │ │ │ ├── i18n/ # Internationalisation (en, de) │ │ │ ├── stores/ # Svelte stores (auth, timezone) -│ │ │ ├── components/ # 27 Svelte components (41 files incl. tests) +│ │ │ ├── components/ # 24 Svelte components (41 files incl. tests) │ │ │ └── test/ # Test setup & mocks │ │ └── routes/ # SvelteKit pages │ └── Dockerfile diff --git a/frontend/src/routes/about/+page.svelte b/frontend/src/routes/about/+page.svelte index 91de377..03532f6 100644 --- a/frontend/src/routes/about/+page.svelte +++ b/frontend/src/routes/about/+page.svelte @@ -13,7 +13,9 @@ { name: 'svelte-chartjs', url: 'https://github.com/SauravKanchan/svelte-chartjs' }, { name: 'chartjs-plugin-zoom', url: 'https://github.com/chartjs/chartjs-plugin-zoom' }, { name: 'chartjs-chart-matrix', url: 'https://github.com/kurkle/chartjs-chart-matrix' }, + { name: 'DaisyUI', url: 'https://daisyui.com/' }, { name: 'Hammer.js', url: 'https://hammerjs.github.io/' }, + { name: 'html5-qrcode', url: 'https://github.com/mebjas/html5-qrcode' }, ]; const backendDeps: Array<{ name: string; url: string }> = [ From b0ba7b96789f57e275e08dc2114e919a4ad2b3b9 Mon Sep 17 00:00:00 2001 From: codebude Date: Mon, 25 May 2026 00:51:08 +0200 Subject: [PATCH 081/165] Optimized import book layout --- .../src/lib/components/AddBookModal.svelte | 58 +++++++++---------- .../src/lib/components/ImportSearch.svelte | 37 +++++++----- .../src/lib/components/SuggestionInput.svelte | 2 +- frontend/src/lib/i18n/locales/de.json | 1 + frontend/src/lib/i18n/locales/en.json | 1 + 5 files changed, 55 insertions(+), 44 deletions(-) diff --git a/frontend/src/lib/components/AddBookModal.svelte b/frontend/src/lib/components/AddBookModal.svelte index f655487..6148391 100644 --- a/frontend/src/lib/components/AddBookModal.svelte +++ b/frontend/src/lib/components/AddBookModal.svelte @@ -108,7 +108,7 @@ {#if open}