diff --git a/.grype.yaml b/.grype.yaml index 068f32f..920ec5d 100644 --- a/.grype.yaml +++ b/.grype.yaml @@ -110,6 +110,24 @@ ignore: name: libexpat1-dev version: 2.8.3-1~deb13u1+dhi2 type: deb + + # Expat 2.8.4 fixes CVE-2026-76956 and CVE-2026-76957, but Debian 13 + # currently classifies both as no-DSA minor issues and has no fixed stable + # package. This shared library is copied only for Fontconfig/Poppler; Pullbox + # XML parsing uses Python's separately bundled parser, and pyexpat does not + # expose the custom encoding callback required by CVE-2026-76957. Keep these + # exceptions exact and temporary. Re-review by 2026-10-07 or on the next DHI + # Python refresh, whichever comes first. + - vulnerability: CVE-2026-76956 + package: + name: libexpat1 + version: 2.8.3-1~deb13u1+dhi2 + type: deb + - vulnerability: CVE-2026-76957 + package: + name: libexpat1 + version: 2.8.3-1~deb13u1+dhi2 + type: deb - vulnerability: CVE-2025-59375 package: name: libexpat1 diff --git a/alembic/env.py b/alembic/env.py index fa53398..c9de7bb 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -45,10 +45,26 @@ def run_migrations_offline() -> None: def do_run_migrations(connection: Connection) -> None: """Configure context and run migrations within a connection.""" + # SQLite table rebuilds must not cascade into existing child tables. Keep + # embedded Alembic callers consistent with the standalone migration process. + sqlite_fk_enabled = False + if connection.dialect.name == "sqlite": + sqlite_fk_enabled = bool(connection.exec_driver_sql("PRAGMA foreign_keys").scalar()) + connection.exec_driver_sql("PRAGMA foreign_keys=OFF") + connection.commit() context.configure(connection=connection, target_metadata=target_metadata) - with context.begin_transaction(): - context.run_migrations() + try: + with context.begin_transaction(): + context.run_migrations() + except BaseException: + connection.rollback() + raise + else: + connection.commit() + finally: + if sqlite_fk_enabled: + connection.exec_driver_sql("PRAGMA foreign_keys=ON") async def run_async_migrations() -> None: diff --git a/alembic/versions/n5h6i7j8k901_protect_library_root_removal.py b/alembic/versions/n5h6i7j8k901_protect_library_root_removal.py new file mode 100644 index 0000000..4fddf2e --- /dev/null +++ b/alembic/versions/n5h6i7j8k901_protect_library_root_removal.py @@ -0,0 +1,149 @@ +"""Protect root dependencies and retain removed import destinations. + +Revision ID: n5h6i7j8k901 +Revises: m4g5h6i7j890 +""" + +from __future__ import annotations + +import sqlalchemy as sa + +from alembic import op + +revision = "n5h6i7j8k901" +down_revision = "m4g5h6i7j890" +branch_labels = None +depends_on = None + +_TABLES = ("library_files", "series", "story_arcs", "story_arc_placements", "import_jobs") +_NAMING = {"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s"} +_SQLITE_INLINE_KEYS = { + "import_jobs": {"story_arc_rollback_waiting_work_id": "story_arc_sync_work"}, + "story_arcs": { + "source_import_job_id": "import_jobs", + "target_library_root_id": "library_roots", + }, +} +_STORY_ARC_INLINE_CHECKS = { + "source_kind": "VARCHAR(9) NOT NULL DEFAULT 'legacy' CONSTRAINT storyarcsourcekind " + "CHECK (source_kind IN ('legacy','pullbox','mylar3','folder','comicinfo','provider'))", + "lifecycle": "VARCHAR(8) NOT NULL DEFAULT 'active' CONSTRAINT storyarclifecycle " + "CHECK (lifecycle IN ('active','archived'))", +} + + +def _restore_inline_keys(bind: sa.Connection, table: sa.Table) -> None: + """Restore ADD COLUMN FKs expected by immutable older SQLite downgrades.""" + definitions = { + column: f'INTEGER REFERENCES "{target}"(id) ON DELETE SET NULL' + for column, target in _SQLITE_INLINE_KEYS.get(table.name, {}).items() + } + if table.name == "story_arcs": + definitions.update(_STORY_ARC_INLINE_CHECKS) + for column, definition in definitions.items(): + if column not in table.c: + continue + indexes = [index for index in table.indexes if column in index.columns] + bind.exec_driver_sql( + f'CREATE TEMP TABLE root_removal_refs AS SELECT id, "{column}" FROM "{table.name}"' + ) + bind.exec_driver_sql("CREATE UNIQUE INDEX root_removal_refs_id ON root_removal_refs(id)") + for index in indexes: + index.drop(bind) + op.drop_column(table.name, column) + bind.exec_driver_sql(f'ALTER TABLE "{table.name}" ADD COLUMN "{column}" {definition}') + bind.exec_driver_sql( + f'UPDATE "{table.name}" SET "{column}" = (SELECT "{column}" FROM root_removal_refs ' + f'WHERE root_removal_refs.id = "{table.name}".id)' + ) + bind.exec_driver_sql("DROP TABLE root_removal_refs") + for index in indexes: + index.create(bind) + + +def _change_constraints(*, upgrading: bool) -> None: + bind = op.get_bind() + # Batch recreation with enforcement enabled can cascade into child tables. + if bind.dialect.name == "sqlite" and bind.exec_driver_sql("PRAGMA foreign_keys").scalar(): + raise RuntimeError( + "Run root-removal migration with the standalone Alembic connection (foreign_keys=OFF)." + ) + for table in _TABLES: + metadata = sa.MetaData(naming_convention=_NAMING) + reflected = sa.Table(table, metadata, autoload_with=bind) + if bind.dialect.name == "sqlite": + # SQLAlchemy's SQL-text reflection can lose ON DELETE on columns + # originally added with ALTER TABLE. SQLite's own FK list is exact. + actions = { + row[3]: (row[5], row[6]) + for row in bind.exec_driver_sql(f'PRAGMA foreign_key_list("{table}")') + } + for fk in list(reflected.foreign_key_constraints): + if len(fk.columns) == 1: + column = next(iter(fk.columns)).name + fk.onupdate, fk.ondelete = actions[column] + if not upgrading and column in _SQLITE_INLINE_KEYS.get(table, {}): + reflected.constraints.remove(fk) + if not upgrading and table == "story_arcs": + for constraint in list(reflected.constraints): + if isinstance(constraint, sa.CheckConstraint) and constraint.name in { + "storyarcsourcekind", + "storyarclifecycle", + }: + reflected.constraints.remove(constraint) + keys = [ + key + for key in sa.inspect(bind).get_foreign_keys(table) + if key["referred_table"] == "library_roots" + ] + with op.batch_alter_table( + table, + naming_convention=_NAMING, + copy_from=reflected, + recreate="always" if bind.dialect.name == "sqlite" else "auto", + ) as batch: + for key in keys: + columns = key["constrained_columns"] + if ( + not upgrading + and bind.dialect.name == "sqlite" + and columns[0] in _SQLITE_INLINE_KEYS.get(table, {}) + ): + continue + name = key["name"] or f"fk_{table}_{columns[0]}_library_roots" + batch.drop_constraint(name, type_="foreignkey") + batch.create_foreign_key( + name, + "library_roots", + columns, + ["id"], + ondelete="RESTRICT" + if upgrading + else ("CASCADE" if table == "library_files" else "SET NULL"), + ) + if not upgrading and bind.dialect.name == "sqlite": + _restore_inline_keys(bind, reflected) + if bind.dialect.name == "sqlite" and bind.exec_driver_sql("PRAGMA foreign_key_check").first(): + raise RuntimeError("Foreign key validation failed after root-removal migration.") + + +def upgrade() -> None: + _change_constraints(upgrading=True) + op.add_column( + "import_jobs", sa.Column("removed_library_root_snapshot", sa.JSON(), nullable=True) + ) + + +def downgrade() -> None: + if ( + op.get_bind() + .execute( + sa.text( + "SELECT 1 FROM import_jobs WHERE removed_library_root_snapshot IS NOT NULL LIMIT 1" + ) + ) + .first() + ): + raise RuntimeError("Cannot downgrade while import history records removed library roots.") + _change_constraints(upgrading=False) + op.drop_column("import_jobs", "removed_library_root_snapshot") diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md index b6302f8..640ad17 100644 --- a/docs/development/ARCHITECTURE_OVERVIEW.md +++ b/docs/development/ARCHITECTURE_OVERVIEW.md @@ -496,6 +496,18 @@ the issue catalog as hydrating while full ComicVine issue metadata is fetched in the background. This keeps imports responsive without pretending the catalog is complete before hydration finishes. +Completed reference-only imports support two bounded follow-up paths. Exact +ComicInfo or trusted sidecar identity can repair a misplaced file's logical +series and issue ownership without changing its source path. After those +corrections, the operator may create a separate managed-copy import from the +completed results screen. That job reuses the reviewed issue identity, applies +the destination root's current naming, conversion, and ComicInfo policy, and +replaces each old referenced registration only after its managed copy is +published. Its signed preview covers the exact source lineage and non-overlapping +target root. Rollback restores the original reference and removes only a +verified unchanged managed artifact; it never deletes or renames the Mylar +source. + **Required standard** - Preserve matching quality before optimizing import speed. diff --git a/docs/development/IMPORT_REVIEW_RECOVERY.md b/docs/development/IMPORT_REVIEW_RECOVERY.md index f8fe4de..90a590d 100644 --- a/docs/development/IMPORT_REVIEW_RECOVERY.md +++ b/docs/development/IMPORT_REVIEW_RECOVERY.md @@ -1,24 +1,64 @@ # Recheck A Saved Import Review +## Guided Import Contract + +Collection imports use the same five stages for Mylar and folder sources: +Source, Analyze, Review, Import, and Finish. The normal path is intentionally +task-oriented: + +- Step 1 asks for the source and copy versus keep-in-place behavior. Pullbox + automatically uses the sole or default writable managed root for copied + imports. **Where new files go** appears only when there is a real choice or + no writable destination is available; layout overrides and manual path + mappings remain under progressive disclosure. +- Mylar path analysis groups a shared root problem into one actionable card. + When at least one source is available, missing or stale Mylar references are + retained as non-blocking follow-up instead of requiring an acknowledgement. +- Step 3 shows Ready, Needs attention, and Deferred follow-up first. **Import + all ready comics** selects the safe canonical set without requiring the user + to visit every deferred group. Detailed status tables remain available under + **Review details**. +- Trusted, complete Mylar or ComicInfo Story Arc evidence may create a logical + Story Arc automatically. Inferred or incomplete arc evidence is retained for + later review and never blocks canonical comic import. +- Source-mutating actions are not presented during Review. Import Follow-up + owns unresolved matching and cleanup actions. Import History owns the + optional clean-library organizer. +- In-place imports keep existing files associated with their current roots. If + a selected series spans multiple roots, Step 3 requires one writable root for + future downloads and replacements without relocating the existing files. + Use this maintenance procedure when a review was generated before the Mylar sidecar parser and comic-content checks were corrected. Normal completed-job -recovery is available in the import results screen and does not require an +recovery is available in Import Follow-up and does not require an offline command. It works for Mylar and folder imports. This is not a full rescan, a database restore, or an import. -## Completed Import Recovery +## Import Follow-up + +The Follow-up tab groups actionable work by import job rather than rendering +one flat, cross-import backlog. Selecting an import opens its matching, +recovery, cleanup, Story Arc, failed-file, and exhausted metadata work in one +bounded workspace. Each bulk action has an exact count, three representative +filenames, and an on-demand detail view limited to 25 files per page. Imports +with only successful, duplicate, skipped, or still-hydrating metadata outcomes +do not appear. -The completed results screen groups large backlogs into bounded recovery -categories instead of rendering every row. Each action has an exact count, -three representative filenames, and an on-demand detail view limited to 25 -files per page. This keeps even six-figure imports responsive. +Step 5 remains a concise completion receipt. When follow-up exists, it shows an +above-fold count and a direct link to that import's Follow-up workspace. Available recovery actions are intentionally narrow: - **Dismiss stale Mylar references** marks missing database references skipped. It does not delete a review record or touch Mylar's database. -- **Skip probable cover files** excludes one-page image archives while leaving - the source files intact. +- **Skip one-page archives** excludes one-page image archives while leaving the + source files intact. A one-page archive may be cover art, a damaged archive, + or an intentional one-page comic, so Pullbox does not delete it automatically. +- **Move a reviewed source to Trash** is an individual post-import option for a + confirmed cover or unwanted one-page source. It appears only in Follow-up, + uses a red warning modal and an actor-bound signed preview, and + requires configured Trash plus source write permission. Reference-only Mylar + roots cannot use it; the non-destructive skip remains available instead. - **Skip unusable files** excludes empty, unsupported, and page-less files. - **Allow oversized files once** retries only decompression-size blocks marked overrideable. It does not change the global archive safety policy or approve @@ -31,18 +71,92 @@ Available recovery actions are intentionally narrow: exactly one high-confidence preferred file and the issue is not already owned. Alternatives in that group are skipped and the preferred file alone is retried. +- **Resolve mixed-folder files** uses exact ComicInfo or trusted sidecar series + and issue identity to correct files assigned to the wrong imported series or + issue. Ambiguous titles, filename-only guesses, conflicting target files, + stale references, and managed files remain review-only. The source path and + source artifact are never changed. + +During a Mylar scan, Pullbox can also reconcile one stale recorded path with a +file found in another series folder when the embedded ComicInfo issue ID is an +exact match. If Mylar's issue ID is stale, Pullbox can use a stricter fallback +only when the embedded ComicInfo series ID, series title, issue number, and +issue ID are trusted; the recorded filename is exact; and the missing record +and candidate are both unique. The real embedded issue ID is preserved. Pullbox +links one canonical file in place for import and classifies only byte-identical +extra copies from the exact-ID path as duplicates. Filename guesses, ambiguous +records, conflicting embedded identity, and non-identical candidates remain +untouched for review. + +Follow-up keeps the optional physical cleanup separate from the safe +recovery actions above: + +- **Move misplaced file** moves one canonical file to the exact missing path + already recorded by Mylar. It requires an empty destination, revalidates the + source fingerprint, and updates the Pullbox reference and rollback journal + together. Its signed confirmation is a one-time authorization for that exact + move even when the root is otherwise reference-only; the root's permanent + managed-write policy and Mylar's database remain unchanged. +- **Move all verified files** applies the same checks to the complete current + exact-identity scope. The signed preview covers the scope digest and actor; + changed, ambiguous, occupied, or inaccessible candidates remain untouched. +- **Move duplicate to Trash** is a separate per-file choice available only for + an extra copy that remains byte-identical to its canonical issue. It requires + a configured Trash folder and managed-write permission. Pullbox never removes + these copies automatically. + +All actions use an actor-bound signed preview and restore the physical source +if their database update cannot commit. A reference-only Mylar root remains +non-destructive during import and does not become managed after an explicitly +confirmed cleanup move. Every mutation requires a fresh, actor-bound signed preview. Pullbox rejects an -expired preview or any action whose row set changed after preview. Mutations run -in bounded database pages, recompute import counters, and create import and -security audit records. Dangerous, unknown, and genuinely ambiguous outcomes -remain manual-review items. +expired preview or any action whose row set changed after preview. Bounded, +recoverable actions use a normal confirmation; typed confirmation remains +reserved for permanent deletion. Mutations run in bounded database pages, +recompute import counters, and create import and security audit records. +Dangerous, unknown, and genuinely ambiguous outcomes remain manual-review +items. Once cleanup is complete, **Archive results** hides the finished job from the current history view without deleting its rows, logs, decisions, or rollback evidence. Archived jobs can be restored later. **Clear History** never deletes archived jobs. +## Building A Clean Pullbox Library + +A completed reference-only import can be used as the reviewed source for a +separate clean managed library. Import History exposes this optional organizer +for eligible jobs. Its modal shows the exact file count, series count, and +source size, then requires the operator to choose an enabled writable library +root that does not overlap any source root. + +The clean-library build creates a normal background Step 4 import. It copies +only files with an exact, current imported-file to library-file to issue +lineage. The destination root's current folder naming, file naming, CBZ +conversion, and ComicInfo policy are applied. Existing skip-existing behavior +does not prevent this explicit adoption, because each verified source reference +is being replaced by its newly managed Pullbox copy. + +The operation is intentionally source-preserving: + +- The Mylar database, folders, filenames, permissions, and file content remain + unchanged. +- The destination must have sufficient capacity under the ordinary managed-copy + preflight, including conversion workspace reserve when conversion is enabled. +- The preview token is actor-bound, expires after 15 minutes, and covers the + exact source rows and destination root. Changed scope requires a new preview. +- Each completed placement records the prior reference and source identity. + Rollback removes only an unchanged Pullbox-managed destination, restores the + original referenced library record, and leaves the Mylar file in place. +- A missing or changed source, occupied old identity/path, stale database + lineage, or modified managed destination fails closed and preserves the clean + managed file for review. + +Validate the managed library before disabling or retiring the legacy Mylar +root. Source retirement is a separate operator decision; this workflow never +deletes the legacy tree automatically. + ## Safety And Scope - Stop Pullbox and back up its database before running the command. `--offline` @@ -109,8 +223,8 @@ startup recovery resumes from `MATCHING`, preserving the directory inventory and unaffected review decisions, and returns to Step 3. It does not select files or start Step 4. Genuine source-identity conflicts remain in review. -For a completed import, restart Pullbox, open that import's results, and choose -the relevant recovery action. Only the previewed scope is prepared; successful +For a completed import, restart Pullbox, open that import in Follow-up, and +choose the relevant recovery action. Only the previewed scope is prepared; successful files and series remain untouched. A file that is still missing, outside an approved root, unreadable, or unsafe remains excluded with refreshed diagnostics. @@ -199,7 +313,9 @@ under the database's recorded name. Replace or skip the file; it cannot be allowed once. - `single_page_comic`: possibly an alternate cover, but also possibly an intentional one-page comic. Inspect it and approve individually or skip it. - Bulk archive-size approval does not approve these files. + Bulk archive-size approval does not approve these files. Moving the source to + Trash is never automatic and is available only from completed-import cleanup + after an explicit red warning on a writable source. - Archive read failures remain distinct from empty archives. An unavailable RAR backend, corrupt archive, permissions problem, or disappearing source is not evidence that the archive has zero pages. diff --git a/docs/library-root-removal.md b/docs/library-root-removal.md new file mode 100644 index 0000000..5bb44fd --- /dev/null +++ b/docs/library-root-removal.md @@ -0,0 +1,43 @@ +# Removing an unused library root + +In **Settings > Media Management > Library roots**, disable a root and choose +**Remove root**. Review the checks, choose **Confirm removal**, then confirm the +root's name and path in the standard confirmation dialog. + +This removes configuration, including root-specific naming settings. It never +deletes, moves, scans, or changes permissions on folders or comics. An offline +or missing directory can be removed if nothing still depends on its root. + +Removal is blocked while a root is enabled, is the default destination, or has +registered files, current/preferred series associations, Story Arc destinations +or placements, or default destination settings. The preview gives exact counts +and the next step for each blocker. Disabling a root does not detach its files. +Relocate or explicitly remove the associated library entries first; root +removal is not a bulk-delete-files shortcut. + +Finish or cancel active/paused imports and file utilities before removal. +Pending import placement or rollback work also blocks removal. Confirmation +rechecks dependencies; a changed or expired preview must be reviewed again. + +Historical imports do not trap unused roots forever. Their original root name, +path, and ID are preserved, and their history and rollback records remain. +After removal, retry/recovery cannot silently use the current default root: +start a new import and explicitly choose a valid destination. Existing imported +library contents are not removed by this operation. + +## Developer safeguards + +All live root foreign keys use `RESTRICT`, and the ORM does not delete registered +files when their root is deleted. Historical import destinations are explicitly +detached with a retained snapshot in the same transaction as removal. Only the +root's own naming policy is discarded. Previews are operator-bound, signed, and +valid for 15 minutes. SQLite confirmation takes a short write reservation before +rechecking; final deletion also checks dependencies and relies on FK enforcement. + +The migration rebuilds affected SQLite tables on a dedicated Alembic +connection with foreign-key enforcement disabled for migrations only, then +checks referential integrity. Back up the database before applying migrations. +Runtime connections keep enforcement enabled. Existing inline constraints are +preserved when downgrading so older migrations continue to work correctly. +Downgrade is refused once history contains a removed-root snapshot, because old +code cannot honor its retry guard. PostgreSQL uses ordinary FK alterations. diff --git a/src/pullbox/api/v1/config.py b/src/pullbox/api/v1/config.py index e571d16..5f55e59 100644 --- a/src/pullbox/api/v1/config.py +++ b/src/pullbox/api/v1/config.py @@ -35,6 +35,8 @@ LibraryRootRebindConfirmRequest, LibraryRootRebindPreviewRequest, LibraryRootRebindPreviewResponse, + LibraryRootRemovalConfirm, + LibraryRootRemovalPreview, LibraryRootState, LibraryRootUpdate, NamingPreview, @@ -50,7 +52,9 @@ list_library_roots, preview_library_root, preview_library_root_rebind, + preview_library_root_removal, rebind_library_root, + remove_library_root, update_library_root, ) from pullbox.services.library_root_policy_service import ( @@ -884,6 +888,31 @@ async def confirm_existing_library_root_rebind( return LibraryRootState.model_validate(state) +@router.post( + "/library-roots/{library_root_id}/remove/preview", response_model=LibraryRootRemovalPreview +) +async def preview_root_removal( + library_root_id: int, + user: InteractiveOperatorUser, + session: DbSession, +) -> LibraryRootRemovalPreview: + preview = await preview_library_root_removal(session, library_root_id, actor_id=user.id) + return LibraryRootRemovalPreview.model_validate(preview) + + +@router.delete("/library-roots/{library_root_id}", status_code=204) +async def delete_library_root( + library_root_id: int, + body: LibraryRootRemovalConfirm, + user: InteractiveOperatorUser, + session: DbSession, +) -> None: + await remove_library_root( + session, library_root_id, actor_id=user.id, preview_token=body.preview_token + ) + logger.info("library_root_removed", library_root_id=library_root_id, actor_id=user.id) + + # ── Per-root Naming Policy ────────────────────────────────────────── diff --git a/src/pullbox/api/v1/import_completed_cleanup.py b/src/pullbox/api/v1/import_completed_cleanup.py index f723f93..42d0645 100644 --- a/src/pullbox/api/v1/import_completed_cleanup.py +++ b/src/pullbox/api/v1/import_completed_cleanup.py @@ -6,6 +6,9 @@ from pullbox.api.deps import DbSession, InteractiveOperatorUser # noqa: TC001 from pullbox.schemas.import_completed_cleanup import ( + CleanLibraryImportCreateRequest, + CleanLibraryImportPreviewRead, + CleanLibraryImportResultRead, CompletedImportCleanupApplyRequest, CompletedImportCleanupPreviewRead, CompletedImportCleanupResultRead, @@ -16,11 +19,58 @@ apply_completed_import_cleanup, preview_completed_import_cleanup, ) +from pullbox.services.import_library_adoption import ( + create_clean_library_import, + preview_clean_library_import, +) from pullbox.tasks.import_task import trigger_import_execute router = APIRouter(prefix="/import", tags=["import"]) +@router.get( + "/{job_id}/clean-library/preview", + response_model=CleanLibraryImportPreviewRead, +) +async def preview_clean_library_import_route( + job_id: int, + target_root_id: int, + _user: InteractiveOperatorUser, + session: DbSession, +) -> CleanLibraryImportPreviewRead: + """Preview a source-preserving clean-library build.""" + preview = await preview_clean_library_import( + session, + job_id, + target_root_id=target_root_id, + actor_id=_user.id, + ) + return CleanLibraryImportPreviewRead.model_validate(preview, from_attributes=True) + + +@router.post( + "/{job_id}/clean-library", + response_model=CleanLibraryImportResultRead, +) +async def create_clean_library_import_route( + job_id: int, + body: CleanLibraryImportCreateRequest, + _user: InteractiveOperatorUser, + session: DbSession, +) -> CleanLibraryImportResultRead: + """Start a verified managed-copy import from referenced files.""" + result = await create_clean_library_import( + session, + job_id, + target_root_id=body.target_root_id, + actor_id=_user.id, + preview_token=body.preview_token, + ) + await session.commit() + trigger_import_execute(result.job_id) + return CleanLibraryImportResultRead.model_validate(result, from_attributes=True) + + @router.get( "/{job_id}/cleanup/{action}/preview", response_model=CompletedImportCleanupPreviewRead, diff --git a/src/pullbox/api/v1/import_jobs.py b/src/pullbox/api/v1/import_jobs.py index 3826901..047d853 100644 --- a/src/pullbox/api/v1/import_jobs.py +++ b/src/pullbox/api/v1/import_jobs.py @@ -122,7 +122,10 @@ ) from pullbox.services import import_mylar3_path_reports as path_reports from pullbox.services.import_layout_analysis import ImportLayoutAnalyzer -from pullbox.services.import_mylar3_path_preflight import Mylar3PathPreflightAnalyzer +from pullbox.services.import_mylar3_path_preflight import ( + Mylar3PathPreflightAnalyzer, + add_report_unavailable_attention, +) from pullbox.services.import_story_arc_preflight import StoryArcPreflightAnalyzer from pullbox.tasks.import_task import ( purge_import_runtime_state, @@ -283,6 +286,7 @@ async def preview_import_mylar_paths( except (OSError, ValueError): logger.warning("mylar_preflight_report_unavailable", exc_info=True) result.warnings.append("report_unavailable") + result = add_report_unavailable_attention(result, path_reports.report_directory()) logger.info( "mylar_path_preflight_completed", report_id=result.report_id, diff --git a/src/pullbox/core/archive.py b/src/pullbox/core/archive.py index 5be7081..d0de1e7 100644 --- a/src/pullbox/core/archive.py +++ b/src/pullbox/core/archive.py @@ -24,6 +24,18 @@ _ARCHIVE_IMAGE_SUFFIXES = frozenset({".jpg", ".jpeg", ".png", ".webp", ".gif", ".tif", ".tiff"}) +def comicinfo_member_sort_key(name: str) -> tuple[int, int, str, str]: + """Prefer the canonical root ComicInfo.xml, then the shallowest nested copy.""" + normalized = name.replace("\\", "/") + member = PurePosixPath(normalized) + return ( + 0 if len(member.parts) == 1 else 1, + len(member.parts), + normalized.casefold(), + normalized, + ) + + class ArchiveError(Exception): """Raised when an archive cannot be read or is corrupt.""" @@ -131,12 +143,15 @@ def read_comicinfo(self, *, entries: list[str] | None = None) -> ComicInfoData | """ files = entries if entries is not None else self.list_files() - # ComicInfo.xml can be at root or in a subdirectory - comicinfo_name = None - for name in files: - if name.lower().endswith("comicinfo.xml"): - comicinfo_name = name - break + comicinfo_names = sorted( + ( + name + for name in files + if PurePosixPath(name.replace("\\", "/")).name.casefold() == "comicinfo.xml" + ), + key=comicinfo_member_sort_key, + ) + comicinfo_name = comicinfo_names[0] if comicinfo_names else None if comicinfo_name is None: return None diff --git a/src/pullbox/core/file_ops.py b/src/pullbox/core/file_ops.py index 10f7822..ba0fc1d 100644 --- a/src/pullbox/core/file_ops.py +++ b/src/pullbox/core/file_ops.py @@ -272,6 +272,7 @@ async def register_library_file( allow_resource_safety_exception: bool = False, replace_existing_library_file: bool = False, replacement_trash_dir: Path | None = None, + preserve_replaced_artifact: bool = False, source_scan_root: Path | None = None, strict_import_target: bool = False, recover_existing_managed_artifact: bool = False, @@ -307,6 +308,7 @@ async def register_library_file( allow_resource_safety_exception=allow_resource_safety_exception, replace_existing_library_file=replace_existing_library_file, replacement_trash_dir=replacement_trash_dir, + preserve_replaced_artifact=preserve_replaced_artifact, source_scan_root=source_scan_root, strict_import_target=strict_import_target, recover_existing_managed_artifact=recover_existing_managed_artifact, @@ -345,6 +347,7 @@ async def register_library_file_with_metadata( allow_resource_safety_exception: bool = False, replace_existing_library_file: bool = False, replacement_trash_dir: Path | None = None, + preserve_replaced_artifact: bool = False, source_scan_root: Path | None = None, strict_import_target: bool = False, recover_existing_managed_artifact: bool = False, @@ -642,6 +645,7 @@ async def notify_placement_completed(target_path: Path) -> None: prepared_source, replace_existing_library_file=replace_existing_library_file, replacement_trash_dir=replacement_trash_dir, + preserve_replaced_artifact=preserve_replaced_artifact, ) same_filesystem = await asyncio.to_thread( paths_on_same_filesystem, @@ -692,6 +696,7 @@ async def notify_placement_completed(target_path: Path) -> None: prepared_source, replace_existing_library_file=replace_existing_library_file, replacement_trash_dir=replacement_trash_dir, + preserve_replaced_artifact=preserve_replaced_artifact, ) if _can_materialize_cbz_with_comicinfo( prepared_source, @@ -890,27 +895,44 @@ async def notify_placement_completed(target_path: Path) -> None: extension = final_path.suffix.lstrip(".").lower() file_format = _FORMAT_MAP.get(extension, FileFormat.CBZ) - lf = LibraryFile( - file_path=str(final_path), - file_name=final_path.name, - file_size=stat.st_size, - file_format=file_format, - file_modified_at=datetime.fromtimestamp(stat.st_mtime, tz=UTC), - match_confidence=confidence, - parsed_series=series.title if series else None, - parsed_issue_number=effective_issue.issue_number, - parsed_year=series.year_start if series else None, - issue_id=issue.id, - library_root_id=root.id, - naming_snapshot=naming_snapshot, - storage_mode=effective_storage_mode, - source_signature=( - referenced_signature - if referenced_signature is not None - else managed_placement_signature or build_file_identity_signature(final_path) - ), + registered_signature = ( + referenced_signature + if referenced_signature is not None + else managed_placement_signature or build_file_identity_signature(final_path) ) - session.add(lf) + if replacement_stash is not None: + # Keep the canonical row identity so every dependent FK survives a + # path-changing replacement, including clean-library adoption. + lf = replacement_stash.library_file + await _update_existing_library_file_from_path( + lf, + final_path, + issue=effective_issue, + series=series, + root=root, + confidence=confidence, + naming_snapshot=naming_snapshot, + storage_mode=effective_storage_mode, + source_signature=registered_signature, + ) + else: + lf = LibraryFile( + file_path=str(final_path), + file_name=final_path.name, + file_size=stat.st_size, + file_format=file_format, + file_modified_at=datetime.fromtimestamp(stat.st_mtime, tz=UTC), + match_confidence=confidence, + parsed_series=series.title if series else None, + parsed_issue_number=effective_issue.issue_number, + parsed_year=series.year_start if series else None, + issue_id=issue.id, + library_root_id=root.id, + naming_snapshot=naming_snapshot, + storage_mode=effective_storage_mode, + source_signature=registered_signature, + ) + session.add(lf) # 10. Set Issue status to OWNED issue.status = IssueStatus.OWNED @@ -993,6 +1015,7 @@ async def _stage_replacement_file( *, replace_existing_library_file: bool, replacement_trash_dir: Path | None, + preserve_replaced_artifact: bool = False, ) -> _ReplacementStash | None: """Move an existing issue file aside before materializing a replacement.""" if not replace_existing_library_file or issue.library_file is None: @@ -1000,6 +1023,12 @@ async def _stage_replacement_file( library_file = issue.library_file original_path = Path(library_file.file_path) + if preserve_replaced_artifact: + return _ReplacementStash( + library_file=library_file, + original_path=original_path, + staged_path=None, + ) if not await asyncio.to_thread(original_path.exists): return _ReplacementStash( library_file=library_file, diff --git a/src/pullbox/core/file_safety.py b/src/pullbox/core/file_safety.py index 1024dce..e0e75fe 100644 --- a/src/pullbox/core/file_safety.py +++ b/src/pullbox/core/file_safety.py @@ -19,6 +19,7 @@ import structlog +from pullbox.core.archive import comicinfo_member_sort_key from pullbox.core.comicinfo import ComicInfoData, parse_comicinfo from pullbox.core.filesystem_scan import iter_supported_files_with_handler from pullbox.core.page_sources.base import canonical_page_names @@ -453,7 +454,7 @@ def inspect_zip_archive_safety( and PurePosixPath(entry.filename.replace("\\", "/")).name.lower() == "comicinfo.xml" ), - key=lambda entry: entry.filename.casefold(), + key=lambda entry: comicinfo_member_sort_key(entry.filename), ) comicinfo: ComicInfoData | None = None comicinfo_entry = comicinfo_entries[0].filename if comicinfo_entries else None @@ -532,6 +533,9 @@ def run_safety_checks( """ log = logger.bind(download_path=str(download_path)) + if download_path.is_file() and download_path.stat().st_size == 0: + raise FileSafetyError("zero_byte_file", details=[str(download_path)]) + # 1. Dangerous files on disk if block_dangerous: dangerous_files = scan_directory_for_dangerous_files(download_path) diff --git a/src/pullbox/core/mylar3_reader.py b/src/pullbox/core/mylar3_reader.py index b94ec10..a411e6b 100644 --- a/src/pullbox/core/mylar3_reader.py +++ b/src/pullbox/core/mylar3_reader.py @@ -31,6 +31,7 @@ compile_source_layout, resolve_source_layout_spec, ) +from pullbox.core.name_matcher import NameMatcher from pullbox.core.naming import parse_filename from pullbox.core.naming_type_detection import detect_issue_type from pullbox.core.release_parser import normalize_issue_number @@ -42,6 +43,20 @@ logger = structlog.get_logger(__name__) +_GENERIC_FILENAME_SERIES = frozenset({"book", "comic", "issue", "scan", "unknown"}) + + +def _foreign_filename_series(parsed_series: str, mylar_series: str) -> bool: + """Return whether a filename clearly names a different series than its Mylar folder.""" + normalized = NameMatcher.normalize(parsed_series) + if ( + not normalized + or normalized in _GENERIC_FILENAME_SERIES + or not any(character.isalpha() for character in parsed_series) + ): + return False + return not NameMatcher().match(parsed_series, mylar_series).is_match + @dataclass(frozen=True, slots=True) class Mylar3StoryArcEntrySnapshot: @@ -995,6 +1010,21 @@ def _convert_rows( diagnostics["series_status"] = series_status if issue_count_hint is not None: diagnostics["issue_count_hint"] = issue_count_hint + unrecorded_files = [ + file for file in discovered_files if "mylar3_issue" not in file.metadata_diagnostics + ] + folder_scope_conflicts = [ + file + for file in unrecorded_files + if "mylar3_folder_scope_conflict" in file.metadata_diagnostics + ] + if folder_scope_conflicts: + diagnostics["mylar3_folder_scope"] = { + "review_required": True, + "unrecorded_file_count": len(unrecorded_files), + "conflicting_file_count": len(folder_scope_conflicts), + "examples": [file.file_name for file in folder_scope_conflicts[:5]], + } results.append( DiscoveredSeries( @@ -1344,12 +1374,16 @@ def _build_files( file_size = 0 parsed = parse_filename(file_name) + release_series_candidate = ( + parsed.series if parsed else extractor.from_release_title(file_name).series_name + ) parsed_series: str | None = series_name parsed_issue_number: float | None = None parsed_year: int | None = series_year parsed_publisher: str | None = series_publisher issue_type = IssueType.ISSUE issue_number_raw: str | None = None + folder_scope_conflict: dict[str, object] | None = None metadata_signals: dict[str, str] = { "series_name": MetadataSignal.MYLAR3.value, } @@ -1368,6 +1402,18 @@ def _build_files( metadata_signals["issue_number"] = MetadataSignal.RELEASE_TITLE.value if parsed.issue_type != IssueType.ISSUE.value: metadata_signals["issue_type"] = MetadataSignal.RELEASE_TITLE.value + if ( + issue_record is None + and release_series_candidate + and _foreign_filename_series(release_series_candidate, series_name) + ): + parsed_series = release_series_candidate + metadata_signals["series_name"] = MetadataSignal.RELEASE_TITLE.value + folder_scope_conflict = { + "expected_series": series_name, + "parsed_series": release_series_candidate, + "recorded_issue": False, + } if issue_record is not None and issue_record.issue_number: issue_number_raw = issue_record.issue_number @@ -1386,12 +1432,18 @@ def _build_files( "has_comicinfo": False, "mylar3_folder_metadata_scanned": True, } + if issue_record is None: + metadata_diagnostics["mylar3_unrecorded_file"] = { + "expected_series": series_name, + } if fpath in reconciled: metadata_diagnostics["mylar3_path_reconciliation"] = { "recorded_path": str(reconciled[fpath]), "actual_path": str(fpath), "method": "unique_same_folder_normalized_stem", } + if folder_scope_conflict is not None: + metadata_diagnostics["mylar3_folder_scope_conflict"] = folder_scope_conflict if sidecar_data is not None and ( sidecar_data.get("files_present") or sidecar_data.get("series_id") is not None @@ -1459,13 +1511,17 @@ def _build_files( metadata_diagnostics["identity_conflicts"] = identity_conflicts comicvine_issue_id = sidecar_issue_id - comicvine_series_id = series_cv_id or sidecar_series_id + comicvine_series_id = ( + sidecar_series_id + if folder_scope_conflict is not None + else series_cv_id or sidecar_series_id + ) if comicvine_issue_id is not None: metadata_signals["comicvine_issue_id"] = MetadataSignal.SIDECAR.value if comicvine_series_id is not None: metadata_signals["comicvine_series_id"] = ( MetadataSignal.MYLAR3.value - if series_cv_id is not None + if series_cv_id is not None and folder_scope_conflict is None else MetadataSignal.SIDECAR.value ) if issue_record is not None: diff --git a/src/pullbox/core/source_metadata.py b/src/pullbox/core/source_metadata.py index b5bcd3a..780cedd 100644 --- a/src/pullbox/core/source_metadata.py +++ b/src/pullbox/core/source_metadata.py @@ -178,6 +178,11 @@ def volume_subtitle_hint_from_filename( base_series, flags=re.IGNORECASE, ) + base_series = re.sub( + r"\s*(?:\((?:19|20)\d{2}\)|\[(?:19|20)\d{2}\]|(?:19|20)\d{2})\s*$", + "", + base_series, + ) base_series = re.sub(r"\s+", " ", base_series).strip(" -:") base_series = _VOLUME_HINT_PUBLISHER_PREFIX_RE.sub("", base_series).strip(" -:") subtitle = _clean_volume_subtitle_hint(normalized_stem[match.end() :]) @@ -442,12 +447,13 @@ def from_release_title( issue_number=volume_issue_number, ) volume_issue_applies = issue_type_family(issue_type) == TypeFamily.COLLECTION - if volume_issue_applies and volume_hint is not None and issue_number is None: + if volume_issue_applies and volume_hint is not None: series_name = str(volume_hint["base_series"]) - issue_number = volume_hint["issue_number"] - issue_number_text = format_issue_number(issue_number) - signals["issue_number"] = MetadataSignal.RELEASE_TITLE diagnostics["volume_subtitle_hint"] = volume_hint + if issue_number is None: + issue_number = volume_hint["issue_number"] + issue_number_text = format_issue_number(issue_number) + signals["issue_number"] = MetadataSignal.RELEASE_TITLE elif volume_issue_applies and issue_number is None and volume_issue_number is not None: issue_number = volume_issue_number issue_number_text = format_issue_number(issue_number) diff --git a/src/pullbox/models/audit_log.py b/src/pullbox/models/audit_log.py index 13dada8..7410c32 100644 --- a/src/pullbox/models/audit_log.py +++ b/src/pullbox/models/audit_log.py @@ -26,6 +26,8 @@ class AuditEventType(StrEnum): SECURITY_CONFIG_CHANGED = "security_config_changed" LOCAL_BYPASS_TOGGLED = "local_bypass_toggled" IMPORT_SAFETY_BULK_OVERRIDE = "import_safety_bulk_override" + IMPORT_SAFETY_SOURCE_TRASH = "import_safety_source_trash" + IMPORT_MISPLACED_SOURCE_CLEANUP = "import_misplaced_source_cleanup" IMPORT_RECOVERY_BULK_ACTION = "import_recovery_bulk_action" diff --git a/src/pullbox/models/import_job.py b/src/pullbox/models/import_job.py index f76b07c..3a5a4a5 100644 --- a/src/pullbox/models/import_job.py +++ b/src/pullbox/models/import_job.py @@ -215,8 +215,9 @@ class ImportJob(Base, IdentityMixin, TimestampMixin): # Import settings (captured from wizard) target_library_root_id: Mapped[int | None] = mapped_column( - ForeignKey("library_roots.id", ondelete="SET NULL") + ForeignKey("library_roots.id", ondelete="RESTRICT") ) + removed_library_root_snapshot: Mapped[dict | None] = mapped_column(JSON(none_as_null=True)) # type: ignore[type-arg] monitored: Mapped[bool] = mapped_column(default=False) search_on_add: Mapped[bool] = mapped_column(default=False) move_to_library: Mapped[bool] = mapped_column(default=True) diff --git a/src/pullbox/models/library.py b/src/pullbox/models/library.py index a5349f3..23b181e 100644 --- a/src/pullbox/models/library.py +++ b/src/pullbox/models/library.py @@ -110,7 +110,9 @@ class LibraryFile(Base, IdentityMixin, TimestampMixin): # Foreign keys issue_id: Mapped[int | None] = mapped_column(ForeignKey("issues.id", ondelete="SET NULL")) - library_root_id: Mapped[int] = mapped_column(ForeignKey("library_roots.id", ondelete="CASCADE")) + library_root_id: Mapped[int] = mapped_column( + ForeignKey("library_roots.id", ondelete="RESTRICT") + ) # Relationships issue: Mapped[Issue | None] = relationship(back_populates="library_file") @@ -156,15 +158,17 @@ class LibraryRoot(Base, IdentityMixin, TimestampMixin): # Relationships files: Mapped[list[LibraryFile]] = relationship( - back_populates="library_root", cascade="all, delete-orphan" + back_populates="library_root", passive_deletes="all" ) series: Mapped[list[Series]] = relationship( back_populates="library_root", foreign_keys="Series.library_root_id", + passive_deletes="all", ) preferred_series: Mapped[list[Series]] = relationship( back_populates="preferred_library_root", foreign_keys="Series.preferred_library_root_id", + passive_deletes="all", ) naming_policy: Mapped[LibraryRootPolicy | None] = relationship( back_populates="library_root", diff --git a/src/pullbox/models/series.py b/src/pullbox/models/series.py index 42eb657..9caa638 100644 --- a/src/pullbox/models/series.py +++ b/src/pullbox/models/series.py @@ -146,10 +146,10 @@ class Series(Base, IdentityMixin, TimestampMixin): ForeignKey("publishers.id", ondelete="SET NULL") ) library_root_id: Mapped[int | None] = mapped_column( - ForeignKey("library_roots.id", ondelete="SET NULL") + ForeignKey("library_roots.id", ondelete="RESTRICT") ) preferred_library_root_id: Mapped[int | None] = mapped_column( - ForeignKey("library_roots.id", ondelete="SET NULL") + ForeignKey("library_roots.id", ondelete="RESTRICT") ) # Relationships diff --git a/src/pullbox/models/story_arc.py b/src/pullbox/models/story_arc.py index 48d8c03..3363fd1 100644 --- a/src/pullbox/models/story_arc.py +++ b/src/pullbox/models/story_arc.py @@ -171,7 +171,7 @@ class StoryArc(Base, IdentityMixin, TimestampMixin): ) sync_enabled: Mapped[bool] = mapped_column(default=False, server_default="0", nullable=False) target_library_root_id: Mapped[int | None] = mapped_column( - ForeignKey("library_roots.id", ondelete="SET NULL") + ForeignKey("library_roots.id", ondelete="RESTRICT") ) policy_schema_version: Mapped[int | None] = mapped_column(Integer) policy_snapshot: Mapped[dict] = mapped_column( # type: ignore[type-arg] @@ -365,7 +365,7 @@ class StoryArcPlacement(Base, IdentityMixin, TimestampMixin): ForeignKey("library_files.id", ondelete="SET NULL") ) library_root_id: Mapped[int | None] = mapped_column( - ForeignKey("library_roots.id", ondelete="SET NULL") + ForeignKey("library_roots.id", ondelete="RESTRICT") ) placement_path: Mapped[str] = mapped_column(String(1000), nullable=False) mode: Mapped[StoryArcPlacementMode] = mapped_column( diff --git a/src/pullbox/schemas/config.py b/src/pullbox/schemas/config.py index 3e68436..e56b233 100644 --- a/src/pullbox/schemas/config.py +++ b/src/pullbox/schemas/config.py @@ -121,6 +121,24 @@ class LibraryRootPreviewResponse(BaseModel): can_create: bool +class LibraryRootRemovalPreview(BaseModel): + id: int + name: str + path: str + can_remove: bool + blocking_reasons: list[str] + dependencies: dict[str, int] + history_count: int + has_naming_policy: bool + preview_token: str | None + + +class LibraryRootRemovalConfirm(BaseModel): + model_config = ConfigDict(extra="forbid") + preview_token: str = Field(min_length=1, max_length=8192) + confirmation: Literal["REMOVE"] + + class LibraryRootRebindPreviewRequest(BaseModel): """Request a write-free preview for an established root path replacement.""" diff --git a/src/pullbox/schemas/import_completed_cleanup.py b/src/pullbox/schemas/import_completed_cleanup.py index abaee75..fa05a20 100644 --- a/src/pullbox/schemas/import_completed_cleanup.py +++ b/src/pullbox/schemas/import_completed_cleanup.py @@ -39,3 +39,33 @@ class CompletedImportCleanupResultRead(BaseModel): affected_count: int affected_file_count: int requires_import_retry: bool + + +class CleanLibraryImportPreviewRead(BaseModel): + """Exact referenced scope that will become a managed library.""" + + source_job_id: int + target_root_id: int + eligible_file_count: int + eligible_series_count: int + total_bytes: int + source_preserved: bool + preview_token: str + confirmation_text: Literal["BUILD CLEAN LIBRARY"] = "BUILD CLEAN LIBRARY" + + +class CleanLibraryImportCreateRequest(BaseModel): + """Signed confirmation for a clean managed-library build.""" + + target_root_id: int = Field(..., gt=0) + preview_token: str = Field(..., min_length=1) + confirmation: Literal["BUILD CLEAN LIBRARY"] + + +class CleanLibraryImportResultRead(BaseModel): + """Background managed-copy import created from a reference import.""" + + source_job_id: int + job_id: int + eligible_file_count: int + eligible_series_count: int diff --git a/src/pullbox/schemas/import_job.py b/src/pullbox/schemas/import_job.py index 914a56a..ec90170 100644 --- a/src/pullbox/schemas/import_job.py +++ b/src/pullbox/schemas/import_job.py @@ -560,6 +560,7 @@ class ImportJobRead(BaseModel): # Settings target_library_root_id: int | None + removed_library_root_snapshot: dict[str, str | int] | None = None monitored: bool search_on_add: bool move_to_library: bool = True diff --git a/src/pullbox/schemas/import_mylar3_path_preflight.py b/src/pullbox/schemas/import_mylar3_path_preflight.py index 7b37f8b..6e761c4 100644 --- a/src/pullbox/schemas/import_mylar3_path_preflight.py +++ b/src/pullbox/schemas/import_mylar3_path_preflight.py @@ -24,6 +24,11 @@ "ambiguous", "invalid", ] +MylarPathAttentionActionKind = Literal[ + "acknowledge_unavailable", + "register_reference_root", + "remove_ineffective_mapping", +] class MylarPathMappingDraft(BaseModel): @@ -106,6 +111,51 @@ class MylarPathException(BaseModel): suggested_action: str +class MylarPathProblemGroup(BaseModel): + """One root-level explanation for repeated path failures.""" + + root_path: str + outcome: MylarPathOutcome + series_count: int = Field(ge=0) + location_count: int = Field(ge=0) + reason: str + suggested_action: str + can_register_reference_root: bool = False + + +class MylarPathAttentionAction(BaseModel): + """One server-authorized, deterministic Step 1 resolution.""" + + kind: MylarPathAttentionActionKind + fingerprint: str = Field(min_length=64, max_length=64) + root_path: str | None = None + stored_prefix: str | None = None + pullbox_prefix: str | None = None + + +class MylarPathAttentionDetails(BaseModel): + """Plain-language evidence and manual next steps for one grouped finding.""" + + title: str + series_count: int = Field(ge=0) + location_count: int = Field(ge=0) + known_paths: list[str] = Field(default_factory=list, max_length=6) + steps: list[str] = Field(default_factory=list, min_length=1, max_length=6) + + +class MylarPathAttentionItem(BaseModel): + """One grouped issue that Step 1 can resolve or explain.""" + + key: str = Field(min_length=64, max_length=64) + code: str + blocks_import: bool + reason: str + suggested_action: str + root_path: str | None = None + action: MylarPathAttentionAction | None = None + details: MylarPathAttentionDetails + + class MylarIdentityGroupPreview(BaseModel): """Identity-resolved paths grouped under one enabled root.""" @@ -146,6 +196,9 @@ class MylarPathPreviewResponse(BaseModel): unresolved_fingerprint: str | None = None exception_count: int = 0 exceptions: list[MylarPathException] = Field(default_factory=list) + problem_groups: list[MylarPathProblemGroup] = Field(default_factory=list) + attention_items: list[MylarPathAttentionItem] = Field(default_factory=list) + attention_fingerprint: str | None = None report_id: str | None = None blocking_reasons: list[str] = Field(default_factory=list) partial: bool = False diff --git a/src/pullbox/services/dashboard_metrics.py b/src/pullbox/services/dashboard_metrics.py index 7e9b131..2126707 100644 --- a/src/pullbox/services/dashboard_metrics.py +++ b/src/pullbox/services/dashboard_metrics.py @@ -719,7 +719,7 @@ async def load_unmatched_clusters(self) -> tuple[FailureCluster, ...]: detail=f"{count} files still need a clean match.", count=count, cta_label="Review unmatched", - cta_href="/import?tab=unmatched", + cta_href="/import?tab=follow-up", state="watch", ) ) diff --git a/src/pullbox/services/dashboard_priorities.py b/src/pullbox/services/dashboard_priorities.py index 9b85164..985c060 100644 --- a/src/pullbox/services/dashboard_priorities.py +++ b/src/pullbox/services/dashboard_priorities.py @@ -363,5 +363,5 @@ def build_unmatched_growth_priority( snapshot.computed_at, ), cta_label="Review unmatched", - cta_href="/import?tab=unmatched", + cta_href="/import?tab=follow-up", ) diff --git a/src/pullbox/services/import_catalog_hydration.py b/src/pullbox/services/import_catalog_hydration.py index 2e0a687..3265d07 100644 --- a/src/pullbox/services/import_catalog_hydration.py +++ b/src/pullbox/services/import_catalog_hydration.py @@ -4,11 +4,14 @@ import asyncio from dataclasses import dataclass +from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING, Any import structlog +from sqlalchemy import func, or_, update from sqlalchemy import select as sa_select +from pullbox.core.exceptions import ProviderError from pullbox.core.library_root_resolution import preferred_managed_root_id from pullbox.models.series import IssueCatalogState, Series from pullbox.services.import_metadata_priority import catalog_metadata_work @@ -24,9 +27,12 @@ logger = structlog.get_logger(__name__) -COMICVINE_BULK_BATCH_SIZE = 5_000 +COMICVINE_BULK_BATCH_SIZE = 100 +CATALOG_HYDRATION_RETRY_DELAY = timedelta(hours=2) +_RETRYABLE_ERROR_PREFIX = "Retryable ComicVine hydration failure: " catalog_hydration_tasks: set[asyncio.Task[None]] = set() +catalog_hydration_retry_tasks: set[asyncio.Task[None]] = set() _catalog_hydration_semaphore: asyncio.Semaphore | None = None _catalog_hydration_semaphore_loop: asyncio.AbstractEventLoop | None = None @@ -44,6 +50,30 @@ class PendingCatalogHydration: search_on_add: bool +class _CatalogHydrationDeferredError(Exception): + """Stop the current drain while a shared provider outage cools down.""" + + +def _is_retryable_provider_error(exc: Exception) -> bool: + if isinstance(exc, ProviderError): + details = exc.details if isinstance(exc.details, dict) else {} + status_code = details.get("status_code") + return bool(details.get("retryable")) or status_code in {408, 420, 429} + status_code = getattr(exc, "status_code", None) + return bool(getattr(exc, "retryable", False)) or status_code in {408, 420, 429} + + +def _is_retryable_error_text(error: str | None) -> bool: + normalized = (error or "").casefold() + return bool( + normalized.startswith(_RETRYABLE_ERROR_PREFIX.casefold()) + or "http 420" in normalized + or "http 429" in normalized + or "rate limit" in normalized + or "rate limited" in normalized + ) + + def reset_catalog_hydration_gate() -> None: """Reset the app-local hydration gate for tests and loop restarts.""" global _catalog_hydration_semaphore, _catalog_hydration_semaphore_loop @@ -63,6 +93,9 @@ def schedule_catalog_hydration( return if _catalog_hydration_methods(series_service) is None: return + for retry_task in tuple(catalog_hydration_retry_tasks): + if not retry_task.done(): + retry_task.cancel() if any(not task.done() for task in catalog_hydration_tasks): return @@ -82,6 +115,10 @@ async def run_hydration() -> None: return await asyncio.sleep(0) if not await load_pending_catalog_hydration(session_factory, limit=1): + await ensure_catalog_hydration_retry_scheduled( + session_factory, + series_service=series_service, + ) return task = asyncio.create_task(run_hydration()) @@ -89,6 +126,35 @@ async def run_hydration() -> None: task.add_done_callback(catalog_hydration_tasks.discard) +async def ensure_catalog_hydration_retry_scheduled( + session_factory: async_sessionmaker[AsyncSession], + *, + series_service: Any, +) -> None: + """Wake the catalog drain when the shared provider cooldown expires.""" + retry_delay = await load_catalog_hydration_retry_delay(session_factory) + if retry_delay is None: + return + if any(not task.done() for task in catalog_hydration_retry_tasks): + return + + async def retry_after_cooldown() -> None: + await asyncio.sleep(retry_delay) + current_task = asyncio.current_task() + if current_task is not None: + catalog_hydration_retry_tasks.discard(current_task) + schedule_catalog_hydration( + session_factory, + series_service=series_service, + series_id=0, + search_on_add=False, + ) + + task = asyncio.create_task(retry_after_cooldown()) + catalog_hydration_retry_tasks.add(task) + task.add_done_callback(catalog_hydration_retry_tasks.discard) + + async def run_pending_catalog_hydration( session_factory: async_sessionmaker[AsyncSession], *, @@ -128,6 +194,18 @@ async def run_pending_catalog_hydration( add_from_comicvine_prefetched=add_from_comicvine_prefetched, ) except Exception as exc: + if _is_retryable_provider_error(exc): + await mark_catalog_hydration_deferred( + session_factory, + series_ids=[request.series_id], + error=str(exc), + ) + logger.warning( + "import_catalog_hydration_provider_deferred", + series_id=request.series_id, + error=str(exc), + ) + break await mark_catalog_hydration_failed( session_factory, series_id=request.series_id, @@ -206,13 +284,16 @@ async def _run_pending_catalog_hydration_batch( recovered = 0 for start in range(0, len(planned), COMICVINE_BULK_BATCH_SIZE): - recovered += await _run_planned_catalog_hydration_batch( - session_factory, - planned=planned[start : start + COMICVINE_BULK_BATCH_SIZE], - batch_methods=batch_methods, - add_from_comicvine_prefetched=add_from_comicvine_prefetched, - priority=priority, - ) + try: + recovered += await _run_planned_catalog_hydration_batch( + session_factory, + planned=planned[start : start + COMICVINE_BULK_BATCH_SIZE], + batch_methods=batch_methods, + add_from_comicvine_prefetched=add_from_comicvine_prefetched, + priority=priority, + ) + except _CatalogHydrationDeferredError: + break return recovered @@ -235,6 +316,20 @@ async def _run_planned_catalog_hydration_batch( try: profiles = await prefetch_profiles(comicvine_ids) except Exception as exc: + if _is_retryable_provider_error(exc): + await mark_catalog_hydration_deferred( + session_factory, + series_ids=[request.series_id for request, _plan in planned], + error=str(exc), + ) + for _request, _plan in planned: + await priority.complete_one() + logger.warning( + "import_catalog_hydration_provider_deferred", + affected_series=len(planned), + error=str(exc), + ) + raise _CatalogHydrationDeferredError from exc for request, _plan in planned: await mark_catalog_hydration_failed( session_factory, @@ -280,6 +375,20 @@ async def _run_planned_catalog_hydration_batch( try: catalogs = await prefetch_catalogs([plan.comicvine_id for _request, plan in eligible]) except Exception as exc: + if _is_retryable_provider_error(exc): + await mark_catalog_hydration_deferred( + session_factory, + series_ids=[request.series_id for request, _plan in eligible], + error=str(exc), + ) + for _request, _plan in eligible: + await priority.complete_one() + logger.warning( + "import_catalog_hydration_provider_deferred", + affected_series=len(eligible), + error=str(exc), + ) + raise _CatalogHydrationDeferredError from exc for request, _plan in eligible: await mark_catalog_hydration_failed( session_factory, @@ -325,10 +434,33 @@ async def load_pending_catalog_hydration( ) -> list[PendingCatalogHydration]: """Load incomplete catalog rows that should resume after process restart.""" async with session_factory() as session: + retry_cutoff = datetime.now(UTC) - CATALOG_HYDRATION_RETRY_DELAY + recent_provider_pause = await session.scalar( + sa_select(Series.id) + .where( + Series.issue_catalog_state == IssueCatalogState.HYDRATING, + Series.issue_catalog_error.startswith(_RETRYABLE_ERROR_PREFIX), + Series.issue_catalog_last_checked_at.isnot(None), + Series.issue_catalog_last_checked_at > retry_cutoff, + ) + .limit(1) + ) + if recent_provider_pause is not None: + return [] + + retryable_failure = or_( + Series.issue_catalog_error.startswith(_RETRYABLE_ERROR_PREFIX), + Series.issue_catalog_error.ilike("%HTTP 420%"), + Series.issue_catalog_error.ilike("%HTTP 429%"), + Series.issue_catalog_error.ilike("%rate limit%"), + ) stmt = ( sa_select(Series.id, Series.monitored) .where( - Series.issue_catalog_state == IssueCatalogState.HYDRATING, + or_( + Series.issue_catalog_state == IssueCatalogState.HYDRATING, + ((Series.issue_catalog_state == IssueCatalogState.FAILED) & retryable_failure), + ), Series.comicvine_id.isnot(None), ) .order_by(Series.id.asc()) @@ -345,6 +477,27 @@ async def load_pending_catalog_hydration( ] +async def load_catalog_hydration_retry_delay( + session_factory: async_sessionmaker[AsyncSession], +) -> float | None: + """Return seconds until the earliest active provider cooldown expires.""" + now = datetime.now(UTC) + retry_cutoff = now - CATALOG_HYDRATION_RETRY_DELAY + async with session_factory() as session: + paused_at = await session.scalar( + sa_select(func.min(Series.issue_catalog_last_checked_at)).where( + Series.issue_catalog_state == IssueCatalogState.HYDRATING, + Series.issue_catalog_error.startswith(_RETRYABLE_ERROR_PREFIX), + Series.issue_catalog_last_checked_at.isnot(None), + Series.issue_catalog_last_checked_at > retry_cutoff, + ) + ) + if paused_at is None: + return None + retry_at = paused_at + CATALOG_HYDRATION_RETRY_DELAY + return max(0.0, (retry_at - now).total_seconds()) + + def catalog_hydration_gate() -> asyncio.Semaphore: """Return the app-local lane for background catalog hydration.""" global _catalog_hydration_semaphore, _catalog_hydration_semaphore_loop @@ -453,7 +606,15 @@ async def load_catalog_hydration_plan( series = await session.get(Series, series_id) if series is None: return None - if only_if_hydrating and series.issue_catalog_state != IssueCatalogState.HYDRATING: + retryable_failure = ( + series.issue_catalog_state == IssueCatalogState.FAILED + and _is_retryable_error_text(series.issue_catalog_error) + ) + if ( + only_if_hydrating + and series.issue_catalog_state != IssueCatalogState.HYDRATING + and not retryable_failure + ): return None if series.comicvine_id is None: msg = "Series has no ComicVine ID" @@ -472,6 +633,29 @@ async def load_catalog_hydration_plan( ) +async def mark_catalog_hydration_deferred( + session_factory: async_sessionmaker[AsyncSession], + *, + series_ids: list[int], + error: str, +) -> None: + """Keep transient provider failures resumable and persist a shared cooldown marker.""" + if not series_ids: + return + async with session_factory() as session: + await session.execute( + update(Series) + .where(Series.id.in_(series_ids)) + .values( + issue_catalog_state=IssueCatalogState.HYDRATING, + issue_catalog_error=f"{_RETRYABLE_ERROR_PREFIX}{error}", + issue_catalog_last_synced_at=None, + issue_catalog_last_checked_at=datetime.now(UTC), + ) + ) + await session.commit() + + async def mark_catalog_hydration_failed( session_factory: async_sessionmaker[AsyncSession], *, diff --git a/src/pullbox/services/import_completed_cleanup.py b/src/pullbox/services/import_completed_cleanup.py index 7a90386..aceaf7e 100644 --- a/src/pullbox/services/import_completed_cleanup.py +++ b/src/pullbox/services/import_completed_cleanup.py @@ -10,11 +10,13 @@ from typing import TYPE_CHECKING, Any, Final from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer -from sqlalchemy import case, exists, func, or_, select, update +from sqlalchemy import and_, case, exists, func, or_, select, update from sqlalchemy.orm import aliased from pullbox.core.config_resolver import get_application_secret from pullbox.core.exceptions import NotFoundError, ValidationError +from pullbox.core.issue_numbers import parse_issue_number_text +from pullbox.core.name_matcher import NameMatcher from pullbox.models.audit_log import AuditEventType from pullbox.models.import_job import ( ImportControlRequest, @@ -26,11 +28,16 @@ ImportJobStatus, ImportSeriesStatus, ) -from pullbox.models.library import LibraryFile +from pullbox.models.issue import Issue, IssueStatus +from pullbox.models.library import LibraryFile, LibraryFileStorageMode +from pullbox.models.series import Series from pullbox.services.audit_service import AuditService from pullbox.services.import_counters import recompute_file_counters, recompute_series_counters from pullbox.services.import_review_actions import apply_safety_allow_once_to_file from pullbox.services.import_safety_diagnostics import ImportSafetyCategory +from pullbox.services.import_story_arc_resolution import ( + refresh_story_arc_entries_for_import_files, +) if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession @@ -53,6 +60,7 @@ class CompletedImportCleanupAction(enum.StrEnum): RETRY_SOURCE_INSPECTION = "retry_source_inspection" NORMALIZE_ALREADY_OWNED = "normalize_already_owned" ACCEPT_RECOMMENDED_CONFLICTS = "accept_recommended_conflicts" + RESOLVE_MIXED_FOLDER_FILES = "resolve_mixed_folder_files" @dataclass(frozen=True, slots=True) @@ -103,6 +111,37 @@ class CompletedImportCleanupFilePage: total_pages: int +@dataclass(frozen=True, slots=True) +class CompletedImportCleanupSummary: + """Counts and examples for one results-page recovery card.""" + + affected_count: int + affected_file_count: int + examples: tuple[str, ...] + + +@dataclass(frozen=True, slots=True) +class _MixedFolderResolution: + """One exact, source-preserving mixed-folder ownership correction.""" + + file_id: int + source_import_series_id: int + source_import_series_name: str + target_series_id: int + target_series_title: str + target_issue_id: int + target_issue_cv_id: int | None + target_issue_number: float + target_issue_number_text: str + target_library_file_id: int | None + source_library_file_id: int | None + source_issue_id: int | None + source_library_updated_at: str | None + evidence_source: str + source_series_name: str + source_updated_at: str + + def _serializer() -> URLSafeTimedSerializer: return URLSafeTimedSerializer(get_application_secret(), salt=_PREVIEW_TOKEN_SALT) @@ -115,6 +154,14 @@ def _overrideable_expression() -> Any: return ImportedFile.diagnostics["safety_block"]["overrideable"].as_boolean() +def _source_revalidation_category_expression() -> Any: + return ImportedFile.diagnostics["source_revalidation"]["category"].as_string() + + +def _source_revalidation_retryable_expression() -> Any: + return ImportedFile.diagnostics["source_revalidation"]["retryable"].as_boolean() + + def _safety_filter(*categories: ImportSafetyCategory) -> Any: return _category_expression().in_([category.value for category in categories]) @@ -227,15 +274,26 @@ def _file_filters(job_id: int, action: CompletedImportCleanupAction) -> tuple[An ] ) elif action is CompletedImportCleanupAction.RETRY_SOURCE_INSPECTION: - filters.extend( - [ - ImportedFile.status == ImportedFileStatus.SAFETY_BLOCKED, - _safety_filter( - ImportSafetyCategory.PERMISSION_UNREADABLE, - ImportSafetyCategory.ARCHIVE_INSPECTION_FAILED, - ImportSafetyCategory.SOURCE_CHANGED, + retryable_categories = [ + category.value + for category in ( + ImportSafetyCategory.PERMISSION_UNREADABLE, + ImportSafetyCategory.ARCHIVE_INSPECTION_FAILED, + ImportSafetyCategory.SOURCE_CHANGED, + ) + ] + filters.append( + or_( + and_( + ImportedFile.status == ImportedFileStatus.SAFETY_BLOCKED, + _category_expression().in_(retryable_categories), ), - ] + and_( + ImportedFile.status == ImportedFileStatus.FAILED, + _source_revalidation_category_expression().in_(retryable_categories), + _source_revalidation_retryable_expression().is_(True), + ), + ) ) elif action is CompletedImportCleanupAction.NORMALIZE_ALREADY_OWNED: filters.extend( @@ -252,11 +310,325 @@ def _file_filters(job_id: int, action: CompletedImportCleanupAction) -> tuple[An ImportedFile.conflict_group_id.in_(_eligible_conflict_groups(job_id)), ] ) + elif action is CompletedImportCleanupAction.RESOLVE_MIXED_FOLDER_FILES: + filters.append( + ImportedFile.status.in_((ImportedFileStatus.NO_MATCH, ImportedFileStatus.IMPORTED)) + ) else: # pragma: no cover - exhaustive enum guard raise ValidationError("Unsupported completed-import cleanup action.") return tuple(filters) +def _mapping(value: object) -> Mapping[str, object]: + return value if isinstance(value, Mapping) else {} + + +def _safe_int(value: object) -> int | None: + if not isinstance(value, str | bytes | bytearray | int | float): + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _mixed_folder_source_identity( + imported_file: ImportedFile, +) -> tuple[str, str | float | int, int | None, int | None, str] | None: + """Return only embedded or sidecar identity strong enough for bulk correction.""" + diagnostics = _mapping(imported_file.diagnostics) + signals = _mapping(diagnostics.get("metadata_signals")) + series_signal = str(signals.get("series_name") or "") + issue_signal = str(signals.get("issue_number") or "") + trusted_signals = {"comicinfo", "sidecar"} + if series_signal not in trusted_signals or issue_signal not in trusted_signals: + return None + + source_metadata = _mapping(diagnostics.get("source_metadata")) + comicinfo = _mapping(source_metadata.get("comicinfo")) + if series_signal == "comicinfo": + source_series_name = str(comicinfo.get("series") or "").strip() + else: + source_series_name = str(imported_file.parsed_series or "").strip() + if issue_signal == "comicinfo": + source_issue_number = comicinfo.get("number") + else: + source_issue_number = imported_file.issue_number_raw or imported_file.parsed_issue_number + if not source_series_name or not isinstance(source_issue_number, str | float | int): + return None + + trusted_series_cv_id = ( + _safe_int(diagnostics.get("comicvine_series_id")) + if str(signals.get("comicvine_series_id") or "") in trusted_signals + else None + ) + trusted_issue_cv_id = ( + imported_file.comicvine_issue_id + if str(signals.get("comicvine_issue_id") or "") in trusted_signals + else None + ) + return ( + source_series_name, + source_issue_number, + trusted_series_cv_id, + trusted_issue_cv_id, + series_signal, + ) + + +async def _load_mixed_folder_resolutions( + session: AsyncSession, + job_id: int, +) -> tuple[_MixedFolderResolution, ...]: + """Resolve exact local targets without provider calls or source-file access.""" + source_rows = ( + await session.execute( + select(ImportedFile, ImportedSeries) + .join(ImportedSeries, ImportedSeries.id == ImportedFile.import_series_id) + .where( + ImportedFile.import_job_id == job_id, + ImportedFile.status.in_((ImportedFileStatus.NO_MATCH, ImportedFileStatus.IMPORTED)), + ) + .order_by(ImportedFile.id) + ) + ).all() + if not source_rows: + return () + + current_library_by_file_id: dict[int, LibraryFile] = {} + imported_library_ids = { + int(imported_file.library_file_id) + for imported_file, _imported_series in source_rows + if imported_file.status is ImportedFileStatus.IMPORTED + and imported_file.library_file_id is not None + } + if imported_library_ids: + current_library_by_id = { + int(library_file.id): library_file + for library_file in ( + await session.scalars( + select(LibraryFile).where(LibraryFile.id.in_(imported_library_ids)) + ) + ).all() + } + for imported_file, _imported_series in source_rows: + if imported_file.library_file_id is None: + continue + library_file = current_library_by_id.get(int(imported_file.library_file_id)) + if ( + library_file is not None + and library_file.storage_mode is LibraryFileStorageMode.REFERENCED + and library_file.file_path == imported_file.file_path + and library_file.issue_id == imported_file.matched_issue_id + ): + current_library_by_file_id[int(imported_file.id)] = library_file + + source_candidates: list[ + tuple[ImportedFile, ImportedSeries, str, str, int | None, int | None, str] + ] = [] + normalized_titles: set[str] = set() + trusted_series_cv_ids: set[int] = set() + for imported_file, imported_series in source_rows: + if ( + imported_file.status is ImportedFileStatus.IMPORTED + and int(imported_file.id) not in current_library_by_file_id + ): + continue + identity = _mixed_folder_source_identity(imported_file) + if identity is None: + continue + source_title, raw_number, series_cv_id, issue_cv_id, evidence_source = identity + normalized_source_title = NameMatcher.normalize(source_title) + parent_title = imported_series.cv_title or imported_series.raw_series_name + if not normalized_source_title or normalized_source_title == NameMatcher.normalize( + parent_title + ): + continue + try: + _numeric_number, exact_number = parse_issue_number_text(raw_number) + except ValueError: + continue + source_candidates.append( + ( + imported_file, + imported_series, + source_title, + exact_number, + series_cv_id, + issue_cv_id, + evidence_source, + ) + ) + normalized_titles.add(normalized_source_title) + if series_cv_id is not None: + trusted_series_cv_ids.add(series_cv_id) + if not source_candidates: + return () + + local_series = list((await session.scalars(select(Series))).all()) + series_by_cv_id = { + int(series.comicvine_id): series + for series in local_series + if series.comicvine_id is not None and int(series.comicvine_id) in trusted_series_cv_ids + } + series_by_title: dict[str, list[Series]] = {} + for series in local_series: + normalized = NameMatcher.normalize(series.title) + if normalized in normalized_titles: + series_by_title.setdefault(normalized, []).append(series) + + candidate_targets: list[ + tuple[ImportedFile, ImportedSeries, str, str, str, Series, int | None] + ] = [] + target_series_ids: set[int] = set() + for ( + imported_file, + imported_series, + source_title, + exact_number, + series_cv_id, + issue_cv_id, + evidence_source, + ) in source_candidates: + target_series = series_by_cv_id.get(series_cv_id) if series_cv_id is not None else None + if target_series is None: + title_matches = series_by_title.get(NameMatcher.normalize(source_title), []) + if len(title_matches) != 1: + continue + target_series = title_matches[0] + if imported_series.series_id == target_series.id: + continue + candidate_targets.append( + ( + imported_file, + imported_series, + source_title, + exact_number, + evidence_source, + target_series, + issue_cv_id, + ) + ) + target_series_ids.add(int(target_series.id)) + if not candidate_targets: + return () + + target_issues = list( + ( + await session.scalars( + select(Issue).where(Issue.series_id.in_(sorted(target_series_ids))) + ) + ).all() + ) + issues_by_cv_id = { + int(issue.comicvine_id): issue for issue in target_issues if issue.comicvine_id is not None + } + issues_by_number: dict[tuple[int, str], list[Issue]] = {} + for issue in target_issues: + issues_by_number.setdefault( + (int(issue.series_id), issue.effective_issue_number_text), [] + ).append(issue) + + resolved_targets: list[tuple[ImportedFile, ImportedSeries, str, str, Series, Issue]] = [] + for ( + imported_file, + imported_series, + source_title, + exact_number, + evidence_source, + target_series, + issue_cv_id, + ) in candidate_targets: + target_issue: Issue | None = ( + issues_by_cv_id.get(issue_cv_id) if issue_cv_id is not None else None + ) + if target_issue is not None and target_issue.series_id != target_series.id: + target_issue = None + if target_issue is None: + number_matches = issues_by_number.get((int(target_series.id), exact_number), []) + if len(number_matches) != 1: + continue + target_issue = number_matches[0] + resolved_targets.append( + ( + imported_file, + imported_series, + source_title, + evidence_source, + target_series, + target_issue, + ) + ) + if not resolved_targets: + return () + + target_issue_ids = {int(item[5].id) for item in resolved_targets} + owned_files_by_issue_id: dict[int, list[int]] = {} + for library_file in ( + await session.scalars( + select(LibraryFile).where(LibraryFile.issue_id.in_(sorted(target_issue_ids))) + ) + ).all(): + if library_file.issue_id is not None: + owned_files_by_issue_id.setdefault(int(library_file.issue_id), []).append( + int(library_file.id) + ) + files_by_target_issue: dict[int, list[int]] = {} + for imported_file, _series, _title, _source, _target_series, issue in resolved_targets: + files_by_target_issue.setdefault(int(issue.id), []).append(int(imported_file.id)) + + resolutions: list[_MixedFolderResolution] = [] + for ( + imported_file, + imported_series, + source_title, + evidence_source, + target_series, + issue, + ) in resolved_targets: + existing_library_file_ids = owned_files_by_issue_id.get(int(issue.id), []) + if len(existing_library_file_ids) > 1: + continue + library_file_id = existing_library_file_ids[0] if existing_library_file_ids else None + if library_file_id is None and len(files_by_target_issue[int(issue.id)]) != 1: + continue + current_library_file = current_library_by_file_id.get(int(imported_file.id)) + resolutions.append( + _MixedFolderResolution( + file_id=int(imported_file.id), + source_import_series_id=int(imported_series.id), + source_import_series_name=imported_series.raw_series_name, + target_series_id=int(target_series.id), + target_series_title=target_series.title, + target_issue_id=int(issue.id), + target_issue_cv_id=( + int(issue.comicvine_id) if issue.comicvine_id is not None else None + ), + target_issue_number=float(issue.issue_number), + target_issue_number_text=issue.effective_issue_number_text, + target_library_file_id=library_file_id, + source_library_file_id=( + int(current_library_file.id) if current_library_file is not None else None + ), + source_issue_id=( + int(current_library_file.issue_id) + if current_library_file is not None + and current_library_file.issue_id is not None + else None + ), + source_library_updated_at=( + current_library_file.updated_at.isoformat(timespec="microseconds") + if current_library_file is not None + else None + ), + evidence_source=evidence_source, + source_series_name=source_title, + source_updated_at=imported_file.updated_at.isoformat(timespec="microseconds"), + ) + ) + return tuple(resolutions) + + async def _load_completed_job(session: AsyncSession, job_id: int) -> ImportJob: job = await session.get(ImportJob, job_id, populate_existing=True) if job is None: @@ -282,6 +654,31 @@ async def _load_snapshot( job_id: int, action: CompletedImportCleanupAction, ) -> CompletedImportCleanupSnapshot: + if action is CompletedImportCleanupAction.RESOLVE_MIXED_FOLDER_FILES: + resolutions = await _load_mixed_folder_resolutions(session, job_id) + digest = sha256() + for resolution in resolutions: + digest.update( + ( + f"{resolution.file_id}|{resolution.target_series_id}|" + f"{resolution.target_issue_id}|{resolution.target_library_file_id or 0}|" + f"{resolution.source_library_file_id or 0}|" + f"{resolution.source_library_updated_at or ''}|" + f"{resolution.source_updated_at}\n" + ).encode() + ) + file_ids = [resolution.file_id for resolution in resolutions] + return CompletedImportCleanupSnapshot( + affected_count=len(resolutions), + affected_file_count=len(resolutions), + min_file_id=min(file_ids) if file_ids else None, + max_file_id=max(file_ids) if file_ids else None, + max_updated_at=max( + (resolution.source_updated_at for resolution in resolutions), + default=None, + ), + scope_digest=digest.hexdigest(), + ) filters = _file_filters(job_id, action) aggregate = ( await session.execute( @@ -341,6 +738,9 @@ async def count_completed_import_cleanup_scope( action: CompletedImportCleanupAction, ) -> tuple[int, int]: """Return action and file counts without hashing the full preview scope.""" + if action is CompletedImportCleanupAction.RESOLVE_MIXED_FOLDER_FILES: + count = len(await _load_mixed_folder_resolutions(session, job_id)) + return count, count filters = _file_filters(job_id, action) file_count = int( (await session.scalar(select(func.count(ImportedFile.id)).where(*filters))) or 0 @@ -370,6 +770,31 @@ async def list_completed_import_cleanup_files( await _load_completed_job(session, job_id) normalized_page = max(1, int(page)) normalized_page_size = min(max(1, int(page_size)), 100) + if action is CompletedImportCleanupAction.RESOLVE_MIXED_FOLDER_FILES: + resolutions = await _load_mixed_folder_resolutions(session, job_id) + eligible_file_ids = [resolution.file_id for resolution in resolutions] + total = len(eligible_file_ids) + total_pages = max(1, (total + normalized_page_size - 1) // normalized_page_size) + normalized_page = min(normalized_page, total_pages) + page_file_ids = eligible_file_ids[ + (normalized_page - 1) * normalized_page_size : normalized_page * normalized_page_size + ] + items_by_id = { + int(item.id): item + for item in ( + await session.scalars( + select(ImportedFile).where(ImportedFile.id.in_(page_file_ids)) + ) + ).all() + } + items = tuple(items_by_id[file_id] for file_id in page_file_ids) + return CompletedImportCleanupFilePage( + items=items, + total=total, + page=normalized_page, + page_size=normalized_page_size, + total_pages=total_pages, + ) filters = _file_filters(job_id, action) total = int((await session.scalar(select(func.count(ImportedFile.id)).where(*filters))) or 0) total_pages = max(1, (total + normalized_page_size - 1) // normalized_page_size) @@ -402,6 +827,20 @@ async def list_completed_import_cleanup_examples( limit: int = _EXAMPLE_LIMIT, ) -> tuple[str, ...]: """Return sanitized example filenames without hydrating the full scope.""" + if action is CompletedImportCleanupAction.RESOLVE_MIXED_FOLDER_FILES: + resolutions = await _load_mixed_folder_resolutions(session, job_id) + file_ids = [resolution.file_id for resolution in resolutions[:limit]] + names_by_id = { + int(file_id): file_name + for file_id, file_name in ( + await session.execute( + select(ImportedFile.id, ImportedFile.file_name).where( + ImportedFile.id.in_(file_ids) + ) + ) + ).all() + } + return tuple(_safe_example_name(names_by_id[file_id]) for file_id in file_ids) names = ( await session.scalars( select(ImportedFile.file_name) @@ -413,6 +852,54 @@ async def list_completed_import_cleanup_examples( return tuple(_safe_example_name(name) for name in names) +async def summarize_completed_import_cleanup_scope( + session: AsyncSession, + job_id: int, + action: CompletedImportCleanupAction, + *, + example_limit: int = _EXAMPLE_LIMIT, +) -> CompletedImportCleanupSummary: + """Load a recovery-card summary without resolving mixed folders twice.""" + normalized_limit = min(max(1, int(example_limit)), 10) + if action is not CompletedImportCleanupAction.RESOLVE_MIXED_FOLDER_FILES: + affected_count, affected_file_count = await count_completed_import_cleanup_scope( + session, + job_id, + action, + ) + examples = ( + await list_completed_import_cleanup_examples( + session, + job_id, + action, + limit=normalized_limit, + ) + if affected_count + else () + ) + return CompletedImportCleanupSummary( + affected_count=affected_count, + affected_file_count=affected_file_count, + examples=examples, + ) + + resolutions = await _load_mixed_folder_resolutions(session, job_id) + file_ids = [resolution.file_id for resolution in resolutions[:normalized_limit]] + names_by_id = { + int(file_id): file_name + for file_id, file_name in ( + await session.execute( + select(ImportedFile.id, ImportedFile.file_name).where(ImportedFile.id.in_(file_ids)) + ) + ).all() + } + return CompletedImportCleanupSummary( + affected_count=len(resolutions), + affected_file_count=len(resolutions), + examples=tuple(_safe_example_name(names_by_id[file_id]) for file_id in file_ids), + ) + + def _snapshot_payload(snapshot: CompletedImportCleanupSnapshot) -> dict[str, object]: return { "affected_count": snapshot.affected_count, @@ -539,11 +1026,16 @@ def _mark_skipped(file: ImportedFile, *, action: CompletedImportCleanupAction) - def _prepare_source_retry(file: ImportedFile) -> None: diagnostics = dict(file.diagnostics or {}) raw_block = diagnostics.get("safety_block") - if not isinstance(raw_block, Mapping): + raw_revalidation = diagnostics.get("source_revalidation") + if isinstance(raw_block, Mapping): + retry_evidence = dict(raw_block) + diagnostics.pop("safety_block", None) + elif isinstance(raw_revalidation, Mapping) and raw_revalidation.get("retryable") is True: + retry_evidence = dict(raw_revalidation) + else: raise ValidationError("A selected source failure no longer has safety evidence.") - diagnostics.pop("safety_block", None) diagnostics["source_revalidation"] = { - **dict(raw_block), + **retry_evidence, "kind": "source_revalidation", "retryable": True, "source": "completed_import_cleanup", @@ -576,9 +1068,11 @@ async def _apply_file_action( if not files: break cursor = int(files[-1].id) + page_file_ids: list[int] = [] for file in files: affected_series_ids.add(int(file.import_series_id)) affected_file_ids.append(int(file.id)) + page_file_ids.append(int(file.id)) if action in { CompletedImportCleanupAction.DISMISS_MISSING_REFERENCES, CompletedImportCleanupAction.SKIP_PROBABLE_COVERS, @@ -597,6 +1091,11 @@ async def _apply_file_action( file.error_message = None else: # pragma: no cover - conflict groups use a separate path raise ValidationError("Unsupported file cleanup action.") + await refresh_story_arc_entries_for_import_files( + session, + import_job_id=job.id, + import_file_ids=page_file_ids, + ) await session.flush() return affected_series_ids, tuple(affected_file_ids), requires_import_retry @@ -659,6 +1158,239 @@ async def _apply_recommended_conflicts( return {int(series_id) for series_id in affected_series_ids} +async def _apply_mixed_folder_resolutions( + session: AsyncSession, + job: ImportJob, +) -> tuple[set[int], set[int]]: + """Rebucket exact embedded identities while preserving every source artifact.""" + resolutions = await _load_mixed_folder_resolutions(session, int(job.id)) + if not resolutions: + return set(), set() + + target_series_ids = {resolution.target_series_id for resolution in resolutions} + target_series_by_id = { + int(series.id): series + for series in ( + await session.scalars(select(Series).where(Series.id.in_(target_series_ids))) + ).all() + } + target_issue_by_id = { + int(issue.id): issue + for issue in ( + await session.scalars( + select(Issue).where( + Issue.id.in_({resolution.target_issue_id for resolution in resolutions}) + ) + ) + ).all() + } + source_library_by_id = { + int(library_file.id): library_file + for library_file in ( + await session.scalars( + select(LibraryFile).where( + LibraryFile.id.in_( + { + resolution.source_library_file_id + for resolution in resolutions + if resolution.source_library_file_id is not None + } + ) + ) + ) + ).all() + } + source_issue_by_id = { + int(issue.id): issue + for issue in ( + await session.scalars( + select(Issue).where( + Issue.id.in_( + { + resolution.source_issue_id + for resolution in resolutions + if resolution.source_issue_id is not None + } + ) + ) + ) + ).all() + } + source_series_by_id = { + int(series.id): series + for series in ( + await session.scalars( + select(Series).where( + Series.id.in_({issue.series_id for issue in source_issue_by_id.values()}) + ) + ) + ).all() + } + source_issue_owner_counts = { + int(issue_id): int(owner_count) + for issue_id, owner_count in ( + await session.execute( + select(LibraryFile.issue_id, func.count(LibraryFile.id)) + .where(LibraryFile.issue_id.in_(set(source_issue_by_id))) + .group_by(LibraryFile.issue_id) + ) + ).all() + if issue_id is not None + } + imported_target_by_series_id: dict[int, ImportedSeries] = {} + existing_target_rows = list( + ( + await session.scalars( + select(ImportedSeries) + .where( + ImportedSeries.import_job_id == job.id, + ImportedSeries.series_id.in_(target_series_ids), + ) + .order_by(ImportedSeries.id) + ) + ).all() + ) + for imported_series in existing_target_rows: + if imported_series.series_id is not None: + imported_target_by_series_id.setdefault(int(imported_series.series_id), imported_series) + + affected_series_ids: set[int] = set() + retry_series_ids: set[int] = set() + affected_file_ids: list[int] = [] + for resolution in resolutions: + target_import_series = imported_target_by_series_id.get(resolution.target_series_id) + if target_import_series is None: + target_series = target_series_by_id[resolution.target_series_id] + target_import_series = ImportedSeries( + import_job_id=job.id, + raw_series_name=target_series.title, + raw_year=target_series.year_start, + file_count=0, + sample_paths=[], + has_files=True, + cv_id=target_series.comicvine_id, + cv_title=target_series.title, + cv_year=target_series.year_start, + cv_match_score=1.0, + cv_match_method="completed_import_mixed_folder_recovery", + status=ImportSeriesStatus.DUPLICATE, + selected_for_import=False, + series_id=target_series.id, + diagnostics={ + "kind": "completed_import_mixed_folder_recovery", + "existing_series_id": target_series.id, + "source_preserved": True, + }, + ) + session.add(target_import_series) + await session.flush() + imported_target_by_series_id[resolution.target_series_id] = target_import_series + + imported_file = await session.get(ImportedFile, resolution.file_id) + if imported_file is None: # pragma: no cover - signed snapshot guards deletion + raise ValidationError("A mixed-folder file disappeared. Preview the action again.") + affected_file_ids.append(int(imported_file.id)) + diagnostics = dict(imported_file.diagnostics or {}) + diagnostics["completed_import_cleanup"] = { + "action": CompletedImportCleanupAction.RESOLVE_MIXED_FOLDER_FILES.value, + "resolved_at": datetime.now(UTC).isoformat(), + "source_preserved": True, + "evidence_source": resolution.evidence_source, + "source_import_series_id": resolution.source_import_series_id, + "source_import_series_name": resolution.source_import_series_name, + "source_series_name": resolution.source_series_name, + "target_import_series_id": target_import_series.id, + "target_series_id": resolution.target_series_id, + "target_series_title": resolution.target_series_title, + "target_issue_id": resolution.target_issue_id, + "target_issue_number": resolution.target_issue_number_text, + } + imported_file.import_series_id = int(target_import_series.id) + imported_file.parsed_series = resolution.target_series_title + imported_file.parsed_issue_number = resolution.target_issue_number + imported_file.issue_number_raw = resolution.target_issue_number_text + imported_file.matched_issue_id = resolution.target_issue_id + imported_file.matched_issue_cv_id = resolution.target_issue_cv_id + imported_file.match_confidence = "high" + imported_file.match_method = "completed_import_metadata_reassignment" + imported_file.conflict_group_id = None + imported_file.duplicate_group_id = None + imported_file.duplicate_of_file_id = None + imported_file.is_preferred = False + imported_file.error_message = None + imported_file.diagnostics = diagnostics + source_library_file = ( + source_library_by_id.get(resolution.source_library_file_id) + if resolution.source_library_file_id is not None + else None + ) + target_issue = target_issue_by_id[resolution.target_issue_id] + if source_library_file is not None: + source_issue_id_before = source_library_file.issue_id + previous_issue = ( + source_issue_by_id.get(resolution.source_issue_id) + if resolution.source_issue_id is not None + else None + ) + if source_library_file.issue_id == resolution.target_issue_id: + imported_file.status = ImportedFileStatus.IMPORTED + imported_file.include_in_import = False + imported_file.library_file_id = source_library_file.id + target_issue.status = IssueStatus.OWNED + elif ( + resolution.target_library_file_id is not None + and resolution.target_library_file_id != source_library_file.id + ): + imported_file.status = ImportedFileStatus.ALREADY_OWNED + imported_file.include_in_import = False + imported_file.library_file_id = resolution.target_library_file_id + await session.delete(source_library_file) + target_issue.status = IssueStatus.OWNED + else: + source_library_file.issue_id = resolution.target_issue_id + imported_file.status = ImportedFileStatus.IMPORTED + imported_file.include_in_import = False + imported_file.library_file_id = source_library_file.id + target_issue.status = IssueStatus.OWNED + if previous_issue is not None and previous_issue.id != resolution.target_issue_id: + previous_series = source_series_by_id.get(int(previous_issue.series_id)) + remaining_owners = source_issue_owner_counts.get(int(previous_issue.id), 0) + if source_issue_id_before == previous_issue.id: + remaining_owners = max(0, remaining_owners - 1) + source_issue_owner_counts[int(previous_issue.id)] = remaining_owners + if remaining_owners: + previous_issue.status = IssueStatus.OWNED + else: + previous_issue.status = ( + IssueStatus.WANTED + if previous_series is not None and previous_series.monitored + else IssueStatus.SKIPPED + ) + elif resolution.target_library_file_id is not None: + imported_file.status = ImportedFileStatus.ALREADY_OWNED + imported_file.include_in_import = False + imported_file.library_file_id = resolution.target_library_file_id + target_issue.status = IssueStatus.OWNED + else: + imported_file.status = ImportedFileStatus.CONFIRMED + imported_file.include_in_import = True + retry_series_ids.add(int(target_import_series.id)) + target_import_series.status = ImportSeriesStatus.DUPLICATE + target_import_series.selected_for_import = True + target_import_series.error_message = None + + affected_series_ids.update( + {resolution.source_import_series_id, int(target_import_series.id)} + ) + await refresh_story_arc_entries_for_import_files( + session, + import_job_id=job.id, + import_file_ids=affected_file_ids, + ) + await session.flush() + return affected_series_ids, retry_series_ids + + async def _prepare_series_for_retry( session: AsyncSession, job: ImportJob, @@ -719,6 +1451,10 @@ async def apply_completed_import_cleanup( affected_series_ids = await _apply_recommended_conflicts(session, job) affected_file_ids: tuple[int, ...] = () requires_import_retry = await _prepare_series_for_retry(session, job, affected_series_ids) + elif action is CompletedImportCleanupAction.RESOLVE_MIXED_FOLDER_FILES: + affected_series_ids, retry_series_ids = await _apply_mixed_folder_resolutions(session, job) + affected_file_ids = () + requires_import_retry = await _prepare_series_for_retry(session, job, retry_series_ids) else: affected_series_ids, affected_file_ids, requires_import_retry = await _apply_file_action( session, job, action diff --git a/src/pullbox/services/import_confirm_policy.py b/src/pullbox/services/import_confirm_policy.py index f10ec2a..4932d2a 100644 --- a/src/pullbox/services/import_confirm_policy.py +++ b/src/pullbox/services/import_confirm_policy.py @@ -13,6 +13,7 @@ from pullbox.models.import_job import ImportFileHandlingMode from pullbox.models.library import LibraryRoot from pullbox.services.import_policy_snapshot import apply_ingest_policy_to_import_job +from pullbox.services.import_retry_helpers import require_retained_import_destination from pullbox.services.import_root_policy_activation import ( apply_future_root_policy_to_ingest_policy, ) @@ -31,6 +32,7 @@ async def apply_confirm_import_policy( request: ConfirmImportRequest, ) -> None: """Apply global import policy and confirm-time overrides to an import job.""" + require_retained_import_destination(job) if request.monitored is not None: job.monitored = request.monitored if request.target_library_root_id is not None: diff --git a/src/pullbox/services/import_content_inspection.py b/src/pullbox/services/import_content_inspection.py index 6872337..96ed554 100644 --- a/src/pullbox/services/import_content_inspection.py +++ b/src/pullbox/services/import_content_inspection.py @@ -4,6 +4,8 @@ from typing import TYPE_CHECKING +import structlog + from pullbox.core.archive import ArchiveError, ArchiveReader from pullbox.core.file_safety import FileSafetyError from pullbox.core.page_sources.base import canonical_page_names @@ -15,6 +17,9 @@ from pullbox.core.file_safety import FileSafetyInspection +logger = structlog.get_logger(__name__) + + def inspect_import_content(path: Path, inspection: FileSafetyInspection) -> dict[str, object]: """Reuse ZIP inventory; other comic archives need only their member headers. @@ -30,6 +35,12 @@ def inspect_import_content(path: Path, inspection: FileSafetyInspection) -> dict try: members = ArchiveReader(path).list_members() except ArchiveError as exc: + logger.warning( + "import_archive_content_inspection_failed", + file_name=path.name, + error=str(exc), + error_type=type(exc).__name__, + ) raise FileSafetyError("Archive inspection failed", details=[str(path)]) from exc page_count = len( canonical_page_names( diff --git a/src/pullbox/services/import_duplicate_copies.py b/src/pullbox/services/import_duplicate_copies.py index d0e7b07..caf1974 100644 --- a/src/pullbox/services/import_duplicate_copies.py +++ b/src/pullbox/services/import_duplicate_copies.py @@ -30,7 +30,7 @@ def __call__( ContentHashFunc = Callable[[str], str | None] DuplicateSeriesPredicate = Callable[[ImportedSeries | None], bool] DuplicateTargetKeyFunc = Callable[[ImportedFile], tuple[str, int | float] | None] -FileSortKeyFunc = Callable[[ImportedFile], tuple[int, int, int, int]] +FileSortKeyFunc = Callable[[ImportedFile], tuple[int, int, int, int, int]] NormalizedReleaseNameFunc = Callable[[str], str] diff --git a/src/pullbox/services/import_duplicates.py b/src/pullbox/services/import_duplicates.py index 20809c0..0a92144 100644 --- a/src/pullbox/services/import_duplicates.py +++ b/src/pullbox/services/import_duplicates.py @@ -208,9 +208,14 @@ def _raw_name_has_scanner_suffix(raw_name: str | None, cv_title: str | None) -> return bool(_SCANNER_STYLE_SUFFIX_RE.fullmatch(suffix)) -def preferred_file_sort_key(item: ImportedFile) -> tuple[int, int, int, int]: +def preferred_file_sort_key(item: ImportedFile) -> tuple[int, int, int, int, int]: """Prefer richer metadata, stronger confidence, larger files, then older IDs.""" + cross_folder = dict(item.diagnostics or {}).get("mylar3_cross_folder_reconciliation") + canonical_mylar_reference = ( + isinstance(cross_folder, dict) and cross_folder.get("role") == "canonical" + ) return ( + 1 if canonical_mylar_reference else 0, 1 if item.has_comicinfo else 0, confidence_rank(item.match_confidence), item.file_size, diff --git a/src/pullbox/services/import_file_execution.py b/src/pullbox/services/import_file_execution.py index bac55bd..38240b7 100644 --- a/src/pullbox/services/import_file_execution.py +++ b/src/pullbox/services/import_file_execution.py @@ -107,6 +107,135 @@ class _PlaceholderIssueTarget: metadata_source: str +@dataclass(frozen=True, slots=True) +class _VerifiedLibraryAdoption: + rollback_snapshot: dict[str, object] + + +def _library_adoption_mapping(value: object) -> dict[str, object] | None: + if value is None: + return None + if not isinstance(value, dict): + raise ConfigurationError("Clean-library adoption evidence is invalid. Preview it again.") + return dict(value) + + +def _library_adoption_positive_int(value: object, label: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise ConfigurationError(f"Clean-library adoption {label} is invalid. Preview it again.") + return value + + +async def _load_verified_library_adoption( + session: AsyncSession, + *, + job: ImportJob, + imported_file: ImportedFile, + issue: Issue, +) -> _VerifiedLibraryAdoption | None: + evidence = _library_adoption_mapping( + dict(imported_file.diagnostics or {}).get("library_adoption") + ) + if evidence is None: + return None + if evidence.get("schema_version") != 1 or evidence.get("source_preserved") is not True: + raise ConfigurationError("Clean-library adoption evidence is invalid. Preview it again.") + if evidence.get("source_storage_mode") != LibraryFileStorageMode.REFERENCED.value: + raise ConfigurationError("Only referenced library files can be adopted safely.") + + source_imported_file_id = _library_adoption_positive_int( + evidence.get("source_imported_file_id"), + "source import file", + ) + source_library_file_id = _library_adoption_positive_int( + evidence.get("source_library_file_id"), + "source library file", + ) + source_library_root_id = _library_adoption_positive_int( + evidence.get("source_library_root_id"), + "source library root", + ) + source_job_id = _library_adoption_positive_int( + evidence.get("source_import_job_id"), + "source import job", + ) + if ( + job.file_handling_mode is not ImportFileHandlingMode.MANAGED_COPY + or not job.source_preserved + ): + raise ConfigurationError( + "Clean-library adoption evidence is attached to the wrong import job." + ) + source_path = evidence.get("source_path") + expected_signature = evidence.get("source_signature") + if not isinstance(source_path, str) or not source_path: + raise ConfigurationError("Clean-library adoption source path is invalid.") + if not isinstance(expected_signature, dict) or not expected_signature: + raise ConfigurationError("Clean-library adoption source evidence is missing.") + + source_imported_file = await session.get(ImportedFile, source_imported_file_id) + source_library_file = await session.get(LibraryFile, source_library_file_id) + source_series = await session.get(Series, issue.series_id) + if ( + source_imported_file is None + or source_imported_file.import_job_id != source_job_id + or source_imported_file.status is not ImportedFileStatus.IMPORTED + or source_imported_file.library_file_id != source_library_file_id + or source_imported_file.matched_issue_id != issue.id + or source_imported_file.file_path != source_path + or source_series is None + ): + raise ConfigurationError( + "The original import reference changed after preview. Preview it again." + ) + if ( + source_library_file is None + or source_library_file.storage_mode is not LibraryFileStorageMode.REFERENCED + or source_library_file.issue_id != issue.id + or source_library_file.library_root_id != source_library_root_id + or source_library_file.file_path != source_path + or imported_file.file_path != source_path + or dict(source_library_file.source_signature or {}) != expected_signature + ): + raise ConfigurationError( + "The referenced library file changed after preview. Preview it again." + ) + current_signature = await asyncio.to_thread( + build_file_identity_signature, + Path(source_path), + ) + validate_file_identity_signature(expected_signature, current_signature) + + return _VerifiedLibraryAdoption( + rollback_snapshot={ + "schema_version": 1, + "source_imported_file_id": source_imported_file_id, + "source_library_file_id": source_library_file_id, + "file_path": source_library_file.file_path, + "file_name": source_library_file.file_name, + "file_size": source_library_file.file_size, + "file_format": source_library_file.file_format.value, + "file_hash": source_library_file.file_hash, + "file_modified_at": source_library_file.file_modified_at.isoformat(), + "match_confidence": source_library_file.match_confidence.value, + "parsed_series": source_library_file.parsed_series, + "parsed_issue_number": source_library_file.parsed_issue_number, + "parsed_year": source_library_file.parsed_year, + "parsed_publisher": source_library_file.parsed_publisher, + "has_comicinfo": source_library_file.has_comicinfo, + "naming_snapshot": dict(source_library_file.naming_snapshot or {}), + "storage_mode": source_library_file.storage_mode.value, + "source_signature": dict(source_library_file.source_signature or {}), + "issue_id": source_library_file.issue_id, + "library_root_id": source_library_file.library_root_id, + "source_series_id": source_series.id, + "previous_series_path": source_series.path, + "previous_series_library_root_id": source_series.library_root_id, + "previous_series_preferred_library_root_id": (source_series.preferred_library_root_id), + } + ) + + def _cleanup_failed_library_artifact( *, destination_path: Path | None, @@ -1027,7 +1156,18 @@ async def report_current_file( imp_file.match_confidence or "", MatchConfidence.MEDIUM ) - if skip_existing_enabled and resolved_issue.id in owned_issue_ids: + library_adoption = await _load_verified_library_adoption( + session, + job=current_job, + imported_file=imp_file, + issue=resolved_issue, + ) + + if ( + library_adoption is None + and skip_existing_enabled + and resolved_issue.id in owned_issue_ids + ): imp_file.status = ImportedFileStatus.SKIPPED await log_event( session, @@ -1154,10 +1294,29 @@ async def report_current_file( ), recovery_imported_file_id=int(imp_file.id), recovery_original_source_path=Path(imp_file.file_path), + replace_existing_library_file=library_adoption is not None, + replacement_trash_dir=None, + preserve_replaced_artifact=library_adoption is not None, source_scan_root=source_scan_root, strict_import_target=not in_place, ) library_file, registration = _registration_outcome(registration_result) + if library_adoption is not None: + adopted_series = await session.get(Series, resolved_issue.series_id) + if adopted_series is None: # pragma: no cover - issue FK guarantees this + raise ConfigurationError("The clean-library series no longer exists.") + adopted_series.path = str(Path(library_file.file_path).parent) + adopted_series.library_root_id = library_file.library_root_id + adopted_series.preferred_library_root_id = library_file.library_root_id + library_adoption.rollback_snapshot.update( + { + "installed_series_path": adopted_series.path, + "installed_series_library_root_id": adopted_series.library_root_id, + "installed_series_preferred_library_root_id": ( + adopted_series.preferred_library_root_id + ), + } + ) library_file.has_comicinfo = bool( library_file.has_comicinfo or imp_file.has_comicinfo @@ -1305,6 +1464,9 @@ async def report_current_file( source_transfer_method if move_to_library else "leave_in_place" ), ), + "adopted_reference": ( + library_adoption.rollback_snapshot if library_adoption is not None else None + ), }, ) diff --git a/src/pullbox/services/import_file_execution_protocols.py b/src/pullbox/services/import_file_execution_protocols.py index 53a0511..ba00bbb 100644 --- a/src/pullbox/services/import_file_execution_protocols.py +++ b/src/pullbox/services/import_file_execution_protocols.py @@ -124,6 +124,9 @@ async def __call__( recovery_imported_file_id: int | None = None, recovery_original_source_path: Path | None = None, placement_started_callback: Callable[..., Awaitable[None] | None] | None = None, + replace_existing_library_file: bool = False, + replacement_trash_dir: Path | None = None, + preserve_replaced_artifact: bool = False, source_scan_root: Path | None = None, strict_import_target: bool = False, ) -> LibraryFile | LibraryFileRegistrationOutcome: ... diff --git a/src/pullbox/services/import_file_match_outcomes.py b/src/pullbox/services/import_file_match_outcomes.py index 1772a37..079743f 100644 --- a/src/pullbox/services/import_file_match_outcomes.py +++ b/src/pullbox/services/import_file_match_outcomes.py @@ -52,6 +52,7 @@ class FileMatchLogEvent: "issue_count_hint", "metadata_signals", "source_metadata", + "mylar3_cross_folder_reconciliation", ) diff --git a/src/pullbox/services/import_file_matching.py b/src/pullbox/services/import_file_matching.py index e64c636..b5283bc 100644 --- a/src/pullbox/services/import_file_matching.py +++ b/src/pullbox/services/import_file_matching.py @@ -1981,7 +1981,15 @@ def _persist_deferred_source_evidence( diagnostics["comicvine_series_id"] = metadata.comicvine_series_id imp_file.diagnostics = diagnostics imp_file.has_comicinfo = bool(metadata.diagnostics.get("has_comicinfo")) - if imp_file.comicvine_issue_id is None and metadata.comicvine_issue_id is not None: + issue_identity_reconciliation = metadata.diagnostics.get("mylar3_issue_identity_reconciliation") + if metadata.comicvine_issue_id is not None and ( + imp_file.comicvine_issue_id is None + or ( + isinstance(issue_identity_reconciliation, dict) + and issue_identity_reconciliation.get("embedded_comicvine_issue_id") + == metadata.comicvine_issue_id + ) + ): imp_file.comicvine_issue_id = metadata.comicvine_issue_id diff --git a/src/pullbox/services/import_job_actions.py b/src/pullbox/services/import_job_actions.py index d01a3ba..def26bf 100644 --- a/src/pullbox/services/import_job_actions.py +++ b/src/pullbox/services/import_job_actions.py @@ -17,7 +17,10 @@ from sqlalchemy import update as sa_update from pullbox.core.exceptions import ConfigurationError, NotFoundError -from pullbox.core.library_file_ownership import build_managed_placement_signature +from pullbox.core.library_file_ownership import ( + build_file_identity_signature, + build_managed_placement_signature, +) from pullbox.models.blocklist import BlocklistEntry from pullbox.models.direct_acquisition import DirectAcquisitionAttempt from pullbox.models.download import DownloadHistory @@ -30,7 +33,13 @@ ImportJobActionStatus, ) from pullbox.models.issue import Issue, IssueStatus -from pullbox.models.library import LibraryFile, LibraryFileStorageMode +from pullbox.models.library import ( + FileFormat, + LibraryFile, + LibraryFileStorageMode, + LibraryRoot, + MatchConfidence, +) from pullbox.models.pending_match import PendingMatch from pullbox.models.reader import IssueReaderState from pullbox.models.search_log import SearchLog @@ -88,6 +97,32 @@ class _ManagedPlacementRollbackPayload(TypedDict): source_import_job_id: int +@dataclass(frozen=True, slots=True) +class _AdoptedReferenceRollback: + source_imported_file: ImportedFile + source_library_file_id: int + file_path: str + file_name: str + file_size: int + file_format: FileFormat + file_hash: str | None + file_modified_at: datetime + match_confidence: MatchConfidence + parsed_series: str | None + parsed_issue_number: float | None + parsed_year: int | None + parsed_publisher: str | None + has_comicinfo: bool + naming_snapshot: dict[str, object] + source_signature: dict[str, object] + issue_id: int + library_root_id: int + series: Series + previous_series_path: str | None + previous_series_library_root_id: int | None + previous_series_preferred_library_root_id: int | None + + class StoryArcManagedPlacementRollbackDeferredError(RuntimeError): """A running placement must acknowledge cooperative cancellation first.""" @@ -478,6 +513,256 @@ def _series_issue_rollback_lock_statement(series_id: int) -> Any: ) +def _adopted_reference_string( + snapshot: dict[str, object], + key: str, + *, + optional: bool = False, +) -> str | None: + value = snapshot.get(key) + if value is None and optional: + return None + if not isinstance(value, str) or (not value and not optional): + raise ValueError(f"Invalid {key} in clean-library rollback action") + return value + + +def _adopted_reference_optional_int( + snapshot: dict[str, object], + key: str, +) -> int | None: + value = snapshot.get(key) + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"Invalid {key} in clean-library rollback action") + return value + + +def _adopted_reference_optional_float( + snapshot: dict[str, object], + key: str, +) -> float | None: + value = snapshot.get(key) + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, int | float): + raise ValueError(f"Invalid {key} in clean-library rollback action") + return float(value) + + +async def _prepare_adopted_reference_rollback( + session: AsyncSession, + *, + payload: dict[str, Any], + managed_library_file: LibraryFile | None, +) -> tuple[_AdoptedReferenceRollback | None, str | None]: + raw_snapshot = payload.get("adopted_reference") + if raw_snapshot is None: + return None, None + if not isinstance(raw_snapshot, dict) or raw_snapshot.get("schema_version") != 1: + raise ValueError("Invalid adopted_reference in clean-library rollback action") + snapshot: dict[str, object] = dict(raw_snapshot) + source_imported_file_id = _positive_int( + snapshot.get("source_imported_file_id"), + "source_imported_file_id", + ) + source_library_file_id = _positive_int( + snapshot.get("source_library_file_id"), + "source_library_file_id", + ) + issue_id = _positive_int(snapshot.get("issue_id"), "issue_id") + library_root_id = _positive_int(snapshot.get("library_root_id"), "library_root_id") + source_series_id = _positive_int(snapshot.get("source_series_id"), "source_series_id") + file_path = _adopted_reference_string(snapshot, "file_path") + file_name = _adopted_reference_string(snapshot, "file_name") + file_modified_at_raw = _adopted_reference_string(snapshot, "file_modified_at") + assert file_path is not None + assert file_name is not None + assert file_modified_at_raw is not None + file_size = _non_negative_int(snapshot.get("file_size"), "file_size") + try: + file_format = FileFormat(str(snapshot.get("file_format") or "")) + match_confidence = MatchConfidence(str(snapshot.get("match_confidence") or "")) + file_modified_at = datetime.fromisoformat(file_modified_at_raw) + except ValueError as exc: + raise ValueError("Invalid file metadata in clean-library rollback action") from exc + if file_modified_at.tzinfo is None: + raise ValueError("Invalid file_modified_at in clean-library rollback action") + file_hash = _adopted_reference_string(snapshot, "file_hash", optional=True) + parsed_series = _adopted_reference_string(snapshot, "parsed_series", optional=True) + parsed_publisher = _adopted_reference_string(snapshot, "parsed_publisher", optional=True) + parsed_issue_number = _adopted_reference_optional_float(snapshot, "parsed_issue_number") + parsed_year = _adopted_reference_optional_int(snapshot, "parsed_year") + previous_series_path = _adopted_reference_string( + snapshot, + "previous_series_path", + optional=True, + ) + previous_series_library_root_id = _adopted_reference_optional_int( + snapshot, + "previous_series_library_root_id", + ) + previous_series_preferred_library_root_id = _adopted_reference_optional_int( + snapshot, + "previous_series_preferred_library_root_id", + ) + installed_series_path = _adopted_reference_string(snapshot, "installed_series_path") + installed_series_library_root_id = _positive_int( + snapshot.get("installed_series_library_root_id"), + "installed_series_library_root_id", + ) + installed_series_preferred_library_root_id = _adopted_reference_optional_int( + snapshot, + "installed_series_preferred_library_root_id", + ) + assert installed_series_path is not None + has_comicinfo = snapshot.get("has_comicinfo") + naming_snapshot = snapshot.get("naming_snapshot") + source_signature = snapshot.get("source_signature") + if ( + snapshot.get("storage_mode") != LibraryFileStorageMode.REFERENCED.value + or not isinstance(has_comicinfo, bool) + or not isinstance(naming_snapshot, dict) + or not isinstance(source_signature, dict) + or not source_signature + ): + raise ValueError("Invalid reference metadata in clean-library rollback action") + + source_imported_file = await session.get(ImportedFile, source_imported_file_id) + source_root = await session.get(LibraryRoot, library_root_id) + issue = await session.get(Issue, issue_id) + series = await session.get(Series, source_series_id) + installed_series_path_matches = False + if managed_library_file is not None: + try: + installed_series_path_matches = Path(installed_series_path).resolve( + strict=False + ) == Path(managed_library_file.file_path).parent.resolve(strict=False) + except (OSError, RuntimeError): + installed_series_path_matches = False + occupied_source = await session.scalar( + sa_select(LibraryFile.id).where(LibraryFile.file_path == file_path).limit(1) + ) + occupied_id = await session.get(LibraryFile, source_library_file_id) + source_row_reused = ( + managed_library_file is not None and managed_library_file.id == source_library_file_id + ) + if ( + managed_library_file is None + or managed_library_file.issue_id != issue_id + or source_imported_file is None + or source_imported_file.status is not ImportedFileStatus.IMPORTED + or source_imported_file.matched_issue_id != issue_id + or source_imported_file.file_path != file_path + or source_imported_file.library_file_id not in {None, source_library_file_id} + or source_root is None + or issue is None + or issue.series_id != source_series_id + or series is None + or managed_library_file.library_root_id != installed_series_library_root_id + or not installed_series_path_matches + or occupied_source is not None + or (occupied_id is not None and not source_row_reused) + ): + return None, ( + "The original Mylar reference no longer matches its clean-library rollback " + "record. Pullbox preserved the managed file for review." + ) + installed_series_state = _series_matches_folder_state( + series, + path=installed_series_path, + library_root_id=installed_series_library_root_id, + preferred_library_root_id=installed_series_preferred_library_root_id, + ) + previous_series_state = _series_matches_folder_state( + series, + path=previous_series_path, + library_root_id=previous_series_library_root_id, + preferred_library_root_id=previous_series_preferred_library_root_id, + ) + if not installed_series_state and not previous_series_state: + return None, ( + "The series storage location changed after clean-library adoption. Pullbox " + "preserved the managed file for review." + ) + try: + current_signature = build_file_identity_signature(Path(file_path)) + except (ConfigurationError, OSError, RuntimeError, ValueError): + return None, ( + "The original Mylar source is unavailable. Pullbox preserved the managed file " + "for review." + ) + if current_signature != source_signature: + return None, ( + "The original Mylar source changed after adoption. Pullbox preserved the " + "managed file for review." + ) + return ( + _AdoptedReferenceRollback( + source_imported_file=source_imported_file, + source_library_file_id=source_library_file_id, + file_path=file_path, + file_name=file_name, + file_size=file_size, + file_format=file_format, + file_hash=file_hash, + file_modified_at=file_modified_at, + match_confidence=match_confidence, + parsed_series=parsed_series, + parsed_issue_number=parsed_issue_number, + parsed_year=parsed_year, + parsed_publisher=parsed_publisher, + has_comicinfo=has_comicinfo, + naming_snapshot=dict(naming_snapshot), + source_signature=dict(source_signature), + issue_id=issue_id, + library_root_id=library_root_id, + series=series, + previous_series_path=previous_series_path, + previous_series_library_root_id=previous_series_library_root_id, + previous_series_preferred_library_root_id=(previous_series_preferred_library_root_id), + ), + None, + ) + + +async def _restore_adopted_reference( + session: AsyncSession, + adopted: _AdoptedReferenceRollback, + *, + reusable_library_file: LibraryFile | None = None, +) -> None: + restored = reusable_library_file or LibraryFile(id=adopted.source_library_file_id) + restored.file_path = adopted.file_path + restored.file_name = adopted.file_name + restored.file_size = adopted.file_size + restored.file_format = adopted.file_format + restored.file_hash = adopted.file_hash + restored.file_modified_at = adopted.file_modified_at + restored.match_confidence = adopted.match_confidence + restored.parsed_series = adopted.parsed_series + restored.parsed_issue_number = adopted.parsed_issue_number + restored.parsed_year = adopted.parsed_year + restored.parsed_publisher = adopted.parsed_publisher + restored.has_comicinfo = adopted.has_comicinfo + restored.naming_snapshot = adopted.naming_snapshot + restored.storage_mode = LibraryFileStorageMode.REFERENCED + restored.source_signature = adopted.source_signature + restored.issue_id = adopted.issue_id + restored.library_root_id = adopted.library_root_id + if reusable_library_file is None: + session.add(restored) + await session.flush() + adopted.source_imported_file.library_file_id = restored.id + issue = await session.get(Issue, adopted.issue_id) + if issue is not None: + issue.status = IssueStatus.OWNED + adopted.series.path = adopted.previous_series_path + adopted.series.library_root_id = adopted.previous_series_library_root_id + adopted.series.preferred_library_root_id = adopted.previous_series_preferred_library_root_id + + async def rollback_action( session: AsyncSession, *, @@ -518,6 +803,17 @@ async def rollback_action( permission_restores = list(payload.get("permission_restores") or []) library_file = await session.get(LibraryFile, library_file_id) + adopted_reference, adoption_block_reason = await _prepare_adopted_reference_rollback( + session, + payload=payload, + managed_library_file=library_file, + ) + if adoption_block_reason is not None: + action.status = ImportJobActionStatus.ROLLBACK_FAILED + action.error_message = adoption_block_reason + action.rolled_back_at = None + await session.flush() + return source_reappeared = ( not referenced_file and transfer_method == "move" @@ -557,7 +853,14 @@ async def rollback_action( action.rolled_back_at = None await session.flush() return - if library_file is not None: + reusable_adopted_file = ( + library_file + if adopted_reference is not None + and library_file is not None + and library_file.id == adopted_reference.source_library_file_id + else None + ) + if library_file is not None and reusable_adopted_file is None: await session.delete(library_file) if not referenced_file: @@ -586,6 +889,13 @@ async def rollback_action( payload, destination_parent=destination_path.parent, ) + if adopted_reference is not None: + await session.flush() + await _restore_adopted_reference( + session, + adopted_reference, + reusable_library_file=reusable_adopted_file, + ) elif action_type == "library_file_placement_started": destination_path_raw = str(payload.get("destination_path") or "") diff --git a/src/pullbox/services/import_job_execution.py b/src/pullbox/services/import_job_execution.py index fb654e4..238675a 100644 --- a/src/pullbox/services/import_job_execution.py +++ b/src/pullbox/services/import_job_execution.py @@ -93,6 +93,7 @@ import_group_progress_plan, import_work_progress, ) +from pullbox.services.import_retry_helpers import require_retained_import_destination from pullbox.services.import_root_policy_activation import ( RootPolicyActivationConflictError, activate_future_root_policy, @@ -108,6 +109,9 @@ StoryArcResolutionResult, resolve_staged_story_arc_entries, ) +from pullbox.services.import_story_arc_review import ( + auto_confirm_trusted_logical_story_arcs, +) from pullbox.services.import_workflow_state import ( emit_live_progress, ) @@ -242,6 +246,7 @@ async def execute_import_job( if loaded_job is None: raise NotFoundError("ImportJob", job_id) job = loaded_job + require_retained_import_destination(job) try: await validate_managed_copy_preflight(session, job, stage="execution") @@ -845,6 +850,8 @@ async def durable_story_arc_checkpoint() -> None: cancellation_check=cancellation_checkpoint, durable_checkpoint=durable_story_arc_checkpoint, ) + await auto_confirm_trusted_logical_story_arcs(session, job_id) + await durable_story_arc_checkpoint() materialization = await materialize_confirmed_story_arcs( session, import_job_id=job_id, @@ -858,21 +865,25 @@ async def durable_story_arc_checkpoint() -> None: except Exception as exc: # Earlier canonical and story-arc pages may already be durable. Discard # only the current page, preserve every committed ownership pointer and - # journal row, then durably fail the job before propagating the error. + # journal row, and leave the optional arc work for follow-up without + # invalidating the canonical comic import. await session.rollback() persisted_job = await session.get(ImportJob, job_id) if persisted_job is None: raise NotFoundError("ImportJob", job_id) from exc - failure_message = "Story-arc registration failed; canonical files remain imported." - persisted_job.status = ImportJobStatus.FAILED + failure_message = "Some story arcs need follow-up; canonical comics imported successfully." + persisted_job.status = ImportJobStatus.IMPORTING persisted_job.import_completed_at = None persisted_job.error_message = failure_message failure_snapshot = dict(persisted_job.progress_snapshot or {}) failure_snapshot.update( { - "status": ImportJobStatus.FAILED.value, + "status": ImportJobStatus.IMPORTING.value, "mode": "import", + # Keep the seal fence valid so the outer import can publish any + # already-durable placement work before it completes. "phase": "story_arcs", + "progress": 99, "message": failure_message, } ) @@ -886,7 +897,7 @@ async def durable_story_arc_checkpoint() -> None: failure_type=type(exc).__name__, ) await session.commit() - raise + return persisted_job, StoryArcMaterializationResult(arcs_failed=1) warning_codes = sorted({warning.code for warning in materialization.warnings}) level = "WARNING" if materialization.arcs_failed else "INFO" diff --git a/src/pullbox/services/import_library_adoption.py b/src/pullbox/services/import_library_adoption.py new file mode 100644 index 0000000..74d9b07 --- /dev/null +++ b/src/pullbox/services/import_library_adoption.py @@ -0,0 +1,790 @@ +"""Build a clean managed library from a completed reference-only import.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass +from hashlib import sha256 +from pathlib import Path +from typing import TYPE_CHECKING, Final + +from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer +from sqlalchemy import delete, func, insert, select + +from pullbox.core.config_resolver import get_application_secret +from pullbox.core.exceptions import NotFoundError, ValidationError +from pullbox.core.library_policy import load_effective_library_ingest_policy +from pullbox.models.import_job import ( + ImportedFile, + ImportedFileStatus, + ImportedSeries, + ImportFileHandlingMode, + ImportJob, + ImportJobLog, + ImportJobStatus, + ImportSeriesStatus, +) +from pullbox.models.issue import Issue +from pullbox.models.library import LibraryFile, LibraryFileStorageMode, LibraryRoot +from pullbox.models.series import Series +from pullbox.schemas.import_job import ImportProgressEvent +from pullbox.services.import_completed_cleanup import ( + CompletedImportCleanupAction, + count_completed_import_cleanup_scope, +) +from pullbox.services.import_policy_snapshot import apply_ingest_policy_to_import_job +from pullbox.services.import_workflow_state import ( + ACTIVE_IMPORT_JOB_STATUSES, + emit_progress, + phase_progress, + raise_if_job_cancelled, +) + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + from sqlalchemy.sql.elements import ColumnElement + + +_TOKEN_SALT: Final = "completed-import-clean-library-v1" +_TOKEN_MAX_AGE_SECONDS: Final = 15 * 60 +_TOKEN_VERSION: Final = 1 +_SERIES_BATCH_SIZE: Final = 500 +_FILE_BATCH_SIZE: Final = 2_000 +_PREPARATION_PROGRESS_END: Final = 5 + + +@dataclass(frozen=True, slots=True) +class CleanLibraryImportPreview: + """Exact source-preserving adoption scope shown before job creation.""" + + source_job_id: int + target_root_id: int + eligible_file_count: int + eligible_series_count: int + total_bytes: int + source_preserved: bool + preview_token: str + + +@dataclass(frozen=True, slots=True) +class CleanLibraryImportResult: + """New managed-copy import created from a completed reference import.""" + + source_job_id: int + job_id: int + eligible_file_count: int + eligible_series_count: int + + +@dataclass(frozen=True, slots=True) +class _AdoptionSnapshot: + file_count: int + series_count: int + total_bytes: int + digest: str + + +def _serializer() -> URLSafeTimedSerializer: + return URLSafeTimedSerializer(get_application_secret(), salt=_TOKEN_SALT) + + +async def _load_source_job(session: AsyncSession, source_job_id: int) -> ImportJob: + job = await session.get(ImportJob, source_job_id, populate_existing=True) + if job is None: + raise NotFoundError("ImportJob", source_job_id) + if job.status is not ImportJobStatus.COMPLETED: + raise ValidationError("The source import must be complete before standardization.") + if job.archived_at is not None: + raise ValidationError("Restore the archived import before standardizing its library.") + return job + + +async def _require_mixed_folder_repairs_complete( + session: AsyncSession, + source_job_id: int, +) -> None: + repair_count, _file_count = await count_completed_import_cleanup_scope( + session, + source_job_id, + CompletedImportCleanupAction.RESOLVE_MIXED_FOLDER_FILES, + ) + if repair_count: + raise ValidationError( + "Resolve mixed-folder files before building the clean Pullbox library." + ) + + +def _eligible_sources(source_job_id: int) -> tuple[ColumnElement[bool], ...]: + return ( + ImportedFile.import_job_id == source_job_id, + ImportedFile.status == ImportedFileStatus.IMPORTED, + ImportedFile.matched_issue_id == Issue.id, + ImportedFile.library_file_id == LibraryFile.id, + LibraryFile.issue_id == Issue.id, + LibraryFile.storage_mode == LibraryFileStorageMode.REFERENCED, + LibraryFile.file_path == ImportedFile.file_path, + ) + + +async def _build_snapshot(session: AsyncSession, source_job_id: int) -> _AdoptionSnapshot: + digest = sha256() + series_ids: set[int] = set() + file_count = 0 + total_bytes = 0 + stream = await session.stream( + select( + ImportedFile.id, + LibraryFile.id, + Issue.id, + Issue.series_id, + LibraryFile.file_path, + LibraryFile.file_size, + LibraryFile.updated_at, + ImportedFile.updated_at, + ) + .select_from(ImportedFile) + .join(LibraryFile, LibraryFile.id == ImportedFile.library_file_id) + .join(Issue, Issue.id == LibraryFile.issue_id) + .where(*_eligible_sources(source_job_id)) + .order_by(ImportedFile.id) + .execution_options(yield_per=2_000) + ) + async for row in stream: + file_count += 1 + series_ids.add(int(row[3])) + total_bytes += int(row[5]) + digest.update( + ( + f"{int(row[0])}|{int(row[1])}|{int(row[2])}|{row[4]}|" + f"{row[6].isoformat()}|{row[7].isoformat()}\n" + ).encode() + ) + return _AdoptionSnapshot( + file_count=file_count, + series_count=len(series_ids), + total_bytes=total_bytes, + digest=digest.hexdigest(), + ) + + +def _paths_overlap(first: str, second: str) -> bool: + first_path = Path(first).resolve(strict=False) + second_path = Path(second).resolve(strict=False) + return ( + first_path == second_path + or first_path.is_relative_to(second_path) + or second_path.is_relative_to(first_path) + ) + + +async def _load_target_root( + session: AsyncSession, + target_root_id: int, + source_job_id: int, +) -> LibraryRoot: + root = await session.get(LibraryRoot, target_root_id) + if root is None: + raise NotFoundError("LibraryRoot", target_root_id) + if not root.enabled or not root.allow_managed_writes: + raise ValidationError("Choose an enabled library root that allows managed writes.") + source_root_paths = set( + ( + await session.scalars( + select(LibraryRoot.path) + .select_from(ImportedFile) + .join(LibraryFile, LibraryFile.id == ImportedFile.library_file_id) + .join(Issue, Issue.id == LibraryFile.issue_id) + .join(LibraryRoot, LibraryRoot.id == LibraryFile.library_root_id) + .where(*_eligible_sources(source_job_id)) + .distinct() + ) + ).all() + ) + if any(_paths_overlap(root.path, source_path) for source_path in source_root_paths): + raise ValidationError( + "Choose a separate managed library root that does not overlap the Mylar source." + ) + return root + + +async def preview_clean_library_import( + session: AsyncSession, + source_job_id: int, + *, + target_root_id: int, + actor_id: int, +) -> CleanLibraryImportPreview: + """Preview referenced files that can become a clean managed library.""" + await _load_source_job(session, source_job_id) + await _require_mixed_folder_repairs_complete(session, source_job_id) + snapshot = await _build_snapshot(session, source_job_id) + if snapshot.file_count == 0: + raise ValidationError("This import has no referenced files available to standardize.") + await _load_target_root(session, target_root_id, source_job_id) + token = str( + _serializer().dumps( + { + "version": _TOKEN_VERSION, + "source_job_id": source_job_id, + "target_root_id": target_root_id, + "actor_id": actor_id, + "snapshot": { + "file_count": snapshot.file_count, + "series_count": snapshot.series_count, + "total_bytes": snapshot.total_bytes, + "digest": snapshot.digest, + }, + } + ) + ) + return CleanLibraryImportPreview( + source_job_id=source_job_id, + target_root_id=target_root_id, + eligible_file_count=snapshot.file_count, + eligible_series_count=snapshot.series_count, + total_bytes=snapshot.total_bytes, + source_preserved=True, + preview_token=token, + ) + + +def _snapshot_from_token( + token: str, + *, + source_job_id: int, + target_root_id: int, + actor_id: int, +) -> _AdoptionSnapshot: + try: + payload = _serializer().loads(token, max_age=_TOKEN_MAX_AGE_SECONDS) + except SignatureExpired as exc: + raise ValidationError("The clean-library preview expired. Preview it again.") from exc + except BadSignature as exc: + raise ValidationError("The clean-library preview is invalid. Preview it again.") from exc + if not isinstance(payload, Mapping): + raise ValidationError("The clean-library preview is invalid. Preview it again.") + if ( + payload.get("version") != _TOKEN_VERSION + or payload.get("source_job_id") != source_job_id + or payload.get("target_root_id") != target_root_id + or payload.get("actor_id") != actor_id + ): + raise ValidationError("The clean-library scope changed. Preview it again.") + raw_snapshot = payload.get("snapshot") + if not isinstance(raw_snapshot, Mapping): + raise ValidationError("The clean-library preview is invalid. Preview it again.") + try: + snapshot = _AdoptionSnapshot( + file_count=int(raw_snapshot["file_count"]), + series_count=int(raw_snapshot["series_count"]), + total_bytes=int(raw_snapshot["total_bytes"]), + digest=str(raw_snapshot["digest"]), + ) + except (KeyError, TypeError, ValueError) as exc: + raise ValidationError("The clean-library preview is invalid. Preview it again.") from exc + if ( + snapshot.file_count <= 0 + or snapshot.series_count <= 0 + or snapshot.total_bytes < 0 + or not snapshot.digest + ): + raise ValidationError("The clean-library preview is invalid. Preview it again.") + return snapshot + + +def _adoption_diagnostics( + *, + source_diagnostics: object, + source_job_id: int, + source_imported_file_id: int, + source_library_file_id: int, + source_path: str, + source_library_root_id: int, + source_signature: object, +) -> dict[str, object]: + diagnostics = dict(source_diagnostics) if isinstance(source_diagnostics, dict) else {} + diagnostics["library_adoption"] = { + "schema_version": 1, + "source_import_job_id": source_job_id, + "source_imported_file_id": source_imported_file_id, + "source_library_file_id": source_library_file_id, + "source_path": source_path, + "source_library_root_id": source_library_root_id, + "source_signature": (dict(source_signature) if isinstance(source_signature, dict) else {}), + "source_storage_mode": LibraryFileStorageMode.REFERENCED.value, + "source_preserved": True, + } + return diagnostics + + +async def _create_adoption_series( + session: AsyncSession, + *, + job: ImportJob, + source_job_id: int, + batch_callback: Callable[[int], Awaitable[None]] | None = None, +) -> dict[int, int]: + imported_series_by_id: dict[int, int] = {} + last_series_id = 0 + while True: + rows = ( + await session.execute( + select( + Series.id, + Series.title, + Series.year_start, + Series.comicvine_id, + func.count(ImportedFile.id), + func.min(LibraryFile.file_path), + ) + .select_from(ImportedFile) + .join(LibraryFile, LibraryFile.id == ImportedFile.library_file_id) + .join(Issue, Issue.id == LibraryFile.issue_id) + .join(Series, Series.id == Issue.series_id) + .where(*_eligible_sources(source_job_id)) + .where(Series.id > last_series_id) + .group_by(Series.id, Series.title, Series.year_start, Series.comicvine_id) + .order_by(Series.id) + .limit(_SERIES_BATCH_SIZE) + ) + ).all() + if not rows: + break + pending: list[ImportedSeries] = [] + for series_id, title, year_start, comicvine_id, file_count, sample_path in rows: + imported_series = ImportedSeries( + import_job_id=job.id, + raw_series_name=title, + raw_year=year_start, + file_count=int(file_count), + files_total=int(file_count), + files_matched=int(file_count), + sample_paths=[str(sample_path)] if sample_path else [], + source_folder=str(Path(str(sample_path)).parent) if sample_path else None, + has_files=True, + cv_id=comicvine_id, + cv_title=title, + cv_year=year_start, + cv_match_score=1.0, + cv_match_method="clean_library_adoption", + status=ImportSeriesStatus.DUPLICATE, + selected_for_import=True, + series_id=series_id, + diagnostics={ + "kind": "clean_library_adoption", + "source_import_job_id": source_job_id, + "source_preserved": True, + }, + ) + session.add(imported_series) + pending.append(imported_series) + await session.flush() + imported_series_by_id.update( + {int(item.series_id): int(item.id) for item in pending if item.series_id is not None} + ) + last_series_id = int(rows[-1][0]) + if batch_callback is not None: + await batch_callback(len(imported_series_by_id)) + return imported_series_by_id + + +async def _create_adoption_files( + session: AsyncSession, + *, + job_id: int, + source_job_id: int, + imported_series_by_id: dict[int, int], + batch_callback: Callable[[int], Awaitable[None]] | None = None, +) -> None: + completed = 0 + last_file_id = 0 + while True: + rows = ( + ( + await session.execute( + select( + ImportedFile.id.label("source_imported_file_id"), + ImportedFile.diagnostics.label("source_diagnostics"), + LibraryFile.id.label("source_library_file_id"), + LibraryFile.file_path, + LibraryFile.file_name, + LibraryFile.file_size, + LibraryFile.file_format, + LibraryFile.has_comicinfo, + LibraryFile.source_signature, + LibraryFile.library_root_id.label("source_library_root_id"), + Issue.id.label("issue_id"), + Issue.comicvine_id.label("issue_comicvine_id"), + Issue.issue_number, + Issue.issue_number_text, + Series.id.label("series_id"), + Series.title.label("series_title"), + Series.year_start, + ) + .select_from(ImportedFile) + .join(LibraryFile, LibraryFile.id == ImportedFile.library_file_id) + .join(Issue, Issue.id == LibraryFile.issue_id) + .join(Series, Series.id == Issue.series_id) + .where(*_eligible_sources(source_job_id)) + .where(ImportedFile.id > last_file_id) + .order_by(ImportedFile.id) + .limit(_FILE_BATCH_SIZE) + ) + ) + .mappings() + .all() + ) + if not rows: + break + pending: list[dict[str, object]] = [] + for row in rows: + series_id = int(row["series_id"]) + issue_number = float(row["issue_number"]) + issue_number_text = row["issue_number_text"] + pending.append( + { + "import_job_id": job_id, + "import_series_id": imported_series_by_id[series_id], + "file_path": str(row["file_path"]), + "file_name": str(row["file_name"]), + "file_size": int(row["file_size"]), + "file_format": row["file_format"].value, + "parsed_series": str(row["series_title"]), + "parsed_issue_number": issue_number, + "parsed_year": row["year_start"], + "has_comicinfo": bool(row["has_comicinfo"]), + "comicvine_issue_id": row["issue_comicvine_id"], + "issue_number_raw": ( + str(issue_number_text) if issue_number_text else f"{issue_number:g}" + ), + "status": ImportedFileStatus.CONFIRMED, + "matched_issue_id": int(row["issue_id"]), + "matched_issue_cv_id": row["issue_comicvine_id"], + "match_confidence": "high", + "match_method": "clean_library_adoption", + "include_in_import": True, + "source_signature": dict(row["source_signature"] or {}), + "diagnostics": _adoption_diagnostics( + source_diagnostics=row["source_diagnostics"], + source_job_id=source_job_id, + source_imported_file_id=int(row["source_imported_file_id"]), + source_library_file_id=int(row["source_library_file_id"]), + source_path=str(row["file_path"]), + source_library_root_id=int(row["source_library_root_id"]), + source_signature=row["source_signature"], + ), + } + ) + await session.execute(insert(ImportedFile), pending) + completed += len(pending) + last_file_id = int(rows[-1]["source_imported_file_id"]) + if batch_callback is not None: + await batch_callback(completed) + + +def _snapshot_payload(snapshot: _AdoptionSnapshot) -> dict[str, object]: + return { + "file_count": snapshot.file_count, + "series_count": snapshot.series_count, + "total_bytes": snapshot.total_bytes, + "digest": snapshot.digest, + } + + +def _snapshot_from_job(job: ImportJob) -> _AdoptionSnapshot: + raw = dict(job.progress_snapshot or {}).get("clean_library_source_snapshot") + if not isinstance(raw, Mapping): + raise ValidationError("The clean-library preparation scope is unavailable.") + try: + return _AdoptionSnapshot( + file_count=int(raw["file_count"]), + series_count=int(raw["series_count"]), + total_bytes=int(raw["total_bytes"]), + digest=str(raw["digest"]), + ) + except (KeyError, TypeError, ValueError) as exc: + raise ValidationError("The clean-library preparation scope is invalid.") from exc + + +async def _find_active_clean_library_import( + session: AsyncSession, + *, + source_job_id: int, + target_root_id: int, +) -> ImportJob | None: + jobs = list( + ( + await session.scalars( + select(ImportJob) + .where( + ImportJob.status.in_( + { + ImportJobStatus.IMPORTING, + ImportJobStatus.PAUSING, + ImportJobStatus.PAUSED, + ImportJobStatus.STALLED, + ImportJobStatus.CANCELLING, + ImportJobStatus.ROLLING_BACK, + } + ), + ImportJob.target_library_root_id == target_root_id, + ) + .order_by(ImportJob.id.desc()) + ) + ).all() + ) + for job in jobs: + progress = dict(job.progress_snapshot or {}) + if ( + progress.get("clean_library_adoption") is True + and int(progress.get("source_import_job_id") or 0) == source_job_id + ): + return job + return None + + +async def prepare_clean_library_import( + session: AsyncSession, + job_id: int, + *, + progress_callback: Callable[[ImportProgressEvent], Awaitable[None]] | None = None, +) -> bool: + """Materialize a clean-library plan inside the durable import worker.""" + job = await session.get(ImportJob, job_id, populate_existing=True) + if job is None: + raise NotFoundError("ImportJob", job_id) + progress = dict(job.progress_snapshot or {}) + if progress.get("clean_library_adoption") is not True: + return False + if progress.get("clean_library_adoption_prepared") is True: + return False + + source_job_id = int(progress.get("source_import_job_id") or 0) + if source_job_id <= 0 or job.target_library_root_id is None: + raise ValidationError("The clean-library preparation context is incomplete.") + expected_snapshot = _snapshot_from_job(job) + await _load_source_job(session, source_job_id) + await _require_mixed_folder_repairs_complete(session, source_job_id) + await _load_target_root(session, int(job.target_library_root_id), source_job_id) + current_snapshot = await _build_snapshot(session, source_job_id) + if current_snapshot != expected_snapshot: + raise ValidationError( + "The clean-library source changed after preview. Open the organizer and try again." + ) + + await session.execute(delete(ImportedFile).where(ImportedFile.import_job_id == job_id)) + await session.execute(delete(ImportedSeries).where(ImportedSeries.import_job_id == job_id)) + + total_units = expected_snapshot.series_count + expected_snapshot.file_count + + async def report_progress( + completed_units: int, + *, + message: str, + item_label: str, + ) -> None: + await raise_if_job_cancelled(session, job_id) + job.progress_snapshot = { + **dict(job.progress_snapshot or {}), + "clean_library_adoption": True, + "clean_library_adoption_prepared": False, + "source_import_job_id": source_job_id, + "clean_library_source_snapshot": _snapshot_payload(expected_snapshot), + } + await emit_progress( + session, + job, + ImportProgressEvent( + job_id=job_id, + status=ImportJobStatus.IMPORTING, + mode="import", + phase="clean_library_preparing", + progress=phase_progress( + 0, + _PREPARATION_PROGRESS_END, + completed_units, + total_units, + ), + message=message, + current_file_name=item_label, + current_file_stage="clean_library_preparing", + current_file_progress_current=completed_units, + current_file_progress_total=total_units, + current_file_progress_pct=round((completed_units / total_units) * 100), + current_file_progress_unit="items", + ), + progress_callback, + ) + + await report_progress( + 0, + message="Preparing the clean-library work plan...", + item_label="Preparing library records", + ) + imported_series_by_id = await _create_adoption_series( + session, + job=job, + source_job_id=source_job_id, + batch_callback=lambda completed: report_progress( + completed, + message=(f"Prepared {completed:,} of {expected_snapshot.series_count:,} series."), + item_label="Preparing series", + ), + ) + await _create_adoption_files( + session, + job_id=job_id, + source_job_id=source_job_id, + imported_series_by_id=imported_series_by_id, + batch_callback=lambda completed: report_progress( + expected_snapshot.series_count + completed, + message=(f"Prepared {completed:,} of {expected_snapshot.file_count:,} files."), + item_label="Preparing files", + ), + ) + + refreshed_job = await session.get(ImportJob, job_id, populate_existing=True) + if refreshed_job is None: + raise NotFoundError("ImportJob", job_id) + refreshed_job.series_found = expected_snapshot.series_count + refreshed_job.series_duplicate = expected_snapshot.series_count + refreshed_job.total_files_found = expected_snapshot.file_count + refreshed_job.total_files_matched = expected_snapshot.file_count + refreshed_job.progress_snapshot = { + **dict(refreshed_job.progress_snapshot or {}), + "clean_library_adoption": True, + "clean_library_adoption_prepared": True, + "source_import_job_id": source_job_id, + "clean_library_source_snapshot": _snapshot_payload(expected_snapshot), + } + session.add( + ImportJobLog( + import_job_id=refreshed_job.id, + level="INFO", + event="clean_library_adoption_prepared", + message=( + f"Prepared {expected_snapshot.file_count} referenced files for a clean managed " + "library." + ), + data={ + "source_import_job_id": source_job_id, + "target_library_root_id": refreshed_job.target_library_root_id, + "eligible_file_count": expected_snapshot.file_count, + "eligible_series_count": expected_snapshot.series_count, + "total_bytes": expected_snapshot.total_bytes, + "source_preserved": True, + }, + ) + ) + await report_progress( + total_units, + message="Clean-library plan ready. Starting file processing...", + item_label="Library plan ready", + ) + refreshed_job.progress_snapshot = { + **dict(refreshed_job.progress_snapshot or {}), + "clean_library_adoption_prepared": True, + } + await session.commit() + return True + + +async def create_clean_library_import( + session: AsyncSession, + source_job_id: int, + *, + target_root_id: int, + actor_id: int, + preview_token: str, +) -> CleanLibraryImportResult: + """Create a managed-copy import that adopts exact referenced library files.""" + source_job = await _load_source_job(session, source_job_id) + await _require_mixed_folder_repairs_complete(session, source_job_id) + target_root = await _load_target_root(session, target_root_id, source_job_id) + snapshot = _snapshot_from_token( + preview_token, + source_job_id=source_job_id, + target_root_id=target_root_id, + actor_id=actor_id, + ) + + active_job = await _find_active_clean_library_import( + session, + source_job_id=source_job_id, + target_root_id=target_root_id, + ) + if active_job is not None: + return CleanLibraryImportResult( + source_job_id=source_job_id, + job_id=int(active_job.id), + eligible_file_count=snapshot.file_count, + eligible_series_count=snapshot.series_count, + ) + + active_job_id = await session.scalar( + select(ImportJob.id) + .where(ImportJob.status.in_(ACTIVE_IMPORT_JOB_STATUSES)) + .order_by(ImportJob.created_at.desc()) + .limit(1) + ) + if active_job_id is not None: + raise ValidationError( + "Only one import can be active at a time. " + "Finish, discard, or roll back the current import first." + ) + + policy = await load_effective_library_ingest_policy(session, target_root) + job = ImportJob( + source_path=source_job.source_path, + selected_file_paths=[], + source_type=source_job.source_type, + status=ImportJobStatus.IMPORTING, + target_library_root_id=target_root.id, + monitored=False, + search_on_add=False, + file_handling_mode=ImportFileHandlingMode.MANAGED_COPY, + source_layout_snapshot=dict(source_job.source_layout_snapshot or {}), + mylar3_path_map=dict(source_job.mylar3_path_map or {}), + mylar3_path_map_confirmed=source_job.mylar3_path_map_confirmed, + progress_snapshot={ + "mode": "import", + "phase": "queued", + "progress": 0, + "message": "Preparing a clean Pullbox-managed library.", + "source_import_job_id": source_job.id, + "clean_library_adoption": True, + "clean_library_adoption_prepared": False, + "clean_library_source_snapshot": _snapshot_payload(snapshot), + }, + ) + apply_ingest_policy_to_import_job(job, policy) + session.add(job) + await session.flush() + + job.series_found = snapshot.series_count + job.series_duplicate = snapshot.series_count + job.total_files_found = snapshot.file_count + job.total_files_matched = snapshot.file_count + session.add( + ImportJobLog( + import_job_id=job.id, + level="INFO", + event="clean_library_adoption_queued", + message=(f"Queued {snapshot.file_count} referenced files for a clean managed library."), + data={ + "source_import_job_id": source_job.id, + "target_library_root_id": target_root.id, + "eligible_file_count": snapshot.file_count, + "eligible_series_count": snapshot.series_count, + "total_bytes": snapshot.total_bytes, + "source_preserved": True, + }, + ) + ) + await session.flush() + return CleanLibraryImportResult( + source_job_id=source_job.id, + job_id=job.id, + eligible_file_count=snapshot.file_count, + eligible_series_count=snapshot.series_count, + ) diff --git a/src/pullbox/services/import_misplaced_source_cleanup.py b/src/pullbox/services/import_misplaced_source_cleanup.py new file mode 100644 index 0000000..29ecbfa --- /dev/null +++ b/src/pullbox/services/import_misplaced_source_cleanup.py @@ -0,0 +1,894 @@ +"""Previewed source cleanup for exact cross-folder Mylar recoveries.""" + +from __future__ import annotations + +import asyncio +import enum +import os +import shutil +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import UTC, datetime +from hashlib import sha256 +from pathlib import Path +from typing import TYPE_CHECKING, Final, cast + +from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer +from sqlalchemy import func, select + +from pullbox.config import get_settings +from pullbox.core.config_resolver import get_application_secret +from pullbox.core.exceptions import NotFoundError, ValidationError +from pullbox.core.filesystem_policy import is_invalid_path_text +from pullbox.core.library_file_ownership import ( + build_file_identity_signature, + validate_file_identity_signature, +) +from pullbox.models.audit_log import AuditEventType +from pullbox.models.import_job import ( + ImportedFile, + ImportedFileStatus, + ImportJob, + ImportJobAction, + ImportJobStatus, + ImportSourceType, +) +from pullbox.models.library import LibraryFile, LibraryFileStorageMode, LibraryRoot +from pullbox.services.audit_service import AuditService +from pullbox.services.import_duplicate_copies import compute_content_hash +from pullbox.services.import_runtime_settings import load_import_utility_trash_folder +from pullbox.utilities.settings import ( + move_file_to_utility_trash, + resolve_trash_directory, + restore_file_from_utility_trash, +) + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + from sqlalchemy.sql.elements import ColumnElement + + +class MisplacedSourceCleanupAction(enum.StrEnum): + """Explicit physical cleanup choices for one recovered Mylar source.""" + + RESTORE_RECORDED_PATH = "restore_recorded_path" + TRASH_IDENTICAL_DUPLICATE = "trash_identical_duplicate" + + +@dataclass(frozen=True, slots=True) +class MisplacedSourceCleanupPreview: + """One actor-bound preview for a physical source change.""" + + job_id: int + file_id: int + action: MisplacedSourceCleanupAction + file_name: str + source_path: str + destination_path: str | None + can_apply: bool + unavailable_reason: str + preview_token: str | None + + +@dataclass(frozen=True, slots=True) +class MisplacedSourceCleanupResult: + """Result of one explicit source cleanup.""" + + final_path: Path + + +@dataclass(frozen=True, slots=True) +class MisplacedSourceCleanupBulkPreview: + """Signed preview for every currently eligible verified misplaced file.""" + + job_id: int + affected_count: int + unavailable_count: int + examples: tuple[str, ...] + preview_token: str | None + + +@dataclass(frozen=True, slots=True) +class MisplacedSourceCleanupBulkResult: + """Outcome of one verified bulk source-organization operation.""" + + moved_count: int + skipped_count: int + + +@dataclass(frozen=True, slots=True) +class MisplacedSourceCleanupFilePage: + """One bounded page of exact source-cleanup candidates.""" + + items: tuple[ImportedFile, ...] + total: int + page: int + page_size: int + total_pages: int + + +@dataclass(frozen=True, slots=True) +class _CleanupContext: + job: ImportJob + imported_file: ImportedFile + library_file: LibraryFile + source: Path + destination: Path | None + signature: dict[str, int | str] + unavailable_reason: str + + +@dataclass(frozen=True, slots=True) +class _DuplicateCleanupContext: + job: ImportJob + imported_file: ImportedFile + canonical_file: ImportedFile + source: Path + signature: dict[str, int | str] + canonical_signature: dict[str, int | str] + content_hash: str + trash_dir: Path | None + unavailable_reason: str + + +_TOKEN_SALT: Final = "import-misplaced-source-cleanup-v1" +_VERIFIED_CROSS_FOLDER_METHODS: Final = ( + "verified_cross_folder_issue_identity", + "verified_cross_folder_series_issue_filename", +) +_TOKEN_MAX_AGE_SECONDS: Final = 15 * 60 + + +def _serializer() -> URLSafeTimedSerializer: + return URLSafeTimedSerializer(get_application_secret(), salt=_TOKEN_SALT) + + +async def _load_completed_mylar_job(session: AsyncSession, job_id: int) -> ImportJob: + job = await session.get(ImportJob, job_id) + if job is None: + raise NotFoundError("ImportJob", job_id) + if ( + job.status is not ImportJobStatus.COMPLETED + or job.source_type is not ImportSourceType.MYLAR3 + ): + raise ValidationError("Mylar source cleanup is available after a completed Mylar import.") + return job + + +def _cleanup_scope_filters( + job_id: int, + action: MisplacedSourceCleanupAction, +) -> tuple[ColumnElement[bool], ...]: + evidence = ImportedFile.diagnostics["mylar3_cross_folder_reconciliation"] + base: tuple[ColumnElement[bool], ...] = ( + ImportedFile.import_job_id == job_id, + evidence["method"].as_string().in_(_VERIFIED_CROSS_FOLDER_METHODS), + ) + if action is MisplacedSourceCleanupAction.RESTORE_RECORDED_PATH: + return ( + *base, + ImportedFile.status == ImportedFileStatus.IMPORTED, + evidence["role"].as_string() == "canonical", + evidence["restored_at"].as_string().is_(None), + ImportedFile.library_file_id.is_not(None), + ) + if action is MisplacedSourceCleanupAction.TRASH_IDENTICAL_DUPLICATE: + return ( + *base, + ImportedFile.status == ImportedFileStatus.DUPLICATE_FILE, + evidence["role"].as_string() == "identical_duplicate", + ImportedFile.diagnostics["misplaced_source_cleanup"]["action"].as_string().is_(None), + ImportedFile.duplicate_of_file_id.is_not(None), + ImportedFile.content_hash.is_not(None), + ) + raise ValidationError("This misplaced source cleanup action is not supported.") + + +async def count_misplaced_source_cleanup_files( + session: AsyncSession, + job_id: int, + action: MisplacedSourceCleanupAction, +) -> int: + """Count pending exact source-cleanup candidates without hydrating them.""" + await _load_completed_mylar_job(session, job_id) + return int( + ( + await session.scalar( + select(func.count(ImportedFile.id)).where(*_cleanup_scope_filters(job_id, action)) + ) + ) + or 0 + ) + + +async def list_misplaced_source_cleanup_files( + session: AsyncSession, + job_id: int, + action: MisplacedSourceCleanupAction, + *, + page: int = 1, + page_size: int = 25, +) -> MisplacedSourceCleanupFilePage: + """Return a deterministic, bounded page of pending source cleanups.""" + await _load_completed_mylar_job(session, job_id) + normalized_page = max(1, int(page)) + normalized_page_size = min(max(1, int(page_size)), 100) + filters = _cleanup_scope_filters(job_id, action) + total = int((await session.scalar(select(func.count(ImportedFile.id)).where(*filters))) or 0) + total_pages = max(1, (total + normalized_page_size - 1) // normalized_page_size) + normalized_page = min(normalized_page, total_pages) + items = tuple( + ( + await session.scalars( + select(ImportedFile) + .where(*filters) + .order_by(ImportedFile.file_name, ImportedFile.id) + .offset((normalized_page - 1) * normalized_page_size) + .limit(normalized_page_size) + ) + ).all() + ) + return MisplacedSourceCleanupFilePage( + items=items, + total=total, + page=normalized_page, + page_size=normalized_page_size, + total_pages=total_pages, + ) + + +def _inside(path: Path, root: Path) -> bool: + try: + path.relative_to(root) + except ValueError: + return False + return True + + +def _safe_absolute_path(value: object, *, label: str) -> Path: + if not isinstance(value, str) or not value or is_invalid_path_text(value): + raise ValidationError(f"The recorded {label} is invalid. Re-scan before cleanup.") + path = Path(value).expanduser() + if not path.is_absolute() or ".." in path.parts: + raise ValidationError(f"The recorded {label} is invalid. Re-scan before cleanup.") + return path.absolute() + + +async def _reference_root_for_path( + session: AsyncSession, + *, + lexical_path: Path, + resolved_path: Path, + require_managed_writes: bool, +) -> tuple[LibraryRoot | None, str]: + roots = list( + await session.scalars( + select(LibraryRoot).where( + LibraryRoot.enabled.is_(True), + LibraryRoot.allow_referenced_registrations.is_(True), + ) + ) + ) + matches: list[LibraryRoot] = [] + for root in roots: + try: + lexical_root = Path(root.path).expanduser().absolute() + resolved_root = Path(root.path).expanduser().resolve(strict=True) + except (OSError, RuntimeError, ValueError): + continue + if _inside(lexical_path, lexical_root) and _inside(resolved_path, resolved_root): + matches.append(root) + if len(matches) != 1: + return None, "The file does not belong to one unambiguous enabled library root." + if require_managed_writes and not matches[0].allow_managed_writes: + return None, ( + "This library root does not allow managed writes. Enable managed writes only when " + "you are ready for Pullbox to change the Mylar source library." + ) + return matches[0], "" + + +async def _load_restore_context( + session: AsyncSession, + job_id: int, + file_id: int, +) -> _CleanupContext: + job = await session.get(ImportJob, job_id) + if job is None: + raise NotFoundError("ImportJob", job_id) + if ( + job.status is not ImportJobStatus.COMPLETED + or job.source_type is not ImportSourceType.MYLAR3 + ): + raise ValidationError("Mylar source cleanup is available after a completed Mylar import.") + imported_file = await session.get(ImportedFile, file_id) + if imported_file is None or imported_file.import_job_id != job_id: + raise NotFoundError("ImportedFile", file_id) + evidence = dict(imported_file.diagnostics or {}).get("mylar3_cross_folder_reconciliation") + if ( + imported_file.status is not ImportedFileStatus.IMPORTED + or not isinstance(evidence, Mapping) + or evidence.get("method") not in _VERIFIED_CROSS_FOLDER_METHODS + or evidence.get("role") != "canonical" + ): + raise ValidationError( + "Only an imported, exactly identified misplaced file can be restored." + ) + if imported_file.library_file_id is None: + raise ValidationError("The misplaced file no longer has a Pullbox library reference.") + library_file = await session.get(LibraryFile, imported_file.library_file_id) + if ( + library_file is None + or library_file.storage_mode is not LibraryFileStorageMode.REFERENCED + or library_file.issue_id != imported_file.matched_issue_id + ): + raise ValidationError("The misplaced file reference changed after import.") + + source_lexical = _safe_absolute_path(imported_file.file_path, label="source path") + destination_lexical = _safe_absolute_path(evidence.get("recorded_path"), label="Mylar path") + try: + if source_lexical.is_symlink(): + raise ValidationError("Symlinked source files cannot use Mylar path restoration.") + source = source_lexical.resolve(strict=True) + destination = destination_lexical.resolve(strict=False) + signature = build_file_identity_signature(source) + validate_file_identity_signature(dict(imported_file.source_signature or {}), signature) + validate_file_identity_signature(dict(library_file.source_signature or {}), signature) + except ValidationError: + raise + except Exception as exc: + raise ValidationError( + "The misplaced source changed or is unavailable. Re-scan before cleanup." + ) from exc + if Path(library_file.file_path).expanduser().resolve(strict=True) != source: + raise ValidationError("The misplaced file reference changed after import.") + if source == destination: + return _CleanupContext(job, imported_file, library_file, source, None, signature, "") + + _source_root, source_reason = await _reference_root_for_path( + session, + lexical_path=source_lexical, + resolved_path=source, + require_managed_writes=False, + ) + destination_parent = destination.parent + try: + resolved_parent = destination_parent.resolve(strict=True) + except (OSError, RuntimeError, ValueError): + resolved_parent = destination_parent + _destination_root, destination_reason = await _reference_root_for_path( + session, + lexical_path=destination_lexical, + resolved_path=resolved_parent / destination.name, + require_managed_writes=False, + ) + unavailable_reason = source_reason or destination_reason + if not unavailable_reason and os.path.lexists(destination): + unavailable_reason = ( + "The Mylar-recorded destination already exists, so Pullbox left it unchanged." + ) + if not unavailable_reason and ( + not destination_parent.is_dir() or not os.access(destination_parent, os.W_OK) + ): + unavailable_reason = "The Mylar-recorded destination folder is not writable." + if not unavailable_reason and not os.access(source.parent, os.W_OK): + unavailable_reason = "The current source folder is not writable." + return _CleanupContext( + job, + imported_file, + library_file, + source, + destination, + signature, + unavailable_reason, + ) + + +async def _load_duplicate_context( + session: AsyncSession, + job_id: int, + file_id: int, +) -> _DuplicateCleanupContext: + job = await session.get(ImportJob, job_id) + if job is None: + raise NotFoundError("ImportJob", job_id) + if ( + job.status is not ImportJobStatus.COMPLETED + or job.source_type is not ImportSourceType.MYLAR3 + ): + raise ValidationError("Mylar source cleanup is available after a completed Mylar import.") + imported_file = await session.get(ImportedFile, file_id) + if imported_file is None or imported_file.import_job_id != job_id: + raise NotFoundError("ImportedFile", file_id) + evidence = dict(imported_file.diagnostics or {}).get("mylar3_cross_folder_reconciliation") + cleaned = dict(imported_file.diagnostics or {}).get("misplaced_source_cleanup") + if ( + imported_file.status is not ImportedFileStatus.DUPLICATE_FILE + or not isinstance(evidence, Mapping) + or evidence.get("method") not in _VERIFIED_CROSS_FOLDER_METHODS + or evidence.get("role") != "identical_duplicate" + or isinstance(cleaned, Mapping) + ): + raise ValidationError( + "Only an untouched, hash-confirmed misplaced duplicate can be removed." + ) + if imported_file.duplicate_of_file_id is None or not imported_file.content_hash: + raise ValidationError("The duplicate no longer has exact canonical-file evidence.") + canonical = await session.get(ImportedFile, imported_file.duplicate_of_file_id) + if ( + canonical is None + or canonical.import_job_id != job_id + or canonical.content_hash != imported_file.content_hash + ): + raise ValidationError("The duplicate no longer has exact canonical-file evidence.") + + source_lexical = _safe_absolute_path(imported_file.file_path, label="duplicate path") + canonical_lexical = _safe_absolute_path(canonical.file_path, label="canonical path") + try: + if source_lexical.is_symlink() or canonical_lexical.is_symlink(): + raise ValidationError("Symlinked files cannot use duplicate source cleanup.") + source = source_lexical.resolve(strict=True) + canonical_source = canonical_lexical.resolve(strict=True) + signature = build_file_identity_signature(source) + canonical_signature = build_file_identity_signature(canonical_source) + validate_file_identity_signature(dict(imported_file.source_signature or {}), signature) + validate_file_identity_signature( + dict(canonical.source_signature or {}), canonical_signature + ) + except ValidationError: + raise + except Exception as exc: + raise ValidationError( + "The duplicate or canonical source changed after import. Leave both files unchanged." + ) from exc + unavailable_reason = "" + _root, root_reason = await _reference_root_for_path( + session, + lexical_path=source_lexical, + resolved_path=source, + require_managed_writes=True, + ) + unavailable_reason = root_reason + actual_hash = await asyncio.to_thread(compute_content_hash, str(source)) + canonical_hash = await asyncio.to_thread(compute_content_hash, str(canonical_source)) + if not unavailable_reason and ( + actual_hash is None + or canonical_hash is None + or actual_hash != imported_file.content_hash + or canonical_hash != imported_file.content_hash + ): + unavailable_reason = "The two files are no longer byte-identical, so Pullbox left both." + configured_trash = await load_import_utility_trash_folder(session) + settings = get_settings() + trash_dir = resolve_trash_directory( + configured_trash, + library_root=settings.library_root, + data_dir=settings.data_dir, + ) + if trash_dir is None: + unavailable_reason = unavailable_reason or ( + "Configure the Trash folder in Media Management before removing duplicates." + ) + return _DuplicateCleanupContext( + job, + imported_file, + canonical, + source, + signature, + canonical_signature, + imported_file.content_hash, + trash_dir, + unavailable_reason, + ) + + +async def preview_misplaced_source_cleanup( + session: AsyncSession, + job_id: int, + file_id: int, + action: MisplacedSourceCleanupAction, + *, + actor_id: int, +) -> MisplacedSourceCleanupPreview: + """Preview one exact cross-folder cleanup without mutating state.""" + if action is MisplacedSourceCleanupAction.RESTORE_RECORDED_PATH: + context = await _load_restore_context(session, job_id, file_id) + destination = context.destination + already_restored = destination is None + token = None + if not context.unavailable_reason and not already_restored: + token = str( + _serializer().dumps( + { + "job_id": job_id, + "file_id": file_id, + "actor_id": actor_id, + "action": action.value, + "source": str(context.source), + "destination": str(destination), + "signature": context.signature, + } + ) + ) + return MisplacedSourceCleanupPreview( + job_id=job_id, + file_id=file_id, + action=action, + file_name=context.imported_file.file_name, + source_path=str(context.source), + destination_path=str(destination) if destination is not None else None, + can_apply=token is not None, + unavailable_reason=( + "This file is already at the Mylar-recorded path." + if already_restored + else context.unavailable_reason + ), + preview_token=token, + ) + if action is MisplacedSourceCleanupAction.TRASH_IDENTICAL_DUPLICATE: + duplicate = await _load_duplicate_context(session, job_id, file_id) + token = None + if not duplicate.unavailable_reason and duplicate.trash_dir is not None: + token = str( + _serializer().dumps( + { + "job_id": job_id, + "file_id": file_id, + "actor_id": actor_id, + "action": action.value, + "source": str(duplicate.source), + "trash_dir": str(duplicate.trash_dir), + "signature": duplicate.signature, + "canonical_signature": duplicate.canonical_signature, + "content_hash": duplicate.content_hash, + } + ) + ) + return MisplacedSourceCleanupPreview( + job_id=job_id, + file_id=file_id, + action=action, + file_name=duplicate.imported_file.file_name, + source_path=str(duplicate.source), + destination_path=str(duplicate.trash_dir) if duplicate.trash_dir is not None else None, + can_apply=token is not None, + unavailable_reason=duplicate.unavailable_reason, + preview_token=token, + ) + raise ValidationError("This misplaced source cleanup action is not supported.") + + +async def _load_verified_restore_contexts( + session: AsyncSession, + job_id: int, +) -> tuple[list[_CleanupContext], int]: + file_ids = list( + await session.scalars( + select(ImportedFile.id) + .where( + *_cleanup_scope_filters( + job_id, + MisplacedSourceCleanupAction.RESTORE_RECORDED_PATH, + ) + ) + .order_by(ImportedFile.id) + ) + ) + contexts: list[_CleanupContext] = [] + unavailable_count = 0 + for file_id in file_ids: + try: + context = await _load_restore_context(session, job_id, int(file_id)) + except ValidationError: + unavailable_count += 1 + continue + if context.unavailable_reason or context.destination is None: + unavailable_count += 1 + continue + contexts.append(context) + return contexts, unavailable_count + + +def _restore_scope_digest(contexts: list[_CleanupContext]) -> str: + digest = sha256() + for context in contexts: + destination = context.destination + if destination is None: + continue + digest.update( + ( + f"{context.imported_file.id}\0{context.source}\0{destination}\0" + f"{sorted(context.signature.items())}\n" + ).encode() + ) + return digest.hexdigest() + + +async def _move_restore_source(context: _CleanupContext) -> Path: + if context.destination is None: + raise ValidationError("This file is already at the Mylar-recorded path.") + await asyncio.to_thread(shutil.move, str(context.source), str(context.destination)) + return context.destination.resolve(strict=True) + + +async def _update_restore_registration( + session: AsyncSession, + context: _CleanupContext, + final_path: Path, +) -> None: + final_signature = build_file_identity_signature(final_path) + stat_result = final_path.stat() + context.imported_file.file_path = str(final_path) + context.imported_file.file_name = final_path.name + context.imported_file.source_signature = final_signature + context.library_file.file_path = str(final_path) + context.library_file.file_name = final_path.name + context.library_file.file_size = stat_result.st_size + context.library_file.file_modified_at = datetime.fromtimestamp(stat_result.st_mtime, UTC) + context.library_file.source_signature = final_signature + diagnostics = dict(context.imported_file.diagnostics or {}) + evidence = dict(diagnostics.get("mylar3_cross_folder_reconciliation") or {}) + evidence.update( + { + "restored_at": datetime.now(UTC).isoformat(), + "restored_path": str(final_path), + } + ) + diagnostics["mylar3_cross_folder_reconciliation"] = evidence + context.imported_file.diagnostics = diagnostics + registration_action = await _load_registration_action( + session, + job_id=context.job.id, + imported_file_id=context.imported_file.id, + ) + if registration_action is not None: + registration_payload = dict(registration_action.payload or {}) + registration_payload.update( + { + "destination_path": str(final_path), + "destination_signature": final_signature, + "original_source_path": str(final_path), + } + ) + registration_action.payload = registration_payload + + +async def _restore_source_moves(moved: list[tuple[Path, Path]]) -> None: + for source, destination in reversed(moved): + if os.path.lexists(destination) and not os.path.lexists(source): + await asyncio.to_thread(shutil.move, str(destination), str(source)) + + +async def preview_verified_misplaced_source_cleanup( + session: AsyncSession, + job_id: int, + *, + actor_id: int, +) -> MisplacedSourceCleanupBulkPreview: + """Preview every currently eligible exact misplaced-file restoration.""" + await _load_completed_mylar_job(session, job_id) + contexts, unavailable_count = await _load_verified_restore_contexts(session, job_id) + token = None + if contexts: + token = str( + _serializer().dumps( + { + "job_id": job_id, + "actor_id": actor_id, + "action": "restore_all_verified", + "affected_count": len(contexts), + "unavailable_count": unavailable_count, + "scope_digest": _restore_scope_digest(contexts), + } + ) + ) + return MisplacedSourceCleanupBulkPreview( + job_id=job_id, + affected_count=len(contexts), + unavailable_count=unavailable_count, + examples=tuple(context.imported_file.file_name for context in contexts[:3]), + preview_token=token, + ) + + +async def apply_verified_misplaced_source_cleanup( + session: AsyncSession, + job_id: int, + *, + actor_id: int, + preview_token: str, + actor_username: str | None = None, + source_ip: str | None = None, +) -> MisplacedSourceCleanupBulkResult: + """Move every file covered by a signed exact-scope preview.""" + payload = _load_token(preview_token) + contexts, unavailable_count = await _load_verified_restore_contexts(session, job_id) + if ( + payload.get("job_id") != job_id + or payload.get("actor_id") != actor_id + or payload.get("action") != "restore_all_verified" + or payload.get("affected_count") != len(contexts) + or payload.get("unavailable_count") != unavailable_count + or payload.get("scope_digest") != _restore_scope_digest(contexts) + ): + raise ValidationError("The verified-file cleanup scope changed. Preview it again.") + if not contexts: + raise ValidationError("No verified misplaced files are currently available to move.") + + moved: list[tuple[Path, Path]] = [] + try: + for context in contexts: + destination = context.destination + if destination is None: + raise ValidationError("A verified misplaced file is already at its proposed path.") + final_path = await _move_restore_source(context) + moved.append((context.source, final_path)) + await _update_restore_registration(session, context, final_path) + await AuditService.log_event( + session, + AuditEventType.IMPORT_MISPLACED_SOURCE_CLEANUP, + source_ip=source_ip, + user_id=actor_id, + username=actor_username, + detail=f"{len(moved)} verified misplaced Mylar sources were organized.", + metadata={ + "job_id": job_id, + "moved_count": len(moved), + "skipped_count": unavailable_count, + }, + ) + await session.commit() + except Exception: + await session.rollback() + await _restore_source_moves(moved) + raise + return MisplacedSourceCleanupBulkResult( + moved_count=len(moved), + skipped_count=unavailable_count, + ) + + +def _load_token(token: str) -> Mapping[str, object]: + try: + payload = _serializer().loads(token, max_age=_TOKEN_MAX_AGE_SECONDS) + except SignatureExpired as exc: + raise ValidationError("The Mylar cleanup preview expired. Preview it again.") from exc + except BadSignature as exc: + raise ValidationError("The Mylar cleanup preview is invalid. Preview it again.") from exc + if not isinstance(payload, Mapping): + raise ValidationError("The Mylar cleanup preview is invalid. Preview it again.") + return payload + + +async def _load_registration_action( + session: AsyncSession, + *, + job_id: int, + imported_file_id: int, +) -> ImportJobAction | None: + return cast( + "ImportJobAction | None", + await session.scalar( + select(ImportJobAction) + .where( + ImportJobAction.import_job_id == job_id, + ImportJobAction.action_type == "library_file_registered", + ImportJobAction.payload["imported_file_id"].as_integer() == imported_file_id, + ) + .order_by(ImportJobAction.id.desc()) + .limit(1) + ), + ) + + +async def apply_misplaced_source_cleanup( + session: AsyncSession, + job_id: int, + file_id: int, + action: MisplacedSourceCleanupAction, + *, + actor_id: int, + preview_token: str, + actor_username: str | None = None, + source_ip: str | None = None, +) -> MisplacedSourceCleanupResult: + """Apply one exact cross-folder cleanup after revalidation.""" + if action is MisplacedSourceCleanupAction.TRASH_IDENTICAL_DUPLICATE: + duplicate = await _load_duplicate_context(session, job_id, file_id) + if duplicate.unavailable_reason or duplicate.trash_dir is None: + raise ValidationError(duplicate.unavailable_reason or "Trash is not configured.") + payload = _load_token(preview_token) + expected_signature = payload.get("signature") + expected_canonical_signature = payload.get("canonical_signature") + if ( + payload.get("job_id") != job_id + or payload.get("file_id") != file_id + or payload.get("actor_id") != actor_id + or payload.get("action") != action.value + or payload.get("source") != str(duplicate.source) + or payload.get("trash_dir") != str(duplicate.trash_dir) + or payload.get("content_hash") != duplicate.content_hash + or not isinstance(expected_signature, Mapping) + or not isinstance(expected_canonical_signature, Mapping) + ): + raise ValidationError("The Mylar cleanup preview does not match this file.") + validate_file_identity_signature(dict(expected_signature), duplicate.signature) + validate_file_identity_signature( + dict(expected_canonical_signature), duplicate.canonical_signature + ) + trash_path = await asyncio.to_thread( + move_file_to_utility_trash, + duplicate.source, + duplicate.trash_dir, + relative_path=Path("import-results") / str(job_id) / duplicate.source.name, + ) + try: + diagnostics = dict(duplicate.imported_file.diagnostics or {}) + diagnostics["misplaced_source_cleanup"] = { + "action": action.value, + "completed_at": datetime.now(UTC).isoformat(), + "trash_path": str(trash_path), + } + duplicate.imported_file.diagnostics = diagnostics + await AuditService.log_event( + session, + AuditEventType.IMPORT_MISPLACED_SOURCE_CLEANUP, + source_ip=source_ip, + user_id=actor_id, + username=actor_username, + detail="One hash-confirmed misplaced Mylar duplicate was moved to Trash.", + metadata={"job_id": job_id, "file_id": file_id}, + ) + await session.commit() + except Exception: + await session.rollback() + await asyncio.to_thread( + restore_file_from_utility_trash, + trash_path, + duplicate.source, + ) + raise + return MisplacedSourceCleanupResult(final_path=trash_path) + if action is not MisplacedSourceCleanupAction.RESTORE_RECORDED_PATH: + raise ValidationError("This misplaced source cleanup action is not supported.") + context = await _load_restore_context(session, job_id, file_id) + if context.unavailable_reason: + raise ValidationError(context.unavailable_reason) + if context.destination is None: + raise ValidationError("This file is already at the Mylar-recorded path.") + payload = _load_token(preview_token) + if ( + payload.get("job_id") != job_id + or payload.get("file_id") != file_id + or payload.get("actor_id") != actor_id + or payload.get("action") != action.value + or payload.get("source") != str(context.source) + or payload.get("destination") != str(context.destination) + ): + raise ValidationError("The Mylar cleanup preview does not match this file.") + expected_signature = payload.get("signature") + if not isinstance(expected_signature, Mapping): + raise ValidationError("The Mylar cleanup preview is invalid. Preview it again.") + validate_file_identity_signature(dict(expected_signature), context.signature) + + source = context.source + moved: list[tuple[Path, Path]] = [] + try: + final_path = await _move_restore_source(context) + moved.append((source, final_path)) + await _update_restore_registration(session, context, final_path) + await AuditService.log_event( + session, + AuditEventType.IMPORT_MISPLACED_SOURCE_CLEANUP, + source_ip=source_ip, + user_id=actor_id, + username=actor_username, + detail="One verified misplaced Mylar source was restored to its recorded path.", + metadata={"job_id": job_id, "file_id": file_id}, + ) + await session.commit() + except Exception: + await session.rollback() + await _restore_source_moves(moved) + raise + return MisplacedSourceCleanupResult(final_path=final_path) diff --git a/src/pullbox/services/import_mylar3_path_preflight.py b/src/pullbox/services/import_mylar3_path_preflight.py index b4f55fc..853dbe4 100644 --- a/src/pullbox/services/import_mylar3_path_preflight.py +++ b/src/pullbox/services/import_mylar3_path_preflight.py @@ -28,12 +28,16 @@ from pullbox.models.library import LibraryRoot from pullbox.schemas.import_mylar3_path_preflight import ( MylarIdentityGroupPreview, + MylarPathAttentionAction, + MylarPathAttentionDetails, + MylarPathAttentionItem, MylarPathExample, MylarPathException, MylarPathMappingDraft, MylarPathMappingPreview, MylarPathOutcome, MylarPathPreviewResponse, + MylarPathProblemGroup, MylarPathResolutionCounts, ) @@ -247,6 +251,29 @@ def _analyze( automatic_evidence_blocked, ) ) + can_continue_with_unresolved = bool(safe and unresolved) + problem_groups = _group_path_problems(exceptions) + unavailable_root_models = [ + root for root in root_models if root.id not in {snapshot.root_id for snapshot in roots} + ] + blocking_reasons = [ + f"Library root '{root.name}' ({root.path}) is unavailable or unreadable. " + "Check its mount and permissions." + for root in unavailable_root_models + ] + [ + f"Mapping {state.stored_prefix} to {state.pullbox_prefix}: {reason.replace('_', ' ')}." + for state in mapping_states.values() + for reason in state.blockers + ] + attention_items = _build_attention_items( + exceptions=exceptions, + can_continue_with_unresolved=can_continue_with_unresolved, + unavailable_roots=unavailable_root_models, + available_roots=roots, + mapping_states=list(mapping_states.values()), + warnings=warnings, + partial=partial, + ) return MylarPathPreviewResponse( source_type=ImportSourceType.MYLAR3, resolution=total.response(), @@ -264,23 +291,15 @@ def _analyze( path_map=supplied_map, requires_confirmation=bool(supplied_map), can_confirm=can_confirm, - can_continue_with_unresolved=bool(safe and unresolved), + can_continue_with_unresolved=can_continue_with_unresolved, requires_unresolved_acknowledgement=bool(unresolved), unresolved_fingerprint=_exception_fingerprint(exceptions) if unresolved else None, exceptions=exceptions, exception_count=len(exceptions), - blocking_reasons=[ - f"Library root '{root.name}' ({root.path}) is unavailable or unreadable. " - "Check its mount and permissions." - for root in root_models - if root.id not in {snapshot.root_id for snapshot in roots} - ] - + [ - f"Mapping {state.stored_prefix} to {state.pullbox_prefix}: " - f"{reason.replace('_', ' ')}." - for state in mapping_states.values() - for reason in state.blockers - ], + problem_groups=problem_groups, + attention_items=attention_items, + attention_fingerprint=_attention_fingerprint(attention_items), + blocking_reasons=blocking_reasons, partial=partial, warnings=list(dict.fromkeys(warnings)), ) @@ -607,6 +626,524 @@ def _exception_fingerprint(exceptions: list[MylarPathException]) -> str: return hashlib.sha256(json.dumps(entries, ensure_ascii=True).encode("utf-8")).hexdigest() +def _problem_root(exception: MylarPathException) -> Path: + """Return the smallest useful root that explains this stored location.""" + path = Path(exception.stored_path) + try: + resolved = path.resolve(strict=True) + except (OSError, RuntimeError, ValueError): + resolved = path + if resolved.is_file(): + return resolved.parent.parent + return resolved.parent + + +def _problem_directory(exception: MylarPathException) -> Path: + """Return the existing directory represented by one stored location.""" + path = Path(exception.stored_path) + try: + resolved = path.resolve(strict=True) + except (OSError, RuntimeError, ValueError): + return path.parent + return resolved.parent if resolved.is_file() else resolved + + +def _nearest_safe_mount(path: Path) -> Path | None: + """Return the nearest registerable mount boundary containing ``path``.""" + for candidate in (path, *path.parents): + if candidate == candidate.parent: + break + try: + if candidate.is_mount() and not is_sensitive_path(candidate): + return candidate + except (OSError, RuntimeError, ValueError): + continue + return None + + +def _outside_root_buckets( + exceptions: list[MylarPathException], +) -> list[tuple[Path, list[MylarPathException]]]: + """Group repeated series locations by their actual source boundary.""" + mounted: dict[Path, list[MylarPathException]] = {} + unmounted: list[MylarPathException] = [] + for exception in exceptions: + directory = _problem_directory(exception) + mount = _nearest_safe_mount(directory) + if mount is None: + unmounted.append(exception) + else: + mounted.setdefault(mount, []).append(exception) + + buckets = list(mounted.items()) + if not unmounted: + return buckets + + directories = [_problem_directory(exception) for exception in unmounted] + try: + common = Path(os.path.commonpath([str(path) for path in directories])) + except ValueError: + common = Path() + series_count = len({exception.series_id for exception in unmounted}) + if series_count == 1 and common in directories: + common = common.parent + if common.is_dir() and common != common.parent and not is_sensitive_path(common): + buckets.append((common, unmounted)) + return buckets + + fallback: dict[Path, list[MylarPathException]] = {} + for exception in unmounted: + fallback.setdefault(_problem_root(exception), []).append(exception) + buckets.extend(fallback.items()) + return buckets + + +def _group_path_problems( + exceptions: list[MylarPathException], +) -> list[MylarPathProblemGroup]: + grouped: dict[tuple[MylarPathOutcome, str], list[MylarPathException]] = {} + outside_root: list[MylarPathException] = [] + for exception in exceptions: + if exception.outcome == "outside_root": + outside_root.append(exception) + continue + root = _problem_root(exception) + grouped.setdefault((exception.outcome, str(root)), []).append(exception) + for root, entries in _outside_root_buckets(outside_root): + grouped.setdefault(("outside_root", str(root)), []).extend(entries) + + results: list[MylarPathProblemGroup] = [] + for (outcome, root_path), entries in sorted(grouped.items(), key=lambda item: item[0]): + series_count = len({entry.series_id for entry in entries}) + location_count = len(entries) + if outcome == "outside_root": + reason = ( + f"{series_count} series use {root_path}, which isn't registered as a library root." + ) + suggested_action = ( + "Register this path for existing files, then Pullbox will analyze the paths again." + ) + root = Path(root_path) + can_register = ( + root.is_dir() and _location_is_readable(root) and not is_sensitive_path(root) + ) + else: + reason = entries[0].reason + suggested_action = entries[0].suggested_action + can_register = False + results.append( + MylarPathProblemGroup( + root_path=root_path, + outcome=outcome, + series_count=series_count, + location_count=location_count, + reason=reason, + suggested_action=suggested_action, + can_register_reference_root=can_register, + ) + ) + return results + + +def _attention_key(*parts: object) -> str: + """Return a stable opaque identity for one preview finding or action.""" + payload = json.dumps(parts, ensure_ascii=True, sort_keys=True, default=str) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def _attention_fingerprint(items: list[MylarPathAttentionItem]) -> str | None: + if not items: + return None + return _attention_key("mylar_path_attention", [item.key for item in items]) + + +def add_report_unavailable_attention( + preview: MylarPathPreviewResponse, + report_directory: str | Path, +) -> MylarPathPreviewResponse: + """Explain a nonblocking report-storage failure through the Step 1 action contract.""" + path = str(report_directory) + key = _attention_key("report_unavailable", path) + item = MylarPathAttentionItem( + key=key, + code="report_unavailable", + blocks_import=False, + reason="Pullbox could not save the full Mylar path report.", + suggested_action=( + "You can continue, but only the first 25 path exceptions are available until " + "report storage is restored." + ), + root_path=path, + details=MylarPathAttentionDetails( + title="Restore the full path report", + series_count=0, + location_count=preview.exception_count, + known_paths=[path], + steps=[ + f"Confirm that the Pullbox data volume containing {path} has free space.", + f"Give Pullbox permission to create and replace files inside {path}.", + "If storage is healthy and the report is still too large, split the migration " + "into smaller source databases.", + "Return to Pullbox and recheck the import issues.", + ], + ), + ) + items = [*preview.attention_items, item] + return preview.model_copy( + update={ + "attention_items": items, + "attention_fingerprint": _attention_fingerprint(items), + } + ) + + +def _path_is_under(path: str, root: str) -> bool: + try: + return Path(path).absolute().is_relative_to(Path(root).expanduser().absolute()) + except (OSError, RuntimeError, ValueError): + return False + + +def _known_paths(root_path: str, entries: list[MylarPathException]) -> list[str]: + paths = [root_path] + for entry in entries: + paths.extend((entry.stored_path, entry.attempted_path)) + return list(dict.fromkeys(path for path in paths if path))[:6] + + +def _path_attention_steps( + outcome: MylarPathOutcome, + root_path: str, + *, + can_continue: bool, +) -> list[str]: + if outcome in {"missing", "mapped_missing"}: + return [ + f"Check whether {root_path} was renamed, moved, or deleted after Mylar recorded it.", + "If the content still exists, correct its recorded Mylar location or path mapping.", + "Return to Pullbox and recheck the import issues.", + ] + if outcome == "unmapped": + return [ + f"Make the source available inside the Pullbox container at {root_path}, " + "or map its Mylar prefix to the correct container-visible path.", + "Choose the host-side source folder in your container configuration; " + "Pullbox cannot infer that host path.", + "Register the container-visible path for existing files, then recheck " + "the import issues.", + ] + if outcome == "unreadable": + return [ + f"Give the Pullbox container read and directory-traverse access to {root_path}.", + "If this is a Docker mount, confirm the mount is not hidden by another " + "overlapping mount.", + "Recheck the import issues after access is restored.", + ] + if outcome == "ambiguous": + return [ + "Review the configured library roots and Mylar mappings that can both " + f"claim {root_path}.", + "Keep one unambiguous container-visible root or add one explicit " + "non-overlapping mapping.", + "Recheck the import issues after removing the overlap.", + ] + if outcome == "outside_root": + return [ + f"Confirm that {root_path} is the intended container-visible source root.", + "Register only that source root for existing files; do not expose the " + "container filesystem root.", + "Recheck the import issues after the root is registered.", + ] + if outcome == "invalid": + return [ + f"Correct the unsafe or malformed path recorded beneath {root_path} in Mylar.", + "Remove traversal components, invalid path text, or symlink loops instead " + "of broadening Pullbox access.", + "Recheck the import issues after the stored path is corrected.", + ] + if can_continue: + return ["Resolve this finding for the current import, then continue to the scan."] + return ["Correct the source location, then recheck the import issues."] + + +def _path_attention_items( + exceptions: list[MylarPathException], + problem_groups: list[MylarPathProblemGroup], + *, + can_continue_with_unresolved: bool, +) -> list[MylarPathAttentionItem]: + items: list[MylarPathAttentionItem] = [] + for group in problem_groups: + entries = [ + entry + for entry in exceptions + if entry.outcome == group.outcome + and ( + ( + group.outcome == "outside_root" + and _path_is_under(entry.stored_path, group.root_path) + ) + or ( + group.outcome != "outside_root" and str(_problem_root(entry)) == group.root_path + ) + ) + ] + unresolved = group.outcome in {"missing", "mapped_missing", "unmapped"} + blocks_import = not (unresolved and can_continue_with_unresolved) + action_kind: Literal["acknowledge_unavailable", "register_reference_root"] | None = None + if group.can_register_reference_root: + action_kind = "register_reference_root" + elif unresolved and can_continue_with_unresolved: + action_kind = "acknowledge_unavailable" + key = _attention_key( + "path_group", + group.outcome, + group.root_path, + sorted((entry.series_id, entry.stored_path, entry.attempted_path) for entry in entries), + ) + action = ( + MylarPathAttentionAction( + kind=action_kind, + fingerprint=_attention_key("action", action_kind, key), + root_path=group.root_path, + ) + if action_kind is not None + else None + ) + title = { + "missing": "Review missing Mylar locations", + "mapped_missing": "Review missing mapped locations", + "unmapped": "Connect an unavailable Mylar source", + "unreadable": "Restore read access", + "ambiguous": "Choose one source path", + "outside_root": "Register the existing library source", + "invalid": "Correct unsafe Mylar paths", + }.get(group.outcome, "Review this Mylar path issue") + items.append( + MylarPathAttentionItem( + key=key, + code=group.outcome, + blocks_import=blocks_import, + reason=group.reason, + suggested_action=group.suggested_action, + root_path=group.root_path, + action=action, + details=MylarPathAttentionDetails( + title=title, + series_count=group.series_count, + location_count=group.location_count, + known_paths=_known_paths(group.root_path, entries), + steps=_path_attention_steps( + group.outcome, + group.root_path, + can_continue=can_continue_with_unresolved, + ), + ), + ) + ) + return items + + +def _mapping_attention_item( + state: _MappingState, + reason: str, +) -> MylarPathAttentionItem: + code = reason + key = _attention_key("mapping", state.stored_prefix, state.pullbox_prefix, reason) + action: MylarPathAttentionAction | None = None + if reason == "mapping_does_not_improve_coverage": + action = MylarPathAttentionAction( + kind="remove_ineffective_mapping", + fingerprint=_attention_key("action", "remove_ineffective_mapping", key), + stored_prefix=state.stored_prefix, + pullbox_prefix=state.pullbox_prefix, + ) + elif reason == "mapping_target_outside_enabled_root": + target = Path(state.pullbox_prefix) + if target.is_dir() and _location_is_readable(target) and not is_sensitive_path(target): + action = MylarPathAttentionAction( + kind="register_reference_root", + fingerprint=_attention_key("action", "register_reference_root", key), + root_path=state.pullbox_prefix, + ) + labels = { + "mapping_does_not_improve_coverage": ( + "This path mapping does not match any Mylar locations.", + "Remove the unused mapping, then let Pullbox analyze the remaining paths again.", + "Remove an unused path mapping", + ), + "mapping_target_outside_enabled_root": ( + "This mapping points outside every enabled existing-file library root.", + "Register the mapped container path for existing files or choose another mapped path.", + "Register the mapped source", + ), + "mapping_target_unsafe": ( + "This mapping points to a protected or unsafe location.", + "Choose a narrower container-visible comic folder instead of a system location.", + "Choose a safe mapped folder", + ), + "mapping_target_unavailable": ( + "This mapping points to a location Pullbox cannot find.", + "Correct the container-visible target or add the missing Docker mount.", + "Make the mapped folder available", + ), + "mapping_target_unreadable": ( + "This mapping points to a folder Pullbox cannot read.", + "Restore read and directory-traverse access, then recheck the import issues.", + "Restore mapped-folder access", + ), + "mapping_target_root_ambiguous": ( + "More than one configured root can claim this mapping target.", + "Remove the overlapping root or map to one unambiguous container path.", + "Remove the root overlap", + ), + } + reason_text, suggested_action, title = labels.get( + reason, + ( + "This Mylar path mapping needs attention.", + "Review the mapping and recheck the import issues.", + "Review this path mapping", + ), + ) + return MylarPathAttentionItem( + key=key, + code=code, + blocks_import=True, + reason=reason_text, + suggested_action=suggested_action, + root_path=state.pullbox_prefix, + action=action, + details=MylarPathAttentionDetails( + title=title, + series_count=0, + location_count=state.counts.locations, + known_paths=[state.stored_prefix, state.pullbox_prefix], + steps=[ + f"Review the Mylar prefix {state.stored_prefix}.", + f"Verify that its Pullbox container path is {state.pullbox_prefix}.", + "Correct or remove the mapping, then recheck the import issues.", + ], + ), + ) + + +def _build_attention_items( + *, + exceptions: list[MylarPathException], + can_continue_with_unresolved: bool, + unavailable_roots: list[LibraryRoot], + available_roots: list[_RootBoundary], + mapping_states: list[_MappingState], + warnings: list[str], + partial: bool, +) -> list[MylarPathAttentionItem]: + root_exceptions = { + id(entry) + for root in unavailable_roots + for entry in exceptions + if _path_is_under(entry.stored_path, root.path) + } + remaining_exceptions = [entry for entry in exceptions if id(entry) not in root_exceptions] + items = _path_attention_items( + remaining_exceptions, + _group_path_problems(remaining_exceptions), + can_continue_with_unresolved=can_continue_with_unresolved, + ) + for root in unavailable_roots: + related = [entry for entry in exceptions if _path_is_under(entry.stored_path, root.path)] + key = _attention_key("library_root_unavailable", root.id, root.path) + items.insert( + 0, + MylarPathAttentionItem( + key=key, + code="library_root_unavailable", + blocks_import=True, + reason=f"The configured library root {root.path} is unavailable or unreadable.", + suggested_action=( + "Restore its container mount or read access, then recheck the import issues." + ), + root_path=root.path, + details=MylarPathAttentionDetails( + title=f"Restore access to {root.name}", + series_count=len({entry.series_id for entry in related}), + location_count=len(related), + known_paths=[root.path], + steps=[ + f"Confirm that {root.path} exists inside the Pullbox container.", + "If it is a Docker mount, choose the correct host-side source " + "folder; Pullbox cannot infer that host path.", + f"Give Pullbox read and directory-traverse access to {root.path}, " + "then recheck the import issues.", + ], + ), + ), + ) + for state in mapping_states: + items.extend(_mapping_attention_item(state, reason) for reason in state.blockers) + root_paths = [str(root.lexical) for root in available_roots] + aggregate_findings = { + "library_root_alias": ( + "Two library roots point to the same physical folder.", + "Disable or remove the duplicate root before continuing.", + "Remove a duplicate library root", + ), + "nested_library_roots_ambiguous": ( + "Configured library roots overlap or are nested.", + "Keep one clear root boundary for these files before continuing.", + "Remove overlapping library roots", + ), + } + for code, (reason, suggested_action, title) in aggregate_findings.items(): + if code not in warnings: + continue + key = _attention_key(code, root_paths) + items.append( + MylarPathAttentionItem( + key=key, + code=code, + blocks_import=True, + reason=reason, + suggested_action=suggested_action, + details=MylarPathAttentionDetails( + title=title, + series_count=0, + location_count=0, + known_paths=root_paths[:6], + steps=[ + "Open Media Management and compare the configured library-root " + "paths shown here.", + "Disable or remove the duplicate or nested root without changing " + "the source files.", + "Return here and recheck the import issues.", + ], + ), + ) + ) + if partial: + key = _attention_key("partial_preview", _MAX_LOCATIONS) + items.append( + MylarPathAttentionItem( + key=key, + code="partial_preview", + blocks_import=True, + reason="The Mylar path check reached its safe inspection limit.", + suggested_action="Reduce the selected database scope before starting this import.", + details=MylarPathAttentionDetails( + title="Path analysis is incomplete", + series_count=0, + location_count=_MAX_LOCATIONS, + steps=[ + f"This preview inspected the first {_MAX_LOCATIONS:,} stored locations.", + "Use a smaller Mylar database or split the migration into bounded groups.", + "Recheck the import issues after reducing the source scope.", + ], + ), + ) + ) + return list({item.key: item for item in items}.values()) + + def _path_exception( entry: _Location, outcome: MylarPathOutcome, attempted: str ) -> MylarPathException: diff --git a/src/pullbox/services/import_mylar3_path_reports.py b/src/pullbox/services/import_mylar3_path_reports.py index 401f8de..4b2b7a7 100644 --- a/src/pullbox/services/import_mylar3_path_reports.py +++ b/src/pullbox/services/import_mylar3_path_reports.py @@ -27,6 +27,11 @@ def _directory() -> Path: return get_settings().data_dir / "diagnostics" / "mylar-preflight" +def report_directory() -> Path: + """Return the private directory used for retained Mylar preflight reports.""" + return _directory() + + def save_report(preview: MylarPathPreviewResponse, source_path: str) -> str: """Atomically retain recent reports, never opening the source database for writing.""" report_id = uuid4().hex @@ -37,7 +42,7 @@ def save_report(preview: MylarPathPreviewResponse, source_path: str) -> str: content = json.dumps(report, ensure_ascii=True).encode("utf-8") if len(content) > _MAX_REPORT_BYTES: raise ValueError("Mylar path report exceeds the diagnostic size limit") - directory = _directory() + directory = report_directory() directory.mkdir(parents=True, exist_ok=True, mode=0o700) temporary: Path | None = None try: @@ -58,7 +63,7 @@ def _report_paths() -> list[Path]: return sorted( ( path - for path in _directory().glob("*.json") + for path in report_directory().glob("*.json") if _REPORT_ID.fullmatch(path.stem) and not path.is_symlink() ), key=lambda path: path.stat().st_mtime, @@ -70,7 +75,7 @@ def load_report(report_id: str) -> dict[str, Any]: """Load only an application-generated report, never a caller-supplied path.""" if not _REPORT_ID.fullmatch(report_id): raise FileNotFoundError("Invalid preflight report") - path = _directory() / f"{report_id}.json" + path = report_directory() / f"{report_id}.json" if path.is_symlink(): raise FileNotFoundError("Invalid preflight report") stat = path.stat() diff --git a/src/pullbox/services/import_operation_progress.py b/src/pullbox/services/import_operation_progress.py index 518fe06..dbd0c5b 100644 --- a/src/pullbox/services/import_operation_progress.py +++ b/src/pullbox/services/import_operation_progress.py @@ -140,7 +140,12 @@ def build_import_operation_update( event: ImportProgressEvent, ) -> OperationProgressUpdate: """Map an import event into the shared durable projection contract.""" - source_label = "Mylar import" if job.source_type is ImportSourceType.MYLAR3 else "Folder import" + if dict(job.progress_snapshot or {}).get("clean_library_adoption") is True: + source_label = "Library organization" + else: + source_label = ( + "Mylar import" if job.source_type is ImportSourceType.MYLAR3 else "Folder import" + ) has_failures = bool((event.series_failed or job.series_failed or 0) > 0) attention_required = event.status in { ImportJobStatus.FAILED, diff --git a/src/pullbox/services/import_orphan_recovery_context.py b/src/pullbox/services/import_orphan_recovery_context.py index 5140a3e..d2f1d27 100644 --- a/src/pullbox/services/import_orphan_recovery_context.py +++ b/src/pullbox/services/import_orphan_recovery_context.py @@ -40,7 +40,7 @@ async def load_orphan_recovery_item( if job is None: raise NotFoundError("ImportJob", item.import_job_id) if job.status != ImportJobStatus.COMPLETED: - raise ValidationError("Unmatched recovery is only available for completed imports.") + raise ValidationError("Import follow-up is only available for completed imports.") has_live_issue_recovery = False if item.status == ImportSeriesStatus.IMPORTED: has_live_issue_recovery = bool( diff --git a/src/pullbox/services/import_orphans.py b/src/pullbox/services/import_orphans.py index 024d258..bb876e7 100644 --- a/src/pullbox/services/import_orphans.py +++ b/src/pullbox/services/import_orphans.py @@ -24,6 +24,7 @@ from pullbox.services.import_job_execution_items import ( ensure_target_issue_summary_for_import_file, ) +from pullbox.services.import_retry_helpers import require_retained_import_destination from pullbox.services.import_review_recheck import prepare_retryable_failed_sources_for_retry if TYPE_CHECKING: @@ -108,7 +109,7 @@ def _active_orphan_clause() -> ColumnElement[bool]: def is_active_orphan_row(item: ImportedSeries | None) -> bool: - """Return True when a row should appear in the active Unmatched queue.""" + """Return True when a row should appear in active import Follow-up.""" return bool( item is not None and ( @@ -213,6 +214,7 @@ async def recover_orphan( progress_callback: ProgressCallback | None = None, ) -> dict[str, Any]: """Create/reuse the local series and import selected files for delayed recovery.""" + require_retained_import_destination(job) job_id = job.id item_id = item.id has_identified_series = ( @@ -329,9 +331,9 @@ async def recover_orphan( return recovery_summary -def _orphaned_series_query() -> Select[tuple[ImportedSeries]]: +def _orphaned_series_query(*, job_id: int | None = None) -> Select[tuple[ImportedSeries]]: """Build the shared unresolved-orphan filter used by list and count queries.""" - return ( + query = ( sa_select(ImportedSeries) .join(ImportJob, ImportedSeries.import_job_id == ImportJob.id) .where( @@ -339,6 +341,9 @@ def _orphaned_series_query() -> Select[tuple[ImportedSeries]]: ImportJob.status == ImportJobStatus.COMPLETED, ) ) + if job_id is not None: + query = query.where(ImportedSeries.import_job_id == job_id) + return query async def get_orphaned_series( @@ -347,21 +352,16 @@ async def get_orphaned_series( page: int = 1, page_size: int = 25, sort: str = "file_count_desc", + job_id: int | None = None, ) -> tuple[list[ImportedSeries], int]: """Return paginated active orphaned import series from completed jobs.""" - count_q = ( - sa_select(sa_func.count()) - .select_from(ImportedSeries) - .join(ImportJob, ImportedSeries.import_job_id == ImportJob.id) - .where( - _active_orphan_clause(), - ImportJob.status == ImportJobStatus.COMPLETED, - ) + count_q = sa_select(sa_func.count()).select_from( + _orphaned_series_query(job_id=job_id).subquery() ) total_result = await session.execute(count_q) total = total_result.scalar() or 0 - query = _orphaned_series_query() + query = _orphaned_series_query(job_id=job_id) if sort == "series_name_asc": query = query.order_by(ImportedSeries.raw_series_name.asc()) elif sort == "date_found_desc": @@ -377,16 +377,10 @@ async def get_orphaned_series( return list(result.scalars().all()), total -async def get_orphaned_count(session: AsyncSession) -> int: +async def get_orphaned_count(session: AsyncSession, *, job_id: int | None = None) -> int: """Return total count of active orphaned series from completed jobs.""" - count_q = ( - sa_select(sa_func.count()) - .select_from(ImportedSeries) - .join(ImportJob, ImportedSeries.import_job_id == ImportJob.id) - .where( - _active_orphan_clause(), - ImportJob.status == ImportJobStatus.COMPLETED, - ) + count_q = sa_select(sa_func.count()).select_from( + _orphaned_series_query(job_id=job_id).subquery() ) result = await session.execute(count_q) return result.scalar() or 0 @@ -535,6 +529,8 @@ async def retry_failed_series( if job.status != ImportJobStatus.COMPLETED: raise ValidationError(f"Job must be in COMPLETED state to retry (current: {job.status})") + require_retained_import_destination(job) + normalized_file_ids = ( tuple(sorted({int(file_id) for file_id in file_ids})) if file_ids is not None else None ) diff --git a/src/pullbox/services/import_path_identity.py b/src/pullbox/services/import_path_identity.py index 4fc67cd..8c57593 100644 --- a/src/pullbox/services/import_path_identity.py +++ b/src/pullbox/services/import_path_identity.py @@ -35,11 +35,18 @@ def same_trusted_issue(recorded: SourceMetadata, actual: SourceMetadata) -> bool and recorded.comicvine_series_id != actual.comicvine_series_id ): return False - if ( - not recorded.series_name - or not actual.series_name - or NameMatcher.normalize(recorded.series_name) != NameMatcher.normalize(actual.series_name) - or recorded.issue_number != actual.issue_number + exact_series_identity = ( + recorded.comicvine_series_id is not None + and actual.comicvine_series_id is not None + and recorded.comicvine_series_id == actual.comicvine_series_id + ) + series_names_match = bool( + recorded.series_name + and actual.series_name + and NameMatcher.normalize(recorded.series_name) == NameMatcher.normalize(actual.series_name) + ) + if (not exact_series_identity and not series_names_match) or ( + recorded.issue_number != actual.issue_number ): return False recorded_issue = recorded.diagnostics.get("mylar3_issue") @@ -79,10 +86,28 @@ def unchanged_same_folder_pair(recorded: Path, actual: Path, signature: dict[str return False -def reconciliation_evidence(recorded: str, actual: str, issue_id: int) -> dict[str, Any]: - return { +def reconciliation_evidence( + recorded: str, + actual: str, + issue_id: int, + *, + recorded_series_name: str | None = None, + actual_series_name: str | None = None, +) -> dict[str, Any]: + evidence: dict[str, Any] = { "recorded_path": recorded, "actual_path": actual, "comicvine_issue_id": issue_id, "method": "verified_same_folder_issue_identity", } + if ( + recorded_series_name + and actual_series_name + and NameMatcher.normalize(recorded_series_name) != NameMatcher.normalize(actual_series_name) + ): + evidence["series_name_alias"] = { + "recorded": recorded_series_name, + "actual": actual_series_name, + "accepted_by": "exact_comicvine_series_and_issue_identity", + } + return evidence diff --git a/src/pullbox/services/import_path_reconciliation.py b/src/pullbox/services/import_path_reconciliation.py index 1e4b51a..df5085e 100644 --- a/src/pullbox/services/import_path_reconciliation.py +++ b/src/pullbox/services/import_path_reconciliation.py @@ -239,7 +239,11 @@ async def reconcile_saved_mylar_paths( continue evidence = { **reconciliation_evidence( - record.file_path, actual.file_path, record.comicvine_issue_id + record.file_path, + actual.file_path, + record.comicvine_issue_id, + recorded_series_name=base.series_name, + actual_series_name=metadata.series_name, ), "recorded_file_id": record.id, } diff --git a/src/pullbox/services/import_retry_helpers.py b/src/pullbox/services/import_retry_helpers.py index 8984776..2efd065 100644 --- a/src/pullbox/services/import_retry_helpers.py +++ b/src/pullbox/services/import_retry_helpers.py @@ -4,6 +4,7 @@ from typing import Any +from pullbox.core.exceptions import ValidationError from pullbox.models.import_job import ImportFileHandlingMode from pullbox.schemas.import_job import ImportJobCreate from pullbox.schemas.import_layout import SourceLayoutSpecPayload @@ -23,6 +24,7 @@ def build_retry_import_request(original: Any) -> ImportJobCreate: """Build the creation request for a fresh retry job.""" + require_retained_import_destination(original) source_layout_snapshot = getattr(original, "source_layout_snapshot", None) or {} future_root_policy_snapshot = getattr(original, "future_root_policy_snapshot", None) frozen_path_map = dict(original.mylar3_path_map or {}) @@ -54,6 +56,16 @@ def build_retry_import_request(original: Any) -> ImportJobCreate: ) +def require_retained_import_destination(job: Any) -> None: + """Never replace an explicitly removed historical destination with a default.""" + if getattr(job, "removed_library_root_snapshot", None) and job.target_library_root_id is None: + raise ValidationError( + "This import's library root was removed. Start a new import and explicitly " + "select an enabled library root. Existing library files and import history " + "are unchanged." + ) + + def copy_retry_import_settings(original: Any, retry: Any) -> None: """Copy runtime import policy fields onto the fresh retry job.""" for field_name in _RETRY_RUNTIME_FIELDS: diff --git a/src/pullbox/services/import_review_actions.py b/src/pullbox/services/import_review_actions.py index 1b276a8..d956584 100644 --- a/src/pullbox/services/import_review_actions.py +++ b/src/pullbox/services/import_review_actions.py @@ -21,11 +21,35 @@ ) from pullbox.services.import_duplicates import duplicate_merge_is_actionable, is_duplicate_series from pullbox.services.import_safety_diagnostics import normalize_import_safety_diagnostics +from pullbox.services.import_story_arc_resolution import ( + refresh_story_arc_entries_for_import_files, +) RecomputeFileCounters = Callable[[AsyncSession, ImportJob, list[int]], Awaitable[None]] RecomputeSeriesCounters = Callable[[AsyncSession, ImportJob], Awaitable[None]] +def prepare_series_for_safety_rematch(imported_series: ImportedSeries) -> bool: + """Restore a proven series target and mark it for safety-file rematching.""" + if imported_series.status == ImportSeriesStatus.SKIPPED: + if imported_series.series_id is not None: + imported_series.status = ImportSeriesStatus.DUPLICATE + elif imported_series.user_selected_cv_id is not None or imported_series.cv_id is not None: + imported_series.status = ImportSeriesStatus.MATCHED + + if imported_series.status not in { + ImportSeriesStatus.MATCHED, + ImportSeriesStatus.DUPLICATE, + }: + return False + + diagnostics = dict(imported_series.diagnostics or {}) + diagnostics["rematch_pending"] = True + imported_series.diagnostics = diagnostics + imported_series.selected_for_import = False + return True + + def apply_safety_allow_once_to_file( imp_file: ImportedFile, *, @@ -333,6 +357,13 @@ async def allow_safety_blocked_file_once( ) apply_safety_allow_once_to_file(imp_file, retry_import=retry_import) + if not retry_import: + prepare_series_for_safety_rematch(imported_series) + await refresh_story_arc_entries_for_import_files( + session, + import_job_id=job_id, + import_file_ids=[imp_file.id], + ) imported_series.selected_for_import = bool(retry_import) if retry_import and imported_series.status in { ImportSeriesStatus.IMPORTED, @@ -362,6 +393,22 @@ async def skip_safety_blocked_file( """Skip a safety-blocked file from the active import review.""" job, imported_series, imp_file = await _load_safety_blocked_file(session, job_id, file_id) + apply_safety_skip_to_file(imp_file) + await refresh_story_arc_entries_for_import_files( + session, + import_job_id=job_id, + import_file_ids=[imp_file.id], + ) + imported_series.selected_for_import = False + + await recompute_file_counters(session, job, [imported_series.id]) + await recompute_series_counters(session, job) + await session.flush() + return imp_file + + +def apply_safety_skip_to_file(imp_file: ImportedFile) -> None: + """Apply the source-preserving skip mutation to one reviewed file.""" diagnostics = dict(imp_file.diagnostics or {}) imp_file.status = ImportedFileStatus.SKIPPED imp_file.include_in_import = False @@ -379,12 +426,6 @@ async def skip_safety_blocked_file( "kind": "file_safety_review", "resolution": "skipped", } - imported_series.selected_for_import = False - - await recompute_file_counters(session, job, [imported_series.id]) - await recompute_series_counters(session, job) - await session.flush() - return imp_file def _series_has_match_target(imported_series: ImportedSeries) -> bool: diff --git a/src/pullbox/services/import_safety_bulk_review.py b/src/pullbox/services/import_safety_bulk_review.py index 561a57f..0938b88 100644 --- a/src/pullbox/services/import_safety_bulk_review.py +++ b/src/pullbox/services/import_safety_bulk_review.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Mapping -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import UTC, datetime from hashlib import sha256 from typing import TYPE_CHECKING, Any, Final, Literal @@ -21,17 +21,25 @@ ImportedSeries, ImportJob, ImportJobStatus, - ImportSeriesStatus, ImportSourceType, ) from pullbox.services.audit_service import AuditService -from pullbox.services.import_review_actions import apply_safety_allow_once_to_file +from pullbox.services.import_counters import recompute_file_counters, recompute_series_counters +from pullbox.services.import_review_actions import ( + apply_safety_allow_once_to_file, + apply_safety_skip_to_file, + prepare_series_for_safety_rematch, +) from pullbox.services.import_safety_diagnostics import ImportSafetyCategory +from pullbox.services.import_story_arc_resolution import ( + refresh_story_arc_entries_for_import_files, +) if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession IMPORT_SAFETY_BULK_CONFIRMATION: Final = "ALLOW ONCE" +IMPORT_SAFETY_BULK_SKIP_CONFIRMATION: Final = "SKIP FILES" IMPORT_SAFETY_BULK_PAGE_SIZE: Final = 200 IMPORT_SAFETY_PREVIEW_EXAMPLE_LIMIT: Final = 3 IMPORT_SAFETY_SNAPSHOT_PAGE_SIZE: Final = 20_000 @@ -40,7 +48,9 @@ _PREVIEW_TOKEN_MAX_AGE_SECONDS: Final = 15 * 60 _PREVIEW_TOKEN_VERSION: Final = 1 _ALLOW_ONCE_ACTION: Final = "allow_once" +_SKIP_ACTION: Final = "skip" _BULK_OVERRIDEABLE_CATEGORIES: Final = frozenset({ImportSafetyCategory.DECOMPRESSION_SIZE_LIMIT}) +_BULK_SKIPPABLE_CATEGORIES: Final = frozenset({ImportSafetyCategory.SINGLE_PAGE_COMIC}) @dataclass(frozen=True, slots=True) @@ -72,7 +82,7 @@ class ImportSafetyBulkPreview: @dataclass(frozen=True, slots=True) class ImportSafetyBulkResult: - """Counts from one category-specific allow-once operation.""" + """Counts from one category-specific safety review operation.""" job_id: int source_type: ImportSourceType @@ -266,6 +276,7 @@ def _build_preview_token( category: ImportSafetyCategory, actor_id: int, snapshot: ImportSafetyBulkSnapshot, + action: str = _ALLOW_ONCE_ACTION, ) -> str: return str( _serializer().dumps( @@ -274,7 +285,7 @@ def _build_preview_token( "job_id": job.id, "source_type": job.source_type.value, "category": category.value, - "action": _ALLOW_ONCE_ACTION, + "action": action, "actor_id": actor_id, "snapshot": _snapshot_payload(snapshot), } @@ -340,13 +351,14 @@ def _validate_preview_token_scope( job: ImportJob, category: ImportSafetyCategory, actor_id: int, + action: str = _ALLOW_ONCE_ACTION, ) -> ImportSafetyBulkSnapshot: if ( payload.get("version") != _PREVIEW_TOKEN_VERSION or _token_int(payload, "job_id") != job.id or payload.get("source_type") != job.source_type.value or payload.get("category") != category.value - or payload.get("action") != _ALLOW_ONCE_ACTION + or payload.get("action") != action or _token_int(payload, "actor_id") != actor_id ): raise ValidationError("The safety preview does not match this job and category.") @@ -403,6 +415,49 @@ async def preview_import_safety_category( ) +async def preview_import_safety_category_skip( + session: AsyncSession, + job_id: int, + category: ImportSafetyCategory, + *, + actor_id: int, + example_limit: int = IMPORT_SAFETY_PREVIEW_EXAMPLE_LIMIT, +) -> ImportSafetyBulkPreview: + """Preview a source-preserving category skip without mutating any file.""" + if category not in _BULK_SKIPPABLE_CATEGORIES: + raise ValidationError("This safety category cannot be bulk-skipped.") + job = await _load_review_job(session, job_id) + snapshot = await _load_snapshot(session, job_id, category) + if snapshot.matching_count == 0: + raise ValidationError( + "No safety-blocked files in this job use the selected structured category." + ) + skip_snapshot = replace(snapshot, eligible_count=snapshot.matching_count) + examples = await _load_bounded_examples( + session, + job_id, + category, + limit=example_limit, + ) + return ImportSafetyBulkPreview( + job_id=job.id, + source_type=job.source_type, + category=category, + matching_count=skip_snapshot.matching_count, + affected_count=skip_snapshot.matching_count, + skipped_count=0, + examples=examples, + overrideable=True, + preview_token=_build_preview_token( + job=job, + category=category, + actor_id=actor_id, + snapshot=skip_snapshot, + action=_SKIP_ACTION, + ), + ) + + def _result( *, job_id: int, @@ -430,12 +485,14 @@ async def _write_durable_bulk_audit( actor_username: str | None, source_ip: str | None, outcome: Literal["requested", "completed", "interrupted"], + action: str = _ALLOW_ONCE_ACTION, ) -> None: """Commit a fixed-field, path-free audit record before returning.""" + action_label = "skip" if action == _SKIP_ACTION else "override" detail_by_outcome = { - "requested": "Import safety category override requested.", - "completed": "Import safety category override completed.", - "interrupted": "Import safety category override interrupted.", + "requested": f"Import safety category {action_label} requested.", + "completed": f"Import safety category {action_label} completed.", + "interrupted": f"Import safety category {action_label} interrupted.", } await AuditService.log_event( session, @@ -448,7 +505,7 @@ async def _write_durable_bulk_audit( "job_id": result.job_id, "source_type": result.source_type.value, "category": result.category.value, - "action": _ALLOW_ONCE_ACTION, + "action": action, "affected_count": result.affected_count, "skipped_count": result.skipped_count, "outcome": outcome, @@ -611,6 +668,7 @@ async def allow_import_safety_category_once( break affected_series_ids: set[int] = set() + affected_file_ids: list[int] = [] for imp_file in page: cursor = max(cursor, imp_file.id) diagnostics = imp_file.diagnostics or {} @@ -623,6 +681,7 @@ async def allow_import_safety_category_once( ): continue apply_safety_allow_once_to_file(imp_file, allowed_at=allowed_at) + affected_file_ids.append(imp_file.id) affected_series_ids.add(imp_file.import_series_id) affected_count += 1 @@ -640,11 +699,13 @@ async def allow_import_safety_category_once( .all() ) for series in imported_series: - series.selected_for_import = False - if series.status in {ImportSeriesStatus.MATCHED, ImportSeriesStatus.DUPLICATE}: - diagnostics = dict(series.diagnostics or {}) - diagnostics["rematch_pending"] = True - series.diagnostics = diagnostics + prepare_series_for_safety_rematch(series) + + await refresh_story_arc_entries_for_import_files( + session, + import_job_id=job_id, + import_file_ids=affected_file_ids, + ) await session.flush() await session.commit() @@ -680,3 +741,227 @@ async def allow_import_safety_category_once( outcome="completed", ) return result + + +async def skip_import_safety_category( + session: AsyncSession, + job_id: int, + category: ImportSafetyCategory, + *, + actor_id: int, + actor_username: str | None = None, + source_ip: str | None = None, + preview_token: str, + page_size: int = IMPORT_SAFETY_BULK_PAGE_SIZE, +) -> ImportSafetyBulkResult: + """Skip one previewed one-page category while preserving every source file.""" + if category not in _BULK_SKIPPABLE_CATEGORIES: + raise ValidationError("This safety category cannot be bulk-skipped.") + if page_size < 1 or page_size > IMPORT_SAFETY_BULK_PAGE_SIZE: + raise ValidationError( + f"Safety bulk page size must be between 1 and {IMPORT_SAFETY_BULK_PAGE_SIZE}." + ) + + job = await _load_review_job(session, job_id) + payload = _load_preview_token(preview_token) + preview_snapshot = _validate_preview_token_scope( + payload, + job=job, + category=category, + actor_id=actor_id, + action=_SKIP_ACTION, + ) + if preview_snapshot.matching_count <= 0 or preview_snapshot.max_file_id is None: + raise ValidationError("This safety category has no files to skip.") + + requested_result = _result( + job_id=job_id, + source_type=job.source_type, + category=category, + affected_count=preview_snapshot.matching_count, + original_matching_count=preview_snapshot.matching_count, + pages_processed=0, + ) + await _write_durable_bulk_audit( + session, + requested_result, + actor_id=actor_id, + actor_username=actor_username, + source_ip=source_ip, + outcome="requested", + action=_SKIP_ACTION, + ) + + try: + job = await _load_review_job(session, job_id) + current_snapshot = replace( + await _load_snapshot(session, job_id, category), + eligible_count=preview_snapshot.matching_count, + ) + except (NotFoundError, ValidationError): + interrupted_result = _result( + job_id=job_id, + source_type=job.source_type, + category=category, + affected_count=0, + original_matching_count=preview_snapshot.matching_count, + pages_processed=0, + ) + await _write_durable_bulk_audit( + session, + interrupted_result, + actor_id=actor_id, + actor_username=actor_username, + source_ip=source_ip, + outcome="interrupted", + action=_SKIP_ACTION, + ) + raise + if current_snapshot != preview_snapshot: + interrupted_result = _result( + job_id=job_id, + source_type=job.source_type, + category=category, + affected_count=0, + original_matching_count=preview_snapshot.matching_count, + pages_processed=0, + ) + await _write_durable_bulk_audit( + session, + interrupted_result, + actor_id=actor_id, + actor_username=actor_username, + source_ip=source_ip, + outcome="interrupted", + action=_SKIP_ACTION, + ) + raise ValidationError("The safety category changed. Preview it again before confirming.") + + affected_count = 0 + pages_processed = 0 + cursor = 0 + max_file_id = preview_snapshot.max_file_id + max_updated_at = ( + datetime.fromisoformat(preview_snapshot.max_updated_at) + if preview_snapshot.max_updated_at is not None + else None + ) + while True: + current_job = await session.get(ImportJob, job_id, populate_existing=True) + if ( + current_job is None + or current_job.status != ImportJobStatus.REVIEW + or current_job.control_request != ImportControlRequest.NONE + ): + partial_result = _result( + job_id=job_id, + source_type=job.source_type, + category=category, + affected_count=affected_count, + original_matching_count=preview_snapshot.matching_count, + pages_processed=pages_processed, + ) + await _write_durable_bulk_audit( + session, + partial_result, + actor_id=actor_id, + actor_username=actor_username, + source_ip=source_ip, + outcome="interrupted", + action=_SKIP_ACTION, + ) + if affected_count: + raise ImportSafetyBulkInterruptedError(partial_result, reason="job_state_changed") + raise ValidationError("The import job is no longer available for safety review.") + + page = list( + ( + await session.execute( + select(ImportedFile) + .where( + *_category_filters(job_id, category), + ImportedFile.id > cursor, + ImportedFile.id <= max_file_id, + *((ImportedFile.updated_at <= max_updated_at,) if max_updated_at else ()), + ) + .order_by(ImportedFile.id) + .limit(page_size) + ) + ) + .scalars() + .all() + ) + if not page: + break + + affected_series_ids: set[int] = set() + affected_file_ids: list[int] = [] + for imp_file in page: + cursor = max(cursor, imp_file.id) + diagnostics = imp_file.diagnostics or {} + raw_block = diagnostics.get("safety_block") + if not isinstance(raw_block, Mapping) or raw_block.get("category") != category.value: + continue + apply_safety_skip_to_file(imp_file) + affected_file_ids.append(imp_file.id) + affected_series_ids.add(imp_file.import_series_id) + affected_count += 1 + + if affected_series_ids: + imported_series = list( + ( + await session.execute( + select(ImportedSeries).where( + ImportedSeries.import_job_id == job_id, + ImportedSeries.id.in_(sorted(affected_series_ids)), + ) + ) + ) + .scalars() + .all() + ) + for series in imported_series: + series.selected_for_import = False + await recompute_file_counters( + session, + current_job, + series_ids=sorted(affected_series_ids), + ) + await recompute_series_counters(session, current_job) + + await refresh_story_arc_entries_for_import_files( + session, + import_job_id=job_id, + import_file_ids=affected_file_ids, + ) + + await session.flush() + await session.commit() + pages_processed += 1 + session.sync_session.expunge_all() + + result = _result( + job_id=job_id, + source_type=job.source_type, + category=category, + affected_count=affected_count, + original_matching_count=preview_snapshot.matching_count, + pages_processed=pages_processed, + ) + outcome: Literal["completed", "interrupted"] = ( + "completed" if affected_count == preview_snapshot.matching_count else "interrupted" + ) + await _write_durable_bulk_audit( + session, + result, + actor_id=actor_id, + actor_username=actor_username, + source_ip=source_ip, + outcome=outcome, + action=_SKIP_ACTION, + ) + if outcome == "interrupted": + if affected_count: + raise ImportSafetyBulkInterruptedError(result, reason="scope_changed_during_apply") + raise ValidationError("The safety category changed. Preview it again before confirming.") + return result diff --git a/src/pullbox/services/import_safety_diagnostics.py b/src/pullbox/services/import_safety_diagnostics.py index 3f7712d..1bbb100 100644 --- a/src/pullbox/services/import_safety_diagnostics.py +++ b/src/pullbox/services/import_safety_diagnostics.py @@ -33,7 +33,7 @@ class ImportSafetyCategory(enum.StrEnum): ImportSafetyCategory.ARCHIVE_INSPECTION_FAILED: "Archive inspection failed", ImportSafetyCategory.ZERO_BYTE: "Zero-byte file", ImportSafetyCategory.ARCHIVE_NO_PAGES: "No comic pages", - ImportSafetyCategory.SINGLE_PAGE_COMIC: "Possible cover-only file", + ImportSafetyCategory.SINGLE_PAGE_COMIC: "One-page archive", ImportSafetyCategory.DECOMPRESSION_SIZE_LIMIT: "Decompression-size limit", ImportSafetyCategory.DANGEROUS_PATH_OR_PAYLOAD: "Dangerous link, path, or payload", ImportSafetyCategory.OUTSIDE_APPROVED_ROOT: "Outside approved root", @@ -65,8 +65,8 @@ def import_safety_category_label(category: ImportSafetyCategory) -> str: "Replace the file or skip it; its series identity is preserved." ), ImportSafetyCategory.SINGLE_PAGE_COMIC: ( - "The archive contains only one image page and may be an alternate cover. " - "Review the source and allow once only if this is intentionally a one-page comic." + "The archive contains one image page. It may be cover art, a damaged archive, " + "or an intentional one-page comic. Review it before importing." ), ImportSafetyCategory.DECOMPRESSION_SIZE_LIMIT: ( "The archive exceeds Pullbox's configured decompressed-size limit." @@ -82,9 +82,9 @@ def import_safety_category_label(category: ImportSafetyCategory) -> str: "The source changed or became unavailable after scanning. Rescan before retrying." ), ImportSafetyCategory.SOURCE_MISSING: ( - "No file was found at the recorded path. The saved filename may be outdated, " - "or the file may have been moved or removed. Verify the source location; " - "reconcile a proven replacement or skip this missing reference." + "No file was found at the recorded path, and Pullbox could not find a safe, verified " + "replacement. The saved filename may be outdated, or the file may have been moved or " + "removed. Verify the source location or skip this missing reference." ), ImportSafetyCategory.UNKNOWN: ( "Pullbox blocked this file because its safety inspection did not complete safely." diff --git a/src/pullbox/services/import_safety_source_cleanup.py b/src/pullbox/services/import_safety_source_cleanup.py new file mode 100644 index 0000000..bddf800 --- /dev/null +++ b/src/pullbox/services/import_safety_source_cleanup.py @@ -0,0 +1,301 @@ +"""Explicit source cleanup for one-page import artifacts.""" + +from __future__ import annotations + +import asyncio +import os +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Final + +from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer +from sqlalchemy import func, select + +from pullbox.config import get_settings +from pullbox.core.config_resolver import get_application_secret +from pullbox.core.exceptions import NotFoundError, ValidationError +from pullbox.core.library_file_ownership import ( + build_file_identity_signature, + validate_file_identity_signature, +) +from pullbox.models.audit_log import AuditEventType +from pullbox.models.import_job import ( + ImportedFile, + ImportedFileStatus, + ImportedSeries, + ImportJob, + ImportJobStatus, + ImportSeriesStatus, + ImportSourceType, +) +from pullbox.models.library import LibraryRoot +from pullbox.services.audit_service import AuditService +from pullbox.services.import_counters import recompute_file_counters, recompute_series_counters +from pullbox.services.import_review_actions import apply_safety_skip_to_file +from pullbox.services.import_runtime_settings import load_import_utility_trash_folder +from pullbox.services.import_safety_diagnostics import ImportSafetyCategory +from pullbox.services.import_story_arc_resolution import ( + refresh_story_arc_entries_for_import_files, +) +from pullbox.utilities.settings import ( + move_file_to_utility_trash, + resolve_trash_directory, + restore_file_from_utility_trash, +) + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + +_TOKEN_SALT: Final = "import-one-page-source-cleanup-v1" +_TOKEN_MAX_AGE_SECONDS: Final = 15 * 60 + + +@dataclass(frozen=True, slots=True) +class OnePageSourceCleanupPreview: + job_id: int + file_id: int + file_name: str + can_move_to_trash: bool + unavailable_reason: str + preview_token: str | None + + +@dataclass(frozen=True, slots=True) +class OnePageSourceCleanupResult: + trash_path: Path + + +@dataclass(frozen=True, slots=True) +class _CleanupContext: + job: ImportJob + imported_file: ImportedFile + imported_series: ImportedSeries + source: Path + signature: dict[str, int | str] + trash_dir: Path | None + unavailable_reason: str + + +def _serializer() -> URLSafeTimedSerializer: + return URLSafeTimedSerializer(get_application_secret(), salt=_TOKEN_SALT) + + +def _is_within(path: Path, root: Path) -> bool: + try: + path.relative_to(root) + except ValueError: + return False + return True + + +async def _load_context( + session: AsyncSession, + job_id: int, + file_id: int, +) -> _CleanupContext: + job = await session.get(ImportJob, job_id) + if job is None: + raise NotFoundError("ImportJob", job_id) + if job.status not in {ImportJobStatus.REVIEW, ImportJobStatus.COMPLETED}: + raise ValidationError( + "Source cleanup is available during review or after a completed import." + ) + imported_file = await session.get(ImportedFile, file_id) + if imported_file is None or imported_file.import_job_id != job_id: + raise NotFoundError("ImportedFile", file_id) + block = imported_file.diagnostics.get("safety_block", {}) + if ( + imported_file.status is not ImportedFileStatus.SAFETY_BLOCKED + or not isinstance(block, Mapping) + or block.get("category") != ImportSafetyCategory.SINGLE_PAGE_COMIC.value + ): + raise ValidationError("Only a one-page archive in safety review can use this action.") + imported_series = await session.get(ImportedSeries, imported_file.import_series_id) + if imported_series is None or imported_series.import_job_id != job_id: + raise NotFoundError("ImportedSeries", imported_file.import_series_id) + + raw_source = Path(imported_file.file_path).expanduser() + try: + if raw_source.is_symlink(): + raise ValidationError("Symlinked source files cannot be removed from import review.") + source = raw_source.resolve(strict=True) + signature = build_file_identity_signature(source) + validate_file_identity_signature(dict(imported_file.source_signature or {}), signature) + except ValidationError: + raise + except Exception as exc: + raise ValidationError( + "The source file changed or is unavailable. Re-scan before removing it." + ) from exc + + unavailable_reason = "" + if job.source_type is ImportSourceType.MYLAR3: + roots = list( + ( + await session.scalars( + select(LibraryRoot).where( + LibraryRoot.enabled.is_(True), + LibraryRoot.allow_referenced_registrations.is_(True), + ) + ) + ).all() + ) + containing = [] + for root in roots: + try: + resolved_root = Path(root.path).expanduser().resolve(strict=True) + except (OSError, RuntimeError, ValueError): + continue + if _is_within(source, resolved_root): + containing.append(root) + if not containing or not any(root.allow_managed_writes for root in containing): + unavailable_reason = ( + "This Mylar source is registered as reference-only. Enable managed writes for " + "its library root only if you intentionally want Pullbox to move source files." + ) + else: + try: + source_root = Path(job.source_path).expanduser().resolve(strict=True) + except (OSError, RuntimeError, ValueError): + source_root = source.parent + if not _is_within(source, source_root) or not os.access(source.parent, os.W_OK): + unavailable_reason = "Pullbox does not have permission to move this source file." + + configured_trash = await load_import_utility_trash_folder(session) + settings = get_settings() + trash_dir = resolve_trash_directory( + configured_trash, + library_root=settings.library_root, + data_dir=settings.data_dir, + ) + if trash_dir is None: + unavailable_reason = ( + unavailable_reason + or "Configure the Trash folder in Media Management before removing source files." + ) + return _CleanupContext( + job=job, + imported_file=imported_file, + imported_series=imported_series, + source=source, + signature=signature, + trash_dir=trash_dir, + unavailable_reason=unavailable_reason, + ) + + +async def preview_one_page_source_cleanup( + session: AsyncSession, + job_id: int, + file_id: int, + *, + actor_id: int, +) -> OnePageSourceCleanupPreview: + """Preview an individual source move without changing the source or database.""" + context = await _load_context(session, job_id, file_id) + token = None + if not context.unavailable_reason: + token = str( + _serializer().dumps( + { + "job_id": job_id, + "file_id": file_id, + "actor_id": actor_id, + "signature": context.signature, + } + ) + ) + return OnePageSourceCleanupPreview( + job_id=job_id, + file_id=file_id, + file_name=context.imported_file.file_name, + can_move_to_trash=token is not None, + unavailable_reason=context.unavailable_reason, + preview_token=token, + ) + + +def _load_token(token: str) -> Mapping[str, object]: + try: + payload = _serializer().loads(token, max_age=_TOKEN_MAX_AGE_SECONDS) + except SignatureExpired as exc: + raise ValidationError("The source cleanup preview expired. Preview it again.") from exc + except BadSignature as exc: + raise ValidationError("The source cleanup preview is invalid. Preview it again.") from exc + if not isinstance(payload, Mapping): + raise ValidationError("The source cleanup preview is invalid. Preview it again.") + return payload + + +async def move_one_page_source_to_trash( + session: AsyncSession, + job_id: int, + file_id: int, + *, + actor_id: int, + preview_token: str, + actor_username: str | None = None, + source_ip: str | None = None, +) -> OnePageSourceCleanupResult: + """Move one explicitly previewed source to Trash and mark it skipped.""" + payload = _load_token(preview_token) + if ( + payload.get("job_id") != job_id + or payload.get("file_id") != file_id + or payload.get("actor_id") != actor_id + ): + raise ValidationError("The source cleanup preview does not match this file.") + expected_signature = payload.get("signature") + if not isinstance(expected_signature, Mapping): + raise ValidationError("The source cleanup preview is invalid. Preview it again.") + + context = await _load_context(session, job_id, file_id) + if context.unavailable_reason or context.trash_dir is None: + raise ValidationError(context.unavailable_reason or "Trash is not configured.") + validate_file_identity_signature(dict(expected_signature), context.signature) + + relative_path = Path("import-review") / str(job_id) / context.source.name + trash_path = await asyncio.to_thread( + move_file_to_utility_trash, + context.source, + context.trash_dir, + relative_path=relative_path, + ) + try: + apply_safety_skip_to_file(context.imported_file) + await refresh_story_arc_entries_for_import_files( + session, + import_job_id=job_id, + import_file_ids=[context.imported_file.id], + ) + context.imported_series.selected_for_import = False + await recompute_file_counters( + session, + context.job, + series_ids=[context.imported_series.id], + ) + remaining_file_count = await session.scalar( + select(func.count(ImportedFile.id)).where( + ImportedFile.import_series_id == context.imported_series.id, + ImportedFile.status != ImportedFileStatus.SKIPPED, + ) + ) + if not remaining_file_count: + context.imported_series.status = ImportSeriesStatus.SKIPPED + await recompute_series_counters(session, context.job) + await AuditService.log_event( + session, + AuditEventType.IMPORT_SAFETY_SOURCE_TRASH, + source_ip=source_ip, + user_id=actor_id, + username=actor_username, + detail="One reviewed import source moved to Trash.", + metadata={"job_id": job_id, "file_id": file_id}, + ) + await session.commit() + except Exception: + await session.rollback() + await asyncio.to_thread(restore_file_from_utility_trash, trash_path, context.source) + raise + return OnePageSourceCleanupResult(trash_path=trash_path) diff --git a/src/pullbox/services/import_scan_materialization.py b/src/pullbox/services/import_scan_materialization.py index 40cc50a..1af4536 100644 --- a/src/pullbox/services/import_scan_materialization.py +++ b/src/pullbox/services/import_scan_materialization.py @@ -25,6 +25,9 @@ _SOURCE_LAYOUT_REVIEW_MESSAGE = ( "This file does not fit the selected source layout. Review its series before importing." ) +_MYLAR_FOLDER_SCOPE_REVIEW_MESSAGE = ( + "This unrecorded file appears to belong to another series in the selected Mylar folder." +) def _requires_source_layout_review(metadata_diagnostics: dict[str, object]) -> bool: @@ -37,6 +40,11 @@ def _requires_source_layout_review(metadata_diagnostics: dict[str, object]) -> b ) +def _requires_mylar_folder_scope_review(metadata_diagnostics: dict[str, object]) -> bool: + """Return whether an unrecorded file contradicts its owning Mylar series folder.""" + return isinstance(metadata_diagnostics.get("mylar3_folder_scope_conflict"), dict) + + async def materialize_discovered_scan_results( session: AsyncSession, job: ImportJob, @@ -106,9 +114,10 @@ async def materialize_discovered_scan_results( metadata_diagnostics = dict(df.metadata_diagnostics) safety_block = metadata_diagnostics.pop("file_safety", None) source_layout_review = _requires_source_layout_review(metadata_diagnostics) + mylar_folder_scope_review = _requires_mylar_folder_scope_review(metadata_diagnostics) if isinstance(safety_block, dict): file_status = ImportedFileStatus.SAFETY_BLOCKED - elif source_layout_review: + elif source_layout_review or mylar_folder_scope_review: file_status = ImportedFileStatus.NO_MATCH else: file_status = ImportedFileStatus.PENDING @@ -120,6 +129,13 @@ async def materialize_discovered_scan_results( "metadata_signals": dict(df.metadata_signals), "source_metadata": metadata_diagnostics, } + cross_folder_reconciliation = metadata_diagnostics.get( + "mylar3_cross_folder_reconciliation" + ) + if isinstance(cross_folder_reconciliation, dict): + diagnostics["mylar3_cross_folder_reconciliation"] = dict( + cross_folder_reconciliation + ) if isinstance(safety_block, dict): diagnostics["safety_block"] = safety_block elif source_layout_review: @@ -130,6 +146,15 @@ async def materialize_discovered_scan_results( "rejection_reason": _SOURCE_LAYOUT_REVIEW_MESSAGE, } ) + elif mylar_folder_scope_review: + diagnostics.update( + { + "kind": "source_scope_review", + "reason": "mylar3_folder_scope_conflict", + "rejection_reason": _MYLAR_FOLDER_SCOPE_REVIEW_MESSAGE, + "preserve_series_match": True, + } + ) error_message = safety_block.get("reason") if isinstance(safety_block, dict) else None file_item = dict( import_job_id=job.id, diff --git a/src/pullbox/services/import_scan_reconciliation.py b/src/pullbox/services/import_scan_reconciliation.py index a45a91e..d90a516 100644 --- a/src/pullbox/services/import_scan_reconciliation.py +++ b/src/pullbox/services/import_scan_reconciliation.py @@ -3,9 +3,11 @@ from __future__ import annotations from collections import defaultdict +from hashlib import sha256 from pathlib import Path from typing import TYPE_CHECKING +from pullbox.core.name_matcher import NameMatcher from pullbox.core.source_metadata import MetadataSignal, SourceMetadata, SourceMetadataExtractor from pullbox.services.import_path_identity import ( reconciliation_evidence, @@ -44,11 +46,9 @@ def reconcile_discovered_mylar_paths(discovered_list: list[DiscoveredSeries]) -> if not isinstance(evidence, dict) or evidence.get("member_index_scanned") is not True: continue sidecar = cached_mylar_sidecar_data(file.metadata_diagnostics) - if sidecar is None: - continue metadata = extractor.from_path( path, - sidecar_data=sidecar, + sidecar_data=sidecar or {}, archive_member_evidence=evidence, include_archive_entry_issue_hint=False, ) @@ -79,15 +79,429 @@ def reconcile_discovered_mylar_paths(discovered_list: list[DiscoveredSeries]) -> Path(record.file_path), Path(actual.file_path), dict(actual.source_signature) ): continue - actual.metadata_diagnostics = { - **actual.metadata_diagnostics, - "mylar3_path_reconciliation": reconciliation_evidence( - record.file_path, actual.file_path, key[1] - ), - } + diagnostics = dict(actual.metadata_diagnostics) + diagnostics.pop("mylar3_folder_scope_conflict", None) + diagnostics.pop("mylar3_unrecorded_file", None) + recorded_issue = record.metadata_diagnostics.get("mylar3_issue") + if isinstance(recorded_issue, dict): + diagnostics["mylar3_issue"] = dict(recorded_issue) + diagnostics["mylar3_path_reconciliation"] = reconciliation_evidence( + record.file_path, + actual.file_path, + key[1], + recorded_series_name=base.series_name, + actual_series_name=metadata.series_name, + ) + actual.metadata_diagnostics = diagnostics + actual.parsed_series = record.parsed_series + actual.parsed_issue_number = record.parsed_issue_number + actual.issue_number_raw = record.issue_number_raw + actual.issue_type = record.issue_type + actual.comicvine_issue_id = metadata.comicvine_issue_id + actual.comicvine_series_id = record.comicvine_series_id + actual.has_comicinfo = True + signals = dict(actual.metadata_signals) + signals["comicvine_issue_id"] = MetadataSignal.COMICINFO.value + signals["comicvine_series_id"] = MetadataSignal.COMICINFO.value + signals["issue_number"] = MetadataSignal.COMICINFO.value + signals["series_name"] = MetadataSignal.MYLAR3.value + actual.metadata_signals = signals removed.add(record.file_path) if removed: series.files = [file for file in series.files if file.file_path not in removed] - series.file_count = len(series.files) - series.sample_paths = [file.file_path for file in series.files[:5]] - series.has_files = bool(series.files) + _refresh_series_shape(series) + _reconcile_cross_folder_mylar_paths(discovered_list, extractor) + + +def _content_hash(path: str) -> str | None: + digest = sha256() + try: + with Path(path).open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + except OSError: + return None + return digest.hexdigest() + + +def _source_metadata( + file: DiscoveredFile, + extractor: SourceMetadataExtractor, +) -> SourceMetadata | None: + evidence = file.metadata_diagnostics.get("archive_member_evidence") + if not isinstance(evidence, dict) or evidence.get("member_index_scanned") is not True: + return None + sidecar = cached_mylar_sidecar_data(file.metadata_diagnostics) + return extractor.from_path( + Path(file.file_path), + sidecar_data=sidecar or {}, + archive_member_evidence=evidence, + include_archive_entry_issue_hint=False, + ) + + +def _recorded_metadata(file: DiscoveredFile) -> SourceMetadata: + return SourceMetadata( + original_title=file.file_name, + series_name=file.parsed_series, + issue_number=file.parsed_issue_number, + issue_type=file.issue_type, + comicvine_issue_id=file.comicvine_issue_id, + comicvine_series_id=file.comicvine_series_id, + signals={"comicvine_issue_id": MetadataSignal.MYLAR3}, + diagnostics=file.metadata_diagnostics, + ) + + +def _series_issue_filename_key( + *, + series_id: int | None, + issue_number: float | None, + file_name: str, +) -> tuple[int, float, str] | None: + if series_id is None or issue_number is None or not file_name: + return None + return int(series_id), float(issue_number), file_name.casefold() + + +def _same_trusted_series_issue_filename( + recorded: DiscoveredFile, + actual_file: DiscoveredFile, + actual: SourceMetadata, +) -> bool: + """Accept a stale Mylar issue ID only when every independent slot signal agrees.""" + if ( + not recorded.comicvine_series_id + or actual.comicvine_series_id != recorded.comicvine_series_id + or actual.comicvine_issue_id is None + or actual.signals.get("comicvine_series_id") is not MetadataSignal.COMICINFO + or actual.signals.get("comicvine_issue_id") is not MetadataSignal.COMICINFO + or actual.signals.get("series_name") is not MetadataSignal.COMICINFO + or actual.signals.get("issue_number") is not MetadataSignal.COMICINFO + or recorded.file_name.casefold() != actual_file.file_name.casefold() + or recorded.parsed_issue_number != actual.issue_number + or recorded.issue_type != actual.issue_type + or recorded.metadata_diagnostics.get("identity_conflicts") + or actual.diagnostics.get("identity_conflicts") + ): + return False + return bool( + recorded.parsed_series + and actual.series_name + and NameMatcher.normalize(recorded.parsed_series) + == NameMatcher.normalize(actual.series_name) + ) + + +def _choose_cross_folder_canonical( + recorded: DiscoveredFile, + candidates: list[tuple[DiscoveredSeries, DiscoveredFile, SourceMetadata]], +) -> ( + tuple[ + tuple[DiscoveredSeries, DiscoveredFile, SourceMetadata], + list[tuple[DiscoveredSeries, DiscoveredFile, SourceMetadata]], + ] + | None +): + exact_name = [item for item in candidates if item[1].file_name == recorded.file_name] + preferred = exact_name if exact_name else candidates + if len(preferred) == 1: + canonical = preferred[0] + else: + hashes = {_content_hash(item[1].file_path) for item in preferred} + if None in hashes or len(hashes) != 1: + return None + canonical = min( + preferred, key=lambda item: (item[1].file_name.casefold(), item[1].file_path) + ) + + canonical_hash = _content_hash(canonical[1].file_path) + identical: list[tuple[DiscoveredSeries, DiscoveredFile, SourceMetadata]] = [] + if canonical_hash is not None: + for item in candidates: + if item is canonical or item[1].file_size != canonical[1].file_size: + continue + if _content_hash(item[1].file_path) == canonical_hash: + identical.append(item) + return canonical, identical + + +def _apply_cross_folder_identity( + file: DiscoveredFile, + recorded: DiscoveredFile, + metadata: SourceMetadata, + *, + source_series: DiscoveredSeries, + role: str, + method: str = "verified_cross_folder_issue_identity", + canonical_path: str | None = None, +) -> None: + diagnostics = dict(file.metadata_diagnostics) + diagnostics.pop("mylar3_folder_scope_conflict", None) + diagnostics.pop("mylar3_unrecorded_file", None) + recorded_issue = recorded.metadata_diagnostics.get("mylar3_issue") + if isinstance(recorded_issue, dict): + diagnostics["mylar3_issue"] = dict(recorded_issue) + actual_issue_id = int(metadata.comicvine_issue_id or recorded.comicvine_issue_id or 0) + recorded_issue_id = int(recorded.comicvine_issue_id or 0) + evidence: dict[str, object] = { + "recorded_path": recorded.file_path, + "actual_path": file.file_path, + "comicvine_issue_id": actual_issue_id, + "comicvine_series_id": int(recorded.comicvine_series_id or 0), + "method": method, + "role": role, + "source_series": source_series.raw_series_name, + } + if recorded_issue_id != actual_issue_id: + evidence["recorded_comicvine_issue_id"] = recorded_issue_id + if ( + recorded.parsed_series + and metadata.series_name + and NameMatcher.normalize(recorded.parsed_series) + != NameMatcher.normalize(metadata.series_name) + ): + evidence["series_name_alias"] = { + "recorded": recorded.parsed_series, + "actual": metadata.series_name, + "accepted_by": "exact_comicvine_series_and_issue_identity", + } + if canonical_path is not None: + evidence["canonical_path"] = canonical_path + diagnostics["mylar3_cross_folder_reconciliation"] = evidence + file.metadata_diagnostics = diagnostics + file.parsed_series = recorded.parsed_series + file.parsed_issue_number = recorded.parsed_issue_number + file.issue_number_raw = recorded.issue_number_raw + file.issue_type = recorded.issue_type + file.comicvine_issue_id = actual_issue_id or None + file.comicvine_series_id = recorded.comicvine_series_id + file.has_comicinfo = True + signals = dict(file.metadata_signals) + signals["comicvine_issue_id"] = MetadataSignal.COMICINFO.value + signals["comicvine_series_id"] = signals.get("comicvine_series_id", MetadataSignal.MYLAR3.value) + signals["issue_number"] = signals.get("issue_number", MetadataSignal.MYLAR3.value) + signals["series_name"] = signals.get("series_name", MetadataSignal.MYLAR3.value) + file.metadata_signals = signals + + +def _has_diagnostic_value( + file: DiscoveredFile, + diagnostic_name: str, + key: str, + value: str, +) -> bool: + block = file.metadata_diagnostics.get(diagnostic_name) + return isinstance(block, dict) and block.get(key) == value + + +def _refresh_series_shape(series: DiscoveredSeries) -> None: + conflicts = [ + file for file in series.files if "mylar3_folder_scope_conflict" in file.metadata_diagnostics + ] + diagnostics = dict(series.diagnostics) + if conflicts: + diagnostics["mylar3_folder_scope"] = { + "review_required": True, + "unrecorded_file_count": sum( + "mylar3_issue" not in file.metadata_diagnostics for file in series.files + ), + "conflicting_file_count": len(conflicts), + "examples": [file.file_name for file in conflicts[:5]], + } + else: + diagnostics.pop("mylar3_folder_scope", None) + + recovered_files = [ + file + for file in series.files + if _has_diagnostic_value( + file, + "mylar3_cross_folder_reconciliation", + "role", + "canonical", + ) + ] + missing_files = [ + file + for file in series.files + if _has_diagnostic_value(file, "file_safety", "code", "source_missing") + ] + if ( + recovered_files + and diagnostics.get("kind") == "mylar3_path_incompatible" + and diagnostics.get("reason") == "source_missing" + ): + diagnostics.pop("kind", None) + diagnostics.pop("reason", None) + diagnostics.pop("rejection_reason", None) + path_details = diagnostics.get("mylar3_path") + if isinstance(path_details, dict): + path_details = dict(path_details) + path_details["status"] = "partially_reconciled" if missing_files else "reconciled" + diagnostics["mylar3_path"] = path_details + diagnostics["mylar3_path_recovery"] = { + "status": "partial" if missing_files else "complete", + "recovered_file_count": len(recovered_files), + "remaining_missing_file_count": len(missing_files), + } + series.diagnostics = diagnostics + series.file_count = len(series.files) + series.sample_paths = [file.file_path for file in series.files[:5]] + series.has_files = bool(series.files) + + +def _reconcile_cross_folder_mylar_paths( + discovered_list: list[DiscoveredSeries], + extractor: SourceMetadataExtractor, +) -> None: + """Link proven misplaced files to one missing Mylar issue without moving sources.""" + missing_by_issue: dict[int, list[tuple[DiscoveredSeries, DiscoveredFile]]] = defaultdict(list) + missing_by_slot: dict[ + tuple[int, float, str], + list[tuple[DiscoveredSeries, DiscoveredFile]], + ] = defaultdict(list) + candidates_by_issue: dict[ + int, + list[tuple[DiscoveredSeries, DiscoveredFile, SourceMetadata]], + ] = defaultdict(list) + candidates_by_slot: dict[ + tuple[int, float, str], + list[tuple[DiscoveredSeries, DiscoveredFile, SourceMetadata]], + ] = defaultdict(list) + for series in discovered_list: + for file in series.files: + block = file.metadata_diagnostics.get("file_safety") + if ( + isinstance(block, dict) + and block.get("code") == "source_missing" + and file.comicvine_issue_id + and file.comicvine_series_id + and file.metadata_signals.get("comicvine_issue_id") == MetadataSignal.MYLAR3.value + and not file.source_signature + ): + missing_by_issue[int(file.comicvine_issue_id)].append((series, file)) + slot = _series_issue_filename_key( + series_id=file.comicvine_series_id, + issue_number=file.parsed_issue_number, + file_name=file.file_name, + ) + if slot is not None: + missing_by_slot[slot].append((series, file)) + continue + if ( + not file.source_signature + or "mylar3_folder_scope_conflict" not in file.metadata_diagnostics + or file.metadata_diagnostics.get("file_safety") + or file.metadata_diagnostics.get("identity_conflicts") + ): + continue + metadata = _source_metadata(file, extractor) + if ( + metadata is None + or metadata.comicvine_issue_id is None + or metadata.signals.get("comicvine_issue_id") is not MetadataSignal.COMICINFO + ): + continue + candidates_by_issue[int(metadata.comicvine_issue_id)].append((series, file, metadata)) + slot = _series_issue_filename_key( + series_id=metadata.comicvine_series_id, + issue_number=metadata.issue_number, + file_name=file.file_name, + ) + if slot is not None: + candidates_by_slot[slot].append((series, file, metadata)) + + changed_series: dict[int, DiscoveredSeries] = {} + used_paths: set[str] = set() + resolved_records: set[int] = set() + + def apply_choice( + target_series: DiscoveredSeries, + recorded: DiscoveredFile, + canonical: tuple[DiscoveredSeries, DiscoveredFile, SourceMetadata], + identical: list[tuple[DiscoveredSeries, DiscoveredFile, SourceMetadata]], + *, + method: str, + ) -> None: + source_series, canonical_file, metadata = canonical + moved = [canonical, *identical] + _apply_cross_folder_identity( + canonical_file, + recorded, + metadata, + source_series=source_series, + role="canonical", + method=method, + ) + for duplicate_series, duplicate, duplicate_metadata in identical: + _apply_cross_folder_identity( + duplicate, + recorded, + duplicate_metadata, + source_series=duplicate_series, + role="identical_duplicate", + method=method, + canonical_path=canonical_file.file_path, + ) + target_series.files = [file for file in target_series.files if file is not recorded] + for owner, file, _metadata in moved: + owner.files = [owned_file for owned_file in owner.files if owned_file is not file] + changed_series[id(owner)] = owner + used_paths.add(file.file_path) + target_series.files.extend(item[1] for item in moved) + changed_series[id(target_series)] = target_series + resolved_records.add(id(recorded)) + + for issue_id, records in missing_by_issue.items(): + candidates = [ + item + for item in candidates_by_issue.get(issue_id, []) + if item[1].file_path not in used_paths + ] + if len(records) != 1 or not candidates: + continue + target_series, recorded = records[0] + recorded_metadata = _recorded_metadata(recorded) + verified = [ + item + for item in candidates + if same_trusted_issue(recorded_metadata, item[2]) + and ( + item[2].comicvine_series_id is None + or item[2].comicvine_series_id == recorded.comicvine_series_id + ) + ] + choice = _choose_cross_folder_canonical(recorded, verified) if verified else None + if choice is None: + continue + canonical, identical = choice + apply_choice( + target_series, + recorded, + canonical, + identical, + method="verified_cross_folder_issue_identity", + ) + + for slot, records in missing_by_slot.items(): + unresolved = [item for item in records if id(item[1]) not in resolved_records] + candidates = [ + item for item in candidates_by_slot.get(slot, []) if item[1].file_path not in used_paths + ] + if len(unresolved) != 1 or len(candidates) != 1: + continue + target_series, recorded = unresolved[0] + candidate = candidates[0] + if not _same_trusted_series_issue_filename(recorded, candidate[1], candidate[2]): + continue + apply_choice( + target_series, + recorded, + candidate, + [], + method="verified_cross_folder_series_issue_filename", + ) + + for series in changed_series.values(): + _refresh_series_shape(series) diff --git a/src/pullbox/services/import_series_file_processor.py b/src/pullbox/services/import_series_file_processor.py index 7246858..124b751 100644 --- a/src/pullbox/services/import_series_file_processor.py +++ b/src/pullbox/services/import_series_file_processor.py @@ -110,6 +110,9 @@ async def register_import_file( | None = None, recovery_imported_file_id: int | None = None, recovery_original_source_path: Path | None = None, + replace_existing_library_file: bool = False, + replacement_trash_dir: Path | None = None, + preserve_replaced_artifact: bool = False, source_scan_root: Path | None = None, strict_import_target: bool = False, ) -> LibraryFile | LibraryFileRegistrationOutcome: @@ -134,6 +137,9 @@ async def register_import_file( comicinfo_progress_callback=comicinfo_progress_callback, recovery_imported_file_id=recovery_imported_file_id, recovery_original_source_path=recovery_original_source_path, + replace_existing_library_file=replace_existing_library_file, + replacement_trash_dir=replacement_trash_dir, + preserve_replaced_artifact=preserve_replaced_artifact, source_scan_root=source_scan_root, strict_import_target=strict_import_target, ) diff --git a/src/pullbox/services/import_service.py b/src/pullbox/services/import_service.py index 18b39a9..faec8d8 100644 --- a/src/pullbox/services/import_service.py +++ b/src/pullbox/services/import_service.py @@ -29,7 +29,10 @@ ImportJobStatus, ImportSourceType, ) -from pullbox.services.import_catalog_hydration import run_pending_catalog_hydration +from pullbox.services.import_catalog_hydration import ( + ensure_catalog_hydration_retry_scheduled, + run_pending_catalog_hydration, +) from pullbox.services.import_comicinfo_enrichment import ( run_pending_import_comicinfo_enrichment, schedule_import_comicinfo_enrichment, @@ -788,10 +791,15 @@ async def recover_pending_catalog_hydration( session_factory: async_sessionmaker[AsyncSession], ) -> int: """Resume full catalog hydration left pending after a restart.""" - return await run_pending_catalog_hydration( + recovered = await run_pending_catalog_hydration( + session_factory, + series_service=self._series_service, + ) + await ensure_catalog_hydration_retry_scheduled( session_factory, series_service=self._series_service, ) + return recovered async def _process_series_files( self, diff --git a/src/pullbox/services/import_service_recovery.py b/src/pullbox/services/import_service_recovery.py index ed0ad3d..47ffbc0 100644 --- a/src/pullbox/services/import_service_recovery.py +++ b/src/pullbox/services/import_service_recovery.py @@ -71,6 +71,8 @@ async def get_orphaned_series( page: int = 1, page_size: int = 25, sort: str = "file_count_desc", + *, + job_id: int | None = None, ) -> tuple[list[ImportedSeries], int]: """Return paginated ImportedSeries with status=NO_MATCH from COMPLETED jobs.""" return await get_import_orphaned_series( @@ -78,14 +80,17 @@ async def get_orphaned_series( page=page, page_size=page_size, sort=sort, + job_id=job_id, ) async def get_orphaned_count( self, session: AsyncSession, + *, + job_id: int | None = None, ) -> int: """Return total count of active unmatched series from COMPLETED jobs.""" - return await get_import_orphaned_count(session) + return await get_import_orphaned_count(session, job_id=job_id) async def _load_orphan_recovery_item( self, diff --git a/src/pullbox/services/import_service_review.py b/src/pullbox/services/import_service_review.py index 2284a06..7dd2c32 100644 --- a/src/pullbox/services/import_service_review.py +++ b/src/pullbox/services/import_service_review.py @@ -341,11 +341,6 @@ async def allow_safety_blocked_file_once( imported_series = await session.get(ImportedSeries, imp_file.import_series_id) if imported_series is None: raise NotFoundError("ImportedSeries", imp_file.import_series_id) - if imported_series.status in {ImportSeriesStatus.MATCHED, ImportSeriesStatus.DUPLICATE}: - diagnostics = dict(imported_series.diagnostics or {}) - diagnostics["rematch_pending"] = True - imported_series.diagnostics = diagnostics - await session.flush() return imported_series async def allow_safety_blocked_file_once_for_retry( @@ -393,6 +388,7 @@ async def skip_safety_blocked_file( (imported_series.files_matched or 0) <= 0 and (imported_series.files_conflict or 0) <= 0 and (imported_series.files_no_match or 0) <= 0 + and int((imported_series.diagnostics or {}).get("safety_blocked_files") or 0) <= 0 ): job = await session.get(ImportJob, job_id) if job is None: diff --git a/src/pullbox/services/import_source_metadata.py b/src/pullbox/services/import_source_metadata.py index 14f393c..9806bf4 100644 --- a/src/pullbox/services/import_source_metadata.py +++ b/src/pullbox/services/import_source_metadata.py @@ -19,6 +19,7 @@ SourceMetadataExtractor, volume_subtitle_hint_from_filename, ) +from pullbox.core.type_semantics import issue_type_family from pullbox.models.import_job import ImportedFile, ImportedFileStatus, ImportedSeries from pullbox.models.issue import IssueType @@ -397,9 +398,35 @@ async def load_deferred_source_metadata_for_import_file( update["issue_number"] = base_metadata.issue_number if loaded_metadata.year is None and base_metadata.year is not None: update["year"] = base_metadata.year + issue_identity_reconciliation = _corroborated_comicinfo_issue_reconciliation( + base_metadata, + loaded_metadata, + ) identity_conflicts = _reconcile_loaded_exact_identities(base_metadata, loaded_metadata) + if issue_identity_reconciliation is not None: + recorded_issue_id = issue_identity_reconciliation["recorded_comicvine_issue_id"] + embedded_issue_id = issue_identity_reconciliation["embedded_comicvine_issue_id"] + identity_conflicts = [ + conflict + for conflict in identity_conflicts + if not ( + conflict.get("field") == "comicvine_issue_id" + and conflict.get("first") == recorded_issue_id + and conflict.get("conflicting") == embedded_issue_id + ) + ] reconciliation = base_metadata.diagnostics.get("mylar3_path_reconciliation") diagnostics = dict(loaded_metadata.diagnostics) + if issue_identity_reconciliation is not None: + diagnostics["mylar3_issue_identity_reconciliation"] = issue_identity_reconciliation + update["diagnostics"] = diagnostics + folder_scope_conflict = _deferred_mylar_folder_scope_conflict( + base_metadata, + loaded_metadata, + ) + if folder_scope_conflict is not None: + diagnostics["mylar3_folder_scope_conflict"] = folder_scope_conflict + update["diagnostics"] = diagnostics if isinstance(reconciliation, dict): diagnostics["mylar3_path_reconciliation"] = dict(reconciliation) update["diagnostics"] = diagnostics @@ -408,6 +435,9 @@ async def load_deferred_source_metadata_for_import_file( **diagnostics, "identity_conflicts": identity_conflicts, } + elif "identity_conflicts" in diagnostics: + diagnostics.pop("identity_conflicts", None) + update["diagnostics"] = diagnostics base_series_signal = base_metadata.signals.get("comicvine_series_id") if ( base_metadata.comicvine_series_id is not None @@ -419,7 +449,11 @@ async def load_deferred_source_metadata_for_import_file( "comicvine_series_id": MetadataSignal.MYLAR3, } base_issue_signal = base_metadata.signals.get("comicvine_issue_id") - if base_metadata.comicvine_issue_id is not None and base_issue_signal == MetadataSignal.MYLAR3: + if ( + base_metadata.comicvine_issue_id is not None + and base_issue_signal == MetadataSignal.MYLAR3 + and issue_identity_reconciliation is None + ): updated_signals = update.get("signals") if not isinstance(updated_signals, dict): updated_signals = loaded_metadata.signals @@ -527,6 +561,80 @@ def _reconcile_loaded_exact_identities( return conflicts +def _corroborated_comicinfo_issue_reconciliation( + base_metadata: SourceMetadata, + loaded_metadata: SourceMetadata, +) -> dict[str, object] | None: + """Prefer an embedded issue ID when independent local evidence proves a stale Mylar ID.""" + recorded_issue_id = base_metadata.comicvine_issue_id + embedded_issue_id = loaded_metadata.comicvine_issue_id + base_issue_type_is_unqualified = ( + base_metadata.issue_type == IssueType.ISSUE and "issue_type" not in base_metadata.signals + ) + if ( + recorded_issue_id is None + or embedded_issue_id is None + or recorded_issue_id == embedded_issue_id + or base_metadata.signals.get("comicvine_issue_id") != MetadataSignal.MYLAR3 + or loaded_metadata.signals.get("comicvine_issue_id") != MetadataSignal.COMICINFO + or base_metadata.comicvine_series_id is None + or loaded_metadata.comicvine_series_id is None + or base_metadata.comicvine_series_id != loaded_metadata.comicvine_series_id + or loaded_metadata.signals.get("comicvine_series_id") != MetadataSignal.COMICINFO + or base_metadata.issue_number is None + or loaded_metadata.issue_number is None + or not _issue_numbers_equal(base_metadata.issue_number, loaded_metadata.issue_number) + or ( + not base_issue_type_is_unqualified + and issue_type_family(base_metadata.issue_type) + != issue_type_family(loaded_metadata.issue_type) + ) + or loaded_metadata.diagnostics.get("has_comicinfo") is not True + ): + return None + + raw_loaded_conflicts = loaded_metadata.diagnostics.get("identity_conflicts") + if isinstance(raw_loaded_conflicts, list) and any( + isinstance(conflict, dict) + and conflict.get("field") in {"comicvine_series_id", "comicvine_issue_id"} + for conflict in raw_loaded_conflicts + ): + return None + + return { + "recorded_comicvine_issue_id": int(recorded_issue_id), + "embedded_comicvine_issue_id": int(embedded_issue_id), + "comicvine_series_id": int(loaded_metadata.comicvine_series_id), + "issue_number": float(loaded_metadata.issue_number), + "method": "corroborated_embedded_comicinfo", + } + + +def _deferred_mylar_folder_scope_conflict( + base_metadata: SourceMetadata, + loaded_metadata: SourceMetadata, +) -> dict[str, object] | None: + """Detect a foreign embedded series on an unrecorded file in a Mylar folder.""" + raw_scope = base_metadata.diagnostics.get("mylar3_unrecorded_file") + if not isinstance(raw_scope, dict): + return None + signal = loaded_metadata.signals.get("series_name") + if signal not in {MetadataSignal.COMICINFO, MetadataSignal.SIDECAR}: + return None + expected_series = str(raw_scope.get("expected_series") or "").strip() + parsed_series = (loaded_metadata.series_name or "").strip() + if not expected_series or not parsed_series: + return None + if NameMatcher.normalize(expected_series) == NameMatcher.normalize(parsed_series): + return None + return { + "expected_series": expected_series, + "parsed_series": parsed_series, + "recorded_issue": False, + "signal": signal.value, + } + + def source_metadata_for_import_series(imp_series: ImportedSeries) -> SourceMetadata: """Build shared semantic metadata from persisted import series scan fields.""" diagnostics = dict(imp_series.diagnostics or {}) @@ -908,6 +1016,22 @@ def build_import_metadata_conflict( target_issue_title: str | None, ) -> dict[str, Any] | None: """Explain why import refused an auto-match due to conflicting strong signals.""" + folder_scope_conflict = metadata.diagnostics.get("mylar3_folder_scope_conflict") + if isinstance(folder_scope_conflict, dict): + return { + "kind": "source_scope_review", + "reason": "mylar3_folder_scope_conflict", + "preserve_series_match": True, + "rejection_reason": ( + "This unrecorded file appears to belong to another series in the selected " + "Mylar folder." + ), + **folder_scope_conflict, + "target_series": target_series_title, + "target_series_year": target_series_year, + "target_issue_number": target_issue_number, + "target_issue_cv_id": target_issue_cv_id, + } raw_identity_conflicts = metadata.diagnostics.get("identity_conflicts") identity_conflicts = ( [dict(conflict) for conflict in raw_identity_conflicts if isinstance(conflict, dict)] diff --git a/src/pullbox/services/import_story_arc_detection.py b/src/pullbox/services/import_story_arc_detection.py index 4877fe9..5a408ad 100644 --- a/src/pullbox/services/import_story_arc_detection.py +++ b/src/pullbox/services/import_story_arc_detection.py @@ -70,30 +70,31 @@ def detect_folder_story_arc( display_name = _collapse_whitespace(item.story_arc) normalized_names.setdefault(normalize_story_arc_name(display_name), display_name) - if any(not item.evidence_complete for item in files) and ( - len(series_keys) > 1 or bool(named) or bool(ordered) - ): + if len(normalized_names) > 1: return _result( FolderArcClassification.NEEDS_REVIEW, - "incomplete_arc_evidence", - next(iter(normalized_names.values()), _clean_folder_label(folder_label)), + "conflicting_exact_arc_names", + None, files, series_keys, ordered, ) - if len(normalized_names) > 1: + ordered_mixed = len(series_keys) > 1 and len(ordered) == len(files) and bool(files) + has_strong_arc_evidence = bool(named) or ordered_mixed + + if any(not item.evidence_complete for item in files) and has_strong_arc_evidence: return _result( FolderArcClassification.NEEDS_REVIEW, - "conflicting_exact_arc_names", - None, + "incomplete_arc_evidence", + next(iter(normalized_names.values()), _clean_folder_label(folder_label)), files, series_keys, ordered, ) order_keys = [_order_key(item.story_arc_number or "") for item in ordered] - if len(order_keys) != len(set(order_keys)): + if has_strong_arc_evidence and len(order_keys) != len(set(order_keys)): return _result( FolderArcClassification.NEEDS_REVIEW, "duplicate_arc_order", @@ -113,7 +114,6 @@ def detect_folder_story_arc( ordered, ) - ordered_mixed = len(series_keys) > 1 and len(ordered) == len(files) and bool(files) if ordered_mixed and confirmed_order_pattern: return _result( FolderArcClassification.STORY_ARC, diff --git a/src/pullbox/services/import_story_arc_resolution.py b/src/pullbox/services/import_story_arc_resolution.py index 46bc591..5197ca7 100644 --- a/src/pullbox/services/import_story_arc_resolution.py +++ b/src/pullbox/services/import_story_arc_resolution.py @@ -123,6 +123,52 @@ async def resolve_staged_story_arc_entries( ) +async def refresh_story_arc_entries_for_import_files( + session: AsyncSession, + *, + import_job_id: int, + import_file_ids: Sequence[int], +) -> int: + """Refresh derived arc state after authoritative import-file mutations.""" + normalized_file_ids = tuple(dict.fromkeys(int(value) for value in import_file_ids)) + if not normalized_file_ids: + return 0 + + entries = list( + ( + await session.scalars( + select(ImportedStoryArcEntry) + .join( + ImportedStoryArc, + ImportedStoryArc.id == ImportedStoryArcEntry.imported_story_arc_id, + ) + .where( + ImportedStoryArc.import_job_id == import_job_id, + ImportedStoryArcEntry.import_file_id.in_(normalized_file_ids), + ) + .order_by(ImportedStoryArcEntry.id) + ) + ).all() + ) + if not entries: + return 0 + + indexes = await _load_resolution_indexes( + session, + import_job_id=import_job_id, + entries=entries, + ) + for entry in entries: + _resolve_entry(entry, indexes) + + await _refresh_arc_safety_diagnostics( + session, + arc_ids={int(entry.imported_story_arc_id) for entry in entries}, + ) + await session.flush() + return len(entries) + + async def _load_resolution_indexes( session: AsyncSession, *, @@ -242,6 +288,9 @@ def _resolve_entry(entry: ImportedStoryArcEntry, indexes: _ResolutionIndexes) -> if candidate is not None: entry.import_file_id = candidate.id + if candidate.status == ImportedFileStatus.SKIPPED: + _mark_skipped(entry, reason="source_file_skipped") + return if candidate.status == ImportedFileStatus.CONFLICT: _mark_review( entry, @@ -250,13 +299,17 @@ def _resolve_entry(entry: ImportedStoryArcEntry, indexes: _ResolutionIndexes) -> ) return if candidate.status == ImportedFileStatus.SAFETY_BLOCKED: - _mark_review( + _mark_safety_review( entry, - StoryArcResolutionState.AMBIGUOUS, - "source_file_safety_blocked", + candidate, ) return + stale_safety_review = _review_reason(entry) == "source_file_safety_blocked" + _clear_current_safety_diagnostics(entry) + if entry.resolution_state == StoryArcResolutionState.AMBIGUOUS and stale_safety_review: + entry.resolution_state = StoryArcResolutionState.PENDING + candidate_issue = ( indexes.issues_by_id.get(candidate.matched_issue_id) if candidate.matched_issue_id is not None @@ -369,6 +422,7 @@ def _mark_resolved(entry: ImportedStoryArcEntry, issue: Issue, *, method: str) - entry.resolution_method = method diagnostics = dict(entry.diagnostics or {}) diagnostics.pop("review_reason", None) + _archive_and_remove_safety_code(diagnostics) diagnostics["resolution_evidence"] = "trusted_local_exact_identity" entry.diagnostics = diagnostics @@ -387,6 +441,89 @@ def _mark_review( entry.diagnostics = diagnostics +def _mark_safety_review(entry: ImportedStoryArcEntry, item: ImportedFile) -> None: + _mark_review( + entry, + StoryArcResolutionState.AMBIGUOUS, + "source_file_safety_blocked", + ) + diagnostics = dict(entry.diagnostics or {}) + safety_block = item.diagnostics.get("safety_block") if item.diagnostics else None + safety_code = safety_block.get("code") if isinstance(safety_block, dict) else None + if isinstance(safety_code, str) and safety_code.strip(): + diagnostics["safety_code"] = safety_code.strip() + entry.diagnostics = diagnostics + + +def _mark_skipped(entry: ImportedStoryArcEntry, *, reason: str) -> None: + entry.matched_issue_id = None + entry.resolution_state = StoryArcResolutionState.SKIPPED + entry.resolution_confidence = None + entry.resolution_method = None + entry.selected_for_import = False + diagnostics = dict(entry.diagnostics or {}) + _archive_and_remove_safety_code(diagnostics) + diagnostics["review_reason"] = reason + entry.diagnostics = diagnostics + + +def _clear_current_safety_diagnostics(entry: ImportedStoryArcEntry) -> None: + diagnostics = dict(entry.diagnostics or {}) + _archive_and_remove_safety_code(diagnostics) + if diagnostics.get("review_reason") == "source_file_safety_blocked": + diagnostics.pop("review_reason", None) + entry.diagnostics = diagnostics + + +def _archive_and_remove_safety_code(diagnostics: dict[str, object]) -> None: + safety_code = diagnostics.pop("safety_code", None) + if isinstance(safety_code, str) and safety_code.strip(): + diagnostics.setdefault("scan_safety_code", safety_code.strip()) + + +def _review_reason(entry: ImportedStoryArcEntry) -> str | None: + reason = (entry.diagnostics or {}).get("review_reason") + return reason if isinstance(reason, str) else None + + +async def _refresh_arc_safety_diagnostics( + session: AsyncSession, + *, + arc_ids: set[int], +) -> None: + if not arc_ids: + return + blocked_arc_ids = { + int(arc_id) + for arc_id in ( + await session.scalars( + select(ImportedStoryArcEntry.imported_story_arc_id) + .join(ImportedFile, ImportedFile.id == ImportedStoryArcEntry.import_file_id) + .where( + ImportedStoryArcEntry.imported_story_arc_id.in_(arc_ids), + ImportedFile.status == ImportedFileStatus.SAFETY_BLOCKED, + ) + .distinct() + ) + ).all() + } + arcs = list( + ( + await session.scalars(select(ImportedStoryArc).where(ImportedStoryArc.id.in_(arc_ids))) + ).all() + ) + for arc in arcs: + diagnostics = dict(arc.diagnostics or {}) + if diagnostics.get("safety_incomplete") is True: + diagnostics.setdefault("scan_safety_incomplete", True) + if diagnostics.get("safety_blocked") is True: + diagnostics.setdefault("scan_safety_blocked", True) + has_current_block = int(arc.id) in blocked_arc_ids + diagnostics["safety_incomplete"] = has_current_block + diagnostics["safety_blocked"] = has_current_block + arc.diagnostics = diagnostics + + def _parse_provider_id(value: str | None) -> int | None: if value is None: return None diff --git a/src/pullbox/services/import_story_arc_review.py b/src/pullbox/services/import_story_arc_review.py index 867bd6e..98454ec 100644 --- a/src/pullbox/services/import_story_arc_review.py +++ b/src/pullbox/services/import_story_arc_review.py @@ -10,7 +10,11 @@ from sqlalchemy.orm import selectinload from pullbox.core.exceptions import NotFoundError, ValidationError -from pullbox.models.import_job import ImportJob, ImportJobStatus +from pullbox.core.story_arc_naming import ( + DEFAULT_STORY_ARC_FILE_TEMPLATE, + DEFAULT_STORY_ARC_FOLDER_TEMPLATE, +) +from pullbox.models.import_job import ImportedFile, ImportedFileStatus, ImportJob, ImportJobStatus from pullbox.models.story_arc import ( ImportedStoryArcStatus, StoryArc, @@ -75,6 +79,70 @@ class ImportedStoryArcReviewPage: StoryArcDecisionTuple = tuple[int, StoryArcReviewAction, int | None] +async def auto_confirm_trusted_logical_story_arcs( + session: AsyncSession, + job_id: int, + *, + batch_size: int = 100, +) -> int: + """Confirm exact local arc evidence for logical post-import creation only.""" + if isinstance(batch_size, bool) or batch_size <= 0: + raise ValidationError("Story arc auto-confirmation batch size must be positive") + + confirmed = 0 + last_id = 0 + while True: + arcs = list( + ( + await session.scalars( + select(ImportedStoryArc) + .where( + ImportedStoryArc.import_job_id == job_id, + ImportedStoryArc.id > last_id, + ImportedStoryArc.status.in_( + ( + ImportedStoryArcStatus.DETECTED, + ImportedStoryArcStatus.NEEDS_REVIEW, + ) + ), + ImportedStoryArc.selected_for_import.is_(False), + ) + .options(selectinload(ImportedStoryArc.entries)) + .order_by(ImportedStoryArc.id) + .limit(batch_size) + ) + ).all() + ) + if not arcs: + break + + blocked_arc_ids = await _load_arc_ids_with_current_safety( + session, + [int(arc.id) for arc in arcs], + ) + for arc in arcs: + if int(arc.id) in blocked_arc_ids or not _has_trusted_complete_arc_evidence(arc): + continue + arc.proposed_policy_snapshot = _logical_auto_policy(arc.source_kind) + diagnostics = dict(arc.diagnostics or {}) + diagnostics["auto_confirmation"] = { + "schema_version": 1, + "scope": "logical_membership_only", + "evidence": "trusted_local_exact_identity", + } + arc.diagnostics = diagnostics + arc.status = ImportedStoryArcStatus.CONFIRMED + arc.selected_for_import = True + for entry in arc.entries: + entry.selected_for_import = ( + entry.resolution_state != StoryArcResolutionState.SKIPPED + ) + confirmed += 1 + await session.flush() + last_id = int(arcs[-1].id) + return confirmed + + async def load_import_story_arc_review_page( session: AsyncSession, job_id: int, @@ -110,13 +178,14 @@ async def load_import_story_arc_review_page( ) arc_ids = [int(arc.id) for arc in arcs] counts_by_arc_id = await _load_entry_counts(session, arc_ids) + safety_blocked_arc_ids = await _load_arc_ids_with_current_safety(session, arc_ids) candidates_by_arc_id = await _load_merge_candidates(session, arcs) rows: list[ImportedStoryArcReviewRow] = [] for arc in arcs: counts = counts_by_arc_id.get(int(arc.id), {}) conflict_count = counts.get(StoryArcResolutionState.CONFLICT, 0) - safety_blocked = _arc_has_safety_findings(arc) + safety_blocked = int(arc.id) in safety_blocked_arc_ids block_reason: str | None = None if safety_blocked: block_reason = "Resolve safety findings before selecting this story arc." @@ -211,7 +280,14 @@ async def update_import_story_arc_decision( return staged_arc await _validate_merge_target(session, proposed_story_arc_id) - _assert_story_arc_selectable(staged_arc, entries) + safety_blocked_arc_ids = await _load_arc_ids_with_current_safety( + session, + [int(staged_arc.id)], + ) + _assert_story_arc_selectable( + entries, + has_current_safety_block=int(staged_arc.id) in safety_blocked_arc_ids, + ) staged_arc.status = ImportedStoryArcStatus.READY staged_arc.selected_for_import = True staged_arc.proposed_story_arc_id = proposed_story_arc_id @@ -250,7 +326,9 @@ async def confirm_import_story_arcs( ImportedStoryArc.import_job_id == job_id, ImportedStoryArc.id.in_(page_ids), ) - .options(selectinload(ImportedStoryArc.entries)) + .options( + selectinload(ImportedStoryArc.entries).joinedload(ImportedStoryArcEntry.import_file) + ) .order_by(ImportedStoryArc.id) ) arcs_by_id.update( @@ -272,6 +350,11 @@ async def confirm_import_story_arcs( ) merge_targets: dict[int, StoryArc] = {} await _load_merge_targets_by_id(session, target_ids, merge_targets) + safety_blocked_arc_ids = { + arc_id + for arc_id, staged_arc in arcs_by_id.items() + if _loaded_arc_has_current_safety(staged_arc) + } for arc_id, action, proposed_story_arc_id in normalized_decisions: _apply_loaded_story_arc_decision( @@ -279,6 +362,7 @@ async def confirm_import_story_arcs( action=action, proposed_story_arc_id=proposed_story_arc_id, merge_targets=merge_targets, + has_current_safety_block=arc_id in safety_blocked_arc_ids, ) for arc_id in compatibility_ids: @@ -290,6 +374,7 @@ async def confirm_import_story_arcs( action="select", proposed_story_arc_id=staged_arc.proposed_story_arc_id, merge_targets=merge_targets, + has_current_safety_block=arc_id in safety_blocked_arc_ids, ) confirmed_count = 0 @@ -302,7 +387,9 @@ async def confirm_import_story_arcs( ImportedStoryArc.selected_for_import.is_(True), ImportedStoryArc.id > last_id, ) - .options(selectinload(ImportedStoryArc.entries)) + .options( + selectinload(ImportedStoryArc.entries).joinedload(ImportedStoryArcEntry.import_file) + ) .order_by(ImportedStoryArc.id) .limit(batch_size) ) @@ -320,7 +407,10 @@ async def confirm_import_story_arcs( staged_arc.proposed_story_arc_id, merge_targets, ) - _assert_story_arc_selectable(staged_arc, staged_arc.entries) + _assert_story_arc_selectable( + staged_arc.entries, + has_current_safety_block=_loaded_arc_has_current_safety(staged_arc), + ) if staged_arc.status != ImportedStoryArcStatus.READY: raise ValidationError( "Select each story arc from Step 3 before confirming the import" @@ -358,6 +448,7 @@ def _apply_loaded_story_arc_decision( action: StoryArcReviewAction, proposed_story_arc_id: int | None, merge_targets: Mapping[int, StoryArc], + has_current_safety_block: bool, ) -> None: if action not in {"select", "skip"}: raise ValidationError("Story arc review action must be select or skip") @@ -372,7 +463,10 @@ def _apply_loaded_story_arc_decision( return _validate_loaded_merge_target(proposed_story_arc_id, merge_targets) - _assert_story_arc_selectable(staged_arc, staged_arc.entries) + _assert_story_arc_selectable( + staged_arc.entries, + has_current_safety_block=has_current_safety_block, + ) staged_arc.status = ImportedStoryArcStatus.READY staged_arc.selected_for_import = True staged_arc.proposed_story_arc_id = proposed_story_arc_id @@ -393,6 +487,69 @@ def _validate_loaded_merge_target( raise ValidationError("An archived story arc cannot be selected as a merge target") +def _has_trusted_complete_arc_evidence(staged_arc: ImportedStoryArc) -> bool: + if not staged_arc.name or not staged_arc.name.strip() or not staged_arc.entries: + return False + active_entries = [ + entry + for entry in staged_arc.entries + if entry.resolution_state != StoryArcResolutionState.SKIPPED + ] + if not active_entries or any( + entry.resolution_state + in { + StoryArcResolutionState.PENDING, + StoryArcResolutionState.AMBIGUOUS, + StoryArcResolutionState.CONFLICT, + } + for entry in active_entries + ): + return False + + diagnostics = staged_arc.diagnostics or {} + if staged_arc.source_kind == StoryArcSourceKind.MYLAR3: + return diagnostics.get("source_name_present") is True and all( + _is_provider_id(entry.source_issue_id) for entry in active_entries + ) + if staged_arc.source_kind == StoryArcSourceKind.COMICINFO: + return all( + entry.resolution_state == StoryArcResolutionState.RESOLVED for entry in active_entries + ) + if staged_arc.source_kind == StoryArcSourceKind.FOLDER: + return diagnostics.get("reason") == "consistent_exact_arc_name" and all( + entry.resolution_state == StoryArcResolutionState.RESOLVED + and (entry.evidence or {}).get("has_comicinfo") is True + for entry in active_entries + ) + return False + + +def _is_provider_id(value: str | None) -> bool: + return value is not None and value.strip().isdecimal() and int(value.strip()) > 0 + + +def _logical_auto_policy(source_kind: StoryArcSourceKind) -> dict[str, object]: + return { + "schema_version": 1, + "source": source_kind.value, + "activation": "confirmed", + "monitored": False, + "search_missing": False, + "include_upcoming": False, + "sync_enabled": False, + "placement_policy": { + "schema_version": 1, + "mode": "logical", + "target_library_root_id": None, + "destination_root": None, + "folder_template": DEFAULT_STORY_ARC_FOLDER_TEMPLATE, + "file_template": DEFAULT_STORY_ARC_FILE_TEMPLATE, + "symlink_style": None, + "synchronize": False, + }, + } + + async def _require_review_job(session: AsyncSession, job_id: int) -> ImportJob: job = await session.get(ImportJob, job_id) if job is None: @@ -413,12 +570,11 @@ async def _validate_merge_target(session: AsyncSession, story_arc_id: int | None def _assert_story_arc_selectable( - staged_arc: ImportedStoryArc, entries: Sequence[ImportedStoryArcEntry], + *, + has_current_safety_block: bool, ) -> None: - if _arc_has_safety_findings(staged_arc) or any( - _entry_has_safety_finding(entry) for entry in entries - ): + if has_current_safety_block: raise ValidationError("Resolve story arc safety findings before confirming this arc") if any(entry.resolution_state == StoryArcResolutionState.CONFLICT for entry in entries): raise ValidationError( @@ -426,15 +582,36 @@ def _assert_story_arc_selectable( ) -def _arc_has_safety_findings(staged_arc: ImportedStoryArc) -> bool: - diagnostics = _mapping(staged_arc.diagnostics) - return diagnostics.get("safety_incomplete") is True or diagnostics.get("safety_blocked") is True +def _loaded_arc_has_current_safety(staged_arc: ImportedStoryArc) -> bool: + """Return whether an eagerly loaded arc still links to a safety-blocked file.""" + return any( + entry.import_file is not None + and entry.import_file.status == ImportedFileStatus.SAFETY_BLOCKED + for entry in staged_arc.entries + ) -def _entry_has_safety_finding(entry: ImportedStoryArcEntry) -> bool: - diagnostics = _mapping(entry.diagnostics) - safety_code = diagnostics.get("safety_code") - return isinstance(safety_code, str) and bool(safety_code.strip()) +async def _load_arc_ids_with_current_safety( + session: AsyncSession, + arc_ids: Sequence[int], +) -> set[int]: + """Return arcs whose currently linked import file is still safety blocked.""" + if not arc_ids: + return set() + return { + int(arc_id) + for arc_id in ( + await session.scalars( + select(ImportedStoryArcEntry.imported_story_arc_id) + .join(ImportedFile, ImportedFile.id == ImportedStoryArcEntry.import_file_id) + .where( + ImportedStoryArcEntry.imported_story_arc_id.in_(arc_ids), + ImportedFile.status == ImportedFileStatus.SAFETY_BLOCKED, + ) + .distinct() + ) + ).all() + } async def _load_entry_counts( diff --git a/src/pullbox/services/import_workflow_state.py b/src/pullbox/services/import_workflow_state.py index ece4f31..513bb2f 100644 --- a/src/pullbox/services/import_workflow_state.py +++ b/src/pullbox/services/import_workflow_state.py @@ -34,6 +34,12 @@ SCAN_PROGRESS_FILE_MATCH_START = 80 SCAN_PROGRESS_FILE_MATCH_END = 99 WORKFLOW_SNAPSHOT_VERSION = 2 +_PERSISTENT_IMPORT_CONTEXT_KEYS = ( + "clean_library_adoption", + "clean_library_adoption_prepared", + "clean_library_source_snapshot", + "source_import_job_id", +) ImportProgressMode = Literal["scan", "import", "rollback"] _INVENTORY_PROGRESS_THRESHOLDS: tuple[tuple[int, int], ...] = ( (1, 1), @@ -505,9 +511,23 @@ def runtime_snapshot_payload( ), "control_state": import_control_state_for_job(job), } + for key in _PERSISTENT_IMPORT_CONTEXT_KEYS: + if key in snapshot: + runtime[key] = snapshot[key] return runtime +def _scale_clean_library_progress(job: ImportJob, event: ImportProgressEvent) -> None: + """Reserve the first five percent for clean-library plan preparation.""" + snapshot = dict(job.progress_snapshot or {}) + if ( + snapshot.get("clean_library_adoption") is not True + or event.phase == "clean_library_preparing" + ): + return + event.progress = min(100, 5 + round(event.progress * 0.95)) + + def initialize_progress_snapshot( job: ImportJob, *, @@ -628,7 +648,11 @@ async def persist_progress_snapshot( event: ImportProgressEvent, ) -> None: """Persist the latest progress payload on the job for recovery/UI hydration.""" + existing_snapshot = dict(job.progress_snapshot or {}) payload = event.model_dump(mode="json") + for key in _PERSISTENT_IMPORT_CONTEXT_KEYS: + if key in existing_snapshot: + payload[key] = existing_snapshot[key] if int(payload.get("progress_revision") or 0) <= 0: payload["progress_revision"] = next_progress_revision(job) else: @@ -696,6 +720,7 @@ async def emit_live_progress( """Publish an explicit live-only event without writing a durable snapshot.""" if job.status in _PROTECTED_RUNTIME_STATUSES and event.status != job.status: return + _scale_clean_library_progress(job, event) highest_revision = max( int(revision_state.get("value") or 0), @@ -733,6 +758,7 @@ async def emit_progress( """ if job.status in _PROTECTED_RUNTIME_STATUSES and event.status != job.status: return + _scale_clean_library_progress(job, event) event.mode = cast("ImportProgressMode", snapshot_mode_for_job(job, default=event.mode)) if event.progress_revision <= 0: event.progress_revision = next_progress_revision(job) diff --git a/src/pullbox/services/library_root_management.py b/src/pullbox/services/library_root_management.py index cb76507..86e9705 100644 --- a/src/pullbox/services/library_root_management.py +++ b/src/pullbox/services/library_root_management.py @@ -26,6 +26,12 @@ from pullbox.models.story_arc import StoryArcPlacement from pullbox.services.import_workflow_state import ACTIVE_IMPORT_JOB_STATUSES from pullbox.services.library_root_policy_service import LibraryRootNotFoundError +from pullbox.services.library_root_removal import ( + preview_library_root_removal as preview_library_root_removal, +) +from pullbox.services.library_root_removal import ( + remove_library_root as remove_library_root, +) if TYPE_CHECKING: from collections.abc import Mapping, Sequence diff --git a/src/pullbox/services/library_root_removal.py b/src/pullbox/services/library_root_removal.py new file mode 100644 index 0000000..68007ae --- /dev/null +++ b/src/pullbox/services/library_root_removal.py @@ -0,0 +1,222 @@ +"""Preview and remove unused roots without touching physical library files.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from itsdangerous import BadSignature, URLSafeTimedSerializer +from sqlalchemy import delete, func, select, update +from sqlalchemy.exc import IntegrityError + +from pullbox.core.config_resolver import get_application_secret +from pullbox.core.exceptions import ValidationError +from pullbox.models.config import SystemConfig +from pullbox.models.import_job import ImportJob +from pullbox.models.library import LibraryFile, LibraryRoot, LibraryRootPolicy +from pullbox.models.series import Series +from pullbox.models.story_arc import StoryArc, StoryArcPlacement +from pullbox.services.import_workflow_state import ACTIVE_IMPORT_JOB_STATUSES +from pullbox.services.library_root_policy_service import LibraryRootNotFoundError +from pullbox.utilities.models import JobState, UtilityJob + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + +_ACTIVE_UTILITIES = tuple( + state + for state in JobState + if state + not in { + JobState.COMPLETED, + JobState.FAILED, + JobState.CANCELLED, + JobState.ROLLED_BACK, + } +) + + +def _serializer() -> URLSafeTimedSerializer: + return URLSafeTimedSerializer(get_application_secret(), salt="library-root-remove-v1") + + +def _dependencies(root_id: int, path: str) -> list[tuple[str, Any, str]]: + return [ + ( + "library_files", + LibraryFile.library_root_id == root_id, + "registered files still use this root. Relocate or remove those library entries " + "first; disabling does not detach them.", + ), + ( + "series", + Series.library_root_id == root_id, + "series still use this root. Relocate or remove those series first.", + ), + ( + "preferred_series", + Series.preferred_library_root_id == root_id, + "series use this as their preferred destination. " + "Choose another destination for those series first.", + ), + ( + "story_arcs", + StoryArc.target_library_root_id == root_id, + "Story Arcs target this root. Change their placement destinations first.", + ), + ( + "story_arc_placements", + StoryArcPlacement.library_root_id == root_id, + "Story Arc placements still use this root. Resolve those placements first.", + ), + ( + "destination_settings", + ( + ( + (SystemConfig.key == "story_arc_files_library_root_id") + & (SystemConfig.value == str(root_id)) + ) + | ((SystemConfig.key == "comics_directory") & (SystemConfig.value == path)) + ), + "destination settings still use this root. " + "Choose another default library or Story Arc destination first.", + ), + ( + "active_imports", + ImportJob.status.in_(tuple(ACTIVE_IMPORT_JOB_STATUSES)), + "imports are active or paused. Finish or cancel them before changing library roots.", + ), + ( + "active_utilities", + UtilityJob.state.in_(_ACTIVE_UTILITIES), + "file utilities are active or paused. Finish or cancel them before removing a root.", + ), + ( + "pending_import_followups", + (ImportJob.target_library_root_id == root_id) + & ( + ImportJob.story_arc_placement_followup_pending.is_(True) + | ImportJob.story_arc_rollback_waiting_work_id.is_not(None) + ), + "import follow-ups still depend on this root. " + "Complete their placement or rollback first.", + ), + ] + + +async def preview_library_root_removal( + session: AsyncSession, + library_root_id: int, + *, + actor_id: int, +) -> dict[str, Any]: + """Return exact blockers without probing or changing the filesystem.""" + root = await session.get(LibraryRoot, library_root_id, populate_existing=True) + if root is None: + raise LibraryRootNotFoundError() + blockers = [] + if root.enabled: + blockers.append("Disable this library root before removing it.") + if root.is_default_managed_destination: + blockers.append("Select another default managed destination before removing this root.") + counts = {} + for key, condition, explanation in _dependencies(root.id, root.path): + count = int(await session.scalar(select(func.count()).where(condition)) or 0) + counts[key] = count + if count: + blockers.append(f"{count} {explanation}") + history_count = int( + await session.scalar( + select(func.count(ImportJob.id)).where( + ImportJob.target_library_root_id == root.id, + ) + ) + or 0 + ) + policy = await session.scalar( + select(LibraryRootPolicy).where(LibraryRootPolicy.library_root_id == root.id) + ) + snapshot = {"id": root.id, "name": root.name, "path": root.path} + signed_state = { + "root": snapshot, + "actor_id": actor_id, + "updated_at": str(root.updated_at), + "history_count": history_count, + "policy_updated_at": str(policy.updated_at) if policy else None, + } + return { + **snapshot, + "can_remove": not blockers, + "blocking_reasons": blockers, + "dependencies": counts, + "history_count": history_count, + "has_naming_policy": policy is not None, + "preview_token": _serializer().dumps(signed_state) if not blockers else None, + } + + +async def remove_library_root( + session: AsyncSession, + library_root_id: int, + *, + actor_id: int, + preview_token: str, +) -> None: + """Recheck under a short write lock; retain detached historical identity.""" + try: + confirmed = _serializer().loads(preview_token, max_age=900) + except BadSignature as exc: + raise ValidationError( + "Removal preview expired or is invalid. Review the root again." + ) from exc + # SQLite needs a write reservation, not SELECT FOR UPDATE, before the recheck. + await session.execute( + update(LibraryRoot) + .where(LibraryRoot.id == library_root_id) + .values( + updated_at=LibraryRoot.updated_at, + ) + ) + preview = await preview_library_root_removal(session, library_root_id, actor_id=actor_id) + if preview["blocking_reasons"]: + raise ValidationError(" ".join(preview["blocking_reasons"])) + current = _serializer().loads(preview["preview_token"], max_age=900) + if confirmed != current: + raise ValidationError("Removal preview changed. Review the root again before confirming.") + snapshot = {key: preview[key] for key in ("id", "name", "path")} + await session.execute( + update(ImportJob) + .where( + ImportJob.target_library_root_id == library_root_id, + ImportJob.status.not_in(tuple(ACTIVE_IMPORT_JOB_STATUSES)), + ImportJob.story_arc_placement_followup_pending.is_(False), + ImportJob.story_arc_rollback_waiting_work_id.is_(None), + ) + .values( + removed_library_root_snapshot=snapshot, + target_library_root_id=None, + ) + ) + guards = [ + ~select(1).where(condition).exists() + for _, condition, _ in _dependencies(library_root_id, preview["path"]) + ] + await session.execute( + delete(LibraryRootPolicy).where(LibraryRootPolicy.library_root_id == library_root_id) + ) + try: + deleted = await session.scalar( + delete(LibraryRoot) + .where( + LibraryRoot.id == library_root_id, + LibraryRoot.enabled.is_(False), + LibraryRoot.is_default_managed_destination.is_(False), + *guards, + ) + .returning(LibraryRoot.id) + ) + except IntegrityError as exc: + raise ValidationError( + "This root gained a dependency. Review it again before removing it." + ) from exc + if deleted is None: + raise ValidationError("This root gained a dependency. Review it again before removing it.") diff --git a/src/pullbox/services/reader_state_service.py b/src/pullbox/services/reader_state_service.py index 4d92a77..0262c97 100644 --- a/src/pullbox/services/reader_state_service.py +++ b/src/pullbox/services/reader_state_service.py @@ -17,6 +17,17 @@ from sqlalchemy.ext.asyncio import AsyncSession +def calculate_reader_position_percent( + last_page_index: int | None, + page_count: int | None, +) -> int: + """Return visible whole-number progress for a started reader session.""" + if last_page_index is None or page_count is None or last_page_index < 0 or page_count <= 0: + return 0 + percentage = ((last_page_index + 1) * 100) // page_count + return max(1, min(100, percentage)) + + class ReaderStateValidationError(Exception): """Raised when a progress write does not match the current content contract.""" @@ -90,9 +101,9 @@ def is_explicitly_unread(self) -> bool: @property def position_percent(self) -> int: - if not self.has_progress or self.last_page_index is None or self.page_count is None: + if not self.has_progress: return 0 - return max(0, min(100, ((self.last_page_index + 1) * 100) // self.page_count)) + return calculate_reader_position_percent(self.last_page_index, self.page_count) @property def is_continue_candidate(self) -> bool: diff --git a/src/pullbox/services/reading_query_service.py b/src/pullbox/services/reading_query_service.py index 8055250..1b4f01f 100644 --- a/src/pullbox/services/reading_query_service.py +++ b/src/pullbox/services/reading_query_service.py @@ -13,7 +13,11 @@ from pullbox.models.reader import IssueReaderState from pullbox.models.series import Series from pullbox.models.story_arc import IssueStoryArc, StoryArcResolutionState -from pullbox.services.reader_state_service import ReaderStateSnapshot, snapshot_reader_state +from pullbox.services.reader_state_service import ( + ReaderStateSnapshot, + calculate_reader_position_percent, + snapshot_reader_state, +) if TYPE_CHECKING: from datetime import datetime @@ -53,9 +57,7 @@ def is_explicitly_unread(self) -> bool: @property def position_percent(self) -> int: - if self.last_page_index is None or self.page_count is None: - return 0 - return max(0, min(100, ((self.last_page_index + 1) * 100) // self.page_count)) + return calculate_reader_position_percent(self.last_page_index, self.page_count) @property def is_continue_candidate(self) -> bool: diff --git a/src/pullbox/tasks/import_task.py b/src/pullbox/tasks/import_task.py index 4c29e42..2a15e76 100644 --- a/src/pullbox/tasks/import_task.py +++ b/src/pullbox/tasks/import_task.py @@ -30,6 +30,7 @@ from pullbox.services.import_job_execution_progress import ( reconcile_durable_import_execution_counters, ) +from pullbox.services.import_library_adoption import prepare_clean_library_import from pullbox.services.import_workflow_state import ( emit_progress, import_control_state_for_job, @@ -695,6 +696,11 @@ async def progress_callback(event: ImportProgressEvent) -> None: ) await session.commit() elif job.status == ImportJobStatus.IMPORTING: + await prepare_clean_library_import( + session, + job_id, + progress_callback=progress_callback, + ) result = await service.run_import( session, job_id, @@ -809,6 +815,11 @@ async def progress_callback(event: ImportProgressEvent) -> None: progress_callback=progress_callback, ) else: + await prepare_clean_library_import( + session, + job_id, + progress_callback=progress_callback, + ) run_import_result = await service.run_import( session, job_id, diff --git a/src/pullbox/ui/import_follow_up.py b/src/pullbox/ui/import_follow_up.py new file mode 100644 index 0000000..871ed72 --- /dev/null +++ b/src/pullbox/ui/import_follow_up.py @@ -0,0 +1,229 @@ +"""Job-grouped context for import follow-up work.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sqlalchemy import and_, func, or_, select + +from pullbox.core.exceptions import NotFoundError +from pullbox.models.import_job import ( + ImportedFile, + ImportedFileStatus, + ImportedSeries, + ImportJob, + ImportJobStatus, + ImportSeriesStatus, +) +from pullbox.models.series import IssueCatalogState, Series +from pullbox.models.story_arc import ImportedStoryArcStatus +from pullbox.models.story_arc_import import ImportedStoryArc +from pullbox.ui.import_orphaned_routes import load_import_orphaned_context +from pullbox.ui.import_results_context import load_import_results_context + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + from sqlalchemy.sql import Select + from sqlalchemy.sql.elements import ColumnElement + + +_FOLLOW_UP_PAGE_SIZE = 25 +_FOLLOW_UP_JOB_STATUSES = (ImportJobStatus.COMPLETED, ImportJobStatus.FAILED) +_FOLLOW_UP_SERIES_STATUSES = ( + ImportSeriesStatus.NO_MATCH, + ImportSeriesStatus.RECOVERY_PENDING, + ImportSeriesStatus.FAILED, +) +_FOLLOW_UP_FILE_STATUSES = ( + ImportedFileStatus.NO_MATCH, + ImportedFileStatus.FAILED, + ImportedFileStatus.SAFETY_BLOCKED, + ImportedFileStatus.CONFLICT, +) +_VERIFIED_CROSS_FOLDER_METHODS = ( + "verified_cross_folder_issue_identity", + "verified_cross_folder_series_issue_filename", +) + + +def _follow_up_job_filter() -> ColumnElement[bool]: + misplaced = ImportedFile.diagnostics["mylar3_cross_folder_reconciliation"] + cleanup = ImportedFile.diagnostics["misplaced_source_cleanup"] + unresolved_series = ( + select(ImportedSeries.id) + .where( + ImportedSeries.import_job_id == ImportJob.id, + ImportedSeries.status.in_(_FOLLOW_UP_SERIES_STATUSES), + ) + .exists() + ) + unresolved_files = ( + select(ImportedFile.id) + .where( + ImportedFile.import_job_id == ImportJob.id, + ImportedFile.status.in_(_FOLLOW_UP_FILE_STATUSES), + ) + .exists() + ) + misplaced_files = ( + select(ImportedFile.id) + .where( + ImportedFile.import_job_id == ImportJob.id, + misplaced["method"].as_string().in_(_VERIFIED_CROSS_FOLDER_METHODS), + or_( + and_( + ImportedFile.status == ImportedFileStatus.IMPORTED, + misplaced["role"].as_string() == "canonical", + misplaced["restored_at"].as_string().is_(None), + ImportedFile.library_file_id.is_not(None), + ), + and_( + ImportedFile.status == ImportedFileStatus.DUPLICATE_FILE, + misplaced["role"].as_string() == "identical_duplicate", + cleanup["action"].as_string().is_(None), + ImportedFile.duplicate_of_file_id.is_not(None), + ImportedFile.content_hash.is_not(None), + ), + ), + ) + .exists() + ) + unresolved_story_arcs = ( + select(ImportedStoryArc.id) + .where( + ImportedStoryArc.import_job_id == ImportJob.id, + ImportedStoryArc.materialized_story_arc_id.is_(None), + ImportedStoryArc.status != ImportedStoryArcStatus.SKIPPED, + ) + .exists() + ) + failed_metadata = ( + select(ImportedSeries.id) + .join(Series, Series.id == ImportedSeries.series_id) + .where( + ImportedSeries.import_job_id == ImportJob.id, + ImportedSeries.status == ImportSeriesStatus.IMPORTED, + Series.issue_catalog_state == IssueCatalogState.FAILED, + ) + .exists() + ) + return or_( + unresolved_series, + unresolved_files, + misplaced_files, + unresolved_story_arcs, + failed_metadata, + ) + + +def _follow_up_jobs_statement() -> Select[tuple[ImportJob]]: + return select(ImportJob).where( + ImportJob.status.in_(_FOLLOW_UP_JOB_STATUSES), + ImportJob.archived_at.is_(None), + _follow_up_job_filter(), + ) + + +async def count_import_follow_up_jobs(session: AsyncSession) -> int: + """Return the number of imports with actionable follow-up.""" + return int( + ( + await session.scalar( + select(func.count()).select_from(_follow_up_jobs_statement().subquery()) + ) + ) + or 0 + ) + + +async def _count_dismissed_import_series(session: AsyncSession) -> int: + return int( + ( + await session.scalar( + select(func.count()) + .select_from(ImportedSeries) + .join(ImportJob, ImportedSeries.import_job_id == ImportJob.id) + .where( + ImportedSeries.status == ImportSeriesStatus.SKIPPED, + ImportJob.status == ImportJobStatus.COMPLETED, + ) + ) + ) + or 0 + ) + + +async def load_import_follow_up_context( + session: AsyncSession, + *, + view: str, + requested_page: int, + job_id: int | None, +) -> dict[str, object]: + """Load either grouped import jobs or one job's actionable follow-up.""" + normalized_view = "dismissed" if view == "dismissed" else "all" + follow_up_job_count = await count_import_follow_up_jobs(session) + + if normalized_view == "dismissed": + orphaned = await load_import_orphaned_context( + session, + view="dismissed", + requested_page=requested_page, + ) + return { + **orphaned, + "selected_follow_up_job": None, + "follow_up_jobs": [], + "follow_up_job_count": follow_up_job_count, + } + + if job_id is not None: + job = await session.get(ImportJob, job_id) + if job is None or job.status not in _FOLLOW_UP_JOB_STATUSES or job.archived_at is not None: + raise NotFoundError("ImportJob", job_id) + orphaned = await load_import_orphaned_context( + session, + view="all", + requested_page=requested_page, + job_id=job_id, + ) + return { + **orphaned, + **await load_import_results_context( + session, + job, + include_clean_library=False, + ), + "selected_follow_up_job": job, + "follow_up_jobs": [], + "follow_up_job_count": follow_up_job_count, + } + + total_pages = max( + 1, + (follow_up_job_count + _FOLLOW_UP_PAGE_SIZE - 1) // _FOLLOW_UP_PAGE_SIZE, + ) + page = min(max(1, requested_page), total_pages) + jobs = list( + ( + await session.scalars( + _follow_up_jobs_statement() + .order_by(ImportJob.created_at.desc(), ImportJob.id.desc()) + .offset((page - 1) * _FOLLOW_UP_PAGE_SIZE) + .limit(_FOLLOW_UP_PAGE_SIZE) + ) + ).all() + ) + return { + "items": [], + "total": follow_up_job_count, + "page": page, + "page_size": _FOLLOW_UP_PAGE_SIZE, + "view": "all", + "orphaned_count": 0, + "dismissed_count": await _count_dismissed_import_series(session), + "total_pages": total_pages, + "selected_follow_up_job": None, + "follow_up_jobs": jobs, + "follow_up_job_count": follow_up_job_count, + } diff --git a/src/pullbox/ui/import_history.py b/src/pullbox/ui/import_history.py index ec92121..8a44817 100644 --- a/src/pullbox/ui/import_history.py +++ b/src/pullbox/ui/import_history.py @@ -6,7 +6,16 @@ from sqlalchemy import ColumnElement, String, case, cast, func, or_, select -from pullbox.models.import_job import ImportedSeries, ImportJob, ImportJobStatus, ImportSeriesStatus +from pullbox.models.import_job import ( + ImportedFile, + ImportedFileStatus, + ImportedSeries, + ImportJob, + ImportJobStatus, + ImportSeriesStatus, +) +from pullbox.models.issue import Issue +from pullbox.models.library import LibraryFile, LibraryFileStorageMode from pullbox.services.import_workflow_state import ( import_control_state_for_job, snapshot_mode_for_job, @@ -224,6 +233,7 @@ async def _load_import_history_context( } for job in jobs } + clean_library_job_ids: set[int] = set() terminal_result_statuses = { ImportJobStatus.COMPLETED, ImportJobStatus.FAILED, @@ -244,6 +254,26 @@ async def _load_import_history_context( } if jobs: job_ids = [job.id for job in jobs] + clean_library_job_ids = set( + ( + await session.scalars( + select(ImportedFile.import_job_id) + .join(LibraryFile, LibraryFile.id == ImportedFile.library_file_id) + .join(Issue, Issue.id == LibraryFile.issue_id) + .join(ImportJob, ImportJob.id == ImportedFile.import_job_id) + .where( + ImportedFile.import_job_id.in_(job_ids), + ImportJob.status == ImportJobStatus.COMPLETED, + ImportJob.archived_at.is_(None), + ImportedFile.status == ImportedFileStatus.IMPORTED, + ImportedFile.matched_issue_id == Issue.id, + LibraryFile.storage_mode == LibraryFileStorageMode.REFERENCED, + LibraryFile.file_path == ImportedFile.file_path, + ) + .distinct() + ) + ).all() + ) series_counts_result = await session.execute( select( ImportedSeries.import_job_id, @@ -280,6 +310,7 @@ async def _load_import_history_context( "job_history_metrics": job_history_metrics, "job_control_states": job_control_states, "job_resume_steps": job_resume_steps, + "clean_library_job_ids": clean_library_job_ids, "history_has_live_jobs": any(job.status in live_history_statuses for job in jobs), "history_stats": { "active": int(active_count or 0), diff --git a/src/pullbox/ui/import_orphaned_routes.py b/src/pullbox/ui/import_orphaned_routes.py index 640c351..d01f3ae 100644 --- a/src/pullbox/ui/import_orphaned_routes.py +++ b/src/pullbox/ui/import_orphaned_routes.py @@ -63,6 +63,7 @@ async def load_import_orphaned_context( *, view: str, requested_page: int, + job_id: int | None = None, ) -> dict[str, object]: """Load unmatched-series page data for the requested view and page.""" from pullbox.composition.services import build_import_control_service @@ -103,6 +104,7 @@ async def load_import_orphaned_context( session, page=requested_page, page_size=_IMPORT_ORPHANED_PAGE_SIZE, + job_id=job_id, ) total_pages = max(1, (total + _IMPORT_ORPHANED_PAGE_SIZE - 1) // _IMPORT_ORPHANED_PAGE_SIZE) page = min(requested_page, total_pages) @@ -111,9 +113,10 @@ async def load_import_orphaned_context( session, page=page, page_size=_IMPORT_ORPHANED_PAGE_SIZE, + job_id=job_id, ) - orphaned_count = await svc.get_orphaned_count(session) + orphaned_count = await svc.get_orphaned_count(session, job_id=job_id) dismissed_q = ( select(func.count()) diff --git a/src/pullbox/ui/import_redirect_routes.py b/src/pullbox/ui/import_redirect_routes.py index 31335d3..ec706a2 100644 --- a/src/pullbox/ui/import_redirect_routes.py +++ b/src/pullbox/ui/import_redirect_routes.py @@ -29,8 +29,8 @@ async def import_orphaned_redirect( tab: str = Query("all"), page: int = Query(1, ge=1), ) -> Response: - """Redirect legacy unmatched-series URLs to the unified Import workspace.""" - params = {"tab": "unmatched", "view": "dismissed" if tab == "dismissed" else "all"} + """Redirect legacy unmatched-series URLs to the Follow-up workspace.""" + params = {"tab": "follow-up", "view": "dismissed" if tab == "dismissed" else "all"} if page != 1: params["page"] = str(page) return RedirectResponse(url=f"/import?{urlencode(params)}", status_code=307) diff --git a/src/pullbox/ui/import_results_context.py b/src/pullbox/ui/import_results_context.py index 57025ff..67f9ec6 100644 --- a/src/pullbox/ui/import_results_context.py +++ b/src/pullbox/ui/import_results_context.py @@ -6,6 +6,7 @@ from sqlalchemy import case, func, select +from pullbox.core.library_policy import load_effective_library_ingest_policy from pullbox.models.import_job import ( ImportedFile, ImportedFileStatus, @@ -15,8 +16,10 @@ ImportJobActionStatus, ImportJobStatus, ImportSeriesStatus, + ImportSourceType, ) -from pullbox.models.library import LibraryFile +from pullbox.models.issue import Issue +from pullbox.models.library import LibraryFile, LibraryFileStorageMode, LibraryRoot from pullbox.models.series import IssueCatalogState, Series from pullbox.models.story_arc import ( ImportedStoryArcStatus, @@ -32,8 +35,11 @@ from pullbox.models.story_arc_sync import StoryArcSyncWork, StoryArcSyncWorkState from pullbox.services.import_completed_cleanup import ( CompletedImportCleanupAction, - count_completed_import_cleanup_scope, - list_completed_import_cleanup_examples, + summarize_completed_import_cleanup_scope, +) +from pullbox.services.import_misplaced_source_cleanup import ( + MisplacedSourceCleanupAction, + count_misplaced_source_cleanup_files, ) from pullbox.services.import_safety_diagnostics import ( ImportSafetyCategory, @@ -82,6 +88,144 @@ ) +def _library_paths_overlap(first: str, second: str) -> bool: + from pathlib import Path + + first_path = Path(first).resolve(strict=False) + second_path = Path(second).resolve(strict=False) + return ( + first_path == second_path + or first_path.is_relative_to(second_path) + or second_path.is_relative_to(first_path) + ) + + +async def load_clean_library_summary( + session: AsyncSession, + job_id: int, +) -> dict[str, object]: + active_clean_job: ImportJob | None = None + active_jobs = list( + ( + await session.scalars( + select(ImportJob) + .where( + ImportJob.status.in_( + { + ImportJobStatus.IMPORTING, + ImportJobStatus.PAUSING, + ImportJobStatus.PAUSED, + ImportJobStatus.STALLED, + ImportJobStatus.CANCELLING, + ImportJobStatus.ROLLING_BACK, + } + ) + ) + .order_by(ImportJob.id.desc()) + ) + ).all() + ) + for candidate in active_jobs: + progress = dict(candidate.progress_snapshot or {}) + if ( + progress.get("clean_library_adoption") is True + and int(progress.get("source_import_job_id") or 0) == job_id + ): + active_clean_job = candidate + break + active_payload = ( + { + "id": int(active_clean_job.id), + "status": active_clean_job.status.value, + "progress_snapshot": dict(active_clean_job.progress_snapshot or {}), + } + if active_clean_job is not None + else None + ) + eligibility = ( + ImportedFile.import_job_id == job_id, + ImportedFile.status == ImportedFileStatus.IMPORTED, + ImportedFile.matched_issue_id == Issue.id, + ImportedFile.library_file_id == LibraryFile.id, + LibraryFile.issue_id == Issue.id, + LibraryFile.storage_mode == LibraryFileStorageMode.REFERENCED, + LibraryFile.file_path == ImportedFile.file_path, + ) + count_row = ( + await session.execute( + select( + func.count(ImportedFile.id), + func.count(func.distinct(Issue.series_id)), + func.coalesce(func.sum(LibraryFile.file_size), 0), + ) + .select_from(ImportedFile) + .join(LibraryFile, LibraryFile.id == ImportedFile.library_file_id) + .join(Issue, Issue.id == LibraryFile.issue_id) + .where(*eligibility) + ) + ).one() + reference_count = int(count_row[0] or 0) + if reference_count == 0: + active_snapshot = dict(active_clean_job.progress_snapshot or {}) if active_clean_job else {} + source_snapshot = active_snapshot.get("clean_library_source_snapshot") + source_snapshot = source_snapshot if isinstance(source_snapshot, dict) else {} + return { + "clean_library_reference_count": int(source_snapshot.get("file_count") or 0), + "clean_library_reference_series_count": int(source_snapshot.get("series_count") or 0), + "clean_library_reference_bytes": int(source_snapshot.get("total_bytes") or 0), + "clean_library_target_roots": [], + "clean_library_active_job": active_payload, + } + source_root_paths = set( + ( + await session.scalars( + select(LibraryRoot.path) + .select_from(ImportedFile) + .join(LibraryFile, LibraryFile.id == ImportedFile.library_file_id) + .join(Issue, Issue.id == LibraryFile.issue_id) + .join(LibraryRoot, LibraryRoot.id == LibraryFile.library_root_id) + .where(*eligibility) + .distinct() + ) + ).all() + ) + roots = list( + ( + await session.scalars( + select(LibraryRoot) + .where( + LibraryRoot.enabled.is_(True), + LibraryRoot.allow_managed_writes.is_(True), + ) + .order_by(LibraryRoot.name, LibraryRoot.id) + ) + ).all() + ) + targets: list[dict[str, object]] = [] + for root in roots: + if any(_library_paths_overlap(root.path, source) for source in source_root_paths): + continue + policy = await load_effective_library_ingest_policy(session, root) + targets.append( + { + "id": root.id, + "name": root.name, + "path": root.path, + "rename_on_import": policy.rename_on_import, + "normalize_to_cbz": policy.normalize_imported_archives_to_cbz, + "update_comicinfo": policy.update_embedded_comicinfo_from_match, + "skip_existing": policy.skip_existing_files, + } + ) + return { + "clean_library_reference_count": reference_count, + "clean_library_reference_series_count": int(count_row[1] or 0), + "clean_library_reference_bytes": int(count_row[2] or 0), + "clean_library_target_roots": targets, + "clean_library_active_job": active_payload, + } + + async def _count_series_status( session: AsyncSession, job_id: int, @@ -189,12 +333,12 @@ async def _load_files_for_status( "tone": "neutral", }, CompletedImportCleanupAction.SKIP_PROBABLE_COVERS: { - "label": "Skip probable cover files", + "label": "Skip one-page archives", "description": ( - "Exclude one-page image archives that look like series cover art, " - "while preserving the source files." + "Exclude one-page image archives from this import while preserving the source files. " + "They may be cover art, damaged archives, or intentional one-page comics." ), - "button_label": "Skip cover files", + "button_label": "Skip from import", "tone": "neutral", }, CompletedImportCleanupAction.SKIP_UNUSABLE_FILES: { @@ -239,6 +383,15 @@ async def _load_files_for_status( "button_label": "Accept recommendations", "tone": "warning", }, + CompletedImportCleanupAction.RESOLVE_MIXED_FOLDER_FILES: { + "label": "Resolve mixed-folder files", + "description": ( + "Use exact embedded ComicInfo identity to assign misplaced files to the correct " + "Pullbox series and issue. Mylar folders and source files remain unchanged." + ), + "button_label": "Resolve and retry", + "tone": "warning", + }, } @@ -248,28 +401,24 @@ async def _load_cleanup_action_summaries( ) -> list[dict[str, object]]: summaries: list[dict[str, object]] = [] for action, presentation in _CLEANUP_ACTION_PRESENTATION.items(): - affected_count, affected_file_count = await count_completed_import_cleanup_scope( + summary = await summarize_completed_import_cleanup_scope( session, job_id, action, ) - if affected_count == 0: + if summary.affected_count == 0: continue summaries.append( { "action": action.value, - "affected_count": affected_count, - "affected_file_count": affected_file_count, + "affected_count": summary.affected_count, + "affected_file_count": summary.affected_file_count, "item_unit": ( "group" if action is CompletedImportCleanupAction.ACCEPT_RECOMMENDED_CONFLICTS else "file" ), - "examples": await list_completed_import_cleanup_examples( - session, - job_id, - action, - ), + "examples": summary.examples, **presentation, } ) @@ -700,11 +849,46 @@ async def _load_rollback_journal_summary( } +async def _load_story_arc_results_summary( + session: AsyncSession, + job_id: int, +) -> dict[str, int]: + """Summarize created arcs separately from retained follow-up evidence.""" + created_count, follow_up_count = ( + await session.execute( + select( + func.sum( + case( + (ImportedStoryArc.materialized_story_arc_id.is_not(None), 1), + else_=0, + ) + ), + func.sum( + case( + ( + ImportedStoryArc.materialized_story_arc_id.is_(None) + & (ImportedStoryArc.status != ImportedStoryArcStatus.SKIPPED), + 1, + ), + else_=0, + ) + ), + ).where(ImportedStoryArc.import_job_id == job_id) + ) + ).one() + return { + "story_arcs_created_count": int(created_count or 0), + "story_arcs_follow_up_count": int(follow_up_count or 0), + } + + async def load_import_results_context( session: AsyncSession, job: ImportJob, + *, + include_clean_library: bool = True, ) -> dict[str, object]: - """Load aggregate counts and detail rows for the Step 5 results template.""" + """Load import results, optionally including History-only organizer data.""" job_id = int(job.id) imported_count = await _count_series_status(session, job_id, ImportSeriesStatus.IMPORTED) failed_count = await _count_series_status(session, job_id, ImportSeriesStatus.FAILED) @@ -807,7 +991,43 @@ async def load_import_results_context( if job.status is ImportJobStatus.COMPLETED and job.archived_at is None else [] ) + misplaced_source_restore_count = 0 + misplaced_source_duplicate_count = 0 + if ( + job.status is ImportJobStatus.COMPLETED + and job.archived_at is None + and job.source_type is ImportSourceType.MYLAR3 + ): + misplaced_source_restore_count = await count_misplaced_source_cleanup_files( + session, + job_id, + MisplacedSourceCleanupAction.RESTORE_RECORDED_PATH, + ) + misplaced_source_duplicate_count = await count_misplaced_source_cleanup_files( + session, + job_id, + MisplacedSourceCleanupAction.TRASH_IDENTICAL_DUPLICATE, + ) + clean_library_summary = ( + await load_clean_library_summary(session, job_id) + if include_clean_library + and job.status is ImportJobStatus.COMPLETED + and job.archived_at is None + else { + "clean_library_reference_count": 0, + "clean_library_reference_series_count": 0, + "clean_library_reference_bytes": 0, + "clean_library_target_roots": [], + } + ) cleanup_by_action = {str(item["action"]): item for item in cleanup_action_summaries} + mixed_folder_summary = cleanup_by_action.get( + CompletedImportCleanupAction.RESOLVE_MIXED_FOLDER_FILES.value, + {}, + ) + clean_library_summary["clean_library_mixed_folder_repair_count"] = ( + _positive_int(mixed_folder_summary.get("affected_file_count")) or 0 + ) recommended_summary = cleanup_by_action.get( CompletedImportCleanupAction.ACCEPT_RECOMMENDED_CONFLICTS.value, {}, @@ -825,19 +1045,15 @@ async def load_import_results_context( files_conflict - recommended_conflict_files - already_owned_conflict_files, 0, ) - actionable_safety_files = sum( - _positive_int(item["affected_file_count"]) or 0 - for item in cleanup_action_summaries - if item["action"] - in { - CompletedImportCleanupAction.DISMISS_MISSING_REFERENCES.value, - CompletedImportCleanupAction.SKIP_PROBABLE_COVERS.value, - CompletedImportCleanupAction.SKIP_UNUSABLE_FILES.value, - CompletedImportCleanupAction.ALLOW_OVERSIZED_FILES.value, - CompletedImportCleanupAction.RETRY_SOURCE_INSPECTION.value, - } + cleanup_safe_action_count = sum( + _positive_int(item["affected_file_count"]) or 0 for item in cleanup_action_summaries + ) + manual_safety_count = sum( + _positive_int(item["count"]) or 0 + for item in safety_category_summaries + if item.get("bucket") == "needs_review" ) - needs_review_safety_count = max(files_safety_blocked - actionable_safety_files, 0) + cleanup_needs_review_count = manual_safety_count + remaining_conflict_files files_total = sum(file_status_counts.values()) orphaned_file_no_match_count = await _orphaned_file_no_match_count(session, job_id) identified_series_file_no_match_count = max( @@ -852,6 +1068,19 @@ async def load_import_results_context( ) catalog_sync_pending_count = len(catalog_sync_series) - catalog_sync_failed_count rollback_journal_summary = await _load_rollback_journal_summary(session, job_id) + story_arc_results_summary = await _load_story_arc_results_summary(session, job_id) + follow_up_group_count = ( + int(unmatched_queue_count > 0) + + len(cleanup_action_summaries) + + int(cleanup_needs_review_count > 0) + + int(misplaced_source_restore_count > 0) + + int(misplaced_source_duplicate_count > 0) + + int(failed_count > 0) + + int(files_failed > 0) + + int(job.status is ImportJobStatus.FAILED and files_safety_blocked > 0) + + int(story_arc_results_summary["story_arcs_follow_up_count"] > 0) + + int(catalog_sync_failed_count > 0) + ) rollback_incomplete = bool( rollback_journal_summary["rollback_manual_recovery_count"] and job.status == ImportJobStatus.FAILED @@ -869,6 +1098,7 @@ async def load_import_results_context( "duplicate_count": duplicate_count, "no_match_count": no_match_count, "unmatched_queue_count": unmatched_queue_count, + "follow_up_group_count": follow_up_group_count, "failed_series": failed_series, "files_total": files_total, "files_imported": files_imported, @@ -902,9 +1132,11 @@ async def load_import_results_context( "already_owned_conflict_files": already_owned_conflict_files, "remaining_conflict_files": remaining_conflict_files, "cleanup_no_action_count": files_duplicate + files_already_owned + files_skipped, - "cleanup_safe_action_count": sum( - _positive_int(item["affected_file_count"]) or 0 for item in cleanup_action_summaries - ), - "cleanup_needs_review_count": needs_review_safety_count + remaining_conflict_files, + "cleanup_safe_action_count": cleanup_safe_action_count, + "cleanup_needs_review_count": cleanup_needs_review_count, + "misplaced_source_restore_count": misplaced_source_restore_count, + "misplaced_source_duplicate_count": misplaced_source_duplicate_count, + **clean_library_summary, **rollback_journal_summary, + **story_arc_results_summary, } diff --git a/src/pullbox/ui/import_review_context.py b/src/pullbox/ui/import_review_context.py index 3448ff6..366fb74 100644 --- a/src/pullbox/ui/import_review_context.py +++ b/src/pullbox/ui/import_review_context.py @@ -180,6 +180,41 @@ async def _load_status_counts( return status_counts +async def _load_safety_review_counts( + session: AsyncSession, + job_id: int, +) -> tuple[int, int]: + row = ( + await session.execute( + select( + func.count(func.distinct(ImportedFile.import_series_id)), + func.count(ImportedFile.id), + ).where( + ImportedFile.import_job_id == job_id, + ImportedFile.status.in_( + (ImportedFileStatus.SAFETY_BLOCKED, ImportedFileStatus.SAFETY_APPROVED) + ), + ) + ) + ).one() + return int(row[0] or 0), int(row[1] or 0) + + +async def has_pending_import_safety_rematch(session: AsyncSession, job_id: int) -> bool: + """Return whether a review job has an approved file actively awaiting rematch.""" + pending_file_id = await session.scalar( + select(ImportedFile.id) + .join(ImportedSeries, ImportedSeries.id == ImportedFile.import_series_id) + .where( + ImportedFile.import_job_id == job_id, + ImportedFile.status == ImportedFileStatus.SAFETY_APPROVED, + ImportedSeries.diagnostics["rematch_pending"].as_boolean().is_(True), + ) + .limit(1) + ) + return pending_file_id is not None + + async def _load_safety_blocked_files_by_series_id( session: AsyncSession, job_id: int, @@ -355,11 +390,6 @@ async def load_import_review_context( job_id, visible_series_ids, ) - safety_rematch_pending = any( - imp_file.status == ImportedFileStatus.SAFETY_APPROVED - for files in safety_blocked_files_by_series_id.values() - for imp_file in files - ) if visible_series_ids: matched_file_targets_by_series_id = await _load_import_review_matched_file_targets( session, @@ -377,6 +407,10 @@ async def load_import_review_context( library_roots = list(library_roots_result.scalars().all()) split_series_review = await load_selected_split_series_review(session, job) + safety_review_series_count, safety_review_file_count = await _load_safety_review_counts( + session, job_id + ) + safety_rematch_pending = await has_pending_import_safety_rematch(session, job_id) managed_library_root_options: list[dict[str, Any]] = [] if split_series_review.requires_preferred_destination: managed_library_root_options = [ @@ -412,6 +446,8 @@ async def load_import_review_context( "current_view": current_view, "sort": normalized_sort, "status_counts": await _load_status_counts(session, job_id), + "safety_review_series_count": safety_review_series_count, + "safety_review_file_count": safety_review_file_count, "review_summary": await load_import_review_summary(session, job), "split_series_review": split_series_review, "managed_library_root_options": managed_library_root_options, diff --git a/src/pullbox/ui/import_review_summary.py b/src/pullbox/ui/import_review_summary.py index c083f95..858f119 100644 --- a/src/pullbox/ui/import_review_summary.py +++ b/src/pullbox/ui/import_review_summary.py @@ -5,7 +5,7 @@ from collections.abc import Mapping from typing import TYPE_CHECKING, Any, cast -from sqlalchemy import func, select +from sqlalchemy import func, or_, select from pullbox.models.import_job import ( ImportedFile, @@ -153,6 +153,31 @@ async def load_import_review_summary( for status, count in story_arc_counts.items() if status in story_arc_reviewable_statuses ) + attention_file_statuses = ( + ImportedFileStatus.NO_MATCH, + ImportedFileStatus.CONFLICT, + ImportedFileStatus.SAFETY_BLOCKED, + ImportedFileStatus.FAILED, + ) + needs_attention_series_total = int( + await session.scalar( + select(func.count(func.distinct(ImportedSeries.id))) + .outerjoin(ImportedFile, ImportedFile.import_series_id == ImportedSeries.id) + .where( + ImportedSeries.import_job_id == job.id, + or_( + ImportedSeries.status.in_( + [ImportSeriesStatus.NO_MATCH, ImportSeriesStatus.FAILED] + ), + ImportedFile.status.in_(attention_file_statuses), + ), + ) + ) + or 0 + ) + needs_attention_files_total = sum( + file_counts.get(status.value, 0) for status in attention_file_statuses + ) row_summary = { "series_total": sum(series_counts.values()), @@ -184,10 +209,12 @@ async def load_import_review_summary( "duplicate_series_importable": duplicate_importable_series_count, "duplicate_series_selected": duplicate_selected_series_count, "selected_series_total": matched_selected_series_count + duplicate_selected_series_count, - "selected_items_total": _object_to_int(selection_state["selected_item_count"]) - + story_arcs_selected, - "importable_items_total": _object_to_int(selection_state["importable_item_count"]) - + story_arcs_reviewable, + # Story Arcs are optional follow-up work, not canonical import items. + "selected_items_total": _object_to_int(selection_state["selected_item_count"]), + "importable_items_total": _object_to_int(selection_state["importable_item_count"]), + "ready_to_import_total": _object_to_int(selection_state["importable_item_count"]), + "needs_attention_total": needs_attention_series_total, + "needs_attention_files_total": needs_attention_files_total, "resolved_file_conflict_groups": resolved_file_conflict_groups, "story_arcs_total": sum(story_arc_counts.values()), "story_arcs_detected": story_arc_counts.get(ImportedStoryArcStatus.DETECTED.value, 0), @@ -198,6 +225,8 @@ async def load_import_review_summary( "story_arcs_ready": story_arc_counts.get(ImportedStoryArcStatus.READY.value, 0), "story_arcs_selected": story_arcs_selected, "story_arcs_reviewable": story_arcs_reviewable, + "deferred_story_arcs_total": story_arcs_reviewable, + "deferred_follow_up_total": story_arcs_reviewable, "story_arcs_skipped": story_arc_counts.get(ImportedStoryArcStatus.SKIPPED.value, 0), "story_arc_entries_total": sum(story_arc_entry_counts.values()), "story_arc_entries_resolved": story_arc_entry_counts.get( diff --git a/src/pullbox/ui/import_routes.py b/src/pullbox/ui/import_routes.py index 23e0465..7ccd07b 100644 --- a/src/pullbox/ui/import_routes.py +++ b/src/pullbox/ui/import_routes.py @@ -1,6 +1,7 @@ """Import workspace UI routes and loaders.""" from collections.abc import Callable, Mapping +from html import escape from typing import Annotated from fastapi import APIRouter, Form, HTTPException, Query, Request @@ -24,14 +25,27 @@ CompletedImportCleanupAction, list_completed_import_cleanup_files, ) +from pullbox.services.import_misplaced_source_cleanup import ( + MisplacedSourceCleanupAction, + apply_misplaced_source_cleanup, + apply_verified_misplaced_source_cleanup, + list_misplaced_source_cleanup_files, + preview_misplaced_source_cleanup, + preview_verified_misplaced_source_cleanup, +) from pullbox.services.import_safety_bulk_review import ( - IMPORT_SAFETY_BULK_CONFIRMATION, ImportSafetyBulkInterruptedError, ImportSafetyBulkPreview, allow_import_safety_category_once, preview_import_safety_category, + preview_import_safety_category_skip, + skip_import_safety_category, ) from pullbox.services.import_safety_diagnostics import ImportSafetyCategory +from pullbox.services.import_safety_source_cleanup import ( + move_one_page_source_to_trash, + preview_one_page_source_cleanup, +) from pullbox.services.import_workflow_state import ( ACTIVE_IMPORT_JOB_STATUSES, snapshot_mode_for_job, @@ -48,13 +62,20 @@ wrap_comicvine_provider_for_ui_cache, ) from pullbox.ui.import_conflict_review import _load_import_conflict_review_context +from pullbox.ui.import_follow_up import ( + count_import_follow_up_jobs, + load_import_follow_up_context, +) from pullbox.ui.import_history import ( _history_resume_step_for_job, _load_import_history_context, ) from pullbox.ui.import_progress_snapshot import build_import_progress_snapshot from pullbox.ui.import_results_context import load_import_results_context -from pullbox.ui.import_review_context import load_import_review_context +from pullbox.ui.import_review_context import ( + has_pending_import_safety_rematch, + load_import_review_context, +) from pullbox.ui.import_review_summary import load_import_review_summary from pullbox.ui.import_series_details_context import load_import_series_details_context from pullbox.ui.import_story_arc_entry_review import StoryArcEntryResolutionFilter @@ -323,12 +344,8 @@ async def _load_import_progress_snapshot( async def _load_import_workspace_counts(session: AsyncSession) -> dict[str, object]: """Load shared counts used by the unified Import workspace tabs.""" - from pullbox.composition.services import build_import_control_service - - svc = build_import_control_service() - return { - "unmatched_count": await svc.get_orphaned_count(session), + "follow_up_count": await count_import_follow_up_jobs(session), } @@ -343,11 +360,15 @@ async def import_page( sort: str = Query(""), page: int = Query(1, ge=1), show_archived: bool = Query(False), + job_id: int | None = Query(None), resume_job_id: int | None = Query(None), resume_step: int | None = Query(None), ) -> Response: """Render the unified Import workspace and its tab-scoped partials.""" - normalized_tab = tab if tab in {"collection", "unmatched", "history"} else "collection" + normalized_tab = "follow-up" if tab in {"follow-up", "unmatched"} else tab + if normalized_tab not in {"collection", "follow-up", "history"}: + normalized_tab = "collection" + selected_follow_up_job_id = job_id if isinstance(job_id, int) else None workspace_ctx: dict[str, object] if normalized_tab == "history": @@ -358,11 +379,12 @@ async def import_page( requested_page=page, show_archived=show_archived, ) - elif normalized_tab == "unmatched": - workspace_ctx = await _load_import_orphaned_context( + elif normalized_tab == "follow-up": + workspace_ctx = await load_import_follow_up_context( session, view=view, requested_page=page, + job_id=selected_follow_up_job_id, ) else: workspace_ctx = await _load_import_collection_context(session) @@ -422,7 +444,7 @@ async def import_page( "partials/import_history_panel_bundle.html", ctx, ) - if hx_target == "import-orphaned-results" and normalized_tab == "unmatched": + if hx_target == "import-orphaned-results" and normalized_tab == "follow-up": return _templates().TemplateResponse( request, "partials/import_orphaned_content_bundle.html", @@ -526,6 +548,36 @@ async def import_review_partial( ) +@router.get( + "/import/{job_id}/review-rematch-status", + response_class=HTMLResponse, + include_in_schema=False, +) +async def import_review_rematch_status( + job_id: int, + _user: AuthenticatedUser, + session: DbSession, +) -> Response: + """Poll safety rematch completion without replacing the review surface.""" + job = await session.get(ImportJob, job_id) + if job is None: + raise NotFoundError("ImportJob", job_id) + + pending = await has_pending_import_safety_rematch(session, job_id) + if pending: + escaped_job_id = escape(str(job_id), quote=True) + return HTMLResponse( + f'' + ) + + response = HTMLResponse('') + response.headers["HX-Trigger"] = "import:review-refresh" + return response + + async def _render_import_review_partial( job_id: int, request: Request, @@ -592,6 +644,28 @@ async def _load_import_safety_bulk_preview( return preview +async def _load_import_safety_bulk_skip_preview( + session: AsyncSession, + *, + job_id: int, + category: ImportSafetyCategory, + actor_id: int, +) -> ImportSafetyBulkPreview: + """Load an authoritative preview for a source-preserving category skip.""" + try: + preview = await preview_import_safety_category_skip( + session, + job_id, + category, + actor_id=actor_id, + ) + except ValidationError as exc: + raise HTTPException(status_code=409, detail=exc.message) from exc + if preview.preview_token is None: + raise HTTPException(status_code=404, detail="Bulk safety action not available.") + return preview + + @router.get( "/import/{job_id}/safety/categories/{category}/preview", response_class=HTMLResponse, @@ -638,7 +712,6 @@ async def import_review_allow_safety_category_once( user: InteractiveOperatorUser, session: DbSession, preview_token: Annotated[str, Form(min_length=1, max_length=4096)], - confirmation: Annotated[str, Form(max_length=64)] = "", status: str | None = Query("safety_blocked"), page: int = Query(1, ge=1), sort: str | None = Query(None), @@ -646,28 +719,6 @@ async def import_review_allow_safety_category_once( """Apply one exact signed category preview, then refresh Step 3.""" if category is not ImportSafetyCategory.DECOMPRESSION_SIZE_LIMIT: raise HTTPException(status_code=404, detail="Bulk safety action not available.") - if confirmation != IMPORT_SAFETY_BULK_CONFIRMATION: - preview = await _load_import_safety_bulk_preview( - session, - job_id=job_id, - category=category, - actor_id=user.id, - ) - return await _render_import_review_partial( - job_id, - request, - user, - session, - status=status, - page=page, - sort=sort, - extra_context={ - "safety_bulk_preview": preview, - "safety_bulk_error": "Type ALLOW ONCE exactly to confirm this action.", - "safety_bulk_error_category": category.value, - }, - ) - try: await allow_import_safety_category_once( session, @@ -724,6 +775,194 @@ async def import_review_allow_safety_category_once( ) +@router.get( + "/import/{job_id}/safety/categories/{category}/skip-preview", + response_class=HTMLResponse, + include_in_schema=False, +) +async def import_review_preview_safety_category_skip( + job_id: int, + category: ImportSafetyCategory, + request: Request, + user: InteractiveOperatorUser, + session: DbSession, + status: str | None = Query("safety_blocked"), + page: int = Query(1, ge=1), + sort: str | None = Query(None), +) -> Response: + """Render a signed preview for excluding one-page archives from this import.""" + preview = await _load_import_safety_bulk_skip_preview( + session, + job_id=job_id, + category=category, + actor_id=user.id, + ) + return await _render_import_review_partial( + job_id, + request, + user, + session, + status=status, + page=page, + sort=sort, + extra_context={ + "safety_bulk_preview": preview, + "safety_bulk_action": "skip", + }, + ) + + +@router.post( + "/import/{job_id}/safety/categories/{category}/skip", + response_class=HTMLResponse, + include_in_schema=False, +) +async def import_review_skip_safety_category( + job_id: int, + category: ImportSafetyCategory, + request: Request, + user: InteractiveOperatorUser, + session: DbSession, + preview_token: Annotated[str, Form(min_length=1, max_length=4096)], + status: str | None = Query("safety_blocked"), + page: int = Query(1, ge=1), + sort: str | None = Query(None), +) -> Response: + """Exclude a signed one-page archive set while leaving source files untouched.""" + try: + await skip_import_safety_category( + session, + job_id, + category, + actor_id=user.id, + actor_username=user.username, + source_ip=source_ip_from_request(request), + preview_token=preview_token, + ) + except (ImportSafetyBulkInterruptedError, ValidationError) as exc: + await session.rollback() + message = ( + exc.message + if isinstance(exc, ValidationError) + else "The bulk skip stopped because the import job changed. Review the latest state." + ) + return await _render_import_review_partial( + job_id, + request, + user, + session, + status=status, + page=page, + sort=sort, + extra_context={ + "safety_bulk_error": message, + "safety_bulk_error_category": category.value, + }, + ) + return await import_review_partial( + job_id, + request, + user, + session, + status=status, + page=page, + sort=sort, + ) + + +@router.get( + "/import/{job_id}/files/{file_id}/safety/source-cleanup-preview", + response_class=HTMLResponse, + include_in_schema=False, +) +async def import_review_source_cleanup_preview( + job_id: int, + file_id: int, + request: Request, + user: InteractiveOperatorUser, + session: DbSession, + status: str | None = Query("safety_blocked"), + page: int = Query(1, ge=1), + sort: str | None = Query(None), + return_to: str = Query("review"), +) -> Response: + """Preview the explicitly destructive source cleanup for one one-page archive.""" + try: + preview = await preview_one_page_source_cleanup( + session, + job_id, + file_id, + actor_id=user.id, + ) + except ValidationError as exc: + raise HTTPException(status_code=409, detail=exc.message) from exc + return _templates().TemplateResponse( + request, + "partials/import_source_cleanup_modal.html", + _ctx( + request, + user, + preview=preview, + status=status or "safety_blocked", + page=page, + sort=sort or "confidence", + return_to=(return_to if return_to in {"results", "follow-up"} else "review"), + error="", + ), + ) + + +@router.post( + "/import/{job_id}/files/{file_id}/safety/source-cleanup", + response_class=HTMLResponse, + include_in_schema=False, +) +async def import_review_source_cleanup( + job_id: int, + file_id: int, + request: Request, + user: InteractiveOperatorUser, + session: DbSession, + preview_token: Annotated[str, Form(min_length=1, max_length=4096)], + status: str | None = Query("safety_blocked"), + page: int = Query(1, ge=1), + sort: str | None = Query(None), + return_to: str = Query("review"), +) -> Response: + """Move one explicitly confirmed source file to configured Trash.""" + try: + await move_one_page_source_to_trash( + session, + job_id, + file_id, + actor_id=user.id, + actor_username=user.username, + source_ip=source_ip_from_request(request), + preview_token=preview_token, + ) + except ValidationError as exc: + await session.rollback() + raise HTTPException(status_code=409, detail=exc.message) from exc + if return_to == "results": + return Response( + status_code=204, + headers={ + "HX-Redirect": (f"/import?tab=collection&resume_job_id={job_id}&resume_step=5") + }, + ) + if return_to == "follow-up": + return await import_follow_up_job_partial(job_id, request, user, session) + return await import_review_partial( + job_id, + request, + user, + session, + status=status, + page=page, + sort=sort, + ) + + @router.post( "/import/{job_id}/files/{file_id}/safety/allow-once", response_class=HTMLResponse, @@ -864,7 +1103,11 @@ async def import_results_partial( if job.status not in {ImportJobStatus.COMPLETED, ImportJobStatus.FAILED}: raise ValidationError("Results are only available for completed or failed imports.") progress_snapshot = await _load_import_progress_snapshot(session, job) - results_context = await load_import_results_context(session, job) + results_context = await load_import_results_context( + session, + job, + include_clean_library=False, + ) return _templates().TemplateResponse( request, @@ -881,6 +1124,55 @@ async def import_results_partial( ) +async def import_follow_up_job_partial( + job_id: int, + request: Request, + user: AuthenticatedUser, + session: DbSession, +) -> Response: + """Refresh one job-scoped Follow-up surface after a mutation.""" + follow_up_context = await load_import_follow_up_context( + session, + view="all", + requested_page=1, + job_id=job_id, + ) + return _templates().TemplateResponse( + request, + "partials/import_orphaned_results.html", + _ctx(request, user, tab="follow-up", **follow_up_context), + ) + + +@router.get( + "/import/{job_id}/clean-library-panel", + response_class=HTMLResponse, + include_in_schema=False, +) +async def import_clean_library_panel( + job_id: int, + request: Request, + user: AuthenticatedUser, + session: DbSession, +) -> Response: + """Render optional clean-library organization from Import History.""" + job = await session.get(ImportJob, job_id) + if job is None: + raise NotFoundError("ImportJob", job_id) + if job.status is not ImportJobStatus.COMPLETED or job.archived_at is not None: + raise ValidationError("Library organization is available for current completed imports.") + results_context = await load_import_results_context( + session, + job, + include_clean_library=True, + ) + return _templates().TemplateResponse( + request, + "partials/import_clean_library_modal.html", + _ctx(request, user, job=job, **results_context), + ) + + @router.get( "/import/{job_id}/cleanup/{action}/files", response_class=HTMLResponse, @@ -914,6 +1206,157 @@ async def import_completed_cleanup_files_partial( ) +@router.get( + "/import/{job_id}/misplaced-source-cleanup/{action}/files", + response_class=HTMLResponse, + include_in_schema=False, +) +async def import_misplaced_source_cleanup_files_partial( + job_id: int, + action: MisplacedSourceCleanupAction, + request: Request, + user: AuthenticatedUser, + session: DbSession, + page: int = Query(1, ge=1), +) -> Response: + """Render one bounded page of optional source cleanup candidates.""" + cleanup_page = await list_misplaced_source_cleanup_files( + session, + job_id, + action, + page=page, + ) + return _templates().TemplateResponse( + request, + "partials/import_misplaced_source_cleanup_files.html", + _ctx( + request, + user, + job_id=job_id, + cleanup_action=action.value, + cleanup_page=cleanup_page, + ), + ) + + +@router.get( + "/import/{job_id}/misplaced-source-cleanup/restore-recorded-path/preview-all", + response_class=HTMLResponse, + include_in_schema=False, +) +async def import_misplaced_source_cleanup_preview_all( + job_id: int, + request: Request, + user: InteractiveOperatorUser, + session: DbSession, +) -> Response: + """Preview moving every currently eligible verified misplaced source.""" + try: + preview = await preview_verified_misplaced_source_cleanup( + session, + job_id, + actor_id=user.id, + ) + except ValidationError as exc: + raise HTTPException(status_code=409, detail=exc.message) from exc + return _templates().TemplateResponse( + request, + "partials/import_misplaced_source_cleanup_bulk_modal.html", + _ctx(request, user, preview=preview), + ) + + +@router.post( + "/import/{job_id}/misplaced-source-cleanup/restore-recorded-path/apply-all", + response_class=HTMLResponse, + include_in_schema=False, +) +async def import_misplaced_source_cleanup_apply_all( + job_id: int, + request: Request, + user: InteractiveOperatorUser, + session: DbSession, + preview_token: Annotated[str, Form(min_length=1, max_length=4096)], +) -> Response: + """Apply a signed exact-scope move and refresh Step 5 without navigation.""" + try: + await apply_verified_misplaced_source_cleanup( + session, + job_id, + actor_id=user.id, + actor_username=user.username, + source_ip=source_ip_from_request(request), + preview_token=preview_token, + ) + except ValidationError as exc: + await session.rollback() + raise HTTPException(status_code=409, detail=exc.message) from exc + return await import_follow_up_job_partial(job_id, request, user, session) + + +@router.get( + "/import/{job_id}/files/{file_id}/misplaced-source-cleanup/{action}/preview", + response_class=HTMLResponse, + include_in_schema=False, +) +async def import_misplaced_source_cleanup_preview( + job_id: int, + file_id: int, + action: MisplacedSourceCleanupAction, + request: Request, + user: InteractiveOperatorUser, + session: DbSession, +) -> Response: + """Preview one physical source cleanup without changing the library.""" + try: + preview = await preview_misplaced_source_cleanup( + session, + job_id, + file_id, + action, + actor_id=user.id, + ) + except ValidationError as exc: + raise HTTPException(status_code=409, detail=exc.message) from exc + return _templates().TemplateResponse( + request, + "partials/import_misplaced_source_cleanup_modal.html", + _ctx(request, user, preview=preview, error=""), + ) + + +@router.post( + "/import/{job_id}/files/{file_id}/misplaced-source-cleanup/{action}", + response_class=HTMLResponse, + include_in_schema=False, +) +async def import_misplaced_source_cleanup_apply( + job_id: int, + file_id: int, + action: MisplacedSourceCleanupAction, + request: Request, + user: InteractiveOperatorUser, + session: DbSession, + preview_token: Annotated[str, Form(min_length=1, max_length=4096)], +) -> Response: + """Apply one signed source cleanup and return to the completed results.""" + try: + await apply_misplaced_source_cleanup( + session, + job_id, + file_id, + action, + actor_id=user.id, + actor_username=user.username, + source_ip=source_ip_from_request(request), + preview_token=preview_token, + ) + except ValidationError as exc: + await session.rollback() + raise HTTPException(status_code=409, detail=exc.message) from exc + return await import_follow_up_job_partial(job_id, request, user, session) + + @router.get( "/import/{job_id}/series/{series_id}/details-partial", response_class=HTMLResponse, diff --git a/src/pullbox/ui/routes.py b/src/pullbox/ui/routes.py index 36d681b..ed8c313 100644 --- a/src/pullbox/ui/routes.py +++ b/src/pullbox/ui/routes.py @@ -561,6 +561,12 @@ async def _post_processing_live_status_map_bridge( import_review_partial = import_routes.import_review_partial import_series_reconcile = import_routes.import_series_reconcile import_results_partial = import_routes.import_results_partial +import_clean_library_panel = import_routes.import_clean_library_panel +import_misplaced_source_cleanup_files_partial = ( + import_routes.import_misplaced_source_cleanup_files_partial +) +import_misplaced_source_cleanup_preview = import_routes.import_misplaced_source_cleanup_preview +import_misplaced_source_cleanup_apply = import_routes.import_misplaced_source_cleanup_apply import_series_details_partial = import_routes.import_series_details_partial import_conflicts_partial = import_routes.import_conflicts_partial import_log_panel = import_routes.import_log_panel diff --git a/src/pullbox/ui/static/css/input.css b/src/pullbox/ui/static/css/input.css index e3dbf55..70d5710 100644 --- a/src/pullbox/ui/static/css/input.css +++ b/src/pullbox/ui/static/css/input.css @@ -7175,6 +7175,9 @@ width: var(--pb-control-icon-size); height: var(--pb-control-icon-size); } + .import-advanced-disclosure[open] > summary .import-advanced-disclosure-chevron { + @apply rotate-90; + } .dropdown-select-panel { @apply fixed left-0 top-0 z-[80] max-h-60 overflow-y-auto rounded-lg border border-pb-border-hover bg-pb-card py-1 shadow-2xl shadow-black/20; max-height: var(--pb-dropdown-panel-max-height, 15rem); diff --git a/src/pullbox/ui/static/css/tailwind.css b/src/pullbox/ui/static/css/tailwind.css index d99e227..e6065fa 100644 --- a/src/pullbox/ui/static/css/tailwind.css +++ b/src/pullbox/ui/static/css/tailwind.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--color-red-300:oklch(80.8% .114 19.571);--color-slate-950:oklch(12.9% .042 264.695);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--font-weight-black:900;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--leading-tight:1.25;--leading-relaxed:1.625;--radius-xs:.125rem;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--radius-3xl:1.5rem;--ease-in:cubic-bezier(.4, 0, 1, 1);--ease-out:cubic-bezier(0, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-xs:4px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent;font-family:DM Sans,sans-serif;line-height:1.5}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-feature-settings:normal;font-variation-settings:normal;font-family:JetBrains Mono,Fira Code,monospace;font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}html{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}:root,[data-theme=dark]{--pb-surface-app:#101824;--pb-surface-shell:#141e2c;--pb-surface-card:#172231;--pb-surface-raised:#1d2a3d;--pb-surface-input:#132030;--pb-surface-overlay:#070b12b8;--pb-surface-selected:#8fb9ee29;--pb-text-primary:#f7f1e8;--pb-text-secondary:#cbd5e1;--pb-text-tertiary:#9aaaba;--pb-text-inverse:#101824;--pb-border-subtle:#cbd5e11f;--pb-border-default:#cbd5e133;--pb-border-strong:#cbd5e152;--pb-interactive:#8fb9ee;--pb-interactive-hover:#7da9df;--pb-interactive-active:#6a96c8;--pb-interactive-selected:#8fb9ee2e;--pb-brand:#c6a17b;--pb-brand-muted:#c6a17b29;--pb-status-success:#68b88b;--pb-status-warning:#d7a15b;--pb-status-danger:#e38473;--pb-status-info:#8fb9ee;--pb-focus-ring:#8fb9ee47;--pb-focus-outline:#8fb9ee85;--pb-selection:#8fb9ee38;--pb-shadow-0:none;--pb-shadow-1:0 1px 2px #070b1238, 0 10px 24px #070b1229;--pb-shadow-2:0 2px 6px #070b1247, 0 18px 40px #070b1238;--pb-shadow-overlay:0 20px 56px #00000075;--pb-brand-hover:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-hover:color-mix(in srgb, var(--pb-brand) 84%, black)}}:root,[data-theme=dark]{--pb-brand-dim:var(--pb-brand-muted);--pb-brand-border:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-border:color-mix(in srgb, var(--pb-brand) 28%, transparent)}}:root,[data-theme=dark]{--pb-brand-signal:var(--pb-status-warning);--pb-brand-signal-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-signal-dim:color-mix(in srgb, var(--pb-status-warning) 16%, transparent)}}:root,[data-theme=dark]{--pb-brand-signal-hover:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-signal-hover:color-mix(in srgb, var(--pb-status-warning) 22%, transparent)}}:root,[data-theme=dark]{--pb-brand-signal-border:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-signal-border:color-mix(in srgb, var(--pb-status-warning) 28%, transparent)}}:root,[data-theme=dark]{--pb-interactive-dim:var(--pb-interactive-selected);--pb-interactive-border:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-interactive-border:color-mix(in srgb, var(--pb-interactive) 30%, transparent)}}:root,[data-theme=dark]{--pb-success:var(--pb-status-success);--pb-success-dim:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-success-dim:color-mix(in srgb, var(--pb-status-success) 18%, transparent)}}:root,[data-theme=dark]{--pb-warning:var(--pb-status-warning);--pb-warning-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-warning-dim:color-mix(in srgb, var(--pb-status-warning) 20%, transparent)}}:root,[data-theme=dark]{--pb-error:var(--pb-status-danger);--pb-error-dim:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-error-dim:color-mix(in srgb, var(--pb-status-danger) 18%, transparent)}}:root,[data-theme=dark]{--pb-info:var(--pb-status-info);--pb-info-dim:var(--pb-status-info)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-info-dim:color-mix(in srgb, var(--pb-status-info) 18%, transparent)}}:root,[data-theme=dark]{--pb-purple:#a88bda;--pb-purple-dim:#a88bda2e;--pb-bg-base:var(--pb-surface-app);--pb-bg-surface:var(--pb-surface-shell);--pb-bg-card:var(--pb-surface-card);--pb-bg-card-hover:var(--pb-surface-card)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-bg-card-hover:color-mix(in srgb, var(--pb-surface-card) 72%, var(--pb-surface-raised))}}:root,[data-theme=dark]{--pb-bg-input:var(--pb-surface-input);--pb-bg-overlay:var(--pb-surface-overlay);--pb-table-row-stripe:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-table-row-stripe:color-mix(in srgb, var(--pb-bg-card-hover) 68%, transparent)}}:root,[data-theme=dark]{--pb-table-row-hover:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-table-row-hover:color-mix(in srgb, var(--pb-bg-card-hover) 88%, transparent)}}:root,[data-theme=dark]{--pb-table-cell-x:1.25rem;--pb-table-cell-x-tight:1rem;--pb-page-footer-clearance:1.5rem;--pb-text-dim:var(--pb-text-tertiary);--pb-border:var(--pb-border-default);--pb-border-hover:var(--pb-border-strong)}[data-theme=light]{--pb-surface-app:#f6f1e8;--pb-surface-shell:#fbf6ee;--pb-surface-card:#fffcf7;--pb-surface-raised:#fff;--pb-surface-input:#fffdfa;--pb-surface-overlay:#1a16136b;--pb-surface-selected:#2f5e8c1f;--pb-text-primary:#1e1a17;--pb-text-secondary:#51473d;--pb-text-tertiary:#74675b;--pb-text-inverse:#f7f1e8;--pb-border-subtle:#51473d24;--pb-border-default:#51473d33;--pb-border-strong:#51473d57;--pb-interactive:#2f5e8c;--pb-interactive-hover:#274f76;--pb-interactive-active:#21435f;--pb-interactive-selected:#2f5e8c24;--pb-brand:#855e3d;--pb-brand-muted:#8c68471f;--pb-status-success:#2e6a4f;--pb-status-warning:#8a5a1f;--pb-status-danger:#b24432;--pb-status-info:#2f5e8c;--pb-focus-ring:#2f5e8c42;--pb-focus-outline:#2f5e8cb8;--pb-selection:#2f5e8c33;--pb-shadow-0:none;--pb-shadow-1:0 1px 2px #1a161314, 0 10px 24px #1a16130f;--pb-shadow-2:0 2px 6px #1a16131a, 0 18px 40px #1a161314;--pb-shadow-overlay:0 20px 56px #1a161333;--pb-brand-hover:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-hover:color-mix(in srgb, var(--pb-brand) 84%, black)}}[data-theme=light]{--pb-brand-dim:var(--pb-brand-muted);--pb-brand-border:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-border:color-mix(in srgb, var(--pb-brand) 28%, transparent)}}[data-theme=light]{--pb-brand-signal:var(--pb-status-warning);--pb-brand-signal-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-signal-dim:color-mix(in srgb, var(--pb-status-warning) 12%, transparent)}}[data-theme=light]{--pb-brand-signal-hover:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-signal-hover:color-mix(in srgb, var(--pb-status-warning) 18%, transparent)}}[data-theme=light]{--pb-brand-signal-border:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-signal-border:color-mix(in srgb, var(--pb-status-warning) 24%, transparent)}}[data-theme=light]{--pb-interactive-dim:var(--pb-interactive-selected);--pb-interactive-border:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-interactive-border:color-mix(in srgb, var(--pb-interactive) 28%, transparent)}}[data-theme=light]{--pb-success:var(--pb-status-success);--pb-success-dim:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-success-dim:color-mix(in srgb, var(--pb-status-success) 12%, transparent)}}[data-theme=light]{--pb-warning:var(--pb-status-warning);--pb-warning-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-warning-dim:color-mix(in srgb, var(--pb-status-warning) 14%, transparent)}}[data-theme=light]{--pb-error:var(--pb-status-danger);--pb-error-dim:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-error-dim:color-mix(in srgb, var(--pb-status-danger) 12%, transparent)}}[data-theme=light]{--pb-info:var(--pb-status-info);--pb-info-dim:var(--pb-status-info)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-info-dim:color-mix(in srgb, var(--pb-status-info) 12%, transparent)}}[data-theme=light]{--pb-purple:#7b5c91;--pb-purple-dim:#7b5c9124;--pb-bg-base:var(--pb-surface-app);--pb-bg-surface:var(--pb-surface-shell);--pb-bg-card:var(--pb-surface-card);--pb-bg-card-hover:var(--pb-surface-card)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-bg-card-hover:color-mix(in srgb, var(--pb-surface-card) 72%, var(--pb-surface-raised))}}[data-theme=light]{--pb-bg-input:var(--pb-surface-input);--pb-bg-overlay:var(--pb-surface-overlay);--pb-table-row-stripe:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-table-row-stripe:color-mix(in srgb, var(--pb-surface-shell) 40%, transparent)}}[data-theme=light]{--pb-table-row-hover:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-table-row-hover:color-mix(in srgb, var(--pb-surface-shell) 58%, transparent)}}[data-theme=light]{--pb-table-cell-x:1.25rem;--pb-table-cell-x-tight:1rem;--pb-text-dim:var(--pb-text-tertiary);--pb-border:var(--pb-border-default);--pb-border-hover:var(--pb-border-strong)}@media (prefers-color-scheme:light){:root:not([data-theme]){--pb-surface-app:#f6f1e8;--pb-surface-shell:#fbf6ee;--pb-surface-card:#fffcf7;--pb-surface-raised:#fff;--pb-surface-input:#fffdfa;--pb-surface-overlay:#1a16136b;--pb-surface-selected:#2f5e8c1f;--pb-text-primary:#1e1a17;--pb-text-secondary:#51473d;--pb-text-tertiary:#74675b;--pb-text-inverse:#f7f1e8;--pb-border-subtle:#51473d24;--pb-border-default:#51473d33;--pb-border-strong:#51473d57;--pb-interactive:#2f5e8c;--pb-interactive-hover:#274f76;--pb-interactive-active:#21435f;--pb-interactive-selected:#2f5e8c24;--pb-brand:#855e3d;--pb-brand-muted:#8c68471f;--pb-status-success:#2e6a4f;--pb-status-warning:#8a5a1f;--pb-status-danger:#b24432;--pb-status-info:#2f5e8c;--pb-focus-ring:#2f5e8c42;--pb-focus-outline:#2f5e8cb8;--pb-selection:#2f5e8c33;--pb-shadow-0:none;--pb-shadow-1:0 1px 2px #1a161314, 0 10px 24px #1a16130f;--pb-shadow-2:0 2px 6px #1a16131a, 0 18px 40px #1a161314;--pb-shadow-overlay:0 20px 56px #1a161333;--pb-brand-hover:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-hover:color-mix(in srgb, var(--pb-brand) 84%, black)}}:root:not([data-theme]){--pb-brand-dim:var(--pb-brand-muted);--pb-brand-border:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-border:color-mix(in srgb, var(--pb-brand) 28%, transparent)}}:root:not([data-theme]){--pb-brand-signal:var(--pb-status-warning);--pb-brand-signal-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-signal-dim:color-mix(in srgb, var(--pb-status-warning) 12%, transparent)}}:root:not([data-theme]){--pb-brand-signal-hover:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-signal-hover:color-mix(in srgb, var(--pb-status-warning) 18%, transparent)}}:root:not([data-theme]){--pb-brand-signal-border:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-signal-border:color-mix(in srgb, var(--pb-status-warning) 24%, transparent)}}:root:not([data-theme]){--pb-interactive-dim:var(--pb-interactive-selected);--pb-interactive-border:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-interactive-border:color-mix(in srgb, var(--pb-interactive) 28%, transparent)}}:root:not([data-theme]){--pb-success:var(--pb-status-success);--pb-success-dim:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-success-dim:color-mix(in srgb, var(--pb-status-success) 12%, transparent)}}:root:not([data-theme]){--pb-warning:var(--pb-status-warning);--pb-warning-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-warning-dim:color-mix(in srgb, var(--pb-status-warning) 14%, transparent)}}:root:not([data-theme]){--pb-error:var(--pb-status-danger);--pb-error-dim:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-error-dim:color-mix(in srgb, var(--pb-status-danger) 12%, transparent)}}:root:not([data-theme]){--pb-info:var(--pb-status-info);--pb-info-dim:var(--pb-status-info)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-info-dim:color-mix(in srgb, var(--pb-status-info) 12%, transparent)}}:root:not([data-theme]){--pb-purple:#7b5c91;--pb-purple-dim:#7b5c9124;--pb-bg-base:var(--pb-surface-app);--pb-bg-surface:var(--pb-surface-shell);--pb-bg-card:var(--pb-surface-card);--pb-bg-card-hover:var(--pb-surface-card)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-bg-card-hover:color-mix(in srgb, var(--pb-surface-card) 72%, var(--pb-surface-raised))}}:root:not([data-theme]){--pb-bg-input:var(--pb-surface-input);--pb-bg-overlay:var(--pb-surface-overlay);--pb-table-row-stripe:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-table-row-stripe:color-mix(in srgb, var(--pb-surface-shell) 40%, transparent)}}:root:not([data-theme]){--pb-table-row-hover:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-table-row-hover:color-mix(in srgb, var(--pb-surface-shell) 58%, transparent)}}:root:not([data-theme]){--pb-table-cell-x:1.25rem;--pb-table-cell-x-tight:1rem;--pb-text-dim:var(--pb-text-tertiary);--pb-border:var(--pb-border-default);--pb-border-hover:var(--pb-border-strong)}}html,body{overscroll-behavior:none;overflow:hidden}:focus-visible{outline:2px solid var(--pb-focus-outline);outline-offset:3px;box-shadow:0 0 0 4px var(--pb-focus-ring);border-radius:4px}::selection{background-color:var(--pb-selection)}:where(a[href]:not([aria-disabled=true]),button:not(:disabled):not([aria-disabled=true]),input[type=button]:not(:disabled),input[type=submit]:not(:disabled),input[type=reset]:not(:disabled),input[type=checkbox]:not(:disabled),input[type=radio]:not(:disabled),input[type=file]:not(:disabled),input[type=image]:not(:disabled),select:not(:disabled),summary,[role=button]:not([aria-disabled=true]),[role=link]:not([aria-disabled=true]),label:has(:is(input[type=checkbox],input[type=radio],input[type=file]):not(:disabled))){cursor:pointer}:where(button:disabled,input:disabled,select:disabled,[aria-disabled=true]){cursor:not-allowed}}@layer components{.control-size-sm,.btn-sm,.chip-btn-sm,.icon-btn-sm{--pb-control-min-height:2rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.4375rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:.875rem;--pb-control-panel-item-px:.75rem;--pb-control-panel-item-py:.5rem;--pb-control-search-icon-left:.625rem;--pb-control-search-padding-left:2rem;--pb-control-search-padding-right:1.875rem;--pb-control-clear-right:.5rem}.control-size-md,.btn-md,.chip-btn,.icon-btn,.dropdown-select,.search-field,.btn-primary,.btn-warning,.btn-ghost,.btn-danger{--pb-control-min-height:2.5rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.5rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:1rem;--pb-control-panel-item-px:.75rem;--pb-control-panel-item-py:.5rem;--pb-control-search-icon-left:.75rem;--pb-control-search-padding-left:2.5rem;--pb-control-search-padding-right:2.25rem;--pb-control-clear-right:.625rem}.control-size-lg,.btn-lg,.chip-btn-lg,.icon-btn-lg{--pb-control-min-height:2.875rem;--pb-control-radius:.625rem;--pb-control-gap:.625rem;--pb-control-px:.875rem;--pb-control-py:.625rem;--pb-control-font-size:.9375rem;--pb-control-line-height:1.375rem;--pb-control-icon-size:1rem;--pb-control-panel-item-px:.875rem;--pb-control-panel-item-py:.625rem;--pb-control-search-icon-left:.875rem;--pb-control-search-padding-left:2.75rem;--pb-control-search-padding-right:2.5rem;--pb-control-clear-right:.75rem}.btn-primary.btn-sm,.btn-warning.btn-sm,.btn-ghost.btn-sm,.btn-danger.btn-sm,.btn-primary.control-size-sm,.btn-warning.control-size-sm,.btn-ghost.control-size-sm,.btn-danger.control-size-sm{--pb-control-min-height:2rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.4375rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:.875rem}.btn-primary.btn-md,.btn-warning.btn-md,.btn-ghost.btn-md,.btn-danger.btn-md,.btn-primary.control-size-md,.btn-warning.control-size-md,.btn-ghost.control-size-md,.btn-danger.control-size-md{--pb-control-min-height:2.5rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.5rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:1rem}.btn-primary.btn-lg,.btn-warning.btn-lg,.btn-ghost.btn-lg,.btn-danger.btn-lg,.btn-primary.control-size-lg,.btn-warning.control-size-lg,.btn-ghost.control-size-lg,.btn-danger.control-size-lg{--pb-control-min-height:2.875rem;--pb-control-radius:.625rem;--pb-control-gap:.625rem;--pb-control-px:.875rem;--pb-control-py:.625rem;--pb-control-font-size:.9375rem;--pb-control-line-height:1.375rem;--pb-control-icon-size:1rem}.btn-primary{background-color:var(--pb-interactive);--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:10px;justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.btn-primary:hover{background-color:var(--pb-interactive-hover)}}.btn-primary:disabled{cursor:not-allowed;opacity:.5}.btn-primary{color:var(--pb-text-inverse);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.btn-ghost{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:#0000;border-radius:10px;justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.btn-ghost:hover{border-color:var(--pb-border-strong);color:var(--pb-text-primary)}}.btn-ghost:disabled{cursor:not-allowed;opacity:.5}.btn-ghost{min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.btn-warning{--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:10px;justify-content:center;align-items:center;display:inline-flex}.btn-warning:disabled{cursor:not-allowed;opacity:.5}.btn-warning{background-color:var(--pb-warning);color:var(--pb-text-inverse);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.btn-warning:hover{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.btn-warning:hover{background-color:color-mix(in srgb, var(--pb-warning) 86%, var(--pb-text-primary))}}.btn-danger{justify-content:center;align-items:center;gap:var(--pb-control-gap);background-color:var(--pb-error);--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:10px;display:inline-flex}@media (hover:hover){.btn-danger:hover{--tw-brightness:brightness(90%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}}.btn-danger:disabled{cursor:not-allowed;opacity:.5}.btn-danger{color:var(--pb-text-inverse);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.pill,.badge-success,.badge-warning,.badge-error,.badge-info,.badge-neutral,.badge-muted,.badge-purple{justify-content:center;align-items:center;gap:var(--spacing);padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * .5);text-align:center;--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-size:11px;font-weight:var(--font-weight-medium);border-radius:3.40282e38px;display:inline-flex}.pill-success,.badge-success{background-color:var(--pb-success-dim);color:var(--pb-success)}.pill-warning,.badge-warning{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.pill-error,.badge-error{background-color:var(--pb-error-dim);color:var(--pb-error)}.pill-info,.badge-info{background-color:var(--pb-info-dim);color:var(--pb-info)}.pill-neutral,.badge-neutral{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary)}.pill-muted,.badge-muted{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card-hover);color:var(--pb-text-dim)}.pill-purple,.badge-purple{background-color:var(--pb-purple-dim);color:var(--pb-purple)}.count-badge{min-width:1.25rem;min-height:1.25rem;padding-inline:calc(var(--spacing) * 1.5);--tw-leading:1;--tw-font-weight:var(--font-weight-bold);font-size:10px;line-height:1;font-weight:var(--font-weight-bold);--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);border-radius:3.40282e38px;justify-content:center;align-items:center;display:inline-flex}.count-badge-info{background-color:var(--pb-info-dim);color:var(--pb-info)}.count-badge-success{background-color:var(--pb-success-dim);color:var(--pb-success)}.count-badge-warning{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.count-badge-error{background-color:var(--pb-error-dim);color:var(--pb-error)}.count-badge-neutral{background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary)}.table-shell{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card)}.table-shell-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4)}.table-shell-header-comfortable{padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4)}.table-shell-content{padding:calc(var(--spacing) * 5)}.table-shell-content-comfortable{padding:calc(var(--spacing) * 6)}.table-toolbar{gap:calc(var(--spacing) * 3);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);flex-direction:column;display:flex}@media (min-width:64rem){.table-toolbar{flex-direction:row;justify-content:space-between;align-items:flex-end}}:where(.table-toolbar-title-block>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}.table-toolbar-eyebrow{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.table-toolbar-title{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.table-toolbar-title-compact{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.table-toolbar-copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.table-toolbar-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.table-header-row{grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:.75rem;display:grid}.table-header-aside{justify-self:end}.table-summary{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));flex-wrap:wrap;display:flex}.table-scroll{overflow-x:auto}.table-base{border-collapse:collapse;width:100%;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.table-head{text-align:left;font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider);color:var(--pb-text-secondary);text-transform:uppercase}.table-head-row{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border)}.table-head-cell{padding-block:calc(var(--spacing) * 3);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);padding-left:var(--pb-table-cell-x);padding-right:var(--pb-table-cell-x)}.table-head-cell-tight{padding-block:calc(var(--spacing) * 2.5);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);padding-left:var(--pb-table-cell-x-tight);padding-right:var(--pb-table-cell-x-tight)}.table-head-cell-right{text-align:right}:where(.table-body>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-color:var(--pb-border)}.table-body-striped>.table-row:nth-child(2n){background-color:var(--pb-table-row-stripe)}.table-row{color:var(--pb-text-secondary)}.table-row-hover>.table-row{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.table-row-hover>.table-row:hover{background-color:var(--pb-table-row-hover)}.table-row-hover>.table-row-detail{transition-property:none}.table-row-hover>.table-row-detail:hover{background-color:#0000}.table-cell{padding-block:calc(var(--spacing) * 3);padding-left:var(--pb-table-cell-x);padding-right:var(--pb-table-cell-x)}.table-cell-tight{padding-block:calc(var(--spacing) * 2.5);padding-left:var(--pb-table-cell-x-tight);padding-right:var(--pb-table-cell-x-tight)}.table-cell-right{text-align:right}.table-cell-muted{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary)}.table-cell-nowrap{white-space:nowrap}.table-footer{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.table-empty{padding:calc(var(--spacing) * 8);text-align:center}.table-empty-copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.chip-btn{border-radius:var(--radius-lg);border-style:var(--tw-border-style);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-width:1px;justify-content:center;align-items:center;display:inline-flex}.chip-btn:disabled{cursor:not-allowed;opacity:.5}.chip-btn{gap:var(--pb-control-gap);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.chip-btn-pill{padding-inline:calc(var(--spacing) * 3.5);padding-block:calc(var(--spacing) * 1.5);--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:.2em;letter-spacing:.2em;text-transform:uppercase;border-radius:9999px}.chip-btn-neutral{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary)}@media (hover:hover){.chip-btn-neutral:hover{border-color:var(--pb-border-strong);color:var(--pb-text-primary)}}.chip-btn-selected{color:var(--pb-info);background-color:var(--pb-info-dim);border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-selected{border-color:color-mix(in srgb, var(--pb-info) 26%, transparent)}}.chip-btn-selected:hover{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-selected:hover{background-color:color-mix(in srgb, var(--pb-info) 20%, var(--pb-bg-card))}}.chip-btn-selected:hover{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-selected:hover{border-color:color-mix(in srgb, var(--pb-info) 34%, transparent)}}.chip-btn-info{color:var(--pb-info);background-color:var(--pb-info-dim);border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-info{border-color:color-mix(in srgb, var(--pb-info) 24%, transparent)}}.chip-btn-info:hover{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-info:hover{background-color:color-mix(in srgb, var(--pb-info) 20%, var(--pb-bg-card))}}.chip-btn-info:hover{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-info:hover{border-color:color-mix(in srgb, var(--pb-info) 32%, transparent)}}.chip-btn-success{color:var(--pb-success);background-color:var(--pb-success-dim);border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.chip-btn-success{border-color:color-mix(in srgb, var(--pb-success) 24%, transparent)}}.chip-btn-success:hover{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.chip-btn-success:hover{background-color:color-mix(in srgb, var(--pb-success) 20%, var(--pb-bg-card))}}.chip-btn-success:hover{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.chip-btn-success:hover{border-color:color-mix(in srgb, var(--pb-success) 32%, transparent)}}.chip-btn-warning{color:var(--pb-warning);background-color:var(--pb-warning-dim);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.chip-btn-warning{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.chip-btn-warning:hover{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.chip-btn-warning:hover{background-color:color-mix(in srgb, var(--pb-warning) 20%, var(--pb-bg-card))}}.chip-btn-warning:hover{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.chip-btn-warning:hover{border-color:color-mix(in srgb, var(--pb-warning) 32%, transparent)}}.chip-btn-error{color:var(--pb-error);background-color:var(--pb-error-dim);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.chip-btn-error{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.chip-btn-error:hover{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.chip-btn-error:hover{background-color:color-mix(in srgb, var(--pb-error) 20%, var(--pb-bg-card))}}.chip-btn-error:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.chip-btn-error:hover{border-color:color-mix(in srgb, var(--pb-error) 32%, transparent)}}.card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);border-radius:14px}.card-hover{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:14px}@media (hover:hover){.card-hover:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}}.auth-side-panel{border-right:1px solid var(--pb-border);background:linear-gradient(160deg, var(--pb-bg-surface) 0%, var(--pb-bg-surface) 42%, var(--pb-bg-card) 100%);position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.auth-side-panel{background:linear-gradient(160deg, color-mix(in srgb, var(--pb-bg-surface) 90%, var(--pb-brand) 10%) 0%, var(--pb-bg-surface) 42%, color-mix(in srgb, var(--pb-bg-card) 78%, var(--pb-interactive) 22%) 100%)}}.auth-side-panel:before,.auth-side-panel:after{content:"";pointer-events:none;filter:blur(12px);position:absolute;inset:auto}.auth-side-panel:before{background:radial-gradient(circle, var(--pb-brand) 0%, transparent 64%);width:24rem;height:24rem;top:-10%;left:-8%}@supports (color:color-mix(in lab, red, red)){.auth-side-panel:before{background:radial-gradient(circle, color-mix(in srgb, var(--pb-brand) 26%, transparent) 0%, transparent 64%)}}.auth-side-panel:after{background:radial-gradient(circle, var(--pb-interactive) 0%, transparent 66%);width:26rem;height:26rem;bottom:-14%;right:-12%}@supports (color:color-mix(in lab, red, red)){.auth-side-panel:after{background:radial-gradient(circle, color-mix(in srgb, var(--pb-interactive) 22%, transparent) 0%, transparent 66%)}}.auth-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-overlay);border-radius:1.75rem;position:relative;overflow:hidden}.auth-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.auth-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 40%, transparent), transparent)}}.auth-summary-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.auth-summary-card-emphasis{border-color:var(--pb-brand-border);background-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.auth-summary-card-emphasis{background-color:color-mix(in srgb, var(--pb-brand) 8%, var(--pb-bg-card))}}.auth-kicker{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.login-brand-mobile{justify-content:center;display:flex}.login-hero-stack{flex-direction:column;gap:1rem;max-width:29rem;padding-bottom:.25rem;display:flex}.login-hero-headline{letter-spacing:-.02em;color:var(--pb-text-primary);text-wrap:balance;padding-bottom:.1em;font-family:Syne,sans-serif;font-size:clamp(2.3rem,3.1vw,3.35rem);font-weight:800;line-height:1.08}.login-hero-copy{max-width:25rem;color:var(--pb-text-secondary);padding-bottom:.08em;font-size:.95rem;line-height:1.72}.login-side-footer{color:var(--pb-text-tertiary);flex-wrap:wrap;align-items:center;gap:.625rem 1.25rem;padding-bottom:.08em;font-family:JetBrains Mono,monospace;font-size:.68rem;display:flex}.login-side-footer-item{align-items:center;gap:.45rem;display:inline-flex}.login-side-footer-dot{background:var(--pb-success);width:.4rem;height:.4rem;box-shadow:0 0 4px var(--pb-success);border-radius:9999px}@supports (color:color-mix(in lab, red, red)){.login-side-footer-dot{box-shadow:0 0 4px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.login-side-footer-dot{flex-shrink:0}.login-card-title{letter-spacing:-.02em;color:var(--pb-text-primary);padding-bottom:.08em;font-family:Syne,sans-serif;font-size:1.9rem;font-weight:800;line-height:1.08}.login-card-subtitle{color:var(--pb-text-secondary);padding-bottom:.05em;font-size:.875rem;line-height:1.62}.login-card-hint{color:var(--pb-text-tertiary);padding-bottom:.04em;font-size:.75rem;line-height:1.6}.floating-panel{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-overlay);overflow:hidden}.add-series-header-metrics{flex-wrap:wrap;align-items:flex-start;gap:1.5rem;display:flex}.add-series-results-shell{position:relative}.add-series-results-loading{z-index:12;background:var(--pb-bg-base);border-radius:.875rem;justify-content:center;align-items:flex-start;padding:1.25rem;display:flex;position:absolute;inset:0}@supports (color:color-mix(in lab, red, red)){.add-series-results-loading{background:color-mix(in srgb, var(--pb-bg-base) 55%, transparent)}}.add-series-results-loading{-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px);visibility:hidden;pointer-events:none}.add-series-results-loading.htmx-request{opacity:1;visibility:visible}.add-series-results-loading-card{border:1px solid var(--pb-border-default);background:var(--pb-bg-card);border-radius:.875rem;align-items:center;gap:.75rem;max-width:24rem;padding:.875rem 1rem;display:flex}@supports (color:color-mix(in lab, red, red)){.add-series-results-loading-card{background:color-mix(in srgb, var(--pb-bg-card) 94%, transparent)}}.add-series-results-loading-card{color:var(--pb-text-primary);box-shadow:var(--pb-shadow-1)}.add-series-results-loading-card svg{color:var(--pb-interactive);flex-shrink:0}.add-series-results-loading-title{font-size:.82rem;font-weight:800;line-height:1.2}.add-series-results-loading-copy{color:var(--pb-text-secondary);margin-top:.125rem;font-size:.74rem;line-height:1.35}.add-series-preview-notice{border:1px solid var(--pb-interactive);border-radius:.875rem;flex-wrap:wrap;align-items:center;gap:.875rem;padding:.875rem 1rem;display:flex}@supports (color:color-mix(in lab, red, red)){.add-series-preview-notice{border:1px solid color-mix(in srgb, var(--pb-interactive) 32%, var(--pb-border-default))}}.add-series-preview-notice{background:linear-gradient(135deg, var(--pb-interactive), transparent 70%), var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.add-series-preview-notice{background:linear-gradient(135deg, color-mix(in srgb, var(--pb-interactive) 12%, transparent), transparent 70%), var(--pb-bg-card)}}.add-series-preview-notice{color:var(--pb-text-primary);box-shadow:var(--pb-shadow-0)}.add-series-preview-notice-icon{background:var(--pb-interactive);border-radius:999px;flex:none;justify-content:center;align-items:center;width:2rem;height:2rem;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.add-series-preview-notice-icon{background:color-mix(in srgb, var(--pb-interactive) 14%, transparent)}}.add-series-preview-notice-icon{color:var(--pb-interactive)}.add-series-preview-notice-body{flex:auto;min-width:0}.add-series-preview-notice-title{letter-spacing:.08em;text-transform:uppercase;font-size:.78rem;font-weight:850;line-height:1.2}.add-series-preview-notice-copy{color:var(--pb-text-secondary);margin-top:.125rem;font-size:.8rem;line-height:1.45}.add-series-preview-notice-action{white-space:nowrap;flex:none;margin-left:auto}@media (max-width:640px){.add-series-preview-notice{align-items:flex-start}.add-series-preview-notice-action{justify-content:center;width:100%;margin-left:0}}.add-series-results-list{gap:calc(var(--spacing) * 2);flex-direction:column;display:flex}.add-series-result-card{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);border-radius:.875rem;align-items:flex-start;gap:.875rem;padding:.875rem;transition:border-color .14s,background-color .14s;display:flex}.add-series-result-card:hover{border-color:var(--pb-border-strong);background:var(--pb-surface-selected)}@supports (color:color-mix(in lab, red, red)){.add-series-result-card:hover{background:color-mix(in srgb, var(--pb-surface-selected) 35%, var(--pb-bg-card))}}.add-series-result-card-static:hover{border-color:var(--pb-border-subtle);background:var(--pb-bg-card)}.add-series-result-cover{aspect-ratio:2/3;border:1px solid var(--pb-border-subtle);background:var(--pb-surface-shell);width:3.5rem;min-width:3.5rem;color:var(--pb-text-tertiary);border-radius:.625rem;overflow:hidden}.add-series-result-cover-empty{justify-content:center;align-items:center;width:100%;height:100%;display:flex}.add-series-result-main{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:.875rem;display:flex}.add-series-result-title{color:var(--pb-text-primary);font-size:.92rem;font-weight:600;line-height:1.25}.add-series-result-meta{color:var(--pb-text-secondary);flex-wrap:wrap;gap:.25rem .75rem;margin-top:.25rem;font-size:.78rem;display:flex}.add-series-result-description{color:var(--pb-text-tertiary);-webkit-line-clamp:2;-webkit-box-orient:vertical;margin-top:.375rem;font-size:.78rem;line-height:1.55;display:-webkit-box;overflow:hidden}.add-series-result-actions{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.add-series-result-add{white-space:nowrap;flex-shrink:0}.add-series-empty-state{text-align:center;border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);border-radius:.875rem;padding:2.5rem 1.25rem}.add-series-empty-title{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text-primary);margin-top:.75rem;font-family:Syne,sans-serif;font-size:.9rem;font-weight:700}.add-series-empty-copy{color:var(--pb-text-secondary);margin-top:.25rem;font-size:.82rem;line-height:1.55}.add-series-modal-title{color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1rem;font-weight:800;line-height:1.1}.add-series-modal-subtitle{color:var(--pb-text-tertiary);font-size:.78rem}.add-series-modal-section-label{border-bottom:1px solid var(--pb-border-subtle);text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);padding-bottom:.375rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700}.add-series-modal-row{justify-content:space-between;align-items:center;gap:.75rem;display:flex}.add-series-modal-row-label{color:var(--pb-text-secondary);font-size:.85rem;font-weight:500}.add-series-modal-preview{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-shell);color:var(--pb-text-secondary);border-radius:.625rem;padding:.625rem .75rem;font-family:JetBrains Mono,monospace;font-size:.72rem;line-height:1.5}.add-series-root-display.input-pb:disabled,.add-series-root-display.input-pb[readonly]{opacity:1;color:var(--pb-text-primary);background:var(--pb-surface-input);cursor:default}.media-result-card{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);display:flex}.media-result-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.media-result-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.media-result-card:hover{box-shadow:var(--pb-shadow-2)}.media-result-card-static:hover{border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1)}.media-result-cover{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card-hover);flex-shrink:0;width:4.75rem;overflow:hidden}.media-result-cover-lg{width:6.25rem}.media-result-cover-inner{aspect-ratio:2/3;width:100%;height:100%}.media-result-empty{width:100%;height:100%;color:var(--pb-text-dim);justify-content:center;align-items:center;display:flex}.media-result-meta{margin-top:var(--spacing);align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);flex-wrap:wrap;display:flex}.media-result-divider{color:var(--pb-text-dim)}.match-file-card{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-direction:column;display:flex}@media (min-width:64rem){.match-file-card{flex-direction:row;align-items:flex-start}}.match-file-card{box-shadow:var(--pb-shadow-1)}.match-file-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.match-file-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.match-file-card:hover{box-shadow:var(--pb-shadow-2)}.match-file-format{height:calc(var(--spacing) * 14);width:calc(var(--spacing) * 14);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:.18em;letter-spacing:.18em;text-transform:uppercase;border-width:1px;flex-shrink:0;justify-content:center;align-items:center;display:flex}.match-file-format-cbz{color:var(--pb-interactive);border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbz{border-color:color-mix(in srgb, var(--pb-interactive) 24%, transparent)}}.match-file-format-cbz{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbz{background-color:color-mix(in srgb, var(--pb-interactive) 12%, transparent)}}.match-file-format-cbr{color:var(--pb-purple);border-color:var(--pb-purple)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbr{border-color:color-mix(in srgb, var(--pb-purple) 24%, transparent)}}.match-file-format-cbr{background-color:var(--pb-purple)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbr{background-color:color-mix(in srgb, var(--pb-purple) 12%, transparent)}}.match-file-format-pdf{color:var(--pb-warning);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.match-file-format-pdf{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.match-file-format-pdf{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.match-file-format-pdf{background-color:color-mix(in srgb, var(--pb-warning) 12%, transparent)}}.match-file-format-other{color:var(--pb-text-secondary);border-color:var(--pb-border);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.match-file-format-other{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.match-file-card-actions{gap:calc(var(--spacing) * 2);flex-direction:column;width:100%;display:flex}@media (min-width:64rem){.match-file-card-actions{align-items:flex-end;width:auto;min-width:11rem}}.library-series-card{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);min-width:0;height:100%;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);flex-direction:column;display:flex;overflow:hidden}.library-series-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.library-series-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 56%, var(--pb-bg-card))}}.library-series-card:hover{box-shadow:var(--pb-shadow-2)}.library-series-card-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-series-card-active{border-color:color-mix(in srgb, var(--pb-interactive) 30%, transparent)}}.library-series-card-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-series-card-active{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.library-series-cover-frame{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);background:linear-gradient(160deg, var(--pb-bg-card-hover) 0%, var(--pb-bg-card) 100%);position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.library-series-cover-frame{background:linear-gradient(160deg, color-mix(in srgb, var(--pb-bg-card-hover) 88%, transparent) 0%, color-mix(in srgb, var(--pb-bg-card) 82%, transparent) 100%)}}.library-series-cover-frame-compact{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border)}.library-series-placeholder{justify-content:center;align-items:center;gap:calc(var(--spacing) * 2);width:100%;height:100%;color:var(--pb-text-dim);flex-direction:column;display:flex}.library-series-meta{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.library-series-progress-track{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.library-series-progress-track{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.series-grid-card-meta{container-type:inline-size}.series-grid-card-publisher-year{grid-template-columns:8rem minmax(0,1fr);align-items:start;gap:.25rem .5rem;display:grid}.series-grid-card-publisher{overflow-wrap:anywhere;min-width:0}.series-grid-card-year{text-align:right;justify-self:end}.series-grid-card-progress-track{background-color:var(--pb-text-dim)}@supports (color:color-mix(in lab, red, red)){.series-grid-card-progress-track{background-color:color-mix(in srgb, var(--pb-text-dim) 72%, var(--pb-bg-card) 28%)}}@container (max-width:11rem){.series-grid-card-publisher-year{grid-template-columns:8rem}.series-grid-card-year{text-align:left;justify-self:start}}.admin-workspace-page{gap:calc(var(--spacing) * 4);flex-direction:column;min-height:0;display:flex}.admin-workspace-header{padding-block:0}.admin-workspace-header-copy{max-width:var(--container-3xl);min-width:0}.admin-workspace-tag-row{gap:calc(var(--spacing) * 2);padding-top:var(--spacing);flex-wrap:wrap;display:flex}.admin-workspace-header-aside{min-width:0}.admin-workspace-summary-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);height:100%;padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1)}.admin-workspace-body{gap:calc(var(--spacing) * 7);display:grid}@media (min-width:80rem){.admin-workspace-body{grid-template-columns:10.75rem minmax(0,1fr);align-items:flex-start}}.admin-workspace-body{padding-bottom:var(--pb-page-footer-clearance)}.admin-workspace-rail{gap:calc(var(--spacing) * 3);flex-direction:column;display:flex}@media (min-width:80rem){.admin-workspace-rail{top:calc(var(--spacing) * 24);position:sticky}}:where(.admin-workspace-rail-copy>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.admin-workspace-rail-links{gap:var(--spacing);flex-direction:column;display:flex}.admin-workspace-content{flex:1;min-width:0}:where(.admin-workspace-content>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.admin-workspace-content{width:min(100%,58rem)}.section-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);overflow:hidden}.admin-workspace-content .space-y-6>.section-card:not(:has(~.section-card)){margin-bottom:0!important}.section-card-visible{overflow:visible}.system-version-banner{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);flex-direction:column;padding-block:1.125rem;display:flex;position:relative;overflow:hidden}@media (min-width:64rem){.system-version-banner{flex-direction:row;justify-content:space-between;align-items:center}}.system-version-banner{box-shadow:var(--pb-shadow-1)}.system-version-banner:before{content:"";background:var(--pb-brand);width:3px;position:absolute;top:0;bottom:0;left:0}.system-version-banner-main{align-items:flex-start;gap:calc(var(--spacing) * 4);min-width:0;display:flex}.system-version-banner-icon{height:calc(var(--spacing) * 11);width:calc(var(--spacing) * 11);background:var(--pb-brand-muted);color:var(--pb-brand);border-radius:10px;flex-shrink:0;justify-content:center;align-items:center;display:flex}.system-version-banner-title{letter-spacing:-.02em;color:var(--pb-text-primary);flex-wrap:wrap;align-items:center;gap:.625rem;font-family:Bricolage Grotesque,sans-serif;font-size:1.125rem;font-weight:800;display:flex}.system-version-banner-meta{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.system-version-banner-meta span{font-family:JetBrains Mono,monospace;font-size:.6875rem}.system-version-banner-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;flex-shrink:0;display:flex}:where(.system-detail-list>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}.system-detail-list{border-color:var(--pb-border-subtle)}.system-detail-row{justify-content:space-between;align-items:baseline;gap:calc(var(--spacing) * 5);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 2.5);display:flex}.system-detail-label{color:var(--pb-text-secondary);flex-shrink:0;font-size:13px}.system-detail-value{color:var(--pb-text-primary);text-align:right;word-break:break-word;font-family:JetBrains Mono,monospace;font-size:.78125rem}.system-detail-value-brand{color:var(--pb-brand)}.system-registry-filename{--tw-leading:calc(var(--spacing) * 5);font-family:JetBrains Mono,Fira Code,monospace;font-size:.8125rem;line-height:calc(var(--spacing) * 5);--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.system-links-grid{gap:calc(var(--spacing) * 2);padding:calc(var(--spacing) * 5);grid-template-columns:repeat(auto-fit,minmax(11rem,1fr));display:grid}.system-link-card{align-items:center;gap:calc(var(--spacing) * 2.5);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 3.5);padding-block:calc(var(--spacing) * 2.5);--tw-font-weight:var(--font-weight-medium);font-size:12.5px;font-weight:var(--font-weight-medium);color:var(--pb-text-primary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background:var(--pb-bg-card-hover);display:flex}@supports (color:color-mix(in lab, red, red)){.system-link-card{background:color-mix(in srgb, var(--pb-bg-card-hover) 20%, transparent)}}.system-link-card:hover{border-color:var(--pb-border-hover);background:var(--pb-bg-card-hover)}.system-link-card svg{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);color:var(--pb-text-dim);flex-shrink:0}.system-link-card-arrow{height:calc(var(--spacing) * 3);width:calc(var(--spacing) * 3);color:var(--pb-text-dim);margin-left:auto}.stat-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.stat-card-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.stat-card-value{margin-top:calc(var(--spacing) * 3);font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.stat-card-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-secondary)}.info-panel{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1)}.info-panel-muted{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.info-panel-muted{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.dashboard-briefing-shell{border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-2);background:radial-gradient(circle at top right, var(--pb-interactive) 0%, transparent 38%), radial-gradient(circle at left bottom, var(--pb-brand) 0%, transparent 34%), var(--pb-bg-card);position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.dashboard-briefing-shell{background:radial-gradient(circle at top right, color-mix(in srgb, var(--pb-interactive) 14%, transparent) 0%, transparent 38%), radial-gradient(circle at left bottom, color-mix(in srgb, var(--pb-brand) 10%, transparent) 0%, transparent 34%), var(--pb-bg-card)}}.dashboard-briefing-shell:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.dashboard-briefing-shell:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 44%, transparent), color-mix(in srgb, var(--pb-interactive) 44%, transparent), transparent)}}.dashboard-briefing-meta{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);width:100%;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}@media (min-width:64rem){.dashboard-briefing-meta{max-width:13rem}}.dashboard-briefing-meta{box-shadow:var(--pb-shadow-1);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.dashboard-briefing-meta{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 42%, transparent)}}.dashboard-status-pill,.dashboard-priority-badge,.dashboard-watch-tag,.dashboard-scorecard-delta{align-items:center;gap:var(--spacing);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.16em;letter-spacing:.16em;text-transform:uppercase;border-width:1px;border-color:currentColor;border-radius:3.40282e38px;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.dashboard-status-pill,.dashboard-priority-badge,.dashboard-watch-tag,.dashboard-scorecard-delta{border-color:color-mix(in srgb, currentColor 20%, transparent)}}.dashboard-priority-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.dashboard-priority-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}.dashboard-priority-card-v2{align-items:flex-start;gap:calc(var(--spacing) * 4);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:14px;grid-template-columns:1fr auto;transition:box-shadow .14s;display:grid;position:relative;overflow:hidden}.dashboard-priority-card-v2:before{content:"";background-color:#0000;width:6px;position:absolute;inset:0 auto 0 0}.dashboard-priority-card-v2:hover{box-shadow:var(--pb-shadow-2)}.dashboard-priority-card-v2-tone-pill-error:before{background-color:var(--pb-error)}.dashboard-priority-card-v2-tone-pill-warning:before{background-color:var(--pb-warning)}.dashboard-priority-card-v2-tone-pill-info:before{background-color:var(--pb-info)}.dashboard-priority-card-v2-tone-pill-success:before{background-color:var(--pb-success)}.dashboard-priority-card-v2-tone-pill-neutral:before,.dashboard-priority-card-v2-tone-pill-muted:before{background-color:var(--pb-text-dim)}.dashboard-priority-fact{border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-base);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);display:inline-flex}.dashboard-priority-card,.dashboard-decision-card{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-width:1px}.dashboard-priority-card-critical,.dashboard-decision-card-critical{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-critical,.dashboard-decision-card-critical{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.dashboard-priority-card-critical,.dashboard-decision-card-critical{background:linear-gradient(135deg, var(--pb-error) 0%, var(--pb-bg-card-hover) 100%)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-critical,.dashboard-decision-card-critical{background:linear-gradient(135deg, color-mix(in srgb, var(--pb-error) 10%, var(--pb-bg-card)) 0%, color-mix(in srgb, var(--pb-bg-card-hover) 92%, transparent) 100%)}}.dashboard-priority-card-high,.dashboard-decision-card-high{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-high,.dashboard-decision-card-high{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.dashboard-priority-card-high,.dashboard-decision-card-high{background:linear-gradient(135deg, var(--pb-warning) 0%, var(--pb-bg-card-hover) 100%)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-high,.dashboard-decision-card-high{background:linear-gradient(135deg, color-mix(in srgb, var(--pb-warning) 10%, var(--pb-bg-card)) 0%, color-mix(in srgb, var(--pb-bg-card-hover) 92%, transparent) 100%)}}.dashboard-priority-card-watch,.dashboard-decision-card-watch{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-watch,.dashboard-decision-card-watch{border-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.dashboard-priority-card-watch,.dashboard-decision-card-watch{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-watch,.dashboard-decision-card-watch{background-color:color-mix(in srgb, var(--pb-warning) 7%, var(--pb-bg-card))}}.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{border-color:color-mix(in srgb, var(--pb-success) 20%, transparent)}}.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{background-color:color-mix(in srgb, var(--pb-success) 7%, var(--pb-bg-card))}}.dashboard-priority-card-info,.dashboard-decision-card-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-info,.dashboard-decision-card-info{border-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.dashboard-priority-card-info,.dashboard-decision-card-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-info,.dashboard-decision-card-info{background-color:color-mix(in srgb, var(--pb-info) 7%, var(--pb-bg-card))}}.dashboard-priority-facts{margin-top:calc(var(--spacing) * 5);gap:calc(var(--spacing) * 3);border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-top:calc(var(--spacing) * 4);display:grid}@media (min-width:40rem){.dashboard-priority-facts{grid-template-columns:repeat(3,minmax(0,1fr))}}.dashboard-priority-facts dt{--tw-font-weight:var(--font-weight-medium);font-size:10px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.dashboard-priority-facts dd{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-primary)}.dashboard-quiet-note{align-items:center;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-success);background:var(--pb-success);display:flex}@supports (color:color-mix(in lab, red, red)){.dashboard-quiet-note{background:color-mix(in srgb, var(--pb-success) 10%, var(--pb-bg-card))}}.dashboard-quiet-note{border:1px solid var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-quiet-note{border:1px solid color-mix(in srgb, var(--pb-success) 18%, transparent)}}.dashboard-pulse-tile{padding-block:calc(var(--spacing) * 2.5);flex-direction:column;align-items:flex-start;display:flex}.dashboard-pulse-value{margin-top:var(--spacing);font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height));--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.dashboard-pulse-tile-right{border-left-style:var(--tw-border-style);border-left-width:1px;border-color:var(--pb-border);padding-left:calc(var(--spacing) * 3)}.dashboard-pulse-tile-bottom{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-top:calc(var(--spacing) * 2.5)}.dashboard-scoreboard-attention-grid{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.dashboard-scoreboard-attention-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.dashboard-scoreboard-attention-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}.dashboard-scorecard{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);min-width:0;height:100%;padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-width:1px;flex-direction:column;justify-content:space-between;display:flex}.dashboard-scorecard-critical{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-critical{border-color:color-mix(in srgb, var(--pb-error) 22%, transparent)}}.dashboard-scorecard-critical{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-critical{background-color:color-mix(in srgb, var(--pb-error) 8%, var(--pb-bg-card))}}.dashboard-scorecard-high{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-high{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.dashboard-scorecard-high{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-high{background-color:color-mix(in srgb, var(--pb-warning) 9%, var(--pb-bg-card))}}.dashboard-scorecard-watch{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-watch{border-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.dashboard-scorecard-watch{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-watch{background-color:color-mix(in srgb, var(--pb-warning) 7%, var(--pb-bg-card))}}.dashboard-scorecard-healthy{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-healthy{border-color:color-mix(in srgb, var(--pb-success) 20%, transparent)}}.dashboard-scorecard-healthy{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-healthy{background-color:color-mix(in srgb, var(--pb-success) 7%, var(--pb-bg-card))}}.dashboard-scorecard-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-info{border-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.dashboard-scorecard-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-info{background-color:color-mix(in srgb, var(--pb-info) 7%, var(--pb-bg-card))}}.dashboard-scorecard-title{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.dashboard-scorecard-value{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.dashboard-scorecard-value-watch{color:var(--pb-status-warning)}.dashboard-scorecard-value-critical{color:var(--pb-status-danger)}.dashboard-scorecard-copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.dashboard-healthy-strip{align-items:center;gap:calc(var(--spacing) * 2);column-gap:calc(var(--spacing) * 6);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 3.5);flex-wrap:wrap;display:flex}.dashboard-healthy-strip-label{align-items:center;gap:calc(var(--spacing) * 1.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-success);display:flex}.dashboard-healthy-metric{align-items:baseline;gap:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);font-size:.82rem;display:flex}.dashboard-healthy-metric strong{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold);color:var(--pb-text-primary)}.dashboard-inline-link{align-items:center;gap:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.dashboard-inline-link:hover{color:var(--pb-interactive-hover)}.dashboard-download-row{align-items:center;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3.5);grid-template-columns:1fr 100px 80px;display:grid}.dashboard-watch-item,.dashboard-exception-row{border-radius:var(--radius-2xl)}.dashboard-watch-item-critical,.dashboard-exception-row-critical{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-critical,.dashboard-exception-row-critical{border-color:color-mix(in srgb, var(--pb-error) 20%, transparent)}}.dashboard-watch-item-critical,.dashboard-exception-row-critical{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-critical,.dashboard-exception-row-critical{background-color:color-mix(in srgb, var(--pb-error) 7%, var(--pb-bg-card))}}.dashboard-watch-item-high,.dashboard-exception-row-high{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-high,.dashboard-exception-row-high{border-color:color-mix(in srgb, var(--pb-warning) 22%, transparent)}}.dashboard-watch-item-high,.dashboard-exception-row-high{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-high,.dashboard-exception-row-high{background-color:color-mix(in srgb, var(--pb-warning) 8%, var(--pb-bg-card))}}.dashboard-watch-item-watch,.dashboard-exception-row-watch{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-watch,.dashboard-exception-row-watch{border-color:color-mix(in srgb, var(--pb-warning) 16%, transparent)}}.dashboard-watch-item-watch,.dashboard-exception-row-watch{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-watch,.dashboard-exception-row-watch{background-color:color-mix(in srgb, var(--pb-warning) 6%, var(--pb-bg-card))}}.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{border-color:color-mix(in srgb, var(--pb-success) 16%, transparent)}}.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{background-color:color-mix(in srgb, var(--pb-success) 6%, var(--pb-bg-card))}}.dashboard-watch-item-info,.dashboard-exception-row-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-info,.dashboard-exception-row-info{border-color:color-mix(in srgb, var(--pb-info) 16%, transparent)}}.dashboard-watch-item-info,.dashboard-exception-row-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-info,.dashboard-exception-row-info{background-color:color-mix(in srgb, var(--pb-info) 6%, var(--pb-bg-card))}}.dashboard-mission-page{gap:calc(var(--spacing) * 4);width:100%;padding-bottom:var(--pb-page-footer-clearance);flex-direction:column;display:flex}.dashboard-mission-control{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 5);flex-wrap:wrap;display:flex}.dashboard-mission-control__summary{align-items:flex-start;gap:calc(var(--spacing) * 6);flex-wrap:wrap;flex:1;min-width:0;display:flex}.dashboard-mission-control__title-block{min-width:0}.dashboard-mission-control__title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text);font-family:Syne,sans-serif;font-size:1.5rem;font-weight:800;line-height:1}.dashboard-mission-control__title span{color:var(--pb-brand)}.dashboard-mission-control__subtitle{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.dashboard-mission-control__gauges{align-items:flex-end;gap:calc(var(--spacing) * 4);flex-wrap:wrap;display:flex}.dashboard-mission-control__gauges-spacer{flex:none;width:224px;min-width:224px;min-height:72px}.dashboard-gauge{text-align:center;min-width:64px}.dashboard-gauge__ring{width:56px;height:56px;margin-inline:auto;position:relative}.dashboard-gauge__ring svg{width:56px;height:56px;transform:rotate(-90deg)}.dashboard-gauge__track{fill:none;stroke:var(--pb-text-dim)}@supports (color:color-mix(in lab, red, red)){.dashboard-gauge__track{stroke:color-mix(in srgb, var(--pb-text-dim) 14%, transparent)}}.dashboard-gauge__track{stroke-width:4.5px}.dashboard-gauge__fill{fill:none;stroke-width:4.5px;stroke-linecap:round}.dashboard-gauge__value{justify-content:center;align-items:center;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700;display:flex;position:absolute;inset:0}.dashboard-gauge__label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);margin-top:.2rem;font-size:.52rem;font-weight:600;display:block}.dashboard-gauge--success .dashboard-gauge__fill,.dashboard-gauge--success .dashboard-gauge__value{stroke:var(--pb-success);color:var(--pb-success)}.dashboard-gauge--info .dashboard-gauge__fill,.dashboard-gauge--info .dashboard-gauge__value{stroke:var(--pb-info);color:var(--pb-info)}.dashboard-gauge--warning .dashboard-gauge__fill,.dashboard-gauge--warning .dashboard-gauge__value{stroke:var(--pb-warning);color:var(--pb-warning)}.dashboard-gauge--danger .dashboard-gauge__fill,.dashboard-gauge--danger .dashboard-gauge__value{stroke:var(--pb-error);color:var(--pb-error)}.dashboard-gauge--neutral .dashboard-gauge__fill,.dashboard-gauge--neutral .dashboard-gauge__value{stroke:var(--pb-text-dim);color:var(--pb-text-dim)}.dashboard-scoreboard{grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:.5rem;display:grid}.dashboard-scoreboard__item{gap:calc(var(--spacing) * .5);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);min-width:0;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);box-shadow:var(--pb-shadow-1);border-radius:10px;flex-direction:column;display:flex}.dashboard-scoreboard__label,.dashboard-section__label{letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.54rem;font-weight:700}.dashboard-scoreboard__value{color:var(--pb-text);font-family:JetBrains Mono,monospace;font-size:1.05rem;font-weight:700}.dashboard-scoreboard__delta{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.66rem}:where(.dashboard-section>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}.dashboard-table-wrap,.dashboard-activity-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:12px;overflow-x:auto}.dashboard-table{border-collapse:collapse;width:100%}.dashboard-table th{text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);background:var(--pb-bg-shell);border-bottom:1px solid var(--pb-border);padding:.45rem .9rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700}.dashboard-table th.c,.dashboard-table td.c{text-align:center}.dashboard-table th.r,.dashboard-table td.r{text-align:right}.dashboard-table td{border-bottom:1px solid var(--pb-border-subtle);padding:.6rem .9rem;font-size:.82rem;transition:background-color .12s}.dashboard-table tbody tr:last-child td{border-bottom:0}.dashboard-table tbody tr:hover td{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.dashboard-table tbody tr:hover td{background:color-mix(in srgb, var(--pb-interactive) 8%, var(--pb-bg-card))}}.dashboard-table__status-col{width:40px}.dashboard-table__name{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.dashboard-table__detail{margin-top:calc(var(--spacing) * .5);color:var(--pb-text-dim);font-size:.75rem}.table-mono-value,td.table-mono-value,.dashboard-table__mono,td.dashboard-table__mono,.health-table__mono,td.health-table__mono,.series-mission-control-owned,.series-mission-control-type,.utility-tool-table-mono,td.utility-tool-table-mono,.downloads-mono-cell,td.downloads-mono-cell{color:var(--pb-text-secondary);font-variant-numeric:tabular-nums;font-family:JetBrains Mono,monospace;font-size:.75rem;font-weight:400;line-height:1.2}.series-mission-control-reading{color:var(--pb-interactive);white-space:nowrap;margin-top:.22rem;font-family:JetBrains Mono,monospace;font-size:.58rem;font-weight:600;line-height:1.2;display:block}.dashboard-table__link{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.dashboard-table__link:hover{color:var(--pb-interactive-hover)}.dashboard-led{vertical-align:middle;border-radius:999px;flex-shrink:0;width:8px;height:8px;display:inline-block}.dashboard-led--sys{border:1px solid var(--pb-text);width:10px;height:10px}@supports (color:color-mix(in lab, red, red)){.dashboard-led--sys{border:1px solid color-mix(in srgb, var(--pb-text) 10%, transparent)}}.dashboard-led--green{background:var(--pb-success);box-shadow:0 0 5px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.dashboard-led--amber{background:var(--pb-warning);box-shadow:0 0 5px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-warning) 35%, transparent)}}.dashboard-led--red{background:var(--pb-error);box-shadow:0 0 5px var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--red{box-shadow:0 0 5px color-mix(in srgb, var(--pb-error) 35%, transparent)}}.dashboard-led--blue{background:var(--pb-info);box-shadow:0 0 5px var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--blue{box-shadow:0 0 5px color-mix(in srgb, var(--pb-info) 35%, transparent)}}.dashboard-led--off{background:var(--pb-text-dim);opacity:.4}.dashboard-progress{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.dashboard-progress__track{height:calc(var(--spacing) * 1.5);border-radius:var(--radius-xs);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background:var(--pb-text-dim);flex:1;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.dashboard-progress__track{background:color-mix(in srgb, var(--pb-text-dim) 10%, transparent)}}.dashboard-progress__fill{height:100%;position:relative}.dashboard-progress__fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}.dashboard-progress__fill--green{background:linear-gradient(90deg, var(--pb-success), var(--pb-success))}@supports (color:color-mix(in lab, red, red)){.dashboard-progress__fill--green{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-success) 45%, transparent), var(--pb-success))}}.dashboard-progress__fill--blue{background:linear-gradient(90deg, var(--pb-info), var(--pb-info))}@supports (color:color-mix(in lab, red, red)){.dashboard-progress__fill--blue{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-info) 45%, transparent), var(--pb-info))}}.dashboard-progress__fill--pulse{opacity:.6;width:100%;animation:1.6s ease-in-out infinite pulse}.dashboard-progress__percent,.dashboard-activity-row__time,.dashboard-footer-strip{font-family:JetBrains Mono,monospace}.dashboard-progress__percent{text-align:right;min-width:32px;color:var(--pb-text-sec);font-size:.66rem;font-weight:600}.dashboard-activity-card{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2)}.dashboard-activity-row{align-items:flex-start;gap:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);border-bottom:1px solid var(--pb-border-subtle);display:flex}.dashboard-activity-row:last-child{border-bottom:0}.dashboard-activity-row__dot{margin-top:calc(var(--spacing) * 1.5);height:calc(var(--spacing) * 2);width:calc(var(--spacing) * 2);border-radius:3.40282e38px;flex-shrink:0}.dashboard-activity-row__dot--acquired{background:var(--pb-success)}.dashboard-activity-row__dot--imported{background:var(--pb-brand)}.dashboard-activity-row__dot--failed{background:var(--pb-error)}.dashboard-activity-row__body{flex:1}.dashboard-activity-row__summary{--tw-leading:calc(var(--spacing) * 6);font-size:.82rem;line-height:calc(var(--spacing) * 6);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary)}.dashboard-activity-row__detail{--tw-leading:calc(var(--spacing) * 5);font-size:.78rem;line-height:calc(var(--spacing) * 5);color:var(--pb-text-secondary)}.dashboard-activity-row__meta{margin-top:var(--spacing);justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);display:flex}.dashboard-activity-row__time{color:var(--pb-text-dim);font-size:.66rem}.dashboard-activity-row__link{--tw-font-weight:var(--font-weight-semibold);font-size:.68rem;font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.dashboard-activity-row__link:hover{color:var(--pb-interactive-hover)}.dashboard-activity-card__empty{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-dim)}.dashboard-footer-strip{align-items:center;column-gap:calc(var(--spacing) * 6);row-gap:calc(var(--spacing) * 2);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2.5);color:var(--pb-text-dim);box-shadow:var(--pb-shadow-1);border-radius:10px;flex-wrap:wrap;font-size:.72rem;display:flex}.dashboard-footer-strip strong{color:var(--pb-text);font-weight:600}.library-page-shell{flex:1;width:100%;min-height:0;overflow:hidden}.library-format-pills{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.library-scoreboard__item{text-decoration:none;transition:background-color .12s}.library-scoreboard__item--link:hover{background-color:var(--pb-bg-card-hover)}.library-scoreboard__value--info{color:var(--pb-info)}.library-scoreboard__value--success{color:var(--pb-success)}.library-scoreboard__value--warning{color:var(--pb-warning)}.library-matching-banner{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));border-width:1px;border-color:var(--pb-warning);border-radius:12px;display:flex}@supports (color:color-mix(in lab, red, red)){.library-matching-banner{border-color:color-mix(in srgb, var(--pb-warning) 26%, transparent)}}.library-matching-banner{background:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.library-matching-banner{background:color-mix(in srgb, var(--pb-warning) 8%, var(--pb-bg-card))}}.library-matching-banner{box-shadow:var(--pb-shadow-1)}.library-matching-banner__body{align-items:center;column-gap:calc(var(--spacing) * 2);row-gap:var(--spacing);flex-wrap:wrap;min-width:0;display:flex}.library-matching-banner__title{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-matching-banner__copy{color:var(--pb-text-secondary)}.library-browser{--library-browser-header-height:calc(1.5rem + .75rem + 1px);border:1px solid var(--pb-border);background:var(--pb-bg-card);min-height:0;box-shadow:var(--pb-shadow-1);border-radius:14px;flex:1 1 0;grid-template-columns:minmax(220px,260px) minmax(0,1fr);display:grid;overflow:hidden}.library-browser__tree{overscroll-behavior:contain;z-index:2;border-right:1px solid var(--pb-border);background:var(--pb-bg-surface);flex-direction:column;min-height:0;display:flex;position:relative;overflow:hidden}.library-browser__tree-list{min-height:0;padding-block:var(--spacing);overscroll-behavior:contain;flex:1;overflow-y:auto}.library-browser__tree-header,.library-browser__toolbar{min-height:var(--library-browser-header-height);height:var(--library-browser-header-height)}.library-browser__tree-header{padding-inline:calc(var(--spacing) * 3);z-index:3;letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);border-bottom:1px solid var(--pb-border);background:var(--pb-bg-surface);align-items:center;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700;display:flex;position:sticky;top:0}@supports (color:color-mix(in lab, red, red)){.library-browser__tree-header{background:color-mix(in srgb, var(--pb-bg-surface) 72%, transparent)}}.library-browser__tree-node{min-height:calc(var(--spacing) * 6);border-radius:var(--radius-md);min-width:0;padding-inline:calc(var(--spacing) * 1.5);padding-block:var(--spacing);color:var(--pb-text-secondary);flex:1;align-items:center;font-size:.73rem;font-weight:400;line-height:1.2;text-decoration:none;display:flex}.library-browser__tree-node--active{background:var(--pb-interactive-dim);color:var(--pb-interactive);font-weight:500}.library-browser__tree-icon{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);color:var(--pb-text-dim);flex-shrink:0;transition:transform .14s,color .14s}.library-browser__tree-icon--folder{color:var(--pb-brand)}.library-browser__tree-icon--expanded{color:var(--pb-interactive);transform:rotate(90deg)}.library-browser__tree-item{padding-left:calc(var(--library-tree-level,0) * .8rem)}.library-browser__tree-row{min-height:calc(var(--spacing) * 6);align-items:center;gap:var(--spacing);min-width:0;padding-inline:calc(var(--spacing) * 2);display:flex}.library-browser__tree-row--context .library-browser__tree-node,.library-browser__tree-row--context .library-browser__tree-toggle,.library-browser__tree-row--context .library-browser__tree-leaf{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser__tree-row--context .library-browser__tree-node,.library-browser__tree-row--context .library-browser__tree-toggle,.library-browser__tree-row--context .library-browser__tree-leaf{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.library-browser__tree-row--context .library-browser__tree-node,.library-browser__tree-row--context .library-browser__tree-toggle,.library-browser__tree-row--context .library-browser__tree-leaf{color:var(--pb-interactive)}.library-browser__tree-toggle,.library-browser__tree-leaf{height:calc(var(--spacing) * 6);width:calc(var(--spacing) * 6);color:var(--pb-text-dim);flex-shrink:0;justify-content:center;align-items:center;display:inline-flex}.library-browser__tree-toggle{border-radius:4px;transition:background-color .12s,color .12s}.library-browser__tree-toggle:hover{background:var(--pb-bg-card-hover);color:var(--pb-text)}:where(.library-browser__tree-children>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}.library-browser__tree-name{flex:1;min-width:0;display:block}.library-browser__panel{z-index:1;flex-direction:column;min-width:0;min-height:0;display:flex;position:relative;overflow:hidden}.library-browser__toolbar{align-items:center;gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 3);border-bottom:1px solid var(--pb-border);background:var(--pb-bg-surface);flex-shrink:0;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser__toolbar{background:color-mix(in srgb, var(--pb-bg-surface) 72%, transparent)}}.library-browser__toolbar-main{align-items:center;gap:calc(var(--spacing) * 1.5);flex:1;min-width:0;display:flex}.library-browser__up-btn{height:calc(var(--spacing) * 6);width:calc(var(--spacing) * 6);border-radius:var(--radius-xs);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-shrink:0;justify-content:center;align-items:center;text-decoration:none;display:inline-flex}.library-browser__up-btn:hover{background:var(--pb-bg-card-hover);color:var(--pb-text)}.library-browser__up-btn.is-disabled{pointer-events:none;opacity:.4}.library-browser__path-block{flex:1;align-items:center;min-width:0;display:flex}.library-browser__path{color:var(--pb-text-sec);white-space:nowrap;text-overflow:ellipsis;font-family:JetBrains Mono,monospace;font-size:.56rem;line-height:1.15;overflow:hidden}.library-browser__table-wrap{overscroll-behavior:contain;min-height:0;box-shadow:none;background:0 0;border:0;border-radius:0;flex:1;position:relative;overflow:auto}.library-browser-table{min-width:760px}.library-browser-table th{padding:.4rem .75rem;font-size:.54rem;font-weight:600}.library-browser-table td{padding:.5rem .75rem;font-size:.76rem;font-weight:400;line-height:1.3}.library-browser__table-row--file,.library-browser__table-row--file td,.library-browser__table-row--file .library-browser__name-label{cursor:pointer}.library-browser__table-row--context{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser__table-row--context{background:color-mix(in srgb, var(--pb-interactive) 8%, transparent)}}.library-context-menu{border-radius:var(--radius-xl);border-style:var(--tw-border-style);min-width:15.5rem;padding-block:calc(var(--spacing) * 1.5);z-index:80;border-width:1px;border-color:var(--pb-border);position:fixed;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.library-context-menu{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-context-menu{background:linear-gradient(180deg, var(--pb-bg-card) 0%, var(--pb-bg-surface) 100%)}@supports (color:color-mix(in lab, red, red)){.library-context-menu{background:linear-gradient(180deg, color-mix(in srgb, var(--pb-bg-card) 94%, transparent) 0%, color-mix(in srgb, var(--pb-bg-surface) 97%, transparent) 100%)}}.library-context-menu{-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);box-shadow:0 18px 48px #0f172a42,0 6px 18px #0f172a29}.library-context-menu__group{display:contents}.library-context-menu__item{align-items:center;gap:calc(var(--spacing) * 3);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));color:var(--pb-text);display:flex}.library-context-menu__item:hover{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-context-menu__item:hover{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.library-context-menu__item:hover{color:var(--pb-interactive)}.library-context-menu__item--danger:hover{background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-context-menu__item--danger:hover{background:color-mix(in srgb, var(--pb-error) 11%, transparent)}}.library-context-menu__item--danger:hover{color:var(--pb-error)}.library-context-menu__icon{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);color:currentColor;flex-shrink:0}.library-context-menu__sep{margin-block:var(--spacing);background:var(--pb-border-subtle);height:1px}@supports (color:color-mix(in lab, red, red)){.library-context-menu__sep{background:color-mix(in srgb, var(--pb-border-subtle) 80%, transparent)}}.modal-panel.library-browser-modal{border-radius:18px;width:min(92vw,48rem);max-height:min(86vh,52rem);overflow:hidden}.modal-panel.library-browser-modal--properties{width:min(84vw,28rem)}.modal-panel.library-browser-modal--compact,.modal-panel.library-delete-modal{width:min(92vw,34rem)}.library-delete-modal__header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 4);border-bottom-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);border-bottom-width:1px;border-color:var(--pb-border);display:flex}.library-delete-modal__eyebrow{letter-spacing:.14em;text-transform:uppercase;color:var(--pb-text-dim);font-size:11px;font-weight:500}.library-delete-modal__title{margin-top:var(--spacing);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-delete-modal__subtitle{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}:where(.library-delete-modal__body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.library-delete-modal__body{padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4)}.library-delete-modal__copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.library-delete-modal__impact{border-radius:var(--radius-lg);border-style:var(--tw-border-style);padding:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border-hover)}@supports (color:color-mix(in lab, red, red)){.library-delete-modal__impact{border-color:color-mix(in srgb, var(--pb-border-hover) 88%, transparent)}}.library-delete-modal__impact{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-delete-modal__impact{background:color-mix(in srgb, var(--pb-bg-surface) 40%, transparent)}}.library-delete-modal__impact .library-browser-modal__details-table{margin-top:0}.series-delete-modal__options{gap:calc(var(--spacing) * 3);flex-direction:column;display:flex}.series-delete-modal__option{align-items:flex-start;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border);display:flex}@supports (color:color-mix(in lab, red, red)){.series-delete-modal__option{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.series-delete-modal__option{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.series-delete-modal__option{background:color-mix(in srgb, var(--pb-bg-surface) 82%, transparent)}}.series-delete-modal__checkbox{margin-top:calc(var(--spacing) * .5);border-color:var(--pb-border-hover);background:var(--pb-bg-card-hover);color:var(--pb-error);border-radius:.25rem}.series-delete-modal__checkbox:focus{--tw-ring-color:var(--pb-error);--tw-ring-offset-width:0px}.series-delete-modal__option-copy{min-width:0}.series-delete-modal__option-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary)}.series-delete-modal__option-text{margin-top:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);line-height:1.5}.library-delete-modal__footer{justify-content:flex-end;align-items:center;gap:calc(var(--spacing) * 2);border-top-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 3);border-top-width:1px;border-color:var(--pb-border);background:var(--pb-bg-card);display:flex}@supports (color:color-mix(in lab, red, red)){.library-delete-modal__footer{background:color-mix(in srgb, var(--pb-bg-card) 80%, transparent)}}.library-browser-modal__header{align-items:flex-start;gap:calc(var(--spacing) * 4);background:linear-gradient(180deg, var(--pb-bg-surface) 0%, var(--pb-bg-card) 100%)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__header{background:linear-gradient(180deg, color-mix(in srgb, var(--pb-bg-surface) 95%, transparent) 0%, color-mix(in srgb, var(--pb-bg-card) 96%, transparent) 100%)}}.library-browser-modal--properties .library-browser-modal__header,.library-browser-modal--properties .library-browser-modal__body{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.library-browser-modal--properties .modal-footer{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);justify-content:flex-end}.library-browser-modal--form .library-browser-modal__body{padding-block:0;padding-inline:0}.library-browser-modal--form .modal-footer{padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 3)}.library-browser-modal .modal-footer{justify-content:flex-end;gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 3);background:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal .modal-footer{background:color-mix(in srgb, var(--pb-bg-card) 80%, transparent)}}.library-browser-modal__title-block{align-items:flex-start;gap:calc(var(--spacing) * 3);min-width:0;display:flex}.library-browser-modal__icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);color:var(--pb-interactive);border-width:1px;border-color:var(--pb-interactive);flex-shrink:0;justify-content:center;align-items:center;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon{border-color:color-mix(in srgb, var(--pb-interactive) 24%, transparent)}}.library-browser-modal__icon{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.library-browser-modal__icon svg{height:calc(var(--spacing) * 5);width:calc(var(--spacing) * 5)}.library-browser-modal__icon--warning{color:var(--pb-warning);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--warning{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.library-browser-modal__icon--warning{background:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--warning{background:color-mix(in srgb, var(--pb-warning) 10%, transparent)}}.library-browser-modal__icon--danger{color:var(--pb-error);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--danger{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.library-browser-modal__icon--danger{background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--danger{background:color-mix(in srgb, var(--pb-error) 10%, transparent)}}.library-browser-modal--properties .library-browser-modal__icon{height:calc(var(--spacing) * 9);width:calc(var(--spacing) * 9);border-radius:var(--radius-xl)}.library-browser-modal--properties .library-browser-modal__icon svg{width:1.125rem;height:1.125rem}.library-browser-modal__title{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-browser-modal__subtitle{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.library-browser-modal__close{border-radius:var(--radius-lg);padding:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.library-browser-modal__close:hover{background:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__close:hover{background:color-mix(in srgb, var(--pb-bg-card-hover) 88%, transparent)}}.library-browser-modal__close:hover{color:var(--pb-text)}.library-browser-modal__close:focus-visible{outline:2px solid var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__close:focus-visible{outline:2px solid color-mix(in srgb, var(--pb-interactive) 42%, transparent)}}.library-browser-modal__close:focus-visible{outline-offset:2px}.library-browser-modal__body{min-height:0}.library-browser-modal__form{flex-direction:column;flex:1;min-height:0;display:flex}.library-browser-modal__content{gap:calc(var(--spacing) * 4);flex-direction:column;display:flex}.library-browser-modal__section-label{margin-bottom:calc(var(--spacing) * 2);padding-bottom:calc(var(--spacing) * 2);letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);border-bottom:1px solid var(--pb-border-subtle);font-family:Syne,sans-serif;font-size:.64rem;font-weight:700;display:block}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__section-label{border-bottom:1px solid color-mix(in srgb, var(--pb-border-subtle) 80%, transparent)}}.library-browser-modal__section{padding-bottom:var(--spacing)}.library-browser-modal__details-table{border-collapse:collapse;table-layout:fixed;width:100%;margin-top:.625rem;font-size:.8rem}.library-browser-modal__details-table--flush{margin-top:0}.library-browser-modal__detail-label{vertical-align:top;width:6.875rem;color:var(--pb-text-dim);font-size:inherit;text-align:left;padding:.25rem .75rem .25rem 0;font-weight:500;line-height:1.35}.library-browser-modal__detail-value{vertical-align:top;min-width:0;color:var(--pb-text);font-size:inherit;overflow-wrap:anywhere;padding:.25rem 0;font-weight:500;line-height:1.4}.library-browser-modal__detail-value--mono{color:var(--pb-text-sec);font-family:JetBrains Mono,monospace;font-size:.76rem;line-height:1.45}.library-browser-modal__loading{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 6);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__loading{border-color:color-mix(in srgb, var(--pb-border) 86%, transparent)}}.library-browser-modal__loading{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__loading{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser-modal__stats{gap:calc(var(--spacing) * 3);grid-template-columns:repeat(auto-fit,minmax(8.5rem,1fr));display:grid}.library-browser-modal__stat{justify-content:space-between;gap:calc(var(--spacing) * 2);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);min-height:5rem;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border);flex-direction:column;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__stat{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__stat{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__stat{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser-modal__stat-label,.library-browser-modal__field-label{letter-spacing:.11em;text-transform:uppercase;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.62rem;font-weight:700}.library-browser-modal__stat-value,.library-browser-modal__preview-value{min-width:0;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-primary);overflow-wrap:anywhere;font-weight:500;line-height:1.35}.library-browser-modal__meta{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__meta{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__meta{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__meta{background:color-mix(in srgb, var(--pb-bg-surface) 93%, transparent)}}.library-browser-modal__meta-row{gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);grid-template-columns:minmax(8rem,10rem) minmax(0,1fr);display:grid}.library-browser-modal__meta-row+.library-browser-modal__meta-row{border-top:1px solid var(--pb-border-subtle)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__meta-row+.library-browser-modal__meta-row{border-top:1px solid color-mix(in srgb, var(--pb-border-subtle) 84%, transparent)}}.library-browser-modal__meta-label{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-dim);letter-spacing:.08em;text-transform:uppercase}.library-browser-modal__meta-value{min-width:0;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);overflow-wrap:anywhere;font-family:JetBrains Mono,monospace;font-size:.76rem;line-height:1.45}.library-browser-modal__storage{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__storage{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage{background:color-mix(in srgb, var(--pb-bg-surface) 93%, transparent)}}.library-browser-modal__storage-head{margin-bottom:calc(var(--spacing) * 2);justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);display:flex}.library-browser-modal__storage-bar{height:calc(var(--spacing) * 2.5);background:var(--pb-bg-card-hover);border-radius:3.40282e38px;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage-bar{background:color-mix(in srgb, var(--pb-bg-card-hover) 72%, transparent)}}.library-browser-modal__storage-fill{background:linear-gradient(90deg, var(--pb-interactive) 0%, var(--pb-brand) 100%);border-radius:3.40282e38px;height:100%;display:block}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage-fill{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-interactive) 82%, white) 0%, color-mix(in srgb, var(--pb-brand) 74%, white) 100%)}}.library-browser-modal__field{gap:calc(var(--spacing) * 2);flex-direction:column;display:flex}.library-browser-modal__settings-rows{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border)}.library-browser-modal__note{padding-inline:calc(var(--spacing) * 6)}.library-browser-modal__body-inset{padding-inline:calc(var(--spacing) * 6);padding-bottom:calc(var(--spacing) * 4)}.library-browser-modal__body-inset--section{padding-bottom:0}.library-browser-modal__body-inset--compact-top{padding-top:calc(var(--spacing) * 3)}.library-browser-modal__body-inset--roomy-bottom{padding-bottom:calc(var(--spacing) * 4)}.library-browser-modal__section-stack{gap:calc(var(--spacing) * 4);padding-top:calc(var(--spacing) * 4);flex-direction:column;display:flex}.library-browser-modal__section-stack--delete{gap:calc(var(--spacing) * 4)}.library-browser-modal__section-panel{flex-direction:column;display:flex}.library-browser-modal__section-heading-row{padding-inline:calc(var(--spacing) * 6);padding-bottom:calc(var(--spacing) * 3);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-bottom:1px solid var(--pb-border-subtle);padding-top:0}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__section-heading-row{border-bottom:1px solid color-mix(in srgb, var(--pb-border-subtle) 80%, transparent)}}.library-browser-modal__preview-block{min-height:calc(var(--spacing) * 10);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-block{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__preview-block{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-block{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser-modal__preview-block{overflow-wrap:anywhere;line-height:1.45}.library-browser-modal__preview-block--mono{font-family:JetBrains Mono,monospace;font-size:.76rem}.library-browser-modal__detail-input{width:100%;min-height:2.5rem;font-size:.88rem;display:block}.library-browser-modal__helper,.library-browser-modal__context-value{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.library-browser-modal__context-value--mono{color:var(--pb-text-sec);overflow-wrap:anywhere;font-family:JetBrains Mono,monospace;font-size:.76rem;line-height:1.45}.library-browser-modal__error{border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-error);border-width:1px;border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__error{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.library-browser-modal__error{background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__error{background:color-mix(in srgb, var(--pb-error) 8%, transparent)}}.library-browser-modal__actions{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.library-browser-modal__preview-grid{gap:calc(var(--spacing) * 3);grid-template-columns:repeat(auto-fit,minmax(10rem,1fr));display:grid}.library-browser-modal__preview-card{justify-content:space-between;gap:calc(var(--spacing) * 2);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);min-height:5.25rem;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border);flex-direction:column;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-card{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__preview-card{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-card{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser__name{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.library-browser__name-icon{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);flex-shrink:0}.library-browser__name-icon--folder{color:var(--pb-brand)}.library-browser__name-icon--file{color:var(--pb-text-dim)}.library-browser__name-label{text-overflow:ellipsis;white-space:nowrap;min-width:0;font-size:.76rem;font-weight:400;overflow:hidden}.library-browser__name-label--folder{color:var(--pb-text-primary);font-weight:500;text-decoration:none}.library-browser__name-label--folder:hover{color:var(--pb-interactive)}.library-browser__empty{height:100%;min-height:280px;padding-inline:calc(var(--spacing) * 6);text-align:center;flex-direction:column;justify-content:center;align-items:center;display:flex}.library-browser__empty-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);color:var(--pb-text-dim)}.library-browser__empty-title{margin-top:calc(var(--spacing) * 4);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-browser__empty-copy{margin-top:calc(var(--spacing) * 2);max-width:var(--container-xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.library-empty-shell{align-items:flex-start;gap:calc(var(--spacing) * 4);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 8);box-shadow:var(--pb-shadow-1);border-radius:14px;flex-direction:column;display:flex}.library-empty-shell__icon-wrap{height:calc(var(--spacing) * 16);width:calc(var(--spacing) * 16);background-color:var(--pb-bg-card-hover);color:var(--pb-text-dim);border-radius:3.40282e38px;justify-content:center;align-items:center;display:flex}.library-empty-shell__icon{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8)}:where(.library-empty-shell__body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.library-empty-shell__copy{max-width:var(--container-2xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7);color:var(--pb-text-secondary)}.library-empty-shell__actions{align-items:center;gap:calc(var(--spacing) * 3);flex-wrap:wrap;display:flex}@media (max-width:1024px){.library-browser{flex:none;grid-template-columns:1fr;height:auto;min-height:520px}.library-browser__tree{border-right:0;border-bottom:1px solid var(--pb-border);max-height:220px}}.health-section__label{letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);margin-block:.625rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700;display:block}.health-component-card__sub,.health-detail-card__stat-value,.health-history-table td{font-family:JetBrains Mono,monospace}:where(.health-section>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}.health-component-grid{gap:calc(var(--spacing) * 3);grid-template-columns:repeat(auto-fit,minmax(280px,1fr));display:grid}.health-component-card{cursor:pointer;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-top:calc(var(--spacing) * 4);padding-bottom:calc(var(--spacing) * 3);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-width:1px;border-radius:14px;flex-direction:column;height:100%;display:flex}.health-component-card:hover{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.health-component-card:hover{background:color-mix(in srgb, var(--pb-interactive) 4%, var(--pb-bg-card))}}.health-component-card:hover{border-color:var(--pb-border-strong)}.health-component-card--success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.health-component-card--success{border-color:color-mix(in srgb, var(--pb-success) 22%, var(--pb-border))}}.health-component-card--warning{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.health-component-card--warning{border-color:color-mix(in srgb, var(--pb-warning) 22%, var(--pb-border))}}.health-component-card--danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.health-component-card--danger{border-color:color-mix(in srgb, var(--pb-error) 22%, var(--pb-border))}}.health-component-card--neutral{border-color:var(--pb-border)}.health-component-card__header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 3);display:flex}.health-component-card__name{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text);font-family:Syne,sans-serif;font-size:.84rem;font-weight:700}.health-component-card__stats{align-items:flex-start;gap:calc(var(--spacing) * 2);grid-template-columns:repeat(2,minmax(0,1fr));display:grid}.health-detail-card__stats{gap:calc(var(--spacing) * 2);display:grid}@media (min-width:40rem){.health-detail-card__stats{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.health-detail-card__stats{grid-template-columns:repeat(4,minmax(0,1fr))}}.health-component-card__stat,.health-detail-card__stat{border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);border-width:1px;border-color:var(--pb-border-subtle);border-radius:10px;flex-direction:column;gap:0;display:flex}.health-component-card__stat{background:var(--pb-bg-base)}.health-detail-card__stat{padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 1.5);background:var(--pb-bg-base)}.health-component-card__stat-label,.health-detail-card__stat-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.46rem;font-weight:700}.health-component-card__stat-value,.health-detail-card__stat-value{--tw-font-weight:var(--font-weight-semibold);font-size:.82rem;font-weight:var(--font-weight-semibold);color:var(--pb-text-primary);display:block}.health-component-card__stat-value{white-space:pre-line;line-height:1.25}.health-component-card__stat-value--danger,.health-detail-card__stat-value--danger{color:var(--pb-error)}.health-component-card__stat-value--warning,.health-detail-card__stat-value--warning{color:var(--pb-warning)}.health-component-card__message{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary);min-height:2.5rem}.health-component-card__sub{color:var(--pb-text-dim);margin-top:auto;font-size:.68rem}:where(.health-detail-shell>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.health-detail-back{align-items:center;gap:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.health-detail-back:hover{color:var(--pb-interactive-hover)}:where(.health-detail-card>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.health-detail-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:14px}.health-detail-card__header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 3);flex-wrap:wrap;display:flex}.health-detail-card__title-row{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;min-width:0;display:flex}.health-detail-card__title{letter-spacing:.03em;text-transform:uppercase;color:var(--pb-text);font-family:Syne,sans-serif;font-size:1rem;font-weight:800}:where(.health-check-list>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.health-check-row{align-items:center;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);border-radius:10px;grid-template-columns:8px minmax(120px,140px) minmax(0,1fr) auto;display:grid}.health-check-row--database-metric{grid-template-columns:8px minmax(160px,180px) minmax(0,1fr) auto}.health-check-row__name{text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary);justify-self:flex-start}.health-check-row__message{min-width:0;font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim);flex:1}.health-check-list--database-metrics .health-check-row__name,.health-check-list--database-metrics .health-check-row__message{text-align:left;justify-self:flex-start}.health-check-list--database-metrics .health-led{justify-self:center}.health-table-wrap{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);border-radius:12px;overflow-x:auto}.health-table{border-collapse:collapse;width:100%}.health-table th{text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);background:var(--pb-bg-shell);border-bottom:1px solid var(--pb-border);padding:.45rem .85rem;font-family:Syne,sans-serif;font-size:.52rem;font-weight:700}.health-table td{border-bottom:1px solid var(--pb-border-subtle);color:var(--pb-text-sec);padding:.55rem .85rem;font-size:.76rem}.health-table tbody tr:last-child td{border-bottom:0}.health-table tbody tr:hover td{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.health-table tbody tr:hover td{background:color-mix(in srgb, var(--pb-interactive) 8%, var(--pb-bg-card))}}.health-table th.c,.health-table td.c{text-align:center}.health-table th.r,.health-table td.r{text-align:right}.health-table__status-col{width:40px}.health-table__name{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.health-table__detail{margin-top:calc(var(--spacing) * .5);color:var(--pb-text-dim);font-size:.72rem}.health-led{border-radius:999px;flex-shrink:0;width:8px;height:8px;display:inline-block}.health-led--header{width:10px;height:10px}.health-led--green{background:var(--pb-success);box-shadow:0 0 5px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.health-led--green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.health-led--amber{background:var(--pb-warning);box-shadow:0 0 5px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.health-led--amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-warning) 35%, transparent)}}.health-led--red{background:var(--pb-error);box-shadow:0 0 5px var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.health-led--red{box-shadow:0 0 5px color-mix(in srgb, var(--pb-error) 35%, transparent)}}.health-led--off{background:var(--pb-text-dim);opacity:.4}.health-footer-strip{align-items:center;column-gap:calc(var(--spacing) * 6);row-gap:var(--spacing);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);color:var(--pb-text-dim);border-radius:10px;flex-wrap:wrap;font-family:JetBrains Mono,monospace;font-size:.72rem;display:flex}.health-footer-strip strong{color:var(--pb-text);font-weight:600}.step-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1)}.step-badge{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-info);background-color:var(--pb-info);border-radius:3.40282e38px;justify-content:center;align-items:center;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.step-badge{background-color:color-mix(in srgb, var(--pb-info) 14%, transparent)}}.step-badge{border:1px solid var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.step-badge{border:1px solid color-mix(in srgb, var(--pb-info) 28%, transparent)}}.action-card{align-items:flex-start;gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:var(--pb-bg-card-hover);display:flex}@supports (color:color-mix(in lab, red, red)){.action-card{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 40%, transparent)}}.action-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.action-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.action-card-icon{height:calc(var(--spacing) * 11);width:calc(var(--spacing) * 11);border-radius:var(--radius-xl);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-shrink:0;justify-content:center;align-items:center;display:flex}.action-card-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.action-card-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim)}.utility-launch-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);height:100%;padding:calc(var(--spacing) * 5);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;box-shadow:var(--pb-shadow-1);flex-direction:column;transition-duration:.15s;display:flex}.utility-launch-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.utility-launch-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.utility-launch-card:hover{box-shadow:var(--pb-shadow-2)}.utility-launch-icon-brand{background-color:var(--pb-brand-dim);color:var(--pb-brand)}.utility-launch-icon-interactive{background-color:var(--pb-interactive-dim);color:var(--pb-interactive)}.utility-launch-icon-info{background-color:var(--pb-info-dim);color:var(--pb-info)}.utility-launch-icon-success{background-color:var(--pb-success-dim);color:var(--pb-success)}.utility-launch-icon-warning{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.utility-launch-kicker{margin-top:calc(var(--spacing) * 4);--tw-font-weight:var(--font-weight-medium);font-size:10px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-launch-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-launch-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-launch-footer{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);padding-top:calc(var(--spacing) * 4);margin-top:auto;display:flex}.utility-launch-tag{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.utility-launch-link{align-items:center;gap:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.utility-launch-link:hover{color:var(--pb-interactive-hover)}.utility-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.utility-hero-card{padding-inline:calc(var(--spacing) * 7)}}.utility-hero-card{box-shadow:var(--pb-shadow-2)}.utility-top-link{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 1.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);border-radius:3.40282e38px;align-items:center;display:inline-flex}.utility-top-link:hover{border-color:var(--pb-border-hover);color:var(--pb-text);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.utility-top-link:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.utility-top-link-muted{color:var(--pb-text-dim)}.utility-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.utility-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 42%, transparent), transparent)}}.utility-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.utility-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.utility-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.utility-hero-title{margin-top:calc(var(--spacing) * 2);font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-hero-copy{margin-top:calc(var(--spacing) * 2);max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-hero-highlights{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:48rem){.utility-hero-highlights{grid-template-columns:repeat(3,minmax(0,1fr))}}.utility-hero-highlight{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.utility-hero-highlight-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-hero-highlight-value{margin-top:calc(var(--spacing) * 2);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-hero-highlight-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-hero-actions{gap:calc(var(--spacing) * 3);display:grid}.utility-hero-action{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1)}.utility-hero-action:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.utility-hero-action:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.utility-hero-action-primary{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.utility-hero-action-primary{border-color:color-mix(in srgb, var(--pb-interactive) 26%, transparent)}}.utility-hero-action-primary{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.utility-hero-action-primary{background-color:color-mix(in srgb, var(--pb-interactive) 8%, var(--pb-bg-card))}}.utility-hero-action-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-hero-action-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-hero-action-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.utility-hero-notes{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:80rem){.utility-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.utility-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.utility-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-hero-note-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utilities-page{width:100%;max-width:1100px;padding-bottom:var(--pb-page-footer-clearance);margin-inline:auto}.utilities-shell{width:100%}:where(.utilities-content>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.utilities-header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 5);flex-wrap:wrap;display:flex}.utilities-header-copy{align-items:flex-start;gap:calc(var(--spacing) * 5);flex-wrap:wrap;display:flex}.utilities-header-title{letter-spacing:.04em;text-transform:uppercase;font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1}.utilities-header-title span{color:var(--pb-brand)}.utilities-header-subtitle{color:var(--pb-text-dim);margin-top:4px;font-size:.78rem;font-weight:400;line-height:1.5}.utilities-gauges{align-items:flex-end;gap:calc(var(--spacing) * 3.5);display:flex}.utilities-gauge{text-align:center}.utilities-gauge-ring{height:calc(var(--spacing) * 12);width:calc(var(--spacing) * 12);border-style:var(--tw-border-style);background-color:var(--pb-bg-card);border-width:4px;border-radius:3.40282e38px;justify-content:center;align-items:center;margin-inline:auto;display:flex;position:relative}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring{background-color:color-mix(in srgb, var(--pb-bg-card) 70%, var(--pb-bg-base))}}.utilities-gauge-ring-success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring-success{border-color:color-mix(in srgb, var(--pb-success) 60%, var(--pb-border))}}.utilities-gauge-ring-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring-info{border-color:color-mix(in srgb, var(--pb-info) 60%, var(--pb-border))}}.utilities-gauge-ring-ink{border-color:var(--pb-text)}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring-ink{border-color:color-mix(in srgb, var(--pb-text) 22%, var(--pb-border))}}.utilities-gauge-value{font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:700}.utilities-gauge-label{--tw-font-weight:var(--font-weight-medium);font-size:.52rem;font-weight:var(--font-weight-medium);color:var(--pb-text-dim);text-transform:uppercase;letter-spacing:.1em;margin-top:2px;display:block}.utilities-tabs{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);border-radius:10px;align-self:center;display:inline-flex;overflow:hidden}.utilities-tab{padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 2);--tw-font-weight:var(--font-weight-bold);font-family:Syne,sans-serif;font-size:.78rem;font-weight:var(--font-weight-bold);--tw-tracking:.08em;letter-spacing:.08em;color:var(--pb-text-dim);text-transform:uppercase;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.utilities-tab+.utilities-tab{border-left:1px solid var(--pb-border)}.utilities-tab:hover{color:var(--pb-text);background-color:var(--pb-text)}@supports (color:color-mix(in lab, red, red)){.utilities-tab:hover{background-color:color-mix(in srgb, var(--pb-text) 3%, transparent)}}.utilities-tab.is-active{background-color:var(--pb-interactive);color:var(--pb-text-inverse)}.utilities-section-label{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-bottom:calc(var(--spacing) * 1.5);--tw-font-weight:var(--font-weight-bold);font-family:Syne,sans-serif;font-size:.62rem;font-weight:var(--font-weight-bold);--tw-tracking:.12em;letter-spacing:.12em;color:var(--pb-text-dim);text-transform:uppercase}.utilities-tool-grid{grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:.75rem;display:grid}.utilities-tool-grid-wide{grid-template-columns:repeat(auto-fill,minmax(280px,1fr))}.utility-launch-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);height:100%;padding:calc(var(--spacing) * 5);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;box-shadow:var(--pb-shadow-1);border-radius:14px;flex-direction:column;transition-duration:.15s;display:flex;overflow:hidden}.utility-launch-card:hover{border-color:var(--pb-border-strong);box-shadow:var(--pb-shadow-2);background-color:var(--pb-bg-card);transform:translateY(-2px)}.utility-launch-header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 3);display:flex}.utility-launch-arrow{color:var(--pb-text-dim);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;transition-duration:.15s}.utility-launch-card:hover .utility-launch-arrow{color:var(--pb-interactive);transform:translate(2px)}.utility-launch-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:10px;justify-content:center;align-items:center;display:flex}.utility-launch-title{margin-top:calc(var(--spacing) * 3);--tw-font-weight:var(--font-weight-extrabold);font-family:Syne,sans-serif;font-size:.9rem;font-weight:var(--font-weight-extrabold);--tw-tracking:.04em;letter-spacing:.04em;color:var(--pb-text-primary);text-transform:uppercase}.utility-launch-copy{margin-top:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);flex:1;font-size:.78rem;font-weight:400;line-height:1.5}.utility-launch-footer{margin-top:calc(var(--spacing) * 4);justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);display:flex}.utility-launch-tag{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 2);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-family:JetBrains Mono,monospace;font-weight:var(--font-weight-medium);color:var(--pb-text-dim);text-transform:uppercase;letter-spacing:.06em;align-items:center;font-size:.6rem;display:inline-flex}.utility-tool-page{width:100%;max-width:1100px;margin-inline:auto}:where(.utility-tool-header-block>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.utility-tool-back-link{align-items:center;gap:var(--spacing);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));font-size:.82rem;display:inline-flex}@media (hover:hover){.utility-tool-back-link:hover{color:var(--pb-text-primary)}}.utility-tool-header-shell{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 4);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:14px;flex-wrap:wrap;display:flex}.utility-tool-header-left{align-items:center;gap:calc(var(--spacing) * 3.5);display:flex}.utility-tool-header-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:10px;justify-content:center;align-items:center;display:flex}.utility-tool-header-icon-converter{background-color:var(--pb-interactive-dim);color:var(--pb-interactive)}.utility-tool-header-icon-mass-convert{background-color:var(--pb-success-dim);color:var(--pb-success)}.utility-tool-header-icon-integrity{background-color:var(--pb-info-dim);color:var(--pb-info)}.utility-tool-header-icon-rename,.utility-tool-header-icon-db-check{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.utility-tool-header-icon-export{background-color:var(--pb-brand-dim);color:var(--pb-brand)}.utility-tool-header-title{--tw-font-weight:var(--font-weight-extrabold);font-family:Syne,sans-serif;font-size:1.1rem;font-weight:var(--font-weight-extrabold);--tw-tracking:.03em;letter-spacing:.03em;color:var(--pb-text-primary);text-transform:uppercase}.utility-tool-header-title span{color:var(--pb-brand)}.utility-tool-header-subtitle{margin-top:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.utility-tool-header-tag{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-semibold);font-family:JetBrains Mono,monospace;font-size:.62rem;font-weight:var(--font-weight-semibold);--tw-tracking:.08em;letter-spacing:.08em;color:var(--pb-text-dim);text-transform:uppercase;align-items:center;display:inline-flex}.system-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.system-hero-card{padding-inline:calc(var(--spacing) * 7)}}.system-hero-card{box-shadow:var(--pb-shadow-2)}.system-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.system-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 42%, transparent), transparent)}}.system-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.system-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.system-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.system-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.system-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.system-hero-aside{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.system-hero-aside{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.system-hero-aside{grid-template-columns:repeat(1,minmax(0,1fr))}}.system-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.system-hero-note-emphasis{border-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.system-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-brand) 20%, transparent)}}.system-hero-note-emphasis{background-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.system-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-brand) 8%, var(--pb-bg-card))}}.system-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.system-hero-note-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.system-hero-note-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.system-shell-body{gap:calc(var(--spacing) * 6);display:grid}@media (min-width:80rem){.system-shell-body{grid-template-columns:280px minmax(0,1fr)}}@media (min-width:96rem){.system-shell-body{grid-template-columns:300px minmax(0,1fr)}}.system-rail-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;overflow:hidden}.system-rail-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 5)}.system-rail-eyebrow{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.system-rail-title{margin-top:var(--spacing);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.system-rail-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}:where(.system-rail-links>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.system-rail-links{padding:calc(var(--spacing) * 3)}.system-rail-footer{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.system-rail-footer{background-color:color-mix(in srgb, var(--pb-bg-card) 55%, transparent)}}.system-tab-card{align-items:flex-start;gap:calc(var(--spacing) * 3);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;border-width:1px;transition-duration:.15s;display:flex}.system-tab-card-inactive{border-color:var(--pb-border);background-color:var(--pb-bg-surface)}.system-tab-card-inactive:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.system-tab-card-inactive:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.system-tab-card-inactive:hover{box-shadow:var(--pb-shadow-1)}.system-tab-card-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.system-tab-card-active{border-color:color-mix(in srgb, var(--pb-interactive) 30%, transparent)}}.system-tab-card-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.system-tab-card-active{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.system-tab-card-active{box-shadow:var(--pb-shadow-1)}.system-tab-icon{margin-top:calc(var(--spacing) * .5);height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:var(--radius-2xl);flex-shrink:0;justify-content:center;align-items:center;display:flex}.system-tab-copy{flex:1;min-width:0}.system-tab-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.system-tab-description{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-secondary);display:block}.system-tab-arrow{margin-top:var(--spacing);color:var(--pb-text-dim);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;transition-duration:.15s}.system-tab-card:hover .system-tab-arrow,.system-tab-card-active .system-tab-arrow{color:var(--pb-interactive);transform:translate(.125rem)}.system-focus-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;position:relative;overflow:hidden}.system-focus-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.system-focus-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-interactive) 34%, transparent), transparent)}}.system-focus-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.system-focus-grid{grid-template-columns:minmax(0,1.35fr) minmax(320px,.95fr);align-items:flex-start}}.system-focus-meta{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim);flex-wrap:wrap;display:flex}.system-focus-chip{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.system-focus-actions{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.system-focus-actions{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.system-focus-actions{grid-template-columns:repeat(1,minmax(0,1fr))}}.review-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.review-hero-card{padding-inline:calc(var(--spacing) * 7)}}.review-hero-card{box-shadow:var(--pb-shadow-2)}.review-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-info), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.review-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-info) 42%, transparent), transparent)}}.review-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.review-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.review-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.review-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.review-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.review-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.review-hero-notes{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.review-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.review-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.review-hero-note-emphasis{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-info) 20%, transparent)}}.review-hero-note-emphasis{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-info) 10%, var(--pb-bg-card))}}.review-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.review-hero-note-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.review-workspace-tabs{align-items:center;gap:calc(var(--spacing) * 2);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-radius:1.25rem;flex-wrap:wrap;display:flex}@media (min-width:40rem){.review-workspace-tabs{padding-inline:calc(var(--spacing) * 5)}}.review-workspace-tabs{box-shadow:var(--pb-shadow-1)}.review-tab-chip-active{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary);border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-tab-chip-active{border-color:color-mix(in srgb, var(--pb-info) 34%, transparent)}}.review-tab-chip-active{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-tab-chip-active{background-color:color-mix(in srgb, var(--pb-info) 12%, var(--pb-bg-card))}}.review-tab-chip-active{box-shadow:var(--pb-shadow-1)}.review-tab-chip-inactive{color:var(--pb-text-dim)}.review-focus-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;position:relative;overflow:hidden}.review-focus-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.review-focus-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 34%, transparent), transparent)}}.review-focus-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.review-focus-grid{grid-template-columns:minmax(0,1.35fr) minmax(320px,.95fr);align-items:flex-start}}.review-focus-chip{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.review-focus-actions{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.review-focus-actions{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.review-focus-actions{grid-template-columns:repeat(1,minmax(0,1fr))}}.review-note-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:1.25rem}.workflow-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.workflow-hero-card{padding-inline:calc(var(--spacing) * 7)}}.workflow-hero-card{box-shadow:var(--pb-shadow-2)}.workflow-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.workflow-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-interactive) 40%, transparent), transparent)}}.workflow-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.workflow-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.workflow-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.workflow-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.workflow-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.workflow-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.workflow-hero-notes{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.workflow-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.workflow-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.workflow-hero-note-emphasis{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-interactive) 20%, transparent)}}.workflow-hero-note-emphasis{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.workflow-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.workflow-hero-note-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.series-page-shell{gap:calc(var(--spacing) * 6);flex-direction:column;min-height:100%;display:flex}.series-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.series-hero-card{padding-inline:calc(var(--spacing) * 7)}}.series-hero-card{box-shadow:var(--pb-shadow-2)}.series-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.series-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 34%, transparent), color-mix(in srgb, var(--pb-interactive) 34%, transparent), transparent)}}.series-hero-grid{gap:calc(var(--spacing) * 5);display:grid}@media (min-width:96rem){.series-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.8fr);align-items:flex-start}}.series-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.series-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.series-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7);color:var(--pb-text-secondary)}.series-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.series-hero-notes{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:96rem){.series-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.series-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.series-hero-note-emphasis{border-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.series-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-brand) 22%, transparent)}}.series-hero-note-emphasis{background-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.series-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-brand) 8%, var(--pb-bg-card))}}.series-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.series-hero-note-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.series-hero-note-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.page-context-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:1.25rem}.page-context-back-link{align-items:center;gap:calc(var(--spacing) * 1.5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.page-context-back-link:hover{color:var(--pb-text-primary)}.page-context-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.page-context-grid{grid-template-columns:minmax(0,1.35fr) minmax(280px,.9fr);align-items:flex-start}}.page-context-breadcrumbs{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);flex-wrap:wrap;display:flex}.page-context-link{align-items:center;gap:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.page-context-link:hover{color:var(--pb-text-primary)}.page-context-separator{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);color:var(--pb-text-dim)}.page-context-summary{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.page-context-chip-row{gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.page-context-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);padding:calc(var(--spacing) * 4);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.page-context-note{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 40%, transparent)}}.page-context-note-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.page-context-note-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.detail-hero-shell{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.detail-hero-shell{padding-inline:calc(var(--spacing) * 7)}}.detail-hero-shell{box-shadow:var(--pb-shadow-2)}.detail-hero-shell:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.detail-hero-shell:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 40%, transparent), transparent)}}.detail-hero-grid{gap:calc(var(--spacing) * 6);display:grid}@media (min-width:80rem){.detail-hero-grid{grid-template-columns:220px minmax(0,1fr) 320px}}.detail-cover-panel{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card-hover);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:1.5rem}.detail-summary-panel{min-width:0}:where(.detail-summary-panel>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.detail-header-stack>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.detail-status-row{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.detail-body-copy{max-width:56rem}.detail-stat-grid{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.detail-stat-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.detail-stat-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}.detail-info-grid{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.detail-info-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.detail-info-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}:where(.detail-aside>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.detail-action-panel{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.25rem}.series-domain-page{width:100%;max-width:1400px;padding-bottom:var(--pb-page-footer-clearance);margin-inline:auto}.series-domain-breadcrumb-row{flex-wrap:wrap;align-items:center;gap:.75rem;display:flex}.series-domain-back-link{color:var(--pb-interactive);align-items:center;gap:.25rem;font-size:.82rem;transition:color .14s;display:inline-flex}.series-domain-back-link:hover{color:var(--pb-interactive-hover)}.series-domain-breadcrumbs{color:var(--pb-text-tertiary);flex-wrap:wrap;align-items:center;gap:.35rem;font-size:.82rem;display:flex}.series-domain-breadcrumbs a{color:inherit}.series-domain-breadcrumbs a:hover{color:var(--pb-interactive)}.series-domain-breadcrumb-sep{font-size:.7rem}.series-domain-breadcrumbs .current{color:var(--pb-text-primary);font-weight:600}.detail-hero-shell{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);box-shadow:var(--pb-shadow-1);border-radius:14px;overflow:hidden}.detail-hero-shell:before{display:none}.series-domain-hero-inner{grid-template-columns:130px minmax(0,1fr) minmax(13rem,16rem);align-items:start;gap:1.25rem;display:grid}.story-arc-detail-hero-inner{grid-template-columns:130px minmax(0,1fr) minmax(18rem,19rem)}.issue-domain-hero-inner{grid-template-columns:120px minmax(0,1fr) minmax(13rem,16rem)}.series-domain-cover-column{flex-shrink:0;width:130px}.issue-domain-cover-column{width:120px}.series-domain-cover-frame{aspect-ratio:2/3;border:1px solid var(--pb-border-default);background:linear-gradient(155deg, var(--pb-surface-card) 0%, var(--pb-surface-app) 100%);color:var(--pb-text-tertiary);border-radius:12px;justify-content:center;align-items:center;transition:box-shadow .2s;display:flex;overflow:hidden}.series-domain-cover-frame:hover{box-shadow:var(--pb-shadow-2)}.issue-domain-cover-frame{width:120px}.series-domain-cover-image{object-fit:cover;object-position:top;cursor:pointer;width:100%;height:100%}.series-domain-cover-placeholder{justify-content:center;align-items:center;width:100%;height:100%;display:flex}.series-domain-hero-title{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text-primary);overflow-wrap:anywhere;font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1.05}.series-domain-hero-title span{color:var(--pb-brand)}.series-domain-hero-title-link{color:inherit;text-underline-offset:.18em;text-decoration:none;text-decoration-thickness:2px;transition:color .14s,text-decoration-color .14s;display:inline}.series-domain-hero-title-link:hover,.series-domain-hero-title-link:focus-visible{color:var(--pb-interactive);text-decoration-line:underline;-webkit-text-decoration-color:var(--pb-interactive);-webkit-text-decoration-color:var(--pb-interactive);text-decoration-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-domain-hero-title-link:hover,.series-domain-hero-title-link:focus-visible{-webkit-text-decoration-color:color-mix(in srgb, var(--pb-interactive) 58%, transparent);-webkit-text-decoration-color:color-mix(in srgb, var(--pb-interactive) 58%, transparent);text-decoration-color:color-mix(in srgb, var(--pb-interactive) 58%, transparent)}}.series-domain-hero-title-link:hover,.series-domain-hero-title-link:focus-visible{outline:none}.series-domain-hero-title-sm{font-size:1.4rem}.series-domain-hero-description{max-width:35rem;color:var(--pb-text-secondary);margin-top:.5rem;font-size:.85rem;line-height:1.7}.series-domain-hero-subtitle{color:var(--pb-text-secondary);margin-top:.25rem;font-size:.88rem}.series-domain-status-row{flex-wrap:wrap;align-items:center;gap:.375rem;margin-top:.75rem;display:flex}.series-domain-led{border-radius:999px;width:8px;height:8px;display:inline-block}.series-domain-led-on{background:var(--pb-status-success);box-shadow:0 0 5px var(--pb-status-success), 0 0 10px var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.series-domain-led-on{box-shadow:0 0 5px color-mix(in srgb, var(--pb-status-success) 35%, transparent), 0 0 10px color-mix(in srgb, var(--pb-status-success) 12%, transparent)}}.series-domain-led-off{background:var(--pb-text-tertiary);opacity:.35}.app-progress{align-items:center;gap:.625rem;display:flex}.app-progress-track{background:var(--pb-text-secondary);border-radius:2px;flex:1;height:8px}@supports (color:color-mix(in lab, red, red)){.app-progress-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.app-progress-track{border:1px solid var(--pb-border-subtle);position:relative;overflow:hidden}.app-progress-fill{background:linear-gradient(90deg, var(--pb-status-success), var(--pb-status-success));height:100%}@supports (color:color-mix(in lab, red, red)){.app-progress-fill{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-success) 45%, transparent), var(--pb-status-success))}}.app-progress-fill{position:relative}.app-progress-fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}.app-progress-fill--interactive{background:linear-gradient(90deg, var(--pb-interactive), var(--pb-interactive))}@supports (color:color-mix(in lab, red, red)){.app-progress-fill--interactive{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-interactive) 45%, transparent), var(--pb-interactive))}}.app-progress-fill--error{background:linear-gradient(90deg, var(--pb-error), var(--pb-error))}@supports (color:color-mix(in lab, red, red)){.app-progress-fill--error{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-error) 45%, transparent), var(--pb-error))}}.app-progress-value{text-align:right;min-width:40px;color:var(--pb-status-success);font-family:JetBrains Mono,monospace;font-size:.82rem;font-weight:700}.app-progress-value--interactive{color:var(--pb-interactive)}.app-progress-value--error{color:var(--pb-error)}.app-progress-value-stack{flex-direction:column;align-items:flex-end;line-height:1.05;display:flex}.app-progress-value-secondary{letter-spacing:.01em;color:var(--pb-text-dim);white-space:nowrap;font-family:DM Sans,sans-serif;font-size:.62rem;font-weight:600}.series-domain-info-grid{grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:.625rem;margin-top:1rem;display:grid}.series-domain-info-box{background:var(--pb-surface-app);border:1px solid var(--pb-border-subtle);border-radius:10px;padding:.75rem .875rem}.series-domain-info-box-wide{grid-column:span 2}.series-domain-info-label{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.series-domain-info-value{color:var(--pb-text-secondary);margin-top:.25rem;font-size:.85rem}.series-domain-path{word-break:break-all;font-size:.75rem}.series-domain-actions-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;padding:1.125rem 1.5rem}.series-domain-actions-inner{flex-wrap:wrap;align-items:center;gap:1rem;display:flex}.series-domain-actions-panel{border-left:1px solid var(--pb-border-subtle);flex-direction:column;gap:.75rem;min-width:0;padding-left:1.25rem;display:flex}.series-domain-actions-panel-wide{min-width:18rem}.series-domain-actions-title{text-transform:uppercase;letter-spacing:.08em;color:var(--pb-text-primary);white-space:nowrap;font-family:Syne,sans-serif;font-size:.88rem;font-weight:800}.series-domain-actions-title span{color:var(--pb-brand)}.series-domain-actions-divider{background:var(--pb-border-default);align-self:stretch;width:1px}.series-domain-actions-buttons{flex-wrap:wrap;flex:1;align-items:center;gap:.5rem;display:flex}.series-domain-actions-panel .series-domain-actions-buttons{flex-direction:column;flex:initial;align-items:center;gap:.55rem}.series-domain-actions-panel .series-domain-actions-buttons>a,.series-domain-actions-panel .series-domain-actions-buttons>button,.series-domain-actions-panel .series-domain-actions-buttons .series-domain-inline-toggle{width:100%}.series-domain-actions-panel .series-domain-actions-buttons>a,.series-domain-actions-panel .series-domain-actions-buttons>button{gap:var(--pb-control-gap);justify-content:center}.series-domain-actions-panel .series-domain-actions-buttons .series-domain-inline-toggle{justify-content:space-between}.series-domain-action-form{width:100%}.series-domain-action-form>button{justify-content:center;width:100%}.series-domain-inline-toggle{border:1px solid var(--pb-border-default);background:var(--pb-surface-card);border-radius:10px;align-items:center;gap:.75rem;height:40px;padding:0 .75rem;display:inline-flex}.series-domain-inline-toggle-label{color:var(--pb-text-primary);font-size:.82rem;font-weight:600}.series-domain-section-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;padding:1rem 1.25rem}.series-domain-section-title{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:.82rem;font-weight:700}:where(.series-domain-alt-panel>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.series-domain-alt-title{text-transform:uppercase;letter-spacing:.12em;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.72rem;font-weight:700}.series-domain-alt-strip{flex-direction:column;align-items:flex-start;gap:.5rem;display:flex}.series-domain-alt-list{flex-wrap:wrap;align-items:flex-start;gap:.35rem;min-height:0;display:flex}.series-domain-alt-pill{gap:.35rem}.series-domain-alt-pill-remove{color:var(--pb-text-tertiary);margin-left:.1rem;transition:color .14s}.series-domain-alt-pill-remove:hover{color:var(--pb-status-danger)}.series-domain-alt-form{flex-wrap:wrap;align-items:center;gap:.5rem;width:100%;display:flex}.series-domain-alt-input{border:1px solid var(--pb-border-default);background:var(--pb-surface-input);width:180px;color:var(--pb-text-primary);border-radius:8px;padding:.45rem .75rem;font-size:.78rem}.series-domain-alt-input:focus{border-color:var(--pb-interactive);outline:none}.series-domain-alt-add{padding:.3rem .75rem;font-size:.78rem}.series-domain-issues-wrap{overflow:visible}.series-domain-issues-card{padding-top:.875rem}.series-domain-issues-toolbar{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:.75rem;display:flex}.series-domain-issues-toolbar-left{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.series-domain-issues-title{color:var(--pb-text-primary);font-size:.88rem;font-weight:700}.series-domain-issues-summary{flex-wrap:wrap;align-items:center;gap:.35rem;display:flex}.series-domain-issues-summary .pill{font-size:.62rem}.series-domain-issues-select.dropdown-select{--pb-control-min-height:34px;--pb-control-font-size:.78rem;width:auto}.series-domain-issues-select .dropdown-select-trigger{background:var(--pb-surface-input);padding-block:.4rem;padding-inline:.75rem .65rem}.series-domain-issues-progress{align-items:center;gap:.625rem;margin-top:.9rem;display:flex}.series-domain-issues-progress-track{background:var(--pb-text-secondary);border-radius:2px;flex:1;height:8px}@supports (color:color-mix(in lab, red, red)){.series-domain-issues-progress-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.series-domain-issues-progress-track{border:1px solid var(--pb-border-subtle);overflow:hidden}.series-domain-issues-progress-fill{background:linear-gradient(90deg, var(--pb-status-success), var(--pb-status-success));height:100%}@supports (color:color-mix(in lab, red, red)){.series-domain-issues-progress-fill{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-success) 45%, transparent), var(--pb-status-success))}}.series-domain-issues-progress-label{text-align:right;min-width:36px;color:var(--pb-status-success);font-family:JetBrains Mono,monospace;font-size:.78rem;font-weight:700}.series-domain-issues-table-wrap{margin-top:.875rem;overflow-x:auto}.series-domain-issues-table{border-collapse:collapse;width:100%}.series-domain-issues-table th,.series-domain-issues-table td{border-bottom:1px solid var(--pb-border-subtle);padding:.75rem .875rem}.series-domain-issues-table thead th{text-align:left;text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);background:var(--pb-surface-shell);border-bottom:2px solid var(--pb-border-default);font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.series-domain-issues-table tbody tr:hover td{background:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.series-domain-issues-table tbody tr:hover td{background:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.series-domain-table-head.r{text-align:right}.series-domain-issue-num{white-space:nowrap;color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.84rem}.series-domain-issue-title-cell{min-width:240px;color:var(--pb-text-secondary);font-size:.84rem}.series-domain-issue-title-link{color:var(--pb-text-primary);transition:color .14s;display:block}.series-domain-issue-title-link:hover{color:var(--pb-interactive)}.series-domain-issue-date{white-space:nowrap;color:var(--pb-text-tertiary);font-size:.8rem}.series-domain-issue-reading{min-width:9.5rem;color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.7rem}.series-domain-issue-reading-mobile{color:var(--pb-interactive);margin-top:.3rem;font-family:JetBrains Mono,monospace;font-size:.65rem}.series-domain-reading-progress{gap:.28rem;max-width:8.5rem;display:grid}.series-domain-reading-progress-track{background:var(--pb-border-default);border-radius:999px;height:3px;display:block;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.series-domain-reading-progress-track{background:color-mix(in srgb, var(--pb-border-default) 68%, transparent)}}.series-domain-reading-progress-fill{border-radius:inherit;background:var(--pb-interactive);height:100%;display:block}.series-domain-reading-queue{color:var(--pb-text-tertiary);margin-top:.3rem;font-family:DM Sans,sans-serif;font-size:.62rem;display:block}.series-domain-issue-status{white-space:nowrap}.series-domain-issue-actions-cell{text-align:right}.series-domain-issue-actions{align-items:center;gap:.25rem;display:inline-flex}.series-domain-issue-action-btn{width:28px;height:28px;color:var(--pb-text-tertiary);background:0 0;border:1px solid #0000;border-radius:8px;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.series-domain-issue-action-btn:hover{color:var(--pb-interactive);background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-domain-issue-action-btn:hover{background:color-mix(in srgb, var(--pb-interactive) 6%, transparent)}}.series-domain-reading-menu{z-index:60;border:1px solid var(--pb-border-default);background:var(--pb-surface-raised);min-width:10.5rem;box-shadow:var(--pb-shadow-2);border-radius:10px;display:grid;position:absolute;top:calc(100% + .3rem);right:0;overflow:hidden}.series-domain-reading-menu button{text-align:left;min-height:2.5rem;color:var(--pb-text-secondary);padding:.55rem .75rem;font-size:.72rem;font-weight:600}.series-domain-reading-menu button:hover,.series-domain-reading-menu button:focus-visible{color:var(--pb-text-primary);background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-domain-reading-menu button:hover,.series-domain-reading-menu button:focus-visible{background:color-mix(in srgb, var(--pb-interactive) 9%, transparent)}}.series-domain-table-footer{margin-top:.75rem}.series-domain-copy-block{color:var(--pb-text-secondary);margin-top:.75rem}.issue-domain-stat-strip{grid-template-columns:repeat(4,minmax(0,1fr));gap:.625rem;margin-top:1rem;display:grid}.issue-domain-stat-box{background:var(--pb-surface-shell);border:1px solid var(--pb-border-subtle);border-radius:12px;padding:.8rem .9rem}.issue-domain-stat-label{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.issue-domain-stat-value{color:var(--pb-text-primary);margin-top:.3rem;font-family:JetBrains Mono,monospace;font-size:.85rem}.issue-domain-stat-value a{color:var(--pb-interactive)}.issue-domain-creators-grid{grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:.75rem;margin-top:.75rem;display:grid}.issue-domain-creator-card{background:var(--pb-surface-app);border:1px solid var(--pb-border-subtle);border-radius:10px;padding:.8rem .9rem}.issue-domain-creator-name{color:var(--pb-text-primary);font-size:.85rem;font-weight:600}.issue-domain-creator-role{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);margin-top:.2rem;font-size:.68rem}.issue-domain-file-grid{grid-template-columns:repeat(3,minmax(0,1fr));gap:.75rem;margin-top:.75rem;display:grid}.issue-domain-file-name{font-size:.78rem}.issue-domain-file-path-box{background:var(--pb-surface-app);border:1px solid var(--pb-border-subtle);border-radius:12px;justify-content:space-between;align-items:flex-start;gap:.75rem;margin-top:.75rem;padding:.875rem 1rem;display:flex}.issue-domain-file-path{word-break:break-all;color:var(--pb-text-secondary);margin-top:.3rem;font-family:JetBrains Mono,monospace;font-size:.75rem;line-height:1.7}.issue-domain-copy-btn{border:1px solid var(--pb-border-default);background:var(--pb-surface-card);width:30px;height:30px;color:var(--pb-text-tertiary);border-radius:8px;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.issue-domain-copy-btn:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.series-domain-telemetry-strip{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);color:var(--pb-text-tertiary);box-shadow:var(--pb-shadow-1);border-radius:12px;flex-wrap:wrap;align-items:center;gap:.35rem 1rem;padding:.65rem 1rem;font-family:JetBrains Mono,monospace;font-size:.7rem;display:flex}.series-domain-telemetry-strip strong{color:var(--pb-text-primary);font-weight:700}.series-page-shell{gap:calc(var(--spacing) * 4);flex-direction:column;display:flex}.series-registry-header{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:1.25rem;display:flex}.series-registry-header-left{flex-wrap:wrap;align-items:flex-start;gap:1.5rem;display:flex}.series-registry-title{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1}.series-registry-title span{color:var(--pb-brand)}.series-registry-subtitle{color:var(--pb-text-tertiary);letter-spacing:.01em;margin-top:.25rem;font-size:.78rem}.series-registry-gauges{align-items:flex-end;gap:1rem;display:flex}.series-registry-gauge{text-align:center}.series-registry-gauge-ring{width:56px;height:56px;margin:0 auto;position:relative}.series-registry-gauge-ring svg{transform:rotate(-90deg)}.series-registry-gauge-bg{fill:none;stroke:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.series-registry-gauge-bg{stroke:color-mix(in srgb, var(--pb-text-secondary) 10%, transparent)}}.series-registry-gauge-bg{stroke-width:4.5px}.series-registry-gauge-fill{fill:none;stroke-width:4.5px;stroke-linecap:round;transition:stroke-dashoffset .6s}.series-registry-gauge-fill-success{stroke:var(--pb-status-success)}.series-registry-gauge-fill-info{stroke:var(--pb-status-info)}.series-registry-gauge-fill-warning{stroke:var(--pb-status-warning)}.series-registry-gauge-fill-danger{stroke:var(--pb-status-danger)}.series-registry-gauge-fill-default{stroke:var(--pb-text-secondary)}.series-registry-gauge-value{justify-content:center;align-items:center;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700;display:flex;position:absolute;inset:0}.series-registry-gauge-label{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);margin-top:.25rem;font-size:.6rem;font-weight:600}.series-registry-actions{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.series-toolbar-shell{z-index:20;position:sticky;top:0}.series-toolbar-frame{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);overflow:visible}.series-toolbar-body{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.series-toolbar-browse{flex-wrap:wrap;align-items:flex-end;gap:.5rem;display:flex}.series-toolbar-primary{flex-wrap:wrap;flex:1;align-items:flex-end;gap:.5rem;min-width:0;display:flex}.series-toolbar-actions{flex-wrap:wrap;align-items:flex-end;gap:.5rem;display:flex}.series-toolbar-field{flex-shrink:0;display:block}.series-toolbar-label{text-transform:uppercase;letter-spacing:.08em;color:var(--pb-text-tertiary);margin-bottom:.25rem;font-size:.68rem;font-weight:700;display:block}.series-registry-search{width:220px;max-width:100%}.add-series-search-field{width:275px}.series-registry-search.search-field{border-color:var(--pb-border-default);background:var(--pb-surface-input);min-height:38px;box-shadow:none;border-radius:8px}.series-registry-search .search-field-input{font-size:.82rem}.series-registry-dropdown.dropdown-select{--pb-control-min-height:38px;--pb-control-font-size:.78rem;--pb-control-radius:8px;width:fit-content;max-width:100%}.series-registry-dropdown .dropdown-select-trigger{border-color:var(--pb-border-default);background:var(--pb-surface-input);box-shadow:none;padding-inline:.75rem}.series-registry-dropdown .dropdown-select-trigger-label{color:var(--pb-text-primary);font-weight:500}.series-registry-dropdown .dropdown-select-panel{border-radius:10px}.series-toolbar-view-block{flex-direction:column;display:inline-flex}.series-view-toggle{border:1px solid var(--pb-border-default);background:0 0;border-radius:8px;display:inline-flex;overflow:visible}.series-view-toggle-item:first-child{border-top-left-radius:7px;border-bottom-left-radius:7px}.series-view-toggle-item:last-child{border-top-right-radius:7px;border-bottom-right-radius:7px}.series-view-toggle-item{width:38px;height:34px;color:var(--pb-text-tertiary);background:0 0;border:none;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.series-view-toggle-item+.series-view-toggle-item{border-left:1px solid var(--pb-border-default)}.series-view-toggle-item:hover{background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.series-view-toggle-item:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.series-view-toggle-item:hover{color:var(--pb-text-primary)}html[data-series-view=list] .series-view-toggle-item[data-series-view-option=list],html[data-series-view=grid] .series-view-toggle-item[data-series-view-option=grid],html[data-story-arc-view=list] .series-view-toggle-item[data-story-arc-view-option=list],html[data-story-arc-view=grid] .series-view-toggle-item[data-story-arc-view-option=grid],html[data-whats-new-view=list] .whats-new-view-toggle-item[data-whats-new-view-option=list],html[data-whats-new-view=compact] .whats-new-view-toggle-item[data-whats-new-view-option=compact]{background:var(--pb-interactive);color:var(--pb-text-inverse)}.whats-new-pane-toggle{background:var(--pb-surface-input)}.whats-new-pane-toggle-item{min-height:34px;color:var(--pb-text-tertiary);letter-spacing:.06em;text-transform:uppercase;background:0 0;border:none;justify-content:center;align-items:center;padding:0 .875rem;font-size:.78rem;font-weight:700;transition:all .14s;display:inline-flex}.whats-new-pane-toggle-item:first-child{border-top-left-radius:7px;border-bottom-left-radius:7px}.whats-new-pane-toggle-item:last-child{border-top-right-radius:7px;border-bottom-right-radius:7px}.whats-new-pane-toggle-item+.whats-new-pane-toggle-item{border-left:1px solid var(--pb-border-default)}.whats-new-pane-toggle-item:hover{background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.whats-new-pane-toggle-item:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.whats-new-pane-toggle-item:hover{color:var(--pb-text-primary)}.whats-new-pane-toggle-item.is-active,.whats-new-pane-toggle-item[aria-current=page]{background:var(--pb-interactive);color:var(--pb-text-inverse)}.whats-new-title-block{min-height:4.55rem}.whats-new-cache-badge{min-height:34px;color:var(--pb-text-inverse);align-items:center;display:inline-flex}.whats-new-cache-badge-success{background:#1f6f4a}.whats-new-cache-badge-error{background:#9f2f24}.whats-new-week-nav{border:1px solid var(--pb-border-default);background:var(--pb-surface-input);border-radius:10px;justify-content:center;justify-self:center;align-items:center;gap:.375rem;width:fit-content;max-width:min(100%,34rem);margin-inline:auto;padding:.375rem;display:inline-flex}.whats-new-week-nav-button{border:1px solid var(--pb-border-default);background:var(--pb-surface-card);min-width:32px;min-height:32px;color:var(--pb-text-secondary);border-radius:8px;justify-content:center;align-items:center;font-size:.9rem;font-weight:800;transition:all .14s;display:inline-flex}.whats-new-week-nav-button:hover{border-color:var(--pb-border-hover);background:var(--pb-interactive-selected);color:var(--pb-text-primary)}.whats-new-week-nav-button.is-disabled{opacity:.45;cursor:not-allowed}.whats-new-week-dropdown.dropdown-select{--pb-control-min-height:32px;--pb-control-font-size:.78rem;--pb-control-radius:8px;flex:0 auto;width:fit-content;max-width:min(52vw,14rem)}.whats-new-week-dropdown .dropdown-select-trigger{border-color:var(--pb-border-default);background:var(--pb-surface-card);min-width:9.5rem;max-width:14rem;box-shadow:none;padding-inline:.625rem}.whats-new-week-dropdown .dropdown-select-panel{border-radius:10px;min-width:11.5rem;max-width:min(90vw,16rem)}.whats-new-week-dropdown .dropdown-select-trigger-label,.whats-new-week-dropdown .dropdown-select-option-label{font-weight:700}.whats-new-week-position{color:var(--pb-text-tertiary);letter-spacing:.08em;text-transform:uppercase;flex:none;padding-inline:.25rem .375rem;font-size:.68rem;font-weight:700}@media (max-width:640px){.whats-new-week-nav{max-width:100%}.whats-new-week-dropdown.dropdown-select{max-width:min(58vw,13rem)}.whats-new-week-position{text-align:center;width:100%}}.whats-new-results-stack,.whats-new-panel{gap:1rem;display:grid}.whats-new-stale-banner{border:1px solid var(--pb-status-warning);justify-content:space-between;align-items:center;gap:1rem;display:flex}@supports (color:color-mix(in lab, red, red)){.whats-new-stale-banner{border:1px solid color-mix(in srgb, var(--pb-status-warning) 40%, transparent)}}.whats-new-stale-banner{background:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.whats-new-stale-banner{background:color-mix(in srgb, var(--pb-status-warning) 10%, transparent)}}.whats-new-stale-banner{color:var(--pb-text-primary);border-radius:12px;margin-bottom:1rem;padding:.75rem 1rem;font-size:.82rem}.whats-new-stale-copy{min-width:0}.whats-new-stale-actions{flex-direction:column;flex:none;align-items:flex-end;gap:.375rem;display:flex}.whats-new-stale-message{max-width:24rem;color:var(--pb-text-secondary);text-align:right;font-size:.72rem;line-height:1.35}@media (max-width:640px){.whats-new-stale-banner{flex-direction:column;align-items:stretch}.whats-new-stale-actions{align-items:flex-start}.whats-new-stale-message{text-align:left}}.whats-new-release-summary{margin-bottom:0}.whats-new-release-table-slot{min-width:0}html[data-whats-new-view=compact] .whats-new-release-cover,html[data-whats-new-view=compact] .whats-new-pulls-col{display:none}html[data-whats-new-view=compact] .whats-new-release-title-cell{gap:0}.select-btn{border:1px solid var(--pb-border-default);min-height:38px;color:var(--pb-text-secondary);background:0 0;border-radius:8px;justify-content:center;align-items:center;gap:.375rem;padding:.375rem .875rem;font-family:DM Sans,sans-serif;font-size:.78rem;font-weight:600;transition:all .14s;display:inline-flex}.select-btn:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.select-btn:disabled{cursor:not-allowed;opacity:.45}.select-btn-success{color:var(--pb-status-success);border-color:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.select-btn-success{border-color:color-mix(in srgb, var(--pb-status-success) 30%, transparent)}}.select-btn-success{background:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.select-btn-success{background:color-mix(in srgb, var(--pb-status-success) 8%, transparent)}}.select-btn-warning{color:var(--pb-status-warning);border-color:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.select-btn-warning{border-color:color-mix(in srgb, var(--pb-status-warning) 30%, transparent)}}.select-btn-warning{background:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.select-btn-warning{background:color-mix(in srgb, var(--pb-status-warning) 8%, transparent)}}.select-btn-danger{color:var(--pb-status-danger);border-color:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){.select-btn-danger{border-color:color-mix(in srgb, var(--pb-status-danger) 30%, transparent)}}.select-btn-danger{background:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){.select-btn-danger{background:color-mix(in srgb, var(--pb-status-danger) 8%, transparent)}}.series-selection-shell{flex-direction:column;gap:.625rem;display:flex}.series-selection-inline{align-items:center;display:inline-flex}.series-selection-count{color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.78rem;font-weight:600}.series-selection-controls-row{flex-wrap:wrap;justify-content:space-between;gap:.75rem;display:flex}.series-selection-bulk,.series-selection-actions{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.series-results-shell{gap:calc(var(--spacing) * 4);min-height:16rem;padding-bottom:var(--pb-page-footer-clearance);flex-direction:column;flex:none;display:flex}.series-results-body-shell{min-height:14rem}:where(:is(.series-mission-control-shell,.series-collector-wall-shell)>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.series-mission-control-table-wrap{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;overflow:auto hidden}.series-mission-control-table{border-collapse:collapse;width:100%}.series-mission-control-table th{text-align:left;text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);background:var(--pb-surface-shell);border-bottom:2px solid var(--pb-border-default);padding:.5625rem .875rem;font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.series-mission-control-table td{border-bottom:1px solid var(--pb-border-subtle);padding:.6875rem .875rem;font-size:.82rem;transition:background .1s}.series-mission-control-table tbody tr:last-child td{border-bottom:none}.series-mission-control-table tbody tr:not(.table-detail-row):hover td{background:var(--pb-surface-selected)}.series-mission-control-table th.c,.series-mission-control-table td.c{text-align:center}.series-mission-control-table th.r,.series-mission-control-table td.r{text-align:right}.series-mission-control-row-selected td{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-mission-control-row-selected td{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.series-led{border-radius:50%;width:8px;height:8px;display:inline-block}.series-led-green{background:var(--pb-status-success);box-shadow:0 0 5px var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.series-led-green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-status-success) 35%, transparent)}}.series-led-amber{background:var(--pb-status-warning);box-shadow:0 0 5px var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.series-led-amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-status-warning) 35%, transparent)}}.series-led-off{background:var(--pb-text-tertiary);opacity:.35}.series-mission-control-name{color:var(--pb-text-primary);font-weight:700;transition:color .14s}.series-mission-control-name:hover{color:var(--pb-interactive)}.series-mission-control-publisher{color:var(--pb-text-tertiary);margin-top:1px;font-size:.72rem}.series-mission-control-year{color:var(--pb-text-tertiary);font-size:.78rem}.series-mission-control-bar-cell{width:220px}.series-mission-control-bar{align-items:center;gap:.5rem;display:flex}.series-mission-control-bar-track{background:var(--pb-text-secondary);border-radius:2px;flex:1;height:8px}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.series-mission-control-bar-track{border:1px solid var(--pb-border-subtle);position:relative;overflow:hidden}.series-mission-control-bar-fill{height:100%;position:relative}.series-mission-control-bar-fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}.series-mission-control-bar-fill-green{background:linear-gradient(90deg, var(--pb-status-success), var(--pb-status-success))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-green{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-success) 45%, transparent), var(--pb-status-success))}}.series-mission-control-bar-fill-amber{background:linear-gradient(90deg, var(--pb-status-warning), var(--pb-status-warning))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-amber{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-warning) 45%, transparent), var(--pb-status-warning))}}.series-mission-control-bar-fill-red{background:linear-gradient(90deg, var(--pb-status-danger), var(--pb-status-danger))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-red{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-danger) 45%, transparent), var(--pb-status-danger))}}.series-mission-control-bar-fill-blue{background:linear-gradient(90deg, var(--pb-status-info), var(--pb-status-info))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-blue{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-info) 45%, transparent), var(--pb-status-info))}}.series-mission-control-bar-pct{text-align:right;font-variant-numeric:tabular-nums;min-width:36px;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700}.series-mission-control-bar-pct-green{color:var(--pb-status-success)}.series-mission-control-bar-pct-amber{color:var(--pb-status-warning)}.series-mission-control-bar-pct-red{color:var(--pb-status-danger)}.series-mission-control-bar-pct-blue{color:var(--pb-status-info)}.series-mission-control-actions{justify-content:flex-end;gap:.25rem;display:flex}.series-mission-control-action-btn{border:1px solid var(--pb-border-subtle);width:30px;height:30px;color:var(--pb-text-tertiary);background:0 0;border-radius:8px;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.series-mission-control-action-btn:hover{border-color:var(--pb-interactive);color:var(--pb-interactive);background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-mission-control-action-btn:hover{background:color-mix(in srgb, var(--pb-interactive) 4%, transparent)}}.series-mission-control-action-btn.disabled{opacity:.25;pointer-events:none}.series-mission-control-action-btn svg{width:14px;height:14px}.series-mission-control-footer{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);color:var(--pb-text-tertiary);border-radius:10px;flex-wrap:wrap;align-items:center;gap:.375rem 1.5rem;padding:.625rem 1rem;font-family:JetBrains Mono,monospace;font-size:.7rem;display:flex}.series-mission-control-footer strong{color:var(--pb-text-primary);font-weight:600}.series-collector-wall-grid{grid-template-columns:repeat(auto-fill,minmax(172px,1fr));gap:24px 18px;display:grid}.series-wall-card{min-width:0;transition:transform .22s;position:relative}.series-wall-card:hover{transform:translateY(-5px)}.series-wall-card-selected .series-wall-cover-wrap{outline:2px solid var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-wall-card-selected .series-wall-cover-wrap{outline:2px solid color-mix(in srgb, var(--pb-interactive) 28%, transparent)}}.series-wall-card-selected .series-wall-cover-wrap{outline-offset:2px}.series-wall-cover-wrap{aspect-ratio:2/3;border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;transition:box-shadow .22s;position:relative;overflow:hidden}.series-wall-card:hover .series-wall-cover-wrap{box-shadow:var(--pb-shadow-2), 0 0 20px var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-wall-card:hover .series-wall-cover-wrap{box-shadow:var(--pb-shadow-2), 0 0 20px color-mix(in srgb, var(--pb-interactive) 6%, transparent)}}.series-wall-cover-placeholder{width:100%;height:100%;color:var(--pb-text-tertiary);background:linear-gradient(155deg, var(--pb-surface-card) 0%, var(--pb-surface-app) 100%);justify-content:center;align-items:center;display:flex}.series-monitor-badge{width:1.5rem;height:1.5rem;color:var(--pb-text-inverse);background:var(--pb-brand-signal);border:2px solid var(--pb-surface-card);border-radius:999px;flex:none;justify-content:center;align-items:center;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.series-monitor-badge{border:2px solid color-mix(in srgb, var(--pb-surface-card) 92%, white)}}.series-monitor-badge{box-shadow:0 0 0 2px #0000006b,0 10px 22px #00000057}.series-monitor-badge svg{flex:none;width:.9rem;height:.9rem}.series-monitor-badge.is-paused{color:var(--pb-text-secondary);background:var(--pb-surface-raised);border-color:var(--pb-border-default);box-shadow:0 0 0 1px var(--pb-border-subtle)}.series-wall-monitor-dot{z-index:3;position:absolute;top:9px;left:9px}.story-arc-wall-review{z-index:4;border:1px solid var(--pb-border-default);background:var(--pb-surface-card);border-radius:999px;justify-content:center;align-items:center;gap:.2rem;min-width:1.75rem;height:1.75rem;padding-inline:.38rem;display:inline-flex;position:absolute;top:9px;right:9px}@supports (color:color-mix(in lab, red, red)){.story-arc-wall-review{background:color-mix(in srgb, var(--pb-surface-card) 92%, transparent)}}.story-arc-wall-review{box-shadow:var(--pb-shadow-1);font-family:JetBrains Mono,monospace;font-size:.65rem;font-weight:700}.story-arc-wall-review svg{width:.8rem;height:.8rem}.story-arc-wall-review-warning{color:var(--pb-warning);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.story-arc-wall-review-warning{border-color:color-mix(in srgb, var(--pb-warning) 48%, transparent)}}.story-arc-wall-review-error{color:var(--pb-error);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.story-arc-wall-review-error{border-color:color-mix(in srgb, var(--pb-error) 48%, transparent)}}.story-arc-review-indicator{border:1px solid var(--pb-border-default);border-radius:999px;justify-content:center;align-items:center;gap:.28rem;min-width:2.5rem;min-height:1.75rem;padding-inline:.45rem;font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:700;display:inline-flex}.story-arc-review-indicator svg{width:.82rem;height:.82rem}.story-arc-review-indicator-success{color:var(--pb-success);background:var(--pb-success-dim);border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.story-arc-review-indicator-success{border-color:color-mix(in srgb, var(--pb-success) 32%, transparent)}}.story-arc-review-indicator-warning{color:var(--pb-warning);background:var(--pb-warning-dim);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.story-arc-review-indicator-warning{border-color:color-mix(in srgb, var(--pb-warning) 32%, transparent)}}.story-arc-review-indicator-error{color:var(--pb-error);background:var(--pb-error-dim);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.story-arc-review-indicator-error{border-color:color-mix(in srgb, var(--pb-error) 32%, transparent)}}.series-wall-catalog-state-badge{z-index:4;border:1px solid var(--pb-warning);background:var(--pb-warning);border-radius:999px;justify-content:center;align-items:center;max-width:calc(100% - 18px);display:inline-flex;position:absolute;top:46px;right:9px}@supports (color:color-mix(in lab, red, red)){.series-wall-catalog-state-badge{background:color-mix(in srgb, var(--pb-warning) 92%, black)}}.series-wall-catalog-state-badge{color:var(--pb-text-inverse);letter-spacing:.04em;text-align:center;text-transform:uppercase;padding:.2rem .5rem;font-size:.58rem;font-weight:800;line-height:1.15;box-shadow:0 8px 18px #00000057}.series-wall-catalog-state-badge.is-failed{border-color:var(--pb-error);background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.series-wall-catalog-state-badge.is-failed{background:color-mix(in srgb, var(--pb-error) 92%, black)}}.pull-list-monitor-toggle{width:28px;height:28px;box-shadow:none;cursor:pointer;border-width:1px;border-radius:6px;transition:background-color .14s,border-color .14s,color .14s,box-shadow .14s}.pull-list-monitor-toggle svg{width:12px;height:12px}.pull-list-monitor-toggle:hover{border-color:var(--pb-brand-signal)}@supports (color:color-mix(in lab, red, red)){.pull-list-monitor-toggle:hover{border-color:color-mix(in srgb, var(--pb-brand-signal) 56%, transparent)}}.pull-list-monitor-toggle:hover{background:var(--pb-brand-signal);color:var(--pb-text-inverse);box-shadow:0 0 0 2px var(--pb-brand-signal)}@supports (color:color-mix(in lab, red, red)){.pull-list-monitor-toggle:hover{box-shadow:0 0 0 2px color-mix(in srgb, var(--pb-brand-signal) 14%, transparent)}}.pull-list-monitor-toggle:focus-visible{outline:2px solid var(--pb-focus-outline);outline-offset:3px}.series-wall-selection-control{z-index:4;border:1px solid var(--pb-border-default);background:var(--pb-surface-card);border-radius:10px;position:absolute;top:10px;right:10px}@supports (color:color-mix(in lab, red, red)){.series-wall-selection-control{background:color-mix(in srgb, var(--pb-surface-card) 92%, transparent)}}.series-wall-selection-control{box-shadow:var(--pb-shadow-1);padding:.25rem}.series-wall-ring{z-index:2;width:44px;height:44px;position:absolute;bottom:-6px;right:-6px}.series-wall-ring svg{transform:rotate(-90deg)}.series-wall-ring-bg{fill:none;stroke:var(--pb-surface-card);stroke-width:3.5px}.series-wall-ring-fill{fill:none;stroke-width:3.5px;stroke-linecap:round;transition:stroke-dashoffset .6s}.series-wall-ring-fill-green{stroke:var(--pb-status-success)}.series-wall-ring-fill-amber{stroke:var(--pb-status-warning)}.series-wall-ring-fill-red{stroke:var(--pb-status-danger)}.series-wall-ring-center{background:var(--pb-surface-card);color:var(--pb-text-primary);border-radius:999px;justify-content:center;align-items:center;margin:5px;font-family:JetBrains Mono,monospace;font-size:.58rem;font-weight:700;display:flex;position:absolute;inset:0}.series-wall-overlay{--series-wall-overlay-edge:#fff;z-index:3;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);box-shadow:inset 0 0 0 4.5px var(--series-wall-overlay-edge);opacity:0;pointer-events:none;background:#1e1a17e0;border-radius:18px;flex-direction:column;justify-content:flex-end;padding:.875rem;transition:opacity .2s;display:flex;position:absolute;inset:-4px}[data-theme=light] .series-wall-overlay{--series-wall-overlay-edge:#1e1a17}@media (prefers-color-scheme:light){:root:not([data-theme]) .series-wall-overlay{--series-wall-overlay-edge:#1e1a17}}.series-wall-card:hover .series-wall-overlay{opacity:1}.series-wall-overlay-title{color:var(--pb-text-primary);font-size:.82rem;font-weight:700;line-height:1.3}.series-wall-overlay-meta{color:var(--pb-text-tertiary);gap:.25rem;margin-top:.45rem;font-size:.68rem;display:grid}.series-wall-overlay-meta-line,.series-wall-overlay-stat{grid-template-columns:minmax(4.25rem,max-content) minmax(0,1fr);align-items:baseline;column-gap:.5rem;line-height:1.25;display:grid}.series-wall-overlay-meta-label{color:var(--pb-text-tertiary);letter-spacing:.08em;text-transform:uppercase;font-family:JetBrains Mono,monospace;font-size:.56rem;font-weight:700}.series-wall-overlay-meta-value{overflow-wrap:anywhere;min-width:0;color:var(--pb-text-secondary)}.series-wall-overlay-stats{color:var(--pb-text-secondary);gap:.25rem;margin-top:.35rem;font-size:.68rem;display:grid}.series-wall-overlay-stats strong{color:var(--pb-text-primary);font-weight:600}.series-wall-overlay-actions{pointer-events:auto;gap:6px;margin-top:10px;display:flex}.series-wall-overlay-btn{color:var(--pb-text-secondary);text-align:center;background:#ffffff0f;border:1px solid #cbd5e140;border-radius:8px;flex:1;justify-content:center;padding:7px;font-family:DM Sans,sans-serif;font-size:.72rem;font-weight:600;transition:all .14s;display:inline-flex}.series-wall-overlay-btn:hover{background:var(--pb-interactive);color:var(--pb-text-primary);border-color:var(--pb-interactive)}.series-wall-card-title{text-overflow:ellipsis;white-space:nowrap;color:var(--pb-text-primary);margin-top:10px;font-size:.82rem;font-weight:600;line-height:1.3;display:block;overflow:hidden}.series-empty-state{justify-content:center;align-items:center;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 12);text-align:center;box-shadow:var(--pb-shadow-1);border-radius:1.25rem;flex-direction:column;display:flex}.series-empty-state-title{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.series-empty-state-copy{max-width:var(--container-xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}@media (max-width:1180px){.series-domain-hero-inner{grid-template-columns:130px minmax(0,1fr)}.issue-domain-hero-inner{grid-template-columns:120px minmax(0,1fr)}.series-domain-actions-panel{border-top:1px solid var(--pb-border-subtle);border-left:0;grid-column:1/-1;padding-top:1rem;padding-left:0}.series-domain-actions-panel .series-domain-actions-buttons{grid-template-columns:repeat(auto-fit,minmax(12rem,1fr));align-items:stretch;display:grid}}@media (max-width:900px){.issue-domain-stat-strip{grid-template-columns:repeat(2,minmax(0,1fr))}.issue-domain-file-grid{grid-template-columns:1fr}}@media (max-width:640px){.detail-hero-shell,.series-domain-actions-card,.series-domain-section-card{padding:1rem}.series-domain-cover-column,.issue-domain-cover-column,.issue-domain-cover-frame{width:104px}.series-domain-hero-inner,.issue-domain-hero-inner{grid-template-columns:104px minmax(0,1fr)}.series-domain-hero-title{font-size:1.2rem}.series-domain-actions-inner{align-items:flex-start}.series-domain-actions-divider{display:none}.series-domain-actions-panel .series-domain-actions-buttons,.issue-domain-stat-strip{grid-template-columns:1fr}.series-domain-info-box-wide{grid-column:auto}.series-domain-issues-toolbar{flex-direction:column;align-items:stretch}}[data-series-page][data-series-toolbar-mode=browse] [data-testid=series-select-toolbar],[data-series-page][data-series-toolbar-mode=select] [data-testid=series-browse-toolbar]{display:none}[data-series-page][data-series-toolbar-mode=select] [data-testid=series-select-toolbar]{display:flex}[data-select-toolbar-page][data-toolbar-mode=browse] [data-select-toolbar],[data-select-toolbar-page][data-toolbar-mode=select] [data-browse-toolbar]{display:none}[data-select-toolbar-page][data-toolbar-mode=select] [data-select-toolbar]{display:flex}[data-series-page][data-series-toolbar-mode=browse] [data-series-selection-cell]{display:none}[data-series-page][data-series-toolbar-mode=select] [data-series-selection-cell]{display:table-cell}[data-series-page][data-series-toolbar-mode=browse] [data-series-selection-control]{display:none}[data-series-page][data-series-toolbar-mode=select] [data-series-selection-control]{display:block}[data-series-page][data-series-toolbar-mode=select] [data-series-selection-control=grid]{display:inline-flex}#page-footer-dock:empty{display:none}body:has(#page-footer-dock:not(:empty)) #content{padding-bottom:var(--pb-page-footer-clearance)}body:has(#page-footer-dock:not(:empty)) #content:has(.admin-workspace-page,.dashboard-mission-page,.downloads-view,.series-domain-page,.series-results-shell,.utilities-page){padding-bottom:0}#content:has(.admin-workspace-page){overflow-anchor:none}.page-dock-inner{--page-dock-height-status:2rem;--page-dock-height-pagination:2.5rem;max-width:85rem;height:var(--page-dock-height-status);flex-wrap:nowrap;justify-content:space-between;align-items:center;gap:.75rem;margin:0 auto;padding:0 1rem;display:flex;overflow:hidden}.page-dock-inner:has(.page-dock-pagination){height:var(--page-dock-height-pagination)}.page-footer-clearance{height:var(--pb-page-footer-clearance);flex:0 0 var(--pb-page-footer-clearance);pointer-events:none}.page-dock-inner-status-only{justify-content:flex-end}.page-dock-pagination{flex:none}.page-dock-pagination nav{gap:.125rem}.page-dock-pagination nav>a,.page-dock-pagination nav>button,.page-dock-pagination nav>span{text-align:center;border-radius:.375rem;min-width:1.75rem;padding:.25rem .625rem;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:600;line-height:1}.page-dock-pagination nav>a,.page-dock-pagination nav>button{appearance:none;color:var(--pb-text-secondary);cursor:pointer;background:0 0;border:0;margin:0}.page-dock-pagination nav>span.bg-pb-interactive{border-radius:.375rem}.page-dock-status{min-width:0;color:var(--pb-text-tertiary);scrollbar-width:none;flex-wrap:nowrap;align-items:center;gap:1rem;font-family:JetBrains Mono,monospace;font-size:.65rem;display:flex;overflow-x:auto}.page-dock-status::-webkit-scrollbar{display:none}.page-dock-status-item{white-space:nowrap;align-items:center;gap:.25rem;display:inline-flex}.page-dock-status-value{color:var(--pb-text-primary);font-weight:600}.page-dock-status-label{text-transform:uppercase;letter-spacing:.04em}.page-dock-led{border-radius:999px;width:6px;height:6px;margin-right:2px;display:inline-block}.page-dock-led-green{background:var(--pb-status-success);box-shadow:0 0 4px var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.page-dock-led-green{box-shadow:0 0 4px color-mix(in srgb, var(--pb-status-success) 35%, transparent)}}.page-dock-led-amber{background:var(--pb-status-warning);box-shadow:0 0 4px var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.page-dock-led-amber{box-shadow:0 0 4px color-mix(in srgb, var(--pb-status-warning) 35%, transparent)}}.page-dock-led-off{background:var(--pb-text-tertiary);opacity:.35}@media (max-width:767px){.page-dock-inner{padding-inline:0}.page-dock-status{gap:.75rem}}.workflow-tabs-shell{align-items:center;gap:calc(var(--spacing) * 2);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-radius:1.25rem;flex-wrap:wrap;display:flex}@media (min-width:40rem){.workflow-tabs-shell{padding-inline:calc(var(--spacing) * 5)}}.workflow-tabs-shell{box-shadow:var(--pb-shadow-1)}.workflow-tab-chip-active{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary);border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-tab-chip-active{border-color:color-mix(in srgb, var(--pb-interactive) 34%, transparent)}}.workflow-tab-chip-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-tab-chip-active{background-color:color-mix(in srgb, var(--pb-interactive) 12%, var(--pb-bg-card))}}.workflow-tab-chip-active{box-shadow:var(--pb-shadow-1)}.workflow-tab-chip-inactive{color:var(--pb-text-dim)}.workflow-focus-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;position:relative;overflow:hidden}.workflow-focus-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.workflow-focus-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 34%, transparent), transparent)}}.workflow-focus-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.workflow-focus-grid{grid-template-columns:minmax(0,1.35fr) minmax(320px,.95fr);align-items:flex-start}}.workflow-focus-chip{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.workflow-focus-actions{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.workflow-focus-actions{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.workflow-focus-actions{grid-template-columns:repeat(1,minmax(0,1fr))}}:where(.workflow-shell-region>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.workflow-shell-region{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 4);border-radius:1.5rem;overflow:visible}@media (min-width:40rem){.workflow-shell-region{padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 5)}}.workflow-shell-region{box-shadow:var(--pb-shadow-1)}.utility-workspace-shell{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;overflow:hidden}.utility-workspace-shell-visible{overflow:visible}:where(.utility-workspace-body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.utility-workspace-body{padding:calc(var(--spacing) * 6)}@media (min-width:40rem){.utility-workspace-body{padding:calc(var(--spacing) * 7)}}:where(.utility-tool-body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.utility-tool-body{padding:calc(var(--spacing) * 5)}@media (min-width:40rem){.utility-tool-body{padding:calc(var(--spacing) * 5)}}.utility-workspace-shell .info-panel{border-color:var(--pb-border-subtle);background-color:var(--pb-bg-base);box-shadow:none}.utility-workspace-shell .info-panel-muted{background-color:var(--pb-bg-base)}.utility-empty-state{border-radius:var(--radius-xl);border-style:var(--tw-border-style);--tw-border-style:dashed;border-style:dashed;border-width:1px;border-color:var(--pb-border-subtle);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 8);text-align:center;background-color:var(--pb-bg-base)}.utility-path-pill{align-items:center;gap:calc(var(--spacing) * 2);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);display:flex}.utility-token{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.utility-step-grid{gap:calc(var(--spacing) * 3);display:grid}.utility-step-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);width:100%;padding:calc(var(--spacing) * 4);display:block}.utility-step-card-inline{align-items:center;gap:.75rem;display:flex}.utility-step-card-locked{opacity:.78}.utility-step-num{color:var(--pb-text-tertiary);min-width:1.25rem;font-family:JetBrains Mono,monospace;font-size:.62rem;font-weight:700}.utility-step-name{color:var(--pb-text-primary);flex:auto;min-width:0;font-size:.82rem;font-weight:600}.utility-step-tag{color:var(--pb-text-tertiary);white-space:nowrap;font-family:JetBrains Mono,monospace;font-size:.62rem}.utility-step-card-inline .utility-step-num,.utility-step-card-inline .utility-step-tag{color:var(--pb-text-secondary)}.utility-step-check{flex-shrink:0;margin-top:0}.utility-scan-mode-grid{gap:calc(var(--spacing) * 2);display:grid}@media (min-width:40rem){.utility-scan-mode-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-scan-mode{justify-content:center;align-items:flex-start;gap:var(--spacing);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);min-height:72px;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);text-align:left;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-direction:column;display:flex}.utility-scan-mode:hover{border-color:var(--pb-border-hover)}.utility-scan-mode-active{border-color:var(--pb-interactive);background-color:var(--pb-surface-selected)}.utility-scan-mode-label{--tw-font-weight:var(--font-weight-semibold);font-size:.82rem;font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.utility-scan-mode-copy{--tw-leading:calc(var(--spacing) * 5);font-size:.72rem;line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim)}.utility-scope-chip-row{flex-wrap:wrap;gap:.375rem;display:flex}.utility-scope-chip{border:1px solid var(--pb-border-default);color:var(--pb-text-secondary);background:0 0;border-radius:.5rem;justify-content:center;align-items:center;padding:.4375rem 1rem;font-size:.78rem;font-weight:600;transition:border-color .14s,background-color .14s,color .14s;display:inline-flex}.utility-scope-chip:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.utility-scope-chip-active{background:var(--pb-interactive);color:var(--pb-text-inverse);border-color:var(--pb-interactive)}.utility-scope-chip-active:hover{color:var(--pb-text-inverse)}.utility-export-chip-row{flex-wrap:wrap;gap:.375rem;display:flex}.utility-export-chip{border:1px solid var(--pb-border-default);color:var(--pb-text-secondary);background:0 0;border-radius:999px;justify-content:center;align-items:center;padding:.4375rem .875rem;font-family:DM Sans,sans-serif;font-size:.76rem;font-weight:500;line-height:1.2;transition:border-color .14s,background-color .14s,color .14s;display:inline-flex}.utility-export-chip:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.utility-export-chip-active{border-color:var(--pb-interactive);background:var(--pb-interactive);color:var(--pb-text-inverse)}.utility-export-chip-active:hover{color:var(--pb-text-inverse)}.utility-tool-section-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);margin-bottom:.5rem;font-family:Syne,sans-serif;font-size:.62rem;font-weight:700}.utility-tool-section-row{flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;display:flex}.utility-tool-field-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.58rem;font-weight:700;display:block}.utility-export-group-grid{gap:.75rem;display:grid}@media (min-width:1024px){.utility-export-group-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-export-group-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);border-radius:12px;flex-direction:column;gap:.5rem;padding:.875rem;display:flex}.utility-export-group-title{color:var(--pb-text-primary);font-size:.72rem;font-weight:600}.utility-export-group-fields{flex-direction:column;gap:.375rem;display:flex}.utility-export-group-fields .utility-step-card{padding:.5rem .75rem}.utility-export-field-label{color:var(--pb-text-primary);font-size:.8rem;font-weight:500;line-height:1.25}.utility-export-field-meta{justify-content:space-between;align-items:baseline;gap:.875rem;width:100%;min-width:0;display:flex}.utility-export-field-sub{color:var(--pb-text-tertiary);text-align:right;white-space:nowrap;text-overflow:ellipsis;flex:0 auto;min-width:0;font-family:JetBrains Mono,monospace;font-size:.66rem;line-height:1.2;overflow:hidden}.utility-export-options-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);border-radius:10px;padding:1rem}.utility-export-inline-label{color:var(--pb-text-primary);font-size:.72rem;font-weight:600}.utility-export-multi-grid{gap:.5rem;display:grid}@media (min-width:640px){.utility-export-multi-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-export-summary-grid{gap:.625rem;display:grid}@media (min-width:640px){.utility-export-summary-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-export-summary-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);border-radius:10px;padding:.875rem 1rem}.utility-export-summary-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.58rem;font-weight:700}.utility-export-summary-value{color:var(--pb-text-primary);margin-top:.35rem;font-family:JetBrains Mono,monospace;font-size:.92rem;font-weight:600}.utility-tool-select.dropdown-select{--pb-control-font-size:.82rem;--pb-control-line-height:1.25rem;--pb-control-radius:8px;width:100%}.utility-tool-select .dropdown-select-trigger{border-color:var(--pb-border-default);background:var(--pb-surface-input);min-height:0;box-shadow:none;padding:.5rem .75rem}.utility-tool-select .dropdown-select-trigger-label{color:var(--pb-text-primary);font-weight:400}.utility-tool-select .dropdown-select-chevron{color:var(--pb-text-secondary)}.utility-tool-output-field{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);color:var(--pb-text-secondary);border-radius:8px;padding:.5rem .75rem;font-family:DM Sans,sans-serif;font-size:.82rem;line-height:1.25rem}.utility-tool-browse-button{gap:.375rem;min-height:0;padding:.375rem .875rem;font-size:.78rem;font-weight:600;line-height:1.5}.utility-tool-browse-button svg{width:.875rem;height:.875rem}.utility-tool-table-wrap{border:1px solid var(--pb-border-subtle);border-radius:10px;overflow:hidden}.utility-tool-table{border-collapse:collapse;width:100%}.utility-tool-table th{text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);background:var(--pb-surface-shell);border-bottom:1px solid var(--pb-border-default);padding:.4375rem .75rem;font-family:Syne,sans-serif;font-size:.52rem;font-weight:700}.utility-tool-table th.r,.utility-tool-table td.r{text-align:right}.utility-tool-table td{color:var(--pb-text-secondary);border-bottom:1px solid var(--pb-border-subtle);padding:.5rem .75rem;font-size:.78rem}.utility-tool-table tbody tr:last-child td{border-bottom:none}.utility-tool-table tbody tr:hover td{background:var(--pb-surface-selected)}.utility-tool-table-output{color:var(--pb-status-success)}.utility-tool-action-footer{border-top:1px solid var(--pb-border-subtle);justify-content:flex-end;align-items:center;gap:.625rem;padding:.75rem 0 0;display:flex}.utility-tool-action-footer .settings-footer-actions{gap:.625rem;margin-left:auto}.utility-tool-action-footer .btn-ghost,.utility-tool-action-footer .btn-primary{min-height:0;padding:.5625rem 1.125rem;font-size:.82rem;line-height:1.25rem}.utility-tool-action-footer-card{margin-top:.25rem}.utility-step-card-active{border-color:var(--pb-interactive);background-color:var(--pb-surface-selected)}.utility-step-card-static{border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card)}.utility-empty-state-compact{padding-top:1rem;padding-bottom:1rem}.utility-template-table-type{color:var(--pb-text-primary);font-size:.78rem;font-weight:600}.utility-template-meta-token{color:var(--pb-text-tertiary);font-family:JetBrains Mono,monospace;font-size:.72rem}.utility-template-meta-token strong{color:var(--pb-text-secondary);font-weight:600}.utilities-queue-table-wrap{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:14px;overflow:hidden}.utilities-queue-table{border-collapse:collapse;width:100%}.utilities-queue-table th{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;--tw-font-weight:var(--font-weight-bold);font-family:Syne,sans-serif;font-size:.58rem;font-weight:var(--font-weight-bold);--tw-tracking:.1em;letter-spacing:.1em;color:var(--pb-text-dim);text-transform:uppercase}.utilities-queue-table th.c,.utilities-queue-table td.c{text-align:center}.utilities-queue-table th.r,.utilities-queue-table td.r{text-align:right}.utilities-queue-table td{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border-subtle);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);font-size:.82rem}.utilities-queue-table tbody:last-child tr:last-child td{border-bottom:0}.utilities-queue-row:hover td{background-color:var(--pb-surface-selected)}.utilities-queue-row>td{vertical-align:middle}.utilities-queue-led{height:calc(var(--spacing) * 2);width:calc(var(--spacing) * 2);background-color:var(--pb-text-dim);border-radius:3.40282e38px;display:inline-block}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led{background-color:color-mix(in srgb, var(--pb-text-dim) 45%, transparent)}}.utilities-queue-led-green{background-color:var(--pb-success);box-shadow:0 0 8px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led-green{box-shadow:0 0 8px color-mix(in srgb, var(--pb-success) 30%, transparent)}}.utilities-queue-led-amber{background-color:var(--pb-warning);box-shadow:0 0 8px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led-amber{box-shadow:0 0 8px color-mix(in srgb, var(--pb-warning) 28%, transparent)}}.utilities-queue-led-blue{background-color:var(--pb-info);box-shadow:0 0 8px var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led-blue{box-shadow:0 0 8px color-mix(in srgb, var(--pb-info) 28%, transparent)}}.utilities-queue-job-name{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary)}.utilities-queue-job-type{margin-top:calc(var(--spacing) * .5);color:var(--pb-text-dim);font-size:.72rem}.utilities-queue-job-meta{margin-top:var(--spacing);color:var(--pb-text-secondary);font-size:.68rem}.utilities-queue-items{--tw-font-weight:var(--font-weight-semibold);font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:var(--font-weight-semibold);color:var(--pb-text-secondary)}.utilities-queue-progress-cell .series-mission-control-bar{gap:.625rem}.utilities-queue-progress-cell .series-mission-control-bar-track{min-width:120px}.utilities-queue-time{color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.72rem}.utilities-queue-actions{justify-content:flex-end;align-items:center;gap:calc(var(--spacing) * 1.5);display:flex}.utilities-queue-act-btn{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:8px;justify-content:center;align-items:center;display:inline-flex}.utilities-queue-act-btn:hover{border-color:var(--pb-border-strong);color:var(--pb-text);background-color:var(--pb-bg-card-hover)}.utilities-queue-act-btn-danger{color:var(--pb-error)}.utilities-queue-act-btn-danger:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-act-btn-danger:hover{border-color:color-mix(in srgb, var(--pb-error) 36%, transparent)}}.utilities-queue-act-btn-danger:hover{background-color:var(--pb-error-dim);color:var(--pb-error)}.utilities-queue-detail-cell{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-detail-cell{background-color:color-mix(in srgb, var(--pb-bg-surface) 72%, transparent)}}.utilities-queue-detail-summary{align-items:center;column-gap:calc(var(--spacing) * 6);row-gap:var(--spacing);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);color:var(--pb-text-dim);flex-wrap:wrap;font-size:.72rem;display:flex}.utilities-queue-detail-summary strong{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.utilities-queue-detail-label{--tw-tracking:.08em;letter-spacing:.08em;text-transform:uppercase}.utilities-queue-empty{justify-content:center;align-items:center;gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 12);text-align:center;flex-direction:column;display:flex}.utilities-queue-empty-compact{padding-block:calc(var(--spacing) * 8)}.utilities-queue-empty-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.utilities-queue-empty-copy{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.queue-job-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);overflow:hidden}.queue-job-card-expanded{border-color:var(--pb-border-hover);box-shadow:var(--pb-shadow-2)}.queue-progress-track{height:calc(var(--spacing) * 1.5);background-color:var(--pb-bg-card-hover);border-radius:3.40282e38px;width:100%;overflow:hidden}.queue-progress-fill{height:100%;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.5s;background-color:var(--pb-success);border-radius:3.40282e38px;transition-duration:.5s}.queue-progress-fill-paused{background-color:var(--pb-warning)}.selection-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);text-align:left;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1)}.selection-card:not(.selection-card-active):hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.selection-card:not(.selection-card-active):hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 55%, transparent)}}.selection-card-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.selection-card-active{border-color:color-mix(in srgb, var(--pb-interactive) 42%, transparent)}}.selection-card-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.selection-card-active{background-color:color-mix(in srgb, var(--pb-interactive) 14%, var(--pb-bg-card))}}.selection-card-active{box-shadow:var(--pb-shadow-2)}.selection-card-icon{height:calc(var(--spacing) * 11);width:calc(var(--spacing) * 11);border-radius:var(--radius-xl);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-shrink:0;justify-content:center;align-items:center;display:flex}.selection-card-icon-active{color:var(--pb-interactive);background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.selection-card-icon-active{background-color:color-mix(in srgb, var(--pb-interactive) 14%, transparent)}}.settings-theme-choice{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.settings-theme-choice-icon{height:calc(var(--spacing) * 9);width:calc(var(--spacing) * 9);border-radius:var(--radius-lg)}.settings-media-preview-panel{background-color:var(--pb-surface-selected);border-color:var(--pb-border-hover)}@supports (color:color-mix(in lab, red, red)){.settings-media-preview-panel{border-color:color-mix(in srgb, var(--pb-border-hover) 85%, transparent)}}.settings-media-preview-panel{overflow-wrap:anywhere;min-height:4.625rem;transition:opacity .16s,border-color .16s}.settings-media-preview-panel.is-loading{opacity:.68;border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.settings-media-preview-panel.is-loading{border-color:color-mix(in srgb, var(--pb-interactive) 30%, var(--pb-border-hover))}}.workflow-step{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);box-shadow:var(--pb-shadow-1)}.workflow-step-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active{border-color:color-mix(in srgb, var(--pb-interactive) 32%, transparent)}}.workflow-step-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.workflow-step-complete{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete{border-color:color-mix(in srgb, var(--pb-success) 28%, transparent)}}.workflow-step-complete{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete{background-color:color-mix(in srgb, var(--pb-success) 10%, var(--pb-bg-card))}}.workflow-step-badge{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8);border-style:var(--tw-border-style);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);border-width:1px;border-color:var(--pb-border-hover);color:var(--pb-text-secondary);background-color:var(--pb-bg-card);border-radius:3.40282e38px;justify-content:center;align-items:center;display:flex}.workflow-step-active .workflow-step-badge{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active .workflow-step-badge{border-color:color-mix(in srgb, var(--pb-interactive) 32%, transparent)}}.workflow-step-active .workflow-step-badge{color:var(--pb-interactive);background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active .workflow-step-badge{background-color:color-mix(in srgb, var(--pb-interactive) 12%, transparent)}}.workflow-step-complete .workflow-step-badge{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete .workflow-step-badge{border-color:color-mix(in srgb, var(--pb-success) 28%, transparent)}}.workflow-step-complete .workflow-step-badge{color:var(--pb-success);background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete .workflow-step-badge{background-color:color-mix(in srgb, var(--pb-success) 12%, transparent)}}.workflow-step-label{--tw-font-weight:var(--font-weight-medium);font-size:10px;font-weight:var(--font-weight-medium);--tw-tracking:.12em;letter-spacing:.12em;color:var(--pb-text-dim);text-transform:uppercase}.workflow-step-title{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.checklist-item{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);display:flex}.section-body{padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4)}.section-header{gap:calc(var(--spacing) * 2);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);flex-direction:column;display:flex}@media (min-width:40rem){.section-header{flex-direction:row;justify-content:space-between;align-items:flex-start}}.section-header-copy{min-width:0}:where(.section-header-copy>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}.section-header-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;flex-shrink:0;display:flex}.section-eyebrow{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.section-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-primary);text-transform:uppercase}.section-title-plain{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.section-description{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.settings-connection-list{gap:calc(var(--spacing) * 3);flex-direction:column;display:flex}.settings-connection-card{cursor:pointer;border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);text-align:left;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.settings-connection-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.settings-connection-card-muted{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card-muted{background-color:color-mix(in srgb, var(--pb-bg-surface) 70%, transparent)}}.settings-connection-card-muted{opacity:.78}.settings-connection-card .downloads-action-btn.is-danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card .downloads-action-btn.is-danger{border-color:color-mix(in srgb, var(--pb-error) 28%, var(--pb-border-subtle))}}.settings-connection-card .downloads-action-btn.is-danger{background:var(--pb-error-dim);color:var(--pb-error)}.settings-connection-card .downloads-action-btn.is-danger:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card .downloads-action-btn.is-danger:hover{border-color:color-mix(in srgb, var(--pb-error) 44%, transparent)}}.settings-connection-card .downloads-action-btn.is-danger:hover{background:var(--pb-error-dim)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card .downloads-action-btn.is-danger:hover{background:color-mix(in srgb, var(--pb-error-dim) 86%, transparent)}}.settings-connection-card .downloads-action-btn.is-danger:hover{color:var(--pb-error)}.admin-nav-link{align-items:center;gap:calc(var(--spacing) * 2.5);border-radius:var(--radius-lg);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * 2);--tw-font-weight:var(--font-weight-medium);font-size:.8125rem;font-weight:var(--font-weight-medium);color:var(--pb-text-dim);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-width:1px;border-color:#0000;display:flex}.admin-nav-link:hover{color:var(--pb-text-secondary);background-color:var(--pb-surface-selected)}.admin-nav-link-active{color:var(--pb-interactive);background-color:var(--pb-surface-selected)}.admin-nav-link-icon{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);flex-shrink:0;justify-content:center;align-items:center;display:flex}.admin-nav-link-active .admin-nav-link-icon{color:var(--pb-interactive)}.admin-nav-link-copy{min-width:0}.admin-nav-link-title{text-overflow:ellipsis;white-space:nowrap;--tw-font-weight:var(--font-weight-medium);font-size:.8125rem;font-weight:var(--font-weight-medium);overflow:hidden}:where(.settings-rows>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-color:var(--pb-border)}.settings-row{gap:calc(var(--spacing) * 4);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);grid-template-columns:repeat(1,minmax(0,1fr));display:grid}@media (min-width:40rem){.settings-row{grid-template-columns:220px minmax(0,1fr);align-items:flex-start}}.settings-row-meta{padding-top:var(--spacing)}.settings-row-label{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);display:block}.settings-row-help{margin-top:calc(var(--spacing) * .5);--tw-leading:calc(var(--spacing) * 5);font-size:11px;line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim)}.settings-row-content{min-width:0}.settings-rows-align-end .settings-row-content{flex-direction:column;align-items:flex-end;display:flex}.settings-footer{gap:calc(var(--spacing) * 3);border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 3);flex-direction:column;display:flex}@media (min-width:40rem){.settings-footer{flex-direction:row;justify-content:space-between;align-items:center}}.settings-footer{background-color:var(--pb-bg-surface);overflow-anchor:none}@media (min-width:40rem){.settings-footer-end{justify-content:flex-end}}.settings-footer-copy{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.settings-footer-actions{align-items:center;gap:calc(var(--spacing) * 3);flex-wrap:wrap;display:flex}.settings-footer-actions .btn-primary,.settings-footer-actions .btn-ghost,.settings-footer-actions .btn-danger,.settings-footer-actions .btn-warning{min-height:calc(var(--spacing) * 10);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.field-note{margin-top:calc(var(--spacing) * 1.5);align-items:flex-start;gap:calc(var(--spacing) * 1.5);--tw-leading:calc(var(--spacing) * 5);font-size:11px;line-height:calc(var(--spacing) * 5);display:flex}.field-note code{color:var(--pb-text-secondary)}.field-note-icon{margin-top:calc(var(--spacing) * .5);flex-shrink:0;width:.75rem;height:.75rem}.field-note-warning{color:var(--pb-warning)}.field-note-info{color:var(--pb-info)}.field-note-danger{color:var(--pb-error)}.field-note-success{color:var(--pb-success)}.alert-banner{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);border-width:1px;flex-direction:column;display:flex}@media (min-width:40rem){.alert-banner{flex-direction:row;justify-content:space-between;align-items:flex-start}}.alert-banner{box-shadow:var(--pb-shadow-1)}.alert-banner-main{align-items:flex-start;gap:calc(var(--spacing) * 4);min-width:0;display:flex}.alert-banner-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:var(--radius-lg);flex-shrink:0;justify-content:center;align-items:center;display:flex}.alert-banner-copy{min-width:0}:where(.alert-banner-copy>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}.alert-banner-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.alert-banner-description{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.alert-banner-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;flex-shrink:0;display:flex}.alert-banner-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.alert-banner-info{background-color:color-mix(in srgb, var(--pb-info) 10%, var(--pb-bg-card))}}.alert-banner-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.alert-banner-info{border-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.alert-banner-info .alert-banner-icon{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.alert-banner-info .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.alert-banner-info .alert-banner-icon{color:var(--pb-info)}.alert-banner-warning{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.alert-banner-warning{background-color:color-mix(in srgb, var(--pb-warning) 10%, var(--pb-bg-card))}}.alert-banner-warning{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.alert-banner-warning{border-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.alert-banner-warning .alert-banner-icon{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.alert-banner-warning .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.alert-banner-warning .alert-banner-icon{color:var(--pb-warning)}.alert-banner-danger{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.alert-banner-danger{background-color:color-mix(in srgb, var(--pb-error) 10%, var(--pb-bg-card))}}.alert-banner-danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.alert-banner-danger{border-color:color-mix(in srgb, var(--pb-error) 18%, transparent)}}.alert-banner-danger .alert-banner-icon{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.alert-banner-danger .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-error) 18%, transparent)}}.alert-banner-danger .alert-banner-icon{color:var(--pb-error)}.alert-banner-success{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.alert-banner-success{background-color:color-mix(in srgb, var(--pb-success) 10%, var(--pb-bg-card))}}.alert-banner-success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.alert-banner-success{border-color:color-mix(in srgb, var(--pb-success) 18%, transparent)}}.alert-banner-success .alert-banner-icon{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.alert-banner-success .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-success) 18%, transparent)}}.alert-banner-success .alert-banner-icon{color:var(--pb-success)}.modal-shell{z-index:50;padding:calc(var(--spacing) * 4);padding-top:calc(var(--spacing) * 16);justify-content:center;align-items:flex-start;display:flex;position:fixed;inset:0}.modal-backdrop{z-index:0;background-color:var(--pb-bg-overlay);--tw-backdrop-blur:blur(var(--blur-xs));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);position:fixed;inset:0}.modal-panel{z-index:10;border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);width:100%;box-shadow:var(--pb-shadow-overlay);flex-direction:column;display:flex;position:relative;overflow:hidden}.modal-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);justify-content:space-between;align-items:center;display:flex}.modal-body{flex:1;overflow-y:auto}.modal-footer{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);flex-shrink:0;justify-content:space-between;align-items:center;display:flex}.donation-modal-shell{align-items:flex-start;padding-top:4.75rem}.donation-modal-panel{border-radius:1.5rem;width:min(100vw - 2rem,56rem);max-height:calc(100dvh - 6rem)}.donation-modal-header{border-bottom-color:var(--pb-border-subtle);background:radial-gradient(circle at top left, var(--pb-brand-dim), transparent 18rem), var(--pb-bg-card);align-items:flex-start;gap:1rem}.donation-modal-kicker{letter-spacing:.18em;text-transform:uppercase;color:var(--pb-brand);font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:700}.donation-modal-title{color:var(--pb-text);letter-spacing:-.03em;margin-top:.2rem;font-family:Syne,sans-serif;font-size:clamp(1.35rem,2.5vw,2rem);font-weight:800;line-height:1.05}.donation-modal-copy{max-width:38rem;color:var(--pb-text-sec);margin-top:.45rem;font-size:.9rem;line-height:1.55}.donation-modal-body{background:linear-gradient(135deg, transparent, var(--pb-surface-selected)), var(--pb-bg-surface);gap:1rem;padding:1rem;display:grid}.donation-option-card{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:1.25rem;grid-template-columns:minmax(0,1fr) 10.5rem;align-items:stretch;gap:1rem;padding:1rem;display:grid}.donation-option-card--coffee{border-color:#fd0}@supports (color:color-mix(in lab, red, red)){.donation-option-card--coffee{border-color:color-mix(in srgb, #fd0 38%, var(--pb-border-subtle))}}.donation-option-card--liberapay{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.donation-option-card--liberapay{border-color:color-mix(in srgb, var(--pb-success) 32%, var(--pb-border-subtle))}}.donation-option-main{flex-direction:column;justify-content:space-between;gap:1rem;min-width:0;display:flex}.donation-option-eyebrow{letter-spacing:.16em;text-transform:uppercase;color:var(--pb-text-sec);font-family:JetBrains Mono,monospace;font-size:.64rem;font-weight:700}.donation-option-title{color:var(--pb-text);margin-top:.25rem;font-family:Syne,sans-serif;font-size:1.1rem;font-weight:800}.donation-option-copy{max-width:28rem;color:var(--pb-text-sec);margin-top:.35rem;font-size:.82rem;line-height:1.5}.donation-option-button{border-radius:999px;justify-content:center;align-items:center;width:fit-content;padding:.62rem 1rem;font-size:.82rem;font-weight:800;line-height:1;text-decoration:none;transition:transform .16s,box-shadow .16s,filter .16s;display:inline-flex}.donation-option-button:hover,.donation-option-button:focus-visible{filter:brightness(1.02);transform:translateY(-1px)}.donation-option-button:focus-visible{outline:2px solid var(--pb-focus-outline);outline-offset:3px}.donation-option-button--coffee{color:#16130a;background:#fd0;box-shadow:0 10px 20px #fd03}.donation-option-button--liberapay{background:var(--pb-success);color:#f7f1e8;box-shadow:0 10px 20px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.donation-option-button--liberapay{box-shadow:0 10px 20px color-mix(in srgb, var(--pb-success) 20%, transparent)}}[data-theme=dark] .donation-option-button--liberapay{color:#16130a}.donation-qr-frame{border:1px solid var(--pb-border-subtle);background:#fff;border-radius:1rem;place-items:center;min-height:10.5rem;padding:.625rem;display:grid}.donation-qr{object-fit:contain;width:9.25rem;height:9.25rem}@media (max-width:720px){.donation-modal-shell{padding-top:3.25rem}.donation-modal-panel{max-height:calc(100dvh - 4rem)}.donation-option-card{grid-template-columns:1fr}.donation-option-button{width:100%}}.issue-search-modal-panel{border-radius:1.25rem;width:min(100vw - 2rem,78rem);max-width:none;max-height:90vh}.issue-search-modal-header{border-bottom-color:var(--pb-border-subtle);align-items:flex-start;padding:1.125rem 1.5rem}.issue-search-modal-title{letter-spacing:.03em;text-transform:uppercase;color:var(--pb-text);font-family:Syne,sans-serif;font-size:1.05rem;font-weight:800}.issue-search-modal-title span{color:var(--pb-brand)}.issue-search-modal-subtitle{color:var(--pb-text-dim);margin-top:.1875rem;font-size:.78rem}.issue-search-modal-stats{flex-wrap:wrap;gap:.375rem;margin-top:.5rem;display:flex}.issue-search-modal-close{border-radius:var(--radius-lg);--tw-border-style:none;color:var(--pb-text-dim);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:#0000;border-style:none;justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.issue-search-modal-close:hover{color:var(--pb-text-primary)}}.issue-search-modal-close{width:1.875rem;height:1.875rem}.issue-search-modal-close:hover{background:var(--pb-surface-selected)}.issue-search-modal-body{padding:0}.issue-search-loading-state{min-height:24rem}.issue-search-loading-title{color:var(--pb-text);font-size:.82rem;font-weight:600}.issue-search-loading-copy{color:var(--pb-text-dim);font-size:.72rem}.issue-search-dc-status{border-top:1px solid var(--pb-border-subtle);color:var(--pb-text-dim);align-items:center;gap:.625rem;padding:.75rem 1rem;font-size:.78rem;display:flex}.issue-search-dc-results{border-top:1px solid var(--pb-border-subtle)}.issue-search-dc-results-heading{align-items:center;gap:.5rem;padding:.75rem 1rem;display:flex}.issue-search-modal-footer{border-top-color:var(--pb-border-subtle);justify-content:flex-end;padding:.75rem 1.5rem}.issue-search-modal-footer-meta{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.68rem}.issue-search-modal-footer-meta:empty{display:none}.issue-search-modal-footer-meta strong{color:var(--pb-text);font-weight:700}.issue-search-modal-footer-close{margin-left:auto}.inline-alert{align-items:flex-start;gap:calc(var(--spacing) * 2);border-radius:var(--radius-lg);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2.5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));border-width:1px;display:flex}.inline-alert-icon{margin-top:calc(var(--spacing) * .5);flex-shrink:0}.inline-alert-danger{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.inline-alert-danger{background-color:color-mix(in srgb, var(--pb-error) 12%, var(--pb-bg-card))}}.inline-alert-danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.inline-alert-danger{border-color:color-mix(in srgb, var(--pb-error) 22%, transparent)}}.inline-alert-danger{color:var(--pb-error)}.inline-alert-warning{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.inline-alert-warning{background-color:color-mix(in srgb, var(--pb-warning) 12%, var(--pb-bg-card))}}.inline-alert-warning{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.inline-alert-warning{border-color:color-mix(in srgb, var(--pb-warning) 22%, transparent)}}.inline-alert-warning{color:var(--pb-warning)}.inline-alert-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.inline-alert-info{background-color:color-mix(in srgb, var(--pb-info) 12%, var(--pb-bg-card))}}.inline-alert-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.inline-alert-info{border-color:color-mix(in srgb, var(--pb-info) 22%, transparent)}}.inline-alert-info{color:var(--pb-info)}.inline-alert-success{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.inline-alert-success{background-color:color-mix(in srgb, var(--pb-success) 12%, var(--pb-bg-card))}}.inline-alert-success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.inline-alert-success{border-color:color-mix(in srgb, var(--pb-success) 22%, transparent)}}.inline-alert-success{color:var(--pb-success)}.field-invalid{border-color:var(--pb-error)!important}@supports (color:color-mix(in lab, red, red)){.field-invalid{border-color:color-mix(in srgb, var(--pb-error) 52%, transparent)!important}}.icon-btn{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.icon-btn:hover{border-color:var(--pb-border-strong);color:var(--pb-text-primary)}}.icon-btn:disabled{cursor:not-allowed;opacity:.5}.icon-btn{min-width:var(--pb-control-min-height);width:var(--pb-control-min-height);min-height:var(--pb-control-min-height);border-radius:var(--pb-control-radius);padding:0}.toggle-switch{cursor:pointer;background-color:var(--pb-bg-card-hover);width:2.25rem;height:1.25rem;box-shadow:inset 0 0 0 1px var(--pb-border-hover);border-radius:999px;flex-shrink:0;transition:background-color .18s,box-shadow .18s;display:inline-flex;position:relative}label:has(>.toggle-input+.toggle-switch){position:relative}.toggle-input{clip:rect(0, 0, 0, 0);clip-path:inset(50%);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.toggle-input-overlay{opacity:0;clip:auto;clip-path:none;white-space:normal;z-index:1;width:100%;height:100%;margin:0;inset:0}.toggle-switch:after{content:"";background-color:var(--pb-brand);width:1rem;height:1rem;box-shadow:0 1px 2px var(--pb-text-primary);border-radius:999px;position:absolute;top:2px;left:2px}@supports (color:color-mix(in lab, red, red)){.toggle-switch:after{box-shadow:0 1px 2px color-mix(in srgb, var(--pb-text-primary) 14%, transparent)}}.toggle-switch:after{transition:transform .18s}.peer:checked+.toggle-switch{background-color:var(--pb-interactive);box-shadow:inset 0 0 0 1px var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.peer:checked+.toggle-switch{box-shadow:inset 0 0 0 1px color-mix(in srgb, var(--pb-interactive) 34%, transparent)}}.peer:checked+.toggle-switch:after{background-color:var(--pb-surface-shell);transform:translate(1rem)}.peer:focus-visible+.toggle-switch{outline:2px solid var(--pb-focus-outline);outline-offset:3px;box-shadow:0 0 0 4px var(--pb-focus-ring)}.strength-segment{height:calc(var(--spacing) * 1.5);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.3s;border-radius:3.40282e38px;flex:1;transition-duration:.3s}.strength-segment-neutral{background-color:var(--pb-bg-card-hover)}.strength-segment-danger{background-color:var(--pb-error)}.strength-segment-warning{background-color:var(--pb-warning)}.strength-segment-success{background-color:var(--pb-success)}.input-pb{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-input);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);font-family:DM Sans,sans-serif;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-primary);border-radius:10px}.input-pb::placeholder{color:var(--pb-text-dim)}.input-pb:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.input-pb:focus{outline-offset:2px;outline:2px solid #0000}}.checkbox-pb{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);border-color:var(--pb-border-hover);background-color:var(--pb-bg-input);color:var(--pb-interactive);border-radius:.25rem}.checkbox-pb:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-ring-offset-width:0px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.select-pb{appearance:none;border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-input);width:100%;padding-block:calc(var(--spacing) * 2);padding-right:calc(var(--spacing) * 8);padding-left:calc(var(--spacing) * 3);font-family:DM Sans,sans-serif;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-primary);border-radius:10px}.select-pb:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.select-pb:focus{outline-offset:2px;outline:2px solid #0000}}.select-pb{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%237A8599' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.25rem}.dropdown-select{min-width:0;position:relative}.dropdown-select-trigger{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);width:100%;color:var(--pb-text-primary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));justify-content:space-between;align-items:center;display:flex}@media (hover:hover){.dropdown-select-trigger:hover{border-color:var(--pb-border-strong)}}.dropdown-select-trigger:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.dropdown-select-trigger:focus{outline-offset:2px;outline:2px solid #0000}}.dropdown-select-trigger:disabled{cursor:not-allowed;opacity:.6}.dropdown-select-trigger{gap:var(--pb-control-gap);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.dropdown-select-trigger-label{text-overflow:ellipsis;white-space:nowrap;text-align:left;flex:1;min-width:0;overflow:hidden}.dropdown-select-chevron{color:var(--pb-text-secondary);transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));width:var(--pb-control-icon-size);height:var(--pb-control-icon-size);flex-shrink:0}.dropdown-select-panel{z-index:80;max-height:calc(var(--spacing) * 60);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card);padding-block:var(--spacing);--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-shadow-color:#0003;position:fixed;top:0;left:0;overflow-y:auto}@supports (color:color-mix(in lab, red, red)){.dropdown-select-panel{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.dropdown-select-panel{max-height:var(--pb-dropdown-panel-max-height,15rem);will-change:top, left}.dropdown-select-panel[data-ready=false]{visibility:hidden;pointer-events:none}.dropdown-select-option{text-align:left;width:100%;color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));justify-content:space-between;align-items:center;gap:var(--pb-control-gap);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-panel-item-px);padding-block:var(--pb-control-panel-item-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);display:flex}.dropdown-select-option-label{text-align:left;white-space:nowrap;overflow-wrap:normal;flex:1;min-width:0}.import-reconcile-issue-dropdown .dropdown-select-trigger{align-items:flex-start;height:auto}.import-reconcile-issue-dropdown .dropdown-select-trigger-label{white-space:normal;overflow-wrap:anywhere;text-overflow:clip;overflow:visible}.dropdown-select-panel-wrap .dropdown-select-option{align-items:flex-start}.dropdown-select-panel-wrap .dropdown-select-option-label{white-space:normal;overflow-wrap:anywhere;word-break:normal}.dropdown-select-option:hover,.dropdown-select-option-active{background-color:var(--pb-info-dim);color:var(--pb-text-primary)}.dropdown-select-option-active{transition-duration:0s}.dropdown-select-option-selected{background-color:var(--pb-info-dim);color:var(--pb-text-primary);font-weight:500}.dropdown-select-option-check{width:var(--pb-control-icon-size);height:var(--pb-control-icon-size);flex-shrink:0}.direct-provider-uri-control{position:relative}.direct-provider-uri-input{padding-right:calc(var(--spacing) * 10);font-family:JetBrains Mono,Fira Code,monospace}.direct-provider-uri-toggle{width:calc(var(--spacing) * 10);border-top-right-radius:var(--radius-lg);border-bottom-right-radius:var(--radius-lg);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));inset-block:0;justify-content:center;align-items:center;display:flex;position:absolute;right:0}@media (hover:hover){.direct-provider-uri-toggle:hover{color:var(--pb-text-primary)}}.direct-provider-uri-toggle:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.direct-provider-uri-toggle:focus{outline-offset:2px;outline:2px solid #0000}}.direct-provider-uri-toggle:focus{--tw-ring-inset:inset}.direct-provider-uri-options{z-index:90;margin-top:var(--spacing);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card);padding-block:var(--spacing);--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-shadow-color:#0003;position:absolute;top:100%;left:0;right:0;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.direct-provider-uri-options{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.direct-provider-uri-option{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 2);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:flex}@media (hover:hover){.direct-provider-uri-option:hover{background-color:var(--pb-info-dim);color:var(--pb-text-primary)}}.direct-provider-uri-option:focus{background-color:var(--pb-info-dim);color:var(--pb-text-primary);--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.direct-provider-uri-option:focus{outline-offset:2px;outline:2px solid #0000}}.direct-provider-uri-option-selected{background-color:var(--pb-info-dim);color:var(--pb-text-primary)}.search-field{min-width:0;position:relative}.search-field-icon{pointer-events:none;z-index:10;--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y);color:var(--pb-text-secondary);top:50%;left:var(--pb-control-search-icon-left);width:var(--pb-control-icon-size);height:var(--pb-control-icon-size);position:absolute}.search-field-input{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);width:100%;color:var(--pb-text-primary)}.search-field-input::placeholder{color:var(--pb-text-secondary)}.search-field-input:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.search-field-input:focus{outline-offset:2px;outline:2px solid #0000}}.search-field-input{min-height:var(--pb-control-min-height);padding-inline-start:var(--pb-control-search-padding-left);padding-inline-end:var(--pb-control-search-padding-right);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius);appearance:none;background-image:none}.search-field-editor{white-space:nowrap;min-height:42px;overflow:hidden}.search-field-editor[data-empty=true]:before{content:attr(data-placeholder);color:var(--pb-text-secondary)}.search-field-input::-webkit-search-decoration{appearance:none;display:none}.search-field-input::-webkit-search-cancel-button{appearance:none;display:none}.search-field-input::-webkit-search-results-button{appearance:none;display:none}.search-field-input::-webkit-search-results-decoration{appearance:none;display:none}.search-field-input::-ms-clear{width:0;height:0;display:none}.search-field-input::-ms-reveal{width:0;height:0;display:none}.search-field-clear{z-index:10;--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y);padding:calc(var(--spacing) * .5);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:.25rem;position:absolute;top:50%}@media (hover:hover){.search-field-clear:hover{color:var(--pb-text-primary)}}.search-field-clear{right:var(--pb-control-clear-right)}.search-history-panel{z-index:30;margin-top:var(--spacing);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card);--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-shadow-color:#0006;position:absolute;top:100%;left:0;right:0;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.search-history-panel{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 40%, transparent) var(--tw-shadow-alpha), transparent)}}.search-history-panel-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);justify-content:space-between;align-items:center;display:flex}.search-history-panel-title{--tw-font-weight:var(--font-weight-semibold);font-size:10px;font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider);color:var(--pb-text-dim);text-transform:uppercase}.search-history-panel-clear{color:var(--pb-text-dim);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));font-size:10px}@media (hover:hover){.search-history-panel-clear:hover{color:var(--pb-error)}}.search-history-list{max-height:280px;padding-block:var(--spacing);overflow-y:auto}.search-history-item{align-items:center;display:flex}.search-history-item-button{align-items:center;gap:calc(var(--spacing) * 2.5);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex:1;display:flex}@media (hover:hover){.search-history-item-button:hover{color:var(--pb-text-primary)}}.search-history-item-button:hover{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.search-history-item-button:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.search-history-item-remove{padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * 2);color:var(--pb-text-dim);opacity:0;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){.search-history-item-remove:is(:where(.group):hover *){opacity:1}.search-history-item-remove:hover{color:var(--pb-error)}}.touch-target{min-width:44px;min-height:44px}.progress-track{height:var(--spacing);background-color:var(--pb-border);border-radius:3.40282e38px}.progress-fill{background-color:var(--pb-success);height:100%;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:3.40282e38px}.progress-fill-partial{background-color:var(--pb-interactive);height:100%;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:3.40282e38px}.callout-brand{align-items:center;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:var(--pb-brand-signal-dim);border-width:1px;border-color:var(--pb-brand-signal-border);display:flex}.callout-brand:hover{background-color:var(--pb-brand-signal-hover);border-color:var(--pb-brand-signal-border)}.callout-brand-icon{border-radius:var(--radius-lg);padding:calc(var(--spacing) * 2);background-color:var(--pb-brand-signal-dim);color:var(--pb-brand-signal);flex-shrink:0}.callout-brand-title{color:var(--pb-brand-signal)}.callout-brand-body{color:var(--pb-brand-signal);opacity:.7}.callout-brand-chevron{color:var(--pb-brand-signal);opacity:.5}}@layer utilities{.pointer-events-none{pointer-events:none}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-y-0{inset-block:0}.-top-3{top:calc(var(--spacing) * -3)}.top-0{top:0}.top-1\/2{top:50%}.top-2{top:calc(var(--spacing) * 2)}.top-4{top:calc(var(--spacing) * 4)}.-right-1{right:calc(var(--spacing) * -1)}.-right-3{right:calc(var(--spacing) * -3)}.right-0{right:0}.right-2{right:calc(var(--spacing) * 2)}.right-4{right:calc(var(--spacing) * 4)}.-bottom-1{bottom:calc(var(--spacing) * -1)}.left-0{left:0}.left-3{left:calc(var(--spacing) * 3)}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[1\]{z-index:1}.z-\[60\]{z-index:60}.z-\[100\]{z-index:100}.z-\[110\]{z-index:110}.col-span-2{grid-column:span 2/span 2}.col-span-full{grid-column:1/-1}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.m-4{margin:calc(var(--spacing) * 4)}.mx-auto{margin-inline:auto}.my-2{margin-block:calc(var(--spacing) * 2)}.\!mt-0{margin-top:0!important}.\!mt-2{margin-top:calc(var(--spacing) * 2)!important}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-1{margin-right:var(--spacing)}.\!mb-0{margin-bottom:0!important}.mb-1{margin-bottom:var(--spacing)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.-ml-2{margin-left:calc(var(--spacing) * -2)}.ml-0\.5{margin-left:calc(var(--spacing) * .5)}.ml-1{margin-left:var(--spacing)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-\[140px\]{margin-left:140px}.ml-auto{margin-left:auto}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.table-cell{display:table-cell}.table-row{display:table-row}.aspect-\[2\/3\]{aspect-ratio:2/3}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-4\.5{height:calc(var(--spacing) * 4.5)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-16{height:calc(var(--spacing) * 16)}.h-32{height:calc(var(--spacing) * 32)}.h-96{height:calc(var(--spacing) * 96)}.h-\[18px\]{height:18px}.h-\[100dvh\]{height:100dvh}.h-full{height:100%}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-72{max-height:calc(var(--spacing) * 72)}.max-h-80{max-height:calc(var(--spacing) * 80)}.max-h-\[28rem\]{max-height:28rem}.max-h-\[62vh\]{max-height:62vh}.max-h-\[70vh\]{max-height:70vh}.max-h-\[85vh\]{max-height:85vh}.max-h-\[90vh\]{max-height:90vh}.max-h-\[95vh\]{max-height:95vh}.max-h-\[calc\(100vh-5rem\)\]{max-height:calc(100vh - 5rem)}.\!min-h-8{min-height:calc(var(--spacing) * 8)!important}.\!min-h-10{min-height:calc(var(--spacing) * 10)!important}.min-h-0{min-height:0}.min-h-8{min-height:calc(var(--spacing) * 8)}.min-h-56{min-height:calc(var(--spacing) * 56)}.min-h-\[36px\]{min-height:36px}.min-h-\[200px\]{min-height:200px}.\!w-8{width:calc(var(--spacing) * 8)!important}.\!w-10{width:calc(var(--spacing) * 10)!important}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-1\/3{width:33.3333%}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-4\.5{width:calc(var(--spacing) * 4.5)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-12{width:calc(var(--spacing) * 12)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-32{width:calc(var(--spacing) * 32)}.w-36{width:calc(var(--spacing) * 36)}.w-40{width:calc(var(--spacing) * 40)}.w-44{width:calc(var(--spacing) * 44)}.w-48{width:calc(var(--spacing) * 48)}.w-56{width:calc(var(--spacing) * 56)}.w-64{width:calc(var(--spacing) * 64)}.w-80{width:calc(var(--spacing) * 80)}.w-96{width:calc(var(--spacing) * 96)}.w-\[10\%\]{width:10%}.w-\[12\%\]{width:12%}.w-\[14\%\]{width:14%}.w-\[18px\]{width:18px}.w-\[22rem\]{width:22rem}.w-\[24\%\]{width:24%}.w-\[26\%\]{width:26%}.w-\[28px\]{width:28px}.w-\[28rem\]{width:28rem}.w-\[30rem\]{width:30rem}.w-\[32px\]{width:32px}.w-\[52px\]{width:52px}.w-\[60px\]{width:60px}.w-\[64px\]{width:64px}.w-\[68px\]{width:68px}.w-\[72px\]{width:72px}.w-\[76px\]{width:76px}.w-\[80px\]{width:80px}.w-\[82px\]{width:82px}.w-\[84px\]{width:84px}.w-\[88px\]{width:88px}.w-\[90px\]{width:90px}.w-\[92px\]{width:92px}.w-\[96px\]{width:96px}.w-\[98px\]{width:98px}.w-\[100px\]{width:100px}.w-\[104px\]{width:104px}.w-\[108px\]{width:108px}.w-\[110px\]{width:110px}.w-\[112px\]{width:112px}.w-\[116px\]{width:116px}.w-\[118px\]{width:118px}.w-\[120px\]{width:120px}.w-\[128px\]{width:128px}.w-\[136px\]{width:136px}.w-\[140px\]{width:140px}.w-\[148px\]{width:148px}.w-\[152px\]{width:152px}.w-\[156px\]{width:156px}.w-\[168px\]{width:168px}.w-\[180px\]{width:180px}.w-\[240px\]{width:240px}.w-auto{width:auto}.w-full{width:100%}.max-w-0{max-width:0}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-64{max-width:calc(var(--spacing) * 64)}.max-w-\[16rem\]{max-width:16rem}.max-w-\[140px\]{max-width:140px}.max-w-\[150px\]{max-width:150px}.max-w-\[160px\]{max-width:160px}.max-w-\[220px\]{max-width:220px}.max-w-\[240px\]{max-width:240px}.max-w-\[260px\]{max-width:260px}.max-w-\[280px\]{max-width:280px}.max-w-\[300px\]{max-width:300px}.max-w-\[320px\]{max-width:320px}.max-w-\[calc\(100vw-2rem\)\]{max-width:calc(100vw - 2rem)}.max-w-full{max-width:100%}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.max-w-sm{max-width:var(--container-sm)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:0}.min-w-5{min-width:calc(var(--spacing) * 5)}.min-w-7{min-width:calc(var(--spacing) * 7)}.min-w-40{min-width:calc(var(--spacing) * 40)}.min-w-48{min-width:calc(var(--spacing) * 48)}.min-w-\[11\.5rem\]{min-width:11.5rem}.min-w-\[20px\]{min-width:20px}.min-w-\[48rem\]{min-width:48rem}.min-w-\[54rem\]{min-width:54rem}.min-w-\[60px\]{min-width:60px}.min-w-\[440px\]{min-width:440px}.min-w-\[620px\]{min-width:620px}.min-w-\[640px\]{min-width:640px}.min-w-\[720px\]{min-width:720px}.min-w-\[760px\]{min-width:760px}.min-w-\[860px\]{min-width:860px}.min-w-\[880px\]{min-width:880px}.min-w-\[920px\]{min-width:920px}.min-w-\[980px\]{min-width:980px}.min-w-\[1040px\]{min-width:1040px}.flex-1{flex:1}.flex-shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.table-fixed{table-layout:fixed}.-translate-x-full{--tw-translate-x:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-0{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-full{--tw-translate-x:100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1{--tw-translate-y:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-0{--tw-translate-y:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-1{--tw-translate-y:var(--spacing);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-2{--tw-translate-y:calc(var(--spacing) * 2);translate:var(--tw-translate-x) var(--tw-translate-y)}.scale-95{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.rotate-90{rotate:90deg}.rotate-180{rotate:180deg}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-help{cursor:help}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.cursor-wait{cursor:wait}.resize{resize:both}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.grid-cols-\[1fr_auto\]{grid-template-columns:1fr auto}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-nowrap{flex-wrap:nowrap}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-start{justify-content:flex-start}.gap-0{gap:0}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}:where(.space-y-0>:not(:last-child)){--tw-space-y-reverse:0;margin-block:0}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-1{column-gap:var(--spacing)}.gap-x-3{column-gap:calc(var(--spacing) * 3)}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-x-5{column-gap:calc(var(--spacing) * 5)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-y-1{row-gap:var(--spacing)}.gap-y-1\.5{row-gap:calc(var(--spacing) * 1.5)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-pb-border>:not(:last-child)){border-color:var(--pb-border)}:where(.divide-pb-border-subtle>:not(:last-child)){border-color:var(--pb-border-subtle)}:where(.divide-pb-border\/40>:not(:last-child)){border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){:where(.divide-pb-border\/40>:not(:last-child)){border-color:color-mix(in oklab, var(--pb-border) 40%, transparent)}}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-visible{overflow:visible}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.overscroll-contain{overscroll-behavior:contain}.\!rounded-none{border-radius:0!important}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-3xl{border-radius:var(--radius-3xl)}.rounded-\[var\(--radius-card\)\]{border-radius:var(--radius-card)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t-xl{border-top-left-radius:var(--radius-xl);border-top-right-radius:var(--radius-xl)}.rounded-b-xl{border-bottom-right-radius:var(--radius-xl);border-bottom-left-radius:var(--radius-xl)}.\!border-0{border-style:var(--tw-border-style)!important;border-width:0!important}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-\[3px\]{border-style:var(--tw-border-style);border-width:3px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.\!border-pb-warning\/35{border-color:var(--pb-warning)!important}@supports (color:color-mix(in lab, red, red)){.\!border-pb-warning\/35{border-color:color-mix(in oklab, var(--pb-warning) 35%, transparent)!important}}.border-current\/15{border-color:currentColor}@supports (color:color-mix(in lab, red, red)){.border-current\/15{border-color:color-mix(in oklab, currentcolor 15%, transparent)}}.border-pb-border{border-color:var(--pb-border)}.border-pb-border-hover,.border-pb-border-hover\/50{border-color:var(--pb-border-hover)}@supports (color:color-mix(in lab, red, red)){.border-pb-border-hover\/50{border-color:color-mix(in oklab, var(--pb-border-hover) 50%, transparent)}}.border-pb-border-subtle{border-color:var(--pb-border-subtle)}.border-pb-border\/50{border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.border-pb-border\/50{border-color:color-mix(in oklab, var(--pb-border) 50%, transparent)}}.border-pb-border\/70{border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.border-pb-border\/70{border-color:color-mix(in oklab, var(--pb-border) 70%, transparent)}}.border-pb-error,.border-pb-error\/30{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.border-pb-error\/30{border-color:color-mix(in oklab, var(--pb-error) 30%, transparent)}}.border-pb-error\/35{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.border-pb-error\/35{border-color:color-mix(in oklab, var(--pb-error) 35%, transparent)}}.border-pb-error\/40{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.border-pb-error\/40{border-color:color-mix(in oklab, var(--pb-error) 40%, transparent)}}.border-pb-info\/30{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.border-pb-info\/30{border-color:color-mix(in oklab, var(--pb-info) 30%, transparent)}}.border-pb-info\/40{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.border-pb-info\/40{border-color:color-mix(in oklab, var(--pb-info) 40%, transparent)}}.border-pb-interactive,.border-pb-interactive\/25{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.border-pb-interactive\/25{border-color:color-mix(in oklab, var(--pb-interactive) 25%, transparent)}}.border-pb-interactive\/30{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.border-pb-interactive\/30{border-color:color-mix(in oklab, var(--pb-interactive) 30%, transparent)}}.border-pb-interactive\/40{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.border-pb-interactive\/40{border-color:color-mix(in oklab, var(--pb-interactive) 40%, transparent)}}.border-pb-success\/30{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.border-pb-success\/30{border-color:color-mix(in oklab, var(--pb-success) 30%, transparent)}}.border-pb-success\/35{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.border-pb-success\/35{border-color:color-mix(in oklab, var(--pb-success) 35%, transparent)}}.border-pb-success\/40{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.border-pb-success\/40{border-color:color-mix(in oklab, var(--pb-success) 40%, transparent)}}.border-pb-surface{border-color:var(--pb-bg-surface)}.border-pb-warning\/20{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/20{border-color:color-mix(in oklab, var(--pb-warning) 20%, transparent)}}.border-pb-warning\/25{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/25{border-color:color-mix(in oklab, var(--pb-warning) 25%, transparent)}}.border-pb-warning\/30{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/30{border-color:color-mix(in oklab, var(--pb-warning) 30%, transparent)}}.border-pb-warning\/35{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/35{border-color:color-mix(in oklab, var(--pb-warning) 35%, transparent)}}.border-pb-warning\/40{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/40{border-color:color-mix(in oklab, var(--pb-warning) 40%, transparent)}}.border-pb-warning\/50{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/50{border-color:color-mix(in oklab, var(--pb-warning) 50%, transparent)}}.border-transparent{border-color:#0000}.border-t-pb-interactive{border-top-color:var(--pb-interactive)}.\!bg-pb-warning-dim\/45{background-color:var(--pb-warning-dim)!important}@supports (color:color-mix(in lab, red, red)){.\!bg-pb-warning-dim\/45{background-color:color-mix(in oklab, var(--pb-warning-dim) 45%, transparent)!important}}.\!bg-transparent{background-color:#0000!important}.bg-black\/80{background-color:#000c}@supports (color:color-mix(in lab, red, red)){.bg-black\/80{background-color:color-mix(in oklab, var(--color-black) 80%, transparent)}}.bg-pb-base{background-color:var(--pb-bg-base)}.bg-pb-card{background-color:var(--pb-bg-card)}.bg-pb-card-hover,.bg-pb-card-hover\/20{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/20{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 20%, transparent)}}.bg-pb-card-hover\/25{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/25{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 25%, transparent)}}.bg-pb-card-hover\/35{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/35{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 35%, transparent)}}.bg-pb-card-hover\/40{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/40{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 40%, transparent)}}.bg-pb-card-hover\/45{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/45{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 45%, transparent)}}.bg-pb-card-hover\/50{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/50{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 50%, transparent)}}.bg-pb-card-hover\/60{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/60{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 60%, transparent)}}.bg-pb-card\/40{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/40{background-color:color-mix(in oklab, var(--pb-bg-card) 40%, transparent)}}.bg-pb-card\/50{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/50{background-color:color-mix(in oklab, var(--pb-bg-card) 50%, transparent)}}.bg-pb-card\/60{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/60{background-color:color-mix(in oklab, var(--pb-bg-card) 60%, transparent)}}.bg-pb-card\/70{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/70{background-color:color-mix(in oklab, var(--pb-bg-card) 70%, transparent)}}.bg-pb-card\/80{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/80{background-color:color-mix(in oklab, var(--pb-bg-card) 80%, transparent)}}.bg-pb-card\/85{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/85{background-color:color-mix(in oklab, var(--pb-bg-card) 85%, transparent)}}.bg-pb-card\/95{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/95{background-color:color-mix(in oklab, var(--pb-bg-card) 95%, transparent)}}.bg-pb-error{background-color:var(--pb-error)}.bg-pb-error-dim,.bg-pb-error-dim\/70{background-color:var(--pb-error-dim)}@supports (color:color-mix(in lab, red, red)){.bg-pb-error-dim\/70{background-color:color-mix(in oklab, var(--pb-error-dim) 70%, transparent)}}.bg-pb-error\/10{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.bg-pb-error\/10{background-color:color-mix(in oklab, var(--pb-error) 10%, transparent)}}.bg-pb-error\/15{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.bg-pb-error\/15{background-color:color-mix(in oklab, var(--pb-error) 15%, transparent)}}.bg-pb-info{background-color:var(--pb-info)}.bg-pb-info-dim{background-color:var(--pb-info-dim)}.bg-pb-info\/10{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.bg-pb-info\/10{background-color:color-mix(in oklab, var(--pb-info) 10%, transparent)}}.bg-pb-input{background-color:var(--pb-bg-input)}.bg-pb-interactive{background-color:var(--pb-interactive)}.bg-pb-interactive-dim{background-color:var(--pb-interactive-dim)}.bg-pb-interactive\/15{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.bg-pb-interactive\/15{background-color:color-mix(in oklab, var(--pb-interactive) 15%, transparent)}}.bg-pb-overlay,.bg-pb-overlay\/45{background-color:var(--pb-bg-overlay)}@supports (color:color-mix(in lab, red, red)){.bg-pb-overlay\/45{background-color:color-mix(in oklab, var(--pb-bg-overlay) 45%, transparent)}}.bg-pb-purple-dim{background-color:var(--pb-purple-dim)}.bg-pb-success{background-color:var(--pb-success)}.bg-pb-success-dim,.bg-pb-success-dim\/70{background-color:var(--pb-success-dim)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success-dim\/70{background-color:color-mix(in oklab, var(--pb-success-dim) 70%, transparent)}}.bg-pb-success\/10{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success\/10{background-color:color-mix(in oklab, var(--pb-success) 10%, transparent)}}.bg-pb-success\/15{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success\/15{background-color:color-mix(in oklab, var(--pb-success) 15%, transparent)}}.bg-pb-surface,.bg-pb-surface\/30{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/30{background-color:color-mix(in oklab, var(--pb-bg-surface) 30%, transparent)}}.bg-pb-surface\/40{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/40{background-color:color-mix(in oklab, var(--pb-bg-surface) 40%, transparent)}}.bg-pb-surface\/50{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/50{background-color:color-mix(in oklab, var(--pb-bg-surface) 50%, transparent)}}.bg-pb-surface\/60{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/60{background-color:color-mix(in oklab, var(--pb-bg-surface) 60%, transparent)}}.bg-pb-surface\/80{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/80{background-color:color-mix(in oklab, var(--pb-bg-surface) 80%, transparent)}}.bg-pb-text-dim{background-color:var(--pb-text-dim)}.bg-pb-warning{background-color:var(--pb-warning)}.bg-pb-warning-dim,.bg-pb-warning-dim\/45{background-color:var(--pb-warning-dim)}@supports (color:color-mix(in lab, red, red)){.bg-pb-warning-dim\/45{background-color:color-mix(in oklab, var(--pb-warning-dim) 45%, transparent)}}.bg-pb-warning\/5{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.bg-pb-warning\/5{background-color:color-mix(in oklab, var(--pb-warning) 5%, transparent)}}.bg-pb-warning\/10{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.bg-pb-warning\/10{background-color:color-mix(in oklab, var(--pb-warning) 10%, transparent)}}.bg-slate-950\/70{background-color:#020618b3}@supports (color:color-mix(in lab, red, red)){.bg-slate-950\/70{background-color:color-mix(in oklab, var(--color-slate-950) 70%, transparent)}}.bg-transparent{background-color:#0000}.object-contain{object-fit:contain}.object-cover{object-fit:cover}.object-top{object-position:top}.\!p-0{padding:0!important}.p-0{padding:0}.p-0\.5{padding:calc(var(--spacing) * .5)}.p-1{padding:var(--spacing)}.p-2{padding:calc(var(--spacing) * 2)}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.\!px-0{padding-inline:0!important}.\!px-2\.5{padding-inline:calc(var(--spacing) * 2.5)!important}.\!px-3{padding-inline:calc(var(--spacing) * 3)!important}.\!px-4{padding-inline:calc(var(--spacing) * 4)!important}.px-0{padding-inline:0}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.\!py-0{padding-block:0!important}.\!py-1\.5{padding-block:calc(var(--spacing) * 1.5)!important}.\!py-2{padding-block:calc(var(--spacing) * 2)!important}.\!py-2\.5{padding-block:calc(var(--spacing) * 2.5)!important}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-\[3px\]{padding-block:3px}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-1{padding-top:var(--spacing)}.pt-1\.5{padding-top:calc(var(--spacing) * 1.5)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-5{padding-top:calc(var(--spacing) * 5)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-12{padding-top:calc(var(--spacing) * 12)}.pr-1{padding-right:var(--spacing)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-4{padding-right:calc(var(--spacing) * 4)}.\!pb-0{padding-bottom:0!important}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pl-5{padding-left:calc(var(--spacing) * 5)}.pl-6{padding-left:calc(var(--spacing) * 6)}.\!text-left{text-align:left!important}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-top{vertical-align:top}.font-display{font-family:Bricolage Grotesque,sans-serif}.font-mono{font-family:JetBrains Mono,Fira Code,monospace}.font-sans{font-family:DM Sans,sans-serif}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.72rem\]{font-size:.72rem}.text-\[0\.78rem\]{font-size:.78rem}.text-\[0\.82rem\]{font-size:.82rem}.text-\[0\.88rem\]{font-size:.88rem}.text-\[9px\]{font-size:9px}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.leading-4{--tw-leading:calc(var(--spacing) * 4);line-height:calc(var(--spacing) * 4)}.leading-5{--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5)}.leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.leading-7{--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7)}.leading-\[1\.5\]{--tw-leading:1.5;line-height:1.5}.leading-\[1\.6\]{--tw-leading:1.6;line-height:1.6}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-black{--tw-font-weight:var(--font-weight-black);font-weight:var(--font-weight-black)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-extrabold{--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.2em\]{--tw-tracking:.2em;letter-spacing:.2em}.tracking-\[0\.12em\]{--tw-tracking:.12em;letter-spacing:.12em}.tracking-\[0\.14em\]{--tw-tracking:.14em;letter-spacing:.14em}.tracking-\[0\.16em\]{--tw-tracking:.16em;letter-spacing:.16em}.tracking-\[0\.18em\]{--tw-tracking:.18em;letter-spacing:.18em}.tracking-\[0\.22em\]{--tw-tracking:.22em;letter-spacing:.22em}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-normal{white-space:normal}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.\!text-pb-text{color:var(--pb-text-primary)!important}.\!text-pb-warning{color:var(--pb-warning)!important}.text-pb-border{color:var(--pb-border)}.text-pb-brand{color:var(--pb-brand)}.text-pb-error{color:var(--pb-error)}.text-pb-info{color:var(--pb-info)}.text-pb-interactive{color:var(--pb-interactive)}.text-pb-purple{color:var(--pb-purple)}.text-pb-success{color:var(--pb-success)}.text-pb-text{color:var(--pb-text-primary)}.text-pb-text-dim{color:var(--pb-text-dim)}.text-pb-text-inverse{color:var(--pb-text-inverse)}.text-pb-text-sec{color:var(--pb-text-secondary)}.text-pb-warning,.text-pb-warning\/70{color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.text-pb-warning\/70{color:color-mix(in oklab, var(--pb-warning) 70%, transparent)}}.text-red-300{color:var(--color-red-300)}.text-white{color:var(--color-white)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.no-underline{text-decoration-line:none}.underline{text-decoration-line:underline}.underline-offset-4{text-underline-offset:4px}.accent-pb-interactive{accent-color:var(--pb-interactive)}.opacity-0{opacity:0}.opacity-25{opacity:.25}.opacity-30{opacity:.3}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-90{opacity:.9}.opacity-100{opacity:1}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur-\[1px\]{--tw-backdrop-blur:blur(1px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-xs{--tw-backdrop-blur:blur(var(--blur-xs));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-none{transition-property:none}.delay-75{transition-delay:75ms}.duration-75{--tw-duration:75ms;transition-duration:75ms}.duration-100{--tw-duration:.1s;transition-duration:.1s}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-500{--tw-duration:.5s;transition-duration:.5s}.ease-in{--tw-ease:var(--ease-in);transition-timing-function:var(--ease-in)}.ease-linear{--tw-ease:linear;transition-timing-function:linear}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}@media (hover:hover){.group-hover\:border-pb-text-sec:is(:where(.group):hover *){border-color:var(--pb-text-secondary)}.group-hover\:text-pb-interactive:is(:where(.group):hover *){color:var(--pb-interactive)}.group-hover\:text-pb-text:is(:where(.group):hover *){color:var(--pb-text-primary)}.group-hover\:text-pb-text-sec:is(:where(.group):hover *){color:var(--pb-text-secondary)}.group-hover\:opacity-40:is(:where(.group):hover *){opacity:.4}}.placeholder\:text-pb-text-dim::placeholder{color:var(--pb-text-dim)}.placeholder\:text-pb-text-sec::placeholder{color:var(--pb-text-secondary)}.first\:pt-0:first-child{padding-top:0}.last\:mb-0:last-child{margin-bottom:0}.last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.last\:pb-0:last-child{padding-bottom:0}@media (hover:hover){.hover\:\!border-pb-warning\/45:hover{border-color:var(--pb-warning)!important}@supports (color:color-mix(in lab, red, red)){.hover\:\!border-pb-warning\/45:hover{border-color:color-mix(in oklab, var(--pb-warning) 45%, transparent)!important}}.hover\:border-pb-border-hover:hover{border-color:var(--pb-border-hover)}.hover\:border-pb-border-strong:hover{border-color:var(--pb-border-strong)}.hover\:border-pb-error\/40:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-error\/40:hover{border-color:color-mix(in oklab, var(--pb-error) 40%, transparent)}}.hover\:border-pb-info\/40:hover{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-info\/40:hover{border-color:color-mix(in oklab, var(--pb-info) 40%, transparent)}}.hover\:border-pb-purple\/40:hover{border-color:var(--pb-purple)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-purple\/40:hover{border-color:color-mix(in oklab, var(--pb-purple) 40%, transparent)}}.hover\:border-pb-success\/40:hover{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-success\/40:hover{border-color:color-mix(in oklab, var(--pb-success) 40%, transparent)}}.hover\:border-pb-warning\/40:hover{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-warning\/40:hover{border-color:color-mix(in oklab, var(--pb-warning) 40%, transparent)}}.hover\:\!bg-pb-warning-dim:hover{background-color:var(--pb-warning-dim)!important}.hover\:\!bg-transparent:hover{background-color:#0000!important}.hover\:bg-pb-card:hover{background-color:var(--pb-bg-card)}.hover\:bg-pb-card-hover:hover,.hover\:bg-pb-card-hover\/20:hover{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-card-hover\/20:hover{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 20%, transparent)}}.hover\:bg-pb-card-hover\/45:hover{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-card-hover\/45:hover{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 45%, transparent)}}.hover\:bg-pb-error-dim:hover{background-color:var(--pb-error-dim)}.hover\:bg-pb-error\/25:hover{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-error\/25:hover{background-color:color-mix(in oklab, var(--pb-error) 25%, transparent)}}.hover\:bg-pb-info-dim:hover{background-color:var(--pb-info-dim)}.hover\:bg-pb-interactive-hover:hover{background-color:var(--pb-interactive-hover)}.hover\:bg-pb-interactive\/10:hover{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-interactive\/10:hover{background-color:color-mix(in oklab, var(--pb-interactive) 10%, transparent)}}.hover\:bg-pb-interactive\/25:hover{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-interactive\/25:hover{background-color:color-mix(in oklab, var(--pb-interactive) 25%, transparent)}}.hover\:bg-pb-purple-dim:hover{background-color:var(--pb-purple-dim)}.hover\:bg-pb-success-dim:hover{background-color:var(--pb-success-dim)}.hover\:bg-pb-success\/25:hover{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-success\/25:hover{background-color:color-mix(in oklab, var(--pb-success) 25%, transparent)}}.hover\:bg-pb-warning-dim:hover{background-color:var(--pb-warning-dim)}.hover\:\!text-pb-text:hover{color:var(--pb-text-primary)!important}.hover\:\!text-pb-warning:hover{color:var(--pb-warning)!important}.hover\:text-pb-error:hover{color:var(--pb-error)}.hover\:text-pb-interactive:hover{color:var(--pb-interactive)}.hover\:text-pb-interactive-hover:hover{color:var(--pb-interactive-hover)}.hover\:text-pb-text:hover{color:var(--pb-text-primary)}.hover\:text-pb-text-sec:hover{color:var(--pb-text-secondary)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-95:hover{opacity:.95}.hover\:opacity-100:hover{opacity:1}.hover\:brightness-90:hover{--tw-brightness:brightness(90%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}}.focus\:border-transparent:focus{border-color:#0000}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-pb-border-hover:focus{--tw-ring-color:var(--pb-border-hover)}.focus\:ring-pb-error:focus{--tw-ring-color:var(--pb-error)}.focus\:ring-pb-interactive:focus{--tw-ring-color:var(--pb-interactive)}.focus\:ring-pb-warning:focus{--tw-ring-color:var(--pb-warning)}.focus\:ring-offset-0:focus{--tw-ring-offset-width:0px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-pb-card:focus{--tw-ring-offset-color:var(--pb-bg-card)}.focus\:outline-hidden:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.focus\:outline-hidden:focus{outline-offset:2px;outline:2px solid #0000}}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\:cursor-default:disabled{cursor:default}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:cursor-wait:disabled{cursor:wait}.disabled\:opacity-20:disabled{opacity:.2}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.disabled\:opacity-100:disabled{opacity:1}@media (min-width:40rem){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:inline{display:inline}.sm\:table-cell{display:table-cell}.sm\:max-h-\[90vh\]{max-height:90vh}.sm\:w-14{width:calc(var(--spacing) * 14)}.sm\:w-44{width:calc(var(--spacing) * 44)}.sm\:w-48{width:calc(var(--spacing) * 48)}.sm\:w-56{width:calc(var(--spacing) * 56)}.sm\:w-auto{width:auto}.sm\:max-w-xs{max-width:var(--container-xs)}.sm\:min-w-\[760px\]{min-width:760px}.sm\:shrink-0{flex-shrink:0}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:grid-cols-\[8rem_minmax\(0\,1fr\)\]{grid-template-columns:8rem minmax(0,1fr)}.sm\:grid-cols-\[9rem_minmax\(0\,1fr\)\]{grid-template-columns:9rem minmax(0,1fr)}.sm\:grid-cols-\[minmax\(0\,1fr\)_auto_minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,1fr) auto minmax(0,1fr)}.sm\:grid-cols-\[minmax\(0\,1fr\)_minmax\(0\,1\.4fr\)_auto\]{grid-template-columns:minmax(0,1fr) minmax(0,1.4fr) auto}.sm\:grid-cols-\[minmax\(0\,3fr\)_minmax\(0\,2fr\)\]{grid-template-columns:minmax(0,3fr) minmax(0,2fr)}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:items-end{align-items:flex-end}.sm\:items-start{align-items:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:justify-end{justify-content:flex-end}.sm\:gap-3{gap:calc(var(--spacing) * 3)}.sm\:self-auto{align-self:auto}.sm\:rounded-xl{border-radius:var(--radius-xl)}.sm\:p-4{padding:calc(var(--spacing) * 4)}.sm\:p-5{padding:calc(var(--spacing) * 5)}.sm\:p-6{padding:calc(var(--spacing) * 6)}.sm\:p-7{padding:calc(var(--spacing) * 7)}.sm\:px-5{padding-inline:calc(var(--spacing) * 5)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:pt-5{padding-top:calc(var(--spacing) * 5)}.sm\:pt-6{padding-top:calc(var(--spacing) * 6)}.sm\:pt-7{padding-top:calc(var(--spacing) * 7)}.sm\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}}@media (min-width:48rem){.md\:block{display:block}.md\:flex{display:flex}.md\:table-cell{display:table-cell}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:grid-cols-\[1fr_auto_1fr_auto\]{grid-template-columns:1fr auto 1fr auto}.md\:grid-cols-\[minmax\(0\,1fr\)_auto_minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,1fr) auto minmax(0,1fr)}.md\:flex-row{flex-direction:row}.md\:items-center{align-items:center}.md\:items-end{align-items:flex-end}.md\:items-start{align-items:flex-start}.md\:justify-between{justify-content:space-between}.md\:justify-center{justify-content:center}}@media (min-width:64rem){.lg\:col-span-2{grid-column:span 2/span 2}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:table-cell{display:table-cell}.lg\:w-52{width:calc(var(--spacing) * 52)}.lg\:w-auto{width:auto}.lg\:max-w-\[11rem\]{max-width:11rem}.lg\:max-w-md{max-width:var(--container-md)}.lg\:min-w-\[920px\]{min-width:920px}.lg\:min-w-\[980px\]{min-width:980px}.lg\:min-w-\[1060px\]{min-width:1060px}.lg\:translate-x-0{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.lg\:grid-cols-\[minmax\(0\,1\.1fr\)_minmax\(280px\,0\.9fr\)\]{grid-template-columns:minmax(0,1.1fr) minmax(280px,.9fr)}.lg\:grid-cols-\[minmax\(0\,1\.15fr\)_minmax\(280px\,0\.85fr\)\]{grid-template-columns:minmax(0,1.15fr) minmax(280px,.85fr)}.lg\:grid-cols-\[minmax\(0\,1\.25fr\)_minmax\(280px\,0\.75fr\)\]{grid-template-columns:minmax(0,1.25fr) minmax(280px,.75fr)}.lg\:grid-cols-\[minmax\(0\,1fr\)_minmax\(16rem\,0\.65fr\)\]{grid-template-columns:minmax(0,1fr) minmax(16rem,.65fr)}.lg\:grid-cols-\[minmax\(0\,1fr\)_minmax\(18rem\,24rem\)\]{grid-template-columns:minmax(0,1fr) minmax(18rem,24rem)}.lg\:grid-cols-\[minmax\(0\,280px\)_minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,280px) minmax(0,1fr)}.lg\:grid-cols-\[minmax\(12rem\,0\.7fr\)_minmax\(18rem\,1\.3fr\)\]{grid-template-columns:minmax(12rem,.7fr) minmax(18rem,1.3fr)}.lg\:grid-cols-\[repeat\(5\,minmax\(0\,1fr\)\)\]{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:flex-col{flex-direction:column}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:items-end{align-items:flex-end}.lg\:items-start{align-items:flex-start}.lg\:justify-between{justify-content:space-between}.lg\:text-right{text-align:right}}@media (min-width:80rem){.xl\:col-span-3{grid-column:span 3/span 3}.xl\:w-32{width:calc(var(--spacing) * 32)}.xl\:w-48{width:calc(var(--spacing) * 48)}.xl\:w-\[34rem\]{width:34rem}.xl\:max-w-sm{max-width:var(--container-sm)}.xl\:min-w-\[30rem\]{min-width:30rem}.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.xl\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.xl\:grid-cols-7{grid-template-columns:repeat(7,minmax(0,1fr))}.xl\:grid-cols-\[minmax\(0\,1\.4fr\)_minmax\(320px\,0\.8fr\)\]{grid-template-columns:minmax(0,1.4fr) minmax(320px,.8fr)}.xl\:grid-cols-\[minmax\(0\,1\.45fr\)_minmax\(17rem\,1fr\)\]{grid-template-columns:minmax(0,1.45fr) minmax(17rem,1fr)}.xl\:grid-cols-\[minmax\(0\,1\.55fr\)_minmax\(320px\,0\.85fr\)\]{grid-template-columns:minmax(0,1.55fr) minmax(320px,.85fr)}.xl\:flex-row{flex-direction:row}.xl\:items-end{align-items:flex-end}.xl\:items-start{align-items:flex-start}.xl\:justify-between{justify-content:space-between}.xl\:justify-end{justify-content:flex-end}}@media (min-width:96rem){.\32 xl\:grid-cols-\[minmax\(0\,1\.45fr\)_minmax\(320px\,0\.75fr\)\]{grid-template-columns:minmax(0,1.45fr) minmax(320px,.75fr)}}}@font-face{font-family:Bricolage Grotesque;font-style:normal;font-weight:800;font-display:swap;src:url(/static/fonts/bricolage-grotesque-800.woff2)format("woff2")}@font-face{font-family:DM Sans;font-style:normal;font-weight:300 700;font-display:swap;src:url(/static/fonts/dm-sans-variable.woff2)format("woff2")}@font-face{font-family:Syne;font-style:normal;font-weight:400;font-display:swap;src:url(/static/fonts/syne-400.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:500;font-display:swap;src:url(/static/fonts/syne-500.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:600;font-display:swap;src:url(/static/fonts/syne-600.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:700;font-display:swap;src:url(/static/fonts/syne-700.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:800;font-display:swap;src:url(/static/fonts/syne-800.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:400;font-display:swap;src:url(/static/fonts/jetbrains-mono-400.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:500;font-display:swap;src:url(/static/fonts/jetbrains-mono-500.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:600;font-display:swap;src:url(/static/fonts/jetbrains-mono-600.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:700;font-display:swap;src:url(/static/fonts/jetbrains-mono-700.ttf)format("truetype")}.no-transitions,.no-transitions *,.no-transitions :before,.no-transitions :after,.boot-no-transitions,.boot-no-transitions *,.boot-no-transitions :before,.boot-no-transitions :after{transition-duration:0s!important}.safe-area-pb{padding-bottom:env(safe-area-inset-bottom,0px)}.skip-link{z-index:100;background:var(--pb-brand);color:var(--pb-text-inverse);border-radius:0 0 .5rem .5rem;padding:.5rem 1rem;font-size:.875rem;font-weight:600;text-decoration:none;transition:top .15s ease-out;position:absolute;top:-100%;left:1rem}.skip-link:focus{outline:2px solid var(--pb-interactive);outline-offset:2px;top:0}input[type=range].range-pb{appearance:none;background:var(--pb-border-hover);cursor:pointer;border-radius:9999px;outline:none;height:6px}input[type=range].range-pb::-webkit-slider-thumb{appearance:none;background:var(--pb-interactive);border:2px solid var(--pb-bg-card);width:18px;height:18px;box-shadow:var(--pb-shadow-1);cursor:pointer;border-radius:50%}input[type=range].range-pb::-moz-range-thumb{background:var(--pb-interactive);border:2px solid var(--pb-bg-card);width:18px;height:18px;box-shadow:var(--pb-shadow-1);cursor:pointer;border-radius:50%}.scrollbar-hidden{scrollbar-width:none;-ms-overflow-style:none}.scrollbar-hidden::-webkit-scrollbar{display:none}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:var(--pb-bg-surface)}::-webkit-scrollbar-thumb{background:var(--pb-border-strong);border-radius:4px}::-webkit-scrollbar-thumb:hover{background:var(--pb-text-dim)}*{scrollbar-width:thin;scrollbar-color:var(--pb-border-strong) var(--pb-bg-surface)}.htmx-indicator{opacity:0;transition:opacity .2s ease-in}.htmx-request .htmx-indicator,.htmx-request.htmx-indicator{opacity:1}#content[data-page-swap-phase=leaving],#content[data-page-swap-phase=entering]{pointer-events:none}#content[data-detail-history-hidden=true]{opacity:0;pointer-events:none}@media (prefers-reduced-motion:reduce){html:focus-within{scroll-behavior:auto}*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}#content{transition:none}}@keyframes toast-enter{0%{opacity:0;transform:translate(100%)}to{opacity:1;transform:translate(0)}}@keyframes toast-exit{0%{opacity:1;transform:translate(0)}to{opacity:0;transform:translate(100%)}}.toast-enter{animation:.3s ease-out forwards toast-enter}.toast-exit{animation:.3s ease-in forwards toast-exit}.sidebar-transition{transition:width .2s ease-in-out,transform .2s ease-in-out}.app-sidebar-backdrop{background:var(--pb-bg-overlay)}@supports (color:color-mix(in lab, red, red)){.app-sidebar-backdrop{background:color-mix(in srgb, var(--pb-bg-overlay) 96%, transparent)}}.app-sidebar-shell{border-right:1px solid var(--pb-border);background:var(--pb-bg-surface);box-shadow:none;overflow:visible}.app-sidebar-brand{background:0 0;padding:1rem 1rem .75rem}.app-sidebar-brand-collapsed{background:0 0;justify-content:center;padding:1rem 0 .75rem;display:flex}.app-sidebar-brand-copy-collapsed{display:none!important}.app-sidebar-brand-card{align-items:center;gap:.75rem;padding:0;transition:color .18s,transform .18s;display:flex}.app-sidebar-brand-card-collapsed{justify-content:center;width:auto;min-width:0;min-height:0;padding:0}.app-sidebar-brand:hover .app-sidebar-brand-card{transform:translateY(-1px)}.app-sidebar-brand-collapsed:hover .app-sidebar-brand-card{transform:none}.app-sidebar-brand-mark{flex-shrink:0;justify-content:center;align-items:center;width:2.25rem;height:2.25rem;display:flex}.app-sidebar-brand-copy{white-space:nowrap;flex-direction:column;min-width:0;display:flex;overflow:hidden}.app-sidebar-brand-title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1.1rem;font-weight:800;line-height:1}.app-sidebar-brand-version{color:var(--pb-text-tertiary);margin-top:2px;font-family:JetBrains Mono,monospace;font-size:.58rem}.app-sidebar-nav{padding:.25rem .625rem 1rem}.app-sidebar-nav-collapsed{padding:.25rem 0 1rem;overflow:visible!important}.app-sidebar-link{color:var(--pb-text-secondary);border:0;border-radius:.5rem;align-items:center;gap:.625rem;margin:1px 0;padding:.4375rem .625rem;font-size:.82rem;font-weight:500;transition:background-color .18s,color .18s,transform .18s;display:flex;position:relative}.app-sidebar-link-collapsed{justify-content:center;gap:0;width:2.75rem;margin-inline:auto;padding:.5rem}.app-sidebar-link-copy-collapsed{display:none!important}.app-sidebar-link:hover{background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.app-sidebar-link:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.app-sidebar-link:hover{color:var(--pb-text-primary)}.app-sidebar-link-collapsed:hover{transform:none}.app-sidebar-link-active{background:var(--pb-selected);color:var(--pb-interactive);font-weight:600}.app-sidebar-link-active:before{content:"";background:var(--pb-interactive);border-radius:0 2px 2px 0;width:3px;position:absolute;top:6px;bottom:6px;left:0}.app-sidebar-link-icon{width:1.25rem;height:1.25rem;color:inherit;flex-shrink:0;justify-content:center;align-items:center;transition:color .18s;display:flex}.app-sidebar-link-copy{flex:1;justify-content:space-between;align-items:center;gap:.75rem;min-width:0;display:flex}.app-sidebar-link-title{text-overflow:ellipsis;white-space:nowrap;min-width:0;font-size:.82rem;font-weight:inherit;overflow:hidden}.app-sidebar-badge-slot{flex-shrink:0;justify-content:flex-end;align-items:center;min-width:1.125rem;display:flex}.app-sidebar-badge-slot .count-badge{border-radius:6px;min-width:18px;min-height:auto;padding:1px 6px;font-family:JetBrains Mono,monospace;font-size:.6rem;font-weight:700;line-height:1.1}.app-sidebar-link-collapsed .app-sidebar-badge-slot{min-width:0;position:absolute;top:2px;right:2px}.app-sidebar-link-collapsed .app-sidebar-badge-slot .count-badge{border-radius:4px;min-width:14px;padding:0 4px;font-size:.52rem}.app-sidebar-section{margin:1rem 0 0}.app-sidebar-section-collapsed{width:3rem;margin:.75rem auto .375rem}.app-sidebar-section-label{letter-spacing:.14em;text-transform:uppercase;color:var(--pb-text-tertiary);align-items:center;gap:.375rem;margin-bottom:.25rem;padding:0 .5rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700;display:flex}.app-sidebar-section-label-collapsed{display:none!important}.app-sidebar-section-label:after{content:"";background:var(--pb-border-subtle);flex:auto;height:1px}.app-sidebar-section-rule{border-color:var(--pb-border-subtle);width:1.5rem;margin:.5rem auto 0}.app-sidebar-toggle{z-index:70;border:1px solid var(--pb-border-hover);background:var(--pb-bg-card);width:1.75rem;height:1.75rem;color:var(--pb-text-secondary);pointer-events:auto;box-shadow:var(--pb-shadow-1);border-radius:999px;justify-content:center;align-items:center;transition:border-color .18s,background-color .18s,color .18s,box-shadow .18s;position:absolute;top:72px;right:-.75rem}.app-sidebar-toggle[data-tip]{position:absolute}.app-sidebar-toggle:hover{border-color:var(--pb-interactive);background:var(--pb-bg-card);color:var(--pb-interactive);box-shadow:var(--pb-shadow-2)}[data-tip]{--pb-tooltip-max-width:min(360px, calc(100vw - 32px), calc(100dvw - 32px));position:relative}.tooltips-overlay-enabled [data-tip]:hover:after,.tooltips-overlay-enabled [data-tip]:focus-visible:after{content:none;display:none}[data-tip]:hover:after,[data-tip]:focus-visible:after{content:attr(data-tip);width:max-content;max-width:var(--pb-tooltip-max-width);background:var(--pb-bg-card);border:1px solid var(--pb-border-hover);color:var(--pb-text-primary);white-space:normal;overflow-wrap:break-word;box-shadow:var(--pb-shadow-overlay);z-index:50;pointer-events:none;border-radius:8px;padding:6px 10px;font-size:11px;line-height:1.4;animation:.15s ease-out tip-in;position:absolute;bottom:calc(100% + 6px);left:0}.app-tooltip-host{z-index:140;pointer-events:none;position:fixed;inset:0}.app-tooltip-overlay{width:max-content;max-width:var(--pb-tooltip-max-width,min(360px, calc(100vw - 32px), calc(100dvw - 32px)));background:var(--pb-bg-card);border:1px solid var(--pb-border-hover);color:var(--pb-text-primary);white-space:normal;overflow-wrap:break-word;box-shadow:var(--pb-shadow-overlay);opacity:0;visibility:hidden;will-change:left, top, opacity;border-radius:8px;padding:6px 10px;font-size:11px;line-height:1.4;transition:opacity .12s ease-out,visibility .12s ease-out;position:absolute;top:0;left:0;transform:translate(0)}.app-tooltip-host[data-visible=true] .app-tooltip-overlay{opacity:1;visibility:visible}[data-search-field-clear][data-tip]{position:absolute}[data-tip-pos=left]:hover:after,[data-tip-pos=left]:focus-visible:after{animation:.15s ease-out tip-in-left;inset:50% calc(100% + 6px) auto auto;transform:translateY(-50%)}[data-tip-pos=right]:hover:after,[data-tip-pos=right]:focus-visible:after{animation:.15s ease-out tip-in-right;inset:50% auto auto calc(100% + 6px);transform:translateY(-50%)}[data-tip-pos=bottom]:hover:after,[data-tip-pos=bottom]:focus-visible:after{animation:.15s ease-out tip-in-bottom;top:calc(100% + 6px);bottom:auto;left:0}[data-tip-size=wide]:hover:after,[data-tip-size=wide]:focus-visible:after{max-width:var(--pb-tooltip-max-width,min(480px, calc(100vw - 32px), calc(100dvw - 32px)))}[data-tip-size=narrow]:hover:after,[data-tip-size=narrow]:focus-visible:after{max-width:var(--pb-tooltip-max-width,min(220px, calc(100vw - 32px), calc(100dvw - 32px)))}.app-header-icon-tip:hover:after,.app-header-icon-tip:focus-visible:after{text-align:center;min-width:7.25rem}.tooltip-wrap{position:relative}.tooltip-panel{z-index:50;visibility:hidden;opacity:0;pointer-events:none;background:var(--pb-bg-card);border:1px solid var(--pb-border-hover);width:max-content;max-width:360px;color:var(--pb-text-primary);white-space:normal;overflow-wrap:break-word;box-shadow:var(--pb-shadow-overlay);border-radius:.5rem;padding:.5rem .75rem;font-size:.75rem;line-height:1.4;transition:opacity .15s ease-out,transform .15s ease-out,visibility .15s ease-out;position:absolute}.tooltip-wrap:hover>.tooltip-panel,.tooltip-wrap:focus-within>.tooltip-panel{visibility:visible;opacity:1}.tooltip-panel-top{bottom:calc(100% + .5rem);left:0;transform:translateY(4px)}.tooltip-wrap:hover>.tooltip-panel-top,.tooltip-wrap:focus-within>.tooltip-panel-top{transform:translateY(0)}.tooltip-panel-left{top:50%;right:calc(100% + .5rem);transform:translate(4px,-50%)}.tooltip-wrap:hover>.tooltip-panel-left,.tooltip-wrap:focus-within>.tooltip-panel-left{transform:translateY(-50%)}.tooltip-panel-right{top:50%;left:calc(100% + .5rem);transform:translate(-4px,-50%)}.tooltip-wrap:hover>.tooltip-panel-right,.tooltip-wrap:focus-within>.tooltip-panel-right{transform:translateY(-50%)}.tooltip-panel-bottom{top:calc(100% + .5rem);left:0;transform:translateY(-4px)}.tooltip-wrap:hover>.tooltip-panel-bottom,.tooltip-wrap:focus-within>.tooltip-panel-bottom{transform:translateY(0)}.tooltip-panel-nowrap{white-space:nowrap}.tooltip-panel-wide{max-width:500px}@keyframes tip-in{0%{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}@keyframes tip-in-left{0%{opacity:0;transform:translate(4px,-50%)}to{opacity:1;transform:translateY(-50%)}}@keyframes tip-in-right{0%{opacity:0;transform:translate(-4px,-50%)}to{opacity:1;transform:translateY(-50%)}}@keyframes tip-in-bottom{0%{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}.log-terminal{contain:inline-size;background:var(--pb-bg-surface);width:100%;min-width:0;max-width:100%;overflow-x:hidden}@supports (color:color-mix(in lab, red, red)){.log-terminal{background:color-mix(in srgb, var(--pb-bg-surface) 82%, var(--pb-bg-base))}}.log-terminal{background-image:repeating-linear-gradient(0deg, transparent, transparent 2px, var(--pb-border-subtle) 2px, var(--pb-border-subtle) 4px)}@supports (color:color-mix(in lab, red, red)){.log-terminal{background-image:repeating-linear-gradient(0deg, transparent, transparent 2px, color-mix(in srgb, var(--pb-border-subtle) 32%, transparent) 2px, color-mix(in srgb, var(--pb-border-subtle) 32%, transparent) 4px)}}.log-line{box-sizing:border-box;border-left:3px solid #0000;width:100%;min-width:0;max-width:100%;overflow:hidden}.log-line:hover{background:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.log-line:hover{background:color-mix(in srgb, var(--pb-bg-card-hover) 36%, transparent)}}.log-line[data-level=error],.log-line[data-level=critical]{border-left-color:var(--pb-error);background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.log-line[data-level=error],.log-line[data-level=critical]{background:color-mix(in srgb, var(--pb-error) 12%, transparent)}}.log-line[data-level=warning]{border-left-color:var(--pb-warning)}.log-line[data-level=info]{border-left-color:var(--pb-info)}.log-line[data-level=debug]{border-left-color:var(--pb-text-dim)}.log-line.expanded{background:var(--pb-bg-card-hover)!important}@supports (color:color-mix(in lab, red, red)){.log-line.expanded{background:color-mix(in srgb, var(--pb-bg-card-hover) 54%, transparent)!important}}.log-detail{box-sizing:border-box;max-width:calc(100% - 140px);overflow:hidden}.log-path{overflow-wrap:anywhere}.log-badge{letter-spacing:.08em;text-transform:uppercase;text-align:center;border-radius:3px;min-width:38px;padding:1px 5px;font-size:9px;font-weight:700;display:inline-block}.badge-debug{color:var(--pb-text-dim);background:var(--pb-text-dim)}@supports (color:color-mix(in lab, red, red)){.badge-debug{background:color-mix(in srgb, var(--pb-text-dim) 16%, transparent)}}.badge-info{color:var(--pb-info);background:var(--pb-info-dim)}.badge-warning{color:var(--pb-warning);background:var(--pb-warning-dim)}.badge-error{color:var(--pb-error);background:var(--pb-error-dim)}.badge-critical{color:var(--pb-text-inverse);background:var(--pb-error)}.live-dot{background:var(--pb-success);border-radius:50%;width:6px;height:6px;animation:2s ease-in-out infinite live-pulse}@keyframes live-pulse{0%,to{opacity:1;box-shadow:0 0 0 0 color-mix(in srgb, var(--pb-success) 42%, transparent)}50%{opacity:.6;box-shadow:0 0 6px 2px color-mix(in srgb, var(--pb-success) 24%, transparent)}}.json-key{color:var(--pb-info)}.json-str{color:var(--pb-success)}.json-num{color:var(--pb-warning)}.json-bool{color:var(--pb-purple)}.json-null{color:var(--pb-text-dim)}.downloads-page-shell{width:100%}.downloads-view{width:100%;max-width:none;padding:0 0 var(--pb-page-footer-clearance);margin:0}.downloads-header{display:block}.downloads-header-main{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:1.25rem;width:100%;display:flex}.downloads-header-summary{flex-wrap:wrap;flex:auto;align-items:flex-start;gap:1.5rem;min-width:0;display:flex}.downloads-header-copy{min-width:0}.downloads-header-actions{flex:none;justify-content:flex-end;align-items:flex-start;margin-left:auto;display:flex}.downloads-title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1}.downloads-title span{color:var(--pb-brand)}.downloads-subtitle{color:var(--pb-text-tertiary);letter-spacing:.01em;margin-top:.25rem;font-size:.78rem}.downloads-gauges{align-items:flex-end;gap:1rem;display:flex}.downloads-gauge{text-align:center}.downloads-gauge-ring{width:56px;height:56px;margin:0 auto;position:relative}.downloads-gauge-ring svg{transform:rotate(-90deg)}.downloads-gauge-bg{fill:none;stroke:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.downloads-gauge-bg{stroke:color-mix(in srgb, var(--pb-text-secondary) 10%, transparent)}}.downloads-gauge-bg{stroke-width:4.5px}.downloads-gauge-fill{fill:none;stroke-width:4.5px;stroke-linecap:round;transition:stroke-dashoffset .6s}.downloads-gauge-fill-success{stroke:var(--pb-status-success)}.downloads-gauge-fill-info{stroke:var(--pb-status-info)}.downloads-gauge-fill-warning{stroke:var(--pb-status-warning)}.downloads-gauge-fill-error{stroke:var(--pb-status-danger)}.downloads-gauge-fill-muted{stroke:var(--pb-text-dim)}.downloads-gauge-value{color:var(--pb-text-primary);justify-content:center;align-items:center;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700;display:flex;position:absolute;inset:0}.downloads-gauge-value-success{color:var(--pb-status-success)}.downloads-gauge-value-info{color:var(--pb-status-info)}.downloads-gauge-value-warning{color:var(--pb-status-warning)}.downloads-gauge-value-error{color:var(--pb-status-danger)}.downloads-gauge-value-muted{color:var(--pb-text-dim)}.downloads-gauge-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);margin-top:.25rem;font-size:.6rem;font-weight:600}.downloads-tab-rail{border:1px solid var(--pb-border);background:0 0;border-radius:10px;align-self:flex-start;display:inline-flex;overflow:hidden}.downloads-tab-btn{letter-spacing:.06em;text-transform:uppercase;color:var(--pb-text-dim);background:0 0;border:0;padding:.5rem 1.25rem;font-family:Syne,sans-serif;font-size:.78rem;font-weight:700;transition:background-color .14s,color .14s}.downloads-tab-btn+.downloads-tab-btn{border-left:1px solid var(--pb-border)}.downloads-tab-btn:hover{color:var(--pb-text);background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.downloads-tab-btn:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.downloads-tab-btn.is-active{background:var(--pb-interactive);color:var(--pb-text-inverse)}.downloads-panel-stack{gap:1.25rem;margin-top:.875rem;display:grid}.import-history-page-shell{margin-top:0}.downloads-section{gap:.625rem;display:grid}.downloads-section-label{border-bottom:1px solid var(--pb-border-subtle);letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);padding-bottom:.375rem;font-family:Syne,sans-serif;font-size:.62rem;font-weight:700}.downloads-table-wrap{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);border-radius:14px;position:relative;overflow:visible;box-shadow:0 1px 3px #1e1a170f,0 1px 2px #1e1a170a}.downloads-table-wrap.is-clipped{overflow:hidden}.downloads-table{border-collapse:collapse;width:100%}.downloads-table th{border-bottom:2px solid var(--pb-border);background:var(--pb-bg-surface);text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);padding:.5625rem .875rem;font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.downloads-table td{border-bottom:1px solid var(--pb-border-subtle);color:var(--pb-text);vertical-align:middle;padding:.6875rem .875rem;font-size:.82rem;transition:background-color .1s}.downloads-table tbody:last-child tr:last-child td{border-bottom:0}.downloads-table tr:not(.table-detail-row):hover td{background:var(--pb-surface-selected)}.downloads-table th.is-center,.downloads-table td.is-center{text-align:center}.downloads-table th.is-right,.downloads-table td.is-right{text-align:right}.issue-search-results-table-wrap{max-height:62vh;overflow-y:auto}.issue-search-results-table thead th{z-index:1;position:sticky;top:0}.issue-search-sort-indicator{color:var(--pb-interactive)}.issue-search-release-link{color:var(--pb-text);transition:color .14s}.issue-search-release-link:hover{color:var(--pb-interactive)}.issue-search-release-title{text-overflow:ellipsis;white-space:nowrap;max-width:320px;color:inherit;font-weight:600;display:block;overflow:hidden}.issue-search-release-title-muted{color:var(--pb-text-dim)}.issue-search-release-match{color:var(--pb-text-dim);margin-top:.125rem;font-size:.64rem}.issue-search-action-row{justify-content:flex-end;align-items:center;gap:.25rem;display:inline-flex}.issue-search-rejected-row td{opacity:.45}.issue-search-rejected-label{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.64rem}.issue-search-empty-state{text-align:center;padding:2.5rem 1.25rem}.issue-search-empty-icon{width:2.25rem;height:2.25rem;color:var(--pb-text-dim);margin-inline:auto}.issue-search-empty-title{color:var(--pb-text);margin-top:.625rem;font-size:.85rem;font-weight:600}.issue-search-empty-copy{color:var(--pb-text-sec);margin-top:.25rem;font-size:.78rem}.downloads-release-name{max-width:260px;color:var(--pb-text);font-weight:600;display:block}.downloads-release-meta{color:var(--pb-text-dim);margin-top:.2rem;font-size:.72rem}.downloads-issue-link{color:var(--pb-interactive);transition:color .14s}.downloads-issue-link:hover{color:var(--pb-interactive-hover)}.table-mono-dim,td.table-mono-dim,.downloads-mono-dim-cell,td.downloads-mono-dim-cell{font-family:JetBrains Mono,monospace;font-size:.72rem}.table-mono-dim{color:var(--pb-text-dim)}.downloads-table td.downloads-mono-cell,.downloads-table td.downloads-mono-dim-cell,.downloads-table .downloads-mono-cell,.downloads-table .downloads-mono-dim-cell{font-variant-numeric:tabular-nums;font-family:JetBrains Mono,monospace}.downloads-table td.downloads-mono-cell,.downloads-table .downloads-mono-cell{color:var(--pb-text-secondary);font-size:.75rem;font-weight:400;line-height:1.2}.downloads-table td.downloads-mono-dim-cell,.downloads-table .downloads-mono-dim-cell{color:var(--pb-text-dim);font-size:.72rem}.downloads-muted-text{color:var(--pb-text-dim)}.downloads-progress-cell{align-items:center;gap:.375rem;display:inline-flex}.downloads-progress-track{border:1px solid var(--pb-border-subtle);background:var(--pb-text-secondary);border-radius:2px;width:100px;height:6px;display:inline-block;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.downloads-progress-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.downloads-progress-track{vertical-align:middle}.downloads-progress-fill{height:100%;display:block;position:relative}.downloads-progress-fill.is-blue{background:linear-gradient(90deg, var(--pb-info), var(--pb-info))}@supports (color:color-mix(in lab, red, red)){.downloads-progress-fill.is-blue{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-info) 45%, transparent), var(--pb-info))}}.downloads-progress-fill.is-amber{background:linear-gradient(90deg, var(--pb-warning), var(--pb-warning))}@supports (color:color-mix(in lab, red, red)){.downloads-progress-fill.is-amber{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-warning) 45%, transparent), var(--pb-warning))}}.downloads-progress-fill.is-green{background:linear-gradient(90deg, var(--pb-success), var(--pb-success))}@supports (color:color-mix(in lab, red, red)){.downloads-progress-fill.is-green{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-success) 45%, transparent), var(--pb-success))}}.downloads-progress-fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}@keyframes downloads-progress-indeterminate{0%{transform:translate(-130%)}to{transform:translate(280%)}}.downloads-progress-fill.is-indeterminate,.app-progress-fill.is-indeterminate{will-change:transform;animation:1.25s ease-in-out infinite downloads-progress-indeterminate}@media (prefers-reduced-motion:reduce){.downloads-progress-fill.is-indeterminate,.app-progress-fill.is-indeterminate{opacity:.7;animation:none;transform:translate(60%)}}.downloads-progress-pct{color:var(--pb-info);font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:600}.downloads-progress-pct-warning{color:var(--pb-warning)}.downloads-progress-pct-success{color:var(--pb-success)}.downloads-progress-pct-muted{color:var(--pb-text-dim)}.downloads-led{border-radius:999px;width:8px;height:8px;display:inline-block}.downloads-led-green{background:var(--pb-success);box-shadow:0 0 5px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.downloads-led-green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.downloads-led-blue{background:var(--pb-info);box-shadow:0 0 5px var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.downloads-led-blue{box-shadow:0 0 5px color-mix(in srgb, var(--pb-info) 35%, transparent)}}.downloads-led-amber{background:var(--pb-warning);box-shadow:0 0 5px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.downloads-led-amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-warning) 35%, transparent)}}.downloads-led-off{background:var(--pb-text-dim);opacity:.35}.downloads-action-group{align-items:center;gap:.25rem;display:inline-flex}@media (hover:hover) and (pointer:fine){.downloads-action-group.is-hover-reveal{opacity:0;pointer-events:none;transition:opacity .14s,transform .14s;transform:translateY(2px)}.downloads-table tbody tr:hover .downloads-action-group.is-hover-reveal,.downloads-table tbody tr:focus-within .downloads-action-group.is-hover-reveal{opacity:1;pointer-events:auto;transform:translateY(0)}}.downloads-action-btn{border:1px solid var(--pb-border-subtle);width:28px;height:28px;color:var(--pb-text-dim);background:0 0;border-radius:6px;justify-content:center;align-items:center;transition:border-color .14s,color .14s,background-color .14s;display:inline-flex}.downloads-action-btn svg{width:12px;height:12px}.import-history-action-group{gap:.375rem}.import-history-action-btn{border-radius:8px;flex-shrink:0;width:30px;height:30px}.downloads-action-btn .import-history-action-icon{shape-rendering:geometricprecision;flex-shrink:0;width:12px;height:12px;overflow:visible}.downloads-action-btn .import-history-action-icon--detail{width:14px;height:14px}.downloads-action-btn:hover{border-color:var(--pb-interactive);background:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.downloads-action-btn:hover{background:color-mix(in srgb, var(--pb-info) 10%, transparent)}}.downloads-action-btn:hover{color:var(--pb-interactive)}.downloads-action-btn.is-danger:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.downloads-action-btn.is-danger:hover{border-color:color-mix(in srgb, var(--pb-error) 40%, transparent)}}.downloads-action-btn.is-danger:hover{background:var(--pb-error-dim);color:var(--pb-error)}.downloads-action-btn.is-warn:hover{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.downloads-action-btn.is-warn:hover{border-color:color-mix(in srgb, var(--pb-warning) 40%, transparent)}}.downloads-action-btn.is-warn:hover{background:var(--pb-warning-dim);color:var(--pb-warning)}.downloads-empty-state{text-align:center;padding:2.5rem 1.5rem}.downloads-empty-state.is-compact{padding:1.5rem 1.25rem}.downloads-empty-state.is-history{padding:2rem 1.5rem}.downloads-empty-icon{width:40px;height:40px;color:var(--pb-text-dim);margin:0 auto}.downloads-empty-title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text);margin-top:.75rem;font-family:Syne,sans-serif;font-size:.88rem;font-weight:700}.downloads-empty-copy{color:var(--pb-text-dim);margin-top:.4rem;font-size:.78rem}.intervention-bulk-bar{border-top:1px solid var(--pb-border-subtle);border-bottom:1px solid var(--pb-border-subtle);background:var(--pb-info-bg);flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;padding:.625rem .875rem;display:flex}@supports (color:color-mix(in lab, red, red)){.intervention-bulk-bar{background:color-mix(in srgb, var(--pb-info-bg) 40%, var(--pb-bg-surface))}}.intervention-bulk-left{flex-wrap:wrap;align-items:center;gap:.625rem;display:inline-flex}.intervention-bulk-count{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.72rem}.intervention-bulk-actions{flex-wrap:wrap;align-items:center;gap:.5rem;display:inline-flex}.downloads-footer-strip{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);color:var(--pb-text-dim);border-radius:10px;flex-wrap:wrap;align-items:center;gap:.375rem 1.5rem;margin-top:1.25rem;padding:.625rem 1rem;font-family:JetBrains Mono,monospace;font-size:.7rem;display:flex}.downloads-footer-strip strong{color:var(--pb-text);font-weight:600}.downloads-history-toolbar{z-index:2;border:0;border-bottom:1px solid var(--pb-border);border-top-left-radius:inherit;border-top-right-radius:inherit;background:var(--pb-bg-surface);box-shadow:none;border-bottom-right-radius:0;border-bottom-left-radius:0;position:relative}.downloads-toolbar{border-bottom:1px solid var(--pb-border);background:var(--pb-bg-surface);flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;padding:.625rem .875rem;display:flex}.downloads-history-toolbar>.downloads-toolbar{border-top-left-radius:inherit;border-top-right-radius:inherit;border-bottom-right-radius:0;border-bottom-left-radius:0}.downloads-toolbar-left{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.downloads-toolbar-search,.downloads-toolbar-select{border:1px solid var(--pb-border);background:var(--pb-bg-card);height:30px;color:var(--pb-text);border-radius:8px;outline:none;font-size:.78rem}.downloads-toolbar-search{width:200px;padding:0 .625rem}.downloads-toolbar-select{appearance:none;background-image:url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%2374675B' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");background-position:right 8px center;background-repeat:no-repeat;padding:0 2rem 0 .625rem}[data-theme=dark] .downloads-toolbar-select{background-image:url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%239AAABA' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")}.downloads-toolbar-search:focus,.downloads-toolbar-select:focus{border-color:var(--pb-interactive)}.downloads-clear-btn{background:var(--pb-error);color:var(--pb-text-inverse);white-space:nowrap;border:0;border-radius:8px;align-items:center;gap:.3125rem;padding:.375rem .75rem;font-size:.75rem;font-weight:600;transition:opacity .14s;display:inline-flex}.downloads-clear-btn svg{width:12px;height:12px}.downloads-clear-btn:hover{opacity:.92}.downloads-sort-btn{width:100%;color:inherit;font-family:inherit;font-size:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;text-align:inherit;text-transform:inherit;justify-content:flex-start;align-items:center;gap:.25rem;transition:color .14s;display:inline-flex}.import-review-cv-year-header{width:5.75rem;min-width:5.75rem}.import-review-cv-year-header .downloads-sort-btn{white-space:nowrap}.import-review-cv-year-header .downloads-sort-chevron{flex-shrink:0}.downloads-table th.is-center .downloads-sort-btn{text-align:center;justify-content:center}.downloads-table th.is-right .downloads-sort-btn{text-align:right;justify-content:flex-end}.downloads-sort-btn:hover,.downloads-sort-btn.is-active{color:var(--pb-text)}.downloads-sort-chevron{opacity:.35;width:12px;height:12px}.downloads-sort-btn.is-active .downloads-sort-chevron{opacity:1}.downloads-sort-chevron.is-desc{transform:rotate(180deg)}.downloads-error-row td{border-bottom:1px solid var(--pb-border-subtle);background:var(--pb-error-dim);padding:.5rem .875rem}@supports (color:color-mix(in lab, red, red)){.downloads-error-row td{background:color-mix(in srgb, var(--pb-error-dim) 50%, var(--pb-bg-card))}}.downloads-error-row td{color:var(--pb-error);font-size:.75rem}.downloads-error-content{align-items:flex-start;gap:.5rem;display:flex}.downloads-error-content svg{flex-shrink:0;width:14px;height:14px;margin-top:1px}.search-history-detail-row td{border-bottom:1px solid var(--pb-border-subtle);background:var(--pb-bg-surface);padding:.75rem .875rem}@supports (color:color-mix(in lab, red, red)){.search-history-detail-row td{background:color-mix(in srgb, var(--pb-bg-surface) 70%, var(--pb-bg-card))}}.search-history-detail-card{border:1px solid var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.search-history-detail-card{border:1px solid color-mix(in srgb, var(--pb-border) 78%, transparent)}}.search-history-detail-card{background:var(--pb-bg-card-hover);border-radius:.75rem}@supports (color:color-mix(in lab, red, red)){.search-history-detail-card{background:color-mix(in srgb, var(--pb-bg-card-hover) 55%, transparent)}}.search-history-detail-card{padding:.625rem .75rem}.search-history-detail-label{letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);font-size:10px;font-weight:600}.search-history-diagnostics-shell{border:1px solid var(--pb-warning);overflow:hidden}@supports (color:color-mix(in lab, red, red)){.search-history-diagnostics-shell{border:1px solid color-mix(in srgb, var(--pb-warning) 30%, transparent)}}.search-history-diagnostics-shell{background:var(--pb-warning-dim);border-radius:.75rem}@supports (color:color-mix(in lab, red, red)){.search-history-diagnostics-shell{background:color-mix(in srgb, var(--pb-warning-dim) 35%, transparent)}}.downloads-pagination{border-top:1px solid var(--pb-border-subtle);padding:.5rem .875rem}@media (max-width:1024px){.downloads-header{gap:1rem}.downloads-table-wrap{overflow-x:auto}.downloads-table-wrap.is-clipped{overflow:auto hidden}.downloads-table{min-width:760px}}@media (max-width:640px){.downloads-header-main{gap:.875rem}.downloads-gauges{gap:.625rem}.downloads-toolbar-search{width:100%}}.comic-reader{--pb-reader-canvas:#070b12;--pb-reader-surface:#0d141ef0;--pb-reader-surface-strong:#0d141efa;--pb-reader-text:#f7f1e8;--pb-reader-text-muted:#cbd5e1;--pb-reader-text-dim:#9aaaba;--pb-reader-interactive:#8fb9ee;--pb-reader-danger:#e38473;--pb-reader-warning:#f2c98d;background:var(--pb-reader-canvas);width:100vw;max-width:none;height:100dvh;max-height:none;color:var(--pb-reader-text);border:0;margin:0;padding:0;position:fixed;inset:0;overflow:hidden}.comic-reader::backdrop{background:var(--pb-reader-canvas)}.comic-reader__shell{background:var(--pb-reader-canvas);width:100%;min-width:0;height:100%;min-height:0;color:var(--pb-reader-text);grid-template-rows:auto minmax(0,1fr) auto;font-family:DM Sans,sans-serif;display:grid;overflow:hidden}.comic-reader__topbar,.comic-reader__controls{z-index:20;background:var(--pb-reader-surface);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-color:#cbd5e129;transition:opacity .14s;position:relative}.comic-reader__topbar{min-height:58px;padding:calc(.45rem + env(safe-area-inset-top,0px)) max(.75rem, env(safe-area-inset-right,0px)) .45rem max(.75rem, env(safe-area-inset-left,0px));border-bottom-width:1px;grid-template-columns:auto minmax(0,1fr) auto auto;align-items:center;gap:.75rem;display:grid}.comic-reader__identity{min-width:0}.comic-reader__identity h2,.comic-reader__identity p{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.comic-reader__identity h2{color:var(--pb-reader-text);margin:0;font-family:Bricolage Grotesque,sans-serif;font-size:.95rem;font-weight:800;line-height:1.2}.comic-reader__identity p{color:var(--pb-reader-text-dim);margin:.1rem 0 0;font-size:.72rem;line-height:1.2}.comic-reader__top-actions,.comic-reader__issue-navigation,.comic-reader__navigation,.comic-reader__sizing,.comic-reader__state-actions,.comic-reader__help-heading{align-items:center;display:flex}.comic-reader__top-actions,.comic-reader__issue-navigation,.comic-reader__navigation,.comic-reader__sizing,.comic-reader__state-actions{gap:.4rem}.comic-reader__issue-navigation{min-width:0}.comic-reader__issue-navigation .comic-reader__button{gap:.35rem}.comic-reader__button,.comic-reader__zoom-label,.comic-reader__fit-select,.comic-reader__page-jump input{min-height:44px;color:var(--pb-reader-text);font:inherit;background:#f7f1e812;border:1px solid #cbd5e138;border-radius:.55rem}.comic-reader__button,.comic-reader__zoom-label{white-space:nowrap;cursor:pointer;justify-content:center;align-items:center;min-width:44px;padding:.55rem .7rem;font-size:.76rem;font-weight:700;line-height:1;display:inline-flex}.comic-reader__button:hover,.comic-reader__zoom-label:hover,.comic-reader__button[aria-pressed=true]{background:#8fb9ee29;border-color:#8fb9ee8c}.comic-reader__button:focus-visible,.comic-reader__zoom-label:focus-visible,.comic-reader__fit-select:focus-visible,.comic-reader__page-jump input:focus-visible,.comic-reader__viewport:focus-visible{outline:2px solid var(--pb-reader-interactive);outline-offset:-2px}.comic-reader__button:disabled{cursor:default;opacity:.38}.comic-reader__button svg{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.8px;width:19px;height:19px}.comic-reader__button--prominent{background:#8fb9ee1f;border-color:#8fb9ee61}.comic-reader__viewport{z-index:1;overscroll-behavior:contain;background:var(--pb-reader-canvas);scrollbar-color:#9aaaba8c transparent;touch-action:pan-y pinch-zoom;min-width:0;min-height:0;position:relative;overflow:auto}.comic-reader__viewport.is-pannable{touch-action:auto}.comic-reader__page-stage{justify-content:center;align-items:center;width:100%;min-width:100%;height:100%;min-height:100%;padding:.75rem;display:flex}.comic-reader__page{z-index:1;object-fit:contain;-webkit-user-select:none;user-select:none;-webkit-user-drag:none;flex:none;max-width:none;max-height:none;display:block;position:relative;box-shadow:0 8px 30px #00000061}.comic-reader__page--page{width:auto;max-width:100%;height:auto;max-height:100%}.comic-reader__page--width{align-self:flex-start;width:100%;height:auto}.comic-reader__page--height{width:auto;height:100%}.comic-reader__page--actual{align-self:flex-start;width:auto;height:auto}.comic-reader__state,.comic-reader__page-busy{z-index:8;text-align:center;justify-content:center;align-items:center;display:flex;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}.comic-reader__state{width:min(90vw,32rem);color:var(--pb-reader-text-muted);background:#0d141ef5;border:1px solid #cbd5e133;border-radius:.9rem;flex-direction:column;gap:.5rem;padding:1.5rem;box-shadow:0 20px 56px #00000075}.comic-reader__state strong{color:var(--pb-reader-text);font-size:1rem}.comic-reader__state--error{border-color:var(--pb-reader-danger)}@supports (color:color-mix(in lab, red, red)){.comic-reader__state--error{border-color:color-mix(in srgb, var(--pb-reader-danger) 45%, transparent)}}.comic-reader__state-actions{flex-wrap:wrap;justify-content:center;margin-top:.5rem}.comic-reader__completion{z-index:9;right:max(.75rem, env(safe-area-inset-right,0px));bottom:max(.75rem, env(safe-area-inset-bottom,0px));width:min(24rem,100% - 1.5rem);color:var(--pb-reader-text-muted);background:#0d141ef5;border:1px solid #8fb9ee6b;border-radius:.8rem;padding:.85rem;position:absolute;box-shadow:0 16px 42px #0000006b}.comic-reader__completion strong{color:var(--pb-reader-text);font-family:Bricolage Grotesque,sans-serif;font-size:1rem;display:block}.comic-reader__completion p{margin:.35rem 0 0;font-size:.78rem}.comic-reader__page-busy{color:var(--pb-reader-text-muted);background:#0d141ee6;border-radius:999px;padding:.45rem .7rem;font-size:.72rem;top:1rem;transform:translate(-50%)}.comic-reader__spinner{border:2px solid #8fb9ee40;border-top-color:var(--pb-reader-interactive);border-radius:999px;width:1.5rem;height:1.5rem;animation:.7s linear infinite comic-reader-spin}@keyframes comic-reader-spin{to{transform:rotate(360deg)}}.comic-reader__tap-zone{z-index:4;background:0 0;border:0;margin:0;padding:0;position:absolute;top:0;bottom:0}.comic-reader__tap-zone--left{width:30%;left:0}.comic-reader__tap-zone--center{width:40%;left:30%}.comic-reader__tap-zone--right{width:30%;right:0}.comic-reader__controls{min-width:0;padding:.45rem max(.75rem, env(safe-area-inset-right,0px)) calc(.45rem + env(safe-area-inset-bottom,0px)) max(.75rem, env(safe-area-inset-left,0px));border-top-width:1px;justify-content:space-between;align-items:center;gap:.75rem;display:flex}.comic-reader__navigation,.comic-reader__sizing{min-width:0;position:relative}.comic-reader__page-jump{color:var(--pb-reader-text-dim);white-space:nowrap;align-items:center;gap:.35rem;font-size:.75rem;display:inline-flex}.comic-reader__page-jump input{text-align:center;appearance:textfield;width:3.4rem;padding:.4rem}.comic-reader__page-jump input::-webkit-outer-spin-button{appearance:none;margin:0}.comic-reader__page-jump input::-webkit-inner-spin-button{appearance:none;margin:0}.comic-reader__fit-select{color-scheme:dark;max-width:8.5rem;padding:0 1.9rem 0 .65rem;display:none}.comic-reader__zoom-label{min-width:3.75rem;color:var(--pb-reader-text-muted);padding-inline:.45rem}.comic-reader__input-error{border:1px solid var(--pb-reader-danger);width:max-content;max-width:min(20rem,100vw - 1.5rem);padding:.35rem .5rem;position:absolute;bottom:calc(100% + .35rem);left:50%;transform:translate(-50%)}@supports (color:color-mix(in lab, red, red)){.comic-reader__input-error{border:1px solid color-mix(in srgb, var(--pb-reader-danger) 42%, transparent)}}.comic-reader__input-error{background:var(--pb-reader-surface-strong);color:var(--pb-reader-danger);border-radius:.45rem;font-size:.7rem}.comic-reader__help{z-index:30;background:var(--pb-reader-surface-strong);border:1px solid #8fb9ee66;border-radius:.9rem;width:min(92vw,34rem);max-height:min(78dvh,38rem);padding:1rem;position:absolute;top:50%;left:50%;overflow:auto;transform:translate(-50%,-50%);box-shadow:0 20px 56px #0009}.comic-reader__help-heading{justify-content:space-between;gap:1rem}.comic-reader__help h3{color:var(--pb-reader-text);margin:0;font-family:Bricolage Grotesque,sans-serif;font-size:1rem}.comic-reader__help dl{gap:.15rem;margin:.75rem 0 0;display:grid}.comic-reader__help dl>div{border-top:1px solid #cbd5e11f;grid-template-columns:minmax(7.5rem,.7fr) minmax(0,1.3fr);gap:.75rem;padding:.45rem 0;display:grid}.comic-reader__help dt{color:var(--pb-reader-interactive);font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:600}.comic-reader__help dd{color:var(--pb-reader-text-muted);margin:0;font-size:.75rem}.comic-reader__save-status{z-index:40;right:max(.75rem, env(safe-area-inset-right,0px));bottom:calc(4.1rem + env(safe-area-inset-bottom,0px));max-width:min(22rem,100vw - 1.5rem);color:var(--pb-reader-warning);background:#231b11f5;border:1px solid #d7a15b66;border-radius:.55rem;margin:0;padding:.45rem .65rem;font-size:.72rem;position:absolute}.comic-reader__shell.is-controls-hidden .comic-reader__topbar,.comic-reader__shell.is-controls-hidden .comic-reader__controls{visibility:hidden;opacity:0;pointer-events:none}@media (max-width:900px){.comic-reader__issue-navigation .comic-reader__button span{clip:rect(0, 0, 0, 0);white-space:nowrap;width:1px;height:1px;position:absolute;overflow:hidden}.comic-reader__fit-page,.comic-reader__fit-wide{display:none}.comic-reader__fit-select{display:block}}@media (max-width:600px){.comic-reader__topbar{grid-template-columns:auto minmax(0,1fr) auto;gap:.5rem;min-height:54px}.comic-reader__issue-navigation{grid-area:2/1/auto/-1;justify-content:center}.comic-reader__top-actions{grid-area:1/3}.comic-reader__desktop-action{display:none}.comic-reader__controls{flex-wrap:wrap;justify-content:center;gap:.35rem .6rem}.comic-reader__navigation,.comic-reader__sizing{justify-content:center}.comic-reader__page-stage{padding:.4rem}.comic-reader__help dl>div{grid-template-columns:1fr;gap:.15rem}}@media (max-height:480px){.comic-reader__topbar,.comic-reader__controls{padding-top:.25rem;padding-bottom:.25rem}.comic-reader__topbar{min-height:48px}}@media (prefers-reduced-motion:reduce){.comic-reader__topbar,.comic-reader__controls{transition:none;visibility:visible!important;opacity:1!important;pointer-events:auto!important}.comic-reader__spinner{animation-duration:1.4s}}.reading-workspace{flex-direction:column;gap:1rem;display:flex}.reading-workspace-header,.dashboard-reading-shelf-header{justify-content:space-between;align-items:flex-end;gap:1rem;display:flex}.reading-page-size{align-items:center;gap:.65rem;display:flex}.reading-tabs{border-bottom:1px solid var(--pb-border);gap:.4rem;display:flex;overflow-x:auto}.reading-tab{color:var(--pb-text-tertiary);letter-spacing:.08em;text-transform:uppercase;flex:none;padding:.65rem .85rem;font-family:Syne,sans-serif;font-size:.7rem;font-weight:700;position:relative}.reading-tab:after{content:"";background:0 0;border-radius:999px;height:2px;position:absolute;bottom:-1px;left:.65rem;right:.65rem}.reading-tab:hover,.reading-tab:focus-visible,.reading-tab.is-active{color:var(--pb-interactive)}.reading-tab.is-active:after{background:var(--pb-interactive)}.reading-card-grid,.dashboard-reading-card-grid{grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:.85rem;display:grid}.dashboard-reading-card-grid{grid-template-columns:repeat(4,minmax(0,1fr))}.reading-card{border:1px solid var(--pb-border);background:linear-gradient(145deg, var(--pb-interactive), transparent 42%), var(--pb-bg-card);border-radius:12px;grid-template-columns:92px minmax(0,1fr);min-width:0;display:grid;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.reading-card{background:linear-gradient(145deg, color-mix(in srgb, var(--pb-interactive) 5%, transparent), transparent 42%), var(--pb-bg-card)}}.reading-card{box-shadow:var(--pb-shadow-1)}.reading-card:not(.reading-card-dashboard){min-height:15.25rem}.reading-card-cover-link,.reading-card-cover-frame{min-height:138px;display:block}.reading-card-cover-frame{border-right:1px solid var(--pb-border-subtle);background:var(--pb-bg-shell);height:100%;position:relative;overflow:hidden}.reading-card-cover{object-fit:cover;width:100%;height:100%}.reading-card-cover-placeholder{width:100%;height:100%;color:var(--pb-text-dim);justify-content:center;align-items:center;display:flex}.reading-card-cover-placeholder svg{width:2.25rem;height:2.25rem}.reading-card-body{flex-direction:column;gap:.7rem;min-width:0;padding:.85rem;display:flex}.reading-card-series{color:var(--pb-text-primary);text-overflow:ellipsis;white-space:nowrap;font-family:Syne,sans-serif;font-size:.78rem;font-weight:700;display:block;overflow:hidden}.reading-card-series:hover,.reading-card-series:focus-visible{color:var(--pb-interactive)}.reading-card-issue{min-width:0;color:var(--pb-text-tertiary);gap:.4rem;margin-top:.2rem;font-size:.72rem;display:flex}.reading-card-issue>span:first-child,.reading-card-state{font-family:JetBrains Mono,monospace}.reading-card-issue-title{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.reading-card-state{color:var(--pb-text-secondary);font-size:.67rem;font-weight:600}.reading-card-progress{background:var(--pb-border-subtle);border-radius:999px;height:3px;margin-top:.4rem;overflow:hidden}.reading-card-progress span{border-radius:inherit;background:var(--pb-interactive);height:100%;display:block}.reading-card-state-region{min-height:1.75rem}.reading-card-actions{grid-template-rows:repeat(2,minmax(2.25rem,auto));grid-template-columns:repeat(2,minmax(0,1fr));align-items:stretch;gap:.4rem;margin-top:auto;display:grid}.reading-card-view-read .reading-card-actions{grid-template-columns:minmax(0,2fr) minmax(0,3fr)}.reading-card-view-read .reading-card-primary-action,.reading-card-view-read .reading-card-completion-action{white-space:nowrap;padding-inline:.5rem}.reading-card-primary-action,.reading-card-completion-action,.reading-card-queue-action{width:100%;min-width:0}.reading-card-queue-action{grid-column:1/-1}.reading-card-queue-action-placeholder{visibility:hidden;pointer-events:none;grid-column:1/-1;min-height:2.25rem}.reading-card-mutation-status{min-height:1rem;color:var(--pb-error);font-size:.68rem}.reading-empty-state{border:1px dashed var(--pb-border);text-align:center;background:var(--pb-bg-card);border-radius:12px;flex-direction:column;justify-content:center;align-items:center;min-height:260px;padding:2.5rem 1.5rem;display:flex}@supports (color:color-mix(in lab, red, red)){.reading-empty-state{background:color-mix(in srgb, var(--pb-bg-card) 78%, transparent)}}.reading-empty-icon{width:2.75rem;height:2.75rem;color:var(--pb-text-dim)}.reading-empty-state h2{color:var(--pb-text-primary);margin-top:.85rem;font-family:Syne,sans-serif;font-size:.95rem;font-weight:700}.reading-empty-state p{max-width:34rem;color:var(--pb-text-tertiary);margin-top:.35rem;font-size:.78rem}.dashboard-reading-shelf{border:1px solid var(--pb-border);background:var(--pb-bg-card);border-radius:12px;padding:.85rem}@supports (color:color-mix(in lab, red, red)){.dashboard-reading-shelf{background:color-mix(in srgb, var(--pb-bg-card) 88%, transparent)}}.dashboard-reading-shelf{box-shadow:var(--pb-shadow-1)}.dashboard-reading-shelf-header h2{color:var(--pb-text-primary);margin-top:.1rem;font-family:Syne,sans-serif;font-size:1rem;font-weight:700}.dashboard-reading-shelf-link{color:var(--pb-interactive);font-size:.72rem;font-weight:700}.dashboard-reading-shelf-link:hover,.dashboard-reading-shelf-link:focus-visible{text-decoration:underline}.reading-card-dashboard{grid-template-columns:76px minmax(0,1fr)}.reading-card-dashboard .reading-card-cover-link,.reading-card-dashboard .reading-card-cover-frame{min-height:124px}.reading-card-dashboard .reading-card-body{gap:.5rem;padding:.7rem}@media (max-width:1180px){.dashboard-reading-card-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (max-width:640px){.reading-workspace-header{flex-direction:column;align-items:flex-start}.reading-card-grid,.dashboard-reading-card-grid{grid-template-columns:1fr}.reading-card{grid-template-columns:82px minmax(0,1fr)}.reading-card-cover-link,.reading-card-cover-frame{min-height:128px}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}} +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--color-red-300:oklch(80.8% .114 19.571);--color-slate-950:oklch(12.9% .042 264.695);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--font-weight-black:900;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--leading-tight:1.25;--leading-relaxed:1.625;--radius-xs:.125rem;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--radius-3xl:1.5rem;--ease-in:cubic-bezier(.4, 0, 1, 1);--ease-out:cubic-bezier(0, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-xs:4px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent;font-family:DM Sans,sans-serif;line-height:1.5}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-feature-settings:normal;font-variation-settings:normal;font-family:JetBrains Mono,Fira Code,monospace;font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}html{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}:root,[data-theme=dark]{--pb-surface-app:#101824;--pb-surface-shell:#141e2c;--pb-surface-card:#172231;--pb-surface-raised:#1d2a3d;--pb-surface-input:#132030;--pb-surface-overlay:#070b12b8;--pb-surface-selected:#8fb9ee29;--pb-text-primary:#f7f1e8;--pb-text-secondary:#cbd5e1;--pb-text-tertiary:#9aaaba;--pb-text-inverse:#101824;--pb-border-subtle:#cbd5e11f;--pb-border-default:#cbd5e133;--pb-border-strong:#cbd5e152;--pb-interactive:#8fb9ee;--pb-interactive-hover:#7da9df;--pb-interactive-active:#6a96c8;--pb-interactive-selected:#8fb9ee2e;--pb-brand:#c6a17b;--pb-brand-muted:#c6a17b29;--pb-status-success:#68b88b;--pb-status-warning:#d7a15b;--pb-status-danger:#e38473;--pb-status-info:#8fb9ee;--pb-focus-ring:#8fb9ee47;--pb-focus-outline:#8fb9ee85;--pb-selection:#8fb9ee38;--pb-shadow-0:none;--pb-shadow-1:0 1px 2px #070b1238, 0 10px 24px #070b1229;--pb-shadow-2:0 2px 6px #070b1247, 0 18px 40px #070b1238;--pb-shadow-overlay:0 20px 56px #00000075;--pb-brand-hover:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-hover:color-mix(in srgb, var(--pb-brand) 84%, black)}}:root,[data-theme=dark]{--pb-brand-dim:var(--pb-brand-muted);--pb-brand-border:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-border:color-mix(in srgb, var(--pb-brand) 28%, transparent)}}:root,[data-theme=dark]{--pb-brand-signal:var(--pb-status-warning);--pb-brand-signal-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-signal-dim:color-mix(in srgb, var(--pb-status-warning) 16%, transparent)}}:root,[data-theme=dark]{--pb-brand-signal-hover:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-signal-hover:color-mix(in srgb, var(--pb-status-warning) 22%, transparent)}}:root,[data-theme=dark]{--pb-brand-signal-border:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-signal-border:color-mix(in srgb, var(--pb-status-warning) 28%, transparent)}}:root,[data-theme=dark]{--pb-interactive-dim:var(--pb-interactive-selected);--pb-interactive-border:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-interactive-border:color-mix(in srgb, var(--pb-interactive) 30%, transparent)}}:root,[data-theme=dark]{--pb-success:var(--pb-status-success);--pb-success-dim:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-success-dim:color-mix(in srgb, var(--pb-status-success) 18%, transparent)}}:root,[data-theme=dark]{--pb-warning:var(--pb-status-warning);--pb-warning-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-warning-dim:color-mix(in srgb, var(--pb-status-warning) 20%, transparent)}}:root,[data-theme=dark]{--pb-error:var(--pb-status-danger);--pb-error-dim:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-error-dim:color-mix(in srgb, var(--pb-status-danger) 18%, transparent)}}:root,[data-theme=dark]{--pb-info:var(--pb-status-info);--pb-info-dim:var(--pb-status-info)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-info-dim:color-mix(in srgb, var(--pb-status-info) 18%, transparent)}}:root,[data-theme=dark]{--pb-purple:#a88bda;--pb-purple-dim:#a88bda2e;--pb-bg-base:var(--pb-surface-app);--pb-bg-surface:var(--pb-surface-shell);--pb-bg-card:var(--pb-surface-card);--pb-bg-card-hover:var(--pb-surface-card)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-bg-card-hover:color-mix(in srgb, var(--pb-surface-card) 72%, var(--pb-surface-raised))}}:root,[data-theme=dark]{--pb-bg-input:var(--pb-surface-input);--pb-bg-overlay:var(--pb-surface-overlay);--pb-table-row-stripe:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-table-row-stripe:color-mix(in srgb, var(--pb-bg-card-hover) 68%, transparent)}}:root,[data-theme=dark]{--pb-table-row-hover:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-table-row-hover:color-mix(in srgb, var(--pb-bg-card-hover) 88%, transparent)}}:root,[data-theme=dark]{--pb-table-cell-x:1.25rem;--pb-table-cell-x-tight:1rem;--pb-page-footer-clearance:1.5rem;--pb-text-dim:var(--pb-text-tertiary);--pb-border:var(--pb-border-default);--pb-border-hover:var(--pb-border-strong)}[data-theme=light]{--pb-surface-app:#f6f1e8;--pb-surface-shell:#fbf6ee;--pb-surface-card:#fffcf7;--pb-surface-raised:#fff;--pb-surface-input:#fffdfa;--pb-surface-overlay:#1a16136b;--pb-surface-selected:#2f5e8c1f;--pb-text-primary:#1e1a17;--pb-text-secondary:#51473d;--pb-text-tertiary:#74675b;--pb-text-inverse:#f7f1e8;--pb-border-subtle:#51473d24;--pb-border-default:#51473d33;--pb-border-strong:#51473d57;--pb-interactive:#2f5e8c;--pb-interactive-hover:#274f76;--pb-interactive-active:#21435f;--pb-interactive-selected:#2f5e8c24;--pb-brand:#855e3d;--pb-brand-muted:#8c68471f;--pb-status-success:#2e6a4f;--pb-status-warning:#8a5a1f;--pb-status-danger:#b24432;--pb-status-info:#2f5e8c;--pb-focus-ring:#2f5e8c42;--pb-focus-outline:#2f5e8cb8;--pb-selection:#2f5e8c33;--pb-shadow-0:none;--pb-shadow-1:0 1px 2px #1a161314, 0 10px 24px #1a16130f;--pb-shadow-2:0 2px 6px #1a16131a, 0 18px 40px #1a161314;--pb-shadow-overlay:0 20px 56px #1a161333;--pb-brand-hover:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-hover:color-mix(in srgb, var(--pb-brand) 84%, black)}}[data-theme=light]{--pb-brand-dim:var(--pb-brand-muted);--pb-brand-border:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-border:color-mix(in srgb, var(--pb-brand) 28%, transparent)}}[data-theme=light]{--pb-brand-signal:var(--pb-status-warning);--pb-brand-signal-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-signal-dim:color-mix(in srgb, var(--pb-status-warning) 12%, transparent)}}[data-theme=light]{--pb-brand-signal-hover:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-signal-hover:color-mix(in srgb, var(--pb-status-warning) 18%, transparent)}}[data-theme=light]{--pb-brand-signal-border:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-signal-border:color-mix(in srgb, var(--pb-status-warning) 24%, transparent)}}[data-theme=light]{--pb-interactive-dim:var(--pb-interactive-selected);--pb-interactive-border:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-interactive-border:color-mix(in srgb, var(--pb-interactive) 28%, transparent)}}[data-theme=light]{--pb-success:var(--pb-status-success);--pb-success-dim:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-success-dim:color-mix(in srgb, var(--pb-status-success) 12%, transparent)}}[data-theme=light]{--pb-warning:var(--pb-status-warning);--pb-warning-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-warning-dim:color-mix(in srgb, var(--pb-status-warning) 14%, transparent)}}[data-theme=light]{--pb-error:var(--pb-status-danger);--pb-error-dim:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-error-dim:color-mix(in srgb, var(--pb-status-danger) 12%, transparent)}}[data-theme=light]{--pb-info:var(--pb-status-info);--pb-info-dim:var(--pb-status-info)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-info-dim:color-mix(in srgb, var(--pb-status-info) 12%, transparent)}}[data-theme=light]{--pb-purple:#7b5c91;--pb-purple-dim:#7b5c9124;--pb-bg-base:var(--pb-surface-app);--pb-bg-surface:var(--pb-surface-shell);--pb-bg-card:var(--pb-surface-card);--pb-bg-card-hover:var(--pb-surface-card)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-bg-card-hover:color-mix(in srgb, var(--pb-surface-card) 72%, var(--pb-surface-raised))}}[data-theme=light]{--pb-bg-input:var(--pb-surface-input);--pb-bg-overlay:var(--pb-surface-overlay);--pb-table-row-stripe:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-table-row-stripe:color-mix(in srgb, var(--pb-surface-shell) 40%, transparent)}}[data-theme=light]{--pb-table-row-hover:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-table-row-hover:color-mix(in srgb, var(--pb-surface-shell) 58%, transparent)}}[data-theme=light]{--pb-table-cell-x:1.25rem;--pb-table-cell-x-tight:1rem;--pb-text-dim:var(--pb-text-tertiary);--pb-border:var(--pb-border-default);--pb-border-hover:var(--pb-border-strong)}@media (prefers-color-scheme:light){:root:not([data-theme]){--pb-surface-app:#f6f1e8;--pb-surface-shell:#fbf6ee;--pb-surface-card:#fffcf7;--pb-surface-raised:#fff;--pb-surface-input:#fffdfa;--pb-surface-overlay:#1a16136b;--pb-surface-selected:#2f5e8c1f;--pb-text-primary:#1e1a17;--pb-text-secondary:#51473d;--pb-text-tertiary:#74675b;--pb-text-inverse:#f7f1e8;--pb-border-subtle:#51473d24;--pb-border-default:#51473d33;--pb-border-strong:#51473d57;--pb-interactive:#2f5e8c;--pb-interactive-hover:#274f76;--pb-interactive-active:#21435f;--pb-interactive-selected:#2f5e8c24;--pb-brand:#855e3d;--pb-brand-muted:#8c68471f;--pb-status-success:#2e6a4f;--pb-status-warning:#8a5a1f;--pb-status-danger:#b24432;--pb-status-info:#2f5e8c;--pb-focus-ring:#2f5e8c42;--pb-focus-outline:#2f5e8cb8;--pb-selection:#2f5e8c33;--pb-shadow-0:none;--pb-shadow-1:0 1px 2px #1a161314, 0 10px 24px #1a16130f;--pb-shadow-2:0 2px 6px #1a16131a, 0 18px 40px #1a161314;--pb-shadow-overlay:0 20px 56px #1a161333;--pb-brand-hover:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-hover:color-mix(in srgb, var(--pb-brand) 84%, black)}}:root:not([data-theme]){--pb-brand-dim:var(--pb-brand-muted);--pb-brand-border:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-border:color-mix(in srgb, var(--pb-brand) 28%, transparent)}}:root:not([data-theme]){--pb-brand-signal:var(--pb-status-warning);--pb-brand-signal-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-signal-dim:color-mix(in srgb, var(--pb-status-warning) 12%, transparent)}}:root:not([data-theme]){--pb-brand-signal-hover:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-signal-hover:color-mix(in srgb, var(--pb-status-warning) 18%, transparent)}}:root:not([data-theme]){--pb-brand-signal-border:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-signal-border:color-mix(in srgb, var(--pb-status-warning) 24%, transparent)}}:root:not([data-theme]){--pb-interactive-dim:var(--pb-interactive-selected);--pb-interactive-border:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-interactive-border:color-mix(in srgb, var(--pb-interactive) 28%, transparent)}}:root:not([data-theme]){--pb-success:var(--pb-status-success);--pb-success-dim:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-success-dim:color-mix(in srgb, var(--pb-status-success) 12%, transparent)}}:root:not([data-theme]){--pb-warning:var(--pb-status-warning);--pb-warning-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-warning-dim:color-mix(in srgb, var(--pb-status-warning) 14%, transparent)}}:root:not([data-theme]){--pb-error:var(--pb-status-danger);--pb-error-dim:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-error-dim:color-mix(in srgb, var(--pb-status-danger) 12%, transparent)}}:root:not([data-theme]){--pb-info:var(--pb-status-info);--pb-info-dim:var(--pb-status-info)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-info-dim:color-mix(in srgb, var(--pb-status-info) 12%, transparent)}}:root:not([data-theme]){--pb-purple:#7b5c91;--pb-purple-dim:#7b5c9124;--pb-bg-base:var(--pb-surface-app);--pb-bg-surface:var(--pb-surface-shell);--pb-bg-card:var(--pb-surface-card);--pb-bg-card-hover:var(--pb-surface-card)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-bg-card-hover:color-mix(in srgb, var(--pb-surface-card) 72%, var(--pb-surface-raised))}}:root:not([data-theme]){--pb-bg-input:var(--pb-surface-input);--pb-bg-overlay:var(--pb-surface-overlay);--pb-table-row-stripe:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-table-row-stripe:color-mix(in srgb, var(--pb-surface-shell) 40%, transparent)}}:root:not([data-theme]){--pb-table-row-hover:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-table-row-hover:color-mix(in srgb, var(--pb-surface-shell) 58%, transparent)}}:root:not([data-theme]){--pb-table-cell-x:1.25rem;--pb-table-cell-x-tight:1rem;--pb-text-dim:var(--pb-text-tertiary);--pb-border:var(--pb-border-default);--pb-border-hover:var(--pb-border-strong)}}html,body{overscroll-behavior:none;overflow:hidden}:focus-visible{outline:2px solid var(--pb-focus-outline);outline-offset:3px;box-shadow:0 0 0 4px var(--pb-focus-ring);border-radius:4px}::selection{background-color:var(--pb-selection)}:where(a[href]:not([aria-disabled=true]),button:not(:disabled):not([aria-disabled=true]),input[type=button]:not(:disabled),input[type=submit]:not(:disabled),input[type=reset]:not(:disabled),input[type=checkbox]:not(:disabled),input[type=radio]:not(:disabled),input[type=file]:not(:disabled),input[type=image]:not(:disabled),select:not(:disabled),summary,[role=button]:not([aria-disabled=true]),[role=link]:not([aria-disabled=true]),label:has(:is(input[type=checkbox],input[type=radio],input[type=file]):not(:disabled))){cursor:pointer}:where(button:disabled,input:disabled,select:disabled,[aria-disabled=true]){cursor:not-allowed}}@layer components{.control-size-sm,.btn-sm,.chip-btn-sm,.icon-btn-sm{--pb-control-min-height:2rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.4375rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:.875rem;--pb-control-panel-item-px:.75rem;--pb-control-panel-item-py:.5rem;--pb-control-search-icon-left:.625rem;--pb-control-search-padding-left:2rem;--pb-control-search-padding-right:1.875rem;--pb-control-clear-right:.5rem}.control-size-md,.btn-md,.chip-btn,.icon-btn,.dropdown-select,.search-field,.btn-primary,.btn-warning,.btn-ghost,.btn-danger{--pb-control-min-height:2.5rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.5rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:1rem;--pb-control-panel-item-px:.75rem;--pb-control-panel-item-py:.5rem;--pb-control-search-icon-left:.75rem;--pb-control-search-padding-left:2.5rem;--pb-control-search-padding-right:2.25rem;--pb-control-clear-right:.625rem}.control-size-lg,.btn-lg,.chip-btn-lg,.icon-btn-lg{--pb-control-min-height:2.875rem;--pb-control-radius:.625rem;--pb-control-gap:.625rem;--pb-control-px:.875rem;--pb-control-py:.625rem;--pb-control-font-size:.9375rem;--pb-control-line-height:1.375rem;--pb-control-icon-size:1rem;--pb-control-panel-item-px:.875rem;--pb-control-panel-item-py:.625rem;--pb-control-search-icon-left:.875rem;--pb-control-search-padding-left:2.75rem;--pb-control-search-padding-right:2.5rem;--pb-control-clear-right:.75rem}.btn-primary.btn-sm,.btn-warning.btn-sm,.btn-ghost.btn-sm,.btn-danger.btn-sm,.btn-primary.control-size-sm,.btn-warning.control-size-sm,.btn-ghost.control-size-sm,.btn-danger.control-size-sm{--pb-control-min-height:2rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.4375rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:.875rem}.btn-primary.btn-md,.btn-warning.btn-md,.btn-ghost.btn-md,.btn-danger.btn-md,.btn-primary.control-size-md,.btn-warning.control-size-md,.btn-ghost.control-size-md,.btn-danger.control-size-md{--pb-control-min-height:2.5rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.5rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:1rem}.btn-primary.btn-lg,.btn-warning.btn-lg,.btn-ghost.btn-lg,.btn-danger.btn-lg,.btn-primary.control-size-lg,.btn-warning.control-size-lg,.btn-ghost.control-size-lg,.btn-danger.control-size-lg{--pb-control-min-height:2.875rem;--pb-control-radius:.625rem;--pb-control-gap:.625rem;--pb-control-px:.875rem;--pb-control-py:.625rem;--pb-control-font-size:.9375rem;--pb-control-line-height:1.375rem;--pb-control-icon-size:1rem}.btn-primary{background-color:var(--pb-interactive);--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:10px;justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.btn-primary:hover{background-color:var(--pb-interactive-hover)}}.btn-primary:disabled{cursor:not-allowed;opacity:.5}.btn-primary{color:var(--pb-text-inverse);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.btn-ghost{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:#0000;border-radius:10px;justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.btn-ghost:hover{border-color:var(--pb-border-strong);color:var(--pb-text-primary)}}.btn-ghost:disabled{cursor:not-allowed;opacity:.5}.btn-ghost{min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.btn-warning{--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:10px;justify-content:center;align-items:center;display:inline-flex}.btn-warning:disabled{cursor:not-allowed;opacity:.5}.btn-warning{background-color:var(--pb-warning);color:var(--pb-text-inverse);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.btn-warning:hover{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.btn-warning:hover{background-color:color-mix(in srgb, var(--pb-warning) 86%, var(--pb-text-primary))}}.btn-danger{justify-content:center;align-items:center;gap:var(--pb-control-gap);background-color:var(--pb-error);--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:10px;display:inline-flex}@media (hover:hover){.btn-danger:hover{--tw-brightness:brightness(90%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}}.btn-danger:disabled{cursor:not-allowed;opacity:.5}.btn-danger{color:var(--pb-text-inverse);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.pill,.badge-success,.badge-warning,.badge-error,.badge-info,.badge-neutral,.badge-muted,.badge-purple{justify-content:center;align-items:center;gap:var(--spacing);padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * .5);text-align:center;--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-size:11px;font-weight:var(--font-weight-medium);border-radius:3.40282e38px;display:inline-flex}.pill-success,.badge-success{background-color:var(--pb-success-dim);color:var(--pb-success)}.pill-warning,.badge-warning{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.pill-error,.badge-error{background-color:var(--pb-error-dim);color:var(--pb-error)}.pill-info,.badge-info{background-color:var(--pb-info-dim);color:var(--pb-info)}.pill-neutral,.badge-neutral{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary)}.pill-muted,.badge-muted{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card-hover);color:var(--pb-text-dim)}.pill-purple,.badge-purple{background-color:var(--pb-purple-dim);color:var(--pb-purple)}.count-badge{min-width:1.25rem;min-height:1.25rem;padding-inline:calc(var(--spacing) * 1.5);--tw-leading:1;--tw-font-weight:var(--font-weight-bold);font-size:10px;line-height:1;font-weight:var(--font-weight-bold);--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);border-radius:3.40282e38px;justify-content:center;align-items:center;display:inline-flex}.count-badge-info{background-color:var(--pb-info-dim);color:var(--pb-info)}.count-badge-success{background-color:var(--pb-success-dim);color:var(--pb-success)}.count-badge-warning{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.count-badge-error{background-color:var(--pb-error-dim);color:var(--pb-error)}.count-badge-neutral{background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary)}.table-shell{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card)}.table-shell-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4)}.table-shell-header-comfortable{padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4)}.table-shell-content{padding:calc(var(--spacing) * 5)}.table-shell-content-comfortable{padding:calc(var(--spacing) * 6)}.table-toolbar{gap:calc(var(--spacing) * 3);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);flex-direction:column;display:flex}@media (min-width:64rem){.table-toolbar{flex-direction:row;justify-content:space-between;align-items:flex-end}}:where(.table-toolbar-title-block>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}.table-toolbar-eyebrow{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.table-toolbar-title{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.table-toolbar-title-compact{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.table-toolbar-copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.table-toolbar-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.table-header-row{grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:.75rem;display:grid}.table-header-aside{justify-self:end}.table-summary{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));flex-wrap:wrap;display:flex}.table-scroll{overflow-x:auto}.table-base{border-collapse:collapse;width:100%;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.table-head{text-align:left;font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider);color:var(--pb-text-secondary);text-transform:uppercase}.table-head-row{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border)}.table-head-cell{padding-block:calc(var(--spacing) * 3);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);padding-left:var(--pb-table-cell-x);padding-right:var(--pb-table-cell-x)}.table-head-cell-tight{padding-block:calc(var(--spacing) * 2.5);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);padding-left:var(--pb-table-cell-x-tight);padding-right:var(--pb-table-cell-x-tight)}.table-head-cell-right{text-align:right}:where(.table-body>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-color:var(--pb-border)}.table-body-striped>.table-row:nth-child(2n){background-color:var(--pb-table-row-stripe)}.table-row{color:var(--pb-text-secondary)}.table-row-hover>.table-row{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.table-row-hover>.table-row:hover{background-color:var(--pb-table-row-hover)}.table-row-hover>.table-row-detail{transition-property:none}.table-row-hover>.table-row-detail:hover{background-color:#0000}.table-cell{padding-block:calc(var(--spacing) * 3);padding-left:var(--pb-table-cell-x);padding-right:var(--pb-table-cell-x)}.table-cell-tight{padding-block:calc(var(--spacing) * 2.5);padding-left:var(--pb-table-cell-x-tight);padding-right:var(--pb-table-cell-x-tight)}.table-cell-right{text-align:right}.table-cell-muted{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary)}.table-cell-nowrap{white-space:nowrap}.table-footer{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.table-empty{padding:calc(var(--spacing) * 8);text-align:center}.table-empty-copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.chip-btn{border-radius:var(--radius-lg);border-style:var(--tw-border-style);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-width:1px;justify-content:center;align-items:center;display:inline-flex}.chip-btn:disabled{cursor:not-allowed;opacity:.5}.chip-btn{gap:var(--pb-control-gap);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.chip-btn-pill{padding-inline:calc(var(--spacing) * 3.5);padding-block:calc(var(--spacing) * 1.5);--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:.2em;letter-spacing:.2em;text-transform:uppercase;border-radius:9999px}.chip-btn-neutral{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary)}@media (hover:hover){.chip-btn-neutral:hover{border-color:var(--pb-border-strong);color:var(--pb-text-primary)}}.chip-btn-selected{color:var(--pb-info);background-color:var(--pb-info-dim);border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-selected{border-color:color-mix(in srgb, var(--pb-info) 26%, transparent)}}.chip-btn-selected:hover{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-selected:hover{background-color:color-mix(in srgb, var(--pb-info) 20%, var(--pb-bg-card))}}.chip-btn-selected:hover{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-selected:hover{border-color:color-mix(in srgb, var(--pb-info) 34%, transparent)}}.chip-btn-info{color:var(--pb-info);background-color:var(--pb-info-dim);border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-info{border-color:color-mix(in srgb, var(--pb-info) 24%, transparent)}}.chip-btn-info:hover{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-info:hover{background-color:color-mix(in srgb, var(--pb-info) 20%, var(--pb-bg-card))}}.chip-btn-info:hover{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-info:hover{border-color:color-mix(in srgb, var(--pb-info) 32%, transparent)}}.chip-btn-success{color:var(--pb-success);background-color:var(--pb-success-dim);border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.chip-btn-success{border-color:color-mix(in srgb, var(--pb-success) 24%, transparent)}}.chip-btn-success:hover{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.chip-btn-success:hover{background-color:color-mix(in srgb, var(--pb-success) 20%, var(--pb-bg-card))}}.chip-btn-success:hover{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.chip-btn-success:hover{border-color:color-mix(in srgb, var(--pb-success) 32%, transparent)}}.chip-btn-warning{color:var(--pb-warning);background-color:var(--pb-warning-dim);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.chip-btn-warning{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.chip-btn-warning:hover{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.chip-btn-warning:hover{background-color:color-mix(in srgb, var(--pb-warning) 20%, var(--pb-bg-card))}}.chip-btn-warning:hover{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.chip-btn-warning:hover{border-color:color-mix(in srgb, var(--pb-warning) 32%, transparent)}}.chip-btn-error{color:var(--pb-error);background-color:var(--pb-error-dim);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.chip-btn-error{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.chip-btn-error:hover{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.chip-btn-error:hover{background-color:color-mix(in srgb, var(--pb-error) 20%, var(--pb-bg-card))}}.chip-btn-error:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.chip-btn-error:hover{border-color:color-mix(in srgb, var(--pb-error) 32%, transparent)}}.card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);border-radius:14px}.card-hover{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:14px}@media (hover:hover){.card-hover:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}}.auth-side-panel{border-right:1px solid var(--pb-border);background:linear-gradient(160deg, var(--pb-bg-surface) 0%, var(--pb-bg-surface) 42%, var(--pb-bg-card) 100%);position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.auth-side-panel{background:linear-gradient(160deg, color-mix(in srgb, var(--pb-bg-surface) 90%, var(--pb-brand) 10%) 0%, var(--pb-bg-surface) 42%, color-mix(in srgb, var(--pb-bg-card) 78%, var(--pb-interactive) 22%) 100%)}}.auth-side-panel:before,.auth-side-panel:after{content:"";pointer-events:none;filter:blur(12px);position:absolute;inset:auto}.auth-side-panel:before{background:radial-gradient(circle, var(--pb-brand) 0%, transparent 64%);width:24rem;height:24rem;top:-10%;left:-8%}@supports (color:color-mix(in lab, red, red)){.auth-side-panel:before{background:radial-gradient(circle, color-mix(in srgb, var(--pb-brand) 26%, transparent) 0%, transparent 64%)}}.auth-side-panel:after{background:radial-gradient(circle, var(--pb-interactive) 0%, transparent 66%);width:26rem;height:26rem;bottom:-14%;right:-12%}@supports (color:color-mix(in lab, red, red)){.auth-side-panel:after{background:radial-gradient(circle, color-mix(in srgb, var(--pb-interactive) 22%, transparent) 0%, transparent 66%)}}.auth-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-overlay);border-radius:1.75rem;position:relative;overflow:hidden}.auth-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.auth-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 40%, transparent), transparent)}}.auth-summary-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.auth-summary-card-emphasis{border-color:var(--pb-brand-border);background-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.auth-summary-card-emphasis{background-color:color-mix(in srgb, var(--pb-brand) 8%, var(--pb-bg-card))}}.auth-kicker{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.login-brand-mobile{justify-content:center;display:flex}.login-hero-stack{flex-direction:column;gap:1rem;max-width:29rem;padding-bottom:.25rem;display:flex}.login-hero-headline{letter-spacing:-.02em;color:var(--pb-text-primary);text-wrap:balance;padding-bottom:.1em;font-family:Syne,sans-serif;font-size:clamp(2.3rem,3.1vw,3.35rem);font-weight:800;line-height:1.08}.login-hero-copy{max-width:25rem;color:var(--pb-text-secondary);padding-bottom:.08em;font-size:.95rem;line-height:1.72}.login-side-footer{color:var(--pb-text-tertiary);flex-wrap:wrap;align-items:center;gap:.625rem 1.25rem;padding-bottom:.08em;font-family:JetBrains Mono,monospace;font-size:.68rem;display:flex}.login-side-footer-item{align-items:center;gap:.45rem;display:inline-flex}.login-side-footer-dot{background:var(--pb-success);width:.4rem;height:.4rem;box-shadow:0 0 4px var(--pb-success);border-radius:9999px}@supports (color:color-mix(in lab, red, red)){.login-side-footer-dot{box-shadow:0 0 4px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.login-side-footer-dot{flex-shrink:0}.login-card-title{letter-spacing:-.02em;color:var(--pb-text-primary);padding-bottom:.08em;font-family:Syne,sans-serif;font-size:1.9rem;font-weight:800;line-height:1.08}.login-card-subtitle{color:var(--pb-text-secondary);padding-bottom:.05em;font-size:.875rem;line-height:1.62}.login-card-hint{color:var(--pb-text-tertiary);padding-bottom:.04em;font-size:.75rem;line-height:1.6}.floating-panel{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-overlay);overflow:hidden}.add-series-header-metrics{flex-wrap:wrap;align-items:flex-start;gap:1.5rem;display:flex}.add-series-results-shell{position:relative}.add-series-results-loading{z-index:12;background:var(--pb-bg-base);border-radius:.875rem;justify-content:center;align-items:flex-start;padding:1.25rem;display:flex;position:absolute;inset:0}@supports (color:color-mix(in lab, red, red)){.add-series-results-loading{background:color-mix(in srgb, var(--pb-bg-base) 55%, transparent)}}.add-series-results-loading{-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px);visibility:hidden;pointer-events:none}.add-series-results-loading.htmx-request{opacity:1;visibility:visible}.add-series-results-loading-card{border:1px solid var(--pb-border-default);background:var(--pb-bg-card);border-radius:.875rem;align-items:center;gap:.75rem;max-width:24rem;padding:.875rem 1rem;display:flex}@supports (color:color-mix(in lab, red, red)){.add-series-results-loading-card{background:color-mix(in srgb, var(--pb-bg-card) 94%, transparent)}}.add-series-results-loading-card{color:var(--pb-text-primary);box-shadow:var(--pb-shadow-1)}.add-series-results-loading-card svg{color:var(--pb-interactive);flex-shrink:0}.add-series-results-loading-title{font-size:.82rem;font-weight:800;line-height:1.2}.add-series-results-loading-copy{color:var(--pb-text-secondary);margin-top:.125rem;font-size:.74rem;line-height:1.35}.add-series-preview-notice{border:1px solid var(--pb-interactive);border-radius:.875rem;flex-wrap:wrap;align-items:center;gap:.875rem;padding:.875rem 1rem;display:flex}@supports (color:color-mix(in lab, red, red)){.add-series-preview-notice{border:1px solid color-mix(in srgb, var(--pb-interactive) 32%, var(--pb-border-default))}}.add-series-preview-notice{background:linear-gradient(135deg, var(--pb-interactive), transparent 70%), var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.add-series-preview-notice{background:linear-gradient(135deg, color-mix(in srgb, var(--pb-interactive) 12%, transparent), transparent 70%), var(--pb-bg-card)}}.add-series-preview-notice{color:var(--pb-text-primary);box-shadow:var(--pb-shadow-0)}.add-series-preview-notice-icon{background:var(--pb-interactive);border-radius:999px;flex:none;justify-content:center;align-items:center;width:2rem;height:2rem;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.add-series-preview-notice-icon{background:color-mix(in srgb, var(--pb-interactive) 14%, transparent)}}.add-series-preview-notice-icon{color:var(--pb-interactive)}.add-series-preview-notice-body{flex:auto;min-width:0}.add-series-preview-notice-title{letter-spacing:.08em;text-transform:uppercase;font-size:.78rem;font-weight:850;line-height:1.2}.add-series-preview-notice-copy{color:var(--pb-text-secondary);margin-top:.125rem;font-size:.8rem;line-height:1.45}.add-series-preview-notice-action{white-space:nowrap;flex:none;margin-left:auto}@media (max-width:640px){.add-series-preview-notice{align-items:flex-start}.add-series-preview-notice-action{justify-content:center;width:100%;margin-left:0}}.add-series-results-list{gap:calc(var(--spacing) * 2);flex-direction:column;display:flex}.add-series-result-card{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);border-radius:.875rem;align-items:flex-start;gap:.875rem;padding:.875rem;transition:border-color .14s,background-color .14s;display:flex}.add-series-result-card:hover{border-color:var(--pb-border-strong);background:var(--pb-surface-selected)}@supports (color:color-mix(in lab, red, red)){.add-series-result-card:hover{background:color-mix(in srgb, var(--pb-surface-selected) 35%, var(--pb-bg-card))}}.add-series-result-card-static:hover{border-color:var(--pb-border-subtle);background:var(--pb-bg-card)}.add-series-result-cover{aspect-ratio:2/3;border:1px solid var(--pb-border-subtle);background:var(--pb-surface-shell);width:3.5rem;min-width:3.5rem;color:var(--pb-text-tertiary);border-radius:.625rem;overflow:hidden}.add-series-result-cover-empty{justify-content:center;align-items:center;width:100%;height:100%;display:flex}.add-series-result-main{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:.875rem;display:flex}.add-series-result-title{color:var(--pb-text-primary);font-size:.92rem;font-weight:600;line-height:1.25}.add-series-result-meta{color:var(--pb-text-secondary);flex-wrap:wrap;gap:.25rem .75rem;margin-top:.25rem;font-size:.78rem;display:flex}.add-series-result-description{color:var(--pb-text-tertiary);-webkit-line-clamp:2;-webkit-box-orient:vertical;margin-top:.375rem;font-size:.78rem;line-height:1.55;display:-webkit-box;overflow:hidden}.add-series-result-actions{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.add-series-result-add{white-space:nowrap;flex-shrink:0}.add-series-empty-state{text-align:center;border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);border-radius:.875rem;padding:2.5rem 1.25rem}.add-series-empty-title{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text-primary);margin-top:.75rem;font-family:Syne,sans-serif;font-size:.9rem;font-weight:700}.add-series-empty-copy{color:var(--pb-text-secondary);margin-top:.25rem;font-size:.82rem;line-height:1.55}.add-series-modal-title{color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1rem;font-weight:800;line-height:1.1}.add-series-modal-subtitle{color:var(--pb-text-tertiary);font-size:.78rem}.add-series-modal-section-label{border-bottom:1px solid var(--pb-border-subtle);text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);padding-bottom:.375rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700}.add-series-modal-row{justify-content:space-between;align-items:center;gap:.75rem;display:flex}.add-series-modal-row-label{color:var(--pb-text-secondary);font-size:.85rem;font-weight:500}.add-series-modal-preview{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-shell);color:var(--pb-text-secondary);border-radius:.625rem;padding:.625rem .75rem;font-family:JetBrains Mono,monospace;font-size:.72rem;line-height:1.5}.add-series-root-display.input-pb:disabled,.add-series-root-display.input-pb[readonly]{opacity:1;color:var(--pb-text-primary);background:var(--pb-surface-input);cursor:default}.media-result-card{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);display:flex}.media-result-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.media-result-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.media-result-card:hover{box-shadow:var(--pb-shadow-2)}.media-result-card-static:hover{border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1)}.media-result-cover{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card-hover);flex-shrink:0;width:4.75rem;overflow:hidden}.media-result-cover-lg{width:6.25rem}.media-result-cover-inner{aspect-ratio:2/3;width:100%;height:100%}.media-result-empty{width:100%;height:100%;color:var(--pb-text-dim);justify-content:center;align-items:center;display:flex}.media-result-meta{margin-top:var(--spacing);align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);flex-wrap:wrap;display:flex}.media-result-divider{color:var(--pb-text-dim)}.match-file-card{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-direction:column;display:flex}@media (min-width:64rem){.match-file-card{flex-direction:row;align-items:flex-start}}.match-file-card{box-shadow:var(--pb-shadow-1)}.match-file-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.match-file-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.match-file-card:hover{box-shadow:var(--pb-shadow-2)}.match-file-format{height:calc(var(--spacing) * 14);width:calc(var(--spacing) * 14);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:.18em;letter-spacing:.18em;text-transform:uppercase;border-width:1px;flex-shrink:0;justify-content:center;align-items:center;display:flex}.match-file-format-cbz{color:var(--pb-interactive);border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbz{border-color:color-mix(in srgb, var(--pb-interactive) 24%, transparent)}}.match-file-format-cbz{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbz{background-color:color-mix(in srgb, var(--pb-interactive) 12%, transparent)}}.match-file-format-cbr{color:var(--pb-purple);border-color:var(--pb-purple)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbr{border-color:color-mix(in srgb, var(--pb-purple) 24%, transparent)}}.match-file-format-cbr{background-color:var(--pb-purple)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbr{background-color:color-mix(in srgb, var(--pb-purple) 12%, transparent)}}.match-file-format-pdf{color:var(--pb-warning);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.match-file-format-pdf{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.match-file-format-pdf{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.match-file-format-pdf{background-color:color-mix(in srgb, var(--pb-warning) 12%, transparent)}}.match-file-format-other{color:var(--pb-text-secondary);border-color:var(--pb-border);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.match-file-format-other{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.match-file-card-actions{gap:calc(var(--spacing) * 2);flex-direction:column;width:100%;display:flex}@media (min-width:64rem){.match-file-card-actions{align-items:flex-end;width:auto;min-width:11rem}}.library-series-card{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);min-width:0;height:100%;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);flex-direction:column;display:flex;overflow:hidden}.library-series-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.library-series-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 56%, var(--pb-bg-card))}}.library-series-card:hover{box-shadow:var(--pb-shadow-2)}.library-series-card-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-series-card-active{border-color:color-mix(in srgb, var(--pb-interactive) 30%, transparent)}}.library-series-card-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-series-card-active{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.library-series-cover-frame{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);background:linear-gradient(160deg, var(--pb-bg-card-hover) 0%, var(--pb-bg-card) 100%);position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.library-series-cover-frame{background:linear-gradient(160deg, color-mix(in srgb, var(--pb-bg-card-hover) 88%, transparent) 0%, color-mix(in srgb, var(--pb-bg-card) 82%, transparent) 100%)}}.library-series-cover-frame-compact{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border)}.library-series-placeholder{justify-content:center;align-items:center;gap:calc(var(--spacing) * 2);width:100%;height:100%;color:var(--pb-text-dim);flex-direction:column;display:flex}.library-series-meta{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.library-series-progress-track{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.library-series-progress-track{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.series-grid-card-meta{container-type:inline-size}.series-grid-card-publisher-year{grid-template-columns:8rem minmax(0,1fr);align-items:start;gap:.25rem .5rem;display:grid}.series-grid-card-publisher{overflow-wrap:anywhere;min-width:0}.series-grid-card-year{text-align:right;justify-self:end}.series-grid-card-progress-track{background-color:var(--pb-text-dim)}@supports (color:color-mix(in lab, red, red)){.series-grid-card-progress-track{background-color:color-mix(in srgb, var(--pb-text-dim) 72%, var(--pb-bg-card) 28%)}}@container (max-width:11rem){.series-grid-card-publisher-year{grid-template-columns:8rem}.series-grid-card-year{text-align:left;justify-self:start}}.admin-workspace-page{gap:calc(var(--spacing) * 4);flex-direction:column;min-height:0;display:flex}.admin-workspace-header{padding-block:0}.admin-workspace-header-copy{max-width:var(--container-3xl);min-width:0}.admin-workspace-tag-row{gap:calc(var(--spacing) * 2);padding-top:var(--spacing);flex-wrap:wrap;display:flex}.admin-workspace-header-aside{min-width:0}.admin-workspace-summary-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);height:100%;padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1)}.admin-workspace-body{gap:calc(var(--spacing) * 7);display:grid}@media (min-width:80rem){.admin-workspace-body{grid-template-columns:10.75rem minmax(0,1fr);align-items:flex-start}}.admin-workspace-body{padding-bottom:var(--pb-page-footer-clearance)}.admin-workspace-rail{gap:calc(var(--spacing) * 3);flex-direction:column;display:flex}@media (min-width:80rem){.admin-workspace-rail{top:calc(var(--spacing) * 24);position:sticky}}:where(.admin-workspace-rail-copy>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.admin-workspace-rail-links{gap:var(--spacing);flex-direction:column;display:flex}.admin-workspace-content{flex:1;min-width:0}:where(.admin-workspace-content>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.admin-workspace-content{width:min(100%,58rem)}.section-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);overflow:hidden}.admin-workspace-content .space-y-6>.section-card:not(:has(~.section-card)){margin-bottom:0!important}.section-card-visible{overflow:visible}.system-version-banner{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);flex-direction:column;padding-block:1.125rem;display:flex;position:relative;overflow:hidden}@media (min-width:64rem){.system-version-banner{flex-direction:row;justify-content:space-between;align-items:center}}.system-version-banner{box-shadow:var(--pb-shadow-1)}.system-version-banner:before{content:"";background:var(--pb-brand);width:3px;position:absolute;top:0;bottom:0;left:0}.system-version-banner-main{align-items:flex-start;gap:calc(var(--spacing) * 4);min-width:0;display:flex}.system-version-banner-icon{height:calc(var(--spacing) * 11);width:calc(var(--spacing) * 11);background:var(--pb-brand-muted);color:var(--pb-brand);border-radius:10px;flex-shrink:0;justify-content:center;align-items:center;display:flex}.system-version-banner-title{letter-spacing:-.02em;color:var(--pb-text-primary);flex-wrap:wrap;align-items:center;gap:.625rem;font-family:Bricolage Grotesque,sans-serif;font-size:1.125rem;font-weight:800;display:flex}.system-version-banner-meta{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.system-version-banner-meta span{font-family:JetBrains Mono,monospace;font-size:.6875rem}.system-version-banner-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;flex-shrink:0;display:flex}:where(.system-detail-list>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}.system-detail-list{border-color:var(--pb-border-subtle)}.system-detail-row{justify-content:space-between;align-items:baseline;gap:calc(var(--spacing) * 5);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 2.5);display:flex}.system-detail-label{color:var(--pb-text-secondary);flex-shrink:0;font-size:13px}.system-detail-value{color:var(--pb-text-primary);text-align:right;word-break:break-word;font-family:JetBrains Mono,monospace;font-size:.78125rem}.system-detail-value-brand{color:var(--pb-brand)}.system-registry-filename{--tw-leading:calc(var(--spacing) * 5);font-family:JetBrains Mono,Fira Code,monospace;font-size:.8125rem;line-height:calc(var(--spacing) * 5);--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.system-links-grid{gap:calc(var(--spacing) * 2);padding:calc(var(--spacing) * 5);grid-template-columns:repeat(auto-fit,minmax(11rem,1fr));display:grid}.system-link-card{align-items:center;gap:calc(var(--spacing) * 2.5);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 3.5);padding-block:calc(var(--spacing) * 2.5);--tw-font-weight:var(--font-weight-medium);font-size:12.5px;font-weight:var(--font-weight-medium);color:var(--pb-text-primary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background:var(--pb-bg-card-hover);display:flex}@supports (color:color-mix(in lab, red, red)){.system-link-card{background:color-mix(in srgb, var(--pb-bg-card-hover) 20%, transparent)}}.system-link-card:hover{border-color:var(--pb-border-hover);background:var(--pb-bg-card-hover)}.system-link-card svg{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);color:var(--pb-text-dim);flex-shrink:0}.system-link-card-arrow{height:calc(var(--spacing) * 3);width:calc(var(--spacing) * 3);color:var(--pb-text-dim);margin-left:auto}.stat-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.stat-card-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.stat-card-value{margin-top:calc(var(--spacing) * 3);font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.stat-card-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-secondary)}.info-panel{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1)}.info-panel-muted{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.info-panel-muted{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.dashboard-briefing-shell{border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-2);background:radial-gradient(circle at top right, var(--pb-interactive) 0%, transparent 38%), radial-gradient(circle at left bottom, var(--pb-brand) 0%, transparent 34%), var(--pb-bg-card);position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.dashboard-briefing-shell{background:radial-gradient(circle at top right, color-mix(in srgb, var(--pb-interactive) 14%, transparent) 0%, transparent 38%), radial-gradient(circle at left bottom, color-mix(in srgb, var(--pb-brand) 10%, transparent) 0%, transparent 34%), var(--pb-bg-card)}}.dashboard-briefing-shell:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.dashboard-briefing-shell:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 44%, transparent), color-mix(in srgb, var(--pb-interactive) 44%, transparent), transparent)}}.dashboard-briefing-meta{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);width:100%;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}@media (min-width:64rem){.dashboard-briefing-meta{max-width:13rem}}.dashboard-briefing-meta{box-shadow:var(--pb-shadow-1);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.dashboard-briefing-meta{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 42%, transparent)}}.dashboard-status-pill,.dashboard-priority-badge,.dashboard-watch-tag,.dashboard-scorecard-delta{align-items:center;gap:var(--spacing);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.16em;letter-spacing:.16em;text-transform:uppercase;border-width:1px;border-color:currentColor;border-radius:3.40282e38px;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.dashboard-status-pill,.dashboard-priority-badge,.dashboard-watch-tag,.dashboard-scorecard-delta{border-color:color-mix(in srgb, currentColor 20%, transparent)}}.dashboard-priority-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.dashboard-priority-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}.dashboard-priority-card-v2{align-items:flex-start;gap:calc(var(--spacing) * 4);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:14px;grid-template-columns:1fr auto;transition:box-shadow .14s;display:grid;position:relative;overflow:hidden}.dashboard-priority-card-v2:before{content:"";background-color:#0000;width:6px;position:absolute;inset:0 auto 0 0}.dashboard-priority-card-v2:hover{box-shadow:var(--pb-shadow-2)}.dashboard-priority-card-v2-tone-pill-error:before{background-color:var(--pb-error)}.dashboard-priority-card-v2-tone-pill-warning:before{background-color:var(--pb-warning)}.dashboard-priority-card-v2-tone-pill-info:before{background-color:var(--pb-info)}.dashboard-priority-card-v2-tone-pill-success:before{background-color:var(--pb-success)}.dashboard-priority-card-v2-tone-pill-neutral:before,.dashboard-priority-card-v2-tone-pill-muted:before{background-color:var(--pb-text-dim)}.dashboard-priority-fact{border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-base);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);display:inline-flex}.dashboard-priority-card,.dashboard-decision-card{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-width:1px}.dashboard-priority-card-critical,.dashboard-decision-card-critical{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-critical,.dashboard-decision-card-critical{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.dashboard-priority-card-critical,.dashboard-decision-card-critical{background:linear-gradient(135deg, var(--pb-error) 0%, var(--pb-bg-card-hover) 100%)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-critical,.dashboard-decision-card-critical{background:linear-gradient(135deg, color-mix(in srgb, var(--pb-error) 10%, var(--pb-bg-card)) 0%, color-mix(in srgb, var(--pb-bg-card-hover) 92%, transparent) 100%)}}.dashboard-priority-card-high,.dashboard-decision-card-high{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-high,.dashboard-decision-card-high{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.dashboard-priority-card-high,.dashboard-decision-card-high{background:linear-gradient(135deg, var(--pb-warning) 0%, var(--pb-bg-card-hover) 100%)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-high,.dashboard-decision-card-high{background:linear-gradient(135deg, color-mix(in srgb, var(--pb-warning) 10%, var(--pb-bg-card)) 0%, color-mix(in srgb, var(--pb-bg-card-hover) 92%, transparent) 100%)}}.dashboard-priority-card-watch,.dashboard-decision-card-watch{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-watch,.dashboard-decision-card-watch{border-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.dashboard-priority-card-watch,.dashboard-decision-card-watch{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-watch,.dashboard-decision-card-watch{background-color:color-mix(in srgb, var(--pb-warning) 7%, var(--pb-bg-card))}}.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{border-color:color-mix(in srgb, var(--pb-success) 20%, transparent)}}.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{background-color:color-mix(in srgb, var(--pb-success) 7%, var(--pb-bg-card))}}.dashboard-priority-card-info,.dashboard-decision-card-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-info,.dashboard-decision-card-info{border-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.dashboard-priority-card-info,.dashboard-decision-card-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-info,.dashboard-decision-card-info{background-color:color-mix(in srgb, var(--pb-info) 7%, var(--pb-bg-card))}}.dashboard-priority-facts{margin-top:calc(var(--spacing) * 5);gap:calc(var(--spacing) * 3);border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-top:calc(var(--spacing) * 4);display:grid}@media (min-width:40rem){.dashboard-priority-facts{grid-template-columns:repeat(3,minmax(0,1fr))}}.dashboard-priority-facts dt{--tw-font-weight:var(--font-weight-medium);font-size:10px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.dashboard-priority-facts dd{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-primary)}.dashboard-quiet-note{align-items:center;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-success);background:var(--pb-success);display:flex}@supports (color:color-mix(in lab, red, red)){.dashboard-quiet-note{background:color-mix(in srgb, var(--pb-success) 10%, var(--pb-bg-card))}}.dashboard-quiet-note{border:1px solid var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-quiet-note{border:1px solid color-mix(in srgb, var(--pb-success) 18%, transparent)}}.dashboard-pulse-tile{padding-block:calc(var(--spacing) * 2.5);flex-direction:column;align-items:flex-start;display:flex}.dashboard-pulse-value{margin-top:var(--spacing);font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height));--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.dashboard-pulse-tile-right{border-left-style:var(--tw-border-style);border-left-width:1px;border-color:var(--pb-border);padding-left:calc(var(--spacing) * 3)}.dashboard-pulse-tile-bottom{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-top:calc(var(--spacing) * 2.5)}.dashboard-scoreboard-attention-grid{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.dashboard-scoreboard-attention-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.dashboard-scoreboard-attention-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}.dashboard-scorecard{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);min-width:0;height:100%;padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-width:1px;flex-direction:column;justify-content:space-between;display:flex}.dashboard-scorecard-critical{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-critical{border-color:color-mix(in srgb, var(--pb-error) 22%, transparent)}}.dashboard-scorecard-critical{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-critical{background-color:color-mix(in srgb, var(--pb-error) 8%, var(--pb-bg-card))}}.dashboard-scorecard-high{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-high{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.dashboard-scorecard-high{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-high{background-color:color-mix(in srgb, var(--pb-warning) 9%, var(--pb-bg-card))}}.dashboard-scorecard-watch{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-watch{border-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.dashboard-scorecard-watch{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-watch{background-color:color-mix(in srgb, var(--pb-warning) 7%, var(--pb-bg-card))}}.dashboard-scorecard-healthy{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-healthy{border-color:color-mix(in srgb, var(--pb-success) 20%, transparent)}}.dashboard-scorecard-healthy{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-healthy{background-color:color-mix(in srgb, var(--pb-success) 7%, var(--pb-bg-card))}}.dashboard-scorecard-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-info{border-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.dashboard-scorecard-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-info{background-color:color-mix(in srgb, var(--pb-info) 7%, var(--pb-bg-card))}}.dashboard-scorecard-title{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.dashboard-scorecard-value{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.dashboard-scorecard-value-watch{color:var(--pb-status-warning)}.dashboard-scorecard-value-critical{color:var(--pb-status-danger)}.dashboard-scorecard-copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.dashboard-healthy-strip{align-items:center;gap:calc(var(--spacing) * 2);column-gap:calc(var(--spacing) * 6);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 3.5);flex-wrap:wrap;display:flex}.dashboard-healthy-strip-label{align-items:center;gap:calc(var(--spacing) * 1.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-success);display:flex}.dashboard-healthy-metric{align-items:baseline;gap:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);font-size:.82rem;display:flex}.dashboard-healthy-metric strong{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold);color:var(--pb-text-primary)}.dashboard-inline-link{align-items:center;gap:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.dashboard-inline-link:hover{color:var(--pb-interactive-hover)}.dashboard-download-row{align-items:center;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3.5);grid-template-columns:1fr 100px 80px;display:grid}.dashboard-watch-item,.dashboard-exception-row{border-radius:var(--radius-2xl)}.dashboard-watch-item-critical,.dashboard-exception-row-critical{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-critical,.dashboard-exception-row-critical{border-color:color-mix(in srgb, var(--pb-error) 20%, transparent)}}.dashboard-watch-item-critical,.dashboard-exception-row-critical{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-critical,.dashboard-exception-row-critical{background-color:color-mix(in srgb, var(--pb-error) 7%, var(--pb-bg-card))}}.dashboard-watch-item-high,.dashboard-exception-row-high{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-high,.dashboard-exception-row-high{border-color:color-mix(in srgb, var(--pb-warning) 22%, transparent)}}.dashboard-watch-item-high,.dashboard-exception-row-high{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-high,.dashboard-exception-row-high{background-color:color-mix(in srgb, var(--pb-warning) 8%, var(--pb-bg-card))}}.dashboard-watch-item-watch,.dashboard-exception-row-watch{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-watch,.dashboard-exception-row-watch{border-color:color-mix(in srgb, var(--pb-warning) 16%, transparent)}}.dashboard-watch-item-watch,.dashboard-exception-row-watch{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-watch,.dashboard-exception-row-watch{background-color:color-mix(in srgb, var(--pb-warning) 6%, var(--pb-bg-card))}}.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{border-color:color-mix(in srgb, var(--pb-success) 16%, transparent)}}.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{background-color:color-mix(in srgb, var(--pb-success) 6%, var(--pb-bg-card))}}.dashboard-watch-item-info,.dashboard-exception-row-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-info,.dashboard-exception-row-info{border-color:color-mix(in srgb, var(--pb-info) 16%, transparent)}}.dashboard-watch-item-info,.dashboard-exception-row-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-info,.dashboard-exception-row-info{background-color:color-mix(in srgb, var(--pb-info) 6%, var(--pb-bg-card))}}.dashboard-mission-page{gap:calc(var(--spacing) * 4);width:100%;padding-bottom:var(--pb-page-footer-clearance);flex-direction:column;display:flex}.dashboard-mission-control{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 5);flex-wrap:wrap;display:flex}.dashboard-mission-control__summary{align-items:flex-start;gap:calc(var(--spacing) * 6);flex-wrap:wrap;flex:1;min-width:0;display:flex}.dashboard-mission-control__title-block{min-width:0}.dashboard-mission-control__title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text);font-family:Syne,sans-serif;font-size:1.5rem;font-weight:800;line-height:1}.dashboard-mission-control__title span{color:var(--pb-brand)}.dashboard-mission-control__subtitle{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.dashboard-mission-control__gauges{align-items:flex-end;gap:calc(var(--spacing) * 4);flex-wrap:wrap;display:flex}.dashboard-mission-control__gauges-spacer{flex:none;width:224px;min-width:224px;min-height:72px}.dashboard-gauge{text-align:center;min-width:64px}.dashboard-gauge__ring{width:56px;height:56px;margin-inline:auto;position:relative}.dashboard-gauge__ring svg{width:56px;height:56px;transform:rotate(-90deg)}.dashboard-gauge__track{fill:none;stroke:var(--pb-text-dim)}@supports (color:color-mix(in lab, red, red)){.dashboard-gauge__track{stroke:color-mix(in srgb, var(--pb-text-dim) 14%, transparent)}}.dashboard-gauge__track{stroke-width:4.5px}.dashboard-gauge__fill{fill:none;stroke-width:4.5px;stroke-linecap:round}.dashboard-gauge__value{justify-content:center;align-items:center;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700;display:flex;position:absolute;inset:0}.dashboard-gauge__label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);margin-top:.2rem;font-size:.52rem;font-weight:600;display:block}.dashboard-gauge--success .dashboard-gauge__fill,.dashboard-gauge--success .dashboard-gauge__value{stroke:var(--pb-success);color:var(--pb-success)}.dashboard-gauge--info .dashboard-gauge__fill,.dashboard-gauge--info .dashboard-gauge__value{stroke:var(--pb-info);color:var(--pb-info)}.dashboard-gauge--warning .dashboard-gauge__fill,.dashboard-gauge--warning .dashboard-gauge__value{stroke:var(--pb-warning);color:var(--pb-warning)}.dashboard-gauge--danger .dashboard-gauge__fill,.dashboard-gauge--danger .dashboard-gauge__value{stroke:var(--pb-error);color:var(--pb-error)}.dashboard-gauge--neutral .dashboard-gauge__fill,.dashboard-gauge--neutral .dashboard-gauge__value{stroke:var(--pb-text-dim);color:var(--pb-text-dim)}.dashboard-scoreboard{grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:.5rem;display:grid}.dashboard-scoreboard__item{gap:calc(var(--spacing) * .5);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);min-width:0;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);box-shadow:var(--pb-shadow-1);border-radius:10px;flex-direction:column;display:flex}.dashboard-scoreboard__label,.dashboard-section__label{letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.54rem;font-weight:700}.dashboard-scoreboard__value{color:var(--pb-text);font-family:JetBrains Mono,monospace;font-size:1.05rem;font-weight:700}.dashboard-scoreboard__delta{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.66rem}:where(.dashboard-section>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}.dashboard-table-wrap,.dashboard-activity-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:12px;overflow-x:auto}.dashboard-table{border-collapse:collapse;width:100%}.dashboard-table th{text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);background:var(--pb-bg-shell);border-bottom:1px solid var(--pb-border);padding:.45rem .9rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700}.dashboard-table th.c,.dashboard-table td.c{text-align:center}.dashboard-table th.r,.dashboard-table td.r{text-align:right}.dashboard-table td{border-bottom:1px solid var(--pb-border-subtle);padding:.6rem .9rem;font-size:.82rem;transition:background-color .12s}.dashboard-table tbody tr:last-child td{border-bottom:0}.dashboard-table tbody tr:hover td{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.dashboard-table tbody tr:hover td{background:color-mix(in srgb, var(--pb-interactive) 8%, var(--pb-bg-card))}}.dashboard-table__status-col{width:40px}.dashboard-table__name{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.dashboard-table__detail{margin-top:calc(var(--spacing) * .5);color:var(--pb-text-dim);font-size:.75rem}.table-mono-value,td.table-mono-value,.dashboard-table__mono,td.dashboard-table__mono,.health-table__mono,td.health-table__mono,.series-mission-control-owned,.series-mission-control-type,.utility-tool-table-mono,td.utility-tool-table-mono,.downloads-mono-cell,td.downloads-mono-cell{color:var(--pb-text-secondary);font-variant-numeric:tabular-nums;font-family:JetBrains Mono,monospace;font-size:.75rem;font-weight:400;line-height:1.2}.series-mission-control-reading{color:var(--pb-interactive);white-space:nowrap;margin-top:.22rem;font-family:JetBrains Mono,monospace;font-size:.58rem;font-weight:600;line-height:1.2;display:block}.dashboard-table__link{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.dashboard-table__link:hover{color:var(--pb-interactive-hover)}.dashboard-led{vertical-align:middle;border-radius:999px;flex-shrink:0;width:8px;height:8px;display:inline-block}.dashboard-led--sys{border:1px solid var(--pb-text);width:10px;height:10px}@supports (color:color-mix(in lab, red, red)){.dashboard-led--sys{border:1px solid color-mix(in srgb, var(--pb-text) 10%, transparent)}}.dashboard-led--green{background:var(--pb-success);box-shadow:0 0 5px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.dashboard-led--amber{background:var(--pb-warning);box-shadow:0 0 5px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-warning) 35%, transparent)}}.dashboard-led--red{background:var(--pb-error);box-shadow:0 0 5px var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--red{box-shadow:0 0 5px color-mix(in srgb, var(--pb-error) 35%, transparent)}}.dashboard-led--blue{background:var(--pb-info);box-shadow:0 0 5px var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--blue{box-shadow:0 0 5px color-mix(in srgb, var(--pb-info) 35%, transparent)}}.dashboard-led--off{background:var(--pb-text-dim);opacity:.4}.dashboard-progress{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.dashboard-progress__track{height:calc(var(--spacing) * 1.5);border-radius:var(--radius-xs);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background:var(--pb-text-dim);flex:1;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.dashboard-progress__track{background:color-mix(in srgb, var(--pb-text-dim) 10%, transparent)}}.dashboard-progress__fill{height:100%;position:relative}.dashboard-progress__fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}.dashboard-progress__fill--green{background:linear-gradient(90deg, var(--pb-success), var(--pb-success))}@supports (color:color-mix(in lab, red, red)){.dashboard-progress__fill--green{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-success) 45%, transparent), var(--pb-success))}}.dashboard-progress__fill--blue{background:linear-gradient(90deg, var(--pb-info), var(--pb-info))}@supports (color:color-mix(in lab, red, red)){.dashboard-progress__fill--blue{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-info) 45%, transparent), var(--pb-info))}}.dashboard-progress__fill--pulse{opacity:.6;width:100%;animation:1.6s ease-in-out infinite pulse}.dashboard-progress__percent,.dashboard-activity-row__time,.dashboard-footer-strip{font-family:JetBrains Mono,monospace}.dashboard-progress__percent{text-align:right;min-width:32px;color:var(--pb-text-sec);font-size:.66rem;font-weight:600}.dashboard-activity-card{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2)}.dashboard-activity-row{align-items:flex-start;gap:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);border-bottom:1px solid var(--pb-border-subtle);display:flex}.dashboard-activity-row:last-child{border-bottom:0}.dashboard-activity-row__dot{margin-top:calc(var(--spacing) * 1.5);height:calc(var(--spacing) * 2);width:calc(var(--spacing) * 2);border-radius:3.40282e38px;flex-shrink:0}.dashboard-activity-row__dot--acquired{background:var(--pb-success)}.dashboard-activity-row__dot--imported{background:var(--pb-brand)}.dashboard-activity-row__dot--failed{background:var(--pb-error)}.dashboard-activity-row__body{flex:1}.dashboard-activity-row__summary{--tw-leading:calc(var(--spacing) * 6);font-size:.82rem;line-height:calc(var(--spacing) * 6);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary)}.dashboard-activity-row__detail{--tw-leading:calc(var(--spacing) * 5);font-size:.78rem;line-height:calc(var(--spacing) * 5);color:var(--pb-text-secondary)}.dashboard-activity-row__meta{margin-top:var(--spacing);justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);display:flex}.dashboard-activity-row__time{color:var(--pb-text-dim);font-size:.66rem}.dashboard-activity-row__link{--tw-font-weight:var(--font-weight-semibold);font-size:.68rem;font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.dashboard-activity-row__link:hover{color:var(--pb-interactive-hover)}.dashboard-activity-card__empty{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-dim)}.dashboard-footer-strip{align-items:center;column-gap:calc(var(--spacing) * 6);row-gap:calc(var(--spacing) * 2);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2.5);color:var(--pb-text-dim);box-shadow:var(--pb-shadow-1);border-radius:10px;flex-wrap:wrap;font-size:.72rem;display:flex}.dashboard-footer-strip strong{color:var(--pb-text);font-weight:600}.library-page-shell{flex:1;width:100%;min-height:0;overflow:hidden}.library-format-pills{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.library-scoreboard__item{text-decoration:none;transition:background-color .12s}.library-scoreboard__item--link:hover{background-color:var(--pb-bg-card-hover)}.library-scoreboard__value--info{color:var(--pb-info)}.library-scoreboard__value--success{color:var(--pb-success)}.library-scoreboard__value--warning{color:var(--pb-warning)}.library-matching-banner{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));border-width:1px;border-color:var(--pb-warning);border-radius:12px;display:flex}@supports (color:color-mix(in lab, red, red)){.library-matching-banner{border-color:color-mix(in srgb, var(--pb-warning) 26%, transparent)}}.library-matching-banner{background:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.library-matching-banner{background:color-mix(in srgb, var(--pb-warning) 8%, var(--pb-bg-card))}}.library-matching-banner{box-shadow:var(--pb-shadow-1)}.library-matching-banner__body{align-items:center;column-gap:calc(var(--spacing) * 2);row-gap:var(--spacing);flex-wrap:wrap;min-width:0;display:flex}.library-matching-banner__title{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-matching-banner__copy{color:var(--pb-text-secondary)}.library-browser{--library-browser-header-height:calc(1.5rem + .75rem + 1px);border:1px solid var(--pb-border);background:var(--pb-bg-card);min-height:0;box-shadow:var(--pb-shadow-1);border-radius:14px;flex:1 1 0;grid-template-columns:minmax(220px,260px) minmax(0,1fr);display:grid;overflow:hidden}.library-browser__tree{overscroll-behavior:contain;z-index:2;border-right:1px solid var(--pb-border);background:var(--pb-bg-surface);flex-direction:column;min-height:0;display:flex;position:relative;overflow:hidden}.library-browser__tree-list{min-height:0;padding-block:var(--spacing);overscroll-behavior:contain;flex:1;overflow-y:auto}.library-browser__tree-header,.library-browser__toolbar{min-height:var(--library-browser-header-height);height:var(--library-browser-header-height)}.library-browser__tree-header{padding-inline:calc(var(--spacing) * 3);z-index:3;letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);border-bottom:1px solid var(--pb-border);background:var(--pb-bg-surface);align-items:center;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700;display:flex;position:sticky;top:0}@supports (color:color-mix(in lab, red, red)){.library-browser__tree-header{background:color-mix(in srgb, var(--pb-bg-surface) 72%, transparent)}}.library-browser__tree-node{min-height:calc(var(--spacing) * 6);border-radius:var(--radius-md);min-width:0;padding-inline:calc(var(--spacing) * 1.5);padding-block:var(--spacing);color:var(--pb-text-secondary);flex:1;align-items:center;font-size:.73rem;font-weight:400;line-height:1.2;text-decoration:none;display:flex}.library-browser__tree-node--active{background:var(--pb-interactive-dim);color:var(--pb-interactive);font-weight:500}.library-browser__tree-icon{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);color:var(--pb-text-dim);flex-shrink:0;transition:transform .14s,color .14s}.library-browser__tree-icon--folder{color:var(--pb-brand)}.library-browser__tree-icon--expanded{color:var(--pb-interactive);transform:rotate(90deg)}.library-browser__tree-item{padding-left:calc(var(--library-tree-level,0) * .8rem)}.library-browser__tree-row{min-height:calc(var(--spacing) * 6);align-items:center;gap:var(--spacing);min-width:0;padding-inline:calc(var(--spacing) * 2);display:flex}.library-browser__tree-row--context .library-browser__tree-node,.library-browser__tree-row--context .library-browser__tree-toggle,.library-browser__tree-row--context .library-browser__tree-leaf{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser__tree-row--context .library-browser__tree-node,.library-browser__tree-row--context .library-browser__tree-toggle,.library-browser__tree-row--context .library-browser__tree-leaf{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.library-browser__tree-row--context .library-browser__tree-node,.library-browser__tree-row--context .library-browser__tree-toggle,.library-browser__tree-row--context .library-browser__tree-leaf{color:var(--pb-interactive)}.library-browser__tree-toggle,.library-browser__tree-leaf{height:calc(var(--spacing) * 6);width:calc(var(--spacing) * 6);color:var(--pb-text-dim);flex-shrink:0;justify-content:center;align-items:center;display:inline-flex}.library-browser__tree-toggle{border-radius:4px;transition:background-color .12s,color .12s}.library-browser__tree-toggle:hover{background:var(--pb-bg-card-hover);color:var(--pb-text)}:where(.library-browser__tree-children>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}.library-browser__tree-name{flex:1;min-width:0;display:block}.library-browser__panel{z-index:1;flex-direction:column;min-width:0;min-height:0;display:flex;position:relative;overflow:hidden}.library-browser__toolbar{align-items:center;gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 3);border-bottom:1px solid var(--pb-border);background:var(--pb-bg-surface);flex-shrink:0;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser__toolbar{background:color-mix(in srgb, var(--pb-bg-surface) 72%, transparent)}}.library-browser__toolbar-main{align-items:center;gap:calc(var(--spacing) * 1.5);flex:1;min-width:0;display:flex}.library-browser__up-btn{height:calc(var(--spacing) * 6);width:calc(var(--spacing) * 6);border-radius:var(--radius-xs);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-shrink:0;justify-content:center;align-items:center;text-decoration:none;display:inline-flex}.library-browser__up-btn:hover{background:var(--pb-bg-card-hover);color:var(--pb-text)}.library-browser__up-btn.is-disabled{pointer-events:none;opacity:.4}.library-browser__path-block{flex:1;align-items:center;min-width:0;display:flex}.library-browser__path{color:var(--pb-text-sec);white-space:nowrap;text-overflow:ellipsis;font-family:JetBrains Mono,monospace;font-size:.56rem;line-height:1.15;overflow:hidden}.library-browser__table-wrap{overscroll-behavior:contain;min-height:0;box-shadow:none;background:0 0;border:0;border-radius:0;flex:1;position:relative;overflow:auto}.library-browser-table{min-width:760px}.library-browser-table th{padding:.4rem .75rem;font-size:.54rem;font-weight:600}.library-browser-table td{padding:.5rem .75rem;font-size:.76rem;font-weight:400;line-height:1.3}.library-browser__table-row--file,.library-browser__table-row--file td,.library-browser__table-row--file .library-browser__name-label{cursor:pointer}.library-browser__table-row--context{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser__table-row--context{background:color-mix(in srgb, var(--pb-interactive) 8%, transparent)}}.library-context-menu{border-radius:var(--radius-xl);border-style:var(--tw-border-style);min-width:15.5rem;padding-block:calc(var(--spacing) * 1.5);z-index:80;border-width:1px;border-color:var(--pb-border);position:fixed;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.library-context-menu{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-context-menu{background:linear-gradient(180deg, var(--pb-bg-card) 0%, var(--pb-bg-surface) 100%)}@supports (color:color-mix(in lab, red, red)){.library-context-menu{background:linear-gradient(180deg, color-mix(in srgb, var(--pb-bg-card) 94%, transparent) 0%, color-mix(in srgb, var(--pb-bg-surface) 97%, transparent) 100%)}}.library-context-menu{-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);box-shadow:0 18px 48px #0f172a42,0 6px 18px #0f172a29}.library-context-menu__group{display:contents}.library-context-menu__item{align-items:center;gap:calc(var(--spacing) * 3);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));color:var(--pb-text);display:flex}.library-context-menu__item:hover{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-context-menu__item:hover{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.library-context-menu__item:hover{color:var(--pb-interactive)}.library-context-menu__item--danger:hover{background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-context-menu__item--danger:hover{background:color-mix(in srgb, var(--pb-error) 11%, transparent)}}.library-context-menu__item--danger:hover{color:var(--pb-error)}.library-context-menu__icon{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);color:currentColor;flex-shrink:0}.library-context-menu__sep{margin-block:var(--spacing);background:var(--pb-border-subtle);height:1px}@supports (color:color-mix(in lab, red, red)){.library-context-menu__sep{background:color-mix(in srgb, var(--pb-border-subtle) 80%, transparent)}}.modal-panel.library-browser-modal{border-radius:18px;width:min(92vw,48rem);max-height:min(86vh,52rem);overflow:hidden}.modal-panel.library-browser-modal--properties{width:min(84vw,28rem)}.modal-panel.library-browser-modal--compact,.modal-panel.library-delete-modal{width:min(92vw,34rem)}.library-delete-modal__header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 4);border-bottom-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);border-bottom-width:1px;border-color:var(--pb-border);display:flex}.library-delete-modal__eyebrow{letter-spacing:.14em;text-transform:uppercase;color:var(--pb-text-dim);font-size:11px;font-weight:500}.library-delete-modal__title{margin-top:var(--spacing);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-delete-modal__subtitle{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}:where(.library-delete-modal__body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.library-delete-modal__body{padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4)}.library-delete-modal__copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.library-delete-modal__impact{border-radius:var(--radius-lg);border-style:var(--tw-border-style);padding:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border-hover)}@supports (color:color-mix(in lab, red, red)){.library-delete-modal__impact{border-color:color-mix(in srgb, var(--pb-border-hover) 88%, transparent)}}.library-delete-modal__impact{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-delete-modal__impact{background:color-mix(in srgb, var(--pb-bg-surface) 40%, transparent)}}.library-delete-modal__impact .library-browser-modal__details-table{margin-top:0}.series-delete-modal__options{gap:calc(var(--spacing) * 3);flex-direction:column;display:flex}.series-delete-modal__option{align-items:flex-start;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border);display:flex}@supports (color:color-mix(in lab, red, red)){.series-delete-modal__option{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.series-delete-modal__option{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.series-delete-modal__option{background:color-mix(in srgb, var(--pb-bg-surface) 82%, transparent)}}.series-delete-modal__checkbox{margin-top:calc(var(--spacing) * .5);border-color:var(--pb-border-hover);background:var(--pb-bg-card-hover);color:var(--pb-error);border-radius:.25rem}.series-delete-modal__checkbox:focus{--tw-ring-color:var(--pb-error);--tw-ring-offset-width:0px}.series-delete-modal__option-copy{min-width:0}.series-delete-modal__option-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary)}.series-delete-modal__option-text{margin-top:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);line-height:1.5}.library-delete-modal__footer{justify-content:flex-end;align-items:center;gap:calc(var(--spacing) * 2);border-top-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 3);border-top-width:1px;border-color:var(--pb-border);background:var(--pb-bg-card);display:flex}@supports (color:color-mix(in lab, red, red)){.library-delete-modal__footer{background:color-mix(in srgb, var(--pb-bg-card) 80%, transparent)}}.library-browser-modal__header{align-items:flex-start;gap:calc(var(--spacing) * 4);background:linear-gradient(180deg, var(--pb-bg-surface) 0%, var(--pb-bg-card) 100%)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__header{background:linear-gradient(180deg, color-mix(in srgb, var(--pb-bg-surface) 95%, transparent) 0%, color-mix(in srgb, var(--pb-bg-card) 96%, transparent) 100%)}}.library-browser-modal--properties .library-browser-modal__header,.library-browser-modal--properties .library-browser-modal__body{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.library-browser-modal--properties .modal-footer{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);justify-content:flex-end}.library-browser-modal--form .library-browser-modal__body{padding-block:0;padding-inline:0}.library-browser-modal--form .modal-footer{padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 3)}.library-browser-modal .modal-footer{justify-content:flex-end;gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 3);background:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal .modal-footer{background:color-mix(in srgb, var(--pb-bg-card) 80%, transparent)}}.library-browser-modal__title-block{align-items:flex-start;gap:calc(var(--spacing) * 3);min-width:0;display:flex}.library-browser-modal__icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);color:var(--pb-interactive);border-width:1px;border-color:var(--pb-interactive);flex-shrink:0;justify-content:center;align-items:center;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon{border-color:color-mix(in srgb, var(--pb-interactive) 24%, transparent)}}.library-browser-modal__icon{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.library-browser-modal__icon svg{height:calc(var(--spacing) * 5);width:calc(var(--spacing) * 5)}.library-browser-modal__icon--warning{color:var(--pb-warning);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--warning{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.library-browser-modal__icon--warning{background:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--warning{background:color-mix(in srgb, var(--pb-warning) 10%, transparent)}}.library-browser-modal__icon--danger{color:var(--pb-error);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--danger{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.library-browser-modal__icon--danger{background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--danger{background:color-mix(in srgb, var(--pb-error) 10%, transparent)}}.library-browser-modal--properties .library-browser-modal__icon{height:calc(var(--spacing) * 9);width:calc(var(--spacing) * 9);border-radius:var(--radius-xl)}.library-browser-modal--properties .library-browser-modal__icon svg{width:1.125rem;height:1.125rem}.library-browser-modal__title{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-browser-modal__subtitle{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.library-browser-modal__close{border-radius:var(--radius-lg);padding:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.library-browser-modal__close:hover{background:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__close:hover{background:color-mix(in srgb, var(--pb-bg-card-hover) 88%, transparent)}}.library-browser-modal__close:hover{color:var(--pb-text)}.library-browser-modal__close:focus-visible{outline:2px solid var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__close:focus-visible{outline:2px solid color-mix(in srgb, var(--pb-interactive) 42%, transparent)}}.library-browser-modal__close:focus-visible{outline-offset:2px}.library-browser-modal__body{min-height:0}.library-browser-modal__form{flex-direction:column;flex:1;min-height:0;display:flex}.library-browser-modal__content{gap:calc(var(--spacing) * 4);flex-direction:column;display:flex}.library-browser-modal__section-label{margin-bottom:calc(var(--spacing) * 2);padding-bottom:calc(var(--spacing) * 2);letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);border-bottom:1px solid var(--pb-border-subtle);font-family:Syne,sans-serif;font-size:.64rem;font-weight:700;display:block}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__section-label{border-bottom:1px solid color-mix(in srgb, var(--pb-border-subtle) 80%, transparent)}}.library-browser-modal__section{padding-bottom:var(--spacing)}.library-browser-modal__details-table{border-collapse:collapse;table-layout:fixed;width:100%;margin-top:.625rem;font-size:.8rem}.library-browser-modal__details-table--flush{margin-top:0}.library-browser-modal__detail-label{vertical-align:top;width:6.875rem;color:var(--pb-text-dim);font-size:inherit;text-align:left;padding:.25rem .75rem .25rem 0;font-weight:500;line-height:1.35}.library-browser-modal__detail-value{vertical-align:top;min-width:0;color:var(--pb-text);font-size:inherit;overflow-wrap:anywhere;padding:.25rem 0;font-weight:500;line-height:1.4}.library-browser-modal__detail-value--mono{color:var(--pb-text-sec);font-family:JetBrains Mono,monospace;font-size:.76rem;line-height:1.45}.library-browser-modal__loading{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 6);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__loading{border-color:color-mix(in srgb, var(--pb-border) 86%, transparent)}}.library-browser-modal__loading{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__loading{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser-modal__stats{gap:calc(var(--spacing) * 3);grid-template-columns:repeat(auto-fit,minmax(8.5rem,1fr));display:grid}.library-browser-modal__stat{justify-content:space-between;gap:calc(var(--spacing) * 2);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);min-height:5rem;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border);flex-direction:column;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__stat{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__stat{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__stat{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser-modal__stat-label,.library-browser-modal__field-label{letter-spacing:.11em;text-transform:uppercase;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.62rem;font-weight:700}.library-browser-modal__stat-value,.library-browser-modal__preview-value{min-width:0;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-primary);overflow-wrap:anywhere;font-weight:500;line-height:1.35}.library-browser-modal__meta{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__meta{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__meta{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__meta{background:color-mix(in srgb, var(--pb-bg-surface) 93%, transparent)}}.library-browser-modal__meta-row{gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);grid-template-columns:minmax(8rem,10rem) minmax(0,1fr);display:grid}.library-browser-modal__meta-row+.library-browser-modal__meta-row{border-top:1px solid var(--pb-border-subtle)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__meta-row+.library-browser-modal__meta-row{border-top:1px solid color-mix(in srgb, var(--pb-border-subtle) 84%, transparent)}}.library-browser-modal__meta-label{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-dim);letter-spacing:.08em;text-transform:uppercase}.library-browser-modal__meta-value{min-width:0;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);overflow-wrap:anywhere;font-family:JetBrains Mono,monospace;font-size:.76rem;line-height:1.45}.library-browser-modal__storage{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__storage{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage{background:color-mix(in srgb, var(--pb-bg-surface) 93%, transparent)}}.library-browser-modal__storage-head{margin-bottom:calc(var(--spacing) * 2);justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);display:flex}.library-browser-modal__storage-bar{height:calc(var(--spacing) * 2.5);background:var(--pb-bg-card-hover);border-radius:3.40282e38px;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage-bar{background:color-mix(in srgb, var(--pb-bg-card-hover) 72%, transparent)}}.library-browser-modal__storage-fill{background:linear-gradient(90deg, var(--pb-interactive) 0%, var(--pb-brand) 100%);border-radius:3.40282e38px;height:100%;display:block}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage-fill{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-interactive) 82%, white) 0%, color-mix(in srgb, var(--pb-brand) 74%, white) 100%)}}.library-browser-modal__field{gap:calc(var(--spacing) * 2);flex-direction:column;display:flex}.library-browser-modal__settings-rows{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border)}.library-browser-modal__note{padding-inline:calc(var(--spacing) * 6)}.library-browser-modal__body-inset{padding-inline:calc(var(--spacing) * 6);padding-bottom:calc(var(--spacing) * 4)}.library-browser-modal__body-inset--section{padding-bottom:0}.library-browser-modal__body-inset--compact-top{padding-top:calc(var(--spacing) * 3)}.library-browser-modal__body-inset--roomy-bottom{padding-bottom:calc(var(--spacing) * 4)}.library-browser-modal__section-stack{gap:calc(var(--spacing) * 4);padding-top:calc(var(--spacing) * 4);flex-direction:column;display:flex}.library-browser-modal__section-stack--delete{gap:calc(var(--spacing) * 4)}.library-browser-modal__section-panel{flex-direction:column;display:flex}.library-browser-modal__section-heading-row{padding-inline:calc(var(--spacing) * 6);padding-bottom:calc(var(--spacing) * 3);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-bottom:1px solid var(--pb-border-subtle);padding-top:0}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__section-heading-row{border-bottom:1px solid color-mix(in srgb, var(--pb-border-subtle) 80%, transparent)}}.library-browser-modal__preview-block{min-height:calc(var(--spacing) * 10);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-block{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__preview-block{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-block{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser-modal__preview-block{overflow-wrap:anywhere;line-height:1.45}.library-browser-modal__preview-block--mono{font-family:JetBrains Mono,monospace;font-size:.76rem}.library-browser-modal__detail-input{width:100%;min-height:2.5rem;font-size:.88rem;display:block}.library-browser-modal__helper,.library-browser-modal__context-value{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.library-browser-modal__context-value--mono{color:var(--pb-text-sec);overflow-wrap:anywhere;font-family:JetBrains Mono,monospace;font-size:.76rem;line-height:1.45}.library-browser-modal__error{border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-error);border-width:1px;border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__error{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.library-browser-modal__error{background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__error{background:color-mix(in srgb, var(--pb-error) 8%, transparent)}}.library-browser-modal__actions{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.library-browser-modal__preview-grid{gap:calc(var(--spacing) * 3);grid-template-columns:repeat(auto-fit,minmax(10rem,1fr));display:grid}.library-browser-modal__preview-card{justify-content:space-between;gap:calc(var(--spacing) * 2);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);min-height:5.25rem;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border);flex-direction:column;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-card{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__preview-card{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-card{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser__name{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.library-browser__name-icon{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);flex-shrink:0}.library-browser__name-icon--folder{color:var(--pb-brand)}.library-browser__name-icon--file{color:var(--pb-text-dim)}.library-browser__name-label{text-overflow:ellipsis;white-space:nowrap;min-width:0;font-size:.76rem;font-weight:400;overflow:hidden}.library-browser__name-label--folder{color:var(--pb-text-primary);font-weight:500;text-decoration:none}.library-browser__name-label--folder:hover{color:var(--pb-interactive)}.library-browser__empty{height:100%;min-height:280px;padding-inline:calc(var(--spacing) * 6);text-align:center;flex-direction:column;justify-content:center;align-items:center;display:flex}.library-browser__empty-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);color:var(--pb-text-dim)}.library-browser__empty-title{margin-top:calc(var(--spacing) * 4);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-browser__empty-copy{margin-top:calc(var(--spacing) * 2);max-width:var(--container-xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.library-empty-shell{align-items:flex-start;gap:calc(var(--spacing) * 4);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 8);box-shadow:var(--pb-shadow-1);border-radius:14px;flex-direction:column;display:flex}.library-empty-shell__icon-wrap{height:calc(var(--spacing) * 16);width:calc(var(--spacing) * 16);background-color:var(--pb-bg-card-hover);color:var(--pb-text-dim);border-radius:3.40282e38px;justify-content:center;align-items:center;display:flex}.library-empty-shell__icon{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8)}:where(.library-empty-shell__body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.library-empty-shell__copy{max-width:var(--container-2xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7);color:var(--pb-text-secondary)}.library-empty-shell__actions{align-items:center;gap:calc(var(--spacing) * 3);flex-wrap:wrap;display:flex}@media (max-width:1024px){.library-browser{flex:none;grid-template-columns:1fr;height:auto;min-height:520px}.library-browser__tree{border-right:0;border-bottom:1px solid var(--pb-border);max-height:220px}}.health-section__label{letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);margin-block:.625rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700;display:block}.health-component-card__sub,.health-detail-card__stat-value,.health-history-table td{font-family:JetBrains Mono,monospace}:where(.health-section>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}.health-component-grid{gap:calc(var(--spacing) * 3);grid-template-columns:repeat(auto-fit,minmax(280px,1fr));display:grid}.health-component-card{cursor:pointer;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-top:calc(var(--spacing) * 4);padding-bottom:calc(var(--spacing) * 3);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-width:1px;border-radius:14px;flex-direction:column;height:100%;display:flex}.health-component-card:hover{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.health-component-card:hover{background:color-mix(in srgb, var(--pb-interactive) 4%, var(--pb-bg-card))}}.health-component-card:hover{border-color:var(--pb-border-strong)}.health-component-card--success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.health-component-card--success{border-color:color-mix(in srgb, var(--pb-success) 22%, var(--pb-border))}}.health-component-card--warning{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.health-component-card--warning{border-color:color-mix(in srgb, var(--pb-warning) 22%, var(--pb-border))}}.health-component-card--danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.health-component-card--danger{border-color:color-mix(in srgb, var(--pb-error) 22%, var(--pb-border))}}.health-component-card--neutral{border-color:var(--pb-border)}.health-component-card__header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 3);display:flex}.health-component-card__name{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text);font-family:Syne,sans-serif;font-size:.84rem;font-weight:700}.health-component-card__stats{align-items:flex-start;gap:calc(var(--spacing) * 2);grid-template-columns:repeat(2,minmax(0,1fr));display:grid}.health-detail-card__stats{gap:calc(var(--spacing) * 2);display:grid}@media (min-width:40rem){.health-detail-card__stats{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.health-detail-card__stats{grid-template-columns:repeat(4,minmax(0,1fr))}}.health-component-card__stat,.health-detail-card__stat{border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);border-width:1px;border-color:var(--pb-border-subtle);border-radius:10px;flex-direction:column;gap:0;display:flex}.health-component-card__stat{background:var(--pb-bg-base)}.health-detail-card__stat{padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 1.5);background:var(--pb-bg-base)}.health-component-card__stat-label,.health-detail-card__stat-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.46rem;font-weight:700}.health-component-card__stat-value,.health-detail-card__stat-value{--tw-font-weight:var(--font-weight-semibold);font-size:.82rem;font-weight:var(--font-weight-semibold);color:var(--pb-text-primary);display:block}.health-component-card__stat-value{white-space:pre-line;line-height:1.25}.health-component-card__stat-value--danger,.health-detail-card__stat-value--danger{color:var(--pb-error)}.health-component-card__stat-value--warning,.health-detail-card__stat-value--warning{color:var(--pb-warning)}.health-component-card__message{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary);min-height:2.5rem}.health-component-card__sub{color:var(--pb-text-dim);margin-top:auto;font-size:.68rem}:where(.health-detail-shell>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.health-detail-back{align-items:center;gap:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.health-detail-back:hover{color:var(--pb-interactive-hover)}:where(.health-detail-card>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.health-detail-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:14px}.health-detail-card__header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 3);flex-wrap:wrap;display:flex}.health-detail-card__title-row{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;min-width:0;display:flex}.health-detail-card__title{letter-spacing:.03em;text-transform:uppercase;color:var(--pb-text);font-family:Syne,sans-serif;font-size:1rem;font-weight:800}:where(.health-check-list>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.health-check-row{align-items:center;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);border-radius:10px;grid-template-columns:8px minmax(120px,140px) minmax(0,1fr) auto;display:grid}.health-check-row--database-metric{grid-template-columns:8px minmax(160px,180px) minmax(0,1fr) auto}.health-check-row__name{text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary);justify-self:flex-start}.health-check-row__message{min-width:0;font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim);flex:1}.health-check-list--database-metrics .health-check-row__name,.health-check-list--database-metrics .health-check-row__message{text-align:left;justify-self:flex-start}.health-check-list--database-metrics .health-led{justify-self:center}.health-table-wrap{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);border-radius:12px;overflow-x:auto}.health-table{border-collapse:collapse;width:100%}.health-table th{text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);background:var(--pb-bg-shell);border-bottom:1px solid var(--pb-border);padding:.45rem .85rem;font-family:Syne,sans-serif;font-size:.52rem;font-weight:700}.health-table td{border-bottom:1px solid var(--pb-border-subtle);color:var(--pb-text-sec);padding:.55rem .85rem;font-size:.76rem}.health-table tbody tr:last-child td{border-bottom:0}.health-table tbody tr:hover td{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.health-table tbody tr:hover td{background:color-mix(in srgb, var(--pb-interactive) 8%, var(--pb-bg-card))}}.health-table th.c,.health-table td.c{text-align:center}.health-table th.r,.health-table td.r{text-align:right}.health-table__status-col{width:40px}.health-table__name{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.health-table__detail{margin-top:calc(var(--spacing) * .5);color:var(--pb-text-dim);font-size:.72rem}.health-led{border-radius:999px;flex-shrink:0;width:8px;height:8px;display:inline-block}.health-led--header{width:10px;height:10px}.health-led--green{background:var(--pb-success);box-shadow:0 0 5px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.health-led--green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.health-led--amber{background:var(--pb-warning);box-shadow:0 0 5px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.health-led--amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-warning) 35%, transparent)}}.health-led--red{background:var(--pb-error);box-shadow:0 0 5px var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.health-led--red{box-shadow:0 0 5px color-mix(in srgb, var(--pb-error) 35%, transparent)}}.health-led--off{background:var(--pb-text-dim);opacity:.4}.health-footer-strip{align-items:center;column-gap:calc(var(--spacing) * 6);row-gap:var(--spacing);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);color:var(--pb-text-dim);border-radius:10px;flex-wrap:wrap;font-family:JetBrains Mono,monospace;font-size:.72rem;display:flex}.health-footer-strip strong{color:var(--pb-text);font-weight:600}.step-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1)}.step-badge{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-info);background-color:var(--pb-info);border-radius:3.40282e38px;justify-content:center;align-items:center;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.step-badge{background-color:color-mix(in srgb, var(--pb-info) 14%, transparent)}}.step-badge{border:1px solid var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.step-badge{border:1px solid color-mix(in srgb, var(--pb-info) 28%, transparent)}}.action-card{align-items:flex-start;gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:var(--pb-bg-card-hover);display:flex}@supports (color:color-mix(in lab, red, red)){.action-card{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 40%, transparent)}}.action-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.action-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.action-card-icon{height:calc(var(--spacing) * 11);width:calc(var(--spacing) * 11);border-radius:var(--radius-xl);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-shrink:0;justify-content:center;align-items:center;display:flex}.action-card-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.action-card-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim)}.utility-launch-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);height:100%;padding:calc(var(--spacing) * 5);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;box-shadow:var(--pb-shadow-1);flex-direction:column;transition-duration:.15s;display:flex}.utility-launch-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.utility-launch-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.utility-launch-card:hover{box-shadow:var(--pb-shadow-2)}.utility-launch-icon-brand{background-color:var(--pb-brand-dim);color:var(--pb-brand)}.utility-launch-icon-interactive{background-color:var(--pb-interactive-dim);color:var(--pb-interactive)}.utility-launch-icon-info{background-color:var(--pb-info-dim);color:var(--pb-info)}.utility-launch-icon-success{background-color:var(--pb-success-dim);color:var(--pb-success)}.utility-launch-icon-warning{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.utility-launch-kicker{margin-top:calc(var(--spacing) * 4);--tw-font-weight:var(--font-weight-medium);font-size:10px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-launch-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-launch-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-launch-footer{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);padding-top:calc(var(--spacing) * 4);margin-top:auto;display:flex}.utility-launch-tag{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.utility-launch-link{align-items:center;gap:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.utility-launch-link:hover{color:var(--pb-interactive-hover)}.utility-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.utility-hero-card{padding-inline:calc(var(--spacing) * 7)}}.utility-hero-card{box-shadow:var(--pb-shadow-2)}.utility-top-link{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 1.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);border-radius:3.40282e38px;align-items:center;display:inline-flex}.utility-top-link:hover{border-color:var(--pb-border-hover);color:var(--pb-text);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.utility-top-link:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.utility-top-link-muted{color:var(--pb-text-dim)}.utility-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.utility-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 42%, transparent), transparent)}}.utility-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.utility-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.utility-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.utility-hero-title{margin-top:calc(var(--spacing) * 2);font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-hero-copy{margin-top:calc(var(--spacing) * 2);max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-hero-highlights{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:48rem){.utility-hero-highlights{grid-template-columns:repeat(3,minmax(0,1fr))}}.utility-hero-highlight{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.utility-hero-highlight-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-hero-highlight-value{margin-top:calc(var(--spacing) * 2);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-hero-highlight-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-hero-actions{gap:calc(var(--spacing) * 3);display:grid}.utility-hero-action{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1)}.utility-hero-action:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.utility-hero-action:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.utility-hero-action-primary{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.utility-hero-action-primary{border-color:color-mix(in srgb, var(--pb-interactive) 26%, transparent)}}.utility-hero-action-primary{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.utility-hero-action-primary{background-color:color-mix(in srgb, var(--pb-interactive) 8%, var(--pb-bg-card))}}.utility-hero-action-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-hero-action-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-hero-action-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.utility-hero-notes{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:80rem){.utility-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.utility-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.utility-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-hero-note-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utilities-page{width:100%;max-width:1100px;padding-bottom:var(--pb-page-footer-clearance);margin-inline:auto}.utilities-shell{width:100%}:where(.utilities-content>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.utilities-header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 5);flex-wrap:wrap;display:flex}.utilities-header-copy{align-items:flex-start;gap:calc(var(--spacing) * 5);flex-wrap:wrap;display:flex}.utilities-header-title{letter-spacing:.04em;text-transform:uppercase;font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1}.utilities-header-title span{color:var(--pb-brand)}.utilities-header-subtitle{color:var(--pb-text-dim);margin-top:4px;font-size:.78rem;font-weight:400;line-height:1.5}.utilities-gauges{align-items:flex-end;gap:calc(var(--spacing) * 3.5);display:flex}.utilities-gauge{text-align:center}.utilities-gauge-ring{height:calc(var(--spacing) * 12);width:calc(var(--spacing) * 12);border-style:var(--tw-border-style);background-color:var(--pb-bg-card);border-width:4px;border-radius:3.40282e38px;justify-content:center;align-items:center;margin-inline:auto;display:flex;position:relative}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring{background-color:color-mix(in srgb, var(--pb-bg-card) 70%, var(--pb-bg-base))}}.utilities-gauge-ring-success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring-success{border-color:color-mix(in srgb, var(--pb-success) 60%, var(--pb-border))}}.utilities-gauge-ring-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring-info{border-color:color-mix(in srgb, var(--pb-info) 60%, var(--pb-border))}}.utilities-gauge-ring-ink{border-color:var(--pb-text)}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring-ink{border-color:color-mix(in srgb, var(--pb-text) 22%, var(--pb-border))}}.utilities-gauge-value{font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:700}.utilities-gauge-label{--tw-font-weight:var(--font-weight-medium);font-size:.52rem;font-weight:var(--font-weight-medium);color:var(--pb-text-dim);text-transform:uppercase;letter-spacing:.1em;margin-top:2px;display:block}.utilities-tabs{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);border-radius:10px;align-self:center;display:inline-flex;overflow:hidden}.utilities-tab{padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 2);--tw-font-weight:var(--font-weight-bold);font-family:Syne,sans-serif;font-size:.78rem;font-weight:var(--font-weight-bold);--tw-tracking:.08em;letter-spacing:.08em;color:var(--pb-text-dim);text-transform:uppercase;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.utilities-tab+.utilities-tab{border-left:1px solid var(--pb-border)}.utilities-tab:hover{color:var(--pb-text);background-color:var(--pb-text)}@supports (color:color-mix(in lab, red, red)){.utilities-tab:hover{background-color:color-mix(in srgb, var(--pb-text) 3%, transparent)}}.utilities-tab.is-active{background-color:var(--pb-interactive);color:var(--pb-text-inverse)}.utilities-section-label{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-bottom:calc(var(--spacing) * 1.5);--tw-font-weight:var(--font-weight-bold);font-family:Syne,sans-serif;font-size:.62rem;font-weight:var(--font-weight-bold);--tw-tracking:.12em;letter-spacing:.12em;color:var(--pb-text-dim);text-transform:uppercase}.utilities-tool-grid{grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:.75rem;display:grid}.utilities-tool-grid-wide{grid-template-columns:repeat(auto-fill,minmax(280px,1fr))}.utility-launch-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);height:100%;padding:calc(var(--spacing) * 5);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;box-shadow:var(--pb-shadow-1);border-radius:14px;flex-direction:column;transition-duration:.15s;display:flex;overflow:hidden}.utility-launch-card:hover{border-color:var(--pb-border-strong);box-shadow:var(--pb-shadow-2);background-color:var(--pb-bg-card);transform:translateY(-2px)}.utility-launch-header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 3);display:flex}.utility-launch-arrow{color:var(--pb-text-dim);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;transition-duration:.15s}.utility-launch-card:hover .utility-launch-arrow{color:var(--pb-interactive);transform:translate(2px)}.utility-launch-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:10px;justify-content:center;align-items:center;display:flex}.utility-launch-title{margin-top:calc(var(--spacing) * 3);--tw-font-weight:var(--font-weight-extrabold);font-family:Syne,sans-serif;font-size:.9rem;font-weight:var(--font-weight-extrabold);--tw-tracking:.04em;letter-spacing:.04em;color:var(--pb-text-primary);text-transform:uppercase}.utility-launch-copy{margin-top:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);flex:1;font-size:.78rem;font-weight:400;line-height:1.5}.utility-launch-footer{margin-top:calc(var(--spacing) * 4);justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);display:flex}.utility-launch-tag{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 2);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-family:JetBrains Mono,monospace;font-weight:var(--font-weight-medium);color:var(--pb-text-dim);text-transform:uppercase;letter-spacing:.06em;align-items:center;font-size:.6rem;display:inline-flex}.utility-tool-page{width:100%;max-width:1100px;margin-inline:auto}:where(.utility-tool-header-block>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.utility-tool-back-link{align-items:center;gap:var(--spacing);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));font-size:.82rem;display:inline-flex}@media (hover:hover){.utility-tool-back-link:hover{color:var(--pb-text-primary)}}.utility-tool-header-shell{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 4);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:14px;flex-wrap:wrap;display:flex}.utility-tool-header-left{align-items:center;gap:calc(var(--spacing) * 3.5);display:flex}.utility-tool-header-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:10px;justify-content:center;align-items:center;display:flex}.utility-tool-header-icon-converter{background-color:var(--pb-interactive-dim);color:var(--pb-interactive)}.utility-tool-header-icon-mass-convert{background-color:var(--pb-success-dim);color:var(--pb-success)}.utility-tool-header-icon-integrity{background-color:var(--pb-info-dim);color:var(--pb-info)}.utility-tool-header-icon-rename,.utility-tool-header-icon-db-check{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.utility-tool-header-icon-export{background-color:var(--pb-brand-dim);color:var(--pb-brand)}.utility-tool-header-title{--tw-font-weight:var(--font-weight-extrabold);font-family:Syne,sans-serif;font-size:1.1rem;font-weight:var(--font-weight-extrabold);--tw-tracking:.03em;letter-spacing:.03em;color:var(--pb-text-primary);text-transform:uppercase}.utility-tool-header-title span{color:var(--pb-brand)}.utility-tool-header-subtitle{margin-top:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.utility-tool-header-tag{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-semibold);font-family:JetBrains Mono,monospace;font-size:.62rem;font-weight:var(--font-weight-semibold);--tw-tracking:.08em;letter-spacing:.08em;color:var(--pb-text-dim);text-transform:uppercase;align-items:center;display:inline-flex}.system-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.system-hero-card{padding-inline:calc(var(--spacing) * 7)}}.system-hero-card{box-shadow:var(--pb-shadow-2)}.system-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.system-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 42%, transparent), transparent)}}.system-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.system-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.system-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.system-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.system-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.system-hero-aside{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.system-hero-aside{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.system-hero-aside{grid-template-columns:repeat(1,minmax(0,1fr))}}.system-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.system-hero-note-emphasis{border-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.system-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-brand) 20%, transparent)}}.system-hero-note-emphasis{background-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.system-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-brand) 8%, var(--pb-bg-card))}}.system-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.system-hero-note-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.system-hero-note-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.system-shell-body{gap:calc(var(--spacing) * 6);display:grid}@media (min-width:80rem){.system-shell-body{grid-template-columns:280px minmax(0,1fr)}}@media (min-width:96rem){.system-shell-body{grid-template-columns:300px minmax(0,1fr)}}.system-rail-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;overflow:hidden}.system-rail-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 5)}.system-rail-eyebrow{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.system-rail-title{margin-top:var(--spacing);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.system-rail-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}:where(.system-rail-links>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.system-rail-links{padding:calc(var(--spacing) * 3)}.system-rail-footer{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.system-rail-footer{background-color:color-mix(in srgb, var(--pb-bg-card) 55%, transparent)}}.system-tab-card{align-items:flex-start;gap:calc(var(--spacing) * 3);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;border-width:1px;transition-duration:.15s;display:flex}.system-tab-card-inactive{border-color:var(--pb-border);background-color:var(--pb-bg-surface)}.system-tab-card-inactive:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.system-tab-card-inactive:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.system-tab-card-inactive:hover{box-shadow:var(--pb-shadow-1)}.system-tab-card-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.system-tab-card-active{border-color:color-mix(in srgb, var(--pb-interactive) 30%, transparent)}}.system-tab-card-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.system-tab-card-active{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.system-tab-card-active{box-shadow:var(--pb-shadow-1)}.system-tab-icon{margin-top:calc(var(--spacing) * .5);height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:var(--radius-2xl);flex-shrink:0;justify-content:center;align-items:center;display:flex}.system-tab-copy{flex:1;min-width:0}.system-tab-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.system-tab-description{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-secondary);display:block}.system-tab-arrow{margin-top:var(--spacing);color:var(--pb-text-dim);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;transition-duration:.15s}.system-tab-card:hover .system-tab-arrow,.system-tab-card-active .system-tab-arrow{color:var(--pb-interactive);transform:translate(.125rem)}.system-focus-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;position:relative;overflow:hidden}.system-focus-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.system-focus-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-interactive) 34%, transparent), transparent)}}.system-focus-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.system-focus-grid{grid-template-columns:minmax(0,1.35fr) minmax(320px,.95fr);align-items:flex-start}}.system-focus-meta{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim);flex-wrap:wrap;display:flex}.system-focus-chip{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.system-focus-actions{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.system-focus-actions{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.system-focus-actions{grid-template-columns:repeat(1,minmax(0,1fr))}}.review-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.review-hero-card{padding-inline:calc(var(--spacing) * 7)}}.review-hero-card{box-shadow:var(--pb-shadow-2)}.review-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-info), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.review-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-info) 42%, transparent), transparent)}}.review-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.review-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.review-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.review-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.review-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.review-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.review-hero-notes{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.review-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.review-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.review-hero-note-emphasis{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-info) 20%, transparent)}}.review-hero-note-emphasis{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-info) 10%, var(--pb-bg-card))}}.review-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.review-hero-note-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.review-workspace-tabs{align-items:center;gap:calc(var(--spacing) * 2);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-radius:1.25rem;flex-wrap:wrap;display:flex}@media (min-width:40rem){.review-workspace-tabs{padding-inline:calc(var(--spacing) * 5)}}.review-workspace-tabs{box-shadow:var(--pb-shadow-1)}.review-tab-chip-active{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary);border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-tab-chip-active{border-color:color-mix(in srgb, var(--pb-info) 34%, transparent)}}.review-tab-chip-active{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-tab-chip-active{background-color:color-mix(in srgb, var(--pb-info) 12%, var(--pb-bg-card))}}.review-tab-chip-active{box-shadow:var(--pb-shadow-1)}.review-tab-chip-inactive{color:var(--pb-text-dim)}.review-focus-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;position:relative;overflow:hidden}.review-focus-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.review-focus-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 34%, transparent), transparent)}}.review-focus-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.review-focus-grid{grid-template-columns:minmax(0,1.35fr) minmax(320px,.95fr);align-items:flex-start}}.review-focus-chip{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.review-focus-actions{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.review-focus-actions{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.review-focus-actions{grid-template-columns:repeat(1,minmax(0,1fr))}}.review-note-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:1.25rem}.workflow-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.workflow-hero-card{padding-inline:calc(var(--spacing) * 7)}}.workflow-hero-card{box-shadow:var(--pb-shadow-2)}.workflow-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.workflow-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-interactive) 40%, transparent), transparent)}}.workflow-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.workflow-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.workflow-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.workflow-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.workflow-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.workflow-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.workflow-hero-notes{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.workflow-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.workflow-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.workflow-hero-note-emphasis{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-interactive) 20%, transparent)}}.workflow-hero-note-emphasis{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.workflow-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.workflow-hero-note-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.series-page-shell{gap:calc(var(--spacing) * 6);flex-direction:column;min-height:100%;display:flex}.series-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.series-hero-card{padding-inline:calc(var(--spacing) * 7)}}.series-hero-card{box-shadow:var(--pb-shadow-2)}.series-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.series-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 34%, transparent), color-mix(in srgb, var(--pb-interactive) 34%, transparent), transparent)}}.series-hero-grid{gap:calc(var(--spacing) * 5);display:grid}@media (min-width:96rem){.series-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.8fr);align-items:flex-start}}.series-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.series-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.series-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7);color:var(--pb-text-secondary)}.series-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.series-hero-notes{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:96rem){.series-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.series-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.series-hero-note-emphasis{border-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.series-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-brand) 22%, transparent)}}.series-hero-note-emphasis{background-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.series-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-brand) 8%, var(--pb-bg-card))}}.series-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.series-hero-note-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.series-hero-note-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.page-context-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:1.25rem}.page-context-back-link{align-items:center;gap:calc(var(--spacing) * 1.5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.page-context-back-link:hover{color:var(--pb-text-primary)}.page-context-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.page-context-grid{grid-template-columns:minmax(0,1.35fr) minmax(280px,.9fr);align-items:flex-start}}.page-context-breadcrumbs{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);flex-wrap:wrap;display:flex}.page-context-link{align-items:center;gap:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.page-context-link:hover{color:var(--pb-text-primary)}.page-context-separator{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);color:var(--pb-text-dim)}.page-context-summary{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.page-context-chip-row{gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.page-context-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);padding:calc(var(--spacing) * 4);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.page-context-note{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 40%, transparent)}}.page-context-note-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.page-context-note-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.detail-hero-shell{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.detail-hero-shell{padding-inline:calc(var(--spacing) * 7)}}.detail-hero-shell{box-shadow:var(--pb-shadow-2)}.detail-hero-shell:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.detail-hero-shell:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 40%, transparent), transparent)}}.detail-hero-grid{gap:calc(var(--spacing) * 6);display:grid}@media (min-width:80rem){.detail-hero-grid{grid-template-columns:220px minmax(0,1fr) 320px}}.detail-cover-panel{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card-hover);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:1.5rem}.detail-summary-panel{min-width:0}:where(.detail-summary-panel>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.detail-header-stack>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.detail-status-row{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.detail-body-copy{max-width:56rem}.detail-stat-grid{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.detail-stat-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.detail-stat-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}.detail-info-grid{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.detail-info-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.detail-info-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}:where(.detail-aside>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.detail-action-panel{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.25rem}.series-domain-page{width:100%;max-width:1400px;padding-bottom:var(--pb-page-footer-clearance);margin-inline:auto}.series-domain-breadcrumb-row{flex-wrap:wrap;align-items:center;gap:.75rem;display:flex}.series-domain-back-link{color:var(--pb-interactive);align-items:center;gap:.25rem;font-size:.82rem;transition:color .14s;display:inline-flex}.series-domain-back-link:hover{color:var(--pb-interactive-hover)}.series-domain-breadcrumbs{color:var(--pb-text-tertiary);flex-wrap:wrap;align-items:center;gap:.35rem;font-size:.82rem;display:flex}.series-domain-breadcrumbs a{color:inherit}.series-domain-breadcrumbs a:hover{color:var(--pb-interactive)}.series-domain-breadcrumb-sep{font-size:.7rem}.series-domain-breadcrumbs .current{color:var(--pb-text-primary);font-weight:600}.detail-hero-shell{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);box-shadow:var(--pb-shadow-1);border-radius:14px;overflow:hidden}.detail-hero-shell:before{display:none}.series-domain-hero-inner{grid-template-columns:130px minmax(0,1fr) minmax(13rem,16rem);align-items:start;gap:1.25rem;display:grid}.story-arc-detail-hero-inner{grid-template-columns:130px minmax(0,1fr) minmax(18rem,19rem)}.issue-domain-hero-inner{grid-template-columns:120px minmax(0,1fr) minmax(13rem,16rem)}.series-domain-cover-column{flex-shrink:0;width:130px}.issue-domain-cover-column{width:120px}.series-domain-cover-frame{aspect-ratio:2/3;border:1px solid var(--pb-border-default);background:linear-gradient(155deg, var(--pb-surface-card) 0%, var(--pb-surface-app) 100%);color:var(--pb-text-tertiary);border-radius:12px;justify-content:center;align-items:center;transition:box-shadow .2s;display:flex;overflow:hidden}.series-domain-cover-frame:hover{box-shadow:var(--pb-shadow-2)}.issue-domain-cover-frame{width:120px}.series-domain-cover-image{object-fit:cover;object-position:top;cursor:pointer;width:100%;height:100%}.series-domain-cover-placeholder{justify-content:center;align-items:center;width:100%;height:100%;display:flex}.series-domain-hero-title{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text-primary);overflow-wrap:anywhere;font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1.05}.series-domain-hero-title span{color:var(--pb-brand)}.series-domain-hero-title-link{color:inherit;text-underline-offset:.18em;text-decoration:none;text-decoration-thickness:2px;transition:color .14s,text-decoration-color .14s;display:inline}.series-domain-hero-title-link:hover,.series-domain-hero-title-link:focus-visible{color:var(--pb-interactive);text-decoration-line:underline;-webkit-text-decoration-color:var(--pb-interactive);-webkit-text-decoration-color:var(--pb-interactive);text-decoration-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-domain-hero-title-link:hover,.series-domain-hero-title-link:focus-visible{-webkit-text-decoration-color:color-mix(in srgb, var(--pb-interactive) 58%, transparent);-webkit-text-decoration-color:color-mix(in srgb, var(--pb-interactive) 58%, transparent);text-decoration-color:color-mix(in srgb, var(--pb-interactive) 58%, transparent)}}.series-domain-hero-title-link:hover,.series-domain-hero-title-link:focus-visible{outline:none}.series-domain-hero-title-sm{font-size:1.4rem}.series-domain-hero-description{max-width:35rem;color:var(--pb-text-secondary);margin-top:.5rem;font-size:.85rem;line-height:1.7}.series-domain-hero-subtitle{color:var(--pb-text-secondary);margin-top:.25rem;font-size:.88rem}.series-domain-status-row{flex-wrap:wrap;align-items:center;gap:.375rem;margin-top:.75rem;display:flex}.series-domain-led{border-radius:999px;width:8px;height:8px;display:inline-block}.series-domain-led-on{background:var(--pb-status-success);box-shadow:0 0 5px var(--pb-status-success), 0 0 10px var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.series-domain-led-on{box-shadow:0 0 5px color-mix(in srgb, var(--pb-status-success) 35%, transparent), 0 0 10px color-mix(in srgb, var(--pb-status-success) 12%, transparent)}}.series-domain-led-off{background:var(--pb-text-tertiary);opacity:.35}.app-progress{align-items:center;gap:.625rem;display:flex}.app-progress-track{background:var(--pb-text-secondary);border-radius:2px;flex:1;height:8px}@supports (color:color-mix(in lab, red, red)){.app-progress-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.app-progress-track{border:1px solid var(--pb-border-subtle);position:relative;overflow:hidden}.app-progress-fill{background:linear-gradient(90deg, var(--pb-status-success), var(--pb-status-success));height:100%}@supports (color:color-mix(in lab, red, red)){.app-progress-fill{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-success) 45%, transparent), var(--pb-status-success))}}.app-progress-fill{position:relative}.app-progress-fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}.app-progress-fill--interactive{background:linear-gradient(90deg, var(--pb-interactive), var(--pb-interactive))}@supports (color:color-mix(in lab, red, red)){.app-progress-fill--interactive{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-interactive) 45%, transparent), var(--pb-interactive))}}.app-progress-fill--error{background:linear-gradient(90deg, var(--pb-error), var(--pb-error))}@supports (color:color-mix(in lab, red, red)){.app-progress-fill--error{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-error) 45%, transparent), var(--pb-error))}}.app-progress-value{text-align:right;min-width:40px;color:var(--pb-status-success);font-family:JetBrains Mono,monospace;font-size:.82rem;font-weight:700}.app-progress-value--interactive{color:var(--pb-interactive)}.app-progress-value--error{color:var(--pb-error)}.app-progress-value-stack{flex-direction:column;align-items:flex-end;line-height:1.05;display:flex}.app-progress-value-secondary{letter-spacing:.01em;color:var(--pb-text-dim);white-space:nowrap;font-family:DM Sans,sans-serif;font-size:.62rem;font-weight:600}.series-domain-info-grid{grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:.625rem;margin-top:1rem;display:grid}.series-domain-info-box{background:var(--pb-surface-app);border:1px solid var(--pb-border-subtle);border-radius:10px;padding:.75rem .875rem}.series-domain-info-box-wide{grid-column:span 2}.series-domain-info-label{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.series-domain-info-value{color:var(--pb-text-secondary);margin-top:.25rem;font-size:.85rem}.series-domain-path{word-break:break-all;font-size:.75rem}.series-domain-actions-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;padding:1.125rem 1.5rem}.series-domain-actions-inner{flex-wrap:wrap;align-items:center;gap:1rem;display:flex}.series-domain-actions-panel{border-left:1px solid var(--pb-border-subtle);flex-direction:column;gap:.75rem;min-width:0;padding-left:1.25rem;display:flex}.series-domain-actions-panel-wide{min-width:18rem}.series-domain-actions-title{text-transform:uppercase;letter-spacing:.08em;color:var(--pb-text-primary);white-space:nowrap;font-family:Syne,sans-serif;font-size:.88rem;font-weight:800}.series-domain-actions-title span{color:var(--pb-brand)}.series-domain-actions-divider{background:var(--pb-border-default);align-self:stretch;width:1px}.series-domain-actions-buttons{flex-wrap:wrap;flex:1;align-items:center;gap:.5rem;display:flex}.series-domain-actions-panel .series-domain-actions-buttons{flex-direction:column;flex:initial;align-items:center;gap:.55rem}.series-domain-actions-panel .series-domain-actions-buttons>a,.series-domain-actions-panel .series-domain-actions-buttons>button,.series-domain-actions-panel .series-domain-actions-buttons .series-domain-inline-toggle{width:100%}.series-domain-actions-panel .series-domain-actions-buttons>a,.series-domain-actions-panel .series-domain-actions-buttons>button{gap:var(--pb-control-gap);justify-content:center}.series-domain-actions-panel .series-domain-actions-buttons .series-domain-inline-toggle{justify-content:space-between}.series-domain-action-form{width:100%}.series-domain-action-form>button{justify-content:center;width:100%}.series-domain-inline-toggle{border:1px solid var(--pb-border-default);background:var(--pb-surface-card);border-radius:10px;align-items:center;gap:.75rem;height:40px;padding:0 .75rem;display:inline-flex}.series-domain-inline-toggle-label{color:var(--pb-text-primary);font-size:.82rem;font-weight:600}.series-domain-section-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;padding:1rem 1.25rem}.series-domain-section-title{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:.82rem;font-weight:700}:where(.series-domain-alt-panel>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.series-domain-alt-title{text-transform:uppercase;letter-spacing:.12em;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.72rem;font-weight:700}.series-domain-alt-strip{flex-direction:column;align-items:flex-start;gap:.5rem;display:flex}.series-domain-alt-list{flex-wrap:wrap;align-items:flex-start;gap:.35rem;min-height:0;display:flex}.series-domain-alt-pill{gap:.35rem}.series-domain-alt-pill-remove{color:var(--pb-text-tertiary);margin-left:.1rem;transition:color .14s}.series-domain-alt-pill-remove:hover{color:var(--pb-status-danger)}.series-domain-alt-form{flex-wrap:wrap;align-items:center;gap:.5rem;width:100%;display:flex}.series-domain-alt-input{border:1px solid var(--pb-border-default);background:var(--pb-surface-input);width:180px;color:var(--pb-text-primary);border-radius:8px;padding:.45rem .75rem;font-size:.78rem}.series-domain-alt-input:focus{border-color:var(--pb-interactive);outline:none}.series-domain-alt-add{padding:.3rem .75rem;font-size:.78rem}.series-domain-issues-wrap{overflow:visible}.series-domain-issues-card{padding-top:.875rem}.series-domain-issues-toolbar{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:.75rem;display:flex}.series-domain-issues-toolbar-left{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.series-domain-issues-title{color:var(--pb-text-primary);font-size:.88rem;font-weight:700}.series-domain-issues-summary{flex-wrap:wrap;align-items:center;gap:.35rem;display:flex}.series-domain-issues-summary .pill{font-size:.62rem}.series-domain-issues-select.dropdown-select{--pb-control-min-height:34px;--pb-control-font-size:.78rem;width:auto}.series-domain-issues-select .dropdown-select-trigger{background:var(--pb-surface-input);padding-block:.4rem;padding-inline:.75rem .65rem}.series-domain-issues-progress{align-items:center;gap:.625rem;margin-top:.9rem;display:flex}.series-domain-issues-progress-track{background:var(--pb-text-secondary);border-radius:2px;flex:1;height:8px}@supports (color:color-mix(in lab, red, red)){.series-domain-issues-progress-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.series-domain-issues-progress-track{border:1px solid var(--pb-border-subtle);overflow:hidden}.series-domain-issues-progress-fill{background:linear-gradient(90deg, var(--pb-status-success), var(--pb-status-success));height:100%}@supports (color:color-mix(in lab, red, red)){.series-domain-issues-progress-fill{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-success) 45%, transparent), var(--pb-status-success))}}.series-domain-issues-progress-label{text-align:right;min-width:36px;color:var(--pb-status-success);font-family:JetBrains Mono,monospace;font-size:.78rem;font-weight:700}.series-domain-issues-table-wrap{margin-top:.875rem;overflow-x:auto}.series-domain-issues-table{border-collapse:collapse;width:100%}.series-domain-issues-table th,.series-domain-issues-table td{border-bottom:1px solid var(--pb-border-subtle);padding:.75rem .875rem}.series-domain-issues-table thead th{text-align:left;text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);background:var(--pb-surface-shell);border-bottom:2px solid var(--pb-border-default);font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.series-domain-issues-table tbody tr:hover td{background:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.series-domain-issues-table tbody tr:hover td{background:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.series-domain-table-head.r{text-align:right}.series-domain-issue-num{white-space:nowrap;color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.84rem}.series-domain-issue-title-cell{min-width:240px;color:var(--pb-text-secondary);font-size:.84rem}.series-domain-issue-title-link{color:var(--pb-text-primary);transition:color .14s;display:block}.series-domain-issue-title-link:hover{color:var(--pb-interactive)}.series-domain-issue-date{white-space:nowrap;color:var(--pb-text-tertiary);font-size:.8rem}.series-domain-issue-reading{min-width:9.5rem;color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.7rem}.series-domain-issue-reading-mobile{color:var(--pb-interactive);margin-top:.3rem;font-family:JetBrains Mono,monospace;font-size:.65rem}.series-domain-reading-progress{gap:.28rem;max-width:8.5rem;display:grid}.series-domain-reading-progress-track{background:var(--pb-border-default);border-radius:999px;height:3px;display:block;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.series-domain-reading-progress-track{background:color-mix(in srgb, var(--pb-border-default) 68%, transparent)}}.series-domain-reading-progress-fill{border-radius:inherit;background:var(--pb-interactive);height:100%;display:block}.series-domain-reading-queue{color:var(--pb-text-tertiary);margin-top:.3rem;font-family:DM Sans,sans-serif;font-size:.62rem;display:block}.series-domain-issue-status{white-space:nowrap}.series-domain-issue-actions-cell{text-align:right}.series-domain-issue-actions{align-items:center;gap:.25rem;display:inline-flex}.series-domain-issue-action-btn{width:28px;height:28px;color:var(--pb-text-tertiary);background:0 0;border:1px solid #0000;border-radius:8px;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.series-domain-issue-action-btn:hover{color:var(--pb-interactive);background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-domain-issue-action-btn:hover{background:color-mix(in srgb, var(--pb-interactive) 6%, transparent)}}.series-domain-reading-menu{z-index:60;border:1px solid var(--pb-border-default);background:var(--pb-surface-raised);min-width:10.5rem;box-shadow:var(--pb-shadow-2);border-radius:10px;display:grid;position:absolute;top:calc(100% + .3rem);right:0;overflow:hidden}.series-domain-reading-menu button{text-align:left;min-height:2.5rem;color:var(--pb-text-secondary);padding:.55rem .75rem;font-size:.72rem;font-weight:600}.series-domain-reading-menu button:hover,.series-domain-reading-menu button:focus-visible{color:var(--pb-text-primary);background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-domain-reading-menu button:hover,.series-domain-reading-menu button:focus-visible{background:color-mix(in srgb, var(--pb-interactive) 9%, transparent)}}.series-domain-table-footer{margin-top:.75rem}.series-domain-copy-block{color:var(--pb-text-secondary);margin-top:.75rem}.issue-domain-stat-strip{grid-template-columns:repeat(4,minmax(0,1fr));gap:.625rem;margin-top:1rem;display:grid}.issue-domain-stat-box{background:var(--pb-surface-shell);border:1px solid var(--pb-border-subtle);border-radius:12px;padding:.8rem .9rem}.issue-domain-stat-label{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.issue-domain-stat-value{color:var(--pb-text-primary);margin-top:.3rem;font-family:JetBrains Mono,monospace;font-size:.85rem}.issue-domain-stat-value a{color:var(--pb-interactive)}.issue-domain-creators-grid{grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:.75rem;margin-top:.75rem;display:grid}.issue-domain-creator-card{background:var(--pb-surface-app);border:1px solid var(--pb-border-subtle);border-radius:10px;padding:.8rem .9rem}.issue-domain-creator-name{color:var(--pb-text-primary);font-size:.85rem;font-weight:600}.issue-domain-creator-role{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);margin-top:.2rem;font-size:.68rem}.issue-domain-file-grid{grid-template-columns:repeat(3,minmax(0,1fr));gap:.75rem;margin-top:.75rem;display:grid}.issue-domain-file-name{font-size:.78rem}.issue-domain-file-path-box{background:var(--pb-surface-app);border:1px solid var(--pb-border-subtle);border-radius:12px;justify-content:space-between;align-items:flex-start;gap:.75rem;margin-top:.75rem;padding:.875rem 1rem;display:flex}.issue-domain-file-path{word-break:break-all;color:var(--pb-text-secondary);margin-top:.3rem;font-family:JetBrains Mono,monospace;font-size:.75rem;line-height:1.7}.issue-domain-copy-btn{border:1px solid var(--pb-border-default);background:var(--pb-surface-card);width:30px;height:30px;color:var(--pb-text-tertiary);border-radius:8px;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.issue-domain-copy-btn:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.series-domain-telemetry-strip{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);color:var(--pb-text-tertiary);box-shadow:var(--pb-shadow-1);border-radius:12px;flex-wrap:wrap;align-items:center;gap:.35rem 1rem;padding:.65rem 1rem;font-family:JetBrains Mono,monospace;font-size:.7rem;display:flex}.series-domain-telemetry-strip strong{color:var(--pb-text-primary);font-weight:700}.series-page-shell{gap:calc(var(--spacing) * 4);flex-direction:column;display:flex}.series-registry-header{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:1.25rem;display:flex}.series-registry-header-left{flex-wrap:wrap;align-items:flex-start;gap:1.5rem;display:flex}.series-registry-title{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1}.series-registry-title span{color:var(--pb-brand)}.series-registry-subtitle{color:var(--pb-text-tertiary);letter-spacing:.01em;margin-top:.25rem;font-size:.78rem}.series-registry-gauges{align-items:flex-end;gap:1rem;display:flex}.series-registry-gauge{text-align:center}.series-registry-gauge-ring{width:56px;height:56px;margin:0 auto;position:relative}.series-registry-gauge-ring svg{transform:rotate(-90deg)}.series-registry-gauge-bg{fill:none;stroke:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.series-registry-gauge-bg{stroke:color-mix(in srgb, var(--pb-text-secondary) 10%, transparent)}}.series-registry-gauge-bg{stroke-width:4.5px}.series-registry-gauge-fill{fill:none;stroke-width:4.5px;stroke-linecap:round;transition:stroke-dashoffset .6s}.series-registry-gauge-fill-success{stroke:var(--pb-status-success)}.series-registry-gauge-fill-info{stroke:var(--pb-status-info)}.series-registry-gauge-fill-warning{stroke:var(--pb-status-warning)}.series-registry-gauge-fill-danger{stroke:var(--pb-status-danger)}.series-registry-gauge-fill-default{stroke:var(--pb-text-secondary)}.series-registry-gauge-value{justify-content:center;align-items:center;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700;display:flex;position:absolute;inset:0}.series-registry-gauge-label{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);margin-top:.25rem;font-size:.6rem;font-weight:600}.series-registry-actions{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.series-toolbar-shell{z-index:20;position:sticky;top:0}.series-toolbar-frame{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);overflow:visible}.series-toolbar-body{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.series-toolbar-browse{flex-wrap:wrap;align-items:flex-end;gap:.5rem;display:flex}.series-toolbar-primary{flex-wrap:wrap;flex:1;align-items:flex-end;gap:.5rem;min-width:0;display:flex}.series-toolbar-actions{flex-wrap:wrap;align-items:flex-end;gap:.5rem;display:flex}.series-toolbar-field{flex-shrink:0;display:block}.series-toolbar-label{text-transform:uppercase;letter-spacing:.08em;color:var(--pb-text-tertiary);margin-bottom:.25rem;font-size:.68rem;font-weight:700;display:block}.series-registry-search{width:220px;max-width:100%}.add-series-search-field{width:275px}.series-registry-search.search-field{border-color:var(--pb-border-default);background:var(--pb-surface-input);min-height:38px;box-shadow:none;border-radius:8px}.series-registry-search .search-field-input{font-size:.82rem}.series-registry-dropdown.dropdown-select{--pb-control-min-height:38px;--pb-control-font-size:.78rem;--pb-control-radius:8px;width:fit-content;max-width:100%}.series-registry-dropdown .dropdown-select-trigger{border-color:var(--pb-border-default);background:var(--pb-surface-input);box-shadow:none;padding-inline:.75rem}.series-registry-dropdown .dropdown-select-trigger-label{color:var(--pb-text-primary);font-weight:500}.series-registry-dropdown .dropdown-select-panel{border-radius:10px}.series-toolbar-view-block{flex-direction:column;display:inline-flex}.series-view-toggle{border:1px solid var(--pb-border-default);background:0 0;border-radius:8px;display:inline-flex;overflow:visible}.series-view-toggle-item:first-child{border-top-left-radius:7px;border-bottom-left-radius:7px}.series-view-toggle-item:last-child{border-top-right-radius:7px;border-bottom-right-radius:7px}.series-view-toggle-item{width:38px;height:34px;color:var(--pb-text-tertiary);background:0 0;border:none;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.series-view-toggle-item+.series-view-toggle-item{border-left:1px solid var(--pb-border-default)}.series-view-toggle-item:hover{background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.series-view-toggle-item:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.series-view-toggle-item:hover{color:var(--pb-text-primary)}html[data-series-view=list] .series-view-toggle-item[data-series-view-option=list],html[data-series-view=grid] .series-view-toggle-item[data-series-view-option=grid],html[data-story-arc-view=list] .series-view-toggle-item[data-story-arc-view-option=list],html[data-story-arc-view=grid] .series-view-toggle-item[data-story-arc-view-option=grid],html[data-whats-new-view=list] .whats-new-view-toggle-item[data-whats-new-view-option=list],html[data-whats-new-view=compact] .whats-new-view-toggle-item[data-whats-new-view-option=compact]{background:var(--pb-interactive);color:var(--pb-text-inverse)}.whats-new-pane-toggle{background:var(--pb-surface-input)}.whats-new-pane-toggle-item{min-height:34px;color:var(--pb-text-tertiary);letter-spacing:.06em;text-transform:uppercase;background:0 0;border:none;justify-content:center;align-items:center;padding:0 .875rem;font-size:.78rem;font-weight:700;transition:all .14s;display:inline-flex}.whats-new-pane-toggle-item:first-child{border-top-left-radius:7px;border-bottom-left-radius:7px}.whats-new-pane-toggle-item:last-child{border-top-right-radius:7px;border-bottom-right-radius:7px}.whats-new-pane-toggle-item+.whats-new-pane-toggle-item{border-left:1px solid var(--pb-border-default)}.whats-new-pane-toggle-item:hover{background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.whats-new-pane-toggle-item:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.whats-new-pane-toggle-item:hover{color:var(--pb-text-primary)}.whats-new-pane-toggle-item.is-active,.whats-new-pane-toggle-item[aria-current=page]{background:var(--pb-interactive);color:var(--pb-text-inverse)}.whats-new-title-block{min-height:4.55rem}.whats-new-cache-badge{min-height:34px;color:var(--pb-text-inverse);align-items:center;display:inline-flex}.whats-new-cache-badge-success{background:#1f6f4a}.whats-new-cache-badge-error{background:#9f2f24}.whats-new-week-nav{border:1px solid var(--pb-border-default);background:var(--pb-surface-input);border-radius:10px;justify-content:center;justify-self:center;align-items:center;gap:.375rem;width:fit-content;max-width:min(100%,34rem);margin-inline:auto;padding:.375rem;display:inline-flex}.whats-new-week-nav-button{border:1px solid var(--pb-border-default);background:var(--pb-surface-card);min-width:32px;min-height:32px;color:var(--pb-text-secondary);border-radius:8px;justify-content:center;align-items:center;font-size:.9rem;font-weight:800;transition:all .14s;display:inline-flex}.whats-new-week-nav-button:hover{border-color:var(--pb-border-hover);background:var(--pb-interactive-selected);color:var(--pb-text-primary)}.whats-new-week-nav-button.is-disabled{opacity:.45;cursor:not-allowed}.whats-new-week-dropdown.dropdown-select{--pb-control-min-height:32px;--pb-control-font-size:.78rem;--pb-control-radius:8px;flex:0 auto;width:fit-content;max-width:min(52vw,14rem)}.whats-new-week-dropdown .dropdown-select-trigger{border-color:var(--pb-border-default);background:var(--pb-surface-card);min-width:9.5rem;max-width:14rem;box-shadow:none;padding-inline:.625rem}.whats-new-week-dropdown .dropdown-select-panel{border-radius:10px;min-width:11.5rem;max-width:min(90vw,16rem)}.whats-new-week-dropdown .dropdown-select-trigger-label,.whats-new-week-dropdown .dropdown-select-option-label{font-weight:700}.whats-new-week-position{color:var(--pb-text-tertiary);letter-spacing:.08em;text-transform:uppercase;flex:none;padding-inline:.25rem .375rem;font-size:.68rem;font-weight:700}@media (max-width:640px){.whats-new-week-nav{max-width:100%}.whats-new-week-dropdown.dropdown-select{max-width:min(58vw,13rem)}.whats-new-week-position{text-align:center;width:100%}}.whats-new-results-stack,.whats-new-panel{gap:1rem;display:grid}.whats-new-stale-banner{border:1px solid var(--pb-status-warning);justify-content:space-between;align-items:center;gap:1rem;display:flex}@supports (color:color-mix(in lab, red, red)){.whats-new-stale-banner{border:1px solid color-mix(in srgb, var(--pb-status-warning) 40%, transparent)}}.whats-new-stale-banner{background:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.whats-new-stale-banner{background:color-mix(in srgb, var(--pb-status-warning) 10%, transparent)}}.whats-new-stale-banner{color:var(--pb-text-primary);border-radius:12px;margin-bottom:1rem;padding:.75rem 1rem;font-size:.82rem}.whats-new-stale-copy{min-width:0}.whats-new-stale-actions{flex-direction:column;flex:none;align-items:flex-end;gap:.375rem;display:flex}.whats-new-stale-message{max-width:24rem;color:var(--pb-text-secondary);text-align:right;font-size:.72rem;line-height:1.35}@media (max-width:640px){.whats-new-stale-banner{flex-direction:column;align-items:stretch}.whats-new-stale-actions{align-items:flex-start}.whats-new-stale-message{text-align:left}}.whats-new-release-summary{margin-bottom:0}.whats-new-release-table-slot{min-width:0}html[data-whats-new-view=compact] .whats-new-release-cover,html[data-whats-new-view=compact] .whats-new-pulls-col{display:none}html[data-whats-new-view=compact] .whats-new-release-title-cell{gap:0}.select-btn{border:1px solid var(--pb-border-default);min-height:38px;color:var(--pb-text-secondary);background:0 0;border-radius:8px;justify-content:center;align-items:center;gap:.375rem;padding:.375rem .875rem;font-family:DM Sans,sans-serif;font-size:.78rem;font-weight:600;transition:all .14s;display:inline-flex}.select-btn:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.select-btn:disabled{cursor:not-allowed;opacity:.45}.select-btn-success{color:var(--pb-status-success);border-color:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.select-btn-success{border-color:color-mix(in srgb, var(--pb-status-success) 30%, transparent)}}.select-btn-success{background:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.select-btn-success{background:color-mix(in srgb, var(--pb-status-success) 8%, transparent)}}.select-btn-warning{color:var(--pb-status-warning);border-color:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.select-btn-warning{border-color:color-mix(in srgb, var(--pb-status-warning) 30%, transparent)}}.select-btn-warning{background:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.select-btn-warning{background:color-mix(in srgb, var(--pb-status-warning) 8%, transparent)}}.select-btn-danger{color:var(--pb-status-danger);border-color:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){.select-btn-danger{border-color:color-mix(in srgb, var(--pb-status-danger) 30%, transparent)}}.select-btn-danger{background:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){.select-btn-danger{background:color-mix(in srgb, var(--pb-status-danger) 8%, transparent)}}.series-selection-shell{flex-direction:column;gap:.625rem;display:flex}.series-selection-inline{align-items:center;display:inline-flex}.series-selection-count{color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.78rem;font-weight:600}.series-selection-controls-row{flex-wrap:wrap;justify-content:space-between;gap:.75rem;display:flex}.series-selection-bulk,.series-selection-actions{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.series-results-shell{gap:calc(var(--spacing) * 4);min-height:16rem;padding-bottom:var(--pb-page-footer-clearance);flex-direction:column;flex:none;display:flex}.series-results-body-shell{min-height:14rem}:where(:is(.series-mission-control-shell,.series-collector-wall-shell)>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.series-mission-control-table-wrap{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;overflow:auto hidden}.series-mission-control-table{border-collapse:collapse;width:100%}.series-mission-control-table th{text-align:left;text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);background:var(--pb-surface-shell);border-bottom:2px solid var(--pb-border-default);padding:.5625rem .875rem;font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.series-mission-control-table td{border-bottom:1px solid var(--pb-border-subtle);padding:.6875rem .875rem;font-size:.82rem;transition:background .1s}.series-mission-control-table tbody tr:last-child td{border-bottom:none}.series-mission-control-table tbody tr:not(.table-detail-row):hover td{background:var(--pb-surface-selected)}.series-mission-control-table th.c,.series-mission-control-table td.c{text-align:center}.series-mission-control-table th.r,.series-mission-control-table td.r{text-align:right}.series-mission-control-row-selected td{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-mission-control-row-selected td{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.series-led{border-radius:50%;width:8px;height:8px;display:inline-block}.series-led-green{background:var(--pb-status-success);box-shadow:0 0 5px var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.series-led-green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-status-success) 35%, transparent)}}.series-led-amber{background:var(--pb-status-warning);box-shadow:0 0 5px var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.series-led-amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-status-warning) 35%, transparent)}}.series-led-off{background:var(--pb-text-tertiary);opacity:.35}.series-mission-control-name{color:var(--pb-text-primary);font-weight:700;transition:color .14s}.series-mission-control-name:hover{color:var(--pb-interactive)}.series-mission-control-publisher{color:var(--pb-text-tertiary);margin-top:1px;font-size:.72rem}.series-mission-control-year{color:var(--pb-text-tertiary);font-size:.78rem}.series-mission-control-bar-cell{width:220px}.series-mission-control-bar{align-items:center;gap:.5rem;display:flex}.series-mission-control-bar-track{background:var(--pb-text-secondary);border-radius:2px;flex:1;height:8px}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.series-mission-control-bar-track{border:1px solid var(--pb-border-subtle);position:relative;overflow:hidden}.series-mission-control-bar-fill{height:100%;position:relative}.series-mission-control-bar-fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}.series-mission-control-bar-fill-green{background:linear-gradient(90deg, var(--pb-status-success), var(--pb-status-success))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-green{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-success) 45%, transparent), var(--pb-status-success))}}.series-mission-control-bar-fill-amber{background:linear-gradient(90deg, var(--pb-status-warning), var(--pb-status-warning))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-amber{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-warning) 45%, transparent), var(--pb-status-warning))}}.series-mission-control-bar-fill-red{background:linear-gradient(90deg, var(--pb-status-danger), var(--pb-status-danger))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-red{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-danger) 45%, transparent), var(--pb-status-danger))}}.series-mission-control-bar-fill-blue{background:linear-gradient(90deg, var(--pb-status-info), var(--pb-status-info))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-blue{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-info) 45%, transparent), var(--pb-status-info))}}.series-mission-control-bar-pct{text-align:right;font-variant-numeric:tabular-nums;min-width:36px;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700}.series-mission-control-bar-pct-green{color:var(--pb-status-success)}.series-mission-control-bar-pct-amber{color:var(--pb-status-warning)}.series-mission-control-bar-pct-red{color:var(--pb-status-danger)}.series-mission-control-bar-pct-blue{color:var(--pb-status-info)}.series-mission-control-actions{justify-content:flex-end;gap:.25rem;display:flex}.series-mission-control-action-btn{border:1px solid var(--pb-border-subtle);width:30px;height:30px;color:var(--pb-text-tertiary);background:0 0;border-radius:8px;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.series-mission-control-action-btn:hover{border-color:var(--pb-interactive);color:var(--pb-interactive);background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-mission-control-action-btn:hover{background:color-mix(in srgb, var(--pb-interactive) 4%, transparent)}}.series-mission-control-action-btn.disabled{opacity:.25;pointer-events:none}.series-mission-control-action-btn svg{width:14px;height:14px}.series-mission-control-footer{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);color:var(--pb-text-tertiary);border-radius:10px;flex-wrap:wrap;align-items:center;gap:.375rem 1.5rem;padding:.625rem 1rem;font-family:JetBrains Mono,monospace;font-size:.7rem;display:flex}.series-mission-control-footer strong{color:var(--pb-text-primary);font-weight:600}.series-collector-wall-grid{grid-template-columns:repeat(auto-fill,minmax(172px,1fr));gap:24px 18px;display:grid}.series-wall-card{min-width:0;transition:transform .22s;position:relative}.series-wall-card:hover{transform:translateY(-5px)}.series-wall-card-selected .series-wall-cover-wrap{outline:2px solid var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-wall-card-selected .series-wall-cover-wrap{outline:2px solid color-mix(in srgb, var(--pb-interactive) 28%, transparent)}}.series-wall-card-selected .series-wall-cover-wrap{outline-offset:2px}.series-wall-cover-wrap{aspect-ratio:2/3;border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;transition:box-shadow .22s;position:relative;overflow:hidden}.series-wall-card:hover .series-wall-cover-wrap{box-shadow:var(--pb-shadow-2), 0 0 20px var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-wall-card:hover .series-wall-cover-wrap{box-shadow:var(--pb-shadow-2), 0 0 20px color-mix(in srgb, var(--pb-interactive) 6%, transparent)}}.series-wall-cover-placeholder{width:100%;height:100%;color:var(--pb-text-tertiary);background:linear-gradient(155deg, var(--pb-surface-card) 0%, var(--pb-surface-app) 100%);justify-content:center;align-items:center;display:flex}.series-monitor-badge{width:1.5rem;height:1.5rem;color:var(--pb-text-inverse);background:var(--pb-brand-signal);border:2px solid var(--pb-surface-card);border-radius:999px;flex:none;justify-content:center;align-items:center;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.series-monitor-badge{border:2px solid color-mix(in srgb, var(--pb-surface-card) 92%, white)}}.series-monitor-badge{box-shadow:0 0 0 2px #0000006b,0 10px 22px #00000057}.series-monitor-badge svg{flex:none;width:.9rem;height:.9rem}.series-monitor-badge.is-paused{color:var(--pb-text-secondary);background:var(--pb-surface-raised);border-color:var(--pb-border-default);box-shadow:0 0 0 1px var(--pb-border-subtle)}.series-wall-monitor-dot{z-index:3;position:absolute;top:9px;left:9px}.story-arc-wall-review{z-index:4;border:1px solid var(--pb-border-default);background:var(--pb-surface-card);border-radius:999px;justify-content:center;align-items:center;gap:.2rem;min-width:1.75rem;height:1.75rem;padding-inline:.38rem;display:inline-flex;position:absolute;top:9px;right:9px}@supports (color:color-mix(in lab, red, red)){.story-arc-wall-review{background:color-mix(in srgb, var(--pb-surface-card) 92%, transparent)}}.story-arc-wall-review{box-shadow:var(--pb-shadow-1);font-family:JetBrains Mono,monospace;font-size:.65rem;font-weight:700}.story-arc-wall-review svg{width:.8rem;height:.8rem}.story-arc-wall-review-warning{color:var(--pb-warning);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.story-arc-wall-review-warning{border-color:color-mix(in srgb, var(--pb-warning) 48%, transparent)}}.story-arc-wall-review-error{color:var(--pb-error);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.story-arc-wall-review-error{border-color:color-mix(in srgb, var(--pb-error) 48%, transparent)}}.story-arc-review-indicator{border:1px solid var(--pb-border-default);border-radius:999px;justify-content:center;align-items:center;gap:.28rem;min-width:2.5rem;min-height:1.75rem;padding-inline:.45rem;font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:700;display:inline-flex}.story-arc-review-indicator svg{width:.82rem;height:.82rem}.story-arc-review-indicator-success{color:var(--pb-success);background:var(--pb-success-dim);border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.story-arc-review-indicator-success{border-color:color-mix(in srgb, var(--pb-success) 32%, transparent)}}.story-arc-review-indicator-warning{color:var(--pb-warning);background:var(--pb-warning-dim);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.story-arc-review-indicator-warning{border-color:color-mix(in srgb, var(--pb-warning) 32%, transparent)}}.story-arc-review-indicator-error{color:var(--pb-error);background:var(--pb-error-dim);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.story-arc-review-indicator-error{border-color:color-mix(in srgb, var(--pb-error) 32%, transparent)}}.series-wall-catalog-state-badge{z-index:4;border:1px solid var(--pb-warning);background:var(--pb-warning);border-radius:999px;justify-content:center;align-items:center;max-width:calc(100% - 18px);display:inline-flex;position:absolute;top:46px;right:9px}@supports (color:color-mix(in lab, red, red)){.series-wall-catalog-state-badge{background:color-mix(in srgb, var(--pb-warning) 92%, black)}}.series-wall-catalog-state-badge{color:var(--pb-text-inverse);letter-spacing:.04em;text-align:center;text-transform:uppercase;padding:.2rem .5rem;font-size:.58rem;font-weight:800;line-height:1.15;box-shadow:0 8px 18px #00000057}.series-wall-catalog-state-badge.is-failed{border-color:var(--pb-error);background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.series-wall-catalog-state-badge.is-failed{background:color-mix(in srgb, var(--pb-error) 92%, black)}}.pull-list-monitor-toggle{width:28px;height:28px;box-shadow:none;cursor:pointer;border-width:1px;border-radius:6px;transition:background-color .14s,border-color .14s,color .14s,box-shadow .14s}.pull-list-monitor-toggle svg{width:12px;height:12px}.pull-list-monitor-toggle:hover{border-color:var(--pb-brand-signal)}@supports (color:color-mix(in lab, red, red)){.pull-list-monitor-toggle:hover{border-color:color-mix(in srgb, var(--pb-brand-signal) 56%, transparent)}}.pull-list-monitor-toggle:hover{background:var(--pb-brand-signal);color:var(--pb-text-inverse);box-shadow:0 0 0 2px var(--pb-brand-signal)}@supports (color:color-mix(in lab, red, red)){.pull-list-monitor-toggle:hover{box-shadow:0 0 0 2px color-mix(in srgb, var(--pb-brand-signal) 14%, transparent)}}.pull-list-monitor-toggle:focus-visible{outline:2px solid var(--pb-focus-outline);outline-offset:3px}.series-wall-selection-control{z-index:4;border:1px solid var(--pb-border-default);background:var(--pb-surface-card);border-radius:10px;position:absolute;top:10px;right:10px}@supports (color:color-mix(in lab, red, red)){.series-wall-selection-control{background:color-mix(in srgb, var(--pb-surface-card) 92%, transparent)}}.series-wall-selection-control{box-shadow:var(--pb-shadow-1);padding:.25rem}.series-wall-ring{z-index:2;width:44px;height:44px;position:absolute;bottom:-6px;right:-6px}.series-wall-ring svg{transform:rotate(-90deg)}.series-wall-ring-bg{fill:none;stroke:var(--pb-surface-card);stroke-width:3.5px}.series-wall-ring-fill{fill:none;stroke-width:3.5px;stroke-linecap:round;transition:stroke-dashoffset .6s}.series-wall-ring-fill-green{stroke:var(--pb-status-success)}.series-wall-ring-fill-amber{stroke:var(--pb-status-warning)}.series-wall-ring-fill-red{stroke:var(--pb-status-danger)}.series-wall-ring-center{background:var(--pb-surface-card);color:var(--pb-text-primary);border-radius:999px;justify-content:center;align-items:center;margin:5px;font-family:JetBrains Mono,monospace;font-size:.58rem;font-weight:700;display:flex;position:absolute;inset:0}.series-wall-overlay{--series-wall-overlay-edge:#fff;z-index:3;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);box-shadow:inset 0 0 0 4.5px var(--series-wall-overlay-edge);opacity:0;pointer-events:none;background:#1e1a17e0;border-radius:18px;flex-direction:column;justify-content:flex-end;padding:.875rem;transition:opacity .2s;display:flex;position:absolute;inset:-4px}[data-theme=light] .series-wall-overlay{--series-wall-overlay-edge:#1e1a17}@media (prefers-color-scheme:light){:root:not([data-theme]) .series-wall-overlay{--series-wall-overlay-edge:#1e1a17}}.series-wall-card:hover .series-wall-overlay{opacity:1}.series-wall-overlay-title{color:var(--pb-text-primary);font-size:.82rem;font-weight:700;line-height:1.3}.series-wall-overlay-meta{color:var(--pb-text-tertiary);gap:.25rem;margin-top:.45rem;font-size:.68rem;display:grid}.series-wall-overlay-meta-line,.series-wall-overlay-stat{grid-template-columns:minmax(4.25rem,max-content) minmax(0,1fr);align-items:baseline;column-gap:.5rem;line-height:1.25;display:grid}.series-wall-overlay-meta-label{color:var(--pb-text-tertiary);letter-spacing:.08em;text-transform:uppercase;font-family:JetBrains Mono,monospace;font-size:.56rem;font-weight:700}.series-wall-overlay-meta-value{overflow-wrap:anywhere;min-width:0;color:var(--pb-text-secondary)}.series-wall-overlay-stats{color:var(--pb-text-secondary);gap:.25rem;margin-top:.35rem;font-size:.68rem;display:grid}.series-wall-overlay-stats strong{color:var(--pb-text-primary);font-weight:600}.series-wall-overlay-actions{pointer-events:auto;gap:6px;margin-top:10px;display:flex}.series-wall-overlay-btn{color:var(--pb-text-secondary);text-align:center;background:#ffffff0f;border:1px solid #cbd5e140;border-radius:8px;flex:1;justify-content:center;padding:7px;font-family:DM Sans,sans-serif;font-size:.72rem;font-weight:600;transition:all .14s;display:inline-flex}.series-wall-overlay-btn:hover{background:var(--pb-interactive);color:var(--pb-text-primary);border-color:var(--pb-interactive)}.series-wall-card-title{text-overflow:ellipsis;white-space:nowrap;color:var(--pb-text-primary);margin-top:10px;font-size:.82rem;font-weight:600;line-height:1.3;display:block;overflow:hidden}.series-empty-state{justify-content:center;align-items:center;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 12);text-align:center;box-shadow:var(--pb-shadow-1);border-radius:1.25rem;flex-direction:column;display:flex}.series-empty-state-title{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.series-empty-state-copy{max-width:var(--container-xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}@media (max-width:1180px){.series-domain-hero-inner{grid-template-columns:130px minmax(0,1fr)}.issue-domain-hero-inner{grid-template-columns:120px minmax(0,1fr)}.series-domain-actions-panel{border-top:1px solid var(--pb-border-subtle);border-left:0;grid-column:1/-1;padding-top:1rem;padding-left:0}.series-domain-actions-panel .series-domain-actions-buttons{grid-template-columns:repeat(auto-fit,minmax(12rem,1fr));align-items:stretch;display:grid}}@media (max-width:900px){.issue-domain-stat-strip{grid-template-columns:repeat(2,minmax(0,1fr))}.issue-domain-file-grid{grid-template-columns:1fr}}@media (max-width:640px){.detail-hero-shell,.series-domain-actions-card,.series-domain-section-card{padding:1rem}.series-domain-cover-column,.issue-domain-cover-column,.issue-domain-cover-frame{width:104px}.series-domain-hero-inner,.issue-domain-hero-inner{grid-template-columns:104px minmax(0,1fr)}.series-domain-hero-title{font-size:1.2rem}.series-domain-actions-inner{align-items:flex-start}.series-domain-actions-divider{display:none}.series-domain-actions-panel .series-domain-actions-buttons,.issue-domain-stat-strip{grid-template-columns:1fr}.series-domain-info-box-wide{grid-column:auto}.series-domain-issues-toolbar{flex-direction:column;align-items:stretch}}[data-series-page][data-series-toolbar-mode=browse] [data-testid=series-select-toolbar],[data-series-page][data-series-toolbar-mode=select] [data-testid=series-browse-toolbar]{display:none}[data-series-page][data-series-toolbar-mode=select] [data-testid=series-select-toolbar]{display:flex}[data-select-toolbar-page][data-toolbar-mode=browse] [data-select-toolbar],[data-select-toolbar-page][data-toolbar-mode=select] [data-browse-toolbar]{display:none}[data-select-toolbar-page][data-toolbar-mode=select] [data-select-toolbar]{display:flex}[data-series-page][data-series-toolbar-mode=browse] [data-series-selection-cell]{display:none}[data-series-page][data-series-toolbar-mode=select] [data-series-selection-cell]{display:table-cell}[data-series-page][data-series-toolbar-mode=browse] [data-series-selection-control]{display:none}[data-series-page][data-series-toolbar-mode=select] [data-series-selection-control]{display:block}[data-series-page][data-series-toolbar-mode=select] [data-series-selection-control=grid]{display:inline-flex}#page-footer-dock:empty{display:none}body:has(#page-footer-dock:not(:empty)) #content{padding-bottom:var(--pb-page-footer-clearance)}body:has(#page-footer-dock:not(:empty)) #content:has(.admin-workspace-page,.dashboard-mission-page,.downloads-view,.series-domain-page,.series-results-shell,.utilities-page){padding-bottom:0}#content:has(.admin-workspace-page){overflow-anchor:none}.page-dock-inner{--page-dock-height-status:2rem;--page-dock-height-pagination:2.5rem;max-width:85rem;height:var(--page-dock-height-status);flex-wrap:nowrap;justify-content:space-between;align-items:center;gap:.75rem;margin:0 auto;padding:0 1rem;display:flex;overflow:hidden}.page-dock-inner:has(.page-dock-pagination){height:var(--page-dock-height-pagination)}.page-footer-clearance{height:var(--pb-page-footer-clearance);flex:0 0 var(--pb-page-footer-clearance);pointer-events:none}.page-dock-inner-status-only{justify-content:flex-end}.page-dock-pagination{flex:none}.page-dock-pagination nav{gap:.125rem}.page-dock-pagination nav>a,.page-dock-pagination nav>button,.page-dock-pagination nav>span{text-align:center;border-radius:.375rem;min-width:1.75rem;padding:.25rem .625rem;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:600;line-height:1}.page-dock-pagination nav>a,.page-dock-pagination nav>button{appearance:none;color:var(--pb-text-secondary);cursor:pointer;background:0 0;border:0;margin:0}.page-dock-pagination nav>span.bg-pb-interactive{border-radius:.375rem}.page-dock-status{min-width:0;color:var(--pb-text-tertiary);scrollbar-width:none;flex-wrap:nowrap;align-items:center;gap:1rem;font-family:JetBrains Mono,monospace;font-size:.65rem;display:flex;overflow-x:auto}.page-dock-status::-webkit-scrollbar{display:none}.page-dock-status-item{white-space:nowrap;align-items:center;gap:.25rem;display:inline-flex}.page-dock-status-value{color:var(--pb-text-primary);font-weight:600}.page-dock-status-label{text-transform:uppercase;letter-spacing:.04em}.page-dock-led{border-radius:999px;width:6px;height:6px;margin-right:2px;display:inline-block}.page-dock-led-green{background:var(--pb-status-success);box-shadow:0 0 4px var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.page-dock-led-green{box-shadow:0 0 4px color-mix(in srgb, var(--pb-status-success) 35%, transparent)}}.page-dock-led-amber{background:var(--pb-status-warning);box-shadow:0 0 4px var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.page-dock-led-amber{box-shadow:0 0 4px color-mix(in srgb, var(--pb-status-warning) 35%, transparent)}}.page-dock-led-off{background:var(--pb-text-tertiary);opacity:.35}@media (max-width:767px){.page-dock-inner{padding-inline:0}.page-dock-status{gap:.75rem}}.workflow-tabs-shell{align-items:center;gap:calc(var(--spacing) * 2);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-radius:1.25rem;flex-wrap:wrap;display:flex}@media (min-width:40rem){.workflow-tabs-shell{padding-inline:calc(var(--spacing) * 5)}}.workflow-tabs-shell{box-shadow:var(--pb-shadow-1)}.workflow-tab-chip-active{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary);border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-tab-chip-active{border-color:color-mix(in srgb, var(--pb-interactive) 34%, transparent)}}.workflow-tab-chip-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-tab-chip-active{background-color:color-mix(in srgb, var(--pb-interactive) 12%, var(--pb-bg-card))}}.workflow-tab-chip-active{box-shadow:var(--pb-shadow-1)}.workflow-tab-chip-inactive{color:var(--pb-text-dim)}.workflow-focus-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;position:relative;overflow:hidden}.workflow-focus-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.workflow-focus-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 34%, transparent), transparent)}}.workflow-focus-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.workflow-focus-grid{grid-template-columns:minmax(0,1.35fr) minmax(320px,.95fr);align-items:flex-start}}.workflow-focus-chip{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.workflow-focus-actions{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.workflow-focus-actions{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.workflow-focus-actions{grid-template-columns:repeat(1,minmax(0,1fr))}}:where(.workflow-shell-region>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.workflow-shell-region{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 4);border-radius:1.5rem;overflow:visible}@media (min-width:40rem){.workflow-shell-region{padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 5)}}.workflow-shell-region{box-shadow:var(--pb-shadow-1)}.utility-workspace-shell{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;overflow:hidden}.utility-workspace-shell-visible{overflow:visible}:where(.utility-workspace-body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.utility-workspace-body{padding:calc(var(--spacing) * 6)}@media (min-width:40rem){.utility-workspace-body{padding:calc(var(--spacing) * 7)}}:where(.utility-tool-body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.utility-tool-body{padding:calc(var(--spacing) * 5)}@media (min-width:40rem){.utility-tool-body{padding:calc(var(--spacing) * 5)}}.utility-workspace-shell .info-panel{border-color:var(--pb-border-subtle);background-color:var(--pb-bg-base);box-shadow:none}.utility-workspace-shell .info-panel-muted{background-color:var(--pb-bg-base)}.utility-empty-state{border-radius:var(--radius-xl);border-style:var(--tw-border-style);--tw-border-style:dashed;border-style:dashed;border-width:1px;border-color:var(--pb-border-subtle);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 8);text-align:center;background-color:var(--pb-bg-base)}.utility-path-pill{align-items:center;gap:calc(var(--spacing) * 2);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);display:flex}.utility-token{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.utility-step-grid{gap:calc(var(--spacing) * 3);display:grid}.utility-step-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);width:100%;padding:calc(var(--spacing) * 4);display:block}.utility-step-card-inline{align-items:center;gap:.75rem;display:flex}.utility-step-card-locked{opacity:.78}.utility-step-num{color:var(--pb-text-tertiary);min-width:1.25rem;font-family:JetBrains Mono,monospace;font-size:.62rem;font-weight:700}.utility-step-name{color:var(--pb-text-primary);flex:auto;min-width:0;font-size:.82rem;font-weight:600}.utility-step-tag{color:var(--pb-text-tertiary);white-space:nowrap;font-family:JetBrains Mono,monospace;font-size:.62rem}.utility-step-card-inline .utility-step-num,.utility-step-card-inline .utility-step-tag{color:var(--pb-text-secondary)}.utility-step-check{flex-shrink:0;margin-top:0}.utility-scan-mode-grid{gap:calc(var(--spacing) * 2);display:grid}@media (min-width:40rem){.utility-scan-mode-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-scan-mode{justify-content:center;align-items:flex-start;gap:var(--spacing);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);min-height:72px;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);text-align:left;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-direction:column;display:flex}.utility-scan-mode:hover{border-color:var(--pb-border-hover)}.utility-scan-mode-active{border-color:var(--pb-interactive);background-color:var(--pb-surface-selected)}.utility-scan-mode-label{--tw-font-weight:var(--font-weight-semibold);font-size:.82rem;font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.utility-scan-mode-copy{--tw-leading:calc(var(--spacing) * 5);font-size:.72rem;line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim)}.utility-scope-chip-row{flex-wrap:wrap;gap:.375rem;display:flex}.utility-scope-chip{border:1px solid var(--pb-border-default);color:var(--pb-text-secondary);background:0 0;border-radius:.5rem;justify-content:center;align-items:center;padding:.4375rem 1rem;font-size:.78rem;font-weight:600;transition:border-color .14s,background-color .14s,color .14s;display:inline-flex}.utility-scope-chip:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.utility-scope-chip-active{background:var(--pb-interactive);color:var(--pb-text-inverse);border-color:var(--pb-interactive)}.utility-scope-chip-active:hover{color:var(--pb-text-inverse)}.utility-export-chip-row{flex-wrap:wrap;gap:.375rem;display:flex}.utility-export-chip{border:1px solid var(--pb-border-default);color:var(--pb-text-secondary);background:0 0;border-radius:999px;justify-content:center;align-items:center;padding:.4375rem .875rem;font-family:DM Sans,sans-serif;font-size:.76rem;font-weight:500;line-height:1.2;transition:border-color .14s,background-color .14s,color .14s;display:inline-flex}.utility-export-chip:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.utility-export-chip-active{border-color:var(--pb-interactive);background:var(--pb-interactive);color:var(--pb-text-inverse)}.utility-export-chip-active:hover{color:var(--pb-text-inverse)}.utility-tool-section-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);margin-bottom:.5rem;font-family:Syne,sans-serif;font-size:.62rem;font-weight:700}.utility-tool-section-row{flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;display:flex}.utility-tool-field-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.58rem;font-weight:700;display:block}.utility-export-group-grid{gap:.75rem;display:grid}@media (min-width:1024px){.utility-export-group-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-export-group-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);border-radius:12px;flex-direction:column;gap:.5rem;padding:.875rem;display:flex}.utility-export-group-title{color:var(--pb-text-primary);font-size:.72rem;font-weight:600}.utility-export-group-fields{flex-direction:column;gap:.375rem;display:flex}.utility-export-group-fields .utility-step-card{padding:.5rem .75rem}.utility-export-field-label{color:var(--pb-text-primary);font-size:.8rem;font-weight:500;line-height:1.25}.utility-export-field-meta{justify-content:space-between;align-items:baseline;gap:.875rem;width:100%;min-width:0;display:flex}.utility-export-field-sub{color:var(--pb-text-tertiary);text-align:right;white-space:nowrap;text-overflow:ellipsis;flex:0 auto;min-width:0;font-family:JetBrains Mono,monospace;font-size:.66rem;line-height:1.2;overflow:hidden}.utility-export-options-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);border-radius:10px;padding:1rem}.utility-export-inline-label{color:var(--pb-text-primary);font-size:.72rem;font-weight:600}.utility-export-multi-grid{gap:.5rem;display:grid}@media (min-width:640px){.utility-export-multi-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-export-summary-grid{gap:.625rem;display:grid}@media (min-width:640px){.utility-export-summary-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-export-summary-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);border-radius:10px;padding:.875rem 1rem}.utility-export-summary-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.58rem;font-weight:700}.utility-export-summary-value{color:var(--pb-text-primary);margin-top:.35rem;font-family:JetBrains Mono,monospace;font-size:.92rem;font-weight:600}.utility-tool-select.dropdown-select{--pb-control-font-size:.82rem;--pb-control-line-height:1.25rem;--pb-control-radius:8px;width:100%}.utility-tool-select .dropdown-select-trigger{border-color:var(--pb-border-default);background:var(--pb-surface-input);min-height:0;box-shadow:none;padding:.5rem .75rem}.utility-tool-select .dropdown-select-trigger-label{color:var(--pb-text-primary);font-weight:400}.utility-tool-select .dropdown-select-chevron{color:var(--pb-text-secondary)}.utility-tool-output-field{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);color:var(--pb-text-secondary);border-radius:8px;padding:.5rem .75rem;font-family:DM Sans,sans-serif;font-size:.82rem;line-height:1.25rem}.utility-tool-browse-button{gap:.375rem;min-height:0;padding:.375rem .875rem;font-size:.78rem;font-weight:600;line-height:1.5}.utility-tool-browse-button svg{width:.875rem;height:.875rem}.utility-tool-table-wrap{border:1px solid var(--pb-border-subtle);border-radius:10px;overflow:hidden}.utility-tool-table{border-collapse:collapse;width:100%}.utility-tool-table th{text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);background:var(--pb-surface-shell);border-bottom:1px solid var(--pb-border-default);padding:.4375rem .75rem;font-family:Syne,sans-serif;font-size:.52rem;font-weight:700}.utility-tool-table th.r,.utility-tool-table td.r{text-align:right}.utility-tool-table td{color:var(--pb-text-secondary);border-bottom:1px solid var(--pb-border-subtle);padding:.5rem .75rem;font-size:.78rem}.utility-tool-table tbody tr:last-child td{border-bottom:none}.utility-tool-table tbody tr:hover td{background:var(--pb-surface-selected)}.utility-tool-table-output{color:var(--pb-status-success)}.utility-tool-action-footer{border-top:1px solid var(--pb-border-subtle);justify-content:flex-end;align-items:center;gap:.625rem;padding:.75rem 0 0;display:flex}.utility-tool-action-footer .settings-footer-actions{gap:.625rem;margin-left:auto}.utility-tool-action-footer .btn-ghost,.utility-tool-action-footer .btn-primary{min-height:0;padding:.5625rem 1.125rem;font-size:.82rem;line-height:1.25rem}.utility-tool-action-footer-card{margin-top:.25rem}.utility-step-card-active{border-color:var(--pb-interactive);background-color:var(--pb-surface-selected)}.utility-step-card-static{border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card)}.utility-empty-state-compact{padding-top:1rem;padding-bottom:1rem}.utility-template-table-type{color:var(--pb-text-primary);font-size:.78rem;font-weight:600}.utility-template-meta-token{color:var(--pb-text-tertiary);font-family:JetBrains Mono,monospace;font-size:.72rem}.utility-template-meta-token strong{color:var(--pb-text-secondary);font-weight:600}.utilities-queue-table-wrap{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:14px;overflow:hidden}.utilities-queue-table{border-collapse:collapse;width:100%}.utilities-queue-table th{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;--tw-font-weight:var(--font-weight-bold);font-family:Syne,sans-serif;font-size:.58rem;font-weight:var(--font-weight-bold);--tw-tracking:.1em;letter-spacing:.1em;color:var(--pb-text-dim);text-transform:uppercase}.utilities-queue-table th.c,.utilities-queue-table td.c{text-align:center}.utilities-queue-table th.r,.utilities-queue-table td.r{text-align:right}.utilities-queue-table td{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border-subtle);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);font-size:.82rem}.utilities-queue-table tbody:last-child tr:last-child td{border-bottom:0}.utilities-queue-row:hover td{background-color:var(--pb-surface-selected)}.utilities-queue-row>td{vertical-align:middle}.utilities-queue-led{height:calc(var(--spacing) * 2);width:calc(var(--spacing) * 2);background-color:var(--pb-text-dim);border-radius:3.40282e38px;display:inline-block}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led{background-color:color-mix(in srgb, var(--pb-text-dim) 45%, transparent)}}.utilities-queue-led-green{background-color:var(--pb-success);box-shadow:0 0 8px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led-green{box-shadow:0 0 8px color-mix(in srgb, var(--pb-success) 30%, transparent)}}.utilities-queue-led-amber{background-color:var(--pb-warning);box-shadow:0 0 8px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led-amber{box-shadow:0 0 8px color-mix(in srgb, var(--pb-warning) 28%, transparent)}}.utilities-queue-led-blue{background-color:var(--pb-info);box-shadow:0 0 8px var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led-blue{box-shadow:0 0 8px color-mix(in srgb, var(--pb-info) 28%, transparent)}}.utilities-queue-job-name{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary)}.utilities-queue-job-type{margin-top:calc(var(--spacing) * .5);color:var(--pb-text-dim);font-size:.72rem}.utilities-queue-job-meta{margin-top:var(--spacing);color:var(--pb-text-secondary);font-size:.68rem}.utilities-queue-items{--tw-font-weight:var(--font-weight-semibold);font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:var(--font-weight-semibold);color:var(--pb-text-secondary)}.utilities-queue-progress-cell .series-mission-control-bar{gap:.625rem}.utilities-queue-progress-cell .series-mission-control-bar-track{min-width:120px}.utilities-queue-time{color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.72rem}.utilities-queue-actions{justify-content:flex-end;align-items:center;gap:calc(var(--spacing) * 1.5);display:flex}.utilities-queue-act-btn{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:8px;justify-content:center;align-items:center;display:inline-flex}.utilities-queue-act-btn:hover{border-color:var(--pb-border-strong);color:var(--pb-text);background-color:var(--pb-bg-card-hover)}.utilities-queue-act-btn-danger{color:var(--pb-error)}.utilities-queue-act-btn-danger:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-act-btn-danger:hover{border-color:color-mix(in srgb, var(--pb-error) 36%, transparent)}}.utilities-queue-act-btn-danger:hover{background-color:var(--pb-error-dim);color:var(--pb-error)}.utilities-queue-detail-cell{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-detail-cell{background-color:color-mix(in srgb, var(--pb-bg-surface) 72%, transparent)}}.utilities-queue-detail-summary{align-items:center;column-gap:calc(var(--spacing) * 6);row-gap:var(--spacing);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);color:var(--pb-text-dim);flex-wrap:wrap;font-size:.72rem;display:flex}.utilities-queue-detail-summary strong{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.utilities-queue-detail-label{--tw-tracking:.08em;letter-spacing:.08em;text-transform:uppercase}.utilities-queue-empty{justify-content:center;align-items:center;gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 12);text-align:center;flex-direction:column;display:flex}.utilities-queue-empty-compact{padding-block:calc(var(--spacing) * 8)}.utilities-queue-empty-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.utilities-queue-empty-copy{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.queue-job-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);overflow:hidden}.queue-job-card-expanded{border-color:var(--pb-border-hover);box-shadow:var(--pb-shadow-2)}.queue-progress-track{height:calc(var(--spacing) * 1.5);background-color:var(--pb-bg-card-hover);border-radius:3.40282e38px;width:100%;overflow:hidden}.queue-progress-fill{height:100%;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.5s;background-color:var(--pb-success);border-radius:3.40282e38px;transition-duration:.5s}.queue-progress-fill-paused{background-color:var(--pb-warning)}.selection-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);text-align:left;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1)}.selection-card:not(.selection-card-active):hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.selection-card:not(.selection-card-active):hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 55%, transparent)}}.selection-card-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.selection-card-active{border-color:color-mix(in srgb, var(--pb-interactive) 42%, transparent)}}.selection-card-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.selection-card-active{background-color:color-mix(in srgb, var(--pb-interactive) 14%, var(--pb-bg-card))}}.selection-card-active{box-shadow:var(--pb-shadow-2)}.selection-card-icon{height:calc(var(--spacing) * 11);width:calc(var(--spacing) * 11);border-radius:var(--radius-xl);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-shrink:0;justify-content:center;align-items:center;display:flex}.selection-card-icon-active{color:var(--pb-interactive);background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.selection-card-icon-active{background-color:color-mix(in srgb, var(--pb-interactive) 14%, transparent)}}.settings-theme-choice{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.settings-theme-choice-icon{height:calc(var(--spacing) * 9);width:calc(var(--spacing) * 9);border-radius:var(--radius-lg)}.settings-media-preview-panel{background-color:var(--pb-surface-selected);border-color:var(--pb-border-hover)}@supports (color:color-mix(in lab, red, red)){.settings-media-preview-panel{border-color:color-mix(in srgb, var(--pb-border-hover) 85%, transparent)}}.settings-media-preview-panel{overflow-wrap:anywhere;min-height:4.625rem;transition:opacity .16s,border-color .16s}.settings-media-preview-panel.is-loading{opacity:.68;border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.settings-media-preview-panel.is-loading{border-color:color-mix(in srgb, var(--pb-interactive) 30%, var(--pb-border-hover))}}.workflow-step{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);box-shadow:var(--pb-shadow-1)}.workflow-step-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active{border-color:color-mix(in srgb, var(--pb-interactive) 32%, transparent)}}.workflow-step-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.workflow-step-complete{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete{border-color:color-mix(in srgb, var(--pb-success) 28%, transparent)}}.workflow-step-complete{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete{background-color:color-mix(in srgb, var(--pb-success) 10%, var(--pb-bg-card))}}.workflow-step-badge{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8);border-style:var(--tw-border-style);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);border-width:1px;border-color:var(--pb-border-hover);color:var(--pb-text-secondary);background-color:var(--pb-bg-card);border-radius:3.40282e38px;justify-content:center;align-items:center;display:flex}.workflow-step-active .workflow-step-badge{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active .workflow-step-badge{border-color:color-mix(in srgb, var(--pb-interactive) 32%, transparent)}}.workflow-step-active .workflow-step-badge{color:var(--pb-interactive);background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active .workflow-step-badge{background-color:color-mix(in srgb, var(--pb-interactive) 12%, transparent)}}.workflow-step-complete .workflow-step-badge{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete .workflow-step-badge{border-color:color-mix(in srgb, var(--pb-success) 28%, transparent)}}.workflow-step-complete .workflow-step-badge{color:var(--pb-success);background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete .workflow-step-badge{background-color:color-mix(in srgb, var(--pb-success) 12%, transparent)}}.workflow-step-label{--tw-font-weight:var(--font-weight-medium);font-size:10px;font-weight:var(--font-weight-medium);--tw-tracking:.12em;letter-spacing:.12em;color:var(--pb-text-dim);text-transform:uppercase}.workflow-step-title{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.checklist-item{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);display:flex}.section-body{padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4)}.section-header{gap:calc(var(--spacing) * 2);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);flex-direction:column;display:flex}@media (min-width:40rem){.section-header{flex-direction:row;justify-content:space-between;align-items:flex-start}}.section-header-copy{min-width:0}:where(.section-header-copy>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}.section-header-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;flex-shrink:0;display:flex}.section-eyebrow{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.section-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-primary);text-transform:uppercase}.section-title-plain{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.section-description{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.settings-connection-list{gap:calc(var(--spacing) * 3);flex-direction:column;display:flex}.settings-connection-card{cursor:pointer;border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);text-align:left;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.settings-connection-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.settings-connection-card-muted{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card-muted{background-color:color-mix(in srgb, var(--pb-bg-surface) 70%, transparent)}}.settings-connection-card-muted{opacity:.78}.settings-connection-card .downloads-action-btn.is-danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card .downloads-action-btn.is-danger{border-color:color-mix(in srgb, var(--pb-error) 28%, var(--pb-border-subtle))}}.settings-connection-card .downloads-action-btn.is-danger{background:var(--pb-error-dim);color:var(--pb-error)}.settings-connection-card .downloads-action-btn.is-danger:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card .downloads-action-btn.is-danger:hover{border-color:color-mix(in srgb, var(--pb-error) 44%, transparent)}}.settings-connection-card .downloads-action-btn.is-danger:hover{background:var(--pb-error-dim)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card .downloads-action-btn.is-danger:hover{background:color-mix(in srgb, var(--pb-error-dim) 86%, transparent)}}.settings-connection-card .downloads-action-btn.is-danger:hover{color:var(--pb-error)}.admin-nav-link{align-items:center;gap:calc(var(--spacing) * 2.5);border-radius:var(--radius-lg);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * 2);--tw-font-weight:var(--font-weight-medium);font-size:.8125rem;font-weight:var(--font-weight-medium);color:var(--pb-text-dim);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-width:1px;border-color:#0000;display:flex}.admin-nav-link:hover{color:var(--pb-text-secondary);background-color:var(--pb-surface-selected)}.admin-nav-link-active{color:var(--pb-interactive);background-color:var(--pb-surface-selected)}.admin-nav-link-icon{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);flex-shrink:0;justify-content:center;align-items:center;display:flex}.admin-nav-link-active .admin-nav-link-icon{color:var(--pb-interactive)}.admin-nav-link-copy{min-width:0}.admin-nav-link-title{text-overflow:ellipsis;white-space:nowrap;--tw-font-weight:var(--font-weight-medium);font-size:.8125rem;font-weight:var(--font-weight-medium);overflow:hidden}:where(.settings-rows>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-color:var(--pb-border)}.settings-row{gap:calc(var(--spacing) * 4);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);grid-template-columns:repeat(1,minmax(0,1fr));display:grid}@media (min-width:40rem){.settings-row{grid-template-columns:220px minmax(0,1fr);align-items:flex-start}}.settings-row-meta{padding-top:var(--spacing)}.settings-row-label{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);display:block}.settings-row-help{margin-top:calc(var(--spacing) * .5);--tw-leading:calc(var(--spacing) * 5);font-size:11px;line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim)}.settings-row-content{min-width:0}.settings-rows-align-end .settings-row-content{flex-direction:column;align-items:flex-end;display:flex}.settings-footer{gap:calc(var(--spacing) * 3);border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 3);flex-direction:column;display:flex}@media (min-width:40rem){.settings-footer{flex-direction:row;justify-content:space-between;align-items:center}}.settings-footer{background-color:var(--pb-bg-surface);overflow-anchor:none}@media (min-width:40rem){.settings-footer-end{justify-content:flex-end}}.settings-footer-copy{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.settings-footer-actions{align-items:center;gap:calc(var(--spacing) * 3);flex-wrap:wrap;display:flex}.settings-footer-actions .btn-primary,.settings-footer-actions .btn-ghost,.settings-footer-actions .btn-danger,.settings-footer-actions .btn-warning{min-height:calc(var(--spacing) * 10);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.field-note{margin-top:calc(var(--spacing) * 1.5);align-items:flex-start;gap:calc(var(--spacing) * 1.5);--tw-leading:calc(var(--spacing) * 5);font-size:11px;line-height:calc(var(--spacing) * 5);display:flex}.field-note code{color:var(--pb-text-secondary)}.field-note-icon{margin-top:calc(var(--spacing) * .5);flex-shrink:0;width:.75rem;height:.75rem}.field-note-warning{color:var(--pb-warning)}.field-note-info{color:var(--pb-info)}.field-note-danger{color:var(--pb-error)}.field-note-success{color:var(--pb-success)}.alert-banner{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);border-width:1px;flex-direction:column;display:flex}@media (min-width:40rem){.alert-banner{flex-direction:row;justify-content:space-between;align-items:flex-start}}.alert-banner{box-shadow:var(--pb-shadow-1)}.alert-banner-main{align-items:flex-start;gap:calc(var(--spacing) * 4);min-width:0;display:flex}.alert-banner-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:var(--radius-lg);flex-shrink:0;justify-content:center;align-items:center;display:flex}.alert-banner-copy{min-width:0}:where(.alert-banner-copy>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}.alert-banner-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.alert-banner-description{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.alert-banner-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;flex-shrink:0;display:flex}.alert-banner-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.alert-banner-info{background-color:color-mix(in srgb, var(--pb-info) 10%, var(--pb-bg-card))}}.alert-banner-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.alert-banner-info{border-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.alert-banner-info .alert-banner-icon{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.alert-banner-info .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.alert-banner-info .alert-banner-icon{color:var(--pb-info)}.alert-banner-warning{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.alert-banner-warning{background-color:color-mix(in srgb, var(--pb-warning) 10%, var(--pb-bg-card))}}.alert-banner-warning{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.alert-banner-warning{border-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.alert-banner-warning .alert-banner-icon{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.alert-banner-warning .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.alert-banner-warning .alert-banner-icon{color:var(--pb-warning)}.alert-banner-danger{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.alert-banner-danger{background-color:color-mix(in srgb, var(--pb-error) 10%, var(--pb-bg-card))}}.alert-banner-danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.alert-banner-danger{border-color:color-mix(in srgb, var(--pb-error) 18%, transparent)}}.alert-banner-danger .alert-banner-icon{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.alert-banner-danger .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-error) 18%, transparent)}}.alert-banner-danger .alert-banner-icon{color:var(--pb-error)}.alert-banner-success{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.alert-banner-success{background-color:color-mix(in srgb, var(--pb-success) 10%, var(--pb-bg-card))}}.alert-banner-success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.alert-banner-success{border-color:color-mix(in srgb, var(--pb-success) 18%, transparent)}}.alert-banner-success .alert-banner-icon{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.alert-banner-success .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-success) 18%, transparent)}}.alert-banner-success .alert-banner-icon{color:var(--pb-success)}.modal-shell{z-index:50;padding:calc(var(--spacing) * 4);padding-top:calc(var(--spacing) * 16);justify-content:center;align-items:flex-start;display:flex;position:fixed;inset:0}.modal-backdrop{z-index:0;background-color:var(--pb-bg-overlay);--tw-backdrop-blur:blur(var(--blur-xs));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);position:fixed;inset:0}.modal-panel{z-index:10;border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);width:100%;box-shadow:var(--pb-shadow-overlay);flex-direction:column;display:flex;position:relative;overflow:hidden}.modal-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);justify-content:space-between;align-items:center;display:flex}.modal-body{flex:1;overflow-y:auto}.modal-footer{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);flex-shrink:0;justify-content:space-between;align-items:center;display:flex}.donation-modal-shell{align-items:flex-start;padding-top:4.75rem}.donation-modal-panel{border-radius:1.5rem;width:min(100vw - 2rem,56rem);max-height:calc(100dvh - 6rem)}.donation-modal-header{border-bottom-color:var(--pb-border-subtle);background:radial-gradient(circle at top left, var(--pb-brand-dim), transparent 18rem), var(--pb-bg-card);align-items:flex-start;gap:1rem}.donation-modal-kicker{letter-spacing:.18em;text-transform:uppercase;color:var(--pb-brand);font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:700}.donation-modal-title{color:var(--pb-text);letter-spacing:-.03em;margin-top:.2rem;font-family:Syne,sans-serif;font-size:clamp(1.35rem,2.5vw,2rem);font-weight:800;line-height:1.05}.donation-modal-copy{max-width:38rem;color:var(--pb-text-sec);margin-top:.45rem;font-size:.9rem;line-height:1.55}.donation-modal-body{background:linear-gradient(135deg, transparent, var(--pb-surface-selected)), var(--pb-bg-surface);gap:1rem;padding:1rem;display:grid}.donation-option-card{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:1.25rem;grid-template-columns:minmax(0,1fr) 10.5rem;align-items:stretch;gap:1rem;padding:1rem;display:grid}.donation-option-card--coffee{border-color:#fd0}@supports (color:color-mix(in lab, red, red)){.donation-option-card--coffee{border-color:color-mix(in srgb, #fd0 38%, var(--pb-border-subtle))}}.donation-option-card--liberapay{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.donation-option-card--liberapay{border-color:color-mix(in srgb, var(--pb-success) 32%, var(--pb-border-subtle))}}.donation-option-main{flex-direction:column;justify-content:space-between;gap:1rem;min-width:0;display:flex}.donation-option-eyebrow{letter-spacing:.16em;text-transform:uppercase;color:var(--pb-text-sec);font-family:JetBrains Mono,monospace;font-size:.64rem;font-weight:700}.donation-option-title{color:var(--pb-text);margin-top:.25rem;font-family:Syne,sans-serif;font-size:1.1rem;font-weight:800}.donation-option-copy{max-width:28rem;color:var(--pb-text-sec);margin-top:.35rem;font-size:.82rem;line-height:1.5}.donation-option-button{border-radius:999px;justify-content:center;align-items:center;width:fit-content;padding:.62rem 1rem;font-size:.82rem;font-weight:800;line-height:1;text-decoration:none;transition:transform .16s,box-shadow .16s,filter .16s;display:inline-flex}.donation-option-button:hover,.donation-option-button:focus-visible{filter:brightness(1.02);transform:translateY(-1px)}.donation-option-button:focus-visible{outline:2px solid var(--pb-focus-outline);outline-offset:3px}.donation-option-button--coffee{color:#16130a;background:#fd0;box-shadow:0 10px 20px #fd03}.donation-option-button--liberapay{background:var(--pb-success);color:#f7f1e8;box-shadow:0 10px 20px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.donation-option-button--liberapay{box-shadow:0 10px 20px color-mix(in srgb, var(--pb-success) 20%, transparent)}}[data-theme=dark] .donation-option-button--liberapay{color:#16130a}.donation-qr-frame{border:1px solid var(--pb-border-subtle);background:#fff;border-radius:1rem;place-items:center;min-height:10.5rem;padding:.625rem;display:grid}.donation-qr{object-fit:contain;width:9.25rem;height:9.25rem}@media (max-width:720px){.donation-modal-shell{padding-top:3.25rem}.donation-modal-panel{max-height:calc(100dvh - 4rem)}.donation-option-card{grid-template-columns:1fr}.donation-option-button{width:100%}}.issue-search-modal-panel{border-radius:1.25rem;width:min(100vw - 2rem,78rem);max-width:none;max-height:90vh}.issue-search-modal-header{border-bottom-color:var(--pb-border-subtle);align-items:flex-start;padding:1.125rem 1.5rem}.issue-search-modal-title{letter-spacing:.03em;text-transform:uppercase;color:var(--pb-text);font-family:Syne,sans-serif;font-size:1.05rem;font-weight:800}.issue-search-modal-title span{color:var(--pb-brand)}.issue-search-modal-subtitle{color:var(--pb-text-dim);margin-top:.1875rem;font-size:.78rem}.issue-search-modal-stats{flex-wrap:wrap;gap:.375rem;margin-top:.5rem;display:flex}.issue-search-modal-close{border-radius:var(--radius-lg);--tw-border-style:none;color:var(--pb-text-dim);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:#0000;border-style:none;justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.issue-search-modal-close:hover{color:var(--pb-text-primary)}}.issue-search-modal-close{width:1.875rem;height:1.875rem}.issue-search-modal-close:hover{background:var(--pb-surface-selected)}.issue-search-modal-body{padding:0}.issue-search-loading-state{min-height:24rem}.issue-search-loading-title{color:var(--pb-text);font-size:.82rem;font-weight:600}.issue-search-loading-copy{color:var(--pb-text-dim);font-size:.72rem}.issue-search-dc-status{border-top:1px solid var(--pb-border-subtle);color:var(--pb-text-dim);align-items:center;gap:.625rem;padding:.75rem 1rem;font-size:.78rem;display:flex}.issue-search-dc-results{border-top:1px solid var(--pb-border-subtle)}.issue-search-dc-results-heading{align-items:center;gap:.5rem;padding:.75rem 1rem;display:flex}.issue-search-modal-footer{border-top-color:var(--pb-border-subtle);justify-content:flex-end;padding:.75rem 1.5rem}.issue-search-modal-footer-meta{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.68rem}.issue-search-modal-footer-meta:empty{display:none}.issue-search-modal-footer-meta strong{color:var(--pb-text);font-weight:700}.issue-search-modal-footer-close{margin-left:auto}.inline-alert{align-items:flex-start;gap:calc(var(--spacing) * 2);border-radius:var(--radius-lg);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2.5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));border-width:1px;display:flex}.inline-alert-icon{margin-top:calc(var(--spacing) * .5);flex-shrink:0}.inline-alert-danger{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.inline-alert-danger{background-color:color-mix(in srgb, var(--pb-error) 12%, var(--pb-bg-card))}}.inline-alert-danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.inline-alert-danger{border-color:color-mix(in srgb, var(--pb-error) 22%, transparent)}}.inline-alert-danger{color:var(--pb-error)}.inline-alert-warning{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.inline-alert-warning{background-color:color-mix(in srgb, var(--pb-warning) 12%, var(--pb-bg-card))}}.inline-alert-warning{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.inline-alert-warning{border-color:color-mix(in srgb, var(--pb-warning) 22%, transparent)}}.inline-alert-warning{color:var(--pb-warning)}.inline-alert-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.inline-alert-info{background-color:color-mix(in srgb, var(--pb-info) 12%, var(--pb-bg-card))}}.inline-alert-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.inline-alert-info{border-color:color-mix(in srgb, var(--pb-info) 22%, transparent)}}.inline-alert-info{color:var(--pb-info)}.inline-alert-success{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.inline-alert-success{background-color:color-mix(in srgb, var(--pb-success) 12%, var(--pb-bg-card))}}.inline-alert-success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.inline-alert-success{border-color:color-mix(in srgb, var(--pb-success) 22%, transparent)}}.inline-alert-success{color:var(--pb-success)}.field-invalid{border-color:var(--pb-error)!important}@supports (color:color-mix(in lab, red, red)){.field-invalid{border-color:color-mix(in srgb, var(--pb-error) 52%, transparent)!important}}.icon-btn{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.icon-btn:hover{border-color:var(--pb-border-strong);color:var(--pb-text-primary)}}.icon-btn:disabled{cursor:not-allowed;opacity:.5}.icon-btn{min-width:var(--pb-control-min-height);width:var(--pb-control-min-height);min-height:var(--pb-control-min-height);border-radius:var(--pb-control-radius);padding:0}.toggle-switch{cursor:pointer;background-color:var(--pb-bg-card-hover);width:2.25rem;height:1.25rem;box-shadow:inset 0 0 0 1px var(--pb-border-hover);border-radius:999px;flex-shrink:0;transition:background-color .18s,box-shadow .18s;display:inline-flex;position:relative}label:has(>.toggle-input+.toggle-switch){position:relative}.toggle-input{clip:rect(0, 0, 0, 0);clip-path:inset(50%);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.toggle-input-overlay{opacity:0;clip:auto;clip-path:none;white-space:normal;z-index:1;width:100%;height:100%;margin:0;inset:0}.toggle-switch:after{content:"";background-color:var(--pb-brand);width:1rem;height:1rem;box-shadow:0 1px 2px var(--pb-text-primary);border-radius:999px;position:absolute;top:2px;left:2px}@supports (color:color-mix(in lab, red, red)){.toggle-switch:after{box-shadow:0 1px 2px color-mix(in srgb, var(--pb-text-primary) 14%, transparent)}}.toggle-switch:after{transition:transform .18s}.peer:checked+.toggle-switch{background-color:var(--pb-interactive);box-shadow:inset 0 0 0 1px var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.peer:checked+.toggle-switch{box-shadow:inset 0 0 0 1px color-mix(in srgb, var(--pb-interactive) 34%, transparent)}}.peer:checked+.toggle-switch:after{background-color:var(--pb-surface-shell);transform:translate(1rem)}.peer:focus-visible+.toggle-switch{outline:2px solid var(--pb-focus-outline);outline-offset:3px;box-shadow:0 0 0 4px var(--pb-focus-ring)}.strength-segment{height:calc(var(--spacing) * 1.5);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.3s;border-radius:3.40282e38px;flex:1;transition-duration:.3s}.strength-segment-neutral{background-color:var(--pb-bg-card-hover)}.strength-segment-danger{background-color:var(--pb-error)}.strength-segment-warning{background-color:var(--pb-warning)}.strength-segment-success{background-color:var(--pb-success)}.input-pb{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-input);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);font-family:DM Sans,sans-serif;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-primary);border-radius:10px}.input-pb::placeholder{color:var(--pb-text-dim)}.input-pb:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.input-pb:focus{outline-offset:2px;outline:2px solid #0000}}.checkbox-pb{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);border-color:var(--pb-border-hover);background-color:var(--pb-bg-input);color:var(--pb-interactive);border-radius:.25rem}.checkbox-pb:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-ring-offset-width:0px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.select-pb{appearance:none;border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-input);width:100%;padding-block:calc(var(--spacing) * 2);padding-right:calc(var(--spacing) * 8);padding-left:calc(var(--spacing) * 3);font-family:DM Sans,sans-serif;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-primary);border-radius:10px}.select-pb:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.select-pb:focus{outline-offset:2px;outline:2px solid #0000}}.select-pb{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%237A8599' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.25rem}.dropdown-select{min-width:0;position:relative}.dropdown-select-trigger{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);width:100%;color:var(--pb-text-primary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));justify-content:space-between;align-items:center;display:flex}@media (hover:hover){.dropdown-select-trigger:hover{border-color:var(--pb-border-strong)}}.dropdown-select-trigger:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.dropdown-select-trigger:focus{outline-offset:2px;outline:2px solid #0000}}.dropdown-select-trigger:disabled{cursor:not-allowed;opacity:.6}.dropdown-select-trigger{gap:var(--pb-control-gap);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.dropdown-select-trigger-label{text-overflow:ellipsis;white-space:nowrap;text-align:left;flex:1;min-width:0;overflow:hidden}.dropdown-select-chevron{color:var(--pb-text-secondary);transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));width:var(--pb-control-icon-size);height:var(--pb-control-icon-size);flex-shrink:0}.import-advanced-disclosure[open]>summary .import-advanced-disclosure-chevron{rotate:90deg}.dropdown-select-panel{z-index:80;max-height:calc(var(--spacing) * 60);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card);padding-block:var(--spacing);--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-shadow-color:#0003;position:fixed;top:0;left:0;overflow-y:auto}@supports (color:color-mix(in lab, red, red)){.dropdown-select-panel{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.dropdown-select-panel{max-height:var(--pb-dropdown-panel-max-height,15rem);will-change:top, left}.dropdown-select-panel[data-ready=false]{visibility:hidden;pointer-events:none}.dropdown-select-option{text-align:left;width:100%;color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));justify-content:space-between;align-items:center;gap:var(--pb-control-gap);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-panel-item-px);padding-block:var(--pb-control-panel-item-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);display:flex}.dropdown-select-option-label{text-align:left;white-space:nowrap;overflow-wrap:normal;flex:1;min-width:0}.import-reconcile-issue-dropdown .dropdown-select-trigger{align-items:flex-start;height:auto}.import-reconcile-issue-dropdown .dropdown-select-trigger-label{white-space:normal;overflow-wrap:anywhere;text-overflow:clip;overflow:visible}.dropdown-select-panel-wrap .dropdown-select-option{align-items:flex-start}.dropdown-select-panel-wrap .dropdown-select-option-label{white-space:normal;overflow-wrap:anywhere;word-break:normal}.dropdown-select-option:hover,.dropdown-select-option-active{background-color:var(--pb-info-dim);color:var(--pb-text-primary)}.dropdown-select-option-active{transition-duration:0s}.dropdown-select-option-selected{background-color:var(--pb-info-dim);color:var(--pb-text-primary);font-weight:500}.dropdown-select-option-check{width:var(--pb-control-icon-size);height:var(--pb-control-icon-size);flex-shrink:0}.direct-provider-uri-control{position:relative}.direct-provider-uri-input{padding-right:calc(var(--spacing) * 10);font-family:JetBrains Mono,Fira Code,monospace}.direct-provider-uri-toggle{width:calc(var(--spacing) * 10);border-top-right-radius:var(--radius-lg);border-bottom-right-radius:var(--radius-lg);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));inset-block:0;justify-content:center;align-items:center;display:flex;position:absolute;right:0}@media (hover:hover){.direct-provider-uri-toggle:hover{color:var(--pb-text-primary)}}.direct-provider-uri-toggle:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.direct-provider-uri-toggle:focus{outline-offset:2px;outline:2px solid #0000}}.direct-provider-uri-toggle:focus{--tw-ring-inset:inset}.direct-provider-uri-options{z-index:90;margin-top:var(--spacing);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card);padding-block:var(--spacing);--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-shadow-color:#0003;position:absolute;top:100%;left:0;right:0;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.direct-provider-uri-options{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.direct-provider-uri-option{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 2);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:flex}@media (hover:hover){.direct-provider-uri-option:hover{background-color:var(--pb-info-dim);color:var(--pb-text-primary)}}.direct-provider-uri-option:focus{background-color:var(--pb-info-dim);color:var(--pb-text-primary);--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.direct-provider-uri-option:focus{outline-offset:2px;outline:2px solid #0000}}.direct-provider-uri-option-selected{background-color:var(--pb-info-dim);color:var(--pb-text-primary)}.search-field{min-width:0;position:relative}.search-field-icon{pointer-events:none;z-index:10;--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y);color:var(--pb-text-secondary);top:50%;left:var(--pb-control-search-icon-left);width:var(--pb-control-icon-size);height:var(--pb-control-icon-size);position:absolute}.search-field-input{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);width:100%;color:var(--pb-text-primary)}.search-field-input::placeholder{color:var(--pb-text-secondary)}.search-field-input:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.search-field-input:focus{outline-offset:2px;outline:2px solid #0000}}.search-field-input{min-height:var(--pb-control-min-height);padding-inline-start:var(--pb-control-search-padding-left);padding-inline-end:var(--pb-control-search-padding-right);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius);appearance:none;background-image:none}.search-field-editor{white-space:nowrap;min-height:42px;overflow:hidden}.search-field-editor[data-empty=true]:before{content:attr(data-placeholder);color:var(--pb-text-secondary)}.search-field-input::-webkit-search-decoration{appearance:none;display:none}.search-field-input::-webkit-search-cancel-button{appearance:none;display:none}.search-field-input::-webkit-search-results-button{appearance:none;display:none}.search-field-input::-webkit-search-results-decoration{appearance:none;display:none}.search-field-input::-ms-clear{width:0;height:0;display:none}.search-field-input::-ms-reveal{width:0;height:0;display:none}.search-field-clear{z-index:10;--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y);padding:calc(var(--spacing) * .5);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:.25rem;position:absolute;top:50%}@media (hover:hover){.search-field-clear:hover{color:var(--pb-text-primary)}}.search-field-clear{right:var(--pb-control-clear-right)}.search-history-panel{z-index:30;margin-top:var(--spacing);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card);--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-shadow-color:#0006;position:absolute;top:100%;left:0;right:0;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.search-history-panel{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 40%, transparent) var(--tw-shadow-alpha), transparent)}}.search-history-panel-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);justify-content:space-between;align-items:center;display:flex}.search-history-panel-title{--tw-font-weight:var(--font-weight-semibold);font-size:10px;font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider);color:var(--pb-text-dim);text-transform:uppercase}.search-history-panel-clear{color:var(--pb-text-dim);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));font-size:10px}@media (hover:hover){.search-history-panel-clear:hover{color:var(--pb-error)}}.search-history-list{max-height:280px;padding-block:var(--spacing);overflow-y:auto}.search-history-item{align-items:center;display:flex}.search-history-item-button{align-items:center;gap:calc(var(--spacing) * 2.5);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex:1;display:flex}@media (hover:hover){.search-history-item-button:hover{color:var(--pb-text-primary)}}.search-history-item-button:hover{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.search-history-item-button:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.search-history-item-remove{padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * 2);color:var(--pb-text-dim);opacity:0;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){.search-history-item-remove:is(:where(.group):hover *){opacity:1}.search-history-item-remove:hover{color:var(--pb-error)}}.touch-target{min-width:44px;min-height:44px}.progress-track{height:var(--spacing);background-color:var(--pb-border);border-radius:3.40282e38px}.progress-fill{background-color:var(--pb-success);height:100%;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:3.40282e38px}.progress-fill-partial{background-color:var(--pb-interactive);height:100%;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:3.40282e38px}.callout-brand{align-items:center;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:var(--pb-brand-signal-dim);border-width:1px;border-color:var(--pb-brand-signal-border);display:flex}.callout-brand:hover{background-color:var(--pb-brand-signal-hover);border-color:var(--pb-brand-signal-border)}.callout-brand-icon{border-radius:var(--radius-lg);padding:calc(var(--spacing) * 2);background-color:var(--pb-brand-signal-dim);color:var(--pb-brand-signal);flex-shrink:0}.callout-brand-title{color:var(--pb-brand-signal)}.callout-brand-body{color:var(--pb-brand-signal);opacity:.7}.callout-brand-chevron{color:var(--pb-brand-signal);opacity:.5}}@layer utilities{.pointer-events-none{pointer-events:none}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-y-0{inset-block:0}.-top-3{top:calc(var(--spacing) * -3)}.top-0{top:0}.top-1\/2{top:50%}.top-2{top:calc(var(--spacing) * 2)}.top-4{top:calc(var(--spacing) * 4)}.-right-1{right:calc(var(--spacing) * -1)}.-right-3{right:calc(var(--spacing) * -3)}.right-0{right:0}.right-2{right:calc(var(--spacing) * 2)}.right-4{right:calc(var(--spacing) * 4)}.-bottom-1{bottom:calc(var(--spacing) * -1)}.left-0{left:0}.left-3{left:calc(var(--spacing) * 3)}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[1\]{z-index:1}.z-\[60\]{z-index:60}.z-\[100\]{z-index:100}.z-\[110\]{z-index:110}.col-span-2{grid-column:span 2/span 2}.col-span-full{grid-column:1/-1}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.my-2{margin-block:calc(var(--spacing) * 2)}.\!mt-0{margin-top:0!important}.\!mt-2{margin-top:calc(var(--spacing) * 2)!important}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-1{margin-right:var(--spacing)}.\!mb-0{margin-bottom:0!important}.mb-1{margin-bottom:var(--spacing)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.-ml-2{margin-left:calc(var(--spacing) * -2)}.ml-0\.5{margin-left:calc(var(--spacing) * .5)}.ml-1{margin-left:var(--spacing)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-\[140px\]{margin-left:140px}.ml-auto{margin-left:auto}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.table-cell{display:table-cell}.table-row{display:table-row}.aspect-\[2\/3\]{aspect-ratio:2/3}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-4\.5{height:calc(var(--spacing) * 4.5)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-16{height:calc(var(--spacing) * 16)}.h-32{height:calc(var(--spacing) * 32)}.h-96{height:calc(var(--spacing) * 96)}.h-\[18px\]{height:18px}.h-\[100dvh\]{height:100dvh}.h-full{height:100%}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-72{max-height:calc(var(--spacing) * 72)}.max-h-80{max-height:calc(var(--spacing) * 80)}.max-h-\[28rem\]{max-height:28rem}.max-h-\[62vh\]{max-height:62vh}.max-h-\[70vh\]{max-height:70vh}.max-h-\[85vh\]{max-height:85vh}.max-h-\[90vh\]{max-height:90vh}.max-h-\[95vh\]{max-height:95vh}.max-h-\[calc\(100vh-5rem\)\]{max-height:calc(100vh - 5rem)}.\!min-h-8{min-height:calc(var(--spacing) * 8)!important}.\!min-h-10{min-height:calc(var(--spacing) * 10)!important}.min-h-0{min-height:0}.min-h-8{min-height:calc(var(--spacing) * 8)}.min-h-56{min-height:calc(var(--spacing) * 56)}.min-h-\[36px\]{min-height:36px}.min-h-\[200px\]{min-height:200px}.\!w-8{width:calc(var(--spacing) * 8)!important}.\!w-10{width:calc(var(--spacing) * 10)!important}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-1\/3{width:33.3333%}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-4\.5{width:calc(var(--spacing) * 4.5)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-12{width:calc(var(--spacing) * 12)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-32{width:calc(var(--spacing) * 32)}.w-36{width:calc(var(--spacing) * 36)}.w-40{width:calc(var(--spacing) * 40)}.w-44{width:calc(var(--spacing) * 44)}.w-48{width:calc(var(--spacing) * 48)}.w-56{width:calc(var(--spacing) * 56)}.w-64{width:calc(var(--spacing) * 64)}.w-80{width:calc(var(--spacing) * 80)}.w-96{width:calc(var(--spacing) * 96)}.w-\[10\%\]{width:10%}.w-\[12\%\]{width:12%}.w-\[14\%\]{width:14%}.w-\[18px\]{width:18px}.w-\[22rem\]{width:22rem}.w-\[24\%\]{width:24%}.w-\[26\%\]{width:26%}.w-\[28px\]{width:28px}.w-\[28rem\]{width:28rem}.w-\[30rem\]{width:30rem}.w-\[32px\]{width:32px}.w-\[52px\]{width:52px}.w-\[60px\]{width:60px}.w-\[64px\]{width:64px}.w-\[68px\]{width:68px}.w-\[72px\]{width:72px}.w-\[76px\]{width:76px}.w-\[80px\]{width:80px}.w-\[82px\]{width:82px}.w-\[84px\]{width:84px}.w-\[88px\]{width:88px}.w-\[90px\]{width:90px}.w-\[92px\]{width:92px}.w-\[96px\]{width:96px}.w-\[98px\]{width:98px}.w-\[100px\]{width:100px}.w-\[104px\]{width:104px}.w-\[108px\]{width:108px}.w-\[110px\]{width:110px}.w-\[112px\]{width:112px}.w-\[116px\]{width:116px}.w-\[118px\]{width:118px}.w-\[120px\]{width:120px}.w-\[128px\]{width:128px}.w-\[136px\]{width:136px}.w-\[140px\]{width:140px}.w-\[148px\]{width:148px}.w-\[150px\]{width:150px}.w-\[152px\]{width:152px}.w-\[156px\]{width:156px}.w-\[168px\]{width:168px}.w-\[180px\]{width:180px}.w-\[240px\]{width:240px}.w-auto{width:auto}.w-full{width:100%}.max-w-0{max-width:0}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-64{max-width:calc(var(--spacing) * 64)}.max-w-\[16rem\]{max-width:16rem}.max-w-\[140px\]{max-width:140px}.max-w-\[150px\]{max-width:150px}.max-w-\[160px\]{max-width:160px}.max-w-\[220px\]{max-width:220px}.max-w-\[240px\]{max-width:240px}.max-w-\[260px\]{max-width:260px}.max-w-\[280px\]{max-width:280px}.max-w-\[300px\]{max-width:300px}.max-w-\[320px\]{max-width:320px}.max-w-\[calc\(100vw-2rem\)\]{max-width:calc(100vw - 2rem)}.max-w-full{max-width:100%}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.max-w-sm{max-width:var(--container-sm)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:0}.min-w-5{min-width:calc(var(--spacing) * 5)}.min-w-7{min-width:calc(var(--spacing) * 7)}.min-w-40{min-width:calc(var(--spacing) * 40)}.min-w-48{min-width:calc(var(--spacing) * 48)}.min-w-\[11\.5rem\]{min-width:11.5rem}.min-w-\[20px\]{min-width:20px}.min-w-\[48rem\]{min-width:48rem}.min-w-\[54rem\]{min-width:54rem}.min-w-\[60px\]{min-width:60px}.min-w-\[440px\]{min-width:440px}.min-w-\[620px\]{min-width:620px}.min-w-\[640px\]{min-width:640px}.min-w-\[720px\]{min-width:720px}.min-w-\[760px\]{min-width:760px}.min-w-\[860px\]{min-width:860px}.min-w-\[880px\]{min-width:880px}.min-w-\[920px\]{min-width:920px}.min-w-\[980px\]{min-width:980px}.min-w-\[1040px\]{min-width:1040px}.flex-1{flex:1}.flex-shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.table-fixed{table-layout:fixed}.-translate-x-full{--tw-translate-x:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-0{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-full{--tw-translate-x:100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1{--tw-translate-y:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-0{--tw-translate-y:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-1{--tw-translate-y:var(--spacing);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-2{--tw-translate-y:calc(var(--spacing) * 2);translate:var(--tw-translate-x) var(--tw-translate-y)}.scale-95{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.rotate-90{rotate:90deg}.rotate-180{rotate:180deg}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-help{cursor:help}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.cursor-wait{cursor:wait}.resize{resize:both}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.grid-cols-\[1fr_auto\]{grid-template-columns:1fr auto}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-nowrap{flex-wrap:nowrap}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-start{justify-content:flex-start}.gap-0{gap:0}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}:where(.space-y-0>:not(:last-child)){--tw-space-y-reverse:0;margin-block:0}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-1{column-gap:var(--spacing)}.gap-x-3{column-gap:calc(var(--spacing) * 3)}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-x-5{column-gap:calc(var(--spacing) * 5)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-y-1{row-gap:var(--spacing)}.gap-y-1\.5{row-gap:calc(var(--spacing) * 1.5)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-pb-border>:not(:last-child)){border-color:var(--pb-border)}:where(.divide-pb-border-subtle>:not(:last-child)){border-color:var(--pb-border-subtle)}:where(.divide-pb-border\/40>:not(:last-child)){border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){:where(.divide-pb-border\/40>:not(:last-child)){border-color:color-mix(in oklab, var(--pb-border) 40%, transparent)}}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-visible{overflow:visible}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.overscroll-contain{overscroll-behavior:contain}.\!rounded-none{border-radius:0!important}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-3xl{border-radius:var(--radius-3xl)}.rounded-\[var\(--radius-card\)\]{border-radius:var(--radius-card)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t-xl{border-top-left-radius:var(--radius-xl);border-top-right-radius:var(--radius-xl)}.rounded-b-xl{border-bottom-right-radius:var(--radius-xl);border-bottom-left-radius:var(--radius-xl)}.\!border-0{border-style:var(--tw-border-style)!important;border-width:0!important}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-\[3px\]{border-style:var(--tw-border-style);border-width:3px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.\!border-pb-warning\/35{border-color:var(--pb-warning)!important}@supports (color:color-mix(in lab, red, red)){.\!border-pb-warning\/35{border-color:color-mix(in oklab, var(--pb-warning) 35%, transparent)!important}}.border-current\/15{border-color:currentColor}@supports (color:color-mix(in lab, red, red)){.border-current\/15{border-color:color-mix(in oklab, currentcolor 15%, transparent)}}.border-pb-border{border-color:var(--pb-border)}.border-pb-border-hover,.border-pb-border-hover\/50{border-color:var(--pb-border-hover)}@supports (color:color-mix(in lab, red, red)){.border-pb-border-hover\/50{border-color:color-mix(in oklab, var(--pb-border-hover) 50%, transparent)}}.border-pb-border-subtle{border-color:var(--pb-border-subtle)}.border-pb-border\/50{border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.border-pb-border\/50{border-color:color-mix(in oklab, var(--pb-border) 50%, transparent)}}.border-pb-border\/70{border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.border-pb-border\/70{border-color:color-mix(in oklab, var(--pb-border) 70%, transparent)}}.border-pb-error,.border-pb-error\/30{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.border-pb-error\/30{border-color:color-mix(in oklab, var(--pb-error) 30%, transparent)}}.border-pb-error\/35{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.border-pb-error\/35{border-color:color-mix(in oklab, var(--pb-error) 35%, transparent)}}.border-pb-error\/40{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.border-pb-error\/40{border-color:color-mix(in oklab, var(--pb-error) 40%, transparent)}}.border-pb-info\/30{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.border-pb-info\/30{border-color:color-mix(in oklab, var(--pb-info) 30%, transparent)}}.border-pb-info\/40{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.border-pb-info\/40{border-color:color-mix(in oklab, var(--pb-info) 40%, transparent)}}.border-pb-interactive,.border-pb-interactive\/25{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.border-pb-interactive\/25{border-color:color-mix(in oklab, var(--pb-interactive) 25%, transparent)}}.border-pb-interactive\/30{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.border-pb-interactive\/30{border-color:color-mix(in oklab, var(--pb-interactive) 30%, transparent)}}.border-pb-interactive\/40{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.border-pb-interactive\/40{border-color:color-mix(in oklab, var(--pb-interactive) 40%, transparent)}}.border-pb-success\/30{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.border-pb-success\/30{border-color:color-mix(in oklab, var(--pb-success) 30%, transparent)}}.border-pb-success\/35{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.border-pb-success\/35{border-color:color-mix(in oklab, var(--pb-success) 35%, transparent)}}.border-pb-success\/40{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.border-pb-success\/40{border-color:color-mix(in oklab, var(--pb-success) 40%, transparent)}}.border-pb-surface{border-color:var(--pb-bg-surface)}.border-pb-warning\/20{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/20{border-color:color-mix(in oklab, var(--pb-warning) 20%, transparent)}}.border-pb-warning\/25{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/25{border-color:color-mix(in oklab, var(--pb-warning) 25%, transparent)}}.border-pb-warning\/30{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/30{border-color:color-mix(in oklab, var(--pb-warning) 30%, transparent)}}.border-pb-warning\/35{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/35{border-color:color-mix(in oklab, var(--pb-warning) 35%, transparent)}}.border-pb-warning\/40{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/40{border-color:color-mix(in oklab, var(--pb-warning) 40%, transparent)}}.border-pb-warning\/50{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/50{border-color:color-mix(in oklab, var(--pb-warning) 50%, transparent)}}.border-transparent{border-color:#0000}.border-t-pb-interactive{border-top-color:var(--pb-interactive)}.\!bg-pb-warning-dim\/45{background-color:var(--pb-warning-dim)!important}@supports (color:color-mix(in lab, red, red)){.\!bg-pb-warning-dim\/45{background-color:color-mix(in oklab, var(--pb-warning-dim) 45%, transparent)!important}}.\!bg-transparent{background-color:#0000!important}.bg-black\/80{background-color:#000c}@supports (color:color-mix(in lab, red, red)){.bg-black\/80{background-color:color-mix(in oklab, var(--color-black) 80%, transparent)}}.bg-pb-base{background-color:var(--pb-bg-base)}.bg-pb-card{background-color:var(--pb-bg-card)}.bg-pb-card-hover,.bg-pb-card-hover\/20{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/20{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 20%, transparent)}}.bg-pb-card-hover\/25{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/25{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 25%, transparent)}}.bg-pb-card-hover\/35{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/35{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 35%, transparent)}}.bg-pb-card-hover\/40{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/40{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 40%, transparent)}}.bg-pb-card-hover\/45{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/45{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 45%, transparent)}}.bg-pb-card-hover\/50{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/50{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 50%, transparent)}}.bg-pb-card-hover\/60{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/60{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 60%, transparent)}}.bg-pb-card\/40{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/40{background-color:color-mix(in oklab, var(--pb-bg-card) 40%, transparent)}}.bg-pb-card\/50{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/50{background-color:color-mix(in oklab, var(--pb-bg-card) 50%, transparent)}}.bg-pb-card\/60{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/60{background-color:color-mix(in oklab, var(--pb-bg-card) 60%, transparent)}}.bg-pb-card\/70{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/70{background-color:color-mix(in oklab, var(--pb-bg-card) 70%, transparent)}}.bg-pb-card\/80{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/80{background-color:color-mix(in oklab, var(--pb-bg-card) 80%, transparent)}}.bg-pb-card\/85{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/85{background-color:color-mix(in oklab, var(--pb-bg-card) 85%, transparent)}}.bg-pb-card\/95{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/95{background-color:color-mix(in oklab, var(--pb-bg-card) 95%, transparent)}}.bg-pb-error{background-color:var(--pb-error)}.bg-pb-error-dim,.bg-pb-error-dim\/70{background-color:var(--pb-error-dim)}@supports (color:color-mix(in lab, red, red)){.bg-pb-error-dim\/70{background-color:color-mix(in oklab, var(--pb-error-dim) 70%, transparent)}}.bg-pb-error\/10{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.bg-pb-error\/10{background-color:color-mix(in oklab, var(--pb-error) 10%, transparent)}}.bg-pb-error\/15{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.bg-pb-error\/15{background-color:color-mix(in oklab, var(--pb-error) 15%, transparent)}}.bg-pb-info{background-color:var(--pb-info)}.bg-pb-info-dim{background-color:var(--pb-info-dim)}.bg-pb-info\/10{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.bg-pb-info\/10{background-color:color-mix(in oklab, var(--pb-info) 10%, transparent)}}.bg-pb-input{background-color:var(--pb-bg-input)}.bg-pb-interactive{background-color:var(--pb-interactive)}.bg-pb-interactive-dim{background-color:var(--pb-interactive-dim)}.bg-pb-interactive\/15{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.bg-pb-interactive\/15{background-color:color-mix(in oklab, var(--pb-interactive) 15%, transparent)}}.bg-pb-overlay,.bg-pb-overlay\/45{background-color:var(--pb-bg-overlay)}@supports (color:color-mix(in lab, red, red)){.bg-pb-overlay\/45{background-color:color-mix(in oklab, var(--pb-bg-overlay) 45%, transparent)}}.bg-pb-purple-dim{background-color:var(--pb-purple-dim)}.bg-pb-success{background-color:var(--pb-success)}.bg-pb-success-dim,.bg-pb-success-dim\/70{background-color:var(--pb-success-dim)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success-dim\/70{background-color:color-mix(in oklab, var(--pb-success-dim) 70%, transparent)}}.bg-pb-success\/10{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success\/10{background-color:color-mix(in oklab, var(--pb-success) 10%, transparent)}}.bg-pb-success\/15{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success\/15{background-color:color-mix(in oklab, var(--pb-success) 15%, transparent)}}.bg-pb-surface,.bg-pb-surface\/30{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/30{background-color:color-mix(in oklab, var(--pb-bg-surface) 30%, transparent)}}.bg-pb-surface\/35{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/35{background-color:color-mix(in oklab, var(--pb-bg-surface) 35%, transparent)}}.bg-pb-surface\/40{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/40{background-color:color-mix(in oklab, var(--pb-bg-surface) 40%, transparent)}}.bg-pb-surface\/50{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/50{background-color:color-mix(in oklab, var(--pb-bg-surface) 50%, transparent)}}.bg-pb-surface\/60{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/60{background-color:color-mix(in oklab, var(--pb-bg-surface) 60%, transparent)}}.bg-pb-surface\/80{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/80{background-color:color-mix(in oklab, var(--pb-bg-surface) 80%, transparent)}}.bg-pb-text-dim{background-color:var(--pb-text-dim)}.bg-pb-warning{background-color:var(--pb-warning)}.bg-pb-warning-dim,.bg-pb-warning-dim\/25{background-color:var(--pb-warning-dim)}@supports (color:color-mix(in lab, red, red)){.bg-pb-warning-dim\/25{background-color:color-mix(in oklab, var(--pb-warning-dim) 25%, transparent)}}.bg-pb-warning-dim\/45{background-color:var(--pb-warning-dim)}@supports (color:color-mix(in lab, red, red)){.bg-pb-warning-dim\/45{background-color:color-mix(in oklab, var(--pb-warning-dim) 45%, transparent)}}.bg-pb-warning\/5{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.bg-pb-warning\/5{background-color:color-mix(in oklab, var(--pb-warning) 5%, transparent)}}.bg-pb-warning\/10{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.bg-pb-warning\/10{background-color:color-mix(in oklab, var(--pb-warning) 10%, transparent)}}.bg-slate-950\/70{background-color:#020618b3}@supports (color:color-mix(in lab, red, red)){.bg-slate-950\/70{background-color:color-mix(in oklab, var(--color-slate-950) 70%, transparent)}}.bg-transparent{background-color:#0000}.object-contain{object-fit:contain}.object-cover{object-fit:cover}.object-top{object-position:top}.\!p-0{padding:0!important}.p-0{padding:0}.p-0\.5{padding:calc(var(--spacing) * .5)}.p-1{padding:var(--spacing)}.p-2{padding:calc(var(--spacing) * 2)}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.\!px-0{padding-inline:0!important}.\!px-2\.5{padding-inline:calc(var(--spacing) * 2.5)!important}.\!px-3{padding-inline:calc(var(--spacing) * 3)!important}.\!px-4{padding-inline:calc(var(--spacing) * 4)!important}.px-0{padding-inline:0}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.\!py-0{padding-block:0!important}.\!py-1\.5{padding-block:calc(var(--spacing) * 1.5)!important}.\!py-2{padding-block:calc(var(--spacing) * 2)!important}.\!py-2\.5{padding-block:calc(var(--spacing) * 2.5)!important}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-\[3px\]{padding-block:3px}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-1{padding-top:var(--spacing)}.pt-1\.5{padding-top:calc(var(--spacing) * 1.5)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-5{padding-top:calc(var(--spacing) * 5)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-12{padding-top:calc(var(--spacing) * 12)}.pr-1{padding-right:var(--spacing)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-4{padding-right:calc(var(--spacing) * 4)}.\!pb-0{padding-bottom:0!important}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pl-5{padding-left:calc(var(--spacing) * 5)}.pl-6{padding-left:calc(var(--spacing) * 6)}.\!text-left{text-align:left!important}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-top{vertical-align:top}.font-display{font-family:Bricolage Grotesque,sans-serif}.font-mono{font-family:JetBrains Mono,Fira Code,monospace}.font-sans{font-family:DM Sans,sans-serif}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.72rem\]{font-size:.72rem}.text-\[0\.78rem\]{font-size:.78rem}.text-\[0\.82rem\]{font-size:.82rem}.text-\[0\.88rem\]{font-size:.88rem}.text-\[9px\]{font-size:9px}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.leading-4{--tw-leading:calc(var(--spacing) * 4);line-height:calc(var(--spacing) * 4)}.leading-5{--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5)}.leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.leading-7{--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7)}.leading-\[1\.5\]{--tw-leading:1.5;line-height:1.5}.leading-\[1\.6\]{--tw-leading:1.6;line-height:1.6}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-black{--tw-font-weight:var(--font-weight-black);font-weight:var(--font-weight-black)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-extrabold{--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.2em\]{--tw-tracking:.2em;letter-spacing:.2em}.tracking-\[0\.12em\]{--tw-tracking:.12em;letter-spacing:.12em}.tracking-\[0\.14em\]{--tw-tracking:.14em;letter-spacing:.14em}.tracking-\[0\.16em\]{--tw-tracking:.16em;letter-spacing:.16em}.tracking-\[0\.18em\]{--tw-tracking:.18em;letter-spacing:.18em}.tracking-\[0\.22em\]{--tw-tracking:.22em;letter-spacing:.22em}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-normal{white-space:normal}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.\!text-pb-text{color:var(--pb-text-primary)!important}.\!text-pb-warning{color:var(--pb-warning)!important}.text-pb-border{color:var(--pb-border)}.text-pb-brand{color:var(--pb-brand)}.text-pb-error{color:var(--pb-error)}.text-pb-info{color:var(--pb-info)}.text-pb-interactive{color:var(--pb-interactive)}.text-pb-purple{color:var(--pb-purple)}.text-pb-success{color:var(--pb-success)}.text-pb-text{color:var(--pb-text-primary)}.text-pb-text-dim{color:var(--pb-text-dim)}.text-pb-text-inverse{color:var(--pb-text-inverse)}.text-pb-text-sec{color:var(--pb-text-secondary)}.text-pb-warning,.text-pb-warning\/70{color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.text-pb-warning\/70{color:color-mix(in oklab, var(--pb-warning) 70%, transparent)}}.text-red-300{color:var(--color-red-300)}.text-white{color:var(--color-white)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.no-underline{text-decoration-line:none}.underline{text-decoration-line:underline}.underline-offset-4{text-underline-offset:4px}.accent-pb-interactive{accent-color:var(--pb-interactive)}.opacity-0{opacity:0}.opacity-25{opacity:.25}.opacity-30{opacity:.3}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-90{opacity:.9}.opacity-100{opacity:1}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur-\[1px\]{--tw-backdrop-blur:blur(1px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-xs{--tw-backdrop-blur:blur(var(--blur-xs));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-none{transition-property:none}.delay-75{transition-delay:75ms}.duration-75{--tw-duration:75ms;transition-duration:75ms}.duration-100{--tw-duration:.1s;transition-duration:.1s}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-500{--tw-duration:.5s;transition-duration:.5s}.ease-in{--tw-ease:var(--ease-in);transition-timing-function:var(--ease-in)}.ease-linear{--tw-ease:linear;transition-timing-function:linear}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}@media (hover:hover){.group-hover\:border-pb-text-sec:is(:where(.group):hover *){border-color:var(--pb-text-secondary)}.group-hover\:text-pb-interactive:is(:where(.group):hover *){color:var(--pb-interactive)}.group-hover\:text-pb-text:is(:where(.group):hover *){color:var(--pb-text-primary)}.group-hover\:text-pb-text-sec:is(:where(.group):hover *){color:var(--pb-text-secondary)}.group-hover\:opacity-40:is(:where(.group):hover *){opacity:.4}}.placeholder\:text-pb-text-dim::placeholder{color:var(--pb-text-dim)}.placeholder\:text-pb-text-sec::placeholder{color:var(--pb-text-secondary)}.first\:pt-0:first-child{padding-top:0}.last\:mb-0:last-child{margin-bottom:0}.last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.last\:pb-0:last-child{padding-bottom:0}@media (hover:hover){.hover\:\!border-pb-warning\/45:hover{border-color:var(--pb-warning)!important}@supports (color:color-mix(in lab, red, red)){.hover\:\!border-pb-warning\/45:hover{border-color:color-mix(in oklab, var(--pb-warning) 45%, transparent)!important}}.hover\:border-pb-border-hover:hover{border-color:var(--pb-border-hover)}.hover\:border-pb-border-strong:hover{border-color:var(--pb-border-strong)}.hover\:border-pb-error\/40:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-error\/40:hover{border-color:color-mix(in oklab, var(--pb-error) 40%, transparent)}}.hover\:border-pb-info\/40:hover{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-info\/40:hover{border-color:color-mix(in oklab, var(--pb-info) 40%, transparent)}}.hover\:border-pb-purple\/40:hover{border-color:var(--pb-purple)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-purple\/40:hover{border-color:color-mix(in oklab, var(--pb-purple) 40%, transparent)}}.hover\:border-pb-success\/40:hover{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-success\/40:hover{border-color:color-mix(in oklab, var(--pb-success) 40%, transparent)}}.hover\:border-pb-warning\/40:hover{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-warning\/40:hover{border-color:color-mix(in oklab, var(--pb-warning) 40%, transparent)}}.hover\:\!bg-pb-warning-dim:hover{background-color:var(--pb-warning-dim)!important}.hover\:\!bg-transparent:hover{background-color:#0000!important}.hover\:bg-pb-card:hover{background-color:var(--pb-bg-card)}.hover\:bg-pb-card-hover:hover,.hover\:bg-pb-card-hover\/20:hover{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-card-hover\/20:hover{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 20%, transparent)}}.hover\:bg-pb-card-hover\/45:hover{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-card-hover\/45:hover{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 45%, transparent)}}.hover\:bg-pb-error-dim:hover{background-color:var(--pb-error-dim)}.hover\:bg-pb-error\/25:hover{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-error\/25:hover{background-color:color-mix(in oklab, var(--pb-error) 25%, transparent)}}.hover\:bg-pb-info-dim:hover{background-color:var(--pb-info-dim)}.hover\:bg-pb-interactive-hover:hover{background-color:var(--pb-interactive-hover)}.hover\:bg-pb-interactive\/10:hover{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-interactive\/10:hover{background-color:color-mix(in oklab, var(--pb-interactive) 10%, transparent)}}.hover\:bg-pb-interactive\/25:hover{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-interactive\/25:hover{background-color:color-mix(in oklab, var(--pb-interactive) 25%, transparent)}}.hover\:bg-pb-purple-dim:hover{background-color:var(--pb-purple-dim)}.hover\:bg-pb-success-dim:hover{background-color:var(--pb-success-dim)}.hover\:bg-pb-success\/25:hover{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-success\/25:hover{background-color:color-mix(in oklab, var(--pb-success) 25%, transparent)}}.hover\:bg-pb-warning-dim:hover{background-color:var(--pb-warning-dim)}.hover\:\!text-pb-text:hover{color:var(--pb-text-primary)!important}.hover\:\!text-pb-warning:hover{color:var(--pb-warning)!important}.hover\:text-pb-error:hover{color:var(--pb-error)}.hover\:text-pb-interactive:hover{color:var(--pb-interactive)}.hover\:text-pb-interactive-hover:hover{color:var(--pb-interactive-hover)}.hover\:text-pb-text:hover{color:var(--pb-text-primary)}.hover\:text-pb-text-sec:hover{color:var(--pb-text-secondary)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-95:hover{opacity:.95}.hover\:opacity-100:hover{opacity:1}.hover\:brightness-90:hover{--tw-brightness:brightness(90%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}}.focus\:border-transparent:focus{border-color:#0000}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-pb-border-hover:focus{--tw-ring-color:var(--pb-border-hover)}.focus\:ring-pb-error:focus{--tw-ring-color:var(--pb-error)}.focus\:ring-pb-interactive:focus{--tw-ring-color:var(--pb-interactive)}.focus\:ring-pb-warning:focus{--tw-ring-color:var(--pb-warning)}.focus\:ring-offset-0:focus{--tw-ring-offset-width:0px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-pb-card:focus{--tw-ring-offset-color:var(--pb-bg-card)}.focus\:outline-hidden:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.focus\:outline-hidden:focus{outline-offset:2px;outline:2px solid #0000}}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\:cursor-default:disabled{cursor:default}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:cursor-wait:disabled{cursor:wait}.disabled\:opacity-20:disabled{opacity:.2}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.disabled\:opacity-100:disabled{opacity:1}@media (min-width:40rem){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:ml-auto{margin-left:auto}.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:inline{display:inline}.sm\:table-cell{display:table-cell}.sm\:max-h-\[90vh\]{max-height:90vh}.sm\:w-14{width:calc(var(--spacing) * 14)}.sm\:w-44{width:calc(var(--spacing) * 44)}.sm\:w-48{width:calc(var(--spacing) * 48)}.sm\:w-56{width:calc(var(--spacing) * 56)}.sm\:w-auto{width:auto}.sm\:max-w-xs{max-width:var(--container-xs)}.sm\:min-w-\[760px\]{min-width:760px}.sm\:shrink-0{flex-shrink:0}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:grid-cols-\[8rem_minmax\(0\,1fr\)\]{grid-template-columns:8rem minmax(0,1fr)}.sm\:grid-cols-\[9rem_minmax\(0\,1fr\)\]{grid-template-columns:9rem minmax(0,1fr)}.sm\:grid-cols-\[minmax\(0\,1fr\)_minmax\(0\,1\.4fr\)_auto\]{grid-template-columns:minmax(0,1fr) minmax(0,1.4fr) auto}.sm\:grid-cols-\[minmax\(0\,3fr\)_minmax\(0\,2fr\)\]{grid-template-columns:minmax(0,3fr) minmax(0,2fr)}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:items-end{align-items:flex-end}.sm\:items-start{align-items:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:justify-end{justify-content:flex-end}.sm\:self-auto{align-self:auto}.sm\:rounded-xl{border-radius:var(--radius-xl)}.sm\:p-4{padding:calc(var(--spacing) * 4)}.sm\:p-5{padding:calc(var(--spacing) * 5)}.sm\:p-6{padding:calc(var(--spacing) * 6)}.sm\:p-7{padding:calc(var(--spacing) * 7)}.sm\:px-5{padding-inline:calc(var(--spacing) * 5)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:pt-5{padding-top:calc(var(--spacing) * 5)}.sm\:pt-6{padding-top:calc(var(--spacing) * 6)}.sm\:pt-7{padding-top:calc(var(--spacing) * 7)}.sm\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}}@media (min-width:48rem){.md\:block{display:block}.md\:flex{display:flex}.md\:table-cell{display:table-cell}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:grid-cols-\[1fr_auto_1fr_auto\]{grid-template-columns:1fr auto 1fr auto}.md\:grid-cols-\[minmax\(0\,1fr\)_auto_minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,1fr) auto minmax(0,1fr)}.md\:flex-row{flex-direction:row}.md\:items-center{align-items:center}.md\:items-end{align-items:flex-end}.md\:items-start{align-items:flex-start}.md\:justify-between{justify-content:space-between}.md\:justify-center{justify-content:center}}@media (min-width:64rem){.lg\:col-span-2{grid-column:span 2/span 2}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:table-cell{display:table-cell}.lg\:w-52{width:calc(var(--spacing) * 52)}.lg\:w-auto{width:auto}.lg\:max-w-\[11rem\]{max-width:11rem}.lg\:max-w-md{max-width:var(--container-md)}.lg\:min-w-\[920px\]{min-width:920px}.lg\:min-w-\[980px\]{min-width:980px}.lg\:min-w-\[1060px\]{min-width:1060px}.lg\:translate-x-0{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.lg\:grid-cols-\[minmax\(0\,1\.1fr\)_minmax\(280px\,0\.9fr\)\]{grid-template-columns:minmax(0,1.1fr) minmax(280px,.9fr)}.lg\:grid-cols-\[minmax\(0\,1\.15fr\)_minmax\(280px\,0\.85fr\)\]{grid-template-columns:minmax(0,1.15fr) minmax(280px,.85fr)}.lg\:grid-cols-\[minmax\(0\,1\.25fr\)_minmax\(280px\,0\.75fr\)\]{grid-template-columns:minmax(0,1.25fr) minmax(280px,.75fr)}.lg\:grid-cols-\[minmax\(0\,1fr\)_minmax\(16rem\,0\.65fr\)\]{grid-template-columns:minmax(0,1fr) minmax(16rem,.65fr)}.lg\:grid-cols-\[minmax\(0\,1fr\)_minmax\(18rem\,24rem\)\]{grid-template-columns:minmax(0,1fr) minmax(18rem,24rem)}.lg\:grid-cols-\[minmax\(0\,280px\)_minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,280px) minmax(0,1fr)}.lg\:grid-cols-\[minmax\(12rem\,0\.7fr\)_minmax\(18rem\,1\.3fr\)\]{grid-template-columns:minmax(12rem,.7fr) minmax(18rem,1.3fr)}.lg\:grid-cols-\[repeat\(5\,minmax\(0\,1fr\)\)\]{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:flex-col{flex-direction:column}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:items-end{align-items:flex-end}.lg\:items-start{align-items:flex-start}.lg\:justify-between{justify-content:space-between}.lg\:text-right{text-align:right}}@media (min-width:80rem){.xl\:col-span-3{grid-column:span 3/span 3}.xl\:w-32{width:calc(var(--spacing) * 32)}.xl\:w-48{width:calc(var(--spacing) * 48)}.xl\:w-\[34rem\]{width:34rem}.xl\:max-w-sm{max-width:var(--container-sm)}.xl\:min-w-\[30rem\]{min-width:30rem}.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.xl\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.xl\:grid-cols-7{grid-template-columns:repeat(7,minmax(0,1fr))}.xl\:grid-cols-\[minmax\(0\,1\.4fr\)_minmax\(320px\,0\.8fr\)\]{grid-template-columns:minmax(0,1.4fr) minmax(320px,.8fr)}.xl\:grid-cols-\[minmax\(0\,1\.45fr\)_minmax\(17rem\,1fr\)\]{grid-template-columns:minmax(0,1.45fr) minmax(17rem,1fr)}.xl\:grid-cols-\[minmax\(0\,1\.55fr\)_minmax\(320px\,0\.85fr\)\]{grid-template-columns:minmax(0,1.55fr) minmax(320px,.85fr)}.xl\:flex-row{flex-direction:row}.xl\:items-end{align-items:flex-end}.xl\:items-start{align-items:flex-start}.xl\:justify-between{justify-content:space-between}.xl\:justify-end{justify-content:flex-end}}@media (min-width:96rem){.\32 xl\:grid-cols-\[minmax\(0\,1\.45fr\)_minmax\(320px\,0\.75fr\)\]{grid-template-columns:minmax(0,1.45fr) minmax(320px,.75fr)}}}@font-face{font-family:Bricolage Grotesque;font-style:normal;font-weight:800;font-display:swap;src:url(/static/fonts/bricolage-grotesque-800.woff2)format("woff2")}@font-face{font-family:DM Sans;font-style:normal;font-weight:300 700;font-display:swap;src:url(/static/fonts/dm-sans-variable.woff2)format("woff2")}@font-face{font-family:Syne;font-style:normal;font-weight:400;font-display:swap;src:url(/static/fonts/syne-400.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:500;font-display:swap;src:url(/static/fonts/syne-500.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:600;font-display:swap;src:url(/static/fonts/syne-600.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:700;font-display:swap;src:url(/static/fonts/syne-700.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:800;font-display:swap;src:url(/static/fonts/syne-800.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:400;font-display:swap;src:url(/static/fonts/jetbrains-mono-400.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:500;font-display:swap;src:url(/static/fonts/jetbrains-mono-500.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:600;font-display:swap;src:url(/static/fonts/jetbrains-mono-600.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:700;font-display:swap;src:url(/static/fonts/jetbrains-mono-700.ttf)format("truetype")}.no-transitions,.no-transitions *,.no-transitions :before,.no-transitions :after,.boot-no-transitions,.boot-no-transitions *,.boot-no-transitions :before,.boot-no-transitions :after{transition-duration:0s!important}.safe-area-pb{padding-bottom:env(safe-area-inset-bottom,0px)}.skip-link{z-index:100;background:var(--pb-brand);color:var(--pb-text-inverse);border-radius:0 0 .5rem .5rem;padding:.5rem 1rem;font-size:.875rem;font-weight:600;text-decoration:none;transition:top .15s ease-out;position:absolute;top:-100%;left:1rem}.skip-link:focus{outline:2px solid var(--pb-interactive);outline-offset:2px;top:0}input[type=range].range-pb{appearance:none;background:var(--pb-border-hover);cursor:pointer;border-radius:9999px;outline:none;height:6px}input[type=range].range-pb::-webkit-slider-thumb{appearance:none;background:var(--pb-interactive);border:2px solid var(--pb-bg-card);width:18px;height:18px;box-shadow:var(--pb-shadow-1);cursor:pointer;border-radius:50%}input[type=range].range-pb::-moz-range-thumb{background:var(--pb-interactive);border:2px solid var(--pb-bg-card);width:18px;height:18px;box-shadow:var(--pb-shadow-1);cursor:pointer;border-radius:50%}.scrollbar-hidden{scrollbar-width:none;-ms-overflow-style:none}.scrollbar-hidden::-webkit-scrollbar{display:none}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:var(--pb-bg-surface)}::-webkit-scrollbar-thumb{background:var(--pb-border-strong);border-radius:4px}::-webkit-scrollbar-thumb:hover{background:var(--pb-text-dim)}*{scrollbar-width:thin;scrollbar-color:var(--pb-border-strong) var(--pb-bg-surface)}.htmx-indicator{opacity:0;transition:opacity .2s ease-in}.htmx-request .htmx-indicator,.htmx-request.htmx-indicator{opacity:1}#content[data-page-swap-phase=leaving],#content[data-page-swap-phase=entering]{pointer-events:none}#content[data-detail-history-hidden=true]{opacity:0;pointer-events:none}@media (prefers-reduced-motion:reduce){html:focus-within{scroll-behavior:auto}*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}#content{transition:none}}@keyframes toast-enter{0%{opacity:0;transform:translate(100%)}to{opacity:1;transform:translate(0)}}@keyframes toast-exit{0%{opacity:1;transform:translate(0)}to{opacity:0;transform:translate(100%)}}.toast-enter{animation:.3s ease-out forwards toast-enter}.toast-exit{animation:.3s ease-in forwards toast-exit}.sidebar-transition{transition:width .2s ease-in-out,transform .2s ease-in-out}.app-sidebar-backdrop{background:var(--pb-bg-overlay)}@supports (color:color-mix(in lab, red, red)){.app-sidebar-backdrop{background:color-mix(in srgb, var(--pb-bg-overlay) 96%, transparent)}}.app-sidebar-shell{border-right:1px solid var(--pb-border);background:var(--pb-bg-surface);box-shadow:none;overflow:visible}.app-sidebar-brand{background:0 0;padding:1rem 1rem .75rem}.app-sidebar-brand-collapsed{background:0 0;justify-content:center;padding:1rem 0 .75rem;display:flex}.app-sidebar-brand-copy-collapsed{display:none!important}.app-sidebar-brand-card{align-items:center;gap:.75rem;padding:0;transition:color .18s,transform .18s;display:flex}.app-sidebar-brand-card-collapsed{justify-content:center;width:auto;min-width:0;min-height:0;padding:0}.app-sidebar-brand:hover .app-sidebar-brand-card{transform:translateY(-1px)}.app-sidebar-brand-collapsed:hover .app-sidebar-brand-card{transform:none}.app-sidebar-brand-mark{flex-shrink:0;justify-content:center;align-items:center;width:2.25rem;height:2.25rem;display:flex}.app-sidebar-brand-copy{white-space:nowrap;flex-direction:column;min-width:0;display:flex;overflow:hidden}.app-sidebar-brand-title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1.1rem;font-weight:800;line-height:1}.app-sidebar-brand-version{color:var(--pb-text-tertiary);margin-top:2px;font-family:JetBrains Mono,monospace;font-size:.58rem}.app-sidebar-nav{padding:.25rem .625rem 1rem}.app-sidebar-nav-collapsed{padding:.25rem 0 1rem;overflow:visible!important}.app-sidebar-link{color:var(--pb-text-secondary);border:0;border-radius:.5rem;align-items:center;gap:.625rem;margin:1px 0;padding:.4375rem .625rem;font-size:.82rem;font-weight:500;transition:background-color .18s,color .18s,transform .18s;display:flex;position:relative}.app-sidebar-link-collapsed{justify-content:center;gap:0;width:2.75rem;margin-inline:auto;padding:.5rem}.app-sidebar-link-copy-collapsed{display:none!important}.app-sidebar-link:hover{background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.app-sidebar-link:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.app-sidebar-link:hover{color:var(--pb-text-primary)}.app-sidebar-link-collapsed:hover{transform:none}.app-sidebar-link-active{background:var(--pb-selected);color:var(--pb-interactive);font-weight:600}.app-sidebar-link-active:before{content:"";background:var(--pb-interactive);border-radius:0 2px 2px 0;width:3px;position:absolute;top:6px;bottom:6px;left:0}.app-sidebar-link-icon{width:1.25rem;height:1.25rem;color:inherit;flex-shrink:0;justify-content:center;align-items:center;transition:color .18s;display:flex}.app-sidebar-link-copy{flex:1;justify-content:space-between;align-items:center;gap:.75rem;min-width:0;display:flex}.app-sidebar-link-title{text-overflow:ellipsis;white-space:nowrap;min-width:0;font-size:.82rem;font-weight:inherit;overflow:hidden}.app-sidebar-badge-slot{flex-shrink:0;justify-content:flex-end;align-items:center;min-width:1.125rem;display:flex}.app-sidebar-badge-slot .count-badge{border-radius:6px;min-width:18px;min-height:auto;padding:1px 6px;font-family:JetBrains Mono,monospace;font-size:.6rem;font-weight:700;line-height:1.1}.app-sidebar-link-collapsed .app-sidebar-badge-slot{min-width:0;position:absolute;top:2px;right:2px}.app-sidebar-link-collapsed .app-sidebar-badge-slot .count-badge{border-radius:4px;min-width:14px;padding:0 4px;font-size:.52rem}.app-sidebar-section{margin:1rem 0 0}.app-sidebar-section-collapsed{width:3rem;margin:.75rem auto .375rem}.app-sidebar-section-label{letter-spacing:.14em;text-transform:uppercase;color:var(--pb-text-tertiary);align-items:center;gap:.375rem;margin-bottom:.25rem;padding:0 .5rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700;display:flex}.app-sidebar-section-label-collapsed{display:none!important}.app-sidebar-section-label:after{content:"";background:var(--pb-border-subtle);flex:auto;height:1px}.app-sidebar-section-rule{border-color:var(--pb-border-subtle);width:1.5rem;margin:.5rem auto 0}.app-sidebar-toggle{z-index:70;border:1px solid var(--pb-border-hover);background:var(--pb-bg-card);width:1.75rem;height:1.75rem;color:var(--pb-text-secondary);pointer-events:auto;box-shadow:var(--pb-shadow-1);border-radius:999px;justify-content:center;align-items:center;transition:border-color .18s,background-color .18s,color .18s,box-shadow .18s;position:absolute;top:72px;right:-.75rem}.app-sidebar-toggle[data-tip]{position:absolute}.app-sidebar-toggle:hover{border-color:var(--pb-interactive);background:var(--pb-bg-card);color:var(--pb-interactive);box-shadow:var(--pb-shadow-2)}[data-tip]{--pb-tooltip-max-width:min(360px, calc(100vw - 32px), calc(100dvw - 32px));position:relative}.tooltips-overlay-enabled [data-tip]:hover:after,.tooltips-overlay-enabled [data-tip]:focus-visible:after{content:none;display:none}[data-tip]:hover:after,[data-tip]:focus-visible:after{content:attr(data-tip);width:max-content;max-width:var(--pb-tooltip-max-width);background:var(--pb-bg-card);border:1px solid var(--pb-border-hover);color:var(--pb-text-primary);white-space:normal;overflow-wrap:break-word;box-shadow:var(--pb-shadow-overlay);z-index:50;pointer-events:none;border-radius:8px;padding:6px 10px;font-size:11px;line-height:1.4;animation:.15s ease-out tip-in;position:absolute;bottom:calc(100% + 6px);left:0}.app-tooltip-host{z-index:140;pointer-events:none;position:fixed;inset:0}.app-tooltip-overlay{width:max-content;max-width:var(--pb-tooltip-max-width,min(360px, calc(100vw - 32px), calc(100dvw - 32px)));background:var(--pb-bg-card);border:1px solid var(--pb-border-hover);color:var(--pb-text-primary);white-space:normal;overflow-wrap:break-word;box-shadow:var(--pb-shadow-overlay);opacity:0;visibility:hidden;will-change:left, top, opacity;border-radius:8px;padding:6px 10px;font-size:11px;line-height:1.4;transition:opacity .12s ease-out,visibility .12s ease-out;position:absolute;top:0;left:0;transform:translate(0)}.app-tooltip-host[data-visible=true] .app-tooltip-overlay{opacity:1;visibility:visible}[data-search-field-clear][data-tip]{position:absolute}[data-tip-pos=left]:hover:after,[data-tip-pos=left]:focus-visible:after{animation:.15s ease-out tip-in-left;inset:50% calc(100% + 6px) auto auto;transform:translateY(-50%)}[data-tip-pos=right]:hover:after,[data-tip-pos=right]:focus-visible:after{animation:.15s ease-out tip-in-right;inset:50% auto auto calc(100% + 6px);transform:translateY(-50%)}[data-tip-pos=bottom]:hover:after,[data-tip-pos=bottom]:focus-visible:after{animation:.15s ease-out tip-in-bottom;top:calc(100% + 6px);bottom:auto;left:0}[data-tip-size=wide]:hover:after,[data-tip-size=wide]:focus-visible:after{max-width:var(--pb-tooltip-max-width,min(480px, calc(100vw - 32px), calc(100dvw - 32px)))}[data-tip-size=narrow]:hover:after,[data-tip-size=narrow]:focus-visible:after{max-width:var(--pb-tooltip-max-width,min(220px, calc(100vw - 32px), calc(100dvw - 32px)))}.app-header-icon-tip:hover:after,.app-header-icon-tip:focus-visible:after{text-align:center;min-width:7.25rem}.tooltip-wrap{position:relative}.tooltip-panel{z-index:50;visibility:hidden;opacity:0;pointer-events:none;background:var(--pb-bg-card);border:1px solid var(--pb-border-hover);width:max-content;max-width:360px;color:var(--pb-text-primary);white-space:normal;overflow-wrap:break-word;box-shadow:var(--pb-shadow-overlay);border-radius:.5rem;padding:.5rem .75rem;font-size:.75rem;line-height:1.4;transition:opacity .15s ease-out,transform .15s ease-out,visibility .15s ease-out;position:absolute}.tooltip-wrap:hover>.tooltip-panel,.tooltip-wrap:focus-within>.tooltip-panel{visibility:visible;opacity:1}.tooltip-panel-top{bottom:calc(100% + .5rem);left:0;transform:translateY(4px)}.tooltip-wrap:hover>.tooltip-panel-top,.tooltip-wrap:focus-within>.tooltip-panel-top{transform:translateY(0)}.tooltip-panel-left{top:50%;right:calc(100% + .5rem);transform:translate(4px,-50%)}.tooltip-wrap:hover>.tooltip-panel-left,.tooltip-wrap:focus-within>.tooltip-panel-left{transform:translateY(-50%)}.tooltip-panel-right{top:50%;left:calc(100% + .5rem);transform:translate(-4px,-50%)}.tooltip-wrap:hover>.tooltip-panel-right,.tooltip-wrap:focus-within>.tooltip-panel-right{transform:translateY(-50%)}.tooltip-panel-bottom{top:calc(100% + .5rem);left:0;transform:translateY(-4px)}.tooltip-wrap:hover>.tooltip-panel-bottom,.tooltip-wrap:focus-within>.tooltip-panel-bottom{transform:translateY(0)}.tooltip-panel-nowrap{white-space:nowrap}.tooltip-panel-wide{max-width:500px}@keyframes tip-in{0%{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}@keyframes tip-in-left{0%{opacity:0;transform:translate(4px,-50%)}to{opacity:1;transform:translateY(-50%)}}@keyframes tip-in-right{0%{opacity:0;transform:translate(-4px,-50%)}to{opacity:1;transform:translateY(-50%)}}@keyframes tip-in-bottom{0%{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}.log-terminal{contain:inline-size;background:var(--pb-bg-surface);width:100%;min-width:0;max-width:100%;overflow-x:hidden}@supports (color:color-mix(in lab, red, red)){.log-terminal{background:color-mix(in srgb, var(--pb-bg-surface) 82%, var(--pb-bg-base))}}.log-terminal{background-image:repeating-linear-gradient(0deg, transparent, transparent 2px, var(--pb-border-subtle) 2px, var(--pb-border-subtle) 4px)}@supports (color:color-mix(in lab, red, red)){.log-terminal{background-image:repeating-linear-gradient(0deg, transparent, transparent 2px, color-mix(in srgb, var(--pb-border-subtle) 32%, transparent) 2px, color-mix(in srgb, var(--pb-border-subtle) 32%, transparent) 4px)}}.log-line{box-sizing:border-box;border-left:3px solid #0000;width:100%;min-width:0;max-width:100%;overflow:hidden}.log-line:hover{background:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.log-line:hover{background:color-mix(in srgb, var(--pb-bg-card-hover) 36%, transparent)}}.log-line[data-level=error],.log-line[data-level=critical]{border-left-color:var(--pb-error);background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.log-line[data-level=error],.log-line[data-level=critical]{background:color-mix(in srgb, var(--pb-error) 12%, transparent)}}.log-line[data-level=warning]{border-left-color:var(--pb-warning)}.log-line[data-level=info]{border-left-color:var(--pb-info)}.log-line[data-level=debug]{border-left-color:var(--pb-text-dim)}.log-line.expanded{background:var(--pb-bg-card-hover)!important}@supports (color:color-mix(in lab, red, red)){.log-line.expanded{background:color-mix(in srgb, var(--pb-bg-card-hover) 54%, transparent)!important}}.log-detail{box-sizing:border-box;max-width:calc(100% - 140px);overflow:hidden}.log-path{overflow-wrap:anywhere}.log-badge{letter-spacing:.08em;text-transform:uppercase;text-align:center;border-radius:3px;min-width:38px;padding:1px 5px;font-size:9px;font-weight:700;display:inline-block}.badge-debug{color:var(--pb-text-dim);background:var(--pb-text-dim)}@supports (color:color-mix(in lab, red, red)){.badge-debug{background:color-mix(in srgb, var(--pb-text-dim) 16%, transparent)}}.badge-info{color:var(--pb-info);background:var(--pb-info-dim)}.badge-warning{color:var(--pb-warning);background:var(--pb-warning-dim)}.badge-error{color:var(--pb-error);background:var(--pb-error-dim)}.badge-critical{color:var(--pb-text-inverse);background:var(--pb-error)}.live-dot{background:var(--pb-success);border-radius:50%;width:6px;height:6px;animation:2s ease-in-out infinite live-pulse}@keyframes live-pulse{0%,to{opacity:1;box-shadow:0 0 0 0 color-mix(in srgb, var(--pb-success) 42%, transparent)}50%{opacity:.6;box-shadow:0 0 6px 2px color-mix(in srgb, var(--pb-success) 24%, transparent)}}.json-key{color:var(--pb-info)}.json-str{color:var(--pb-success)}.json-num{color:var(--pb-warning)}.json-bool{color:var(--pb-purple)}.json-null{color:var(--pb-text-dim)}.downloads-page-shell{width:100%}.downloads-view{width:100%;max-width:none;padding:0 0 var(--pb-page-footer-clearance);margin:0}.downloads-header{display:block}.downloads-header-main{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:1.25rem;width:100%;display:flex}.downloads-header-summary{flex-wrap:wrap;flex:auto;align-items:flex-start;gap:1.5rem;min-width:0;display:flex}.downloads-header-copy{min-width:0}.downloads-header-actions{flex:none;justify-content:flex-end;align-items:flex-start;margin-left:auto;display:flex}.downloads-title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1}.downloads-title span{color:var(--pb-brand)}.downloads-subtitle{color:var(--pb-text-tertiary);letter-spacing:.01em;margin-top:.25rem;font-size:.78rem}.downloads-gauges{align-items:flex-end;gap:1rem;display:flex}.downloads-gauge{text-align:center}.downloads-gauge-ring{width:56px;height:56px;margin:0 auto;position:relative}.downloads-gauge-ring svg{transform:rotate(-90deg)}.downloads-gauge-bg{fill:none;stroke:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.downloads-gauge-bg{stroke:color-mix(in srgb, var(--pb-text-secondary) 10%, transparent)}}.downloads-gauge-bg{stroke-width:4.5px}.downloads-gauge-fill{fill:none;stroke-width:4.5px;stroke-linecap:round;transition:stroke-dashoffset .6s}.downloads-gauge-fill-success{stroke:var(--pb-status-success)}.downloads-gauge-fill-info{stroke:var(--pb-status-info)}.downloads-gauge-fill-warning{stroke:var(--pb-status-warning)}.downloads-gauge-fill-error{stroke:var(--pb-status-danger)}.downloads-gauge-fill-muted{stroke:var(--pb-text-dim)}.downloads-gauge-value{color:var(--pb-text-primary);justify-content:center;align-items:center;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700;display:flex;position:absolute;inset:0}.downloads-gauge-value-success{color:var(--pb-status-success)}.downloads-gauge-value-info{color:var(--pb-status-info)}.downloads-gauge-value-warning{color:var(--pb-status-warning)}.downloads-gauge-value-error{color:var(--pb-status-danger)}.downloads-gauge-value-muted{color:var(--pb-text-dim)}.downloads-gauge-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);margin-top:.25rem;font-size:.6rem;font-weight:600}.downloads-tab-rail{border:1px solid var(--pb-border);background:0 0;border-radius:10px;align-self:flex-start;display:inline-flex;overflow:hidden}.downloads-tab-btn{letter-spacing:.06em;text-transform:uppercase;color:var(--pb-text-dim);background:0 0;border:0;padding:.5rem 1.25rem;font-family:Syne,sans-serif;font-size:.78rem;font-weight:700;transition:background-color .14s,color .14s}.downloads-tab-btn+.downloads-tab-btn{border-left:1px solid var(--pb-border)}.downloads-tab-btn:hover{color:var(--pb-text);background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.downloads-tab-btn:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.downloads-tab-btn.is-active{background:var(--pb-interactive);color:var(--pb-text-inverse)}.downloads-panel-stack{gap:1.25rem;margin-top:.875rem;display:grid}.import-history-page-shell{margin-top:0}.downloads-section{gap:.625rem;display:grid}.downloads-section-label{border-bottom:1px solid var(--pb-border-subtle);letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);padding-bottom:.375rem;font-family:Syne,sans-serif;font-size:.62rem;font-weight:700}.downloads-table-wrap{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);border-radius:14px;position:relative;overflow:visible;box-shadow:0 1px 3px #1e1a170f,0 1px 2px #1e1a170a}.downloads-table-wrap.is-clipped{overflow:hidden}.downloads-table{border-collapse:collapse;width:100%}.downloads-table th{border-bottom:2px solid var(--pb-border);background:var(--pb-bg-surface);text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);padding:.5625rem .875rem;font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.downloads-table td{border-bottom:1px solid var(--pb-border-subtle);color:var(--pb-text);vertical-align:middle;padding:.6875rem .875rem;font-size:.82rem;transition:background-color .1s}.downloads-table tbody:last-child tr:last-child td{border-bottom:0}.downloads-table tr:not(.table-detail-row):hover td{background:var(--pb-surface-selected)}.downloads-table th.is-center,.downloads-table td.is-center{text-align:center}.downloads-table th.is-right,.downloads-table td.is-right{text-align:right}.issue-search-results-table-wrap{max-height:62vh;overflow-y:auto}.issue-search-results-table thead th{z-index:1;position:sticky;top:0}.issue-search-sort-indicator{color:var(--pb-interactive)}.issue-search-release-link{color:var(--pb-text);transition:color .14s}.issue-search-release-link:hover{color:var(--pb-interactive)}.issue-search-release-title{text-overflow:ellipsis;white-space:nowrap;max-width:320px;color:inherit;font-weight:600;display:block;overflow:hidden}.issue-search-release-title-muted{color:var(--pb-text-dim)}.issue-search-release-match{color:var(--pb-text-dim);margin-top:.125rem;font-size:.64rem}.issue-search-action-row{justify-content:flex-end;align-items:center;gap:.25rem;display:inline-flex}.issue-search-rejected-row td{opacity:.45}.issue-search-rejected-label{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.64rem}.issue-search-empty-state{text-align:center;padding:2.5rem 1.25rem}.issue-search-empty-icon{width:2.25rem;height:2.25rem;color:var(--pb-text-dim);margin-inline:auto}.issue-search-empty-title{color:var(--pb-text);margin-top:.625rem;font-size:.85rem;font-weight:600}.issue-search-empty-copy{color:var(--pb-text-sec);margin-top:.25rem;font-size:.78rem}.downloads-release-name{max-width:260px;color:var(--pb-text);font-weight:600;display:block}.downloads-release-meta{color:var(--pb-text-dim);margin-top:.2rem;font-size:.72rem}.downloads-issue-link{color:var(--pb-interactive);transition:color .14s}.downloads-issue-link:hover{color:var(--pb-interactive-hover)}.table-mono-dim,td.table-mono-dim,.downloads-mono-dim-cell,td.downloads-mono-dim-cell{font-family:JetBrains Mono,monospace;font-size:.72rem}.table-mono-dim{color:var(--pb-text-dim)}.downloads-table td.downloads-mono-cell,.downloads-table td.downloads-mono-dim-cell,.downloads-table .downloads-mono-cell,.downloads-table .downloads-mono-dim-cell{font-variant-numeric:tabular-nums;font-family:JetBrains Mono,monospace}.downloads-table td.downloads-mono-cell,.downloads-table .downloads-mono-cell{color:var(--pb-text-secondary);font-size:.75rem;font-weight:400;line-height:1.2}.downloads-table td.downloads-mono-dim-cell,.downloads-table .downloads-mono-dim-cell{color:var(--pb-text-dim);font-size:.72rem}.downloads-muted-text{color:var(--pb-text-dim)}.downloads-progress-cell{align-items:center;gap:.375rem;display:inline-flex}.downloads-progress-track{border:1px solid var(--pb-border-subtle);background:var(--pb-text-secondary);border-radius:2px;width:100px;height:6px;display:inline-block;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.downloads-progress-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.downloads-progress-track{vertical-align:middle}.downloads-progress-fill{height:100%;display:block;position:relative}.downloads-progress-fill.is-blue{background:linear-gradient(90deg, var(--pb-info), var(--pb-info))}@supports (color:color-mix(in lab, red, red)){.downloads-progress-fill.is-blue{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-info) 45%, transparent), var(--pb-info))}}.downloads-progress-fill.is-amber{background:linear-gradient(90deg, var(--pb-warning), var(--pb-warning))}@supports (color:color-mix(in lab, red, red)){.downloads-progress-fill.is-amber{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-warning) 45%, transparent), var(--pb-warning))}}.downloads-progress-fill.is-green{background:linear-gradient(90deg, var(--pb-success), var(--pb-success))}@supports (color:color-mix(in lab, red, red)){.downloads-progress-fill.is-green{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-success) 45%, transparent), var(--pb-success))}}.downloads-progress-fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}@keyframes downloads-progress-indeterminate{0%{transform:translate(-130%)}to{transform:translate(280%)}}.downloads-progress-fill.is-indeterminate,.app-progress-fill.is-indeterminate{will-change:transform;animation:1.25s ease-in-out infinite downloads-progress-indeterminate}@media (prefers-reduced-motion:reduce){.downloads-progress-fill.is-indeterminate,.app-progress-fill.is-indeterminate{opacity:.7;animation:none;transform:translate(60%)}}.downloads-progress-pct{color:var(--pb-info);font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:600}.downloads-progress-pct-warning{color:var(--pb-warning)}.downloads-progress-pct-success{color:var(--pb-success)}.downloads-progress-pct-muted{color:var(--pb-text-dim)}.downloads-led{border-radius:999px;width:8px;height:8px;display:inline-block}.downloads-led-green{background:var(--pb-success);box-shadow:0 0 5px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.downloads-led-green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.downloads-led-blue{background:var(--pb-info);box-shadow:0 0 5px var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.downloads-led-blue{box-shadow:0 0 5px color-mix(in srgb, var(--pb-info) 35%, transparent)}}.downloads-led-amber{background:var(--pb-warning);box-shadow:0 0 5px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.downloads-led-amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-warning) 35%, transparent)}}.downloads-led-off{background:var(--pb-text-dim);opacity:.35}.downloads-action-group{align-items:center;gap:.25rem;display:inline-flex}@media (hover:hover) and (pointer:fine){.downloads-action-group.is-hover-reveal{opacity:0;pointer-events:none;transition:opacity .14s,transform .14s;transform:translateY(2px)}.downloads-table tbody tr:hover .downloads-action-group.is-hover-reveal,.downloads-table tbody tr:focus-within .downloads-action-group.is-hover-reveal{opacity:1;pointer-events:auto;transform:translateY(0)}}.downloads-action-btn{border:1px solid var(--pb-border-subtle);width:28px;height:28px;color:var(--pb-text-dim);background:0 0;border-radius:6px;justify-content:center;align-items:center;transition:border-color .14s,color .14s,background-color .14s;display:inline-flex}.downloads-action-btn svg{width:12px;height:12px}.import-history-action-group{gap:.375rem}.import-history-action-btn{border-radius:8px;flex-shrink:0;width:30px;height:30px}.downloads-action-btn .import-history-action-icon{shape-rendering:geometricprecision;flex-shrink:0;width:12px;height:12px;overflow:visible}.downloads-action-btn .import-history-action-icon--detail{width:14px;height:14px}.downloads-action-btn:hover{border-color:var(--pb-interactive);background:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.downloads-action-btn:hover{background:color-mix(in srgb, var(--pb-info) 10%, transparent)}}.downloads-action-btn:hover{color:var(--pb-interactive)}.downloads-action-btn.is-danger:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.downloads-action-btn.is-danger:hover{border-color:color-mix(in srgb, var(--pb-error) 40%, transparent)}}.downloads-action-btn.is-danger:hover{background:var(--pb-error-dim);color:var(--pb-error)}.downloads-action-btn.is-warn:hover{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.downloads-action-btn.is-warn:hover{border-color:color-mix(in srgb, var(--pb-warning) 40%, transparent)}}.downloads-action-btn.is-warn:hover{background:var(--pb-warning-dim);color:var(--pb-warning)}.downloads-empty-state{text-align:center;padding:2.5rem 1.5rem}.downloads-empty-state.is-compact{padding:1.5rem 1.25rem}.downloads-empty-state.is-history{padding:2rem 1.5rem}.downloads-empty-icon{width:40px;height:40px;color:var(--pb-text-dim);margin:0 auto}.downloads-empty-title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text);margin-top:.75rem;font-family:Syne,sans-serif;font-size:.88rem;font-weight:700}.downloads-empty-copy{color:var(--pb-text-dim);margin-top:.4rem;font-size:.78rem}.intervention-bulk-bar{border-top:1px solid var(--pb-border-subtle);border-bottom:1px solid var(--pb-border-subtle);background:var(--pb-info-bg);flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;padding:.625rem .875rem;display:flex}@supports (color:color-mix(in lab, red, red)){.intervention-bulk-bar{background:color-mix(in srgb, var(--pb-info-bg) 40%, var(--pb-bg-surface))}}.intervention-bulk-left{flex-wrap:wrap;align-items:center;gap:.625rem;display:inline-flex}.intervention-bulk-count{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.72rem}.intervention-bulk-actions{flex-wrap:wrap;align-items:center;gap:.5rem;display:inline-flex}.downloads-footer-strip{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);color:var(--pb-text-dim);border-radius:10px;flex-wrap:wrap;align-items:center;gap:.375rem 1.5rem;margin-top:1.25rem;padding:.625rem 1rem;font-family:JetBrains Mono,monospace;font-size:.7rem;display:flex}.downloads-footer-strip strong{color:var(--pb-text);font-weight:600}.downloads-history-toolbar{z-index:2;border:0;border-bottom:1px solid var(--pb-border);border-top-left-radius:inherit;border-top-right-radius:inherit;background:var(--pb-bg-surface);box-shadow:none;border-bottom-right-radius:0;border-bottom-left-radius:0;position:relative}.downloads-toolbar{border-bottom:1px solid var(--pb-border);background:var(--pb-bg-surface);flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;padding:.625rem .875rem;display:flex}.downloads-history-toolbar>.downloads-toolbar{border-top-left-radius:inherit;border-top-right-radius:inherit;border-bottom-right-radius:0;border-bottom-left-radius:0}.downloads-toolbar-left{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.downloads-toolbar-search,.downloads-toolbar-select{border:1px solid var(--pb-border);background:var(--pb-bg-card);height:30px;color:var(--pb-text);border-radius:8px;outline:none;font-size:.78rem}.downloads-toolbar-search{width:200px;padding:0 .625rem}.downloads-toolbar-select{appearance:none;background-image:url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%2374675B' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");background-position:right 8px center;background-repeat:no-repeat;padding:0 2rem 0 .625rem}[data-theme=dark] .downloads-toolbar-select{background-image:url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%239AAABA' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")}.downloads-toolbar-search:focus,.downloads-toolbar-select:focus{border-color:var(--pb-interactive)}.downloads-clear-btn{background:var(--pb-error);color:var(--pb-text-inverse);white-space:nowrap;border:0;border-radius:8px;align-items:center;gap:.3125rem;padding:.375rem .75rem;font-size:.75rem;font-weight:600;transition:opacity .14s;display:inline-flex}.downloads-clear-btn svg{width:12px;height:12px}.downloads-clear-btn:hover{opacity:.92}.downloads-sort-btn{width:100%;color:inherit;font-family:inherit;font-size:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;text-align:inherit;text-transform:inherit;justify-content:flex-start;align-items:center;gap:.25rem;transition:color .14s;display:inline-flex}.import-review-cv-year-header{width:5.75rem;min-width:5.75rem}.import-review-cv-year-header .downloads-sort-btn{white-space:nowrap}.import-review-cv-year-header .downloads-sort-chevron{flex-shrink:0}.downloads-table th.is-center .downloads-sort-btn{text-align:center;justify-content:center}.downloads-table th.is-right .downloads-sort-btn{text-align:right;justify-content:flex-end}.downloads-sort-btn:hover,.downloads-sort-btn.is-active{color:var(--pb-text)}.downloads-sort-chevron{opacity:.35;width:12px;height:12px}.downloads-sort-btn.is-active .downloads-sort-chevron{opacity:1}.downloads-sort-chevron.is-desc{transform:rotate(180deg)}.downloads-error-row td{border-bottom:1px solid var(--pb-border-subtle);background:var(--pb-error-dim);padding:.5rem .875rem}@supports (color:color-mix(in lab, red, red)){.downloads-error-row td{background:color-mix(in srgb, var(--pb-error-dim) 50%, var(--pb-bg-card))}}.downloads-error-row td{color:var(--pb-error);font-size:.75rem}.downloads-error-content{align-items:flex-start;gap:.5rem;display:flex}.downloads-error-content svg{flex-shrink:0;width:14px;height:14px;margin-top:1px}.search-history-detail-row td{border-bottom:1px solid var(--pb-border-subtle);background:var(--pb-bg-surface);padding:.75rem .875rem}@supports (color:color-mix(in lab, red, red)){.search-history-detail-row td{background:color-mix(in srgb, var(--pb-bg-surface) 70%, var(--pb-bg-card))}}.search-history-detail-card{border:1px solid var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.search-history-detail-card{border:1px solid color-mix(in srgb, var(--pb-border) 78%, transparent)}}.search-history-detail-card{background:var(--pb-bg-card-hover);border-radius:.75rem}@supports (color:color-mix(in lab, red, red)){.search-history-detail-card{background:color-mix(in srgb, var(--pb-bg-card-hover) 55%, transparent)}}.search-history-detail-card{padding:.625rem .75rem}.search-history-detail-label{letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);font-size:10px;font-weight:600}.search-history-diagnostics-shell{border:1px solid var(--pb-warning);overflow:hidden}@supports (color:color-mix(in lab, red, red)){.search-history-diagnostics-shell{border:1px solid color-mix(in srgb, var(--pb-warning) 30%, transparent)}}.search-history-diagnostics-shell{background:var(--pb-warning-dim);border-radius:.75rem}@supports (color:color-mix(in lab, red, red)){.search-history-diagnostics-shell{background:color-mix(in srgb, var(--pb-warning-dim) 35%, transparent)}}.downloads-pagination{border-top:1px solid var(--pb-border-subtle);padding:.5rem .875rem}@media (max-width:1024px){.downloads-header{gap:1rem}.downloads-table-wrap{overflow-x:auto}.downloads-table-wrap.is-clipped{overflow:auto hidden}.downloads-table{min-width:760px}}@media (max-width:640px){.downloads-header-main{gap:.875rem}.downloads-gauges{gap:.625rem}.downloads-toolbar-search{width:100%}}.comic-reader{--pb-reader-canvas:#070b12;--pb-reader-surface:#0d141ef0;--pb-reader-surface-strong:#0d141efa;--pb-reader-text:#f7f1e8;--pb-reader-text-muted:#cbd5e1;--pb-reader-text-dim:#9aaaba;--pb-reader-interactive:#8fb9ee;--pb-reader-danger:#e38473;--pb-reader-warning:#f2c98d;background:var(--pb-reader-canvas);width:100vw;max-width:none;height:100dvh;max-height:none;color:var(--pb-reader-text);border:0;margin:0;padding:0;position:fixed;inset:0;overflow:hidden}.comic-reader::backdrop{background:var(--pb-reader-canvas)}.comic-reader__shell{background:var(--pb-reader-canvas);width:100%;min-width:0;height:100%;min-height:0;color:var(--pb-reader-text);grid-template-rows:auto minmax(0,1fr) auto;font-family:DM Sans,sans-serif;display:grid;overflow:hidden}.comic-reader__topbar,.comic-reader__controls{z-index:20;background:var(--pb-reader-surface);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-color:#cbd5e129;transition:opacity .14s;position:relative}.comic-reader__topbar{min-height:58px;padding:calc(.45rem + env(safe-area-inset-top,0px)) max(.75rem, env(safe-area-inset-right,0px)) .45rem max(.75rem, env(safe-area-inset-left,0px));border-bottom-width:1px;grid-template-columns:auto minmax(0,1fr) auto auto;align-items:center;gap:.75rem;display:grid}.comic-reader__identity{min-width:0}.comic-reader__identity h2,.comic-reader__identity p{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.comic-reader__identity h2{color:var(--pb-reader-text);margin:0;font-family:Bricolage Grotesque,sans-serif;font-size:.95rem;font-weight:800;line-height:1.2}.comic-reader__identity p{color:var(--pb-reader-text-dim);margin:.1rem 0 0;font-size:.72rem;line-height:1.2}.comic-reader__top-actions,.comic-reader__issue-navigation,.comic-reader__navigation,.comic-reader__sizing,.comic-reader__state-actions,.comic-reader__help-heading{align-items:center;display:flex}.comic-reader__top-actions,.comic-reader__issue-navigation,.comic-reader__navigation,.comic-reader__sizing,.comic-reader__state-actions{gap:.4rem}.comic-reader__issue-navigation{min-width:0}.comic-reader__issue-navigation .comic-reader__button{gap:.35rem}.comic-reader__button,.comic-reader__zoom-label,.comic-reader__fit-select,.comic-reader__page-jump input{min-height:44px;color:var(--pb-reader-text);font:inherit;background:#f7f1e812;border:1px solid #cbd5e138;border-radius:.55rem}.comic-reader__button,.comic-reader__zoom-label{white-space:nowrap;cursor:pointer;justify-content:center;align-items:center;min-width:44px;padding:.55rem .7rem;font-size:.76rem;font-weight:700;line-height:1;display:inline-flex}.comic-reader__button:hover,.comic-reader__zoom-label:hover,.comic-reader__button[aria-pressed=true]{background:#8fb9ee29;border-color:#8fb9ee8c}.comic-reader__button:focus-visible,.comic-reader__zoom-label:focus-visible,.comic-reader__fit-select:focus-visible,.comic-reader__page-jump input:focus-visible,.comic-reader__viewport:focus-visible{outline:2px solid var(--pb-reader-interactive);outline-offset:-2px}.comic-reader__button:disabled{cursor:default;opacity:.38}.comic-reader__button svg{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.8px;width:19px;height:19px}.comic-reader__button--prominent{background:#8fb9ee1f;border-color:#8fb9ee61}.comic-reader__viewport{z-index:1;overscroll-behavior:contain;background:var(--pb-reader-canvas);scrollbar-color:#9aaaba8c transparent;touch-action:pan-y pinch-zoom;min-width:0;min-height:0;position:relative;overflow:auto}.comic-reader__viewport.is-pannable{touch-action:auto}.comic-reader__page-stage{justify-content:center;align-items:center;width:100%;min-width:100%;height:100%;min-height:100%;padding:.75rem;display:flex}.comic-reader__page{z-index:1;object-fit:contain;-webkit-user-select:none;user-select:none;-webkit-user-drag:none;flex:none;max-width:none;max-height:none;display:block;position:relative;box-shadow:0 8px 30px #00000061}.comic-reader__page--page{width:auto;max-width:100%;height:auto;max-height:100%}.comic-reader__page--width{align-self:flex-start;width:100%;height:auto}.comic-reader__page--height{width:auto;height:100%}.comic-reader__page--actual{align-self:flex-start;width:auto;height:auto}.comic-reader__state,.comic-reader__page-busy{z-index:8;text-align:center;justify-content:center;align-items:center;display:flex;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}.comic-reader__state{width:min(90vw,32rem);color:var(--pb-reader-text-muted);background:#0d141ef5;border:1px solid #cbd5e133;border-radius:.9rem;flex-direction:column;gap:.5rem;padding:1.5rem;box-shadow:0 20px 56px #00000075}.comic-reader__state strong{color:var(--pb-reader-text);font-size:1rem}.comic-reader__state--error{border-color:var(--pb-reader-danger)}@supports (color:color-mix(in lab, red, red)){.comic-reader__state--error{border-color:color-mix(in srgb, var(--pb-reader-danger) 45%, transparent)}}.comic-reader__state-actions{flex-wrap:wrap;justify-content:center;margin-top:.5rem}.comic-reader__completion{z-index:9;right:max(.75rem, env(safe-area-inset-right,0px));bottom:max(.75rem, env(safe-area-inset-bottom,0px));width:min(24rem,100% - 1.5rem);color:var(--pb-reader-text-muted);background:#0d141ef5;border:1px solid #8fb9ee6b;border-radius:.8rem;padding:.85rem;position:absolute;box-shadow:0 16px 42px #0000006b}.comic-reader__completion strong{color:var(--pb-reader-text);font-family:Bricolage Grotesque,sans-serif;font-size:1rem;display:block}.comic-reader__completion p{margin:.35rem 0 0;font-size:.78rem}.comic-reader__page-busy{color:var(--pb-reader-text-muted);background:#0d141ee6;border-radius:999px;padding:.45rem .7rem;font-size:.72rem;top:1rem;transform:translate(-50%)}.comic-reader__spinner{border:2px solid #8fb9ee40;border-top-color:var(--pb-reader-interactive);border-radius:999px;width:1.5rem;height:1.5rem;animation:.7s linear infinite comic-reader-spin}@keyframes comic-reader-spin{to{transform:rotate(360deg)}}.comic-reader__tap-zone{z-index:4;background:0 0;border:0;margin:0;padding:0;position:absolute;top:0;bottom:0}.comic-reader__tap-zone--left{width:30%;left:0}.comic-reader__tap-zone--center{width:40%;left:30%}.comic-reader__tap-zone--right{width:30%;right:0}.comic-reader__controls{min-width:0;padding:.45rem max(.75rem, env(safe-area-inset-right,0px)) calc(.45rem + env(safe-area-inset-bottom,0px)) max(.75rem, env(safe-area-inset-left,0px));border-top-width:1px;justify-content:space-between;align-items:center;gap:.75rem;display:flex}.comic-reader__navigation,.comic-reader__sizing{min-width:0;position:relative}.comic-reader__page-jump{color:var(--pb-reader-text-dim);white-space:nowrap;align-items:center;gap:.35rem;font-size:.75rem;display:inline-flex}.comic-reader__page-jump input{text-align:center;appearance:textfield;width:3.4rem;padding:.4rem}.comic-reader__page-jump input::-webkit-outer-spin-button{appearance:none;margin:0}.comic-reader__page-jump input::-webkit-inner-spin-button{appearance:none;margin:0}.comic-reader__fit-select{color-scheme:dark;max-width:8.5rem;padding:0 1.9rem 0 .65rem;display:none}.comic-reader__zoom-label{min-width:3.75rem;color:var(--pb-reader-text-muted);padding-inline:.45rem}.comic-reader__input-error{border:1px solid var(--pb-reader-danger);width:max-content;max-width:min(20rem,100vw - 1.5rem);padding:.35rem .5rem;position:absolute;bottom:calc(100% + .35rem);left:50%;transform:translate(-50%)}@supports (color:color-mix(in lab, red, red)){.comic-reader__input-error{border:1px solid color-mix(in srgb, var(--pb-reader-danger) 42%, transparent)}}.comic-reader__input-error{background:var(--pb-reader-surface-strong);color:var(--pb-reader-danger);border-radius:.45rem;font-size:.7rem}.comic-reader__help{z-index:30;background:var(--pb-reader-surface-strong);border:1px solid #8fb9ee66;border-radius:.9rem;width:min(92vw,34rem);max-height:min(78dvh,38rem);padding:1rem;position:absolute;top:50%;left:50%;overflow:auto;transform:translate(-50%,-50%);box-shadow:0 20px 56px #0009}.comic-reader__help-heading{justify-content:space-between;gap:1rem}.comic-reader__help h3{color:var(--pb-reader-text);margin:0;font-family:Bricolage Grotesque,sans-serif;font-size:1rem}.comic-reader__help dl{gap:.15rem;margin:.75rem 0 0;display:grid}.comic-reader__help dl>div{border-top:1px solid #cbd5e11f;grid-template-columns:minmax(7.5rem,.7fr) minmax(0,1.3fr);gap:.75rem;padding:.45rem 0;display:grid}.comic-reader__help dt{color:var(--pb-reader-interactive);font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:600}.comic-reader__help dd{color:var(--pb-reader-text-muted);margin:0;font-size:.75rem}.comic-reader__save-status{z-index:40;right:max(.75rem, env(safe-area-inset-right,0px));bottom:calc(4.1rem + env(safe-area-inset-bottom,0px));max-width:min(22rem,100vw - 1.5rem);color:var(--pb-reader-warning);background:#231b11f5;border:1px solid #d7a15b66;border-radius:.55rem;margin:0;padding:.45rem .65rem;font-size:.72rem;position:absolute}.comic-reader__shell.is-controls-hidden .comic-reader__topbar,.comic-reader__shell.is-controls-hidden .comic-reader__controls{visibility:hidden;opacity:0;pointer-events:none}@media (max-width:900px){.comic-reader__issue-navigation .comic-reader__button span{clip:rect(0, 0, 0, 0);white-space:nowrap;width:1px;height:1px;position:absolute;overflow:hidden}.comic-reader__fit-page,.comic-reader__fit-wide{display:none}.comic-reader__fit-select{display:block}}@media (max-width:600px){.comic-reader__topbar{grid-template-columns:auto minmax(0,1fr) auto;gap:.5rem;min-height:54px}.comic-reader__issue-navigation{grid-area:2/1/auto/-1;justify-content:center}.comic-reader__top-actions{grid-area:1/3}.comic-reader__desktop-action{display:none}.comic-reader__controls{flex-wrap:wrap;justify-content:center;gap:.35rem .6rem}.comic-reader__navigation,.comic-reader__sizing{justify-content:center}.comic-reader__page-stage{padding:.4rem}.comic-reader__help dl>div{grid-template-columns:1fr;gap:.15rem}}@media (max-height:480px){.comic-reader__topbar,.comic-reader__controls{padding-top:.25rem;padding-bottom:.25rem}.comic-reader__topbar{min-height:48px}}@media (prefers-reduced-motion:reduce){.comic-reader__topbar,.comic-reader__controls{transition:none;visibility:visible!important;opacity:1!important;pointer-events:auto!important}.comic-reader__spinner{animation-duration:1.4s}}.reading-workspace{flex-direction:column;gap:1rem;display:flex}.reading-workspace-header,.dashboard-reading-shelf-header{justify-content:space-between;align-items:flex-end;gap:1rem;display:flex}.reading-page-size{align-items:center;gap:.65rem;display:flex}.reading-tabs{border-bottom:1px solid var(--pb-border);gap:.4rem;display:flex;overflow-x:auto}.reading-tab{color:var(--pb-text-tertiary);letter-spacing:.08em;text-transform:uppercase;flex:none;padding:.65rem .85rem;font-family:Syne,sans-serif;font-size:.7rem;font-weight:700;position:relative}.reading-tab:after{content:"";background:0 0;border-radius:999px;height:2px;position:absolute;bottom:-1px;left:.65rem;right:.65rem}.reading-tab:hover,.reading-tab:focus-visible,.reading-tab.is-active{color:var(--pb-interactive)}.reading-tab.is-active:after{background:var(--pb-interactive)}.reading-card-grid,.dashboard-reading-card-grid{grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:.85rem;display:grid}.dashboard-reading-card-grid{grid-template-columns:repeat(4,minmax(0,1fr))}.reading-card{border:1px solid var(--pb-border);background:linear-gradient(145deg, var(--pb-interactive), transparent 42%), var(--pb-bg-card);border-radius:12px;grid-template-columns:92px minmax(0,1fr);min-width:0;display:grid;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.reading-card{background:linear-gradient(145deg, color-mix(in srgb, var(--pb-interactive) 5%, transparent), transparent 42%), var(--pb-bg-card)}}.reading-card{box-shadow:var(--pb-shadow-1)}.reading-card:not(.reading-card-dashboard){min-height:15.25rem}.reading-card-cover-link,.reading-card-cover-frame{min-height:138px;display:block}.reading-card-cover-frame{border-right:1px solid var(--pb-border-subtle);background:var(--pb-bg-shell);height:100%;position:relative;overflow:hidden}.reading-card-cover{object-fit:cover;width:100%;height:100%}.reading-card-cover-placeholder{width:100%;height:100%;color:var(--pb-text-dim);justify-content:center;align-items:center;display:flex}.reading-card-cover-placeholder svg{width:2.25rem;height:2.25rem}.reading-card-body{flex-direction:column;gap:.7rem;min-width:0;padding:.85rem;display:flex}.reading-card-series{color:var(--pb-text-primary);text-overflow:ellipsis;white-space:nowrap;font-family:Syne,sans-serif;font-size:.78rem;font-weight:700;display:block;overflow:hidden}.reading-card-series:hover,.reading-card-series:focus-visible{color:var(--pb-interactive)}.reading-card-issue{min-width:0;color:var(--pb-text-tertiary);gap:.4rem;margin-top:.2rem;font-size:.72rem;display:flex}.reading-card-issue>span:first-child,.reading-card-state{font-family:JetBrains Mono,monospace}.reading-card-issue-title{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.reading-card-state{color:var(--pb-text-secondary);font-size:.67rem;font-weight:600}.reading-card-progress{background:var(--pb-border-subtle);border-radius:999px;height:3px;margin-top:.4rem;overflow:hidden}.reading-card-progress span{border-radius:inherit;background:var(--pb-interactive);height:100%;display:block}.reading-card-state-region{min-height:1.75rem}.reading-card-actions{grid-template-rows:repeat(2,minmax(2.25rem,auto));grid-template-columns:repeat(2,minmax(0,1fr));align-items:stretch;gap:.4rem;margin-top:auto;display:grid}.reading-card-view-read .reading-card-actions{grid-template-columns:minmax(0,2fr) minmax(0,3fr)}.reading-card-view-read .reading-card-primary-action,.reading-card-view-read .reading-card-completion-action{white-space:nowrap;padding-inline:.5rem}.reading-card-primary-action,.reading-card-completion-action,.reading-card-queue-action{width:100%;min-width:0}.reading-card-queue-action{grid-column:1/-1}.reading-card-queue-action-placeholder{visibility:hidden;pointer-events:none;grid-column:1/-1;min-height:2.25rem}.reading-card-mutation-status{min-height:1rem;color:var(--pb-error);font-size:.68rem}.reading-empty-state{border:1px dashed var(--pb-border);text-align:center;background:var(--pb-bg-card);border-radius:12px;flex-direction:column;justify-content:center;align-items:center;min-height:260px;padding:2.5rem 1.5rem;display:flex}@supports (color:color-mix(in lab, red, red)){.reading-empty-state{background:color-mix(in srgb, var(--pb-bg-card) 78%, transparent)}}.reading-empty-icon{width:2.75rem;height:2.75rem;color:var(--pb-text-dim)}.reading-empty-state h2{color:var(--pb-text-primary);margin-top:.85rem;font-family:Syne,sans-serif;font-size:.95rem;font-weight:700}.reading-empty-state p{max-width:34rem;color:var(--pb-text-tertiary);margin-top:.35rem;font-size:.78rem}.dashboard-reading-shelf{border:1px solid var(--pb-border);background:var(--pb-bg-card);border-radius:12px;padding:.85rem}@supports (color:color-mix(in lab, red, red)){.dashboard-reading-shelf{background:color-mix(in srgb, var(--pb-bg-card) 88%, transparent)}}.dashboard-reading-shelf{box-shadow:var(--pb-shadow-1)}.dashboard-reading-shelf-header h2{color:var(--pb-text-primary);margin-top:.1rem;font-family:Syne,sans-serif;font-size:1rem;font-weight:700}.dashboard-reading-shelf-link{color:var(--pb-interactive);font-size:.72rem;font-weight:700}.dashboard-reading-shelf-link:hover,.dashboard-reading-shelf-link:focus-visible{text-decoration:underline}.reading-card-dashboard{grid-template-columns:76px minmax(0,1fr)}.reading-card-dashboard .reading-card-cover-link,.reading-card-dashboard .reading-card-cover-frame{min-height:124px}.reading-card-dashboard .reading-card-body{gap:.5rem;padding:.7rem}@media (max-width:1180px){.dashboard-reading-card-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (max-width:640px){.reading-workspace-header{flex-direction:column;align-items:flex-start}.reading-card-grid,.dashboard-reading-card-grid{grid-template-columns:1fr}.reading-card{grid-template-columns:82px minmax(0,1fr)}.reading-card-cover-link,.reading-card-cover-frame{min-height:128px}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}} diff --git a/src/pullbox/ui/static/js/pullbox.js b/src/pullbox/ui/static/js/pullbox.js index ca8450d..e3c00b9 100644 --- a/src/pullbox/ui/static/js/pullbox.js +++ b/src/pullbox/ui/static/js/pullbox.js @@ -1038,6 +1038,63 @@ function importReviewStatusCount(shell, view) { return Number(counts && counts[view]) || 0; } +async function cancelImportReview(button) { + if (!button || button.disabled) { + return; + } + + var jobId = Number(button.getAttribute("data-import-review-cancel-job-id")); + if (!Number.isFinite(jobId) || jobId <= 0) { + showToast({ message: "Unable to identify this import.", level: "error" }); + return; + } + + var confirmed = await window.pbConfirm({ + title: "Cancel Import", + message: + "This removes the current import job and all of its matched data. Use this only when you are sure the run should not continue.", + confirmText: "Cancel Import", + destructive: true, + }); + if (!confirmed) { + return; + } + + var label = button.querySelector("[data-import-review-cancel-label]"); + button.disabled = true; + button.setAttribute("aria-busy", "true"); + if (label) { + label.textContent = "Cancelling..."; + } + + try { + var response = await fetch("/api/v1/import/" + jobId, { + method: "DELETE", + headers: { "X-CSRF-Token": readCsrfTokenFromBody() }, + }); + if (!response.ok) { + var error = await response.json().catch(function () { + return { detail: "Failed to cancel import." }; + }); + throw new Error(error.detail || "Failed to cancel import."); + } + purgeImportClientState(jobId); + window.location.replace("/import?tab=collection"); + } catch (err) { + button.disabled = false; + button.removeAttribute("aria-busy"); + if (label) { + label.textContent = "Cancel"; + } + showToast({ + message: (err && err.message) || "Failed to cancel import. Please try again.", + level: "error", + }); + } +} + +window.cancelImportReview = cancelImportReview; + function importReviewShellHasSeriesBucket(shell, seriesId, bucket) { var numericSeriesId = Number(seriesId); if (!shell || !Number.isFinite(numericSeriesId) || !bucket) { @@ -1055,6 +1112,126 @@ function importReviewShellHasSeriesBucket(shell, seriesId, bucket) { return buckets.indexOf(bucket) >= 0; } +function captureImportReviewViewport(shell, preferredElement) { + var scroller = document.getElementById("content"); + var state = { + scrollTop: scroller ? scroller.scrollTop : window.scrollY || 0, + anchors: [], + expandedRows: [], + }; + if (!shell || typeof shell.querySelectorAll !== "function") { + return state; + } + + var capturedRowKeys = Object.create(null); + var captureRow = function (row) { + if (!row || !shell.contains(row)) { + return; + } + var rowKey = row.getAttribute("data-import-review-row-key"); + if (!rowKey || capturedRowKeys[rowKey]) { + return; + } + capturedRowKeys[rowKey] = true; + state.anchors.push({ + key: rowKey, + top: row.getBoundingClientRect().top, + }); + if (row.querySelector("[data-import-review-expand-action][aria-expanded='true']")) { + state.expandedRows.push({ + key: rowKey, + pendingSubitems: + Number(row.getAttribute("data-import-review-pending-subitems")) || 0, + }); + } + }; + + var preferredRow = + preferredElement && typeof preferredElement.closest === "function" + ? preferredElement.closest("[data-import-review-series-row]") + : null; + captureRow(preferredRow); + + var viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0; + var rows = shell.querySelectorAll("[data-import-review-series-row]"); + for (var i = 0; i < rows.length; i += 1) { + var rect = rows[i].getBoundingClientRect(); + if (rect.bottom < 0 || rect.top > viewportHeight) { + continue; + } + captureRow(rows[i]); + } + return state; +} + +function restoreImportReviewExpansionState(state, shell) { + if (!state) { + return; + } + var expandedRows = Array.isArray(state.expandedRows) ? state.expandedRows : []; + var renderedRows = shell ? shell.querySelectorAll("[data-import-review-series-row]") : []; + for (var expandedIndex = 0; expandedIndex < expandedRows.length; expandedIndex += 1) { + var expandedRow = expandedRows[expandedIndex]; + for (var renderedIndex = 0; renderedIndex < renderedRows.length; renderedIndex += 1) { + var renderedRow = renderedRows[renderedIndex]; + if (renderedRow.getAttribute("data-import-review-row-key") !== expandedRow.key) { + continue; + } + var previousPendingSubitems = Number(expandedRow.pendingSubitems) || 0; + var nextPendingSubitems = + Number(renderedRow.getAttribute("data-import-review-pending-subitems")) || 0; + if (previousPendingSubitems > 0 && nextPendingSubitems === 0) { + setImportReviewRowExpanded(renderedRow, false); + } else { + setImportReviewRowExpanded(renderedRow, true); + } + break; + } + } +} + +function restoreImportReviewViewport(state, shell) { + if (!state) { + return; + } + var renderedRows = shell ? shell.querySelectorAll("[data-import-review-series-row]") : []; + var scroller = document.getElementById("content"); + var anchors = Array.isArray(state.anchors) ? state.anchors : []; + for (var i = 0; i < anchors.length; i += 1) { + var anchor = anchors[i]; + for (var rowIndex = 0; rowIndex < renderedRows.length; rowIndex += 1) { + if (renderedRows[rowIndex].getAttribute("data-import-review-row-key") !== anchor.key) { + continue; + } + var delta = renderedRows[rowIndex].getBoundingClientRect().top - anchor.top; + if (scroller) { + scroller.scrollTop += delta; + } else { + window.scrollBy(0, delta); + } + return; + } + } + if (scroller) { + scroller.scrollTop = state.scrollTop; + } else { + window.scrollTo(0, state.scrollTop); + } +} + +var pendingImportReviewViewportState = null; + +document.body.addEventListener("htmx:beforeSwap", function (event) { + var target = event && event.detail ? event.detail.target : null; + if (target && target.id === "import-step-review-shell") { + var requestElement = + event.detail.requestConfig && event.detail.requestConfig.elt + ? event.detail.requestConfig.elt + : null; + pendingImportReviewViewportState = captureImportReviewViewport(target, requestElement); + } +}); + function loadImportReviewShell(url) { var shell = document.getElementById("import-step-review-shell"); if (!shell || !url) { @@ -1095,18 +1272,25 @@ function loadImportReviewShell(url) { throw new Error("Import review refresh returned an unexpected response."); } + if (typeof Idiomorph === "undefined" || typeof Idiomorph.morph !== "function") { + throw new Error("Import review refresh is unavailable."); + } + var viewportState = captureImportReviewViewport(currentShell); destroyAlpineTree(currentShell); - currentShell.replaceWith(nextShell); - + Idiomorph.morph(currentShell, nextShell, { morphStyle: "outerHTML" }); + var activeShell = document.getElementById("import-step-review-shell"); if (window.htmx && typeof window.htmx.process === "function") { - window.htmx.process(nextShell); + window.htmx.process(activeShell); } if (window.Alpine) { - Alpine.initTree(nextShell); + Alpine.initTree(activeShell); } + restoreImportReviewExpansionState(viewportState, activeShell); + _dispatchSyntheticHtmxAfterSettle(activeShell); _syncFooterDockFromResponse(html); - seedSearchFieldStates(nextShell); - return nextShell; + seedSearchFieldStates(activeShell); + restoreImportReviewViewport(viewportState, activeShell); + return activeShell; }) .finally(function () { var activeShell = document.getElementById("import-step-review-shell"); @@ -1773,7 +1957,40 @@ function fileBrowserMixin(config) { } function dispatchImportWizardAdvance(detail) { - window.dispatchEvent(new CustomEvent("wizard:advance", { detail: detail || {} })); + var payload = detail || {}; + var collectionPage = document.querySelector("[data-testid='import-collection-page']"); + if (collectionPage && collectionPage.isConnected) { + window.dispatchEvent(new CustomEvent("wizard:advance", { detail: payload })); + return; + } + + var jobId = payload.jobId; + var step = Number(payload.step); + if (jobId == null || !Number.isFinite(step) || step < 2) { + window.dispatchEvent(new CustomEvent("wizard:advance", { detail: payload })); + return; + } + + var path = + "/import?tab=collection&resume_job_id=" + + encodeURIComponent(jobId) + + "&resume_step=" + + encodeURIComponent(step); + var importContent = document.getElementById("import-content"); + if (!importContent || typeof htmx === "undefined") { + window.location.assign(path); + return; + } + + if (window.history && typeof window.history.pushState === "function") { + window.history.pushState({}, "", path); + } + performHtmxSwap("GET", path, { + target: "#import-content", + swap: "outerHTML", + }).catch(function () { + window.location.assign(path); + }); } function importReviewAdvanceStorageKey(jobId) { @@ -1871,109 +2088,33 @@ function clearImportReviewSelection(jobId) { void jobId; } -function importReviewExpansionStorageKey(jobId) { - return "pb-import-review-expanded:" + String(jobId || ""); -} - -function normalizeImportReviewExpandedRows(value) { - if (Array.isArray(value)) { - var rowsFromArray = {}; - for (var i = 0; i < value.length; i += 1) { - var rowId = String(value[i] || ""); - if (rowId) { - rowsFromArray[rowId] = true; - } - } - return rowsFromArray; - } - - if (!value || typeof value !== "object") { - return {}; - } - - var rows = {}; - var keys = Object.keys(value); - for (var j = 0; j < keys.length; j += 1) { - if (value[keys[j]]) { - rows[String(keys[j])] = true; - } - } - return rows; -} - -function readImportReviewExpandedRows(jobId) { - if (jobId == null) { - return {}; - } - - try { - var raw = window.sessionStorage.getItem(importReviewExpansionStorageKey(jobId)); - return raw ? normalizeImportReviewExpandedRows(JSON.parse(raw)) : {}; - } catch (_) { - return {}; - } -} - -function writeImportReviewExpandedRows(jobId, expandedRows) { - if (jobId == null) { +function setImportReviewRowExpanded(row, expanded) { + if (!row) { return; } - - try { - var rows = normalizeImportReviewExpandedRows(expandedRows); - var rowIds = Object.keys(rows); - if (rowIds.length === 0) { - window.sessionStorage.removeItem(importReviewExpansionStorageKey(jobId)); - return; - } - window.sessionStorage.setItem(importReviewExpansionStorageKey(jobId), JSON.stringify(rowIds)); - } catch (_) { - // Ignore storage availability failures. + var actions = row.querySelectorAll("[data-import-review-expand-action]"); + for (var actionIndex = 0; actionIndex < actions.length; actionIndex += 1) { + actions[actionIndex].setAttribute("aria-expanded", expanded ? "true" : "false"); } -} - -function isImportReviewRowExpanded(jobId, rowId) { - if (rowId == null) { - return false; + var icons = row.querySelectorAll("[data-import-review-expand-icon]"); + for (var iconIndex = 0; iconIndex < icons.length; iconIndex += 1) { + icons[iconIndex].classList.toggle("rotate-180", expanded); + } + var details = row.querySelectorAll("[data-import-review-detail-row]"); + for (var detailIndex = 0; detailIndex < details.length; detailIndex += 1) { + details[detailIndex].hidden = !expanded; } - var expandedRows = readImportReviewExpandedRows(jobId); - return expandedRows[String(rowId)] === true; } -function setImportReviewRowExpanded(jobId, rowId, expanded) { - if (rowId == null) { +function toggleImportReviewRow(button) { + var row = button ? button.closest("[data-import-review-series-row]") : null; + if (!row) { return; } - - var expandedRows = readImportReviewExpandedRows(jobId); - var key = String(rowId); - if (expanded) { - expandedRows[key] = true; - } else { - delete expandedRows[key]; - } - writeImportReviewExpandedRows(jobId, expandedRows); + setImportReviewRowExpanded(row, button.getAttribute("aria-expanded") !== "true"); } -function importReviewRowExpansionData(config) { - var cfg = config || {}; - return { - expanded: false, - jobId: cfg.jobId, - rowId: cfg.rowId, - - init: function () { - this.expanded = isImportReviewRowExpanded(this.jobId, this.rowId); - }, - - toggle: function () { - this.expanded = !this.expanded; - setImportReviewRowExpanded(this.jobId, this.rowId, this.expanded); - }, - }; -} - -window.importReviewRowExpansionData = importReviewRowExpansionData; +window.toggleImportReviewRow = toggleImportReviewRow; function readImportConflictCommitState(jobId) { function normalizeCommittedPages(pages) { @@ -2757,7 +2898,7 @@ function importCollectionFooterData(config) { Number(snapshot.series_found) || 0, ), recentJobs: Number(cfg.recentJobs) || 0, - unmatched: Number(cfg.unmatched) || 0, + followUp: Number(cfg.followUp) || 0, libraryRoots: Number(cfg.libraryRoots) || 0, footerPhaseLabel: function () { @@ -2817,7 +2958,7 @@ function importCollectionFooterData(config) { return [ { label: "active import", value: this.resumeJobId ? "ready" : "idle" }, { label: "recent jobs", value: String(this.recentJobs) }, - { label: "unmatched", value: String(this.unmatched) }, + { label: "follow-up", value: String(this.followUp) }, { label: "library roots", value: String(this.libraryRoots) }, ]; }, @@ -2915,89 +3056,24 @@ function importCollectionFooterData(config) { }; } -function mylarPathProblemsData() { - return { - reportId: null, - items: [], - total: 0, - page: 1, - pageSize: 25, - search: "", - loading: false, - error: "", - controller: null, - requestId: 0, - init() { - this.setReport(this.mylarPathPreview); - this.$watch("mylarPathPreview", (preview) => this.setReport(preview)); - }, - destroy() { - this.requestId += 1; - if (this.controller) this.controller.abort(); - }, - setReport(preview) { - this.destroy(); - this.reportId = preview && preview.report_id; - this.items = (preview && preview.exceptions) || []; - this.total = (preview && preview.exception_count) || 0; - this.page = 1; - this.search = ""; - this.error = ""; - this.loading = false; - }, - get pages() { - return Math.max(1, Math.ceil(this.total / this.pageSize)); - }, - get exportUrl() { - return this.reportId - ? "/api/v1/import/mylar-path-reports/" + encodeURIComponent(this.reportId) + "/export" - : ""; - }, - async load(page) { - if (!this.reportId) return; - if (this.controller) this.controller.abort(); - const controller = new AbortController(); - this.controller = controller; - const requestId = ++this.requestId; - this.loading = true; - this.error = ""; - try { - const query = new URLSearchParams({ page: String(page), search: this.search }); - const response = await fetch( - "/api/v1/import/mylar-path-reports/" + encodeURIComponent(this.reportId) + "?" + query, - { signal: controller.signal }, - ); - if (!response.ok) throw new Error("Report unavailable or expired. Analyze Mylar paths again."); - const data = await response.json(); - if (requestId !== this.requestId) return; - this.items = data.items; - this.total = data.total; - this.page = data.page; - } catch (error) { - if (error.name !== "AbortError" && requestId === this.requestId) { - this.error = error.message; - } - } finally { - if (requestId === this.requestId) this.loading = false; - } - }, - }; -} - function importSourceData(config) { var cfg = config || {}; var libraryRoots = Array.isArray(cfg.libraryRoots) ? cfg.libraryRoots : []; - var defaultManagedRoot = libraryRoots.find(function (root) { + var initialManagedRoots = libraryRoots.filter(function (root) { return !!( root && root.enabled !== false && root.allow_managed_writes !== false && root.available !== false && - root.writable !== false && - root.is_default_managed_destination + root.writable !== false ); }); - var initialTargetRootId = defaultManagedRoot ? Number(defaultManagedRoot.id) : null; + var defaultManagedRoot = initialManagedRoots.find(function (root) { + return !!root.is_default_managed_destination; + }); + var initialTargetRoot = + defaultManagedRoot || (initialManagedRoots.length === 1 ? initialManagedRoots[0] : null); + var initialTargetRootId = initialTargetRoot ? Number(initialTargetRoot.id) : null; var emptyStoryArcPreview = function () { return { evidence_detected: false, @@ -3064,6 +3140,15 @@ function importSourceData(config) { mylarPathAutoDetect: true, mylarPathConfirmed: false, mylarUnresolvedConfirmed: false, + referenceRootRegistrationPath: "", + referenceRootRegistrationErrorPath: "", + referenceRootRegistrationError: "", + attentionResolvingKey: "", + attentionResolutionErrors: {}, + acknowledgedAttentionActions: {}, + attentionDetailsOpen: false, + attentionDetailsItem: null, + attentionDetailsReturnFocus: null, storyArcPreview: emptyStoryArcPreview(), storyArcPreviewLoading: false, storyArcPreviewError: "", @@ -3091,6 +3176,10 @@ function importSourceData(config) { futurePolicyRequestId: 0, selectSourceType: function (sourceType) { + var previousSourceType = this.sourceType; + if (previousSourceType !== sourceType) { + this.sourcePath = ""; + } this.sourceType = sourceType; if (sourceType !== "filesystem" && sourceType !== "mylar3") { this.fileHandlingMode = "managed_copy"; @@ -3104,7 +3193,6 @@ function importSourceData(config) { } else if (sourceType === "mylar3") { this.scheduleMylarPathPreview(); } - this.scheduleStoryArcPreview(); }, selectImportSource: function (selection) { @@ -3112,7 +3200,6 @@ function importSourceData(config) { this.clearMylarPathPreview(true); this.scheduleLayoutPreview(); this.scheduleMylarPathPreview(); - this.scheduleStoryArcPreview(); this.closeFileBrowser(); }, @@ -3120,7 +3207,6 @@ function importSourceData(config) { this.clearMylarPathPreview(true); this.scheduleLayoutPreview(); this.scheduleMylarPathPreview(); - this.scheduleStoryArcPreview(); }, setFileHandlingMode: function (mode) { @@ -3138,7 +3224,9 @@ function importSourceData(config) { var defaultRoot = this.managedLibraryRoots().find(function (root) { return !!root.is_default_managed_destination; }); - this.targetLibraryRootId = defaultRoot ? Number(defaultRoot.id) : null; + var managedRoots = this.managedLibraryRoots(); + var automaticRoot = defaultRoot || (managedRoots.length === 1 ? managedRoots[0] : null); + this.targetLibraryRootId = automaticRoot ? Number(automaticRoot.id) : null; } if (this.fileHandlingMode === "in_place") { this.scheduleLayoutPreview(); @@ -3160,6 +3248,60 @@ function importSourceData(config) { }); }, + managedLibraryRootOptions: function (emptyLabel) { + var options = this.managedLibraryRoots().map(function (root) { + return { + value: String(root.id), + label: + String(root.name || "Library") + + " — " + + String(root.path || "") + + (root.is_default_managed_destination ? " (default)" : ""), + }; + }); + if (emptyLabel) { + options.unshift({ value: "", label: String(emptyLabel) }); + } + return options; + }, + + selectedManagedLibraryRoot: function () { + var selectedRootId = Number(this.targetLibraryRootId); + return ( + this.managedLibraryRoots().find(function (root) { + return Number(root.id) === selectedRootId && selectedRootId > 0; + }) || null + ); + }, + + shouldShowFileDestinationControl: function () { + var managedRoots = this.managedLibraryRoots(); + if (this.fileHandlingMode === "managed_copy") { + return managedRoots.length > 1 || !this.hasSelectedManagedDestination(); + } + return managedRoots.length !== 1; + }, + + fileDestinationSummary: function () { + var managedRoots = this.managedLibraryRoots(); + var selectedRoot = this.selectedManagedLibraryRoot(); + if (this.fileHandlingMode === "managed_copy") { + if (selectedRoot) { + return ( + String(selectedRoot.name || "Library") + + " will receive the managed copies from this import." + ); + } + return managedRoots.length + ? "Choose the writable library that should receive this import." + : "Set up a writable library before importing copies."; + } + if (!managedRoots.length) { + return "Existing files can stay where they are, but future downloads need a writable library."; + } + return "Existing files stay where they are. You can override where Pullbox manages future files."; + }, + refreshImportLibraryRoots: async function () { if (this.libraryRootsRefreshing) { return; @@ -3187,16 +3329,18 @@ function importSourceData(config) { return String(left.name || "").localeCompare(String(right.name || "")); }); if (!this.hasSelectedManagedDestination()) { - var defaultRoot = this.managedLibraryRoots().find(function (root) { + var managedRoots = this.managedLibraryRoots(); + var defaultRoot = managedRoots.find(function (root) { return !!root.is_default_managed_destination; }); + var automaticRoot = + defaultRoot || (managedRoots.length === 1 ? managedRoots[0] : null); this.targetLibraryRootId = - this.fileHandlingMode === "managed_copy" && defaultRoot - ? Number(defaultRoot.id) + this.fileHandlingMode === "managed_copy" && automaticRoot + ? Number(automaticRoot.id) : null; } if (this.sourceType === "mylar3") { - this.clearMylarPathPreview(false); this.scheduleMylarPathPreview(); } } catch (err) { @@ -3207,6 +3351,82 @@ function importSourceData(config) { } }, + registerReferenceRoot: async function (group) { + var rootPath = String((group && group.root_path) || "").trim(); + if (!rootPath || this.referenceRootRegistrationPath) { + return false; + } + var leaf = rootPath.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || "library"; + var rootPayload = { + name: ("Existing files - " + leaf + " - " + rootPath).slice(0, 255), + path: rootPath, + allow_referenced_registrations: true, + allow_managed_writes: false, + is_default_managed_destination: false, + }; + var request = async function (path) { + var response = await fetch(path, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": this.csrfToken(), + }, + body: JSON.stringify(rootPayload), + }); + var payload = await response.json().catch(function () { + return {}; + }); + if (!response.ok) { + var detail = payload.detail; + if (Array.isArray(detail)) { + detail = detail + .map(function (item) { + return item && item.msg ? item.msg : ""; + }) + .filter(Boolean) + .join(" "); + } + throw new Error( + (payload.error && payload.error.message) || + detail || + "Pullbox could not register this library root.", + ); + } + return payload; + }.bind(this); + + this.referenceRootRegistrationPath = rootPath; + this.referenceRootRegistrationErrorPath = ""; + this.referenceRootRegistrationError = ""; + try { + var preview = await request("/api/v1/config/library-roots/preview"); + if (!preview.can_create) { + throw new Error( + (preview.blocking_reasons || []).join(" ") || + "This path cannot be registered for existing files.", + ); + } + await request("/api/v1/config/library-roots"); + await this.refreshImportLibraryRoots(); + if (this.sourceType === "mylar3") { + await this.previewMylarPaths(); + } else { + await this.previewLayout(); + } + if (typeof showToast === "function") { + showToast({ message: "Existing-file library root registered.", level: "success" }); + } + return true; + } catch (err) { + this.referenceRootRegistrationErrorPath = rootPath; + this.referenceRootRegistrationError = + err && err.message ? err.message : "Pullbox could not register this library root."; + return false; + } finally { + this.referenceRootRegistrationPath = ""; + } + }, + referenceLibraryRoots: function () { return this.libraryRoots.filter(function (root) { return !!( @@ -3490,6 +3710,399 @@ function importSourceData(config) { }); }, + advancedAttentionItems: function () { + if (!this.sourceType || !this.sourcePath.trim()) { + return []; + } + var items = []; + if (this.requiresManagedDestination() && !this.hasSelectedManagedDestination()) { + var managedRoots = this.managedLibraryRoots(); + var preferredRoot = managedRoots.find(function (root) { + return !!root.is_default_managed_destination; + }); + if (!preferredRoot && managedRoots.length === 1) { + preferredRoot = managedRoots[0]; + } + items.push({ + key: "managed-destination", + code: "managed_destination_required", + blocks_import: true, + reason: "Choose a managed destination", + suggested_action: + "Open Where new files go and select an available, writable Pullbox library.", + root_path: "", + action: preferredRoot + ? { kind: "select_managed_destination", library_root_id: Number(preferredRoot.id) } + : null, + details: { + title: "Choose where Pullbox will manage files", + series_count: 0, + location_count: 0, + known_paths: managedRoots.map(function (root) { + return String(root.path || ""); + }), + steps: [ + "Open Where new files go.", + "Choose an available, writable Pullbox library.", + "Recheck the import issues before starting the scan.", + ], + }, + }); + } + if ( + this.sourceType === "filesystem" && + this.fileHandlingMode === "in_place" && + this.layoutPreview && + !this.layoutPreview.can_keep_in_place + ) { + var layoutWarnings = Array.isArray(this.layoutPreview.warnings) + ? this.layoutPreview.warnings + : []; + var sourceOutsideLibraryRoot = layoutWarnings.includes( + "source_outside_library_root", + ); + var otherLayoutWarnings = layoutWarnings.filter(function (warning) { + return warning !== "source_outside_library_root"; + }); + if (sourceOutsideLibraryRoot) { + items.push({ + key: "in-place-reference-root", + code: "source_outside_library_root", + blocks_import: true, + reason: "Register this folder for existing files", + suggested_action: + "Pullbox can add this exact folder as a reference-only library root. Files stay where they are and will not be renamed or modified.", + root_path: this.sourcePath.trim(), + action: { + kind: "register_reference_root", + root_path: this.sourcePath.trim(), + }, + details: { + title: "Register a reference-only library root", + series_count: 0, + location_count: Number(this.layoutPreview.files_considered || 0), + known_paths: [this.sourcePath.trim()], + steps: [ + "Register this exact folder as an enabled library root for existing-file references.", + "Keep managed writes disabled so Pullbox cannot rename or modify files in this folder.", + "Recheck the folder before starting the scan.", + ], + }, + }); + } + if ( + !sourceOutsideLibraryRoot || + otherLayoutWarnings.length || + Number(this.layoutPreview.files_outside_root || 0) > 0 || + !!this.layoutPreview.partial + ) { + items.push({ + key: "in-place-layout", + code: "in_place_layout_unavailable", + blocks_import: true, + reason: "Some files cannot be safely referenced in place", + suggested_action: + "Review the folder details and correct the listed source layout or access problem.", + root_path: this.sourcePath.trim(), + action: null, + details: { + title: "Review files that cannot be referenced", + series_count: 0, + location_count: Number(this.layoutPreview.files_considered || 0), + known_paths: [this.sourcePath.trim()], + steps: [ + "Review the folder-layout details for paths Pullbox could not safely reference.", + "Correct the source layout or access problem shown in the preview.", + "Recheck the import issues before starting the scan.", + ], + }, + }); + } + } + if (this.sourceType !== "mylar3") { + return items; + } + if (this.mylarPathPreviewError) { + items.push({ + key: "mylar-path-error", + code: "mylar_path_analysis_failed", + blocks_import: true, + reason: "Pullbox could not analyze the Mylar library paths", + suggested_action: this.mylarPathPreviewError, + root_path: this.sourcePath.trim(), + action: null, + details: { + title: "Mylar path analysis did not finish", + series_count: 0, + location_count: 0, + known_paths: [this.sourcePath.trim()], + steps: [ + "Confirm that the selected Mylar database still exists and is readable inside Pullbox.", + "Correct the mount or file permissions if the database is unavailable.", + "Run the path check again after access is restored.", + ], + }, + }); + return items; + } + if (!this.mylarPathPreview) { + return items; + } + var serverItems = Array.isArray(this.mylarPathPreview.attention_items) + ? this.mylarPathPreview.attention_items + : []; + serverItems.forEach( + function (item) { + var action = item && item.action ? item.action : null; + var acknowledged = action + ? this.acknowledgedAttentionActions[String(item.key || "")] + : null; + if ( + action && + action.kind === "acknowledge_unavailable" && + acknowledged === action.fingerprint + ) { + return; + } + items.push(item); + }.bind(this), + ); + return items; + }, + + advancedAttentionCount: function () { + return this.advancedAttentionItems().length; + }, + + advancedAttentionError: function (item) { + return this.attentionResolutionErrors[String((item && item.key) || "")] || ""; + }, + + restoreSkippedAttentionActions: function (preview) { + this.acknowledgedAttentionActions = {}; + var sourcePath = this.sourcePath.trim(); + var attentionFingerprint = String((preview && preview.attention_fingerprint) || ""); + if (!sourcePath || !attentionFingerprint) { + return; + } + try { + var raw = window.sessionStorage.getItem("pb-import-mylar-skips:v1"); + if (!raw) { + return; + } + var stored = JSON.parse(raw); + if ( + !stored || + stored.source_path !== sourcePath || + stored.attention_fingerprint !== attentionFingerprint + ) { + window.sessionStorage.removeItem("pb-import-mylar-skips:v1"); + return; + } + var available = {}; + (Array.isArray(preview.attention_items) ? preview.attention_items : []).forEach( + function (item) { + if ( + item && + item.action && + item.action.kind === "acknowledge_unavailable" && + item.action.fingerprint + ) { + available[String(item.key || "")] = String(item.action.fingerprint); + } + }, + ); + var restored = {}; + Object.keys(stored.actions || {}).forEach(function (key) { + var fingerprint = String(stored.actions[key] || ""); + if (fingerprint && available[key] === fingerprint) { + restored[key] = fingerprint; + } + }); + this.acknowledgedAttentionActions = restored; + if (!Object.keys(restored).length) { + window.sessionStorage.removeItem("pb-import-mylar-skips:v1"); + } + } catch (_err) { + this.acknowledgedAttentionActions = {}; + try { + window.sessionStorage.removeItem("pb-import-mylar-skips:v1"); + } catch (_storageErr) { + // Storage may be unavailable in privacy-restricted browser contexts. + } + } + }, + + persistSkippedAttentionActions: function () { + var sourcePath = this.sourcePath.trim(); + var attentionFingerprint = String( + (this.mylarPathPreview && this.mylarPathPreview.attention_fingerprint) || "", + ); + try { + if ( + !sourcePath || + !attentionFingerprint || + !Object.keys(this.acknowledgedAttentionActions).length + ) { + window.sessionStorage.removeItem("pb-import-mylar-skips:v1"); + return; + } + window.sessionStorage.setItem( + "pb-import-mylar-skips:v1", + JSON.stringify({ + source_path: sourcePath, + attention_fingerprint: attentionFingerprint, + actions: this.acknowledgedAttentionActions, + }), + ); + } catch (_err) { + // Skips still work for this page when browser storage is unavailable. + } + }, + + resolveAdvancedAttention: async function (item) { + var key = String((item && item.key) || ""); + var action = item && item.action ? item.action : null; + if (!key || !action || this.attentionResolvingKey) { + return; + } + this.attentionResolvingKey = key; + this.attentionResolutionErrors = Object.assign({}, this.attentionResolutionErrors, { + [key]: "", + }); + try { + switch (action.kind) { + case "select_managed_destination": + this.targetLibraryRootId = Number(action.library_root_id || 0) || null; + if (this.sourceType === "mylar3") { + await this.previewMylarPaths(); + } + break; + case "register_reference_root": + if (!(await this.registerReferenceRoot(action))) { + throw new Error( + this.referenceRootRegistrationError || + "Pullbox could not register this path for existing files.", + ); + } + break; + case "remove_ineffective_mapping": + var mappingIndex = this.mylarPathMappings.findIndex(function (mapping) { + return !!( + mapping && + mapping.stored_prefix === action.stored_prefix && + mapping.pullbox_prefix === action.pullbox_prefix + ); + }); + if (mappingIndex < 0) { + throw new Error("This mapping changed. Review the current path details."); + } + this.mylarPathAutoDetect = false; + this.mylarPathMappings.splice(mappingIndex, 1); + await this.previewMylarPaths(); + break; + default: + throw new Error("This issue does not have a supported automatic resolution."); + } + if (this.sourceType === "mylar3" && this.mylarPathPreviewError) { + throw new Error(this.mylarPathPreviewError); + } + if (this.sourceType === "filesystem" && this.layoutPreviewError) { + throw new Error(this.layoutPreviewError); + } + if ( + this.advancedAttentionItems().some(function (candidate) { + return String((candidate && candidate.key) || "") === key; + }) + ) { + throw new Error( + "Pullbox made the change, but this issue is still present after rechecking.", + ); + } + } catch (err) { + this.attentionResolutionErrors = Object.assign({}, this.attentionResolutionErrors, { + [key]: err && err.message ? err.message : "Pullbox could not resolve this issue.", + }); + } finally { + this.attentionResolvingKey = ""; + } + }, + + skipAdvancedAttention: async function (item) { + var key = String((item && item.key) || ""); + var action = item && item.action ? item.action : null; + if ( + !key || + !action || + action.kind !== "acknowledge_unavailable" || + this.attentionResolvingKey + ) { + return; + } + this.attentionResolvingKey = key; + this.attentionResolutionErrors = Object.assign({}, this.attentionResolutionErrors, { + [key]: "", + }); + try { + await this.previewMylarPaths(); + if (this.mylarPathPreviewError) { + throw new Error(this.mylarPathPreviewError); + } + var attentionItems = + this.mylarPathPreview && Array.isArray(this.mylarPathPreview.attention_items) + ? this.mylarPathPreview.attention_items + : []; + var confirmed = attentionItems.find(function (candidate) { + return !!( + candidate && + candidate.key === key && + candidate.action && + candidate.action.kind === "acknowledge_unavailable" && + candidate.action.fingerprint === action.fingerprint + ); + }); + if (!confirmed) { + throw new Error("The path evidence changed. Review the updated issue before continuing."); + } + this.acknowledgedAttentionActions = Object.assign( + {}, + this.acknowledgedAttentionActions, + { [key]: action.fingerprint }, + ); + this.persistSkippedAttentionActions(); + } catch (err) { + this.attentionResolutionErrors = Object.assign({}, this.attentionResolutionErrors, { + [key]: err && err.message ? err.message : "Pullbox could not skip this issue.", + }); + } finally { + this.attentionResolvingKey = ""; + } + }, + + openAdvancedAttentionDetails: function (item, returnFocus) { + this.attentionDetailsItem = item || null; + this.attentionDetailsReturnFocus = returnFocus || null; + this.attentionDetailsOpen = !!item; + var self = this; + this.$nextTick(function () { + if (self.$refs.attentionDetailsDialog) { + self.$refs.attentionDetailsDialog.focus(); + } + }); + }, + + closeAdvancedAttentionDetails: function () { + var returnFocus = this.attentionDetailsReturnFocus; + this.attentionDetailsOpen = false; + this.attentionDetailsItem = null; + this.attentionDetailsReturnFocus = null; + if (returnFocus && typeof returnFocus.focus === "function") { + this.$nextTick(function () { + returnFocus.focus(); + }); + } + }, + toggleStoryArcImport: function () { if (!this.storyArcImportRequested) { this.storyArcMaterializationRequested = false; @@ -3506,9 +4119,7 @@ function importSourceData(config) { if ( this.sourceType === "mylar3" && (!this.mylarPathPreview || - !(this.mylarPathPreview.can_confirm || this.mylarPathPreview.can_continue_with_unresolved) || - (this.mylarPathPreview.requires_unresolved_acknowledgement && !this.mylarUnresolvedConfirmed) || - (this.mylarPathPreview.requires_confirmation && !this.mylarPathConfirmed)) + !(this.mylarPathPreview.can_confirm || this.mylarPathPreview.can_continue_with_unresolved)) ) { return false; } @@ -3533,7 +4144,7 @@ function importSourceData(config) { return this.layoutChoice !== "custom" || !!this.customSeriesPathTemplate.trim(); }, - clearMylarPathPreview: function (resetMappings) { + cancelMylarPathPreviewRefresh: function (preserveInitialLoading) { if (this.mylarPathPreviewTimer) { clearTimeout(this.mylarPathPreviewTimer); this.mylarPathPreviewTimer = null; @@ -3543,11 +4154,17 @@ function importSourceData(config) { this.mylarPathPreviewController = null; } this.mylarPathPreviewRequestId += 1; + this.mylarPathPreviewLoading = !!preserveInitialLoading && !this.mylarPathPreview; + }, + + clearMylarPathPreview: function (resetMappings) { + this.cancelMylarPathPreviewRefresh(false); this.mylarPathPreview = null; - this.mylarPathPreviewLoading = false; this.mylarPathPreviewError = ""; this.mylarPathConfirmed = false; this.mylarUnresolvedConfirmed = false; + this.acknowledgedAttentionActions = {}; + this.attentionResolutionErrors = {}; if (resetMappings) { this.mylarPathMappings = []; this.mylarPathAutoDetect = true; @@ -3558,10 +4175,10 @@ function importSourceData(config) { if (this.sourceType !== "mylar3") { return; } - if (this.mylarPathPreviewTimer) { - clearTimeout(this.mylarPathPreviewTimer); - } + var preserveInitialLoading = this.mylarPathPreviewLoading && !this.mylarPathPreview; + this.cancelMylarPathPreviewRefresh(preserveInitialLoading); if (!this.canAnalyzeMylarPaths()) { + this.mylarPathPreviewLoading = false; return; } var self = this; @@ -3573,13 +4190,12 @@ function importSourceData(config) { mylarPathMappingChanged: function () { this.mylarPathAutoDetect = false; - this.clearMylarPathPreview(false); this.scheduleMylarPathPreview(); }, addMylarPathMapping: function () { this.mylarPathAutoDetect = false; - this.clearMylarPathPreview(false); + this.cancelMylarPathPreviewRefresh(false); this.mylarPathMappings.push({ id: ++this.mylarPathMappingId, stored_prefix: "", @@ -3589,35 +4205,59 @@ function importSourceData(config) { removeMylarPathMapping: function (index) { this.mylarPathAutoDetect = false; - this.clearMylarPathPreview(false); this.mylarPathMappings.splice(index, 1); this.scheduleMylarPathPreview(); }, resetAutomaticMylarPaths: function () { - this.clearMylarPathPreview(true); - this.previewMylarPaths(); + this.previewMylarPaths({ resetAutomatic: true }); + }, + + reconcileMylarPathMappings: function (mappings) { + var available = this.mylarPathMappings.slice(); + return (mappings || []).map( + function (mapping) { + var existingIndex = available.findIndex(function (candidate) { + return !!( + candidate && + candidate.stored_prefix === mapping.stored_prefix && + candidate.pullbox_prefix === mapping.pullbox_prefix + ); + }); + var existing = existingIndex >= 0 ? available.splice(existingIndex, 1)[0] : null; + return { + id: existing ? existing.id : ++this.mylarPathMappingId, + stored_prefix: mapping.stored_prefix, + pullbox_prefix: mapping.pullbox_prefix, + }; + }.bind(this), + ); }, - previewMylarPaths: async function () { - if (!this.canAnalyzeMylarPaths()) { + previewMylarPaths: async function (options) { + var opts = options || {}; + var resetAutomatic = opts.resetAutomatic === true; + if ( + this.sourceType !== "mylar3" || + !this.sourcePath.trim() || + (!resetAutomatic && !this.canAnalyzeMylarPaths()) + ) { return; } - if (this.mylarPathPreviewTimer) { - clearTimeout(this.mylarPathPreviewTimer); - this.mylarPathPreviewTimer = null; - } - if (this.mylarPathPreviewController) { - this.mylarPathPreviewController.abort(); - } - var requestId = ++this.mylarPathPreviewRequestId; + this.cancelMylarPathPreviewRefresh(false); + var requestId = this.mylarPathPreviewRequestId; var controller = new AbortController(); + var requestAutoDetect = resetAutomatic ? true : this.mylarPathAutoDetect; + var requestMappings = resetAutomatic + ? [] + : this.mylarPathMappings.map(function (mapping) { + return { + stored_prefix: String(mapping.stored_prefix || "").trim(), + pullbox_prefix: String(mapping.pullbox_prefix || "").trim(), + }; + }); this.mylarPathPreviewController = controller; this.mylarPathPreviewLoading = true; - this.mylarPathPreviewError = ""; - this.mylarPathPreview = null; - this.mylarPathConfirmed = false; - this.mylarUnresolvedConfirmed = false; try { var response = await fetch("/api/v1/import/mylar-path-preview", { method: "POST", @@ -3630,13 +4270,8 @@ function importSourceData(config) { source_path: this.sourcePath.trim(), source_type: "mylar3", file_handling_mode: this.fileHandlingMode, - auto_detect: this.mylarPathAutoDetect, - mappings: this.mylarPathMappings.map(function (mapping) { - return { - stored_prefix: String(mapping.stored_prefix || "").trim(), - pullbox_prefix: String(mapping.pullbox_prefix || "").trim(), - }; - }), + auto_detect: requestAutoDetect, + mappings: requestMappings, }), }); var payload = await response.json().catch(function () { @@ -3661,17 +4296,15 @@ function importSourceData(config) { if (requestId !== this.mylarPathPreviewRequestId) { return; } + this.mylarPathPreviewError = ""; + this.mylarPathAutoDetect = requestAutoDetect; + this.restoreSkippedAttentionActions(payload); this.mylarPathPreview = payload; - this.mylarPathMappings = (payload.mappings || []).map( - function (mapping) { - return { - id: ++this.mylarPathMappingId, - stored_prefix: mapping.stored_prefix, - pullbox_prefix: mapping.pullbox_prefix, - }; - }.bind(this), + this.mylarPathMappings = this.reconcileMylarPathMappings(payload.mappings); + this.mylarPathConfirmed = !!( + payload.can_confirm || payload.can_continue_with_unresolved ); - this.mylarPathConfirmed = !!(payload.can_confirm && !payload.requires_confirmation); + this.mylarUnresolvedConfirmed = !!payload.can_continue_with_unresolved; } catch (err) { if (err && err.name === "AbortError") { return; @@ -3724,7 +4357,6 @@ function importSourceData(config) { this.storyArcPreview = emptyStoryArcPreview(); this.storyArcPreviewLoading = false; this.storyArcPreviewError = ""; - this.storyArcImportRequested = false; this.storyArcMaterializationRequested = false; }, @@ -3741,7 +4373,6 @@ function importSourceData(config) { this.storyArcPreview = emptyStoryArcPreview(); this.storyArcPreviewLoading = false; this.storyArcPreviewError = ""; - this.storyArcImportRequested = false; this.storyArcMaterializationRequested = false; if (!this.canAnalyzeStoryArcs()) { return; @@ -3770,7 +4401,6 @@ function importSourceData(config) { this.storyArcPreviewLoading = true; this.storyArcPreviewError = ""; this.storyArcPreview = emptyStoryArcPreview(); - this.storyArcImportRequested = false; this.storyArcMaterializationRequested = false; try { var response = await fetch("/api/v1/import/story-arc-preview", { @@ -3980,14 +4610,16 @@ function importSourceData(config) { : null, future_layout_requested: this.futureLayoutRequested, future_root_policy: this.futureLayoutRequested ? this.futureRootPolicy : null, - story_arc_import_requested: this.storyArcImportRequested, - story_arc_materialization_requested: this.storyArcMaterializationRequested, + story_arc_import_requested: false, + story_arc_materialization_requested: false, mylar3_path_map: this.sourceType === "mylar3" ? Object.assign({}, this.mylarPathPreview.path_map || {}) : {}, mylar3_path_map_confirmed: this.sourceType === "mylar3", - mylar3_allow_unresolved_paths: this.sourceType === "mylar3" && this.mylarUnresolvedConfirmed, - mylar3_unresolved_fingerprint: this.sourceType === "mylar3" && this.mylarUnresolvedConfirmed + mylar3_allow_unresolved_paths: this.sourceType === "mylar3" && + !!this.mylarPathPreview.can_continue_with_unresolved, + mylar3_unresolved_fingerprint: this.sourceType === "mylar3" && + this.mylarPathPreview.can_continue_with_unresolved ? this.mylarPathPreview.unresolved_fingerprint : null, }), }); @@ -6720,8 +7352,6 @@ function importReviewData(configOrDefaultRootId, maybeJobId) { jobId: cfg.jobId, currentView: cfg.currentView || "series", conflictsCommitted: false, - showCancelModal: false, - cancelling: false, reviewToken: typeof cfg.reviewToken === "string" ? cfg.reviewToken : "", preferredRootId: cfg.preferredRootId === null || typeof cfg.preferredRootId === "undefined" @@ -7056,6 +7686,21 @@ function importReviewData(configOrDefaultRootId, maybeJobId) { if (!Number.isFinite(rootId)) { rootId = null; } + if (materialize && rootId === null) { + var rootTrigger = formElement.querySelector( + "[data-testid^='import-story-arc-policy-root-'] [data-dropdown-select-trigger]", + ); + if (rootTrigger) { + rootTrigger.focus(); + } + if (typeof showToast === "function") { + showToast({ + message: "Choose an approved library root before confirming this policy.", + level: "warning", + }); + } + return; + } var destinationElement = field("destination_root"); var symlinkElement = field("symlink_style"); var digestElement = field("expected_policy_digest"); @@ -7278,16 +7923,15 @@ function importReviewData(configOrDefaultRootId, maybeJobId) { } }, - selectAllImportable: async function () { - try { - var seriesResponse = await fetch("/api/v1/import/" + this.jobId + "/series/selection-bulk", { + setAllImportableSelection: async function (includeInImport) { + var seriesResponse = await fetch("/api/v1/import/" + this.jobId + "/series/selection-bulk", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": readCsrfTokenFromBody(), }, body: JSON.stringify({ - include_in_import: true, + include_in_import: includeInImport, imported_series_ids: [], }), }); @@ -7298,7 +7942,12 @@ function importReviewData(configOrDefaultRootId, maybeJobId) { .catch(function () { return {}; }); - throw new Error(seriesError.detail || "Failed to select matched series."); + throw new Error( + seriesError.detail || + (includeInImport + ? "Failed to select matched series." + : "Failed to clear series selection."), + ); } var fileResponse = await fetch("/api/v1/import/" + this.jobId + "/files/selection-bulk", { @@ -7308,7 +7957,7 @@ function importReviewData(configOrDefaultRootId, maybeJobId) { "X-CSRF-Token": readCsrfTokenFromBody(), }, body: JSON.stringify({ - include_in_import: true, + include_in_import: includeInImport, }), }); @@ -7318,9 +7967,18 @@ function importReviewData(configOrDefaultRootId, maybeJobId) { .catch(function () { return {}; }); - throw new Error(fileError.detail || "Failed to select in-library files."); + throw new Error( + fileError.detail || + (includeInImport + ? "Failed to select in-library files." + : "Failed to clear in-library file selection."), + ); } + }, + selectAllImportable: async function () { + try { + await this.setAllImportableSelection(true); await this.refreshReviewSummary(); await this.refreshSeriesReview(); } catch (err) { @@ -7337,47 +7995,7 @@ function importReviewData(configOrDefaultRootId, maybeJobId) { deselectAllImportable: async function () { try { - var seriesResponse = await fetch("/api/v1/import/" + this.jobId + "/series/selection-bulk", { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-CSRF-Token": readCsrfTokenFromBody(), - }, - body: JSON.stringify({ - include_in_import: false, - imported_series_ids: [], - }), - }); - - if (!seriesResponse.ok) { - var seriesError = await seriesResponse - .json() - .catch(function () { - return {}; - }); - throw new Error(seriesError.detail || "Failed to clear series selection."); - } - - var fileResponse = await fetch("/api/v1/import/" + this.jobId + "/files/selection-bulk", { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-CSRF-Token": readCsrfTokenFromBody(), - }, - body: JSON.stringify({ - include_in_import: false, - }), - }); - - if (!fileResponse.ok) { - var fileError = await fileResponse - .json() - .catch(function () { - return {}; - }); - throw new Error(fileError.detail || "Failed to clear in-library file selection."); - } - + await this.setAllImportableSelection(false); await this.refreshReviewSummary(); await this.refreshSeriesReview(); } catch (err) { @@ -7562,19 +8180,6 @@ function importReviewData(configOrDefaultRootId, maybeJobId) { } }, - cancelImport: async function () { - this.cancelling = true; - try { - await fetch("/api/v1/import/" + this.jobId, { - method: "DELETE", - headers: { "X-CSRF-Token": readCsrfTokenFromBody() }, - }); - purgeImportClientState(this.jobId); - } finally { - window.location.replace("/import"); - } - }, - syncSelectionUi: function () { this.syncSelectionSummaryUi(); }, @@ -7642,7 +8247,36 @@ function importResultsData(config) { safetyRetryingFileId: null, cleanupRunningAction: "", cleanupError: "", + cleanLibraryTargetRootId: cfg.defaultCleanLibraryRootId || "", + cleanLibraryRootPolicies: Array.isArray(cfg.cleanLibraryRootPolicies) + ? cfg.cleanLibraryRootPolicies + : [], + cleanLibraryJobId: + cfg.activeCleanLibraryJob && cfg.activeCleanLibraryJob.id + ? Number(cfg.activeCleanLibraryJob.id) + : null, + cleanLibraryStatus: + cfg.activeCleanLibraryJob && cfg.activeCleanLibraryJob.status + ? String(cfg.activeCleanLibraryJob.status).toLowerCase() + : "", + cleanLibraryProgress: + cfg.activeCleanLibraryJob && cfg.activeCleanLibraryJob.progress_snapshot + ? Math.max( + 0, + Math.min(100, Number(cfg.activeCleanLibraryJob.progress_snapshot.progress) || 0) + ) + : 0, + cleanLibraryProgressMessage: + cfg.activeCleanLibraryJob && cfg.activeCleanLibraryJob.progress_snapshot + ? String(cfg.activeCleanLibraryJob.progress_snapshot.message || "") + : "", + cleanLibraryProgressPollTimer: null, + cleanLibraryRunning: false, + cleanLibraryError: "", + cleanLibraryReturnFocus: null, retryError: "", + refreshUrl: cfg.refreshUrl || "/import/" + (cfg.jobId || "") + "/results-partial", + refreshTarget: cfg.refreshTarget || "[data-testid='import-collection-results']", toggleFailedSeries: function () { this.showFailedSeries = !this.showFailedSeries; @@ -7652,6 +8286,202 @@ function importResultsData(config) { this.showFailedFiles = !this.showFailedFiles; }, + initializeCleanLibraryModal: function () { + this.cleanLibraryReturnFocus = document.activeElement; + var self = this; + this.$nextTick(function () { + window.requestAnimationFrame(function () { + var target = self.$refs.cleanLibraryInitialFocus || self.$refs.cleanLibraryDialog; + if (target && typeof target.focus === "function") { + target.focus({ preventScroll: true }); + } + }); + }); + if (this.cleanLibraryJobId) { + this.refreshCleanLibraryProgress(); + } + }, + + closeCleanLibraryModal: function () { + this.clearCleanLibraryProgressPoll(); + var returnFocus = this.cleanLibraryReturnFocus; + this.cleanLibraryReturnFocus = null; + this.open = false; + window.setTimeout(function () { + closeImportCvSearchModal(); + if ( + returnFocus && + document.contains(returnFocus) && + typeof returnFocus.focus === "function" + ) { + returnFocus.focus({ preventScroll: true }); + } + }, 0); + }, + + selectedCleanLibraryPolicy: function () { + var targetRootId = Number(this.cleanLibraryTargetRootId); + return ( + this.cleanLibraryRootPolicies.find(function (root) { + return Number(root && root.id) === targetRootId; + }) || {} + ); + }, + + cleanLibraryPolicyLabel: function (key) { + var policy = this.selectedCleanLibraryPolicy(); + var enabled = policy[key] === true; + var labels = { + rename_on_import: enabled + ? "Use the selected root's naming templates" + : "Keep current file and folder names", + normalize_to_cbz: enabled + ? "Normalize supported archives to CBZ" + : "Keep each supported archive format", + update_comicinfo: enabled + ? "Write matched metadata to ComicInfo.xml" + : "Keep embedded ComicInfo.xml unchanged", + skip_existing: enabled + ? "Skip issues already in the destination" + : "Allow another managed copy when valid", + }; + return labels[key] || "Use current import settings"; + }, + + clearCleanLibraryProgressPoll: function () { + if (this.cleanLibraryProgressPollTimer) { + window.clearTimeout(this.cleanLibraryProgressPollTimer); + this.cleanLibraryProgressPollTimer = null; + } + }, + + cleanLibraryIsTerminal: function () { + return ["completed", "failed", "cancelled", "rolled_back"].indexOf( + this.cleanLibraryStatus + ) !== -1; + }, + + cleanLibraryStatusLabel: function () { + var labels = { + importing: "Building", + pausing: "Pausing", + paused: "Paused", + stalled: "Needs attention", + cancelling: "Cancelling", + rolling_back: "Rolling back", + completed: "Complete", + failed: "Failed", + cancelled: "Cancelled", + rolled_back: "Rolled back", + }; + return labels[this.cleanLibraryStatus] || "Queued"; + }, + + cleanLibraryJobUrl: function () { + var step = this.cleanLibraryStatus === "completed" ? 5 : 4; + return ( + "/import?tab=collection&resume_job_id=" + + encodeURIComponent(this.cleanLibraryJobId || "") + + "&resume_step=" + + step + ); + }, + + scheduleCleanLibraryProgressPoll: function () { + var self = this; + self.clearCleanLibraryProgressPoll(); + if (!self.cleanLibraryJobId || self.cleanLibraryIsTerminal() || !self.open) { + return; + } + self.cleanLibraryProgressPollTimer = window.setTimeout(function () { + self.cleanLibraryProgressPollTimer = null; + self.refreshCleanLibraryProgress(); + }, 1000); + }, + + refreshCleanLibraryProgress: async function () { + if (!this.cleanLibraryJobId) { + return; + } + try { + var response = await fetch( + "/import/" + this.cleanLibraryJobId + "/progress-state", + { headers: { Accept: "application/json" } } + ); + if (!response.ok) { + throw new Error("Could not refresh clean-library progress."); + } + var progress = await response.json(); + this.cleanLibraryStatus = String(progress.status || this.cleanLibraryStatus).toLowerCase(); + var nextProgress = Number(progress.progress); + if (Number.isFinite(nextProgress)) { + this.cleanLibraryProgress = Math.max( + this.cleanLibraryProgress, + Math.max(0, Math.min(100, Math.round(nextProgress))) + ); + } + if (this.cleanLibraryStatus === "completed") { + this.cleanLibraryProgress = 100; + } + this.cleanLibraryProgressMessage = String( + progress.message || this.cleanLibraryProgressMessage || "Building the clean library..." + ); + if (this.cleanLibraryStatus === "failed" && progress.error_message) { + this.cleanLibraryError = String(progress.error_message); + } + } catch (err) { + this.cleanLibraryError = + err && err.message ? err.message : "Could not refresh clean-library progress."; + } finally { + this.scheduleCleanLibraryProgressPoll(); + } + }, + + trapCleanLibraryModalFocus: function (event) { + if (!event || !this.open || !this.$refs.cleanLibraryDialog) { + return; + } + var dialog = this.$refs.cleanLibraryDialog; + var panel = event.target && event.target.closest + ? event.target.closest("[data-dropdown-select-panel]") + : null; + var focusable = Array.prototype.filter.call( + dialog.querySelectorAll( + 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), ' + + 'select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + ), + function (element) { + return element.getClientRects().length > 0; + } + ); + if (!focusable.length) { + event.preventDefault(); + dialog.focus({ preventScroll: true }); + return; + } + if (panel) { + event.preventDefault(); + var labelledBy = panel.getAttribute("aria-labelledby"); + var trigger = labelledBy ? dialog.querySelector("#" + CSS.escape(labelledBy)) : null; + var triggerIndex = trigger ? focusable.indexOf(trigger) : -1; + var destination = event.shiftKey + ? trigger || focusable[0] + : focusable[Math.min(focusable.length - 1, triggerIndex + 1)]; + destination.focus({ preventScroll: true }); + return; + } + var active = document.activeElement; + var first = focusable[0]; + var last = focusable[focusable.length - 1]; + if (event.shiftKey && (active === first || !dialog.contains(active))) { + event.preventDefault(); + last.focus({ preventScroll: true }); + } else if (!event.shiftKey && (active === last || !dialog.contains(active))) { + event.preventDefault(); + first.focus({ preventScroll: true }); + } + }, + retryFailed: async function () { if (!this.jobId || this.retrying) { return; @@ -7751,8 +8581,8 @@ function importResultsData(config) { }, refreshResults: function () { - var url = "/import/" + this.jobId + "/results-partial"; - var target = "[data-testid='import-collection-results']"; + var url = this.refreshUrl; + var target = this.refreshTarget; if (window.htmx && document.querySelector(target)) { window.htmx.ajax("GET", url, { target: target, swap: "outerHTML" }); return; @@ -7844,6 +8674,64 @@ function importResultsData(config) { } }, + buildCleanLibrary: async function () { + if (!this.jobId || !this.cleanLibraryTargetRootId || this.cleanLibraryRunning) { + return; + } + this.cleanLibraryRunning = true; + this.cleanLibraryError = ""; + try { + var targetRootId = Number(this.cleanLibraryTargetRootId); + var previewResponse = await fetch( + "/api/v1/import/" + + this.jobId + + "/clean-library/preview?target_root_id=" + + encodeURIComponent(targetRootId) + ); + var preview = await previewResponse.json().catch(function () { + return {}; + }); + if (!previewResponse.ok) { + throw new Error(preview.detail || "Could not preview the clean library."); + } + var response = await fetch( + "/api/v1/import/" + this.jobId + "/clean-library", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": readCsrfTokenFromBody(), + }, + body: JSON.stringify({ + target_root_id: targetRootId, + preview_token: preview.preview_token, + confirmation: "BUILD CLEAN LIBRARY", + }), + } + ); + var result = await response.json().catch(function () { + return {}; + }); + if (!response.ok) { + throw new Error(result.detail || "Could not start the clean-library build."); + } + this.cleanLibraryJobId = Number(result.job_id); + this.cleanLibraryStatus = "importing"; + this.cleanLibraryProgress = 0; + this.cleanLibraryProgressMessage = "Preparing the clean-library work plan..."; + await this.refreshCleanLibraryProgress(); + } catch (err) { + var message = + err && err.message ? err.message : "Could not start the clean-library build."; + this.cleanLibraryError = message; + if (typeof showToast === "function") { + showToast({ message: message, level: "error" }); + } + } finally { + this.cleanLibraryRunning = false; + } + }, + archiveImport: async function () { if (!this.jobId || this.archiving) { return; @@ -11991,13 +12879,15 @@ function utilitiesPermissionsPage(config) { }, syncFooterDock: function () { + var detail = { + mode: this.runModeLabel(), + scope: this.scopeLabel(), + targets: this.targetsLabel(), + }; + window.pullboxUtilitiesPermissionsFooterState = detail; window.dispatchEvent( new CustomEvent("utilities:permissions-footer", { - detail: { - mode: this.runModeLabel(), - scope: this.scopeLabel(), - targets: this.targetsLabel(), - }, + detail: detail, }) ); }, @@ -13319,33 +14209,36 @@ function downloadsPage(config) { function dropdownSelectData(config) { var cfg = config || {}; - var rawOptions = Array.isArray(cfg.options) ? cfg.options : []; - var normalizedOptions = rawOptions.map(function (option) { - if (Array.isArray(option)) { + function normalizeOptions(options) { + return (Array.isArray(options) ? options : []).map(function (option) { + if (Array.isArray(option)) { + return { + value: option[0] == null ? "" : String(option[0]), + label: + option[1] == null + ? option[0] == null + ? "" + : String(option[0]) + : String(option[1]), + disabled: false, + }; + } + + var item = option || {}; return { - value: option[0] == null ? "" : String(option[0]), + value: item.value == null ? "" : String(item.value), label: - option[1] == null - ? option[0] == null + item.label == null + ? item.value == null ? "" - : String(option[0]) - : String(option[1]), - disabled: false, + : String(item.value) + : String(item.label), + disabled: Boolean(item.disabled), }; - } + }); + } - var item = option || {}; - return { - value: item.value == null ? "" : String(item.value), - label: - item.label == null - ? item.value == null - ? "" - : String(item.value) - : String(item.label), - disabled: Boolean(item.disabled), - }; - }); + var normalizedOptions = normalizeOptions(cfg.options); function findIndexByValue(value) { var normalizedValue = value == null ? "" : String(value); @@ -13452,6 +14345,44 @@ function dropdownSelectData(config) { this.applyFitWidth(); }, + syncExternalOptions: function (nextOptions) { + var normalizedNext = normalizeOptions(nextOptions); + var unchanged = + normalizedNext.length === normalizedOptions.length && + normalizedNext.every(function (option, index) { + var current = normalizedOptions[index]; + return !!( + current && + current.value === option.value && + current.label === option.label && + current.disabled === option.disabled + ); + }); + if (unchanged) { + return; + } + + normalizedOptions = normalizedNext; + this.options = normalizedNext; + this.syncFromValue(); + this.syncInput(); + this.applyFitWidth(); + if (this.open) { + this.schedulePanelPositionUpdate(); + } + }, + + syncExternalDisabled: function (nextDisabled) { + var disabled = Boolean(nextDisabled); + if (disabled === this.disabled) { + return; + } + this.disabled = disabled; + if (disabled && this.open) { + this.close(false); + } + }, + runChangeExpression: function () { if (!this.changeExpression || !this.$el) { return; @@ -16045,7 +16976,7 @@ function orphanedSeriesPage(config) { searchCv: function (importedSeriesId, query) { if (typeof htmx === "undefined") { - window.location.assign("/import?tab=unmatched"); + window.location.assign("/import?tab=follow-up"); return; } @@ -16058,7 +16989,7 @@ function orphanedSeriesPage(config) { openRecovery: function (importedSeriesId) { if (typeof htmx === "undefined") { - window.location.assign("/import?tab=unmatched"); + window.location.assign("/import?tab=follow-up"); return; } @@ -16158,7 +17089,7 @@ function orphanedSeriesPage(config) { self.dispatchToast( "Identified " + ((data && data.cv_title) || "series") + - ". Finish the file recovery to remove it from Unmatched.", + ". Finish the file recovery to remove it from Follow-up.", "success" ); self.refreshResults(); @@ -16698,7 +17629,7 @@ function orphanedRecoveryModal(config) { reload: function () { if (typeof htmx === "undefined") { - window.location.assign("/import?tab=unmatched"); + window.location.assign("/import?tab=follow-up"); return; } htmx.ajax("GET", "/import/orphaned/" + cfg.importedSeriesId + "/recovery", { @@ -20050,6 +20981,24 @@ function prepareAlpineSwap(detail, target) { return true; } +function initializePreparedAlpineSwap(detail) { + if (!detail || !detail.xhr || !_htmxRequestsNeedingAlpineInit.has(detail.xhr)) { + return false; + } + + _htmxRequestsNeedingAlpineInit.delete(detail.xhr); + var target = resolveHtmxLiveTarget(detail.target); + if (!target || target.isConnected === false) { + return false; + } + + if (window.Alpine) { + Alpine.initTree(target); + } + seedSearchFieldStates(target); + return true; +} + function _purgeDetailHistoryRestoreEntry(pathname, search) { var normalizedPath = normalizePath(pathname || window.location.pathname); if (!_isDetailHistoryRestorePath(normalizedPath)) { @@ -20190,21 +21139,9 @@ document.body.addEventListener("htmx:beforeSwap", function (e) { }); document.addEventListener("htmx:afterRequest", function (e) { - var detail = e.detail || {}; - if (!detail.xhr || !_htmxRequestsNeedingAlpineInit.has(detail.xhr)) { - return; - } - - _htmxRequestsNeedingAlpineInit.delete(detail.xhr); - var target = resolveHtmxLiveTarget(detail.target); - if (!target || target.isConnected === false) { - return; - } - - if (window.Alpine) { - Alpine.initTree(target); - } - seedSearchFieldStates(target); + // afterSwap normally initializes the replacement. Keep this as a fallback + // for HTMX request paths that complete without dispatching afterSwap. + initializePreparedAlpineSwap(e.detail || {}); }); // After a shell content swap, update the header title from the full-page response. @@ -20247,6 +21184,17 @@ function _syncFooterDockFromResponse(responseText) { } document.addEventListener("htmx:afterSwap", function (e) { + initializePreparedAlpineSwap(e.detail || {}); + var swappedTarget = resolveHtmxLiveTarget(e.detail.target); + if ( + swappedTarget && + swappedTarget.id === "import-step-review-shell" && + pendingImportReviewViewportState + ) { + // Restore disclosure state in the swap task so a collapsed frame never paints. + restoreImportReviewExpansionState(pendingImportReviewViewportState, swappedTarget); + } + if (e.detail.target && e.detail.target.id === "content" && e.detail.xhr) { _startContentSwapEnter(); _syncFooterDockFromResponse(e.detail.xhr.responseText); @@ -21253,11 +22201,6 @@ document.addEventListener("htmx:afterSettle", function (e) { window.htmx.process(settledTarget); } - // Re-initialize Alpine components in the primary HTMX swap target. - if (window.Alpine && settledTarget) { - Alpine.initTree(settledTarget); - } - if ( settledTarget && (settledTarget.id === "import-step-review" || @@ -21289,6 +22232,15 @@ document.addEventListener("htmx:afterSettle", function (e) { } } + if ( + settledTarget && + settledTarget.id === "import-step-review-shell" && + pendingImportReviewViewportState + ) { + restoreImportReviewViewport(pendingImportReviewViewportState, settledTarget); + pendingImportReviewViewportState = null; + } + if (settledTarget) { seedSearchFieldStates(settledTarget); } diff --git a/src/pullbox/ui/templates/components/dropdown_select.html b/src/pullbox/ui/templates/components/dropdown_select.html index 8bbff32..3bc643f 100644 --- a/src/pullbox/ui/templates/components/dropdown_select.html +++ b/src/pullbox/ui/templates/components/dropdown_select.html @@ -47,6 +47,7 @@ local_model="", local_on_change="", options_expression="", + disabled_expression="", on_change="" ) %} {%- set normalized_size = namespace(value=size) -%} @@ -85,13 +86,15 @@ x-data='dropdownSelectData({ value: {{ value|default("", true)|string|tojson }}, options: {% if options_expression %}{{ options_expression }}{% else %}{{ options|tojson }}{% endif %}, - disabled: {{ "true" if disabled else "false" }}, + disabled: {% if disabled_expression %}Boolean({{ disabled_expression }}){% else %}{{ "true" if disabled else "false" }}{% endif %}, fitWidth: {{ "true" if fit_width else "false" }}, wrapOptions: {{ "true" if wrap_options else "false" }}, changeExpression: {{ change_handler.value|trim|tojson }} })' x-init="init()" - {% if local_model %}x-effect="syncExternalValue({{ local_model }})"{% endif %} + {% if options_expression or local_model or disabled_expression %} + x-effect="{% if options_expression %}syncExternalOptions({{ options_expression }});{% endif %}{% if local_model %} syncExternalValue({{ local_model }});{% endif %}{% if disabled_expression %} syncExternalDisabled({{ disabled_expression }});{% endif %}" + {% endif %} x-on:keydown.escape.prevent.stop="close()" x-on:keydown.tab="close(false)" x-bind:data-dropdown-value="value" @@ -140,6 +143,7 @@ x-on:keydown.arrow-down.prevent="openAndFocus(1)" x-on:keydown.arrow-up.prevent="openAndFocus(-1)" x-bind:aria-expanded="open ? 'true' : 'false'" + x-bind:aria-disabled="disabled ? 'true' : 'false'" x-bind:disabled="disabled" aria-haspopup="listbox" {% if invalid %}aria-invalid="true"{% endif %} diff --git a/src/pullbox/ui/templates/pages/matching_queue.html b/src/pullbox/ui/templates/pages/matching_queue.html index a4a7682..2d49372 100644 --- a/src/pullbox/ui/templates/pages/matching_queue.html +++ b/src/pullbox/ui/templates/pages/matching_queue.html @@ -195,7 +195,7 @@

Everything i

diff --git a/src/pullbox/ui/templates/partials/import_clean_library_modal.html b/src/pullbox/ui/templates/partials/import_clean_library_modal.html new file mode 100644 index 0000000..98b40b9 --- /dev/null +++ b/src/pullbox/ui/templates/partials/import_clean_library_modal.html @@ -0,0 +1,173 @@ +{% from "components/dropdown_select.html" import dropdown_select %} + + diff --git a/src/pullbox/ui/templates/partials/import_collection_footer_dock.html b/src/pullbox/ui/templates/partials/import_collection_footer_dock.html index 1a6f065..1ef60fc 100644 --- a/src/pullbox/ui/templates/partials/import_collection_footer_dock.html +++ b/src/pullbox/ui/templates/partials/import_collection_footer_dock.html @@ -4,7 +4,7 @@ step: {{ resume_step or 1 }}, resumeJobId: {{ resume_job_id or 'null' }}, recentJobs: {{ recent_jobs|default([])|length }}, - unmatched: {{ unmatched_count|default(0) }}, + followUp: {{ follow_up_count|default(0) }}, libraryRoots: {{ library_roots|default([])|length }}, progressSnapshot: {{ resume_progress_snapshot|default({})|tojson|forceescape }} })" diff --git a/src/pullbox/ui/templates/partials/import_completed_cleanup_files.html b/src/pullbox/ui/templates/partials/import_completed_cleanup_files.html index 66e15da..5fa7529 100644 --- a/src/pullbox/ui/templates/partials/import_completed_cleanup_files.html +++ b/src/pullbox/ui/templates/partials/import_completed_cleanup_files.html @@ -10,11 +10,29 @@ {% for item in cleanup_page.items %} {% set diagnostics = item.diagnostics or {} %} {% set safety_block = diagnostics.get('safety_block', {}) %} + {% set source_metadata = diagnostics.get('source_metadata', {}) %} + {% set comicinfo = source_metadata.get('comicinfo', {}) %}

{{ item.file_name }}

- {{ safety_block.get('reason') or item.error_message or 'Pullbox found a safe completed-import follow-up for this file.' }} + {% if cleanup_action == 'resolve_mixed_folder_files' %} + Embedded ComicInfo identifies this as + {{ comicinfo.get('series') }} #{{ comicinfo.get('number') }}. + Pullbox will correct its library assignment without changing the source file. + {% else %} + {{ safety_block.get('reason') or item.error_message or 'Pullbox found a safe completed-import follow-up for this file.' }} + {% endif %}

+ {% if cleanup_action == 'skip_probable_covers' %} + + {% endif %}
{% endfor %} diff --git a/src/pullbox/ui/templates/partials/import_content.html b/src/pullbox/ui/templates/partials/import_content.html index 20f0927..33e9bea 100644 --- a/src/pullbox/ui/templates/partials/import_content.html +++ b/src/pullbox/ui/templates/partials/import_content.html @@ -6,7 +6,7 @@ > {% if tab == "history" %} {% include "partials/import_history_panel.html" %} - {% elif tab == "unmatched" %} + {% elif tab == "follow-up" %} {% include "partials/import_orphaned_panel.html" %} {% else %} {% include "partials/import_collection_panel.html" %} diff --git a/src/pullbox/ui/templates/partials/import_follow_up_job.html b/src/pullbox/ui/templates/partials/import_follow_up_job.html new file mode 100644 index 0000000..80ce826 --- /dev/null +++ b/src/pullbox/ui/templates/partials/import_follow_up_job.html @@ -0,0 +1,48 @@ +
+
+
+
+

Import #{{ selected_follow_up_job.id }}

+

{{ selected_follow_up_job.source_path }}

+

Resolve the remaining matching, recovery, and cleanup groups from this import. Completed work disappears from this page automatically.

+
+ +
+
+ + {% if orphaned_count|default(0) > 0 %} +
+
+
+

Matching

+

Resolve series and issue matches

+

Only unresolved records from this import are shown.

+
+ {{ orphaned_count }} +
+
+ {% include "partials/import_orphaned_table.html" %} +
+
+ {% endif %} + + {% set job = selected_follow_up_job %} + {% set follow_up_workspace = true %} + {% include "partials/import_results.html" %} + + {% if follow_up_group_count|default(0) == 0 %} +
+

This import is resolved

+

There is no remaining follow-up for this import. Return to all follow-up to continue.

+
+ {% endif %} +
diff --git a/src/pullbox/ui/templates/partials/import_follow_up_jobs.html b/src/pullbox/ui/templates/partials/import_follow_up_jobs.html new file mode 100644 index 0000000..06da30d --- /dev/null +++ b/src/pullbox/ui/templates/partials/import_follow_up_jobs.html @@ -0,0 +1,93 @@ +{% from "components/dropdown_select.html" import dropdown_select %} + +
+
+ +
+
+
+
+ Show + {{ dropdown_select( + name="view", + value=view, + size="sm", + options=[("all", "Needs attention"), ("dismissed", "Dismissed")], + root_testid="import-follow-up-view", + hx_get="/import", + hx_target="#import-orphaned-results", + hx_swap="outerHTML", + hx_push_url="true", + hx_include="#import-follow-up-filter-form", + class_name="series-registry-dropdown", + fit_width=true + ) }} +
+
+
+
+
+ + {% if follow_up_jobs %} + + + + + + + + + + + + {% for follow_up_job in follow_up_jobs %} + + + + + + + + {% endfor %} + +
Import sourceType
+ + {{ follow_up_job.source_path }} + Import #{{ follow_up_job.id }} + + + + + +
+ {% else %} +
+ +

No import follow-up

+

Every current import is resolved. New matching, recovery, or cleanup work will appear here.

+
+ {% endif %} +
diff --git a/src/pullbox/ui/templates/partials/import_footer_dock.html b/src/pullbox/ui/templates/partials/import_footer_dock.html index f2b7d14..5a30c07 100644 --- a/src/pullbox/ui/templates/partials/import_footer_dock.html +++ b/src/pullbox/ui/templates/partials/import_footer_dock.html @@ -5,16 +5,18 @@ {% endif %}
- {% if tab == "unmatched" %} - {% set _base_url = "/import?tab=unmatched&view=" ~ (view|default("all")) %} + {% if tab == "follow-up" %} + {% set _base_url = "/import?tab=follow-up&view=" ~ (view|default("all")) %} + {% if selected_follow_up_job %}{% set _base_url = _base_url ~ "&job_id=" ~ selected_follow_up_job.id %}{% endif %} + {% set _visible_count = items|default([])|length if selected_follow_up_job or view|default("all") == "dismissed" else follow_up_jobs|default([])|length %} {{ page_dock( page=page, total_pages=total_pages, base_url=_base_url, status_items=[ - {"led": "amber" if orphaned_count|default(0) > 0 else "off", "label": "unmatched", "value": orphaned_count|default(0)|string}, + {"led": "amber" if follow_up_job_count|default(0) > 0 else "off", "label": "imports", "value": follow_up_job_count|default(0)|string}, {"label": "dismissed", "value": dismissed_count|default(0)|string}, - {"label": "visible", "value": (items|default([])|length)|string}, + {"label": "visible", "value": _visible_count|string}, ], hx_target="#import-orphaned-results", hx_swap="outerHTML", diff --git a/src/pullbox/ui/templates/partials/import_header.html b/src/pullbox/ui/templates/partials/import_header.html index 26dacea..f13a45a 100644 --- a/src/pullbox/ui/templates/partials/import_header.html +++ b/src/pullbox/ui/templates/partials/import_header.html @@ -27,18 +27,18 @@ "stroke_offset": (gauge_circumference * (1 - (history_stats['results_ready'] / gauge_total)))|round(1) }, ] %} -{% elif tab == "unmatched" %} - {% set unmatched_total = orphaned_count|default(0) %} +{% elif tab == "follow-up" %} + {% set follow_up_total = follow_up_job_count|default(0) %} {% set dismissed_total = dismissed_count|default(0) %} - {% set visible_total = items|default([])|length %} - {% set gauge_total = unmatched_total + dismissed_total + visible_total if (unmatched_total + dismissed_total + visible_total) > 0 else 1 %} + {% set visible_total = items|default([])|length if selected_follow_up_job else follow_up_jobs|default([])|length %} + {% set gauge_total = follow_up_total + dismissed_total if (follow_up_total + dismissed_total) > 0 else 1 %} {% set header_gauges = [ { - "key": "unmatched", - "label": "Unmatched", - "value": unmatched_total, + "key": "imports", + "label": "Imports", + "value": follow_up_job_count|default(0), "tone": "warning", - "stroke_offset": (gauge_circumference * (1 - (unmatched_total / gauge_total)))|round(1) + "stroke_offset": (gauge_circumference * (1 - (follow_up_total / gauge_total)))|round(1) }, { "key": "dismissed", @@ -58,8 +58,8 @@ {% else %} {% set active_total = 1 if resume_job_id else 0 %} {% set recent_total = recent_jobs|default([])|length %} - {% set unmatched_total = unmatched_count|default(0) %} - {% set gauge_total = active_total + recent_total + unmatched_total if (active_total + recent_total + unmatched_total) > 0 else 1 %} + {% set follow_up_total = follow_up_count|default(0) %} + {% set gauge_total = active_total + recent_total + follow_up_total if (active_total + recent_total + follow_up_total) > 0 else 1 %} {% set header_gauges = [ { "key": "active", @@ -76,11 +76,11 @@ "stroke_offset": (gauge_circumference * (1 - (recent_total / gauge_total)))|round(1) }, { - "key": "unmatched", - "label": "Unmatched", - "value": unmatched_total, + "key": "follow-up", + "label": "Follow-up", + "value": follow_up_total, "tone": "warning", - "stroke_offset": (gauge_circumference * (1 - (unmatched_total / gauge_total)))|round(1) + "stroke_offset": (gauge_circumference * (1 - (follow_up_total / gauge_total)))|round(1) }, ] %} {% endif %} @@ -92,8 +92,8 @@

{% if tab == "history" %} IMPORT HISTORY - {% elif tab == "unmatched" %} - UNMATCHED SERIES + {% elif tab == "follow-up" %} + IMPORT FOLLOW-UP {% else %} COLLECTION IMPORT {% endif %} @@ -101,8 +101,12 @@

{% if tab == "history" %} {{ total_jobs|default(0) }} jobs · {{ history_stats['active'] }} active · {{ history_stats['resumable'] }} resumable · {{ history_stats['results_ready'] }} results ready - {% elif tab == "unmatched" %} - {{ orphaned_count|default(0) }} unmatched · {{ dismissed_count|default(0) }} dismissed + {% elif tab == "follow-up" %} + {% if selected_follow_up_job %} + Import #{{ selected_follow_up_job.id }} · {{ follow_up_group_count|default(0) }} actionable group{{ '' if follow_up_group_count|default(0) == 1 else 's' }} + {% else %} + {{ follow_up_job_count|default(0) }} import{{ '' if follow_up_job_count|default(0) == 1 else 's' }} need attention · {{ dismissed_count|default(0) }} dismissed + {% endif %} {% else %} Scan · match · review · import {% endif %} diff --git a/src/pullbox/ui/templates/partials/import_history_panel.html b/src/pullbox/ui/templates/partials/import_history_panel.html index 40f5d87..62b89f0 100644 --- a/src/pullbox/ui/templates/partials/import_history_panel.html +++ b/src/pullbox/ui/templates/partials/import_history_panel.html @@ -1,11 +1,13 @@ {% from "components/search_field.html" import search_field %}

{% include "partials/import_history_delete_modal.html" %} +
{{ job.source_path }} + {% if job.removed_library_root_snapshot %} + + Original root removed: {{ job.removed_library_root_snapshot.name }}. + Start a new import to choose a destination. + + {% endif %} @@ -170,6 +176,24 @@ {% endif %} + {% if job.id in clean_library_job_ids|default([]) and not job.archived_at %} + + {% endif %} + {% if control_state.get('can_rollback') %} +
+ +
+

diff --git a/src/pullbox/ui/templates/partials/import_misplaced_source_cleanup_files.html b/src/pullbox/ui/templates/partials/import_misplaced_source_cleanup_files.html new file mode 100644 index 0000000..1f03884 --- /dev/null +++ b/src/pullbox/ui/templates/partials/import_misplaced_source_cleanup_files.html @@ -0,0 +1,65 @@ +
+
+ {{ "{:,}".format(cleanup_page.total) }} file{{ '' if cleanup_page.total == 1 else 's' }} + Page {{ cleanup_page.page }} of {{ cleanup_page.total_pages }} +
+
+ {% for item in cleanup_page.items %} + {% set evidence = (item.diagnostics or {}).get('mylar3_cross_folder_reconciliation', {}) %} +
+
+
+

{{ item.file_name }}

+ {% if cleanup_action == 'restore_recorded_path' %} +
+
+
Current path
+
{{ item.file_path }}
+
+
+
Proposed path
+
{{ evidence.get('recorded_path') }}
+
+
+ {% else %} +

+ Pullbox verified this is byte-identical to the imported copy. It remains untouched until you confirm moving it to Trash. +

+ {% endif %} +
+ +
+
+ {% endfor %} +
+ {% if cleanup_page.total_pages > 1 %} +
+ + +
+ {% endif %} +
diff --git a/src/pullbox/ui/templates/partials/import_misplaced_source_cleanup_modal.html b/src/pullbox/ui/templates/partials/import_misplaced_source_cleanup_modal.html new file mode 100644 index 0000000..7412549 --- /dev/null +++ b/src/pullbox/ui/templates/partials/import_misplaced_source_cleanup_modal.html @@ -0,0 +1,75 @@ +{% set is_restore = preview.action.value == 'restore_recorded_path' %} + diff --git a/src/pullbox/ui/templates/partials/import_mylar_path_exceptions.html b/src/pullbox/ui/templates/partials/import_mylar_path_exceptions.html deleted file mode 100644 index d8949a9..0000000 --- a/src/pullbox/ui/templates/partials/import_mylar_path_exceptions.html +++ /dev/null @@ -1,39 +0,0 @@ -
-
-
- - -
- Export full report -
- -
- - - - - - -
Unavailable Mylar locations and the action needed to resolve each one.
SeriesStored pathAttempted Pullbox pathReason and next step
-

No exceptions match this search.

-
- -
diff --git a/src/pullbox/ui/templates/partials/import_orphaned_panel.html b/src/pullbox/ui/templates/partials/import_orphaned_panel.html index 3912514..6c974dd 100644 --- a/src/pullbox/ui/templates/partials/import_orphaned_panel.html +++ b/src/pullbox/ui/templates/partials/import_orphaned_panel.html @@ -1,5 +1,5 @@
{{ imported_series.cv_title or {% if imported_series.status.value == 'imported' %}

This series is already in the library. Finish the leftover file decisions here by assigning each file to an issue or skipping it for now.

{% else %} -

Map each file to an issue or skip it for now. This deferred item will stay in Unmatched until the recovery work is actually finished.

+

Map each file to an issue or skip it for now. This deferred item will stay in Follow-up until the recovery work is actually finished.

{% endif %}
+ {% endif %} + + + View import history + + + {% else %} + {% set follow_up_group_count = follow_up_group_count|default(0) %} + {% if follow_up_group_count > 0 %} +
+
+
+

Follow-up ready

+

{{ follow_up_group_count }} follow-up group{{ '' if follow_up_group_count == 1 else 's' }} need attention

+

The import is complete. Matching, recovery, and cleanup decisions are saved in one workspace so you can handle them when convenient.

+
+ +
+
+ {% endif %} + {% if job.removed_library_root_snapshot %} +
+

Original library root removed

+

{{ job.removed_library_root_snapshot.name }}: {{ job.removed_library_root_snapshot.path }}

+

Files and import history were preserved. To retry, start a new import and explicitly choose an enabled destination.

+
+ {% endif %}
Starting rollback... {% endif %} - {% if failed_count > 0 or files_failed > 0 %} - - {% endif %} {% endif %} - {% if job.status.value == 'completed' and unmatched_queue_count > 0 %} -
- {% call inline_alert(tone="warning", icon="warning") %} - {{ unmatched_queue_count }} follow-up {{ 'item was' if unmatched_queue_count == 1 else 'items were' }} deferred to Unmatched{% if files_no_match > 0 %}, covering {{ files_no_match }} unresolved file{{ 's' if files_no_match != 1 }}{% endif %}. These were not imported yet; finish or dismiss them when you are ready. - {% endcall %} -
- {% endif %} - {% set catalog_sync_attention_count = catalog_sync_attention_count|default(0) %} {% set catalog_sync_pending_count = catalog_sync_pending_count|default(0) %} {% set catalog_sync_failed_count = catalog_sync_failed_count|default(0) %} {% set catalog_sync_series = catalog_sync_series|default([]) %} - {% if catalog_sync_attention_count > 0 %} -
- {% call inline_alert(tone="warning", icon="warning") %} + {% if catalog_sync_pending_count > 0 %} +
+ {% call inline_alert(tone="info", icon="info") %} - {{ catalog_sync_attention_count }} series still need ComicVine catalog sync follow-up. - {{ catalog_sync_pending_count }} syncing in the background{% if catalog_sync_failed_count > 0 %}; - {{ catalog_sync_failed_count }} needs metadata retry{% endif %}. + {{ catalog_sync_pending_count }} series {{ 'is' if catalog_sync_pending_count == 1 else 'are' }} still syncing metadata in the background. + You can leave this page; catalog hydration will continue independently. {% endcall %} -
-
- {% for series in catalog_sync_series[:8] %} - {% set catalog_state = series.issue_catalog_state.value if series.issue_catalog_state else 'partial' %} - - {{ series.title }} - - {% endfor %} - {% if catalog_sync_series|length > 8 %} - +{{ catalog_sync_series|length - 8 }} more - {% endif %} -
-
{% endif %} @@ -204,10 +211,10 @@

Import results

- {% if failed_count == 0 and files_failed == 0 %} + {% if failed_count == 0 and files_failed == 0 and follow_up_group_count|default(0) == 0 %} The import finished cleanly and the new series are now in Pullbox. {% else %} - The import finished with a small amount of follow-up still worth checking. + The import finished with follow-up still worth checking. {% endif %}

@@ -230,7 +237,7 @@

Deferred Follow-up

{{ unmatched_queue_count }}

-

{{ "Nothing was deferred to Unmatched." if unmatched_queue_count == 0 else "These are unresolved decisions saved for later in Unmatched." }}

+

{{ "Nothing was deferred for follow-up." if unmatched_queue_count == 0 else "These unresolved decisions are saved in Follow-up." }}

Duplicates

@@ -272,7 +279,7 @@

Files Deferred

{{ files_no_match }}

-

{{ "No files were left for follow-up." if files_no_match == 0 else "These files still need series or issue decisions in Unmatched." }}

+

{{ "No files were left for follow-up." if files_no_match == 0 else "These files still need series or issue decisions in Follow-up." }}

Failed

@@ -283,63 +290,21 @@

{% endif %} - {% set managed_artifacts_created = managed_artifacts_created|default(0) %} - {% set referenced_files_registered = referenced_files_registered|default(0) %} - {% set rollback_managed_candidates = rollback_managed_candidates|default(0) %} - {% set rollback_reference_candidates = rollback_reference_candidates|default(0) %} - {% set rollback_manual_recovery_count = rollback_manual_recovery_count|default(0) %} - {% if managed_artifacts_created > 0 or referenced_files_registered > 0 or files_already_owned > 0 %} -
-

Ownership outcomes

-
-
-

Managed artifacts

-

{{ managed_artifacts_created }}

-

Library and Story Arc artifacts created and owned by this import.

-
-
-

In-place references

-

{{ referenced_files_registered }}

-

Existing source and Story Arc files registered without transferring or changing them.

-
-
-

Already owned

-

{{ files_already_owned }}

-

Existing Pullbox registrations left unchanged.

-
-
-
- {% endif %} - - {% if can_rollback and (rollback_managed_candidates > 0 or rollback_reference_candidates > 0 or rollback_manual_recovery_count > 0) %} -
-

Conditional rollback journal

-

- {{ rollback_managed_candidates }} managed artifact{{ '' if rollback_managed_candidates == 1 else 's' }} will be fingerprint-checked and removed only when unchanged; - {{ rollback_reference_candidates }} reference{{ '' if rollback_reference_candidates == 1 else 's' }} will be detached without deleting source files. - {% if rollback_manual_recovery_count > 0 %} - {{ rollback_manual_recovery_count }} prior rollback conflict{{ '' if rollback_manual_recovery_count == 1 else 's' }} require{{ 's' if rollback_manual_recovery_count == 1 else '' }} manual recovery. - {% endif %} -

-
- {% endif %}

+ {% endif %} + {% if follow_up_workspace|default(false) %} {% set cleanup_action_summaries = cleanup_action_summaries|default([]) %} {% set safety_category_summaries = safety_category_summaries|default([]) %} - {% set cleanup_no_action_count = cleanup_no_action_count|default(0) %} {% set cleanup_safe_action_count = cleanup_safe_action_count|default(0) %} {% set cleanup_needs_review_count = cleanup_needs_review_count|default(0) %} - {% if job.status.value == 'completed' and not job.archived_at and (cleanup_action_summaries or cleanup_needs_review_count > 0 or cleanup_no_action_count > 0) %} + {% if job.status.value == 'completed' and not job.archived_at and (cleanup_action_summaries or cleanup_needs_review_count > 0) %}
-

Completed import recovery

+

Follow-up actions

Finish cleanup without starting over

Pullbox grouped the remaining outcomes so large imports stay readable. Preview each safe action before applying it. Source files are never deleted or changed by these cleanup actions. @@ -347,22 +312,21 @@

Finish cleanup without starting over

-
-
-

Already handled

-

{{ "{:,}".format(cleanup_no_action_count) }}

-

Duplicates, already-owned files, and dismissed outcomes need no further action.

-
+
+ {% if cleanup_safe_action_count > 0 %}

Safe next steps

{{ "{:,}".format(cleanup_safe_action_count) }}

Files have a bounded recovery action ready for review.

+ {% endif %} + {% if cleanup_needs_review_count > 0 %}

Needs review

{{ "{:,}".format(cleanup_needs_review_count) }}

Ambiguous or unsafe outcomes remain untouched for manual review.

+ {% endif %}
{% if cleanup_action_summaries %} @@ -435,6 +399,118 @@

{{ action.label }}

{% endif %} + {% set misplaced_source_restore_count = misplaced_source_restore_count|default(0) %} + {% set misplaced_source_duplicate_count = misplaced_source_duplicate_count|default(0) %} + {% if job.status.value == 'completed' and not job.archived_at and (misplaced_source_restore_count > 0 or misplaced_source_duplicate_count > 0) %} +
+
+
+

Optional source cleanup

+

Organize misplaced files

+

+ Pullbox found issues whose exact embedded identity belongs to a different Mylar series folder. Review each current and proposed path before changing the source library. Pullbox does not edit the Mylar database. +

+
+
+
+
+ These actions move existing source files. Nothing moves until you preview and confirm the exact paths. Ambiguous files are never included. +
+
+ {% if misplaced_source_restore_count > 0 %} +
+
+

Verified misplaced files

+ {{ "{:,}".format(misplaced_source_restore_count) }} +
+

+ Move verified issues to the exact missing paths recorded by Mylar and keep Pullbox linked to them there. +

+
+ + +
+
+
+ {% endif %} + {% if misplaced_source_duplicate_count > 0 %} +
+
+

Remove identical duplicates

+ {{ "{:,}".format(misplaced_source_duplicate_count) }} +
+

+ Move one byte-identical extra copy to the configured Trash folder. The canonical issue remains in place. +

+ +
+
+ {% endif %} +
+
+
+ {% endif %} + + {% set story_arcs_created_count = story_arcs_created_count|default(0) %} + {% set story_arcs_follow_up_count = story_arcs_follow_up_count|default(0) %} + {% if story_arcs_follow_up_count > 0 %} +
+
+
+

Story Arc follow-up

+

Story Arc evidence preserved

+

+ {{ story_arcs_follow_up_count }} saved for later review. Canonical comics were imported + independently and no separate arc files were required. +

+
+ +
+
+ {% endif %} + + {% if catalog_sync_failed_count|default(0) > 0 %} +
+
+
+

Metadata

+

{{ catalog_sync_failed_count }} series need metadata retry

+

Background catalog hydration exhausted its retries for these series. Pending hydration is not included here.

+
+
+
+ {% for series in catalog_sync_series|default([]) %} + {% if series.issue_catalog_state.value == 'failed' %} + {{ series.title }} + {% endif %} + {% endfor %} +
+
+ {% endif %} + {% if failed_series %}
@@ -565,7 +641,10 @@

{{ files_safety_blocked }} file{{ "s need" if fi

{% endif %} + {% endif %} +{% if not follow_up_workspace|default(false) %} {% include "partials/import_collection_footer_oob.html" %} +{% endif %} diff --git a/src/pullbox/ui/templates/partials/import_review_footer_dock.html b/src/pullbox/ui/templates/partials/import_review_footer_dock.html index 55f20e9..b4ba50f 100644 --- a/src/pullbox/ui/templates/partials/import_review_footer_dock.html +++ b/src/pullbox/ui/templates/partials/import_review_footer_dock.html @@ -3,7 +3,7 @@ {% if import_review_footer_mode == "conflicts" %} {% set conflict_pagination_base_url = conflict_footer_base_url|default("/import/" ~ job.id ~ "/review-partial?status=conflicts&sort=" ~ sort) %} {% set conflict_pagination_target = conflict_footer_hx_target|default("#import-step-review-shell") %} - {% set conflict_pagination_swap = conflict_footer_hx_swap|default("outerHTML") %} + {% set conflict_pagination_swap = conflict_footer_hx_swap|default("morph:outerHTML") %} {% set conflict_pagination_sync = conflict_footer_hx_sync|default("#import-step-review-shell:replace") %}
{{ page_dock( @@ -37,7 +37,7 @@ {"label": "per page", "value": page_size|string}, ], hx_target="#import-step-review-shell", - hx_swap="outerHTML", + hx_swap="morph:outerHTML", hx_select="", hx_base_url=story_arc_pagination_base_url, hx_sync="#import-step-review-shell:replace", @@ -61,7 +61,7 @@ {"label": "per page", "value": page_size|string}, ], hx_target="#import-step-review-shell", - hx_swap="outerHTML", + hx_swap="morph:outerHTML", hx_select="", hx_base_url=review_pagination_base_url, hx_sync="#import-step-review-shell:replace", diff --git a/src/pullbox/ui/templates/partials/import_source_cleanup_modal.html b/src/pullbox/ui/templates/partials/import_source_cleanup_modal.html new file mode 100644 index 0000000..a0e4c9b --- /dev/null +++ b/src/pullbox/ui/templates/partials/import_source_cleanup_modal.html @@ -0,0 +1,80 @@ + diff --git a/src/pullbox/ui/templates/partials/import_step_progress.html b/src/pullbox/ui/templates/partials/import_step_progress.html index 11c8c64..7843ad2 100644 --- a/src/pullbox/ui/templates/partials/import_step_progress.html +++ b/src/pullbox/ui/templates/partials/import_step_progress.html @@ -392,7 +392,10 @@

-

Remaining

+

Remaining

@@ -400,7 +403,7 @@

-

Selected

+

Discovered

@@ -413,7 +416,7 @@

{{ group.label }}

x-on:import:conflicts-reset.window="handleConflictsReset($event.detail)" data-testid="import-collection-review" data-import-review-status-counts='{{ status_counts|tojson|forceescape }}' - {% if safety_rematch_pending %} - hx-get="/import/{{ job.id }}/review-partial?status=safety_blocked&sort={{ sort }}&page={{ page }}" - hx-trigger="every 2s [window.pullboxLiveUpdatesEnabled()]" - hx-target="#import-step-review-shell" - hx-swap="outerHTML" - hx-sync="#import-step-review-shell:replace" - {% endif %} class="space-y-6" > + {% if safety_rematch_pending %} + + {% endif %} -
-

Step 3

-

Reconcile for Import

-

- Finish series and issue decisions here before starting the file import. Rows that already have a ComicVine series can be reconciled directly without going to Unmatched. -

-
- - {% if job.source_type.value == 'mylar3' and job.mylar3_path_map_confirmed %} -
-
-
-

- Confirmed Mylar path mapping -

-

- This is the frozen Step 1 snapshot used by the scan. Retry revalidates these paths - before reusing them. -

-
- Confirmed -
- {% if job.mylar3_path_map %} -
- {% for stored_prefix, pullbox_prefix in job.mylar3_path_map|dictsort %} -
- {{ stored_prefix }} - - {{ pullbox_prefix }} -
- {% endfor %} -
- {% else %} -
- Identity only. Mylar paths were confirmed without translation. -
- {% endif %} -
- {% endif %} - - {% if job.story_arc_import_requested %} -
-
-

- {% if job.story_arc_materialization_requested %} - Logical arcs and separate arc files requested in Step 1 - {% else %} - Logical arcs requested; separate arc files remain off - {% endif %} -

-

- Review each detected arc below. Step 1 did not select an arc, confirm a policy, or authorize - copies and links. -

+
+
+ {% call inline_alert(tone="danger", icon="warning") %} + + {% endcall %}
-
- {% endif %} - - +
+ + {% if false %}
Custom templates - + {{ dropdown_select( + name="future_library_root_id", + value="", + options=[], + options_expression="managedLibraryRootOptions()", + local_model="targetLibraryRootId", + local_on_change="targetLibraryRootId = $event.detail.value ? Number($event.detail.value) : null; futureLayoutRootChanged()", + disabled_expression="futurePolicyLoading", + trigger_id="import-future-library-root", + root_testid="import-future-layout-root", + wrap_options=true, + class_name="w-full max-w-2xl" + ) }}
@@ -1104,21 +1134,20 @@

Custom templates

+ {% endif %} -
- - -
+
+
+ +

+ The recommended defaults work for most libraries. Adjust these only when you need + to narrow discovery or matching behavior. +

+
+
Custom templates

Higher values push weak matches into review.

+
+
- +
@@ -1181,7 +1212,7 @@

Custom templates

- + @@ -1190,5 +1221,90 @@

Custom templates

+ + {% include "components/file_browser.html" %} diff --git a/src/pullbox/ui/templates/partials/import_story_arc_review_table.html b/src/pullbox/ui/templates/partials/import_story_arc_review_table.html index f039581..b8f4bdc 100644 --- a/src/pullbox/ui/templates/partials/import_story_arc_review_table.html +++ b/src/pullbox/ui/templates/partials/import_story_arc_review_table.html @@ -1,3 +1,5 @@ +{% from "components/dropdown_select.html" import dropdown_select %} +

@@ -67,7 +69,7 @@

Import target for {{ arc.name }} - + {% set arc_target_options = [("", "Create new story arc")] %} + {% for candidate in arc.merge_candidates %} + {% set _ = arc_target_options.append((candidate.id|string, "Merge into " ~ candidate.name)) %} + {% endfor %} + {{ dropdown_select( + name="story_arc_target_" ~ arc.id, + value=arc.proposed_story_arc_id or "", + options=arc_target_options, + trigger_id="import-story-arc-target-" ~ arc.id, + root_testid="import-story-arc-target-" ~ arc.id, + disabled=arc.selection_blocked, + wrap_options=true, + class_name="min-w-48 text-xs" + ) }}

- + {% set story_arc_root_options = [("", "Choose a library root")] %} + {% for root in library_roots %} + {% set _ = story_arc_root_options.append((root.id|string, root.name)) %} + {% endfor %} + {{ dropdown_select( + name="target_library_root_id", + value=policy.target_library_root_id or "", + options=story_arc_root_options, + trigger_id="import-story-arc-policy-root-" ~ arc.id, + root_testid="import-story-arc-policy-root-" ~ arc.id, + wrap_options=true, + class_name="w-full text-xs" + ) }}
@@ -253,10 +257,14 @@

- + {{ dropdown_select( + name="symlink_style", + value=policy.symlink_style or "absolute", + options=[("absolute", "Absolute"), ("relative", "Relative")], + trigger_id="import-story-arc-policy-symlink-" ~ arc.id, + root_testid="import-story-arc-policy-symlink-" ~ arc.id, + class_name="w-full text-xs" + ) }}

@@ -327,7 +335,7 @@

Entry state - + {% set story_arc_entry_options = [] %} + {% for state_option in story_arc_entry_state_options %} + {% set state_label = 'All states' if state_option.value == 'all' else state_option.value|replace('_', ' ')|title %} + {% set _ = story_arc_entry_options.append((state_option.value, state_label)) %} + {% endfor %} + {{ dropdown_select( + name="arc_entry_state", + value=story_arc_entry_state_filter.value, + options=story_arc_entry_options, + trigger_id="import-story-arc-entry-state", + root_testid="import-story-arc-entry-filter", + class_name="min-w-40 text-xs" + ) }} @@ -481,7 +487,7 @@

1 and tab == "unmatched" %} - {% set unmatched_href = unmatched_href ~ "&page=" ~ current_page %} +{% if selected_follow_up_job %} + {% set follow_up_href = follow_up_href ~ "&job_id=" ~ selected_follow_up_job.id %} +{% endif %} +{% if current_page > 1 and tab == "follow-up" %} + {% set follow_up_href = follow_up_href ~ "&page=" ~ current_page %} {% endif %}
@@ -22,16 +25,16 @@ Collection - Unmatched + Follow-up

> - - +
+ + +
@@ -163,7 +170,27 @@

Rebin
+
+ @@ -249,7 +276,7 @@

Add library root

title_style="plain", body_class="p-0" ) %} -
+
{% call settings_row(label="Preferred Format", help="When two otherwise valid releases exist, this format receives priority.") %} {{ dropdown_select( @@ -543,6 +570,7 @@

Add library root

libraryRootRebindPath: '', libraryRootRebindPreview: null, libraryRootRebindPreviewRequest: 0, + libraryRootRemovalPreview: null, libraryRootForm: { name: '', path: '', @@ -648,6 +676,7 @@

Add library root

if (this.libraryRootSaving) return; this.libraryRootSaving = true; this.libraryRootError = ''; + this.libraryRootRemovalPreview = null; try { await this.libraryRootRequest(`/api/v1/config/library-roots/${root.id}`, { method: "PATCH", @@ -663,7 +692,55 @@

Add library root

} }, + async previewLibraryRootRemoval(root) { + if (this.libraryRootSaving) return; + this.libraryRootSaving = true; + this.libraryRootError = ''; + this.libraryRootRemovalPreview = null; + try { + this.libraryRootRemovalPreview = await this.libraryRootRequest( + `/api/v1/config/library-roots/${root.id}/remove/preview`, { method: "POST" } + ); + await this.$nextTick(); + this.$root.querySelector('[data-testid="settings-media-library-root-removal"]')?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } catch (err) { + this.libraryRootError = err.message || 'Could not check this library root.'; + } finally { + this.libraryRootSaving = false; + } + }, + + async confirmLibraryRootRemoval(root) { + const preview = this.libraryRootRemovalPreview; + if (this.libraryRootSaving || preview?.id !== root.id || !preview.can_remove || !preview.preview_token) return; + this.libraryRootSaving = true; + this.libraryRootError = ''; + try { + const confirmed = await pbConfirm({ + title: 'Remove library root?', + message: `Remove "${preview.name}" (${preview.path})? Its configuration and root-specific naming settings will be removed. No folders or files will be deleted. Import history will be preserved.`, + confirmText: 'Remove root', + destructive: true, + }); + if (!confirmed) return; + await this.libraryRootRequest(`/api/v1/config/library-roots/${root.id}`, { + method: "DELETE", + body: JSON.stringify({ preview_token: preview.preview_token, confirmation: "REMOVE" }), + }); + this.libraryRoots = this.libraryRoots.filter(item => item.id !== root.id); + this.libraryRootRemovalPreview = null; + if (this.namingScope === String(root.id)) await this.loadNamingScope('global'); + if (typeof showToast === 'function') showToast({ message: 'Library root removed. Files are unchanged.', level: 'success' }); + } catch (err) { + this.libraryRootError = err.message || 'Could not remove this library root.'; + this.libraryRootRemovalPreview = null; + } finally { + this.libraryRootSaving = false; + } + }, + openLibraryRootRebind(root) { + this.libraryRootRemovalPreview = null; this.libraryRootRebindRootId = Number(root.id); this.libraryRootRebindPath = ''; this.libraryRootRebindPreview = null; diff --git a/src/pullbox/ui/templates/partials/utilities_permissions_footer_dock.html b/src/pullbox/ui/templates/partials/utilities_permissions_footer_dock.html index 9d75e1c..84efeda 100644 --- a/src/pullbox/ui/templates/partials/utilities_permissions_footer_dock.html +++ b/src/pullbox/ui/templates/partials/utilities_permissions_footer_dock.html @@ -1,6 +1,13 @@
None: + root = tmp_path / "comics" + (root / "Existing").mkdir(parents=True) + database = tmp_path / "mylar.db" + self._write_mylar_database(database, [str(root / "Existing")]) + async with _db_factory() as session: + session.add(LibraryRoot(name="Comics", path=str(root), enabled=True)) + await session.commit() + + report_directory = tmp_path / "diagnostics" / "mylar-preflight" + with ( + patch( + "pullbox.api.v1.import_jobs.path_reports.save_report", + side_effect=OSError("disk full"), + ), + patch( + "pullbox.api.v1.import_jobs.path_reports.report_directory", + return_value=report_directory, + ), + ): + response = await client.post( + "/api/v1/import/mylar-path-preview", + json={"source_path": str(database)}, + ) + + assert response.status_code == 200 + preview = response.json() + assert "report_unavailable" in preview["warnings"] + attention = next( + item for item in preview["attention_items"] if item["code"] == "report_unavailable" + ) + assert attention["blocks_import"] is False + assert attention["action"] is None + assert attention["details"]["known_paths"] == [str(report_directory)] + assert any("free space" in step.lower() for step in attention["details"]["steps"]) + assert preview["attention_fingerprint"] + async def test_exception_report_is_paged_searchable_and_downloadable( self, client: AsyncClient, diff --git a/tests/api/test_import_completed_cleanup_api.py b/tests/api/test_import_completed_cleanup_api.py index 88db190..f7a004a 100644 --- a/tests/api/test_import_completed_cleanup_api.py +++ b/tests/api/test_import_completed_cleanup_api.py @@ -4,10 +4,13 @@ import os import sys +from datetime import UTC, datetime from typing import TYPE_CHECKING import pytest +from sqlalchemy import func, select +from pullbox.core.library_file_ownership import build_file_identity_signature from pullbox.models.import_job import ( ImportedFile, ImportedFileStatus, @@ -17,6 +20,15 @@ ImportSeriesStatus, ImportSourceType, ) +from pullbox.models.issue import Issue, IssueStatus +from pullbox.models.library import ( + FileFormat, + LibraryFile, + LibraryFileStorageMode, + LibraryRoot, + MatchConfidence, +) +from pullbox.models.series import Series from pullbox.services.import_safety_diagnostics import ( ImportSafetyCategory, build_import_safety_diagnostics, @@ -73,6 +85,128 @@ async def _seed_missing_reference(factory: async_sessionmaker[AsyncSession]) -> return int(job.id), int(file.id) +async def _seed_mixed_folder_file(factory: async_sessionmaker[AsyncSession]) -> tuple[int, int]: + async with factory() as session: + job = ImportJob( + source_path="/imports/mylar.db", + source_type=ImportSourceType.MYLAR3, + status=ImportJobStatus.COMPLETED, + ) + source_series = ImportedSeries( + import_job=job, + raw_series_name="Fritzi Ritz", + cv_title="Fritzi Ritz", + status=ImportSeriesStatus.IMPORTED, + ) + target = Series( + title="Action Comics", + sort_title="action comics", + year_start=1938, + monitored=True, + ) + session.add_all([job, source_series, target]) + await session.flush() + issue = Issue(series_id=target.id, issue_number=1002, issue_number_text="1002") + target_import_series = ImportedSeries( + import_job=job, + raw_series_name="Action Comics", + cv_title="Action Comics", + status=ImportSeriesStatus.IMPORTED, + series_id=target.id, + ) + session.add_all([issue, target_import_series]) + await session.flush() + imported_file = ImportedFile( + import_job=job, + import_series=source_series, + file_path="/comics/Fritzi Ritz/Action Comics 1002.cbz", + file_name="Action Comics 1002.cbz", + file_size=1024, + file_format="cbz", + status=ImportedFileStatus.NO_MATCH, + diagnostics={ + "metadata_signals": { + "series_name": "comicinfo", + "issue_number": "comicinfo", + }, + "source_metadata": {"comicinfo": {"series": "Action Comics", "number": "1002"}}, + }, + ) + session.add(imported_file) + await session.commit() + return int(job.id), int(imported_file.id) + + +async def _seed_referenced_library( + factory: async_sessionmaker[AsyncSession], + tmp_path, +) -> tuple[int, int]: # type: ignore[no-untyped-def] + async with factory() as session: + source_path = tmp_path / "mylar" / "Batman" / "Batman 001.cbr" + source_path.parent.mkdir(parents=True) + source_path.write_bytes(b"legacy comic") + target_path = tmp_path / "clean-library" + target_path.mkdir() + source_root = LibraryRoot( + name="Mylar", + path=str(tmp_path / "mylar"), + allow_managed_writes=False, + ) + target_root = LibraryRoot(name="Clean library", path=str(target_path)) + series = Series(title="Batman", sort_title="batman", year_start=2016) + session.add_all([source_root, target_root, series]) + await session.flush() + issue = Issue( + series_id=series.id, + issue_number=1.0, + issue_number_text="1", + status=IssueStatus.OWNED, + ) + job = ImportJob( + source_path="/imports/mylar.db", + source_type=ImportSourceType.MYLAR3, + status=ImportJobStatus.COMPLETED, + ) + session.add_all([issue, job]) + await session.flush() + imported_series = ImportedSeries( + import_job_id=job.id, + raw_series_name=series.title, + raw_year=series.year_start, + status=ImportSeriesStatus.IMPORTED, + series_id=series.id, + ) + library_file = LibraryFile( + file_path=str(source_path), + file_name=source_path.name, + file_size=source_path.stat().st_size, + file_format=FileFormat.CBR, + file_modified_at=datetime.now(UTC), + match_confidence=MatchConfidence.HIGH, + issue_id=issue.id, + library_root_id=source_root.id, + storage_mode=LibraryFileStorageMode.REFERENCED, + source_signature=build_file_identity_signature(source_path), + ) + session.add_all([imported_series, library_file]) + await session.flush() + session.add( + ImportedFile( + import_job_id=job.id, + import_series_id=imported_series.id, + file_path=str(source_path), + file_name=source_path.name, + file_size=source_path.stat().st_size, + file_format="cbr", + status=ImportedFileStatus.IMPORTED, + matched_issue_id=issue.id, + library_file_id=library_file.id, + ) + ) + await session.commit() + return int(job.id), int(target_root.id) + + @pytest.mark.asyncio async def test_preview_and_apply_completed_cleanup_are_authenticated_and_scoped( authenticated_client: AsyncClient, @@ -128,6 +262,100 @@ async def test_cleanup_apply_rejects_missing_confirmation( assert response.status_code == 422 +@pytest.mark.asyncio +async def test_mixed_folder_cleanup_api_resumes_only_resolved_files( + authenticated_client: AsyncClient, + sec_db: async_sessionmaker[AsyncSession], + monkeypatch: pytest.MonkeyPatch, +) -> None: + job_id, file_id = await _seed_mixed_folder_file(sec_db) + triggered_jobs: list[int] = [] + monkeypatch.setattr( + "pullbox.api.v1.import_completed_cleanup.trigger_import_execute", + triggered_jobs.append, + ) + action = "resolve_mixed_folder_files" + preview_response = await authenticated_client.get( + f"/api/v1/import/{job_id}/cleanup/{action}/preview" + ) + + assert preview_response.status_code == 200 + preview = preview_response.json() + assert preview["affected_file_count"] == 1 + + response = await authenticated_client.post( + f"/api/v1/import/{job_id}/cleanup/{action}", + headers=_csrf_header_for(authenticated_client), + json={ + "preview_token": preview["preview_token"], + "confirmation": "APPLY CLEANUP", + }, + ) + + assert response.status_code == 200 + assert response.json()["requires_import_retry"] is True + assert triggered_jobs == [job_id] + async with sec_db() as session: + imported_file = await session.get(ImportedFile, file_id) + assert imported_file is not None + assert imported_file.status is ImportedFileStatus.CONFIRMED + + +@pytest.mark.asyncio +async def test_clean_library_api_previews_and_starts_managed_copy( + authenticated_client: AsyncClient, + sec_db: async_sessionmaker[AsyncSession], + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: # type: ignore[no-untyped-def] + job_id, target_root_id = await _seed_referenced_library(sec_db, tmp_path) + triggered_jobs: list[int] = [] + monkeypatch.setattr( + "pullbox.api.v1.import_completed_cleanup.trigger_import_execute", + triggered_jobs.append, + ) + + preview_response = await authenticated_client.get( + f"/api/v1/import/{job_id}/clean-library/preview", + params={"target_root_id": target_root_id}, + ) + + assert preview_response.status_code == 200 + preview = preview_response.json() + assert preview["eligible_file_count"] == 1 + assert preview["eligible_series_count"] == 1 + assert preview["source_preserved"] is True + assert preview["confirmation_text"] == "BUILD CLEAN LIBRARY" + + response = await authenticated_client.post( + f"/api/v1/import/{job_id}/clean-library", + headers=_csrf_header_for(authenticated_client), + json={ + "target_root_id": target_root_id, + "preview_token": preview["preview_token"], + "confirmation": "BUILD CLEAN LIBRARY", + }, + ) + + assert response.status_code == 200 + result = response.json() + assert result["source_job_id"] == job_id + assert result["job_id"] != job_id + assert triggered_jobs == [result["job_id"]] + async with sec_db() as session: + created = await session.get(ImportJob, result["job_id"]) + assert created is not None + assert created.file_handling_mode.value == "managed_copy" + assert created.source_preserved is True + assert created.progress_snapshot["clean_library_adoption_prepared"] is False + created_file_count = await session.scalar( + select(func.count()) + .select_from(ImportedFile) + .where(ImportedFile.import_job_id == created.id) + ) + assert created_file_count == 0 + + @pytest.mark.asyncio async def test_finished_import_can_be_archived_and_restored_through_api( authenticated_client: AsyncClient, diff --git a/tests/e2e/pages/import_page.py b/tests/e2e/pages/import_page.py index f7a52db..4a3d8d1 100644 --- a/tests/e2e/pages/import_page.py +++ b/tests/e2e/pages/import_page.py @@ -109,7 +109,34 @@ def source_layout_series_folders(self) -> Locator: @property def source_layout_section(self) -> Locator: - return self.page.locator("[data-testid='import-collection-layout-section']").first + return self.page.locator("[data-testid='import-layout-advanced']").first + + @property + def advanced_options(self) -> Locator: + return self.page.locator("[data-testid='import-advanced-options']").first + + @property + def advanced_file_management(self) -> Locator: + return self.page.locator("[data-testid='import-advanced-file-management']").first + + def open_advanced_options(self) -> None: + if not self.advanced_options.evaluate("element => element.open"): + self.advanced_options.locator("summary").first.click() + + def open_layout_options(self) -> None: + self.open_advanced_options() + if not self.source_layout_section.evaluate("element => element.open"): + self.source_layout_section.locator("summary").first.click() + + def open_file_management_options(self) -> None: + self.open_advanced_options() + if not self.advanced_file_management.evaluate("element => element.open"): + self.advanced_file_management.locator("summary").first.click() + + def open_mylar_path_options(self) -> None: + self.open_advanced_options() + if not self.mylar_path_section.evaluate("element => element.open"): + self.mylar_path_section.locator("summary").first.click() @property def source_layout_publisher_series(self) -> Locator: @@ -173,7 +200,21 @@ def history_panel(self) -> Locator: @property def unmatched_panel(self) -> Locator: - return self.page.locator("[data-testid='import-orphaned-page']").first + return self.page.locator("[data-testid='import-follow-up-page']").first + + @property + def follow_up_job_list(self) -> Locator: + return self.page.locator("[data-testid='import-follow-up-job-list']").first + + @property + def follow_up_jobs_table(self) -> Locator: + return self.page.locator("[data-testid='import-follow-up-jobs-table']").first + + def open_follow_up_job(self, source_path: str) -> None: + row = self.follow_up_jobs_table.locator("tbody tr").filter(has_text=source_path).first + row.get_by_role("link").click() + self.wait_for_htmx() + self.unmatched_table.wait_for(state="visible", timeout=5000) @property def stepper(self) -> Locator: @@ -328,6 +369,10 @@ def results_panel(self) -> Locator: def retry_failed_button(self) -> Locator: return self.page.locator("[data-testid='import-results-retry-action']").first + @property + def review_follow_up_button(self) -> Locator: + return self.page.locator("[data-testid='import-results-follow-up-action'] a").first + @property def rollback_import_button(self) -> Locator: return self.page.locator("[data-testid='import-results-rollback-action']").first @@ -432,7 +477,11 @@ def unmatched_modal_host(self) -> Locator: return self.page.locator("[data-testid='import-orphaned-modal-host']").first def select_unmatched_view(self, key: str) -> None: - self.select_dropdown_option("import-orphaned-view", key) + follow_up_view = self.dropdown("import-follow-up-view") + dropdown_name = ( + "import-follow-up-view" if follow_up_view.is_visible() else "import-orphaned-view" + ) + self.select_dropdown_option(dropdown_name, key) def show_collection_source_step(self) -> None: self.page.evaluate( diff --git a/tests/e2e/test_import_collection_page.py b/tests/e2e/test_import_collection_page.py index 8ec7bb5..e6a9d03 100644 --- a/tests/e2e/test_import_collection_page.py +++ b/tests/e2e/test_import_collection_page.py @@ -13,8 +13,6 @@ from tests.e2e.pages.import_page import ImportPage if TYPE_CHECKING: - from pathlib import Path - from playwright.sync_api import Page, Route pytestmark = pytest.mark.e2e @@ -23,153 +21,519 @@ class TestImportCollectionTab: """Behavior-first E2E checks for the Import workspace collection tab.""" - def test_mylar_missing_paths_require_acknowledgement_and_reset_on_reanalysis( + def test_source_destination_dropdowns_follow_shared_contract( self, authed_page: Page, seeded_server: str, ) -> None: from playwright.sync_api import expect - preview = self._identity_mylar_path_response(2) + page = ImportPage(authed_page, seeded_server) + page.goto(tab="collection") + page.show_collection_source_step() + page.source_filesystem_card.click() + + advanced_options = authed_page.get_by_test_id("import-advanced-options") + advanced_options.locator("summary").first.click() + destination_section = authed_page.get_by_test_id("import-advanced-file-management") + authed_page.evaluate( + """() => { + const root = document.querySelector("[data-testid='import-collection-source']"); + const data = window.Alpine.$data(root); + const selectedRootId = Number(data.targetLibraryRootId); + data.libraryRoots = data.libraryRoots.filter( + item => Number(item.id) === selectedRootId + ); + }""" + ) + expect(destination_section).to_be_hidden() + + authed_page.evaluate( + """() => { + const root = document.querySelector("[data-testid='import-collection-source']"); + const data = window.Alpine.$data(root); + data.libraryRoots = data.libraryRoots.concat([{ + id: 987654, + name: "Overflow Library", + path: "/tmp/pullbox-e2e-overflow", + enabled: true, + allow_referenced_registrations: true, + allow_managed_writes: true, + available: true, + readable: true, + writable: true, + is_default_managed_destination: false, + }]); + }""" + ) + expect(destination_section).to_be_visible() + expect(destination_section).to_contain_text("Where new files go") + destination_section.locator("summary").click() + + managed_root = page.dropdown("import-managed-library-root") + expect(managed_root).to_be_visible() + expect(managed_root).to_have_attribute("data-dropdown-select-contract", "v1") + expect(managed_root.locator("select")).to_have_count(0) + managed_root.locator("[data-dropdown-select-trigger]").click() + panel = authed_page.locator("[data-dropdown-select-panel]:visible").first + expect(panel).to_contain_text("Overflow Library") + panel.locator("[data-dropdown-option][data-value]:not([data-value=''])").first.click() + expect(managed_root.locator("[data-dropdown-select-input]")).not_to_have_value("") + + page.file_handling_in_place.click() + in_place_root = page.dropdown("import-in-place-library-root") + expect(in_place_root).to_be_visible() + expect(in_place_root.locator("[data-dropdown-select-trigger-label]")).to_have_text( + "No preferred destination" + ) + + def test_folder_in_place_resolution_registers_source_as_reference_root( + self, + authed_page: Page, + seeded_server: str, + ) -> None: + from playwright.sync_api import expect + + source_path = "/imports/pullbox-real-world-import-lab/profiles/folder/source/comics" + root_created = False + preview_requests: list[dict[str, object]] = [] + create_requests: list[dict[str, object]] = [] + + def fulfill_layout_preview(route: Route) -> None: + response = { + "effective_spec": { + "schema_version": 1, + "mode": "auto", + "preset": None, + "series_path_template": None, + "issue_filename_template": None, + "selected_cluster_id": None, + "fallback_to_auto": True, + }, + "classification": "series_folders", + "clusters": [], + "directories_considered": 3, + "files_considered": 12, + "files_fitting": 12, + "files_ambiguous": 0, + "files_outside_root": 0, + "archive_probes": 0, + "can_keep_in_place": root_created, + "can_apply_future_policy": False, + "partial": False, + "warnings": [] if root_created else ["source_outside_library_root"], + } + route.fulfill(json=response) + + def fulfill_root_preview(route: Route) -> None: + preview_requests.append(route.request.post_data_json) + route.fulfill( + json={ + "can_create": True, + "blocking_reasons": [], + "warnings": [], + "normalized_path": source_path, + } + ) + + def fulfill_roots(route: Route) -> None: + nonlocal root_created + if route.request.method == "POST": + create_requests.append(route.request.post_data_json) + root_created = True + route.fulfill(status=201, json={"id": 91, **route.request.post_data_json}) + return + roots = [ + { + "id": 1, + "name": "Comics Directory", + "path": "/comics", + "enabled": True, + "allow_referenced_registrations": True, + "allow_managed_writes": True, + "available": True, + "readable": True, + "writable": True, + "is_default_managed_destination": True, + } + ] + if root_created: + roots.append( + { + "id": 91, + "name": "Existing files - comics", + "path": source_path, + "enabled": True, + "allow_referenced_registrations": True, + "allow_managed_writes": False, + "available": True, + "readable": True, + "writable": True, + "is_default_managed_destination": False, + } + ) + route.fulfill(json=roots) + + authed_page.route("**/api/v1/import/layout-preview", fulfill_layout_preview) + authed_page.route("**/api/v1/config/library-roots/preview", fulfill_root_preview) + authed_page.route("**/api/v1/config/library-roots", fulfill_roots) + + page = ImportPage(authed_page, seeded_server) + page.goto(tab="collection") + page.show_collection_source_step() + page.source_filesystem_card.click() + page.source_path_input.fill(source_path) + page.file_handling_in_place.click() + + advanced = authed_page.get_by_test_id("import-advanced-options") + expect(advanced.get_by_test_id("import-advanced-options-attention-count")).to_have_text("1") + advanced.locator("summary").first.click() + issue = advanced.locator("article").filter( + has_text="Register this folder for existing files" + ) + expect(issue).to_contain_text(source_path) + issue.get_by_role("button", name="Resolve").click() + + expect(advanced.get_by_test_id("import-advanced-options-attention-count")).to_be_hidden() + expect(page.file_handling_in_place).to_have_attribute("aria-pressed", "true") + expect(page.start_scan_button).to_be_enabled() + assert preview_requests == [ + { + "name": f"Existing files - comics - {source_path}", + "path": source_path, + "allow_referenced_registrations": True, + "allow_managed_writes": False, + "is_default_managed_destination": False, + } + ] + assert create_requests == preview_requests + + def test_advanced_attention_distinguishes_repairs_skips_and_manual_fixes( + self, + authed_page: Page, + seeded_server: str, + ) -> None: + from playwright.sync_api import expect + + preview = self._identity_mylar_path_response(3) preview.update( can_confirm=False, can_continue_with_unresolved=True, requires_unresolved_acknowledgement=True, unresolved_fingerprint="b" * 64, - exception_count=1, - report_id="a" * 32, - blocking_reasons=[], - exceptions=[ + attention_fingerprint="c" * 64, + attention_items=[ + { + "key": "d" * 64, + "code": "missing", + "blocks_import": False, + "reason": "One stored Mylar location is missing.", + "suggested_action": "Skip this stale reference for this import.", + "root_path": "/mnt/comics/missing", + "action": { + "kind": "acknowledge_unavailable", + "fingerprint": "e" * 64, + "root_path": "/mnt/comics/missing", + }, + "details": { + "title": "Review missing Mylar locations", + "series_count": 1, + "location_count": 1, + "known_paths": ["/mnt/comics/missing"], + "steps": ["Check whether /mnt/comics/missing was renamed."], + }, + }, + { + "key": "f" * 64, + "code": "invalid", + "blocks_import": True, + "reason": "One stored Mylar path is unsafe.", + "suggested_action": "Correct the stored path, then recheck it.", + "root_path": "/mnt/comics/unsafe", + "action": None, + "details": { + "title": "Correct unsafe Mylar paths", + "series_count": 1, + "location_count": 1, + "known_paths": ["/mnt/comics/unsafe"], + "steps": ["Correct /mnt/comics/unsafe in Mylar."], + }, + }, + { + "key": "a" * 64, + "code": "ineffective_mapping", + "blocks_import": False, + "reason": "One manual mapping no longer changes any paths.", + "suggested_action": "Remove the unused mapping.", + "root_path": "/mnt/comics", + "action": { + "kind": "remove_ineffective_mapping", + "fingerprint": "9" * 64, + "stored_prefix": "/old/comics", + "pullbox_prefix": "/mnt/comics", + }, + "details": { + "title": "Remove an unused mapping", + "series_count": 0, + "location_count": 0, + "known_paths": ["/old/comics", "/mnt/comics"], + "steps": ["Remove the mapping and recheck the import setup."], + }, + }, + ], + mappings=[ { - "series_id": "2", - "series_name": "Missing Series", - "stored_path": "/mnt/comics/Missing", - "attempted_path": "/mnt/comics/Missing", - "outcome": "missing", - "reason": "The stored folder is missing.", - "suggested_action": "Check the folder name or review it later.", + "stored_prefix": "/old/comics", + "pullbox_prefix": "/mnt/comics", } ], ) - preview["resolution"].update(identity_resolved=1, missing=1) - authed_page.route( - "**/api/v1/import/mylar-path-preview", lambda route: route.fulfill(json=preview) - ) - received = [] + analysis_count = 0 - def create(route: Route) -> None: - received.append(route.request.post_data_json) - route.fulfill(status=422, json={"detail": "Test request captured"}) + def analyze(route: Route) -> None: + nonlocal analysis_count + analysis_count += 1 + response = json.loads(json.dumps(preview)) + if route.request.post_data_json.get("auto_detect") is False: + response["mappings"] = [] + response["attention_items"] = [ + item + for item in response["attention_items"] + if item["code"] != "ineffective_mapping" + ] + route.fulfill(json=response) - authed_page.route("**/api/v1/import", create) + authed_page.route("**/api/v1/import/mylar-path-preview", analyze) page = ImportPage(authed_page, seeded_server) page.goto(tab="collection") page.show_collection_source_step() page.source_mylar3_card.click() page.source_path_input.fill("/imports/mylar.db") - report = authed_page.get_by_test_id("import-mylar-path-exceptions") - expect(report).to_be_visible(timeout=5000) - expect(report).to_contain_text("Missing Series") - expect(report).to_contain_text("/mnt/comics/Missing") - expect(page.start_scan_button).to_be_disabled() - acknowledgement = authed_page.get_by_test_id("import-mylar-unresolved-confirm") - acknowledgement.check() - expect(page.start_scan_button).to_be_enabled() - authed_page.get_by_test_id("import-mylar-path-analyze").click() - expect(acknowledgement).not_to_be_checked() - expect(page.start_scan_button).to_be_disabled() - acknowledgement.check() - page.start_scan_button.click() - expect(authed_page.get_by_test_id("import-collection-source")).to_contain_text( - "Test request captured" - ) - assert received[-1]["mylar3_allow_unresolved_paths"] is True - assert received[-1]["mylar3_unresolved_fingerprint"] == "b" * 64 - - def test_mylar_exceptions_paginate_and_search_without_reanalyzing( + + advanced = authed_page.get_by_test_id("import-advanced-options") + expect(advanced.get_by_test_id("import-advanced-options-attention-count")).to_have_text("3") + advanced.locator("summary").first.click() + expect(advanced.get_by_role("button", name="Recheck import setup")).to_be_visible() + expect(authed_page.get_by_test_id("import-mylar-path-analyze")).to_have_count(0) + missing = advanced.locator("article").filter(has_text="One stored Mylar location") + unsafe = advanced.locator("article").filter(has_text="One stored Mylar path is unsafe") + repair = advanced.locator("article").filter(has_text="One manual mapping") + skip = missing.get_by_role("button", name="Skip for this import") + missing_details = missing.get_by_role("button", name="Details") + details = unsafe.get_by_role("button", name="Details") + resolve = repair.get_by_role("button", name="Resolve") + expect(skip).to_be_visible() + expect(skip).to_have_class(re.compile(r"\bbtn-ghost\b")) + expect(missing_details).to_be_visible() + expect(missing.get_by_role("button", name="Resolve")).to_have_count(0) + expect(resolve).to_be_visible() + expect(resolve).to_have_class(re.compile(r"\bbtn-primary\b")) + expect(details).to_be_visible() + expect(details).to_have_class(re.compile(r"\bbtn-ghost\b")) + + unsafe.get_by_role("button", name="Details").click() + modal = authed_page.get_by_test_id("import-attention-details-modal") + expect(modal).to_be_visible() + expect(modal).to_contain_text("Correct unsafe Mylar paths") + expect(modal).to_contain_text("/mnt/comics/unsafe") + expect(modal).to_contain_text("Blocks import") + modal.get_by_role("button", name="OK", exact=True).click() + expect(modal).to_be_hidden() + + resolve.click() + expect(repair).to_be_hidden() + expect(advanced.get_by_test_id("import-advanced-options-attention-count")).to_have_text("2") + + skip.click() + expect(missing).to_be_hidden() + expect(advanced.get_by_test_id("import-advanced-options-attention-count")).to_have_text("1") + assert analysis_count >= 2 + + authed_page.reload() + page = ImportPage(authed_page, seeded_server) + page.show_collection_source_step() + page.source_mylar3_card.click() + page.source_path_input.fill("/imports/mylar.db") + advanced = authed_page.get_by_test_id("import-advanced-options") + expect(advanced.get_by_test_id("import-advanced-options-attention-count")).to_have_text("2") + advanced.locator("summary").first.click() + expect( + advanced.locator("article").filter(has_text="One stored Mylar location") + ).to_be_hidden() + expect(authed_page.get_by_test_id("import-mylar-path-exceptions")).to_have_count(0) + + preview["attention_fingerprint"] = "8" * 64 + advanced.get_by_role("button", name="Recheck import setup").click() + expect(advanced.get_by_test_id("import-advanced-options-attention-count")).to_have_text("3") + expect( + advanced.locator("article").filter(has_text="One stored Mylar location") + ).to_be_visible() + + def test_mylar_preflight_refreshes_without_flashing_existing_controls( self, authed_page: Page, seeded_server: str, - tmp_path: Path, ) -> None: - from urllib.parse import parse_qs, urlparse - from playwright.sync_api import expect - exceptions = [ - { - "series_id": str(index), - "series_name": f"Missing Series {index:02}", - "stored_path": f"/mnt/comics/Missing Series {index:02}", - "attempted_path": f"/mnt/comics/Missing Series {index:02}", - "outcome": "missing", - "reason": "The stored folder is missing.", - "suggested_action": "Check the folder name or review it later.", - } - for index in range(31) - ] - preview = self._identity_mylar_path_response(32) + preview = self._identity_mylar_path_response(3) preview.update( can_confirm=False, can_continue_with_unresolved=True, - requires_unresolved_acknowledgement=True, - unresolved_fingerprint="b" * 64, - exception_count=31, - exceptions=exceptions[:25], - report_id="a" * 32, + attention_fingerprint="c" * 64, + attention_items=[ + { + "key": "missing", + "code": "missing", + "blocks_import": False, + "reason": "One stored Mylar location is missing.", + "suggested_action": "Skip this stale reference for this import.", + "root_path": "/mnt/comics/missing", + "action": { + "kind": "acknowledge_unavailable", + "fingerprint": "e" * 64, + }, + "details": {"title": "Missing", "steps": []}, + }, + { + "key": "unsafe", + "code": "invalid", + "blocks_import": True, + "reason": "One stored Mylar path is unsafe.", + "suggested_action": "Correct the stored path, then recheck it.", + "root_path": "/mnt/comics/unsafe", + "action": None, + "details": {"title": "Unsafe", "steps": []}, + }, + { + "key": "mapping", + "code": "ineffective_mapping", + "blocks_import": False, + "reason": "One manual mapping no longer changes any paths.", + "suggested_action": "Remove the unused mapping.", + "root_path": "/mnt/comics", + "action": { + "kind": "remove_ineffective_mapping", + "fingerprint": "9" * 64, + "stored_prefix": "/old/comics", + "pullbox_prefix": "/mnt/comics", + }, + "details": {"title": "Mapping", "steps": []}, + }, + ], + mappings=[ + { + "stored_prefix": "/old/comics", + "pullbox_prefix": "/mnt/comics", + } + ], ) - analyses: list[str] = [] + requests: list[dict[str, object]] = [] def analyze(route: Route) -> None: - analyses.append(route.request.url) + requests.append(route.request.post_data_json) route.fulfill(json=preview) - def report_page(route: Route) -> None: - params = parse_qs(urlparse(route.request.url).query) - search = params.get("search", [""])[0] - page = int(params.get("page", ["1"])[0]) - found = [item for item in exceptions if search in item["series_name"]] - route.fulfill( - json={ - "items": found[(page - 1) * 25 : page * 25], - "total": len(found), - "page": page, - "page_size": 25, - } - ) - authed_page.route("**/api/v1/import/mylar-path-preview", analyze) - authed_page.route("**/api/v1/import/mylar-path-reports/*?*", report_page) page = ImportPage(authed_page, seeded_server) page.goto(tab="collection") page.show_collection_source_step() page.source_mylar3_card.click() page.source_path_input.fill("/imports/mylar.db") - report = authed_page.get_by_test_id("import-mylar-path-exceptions") - expect(report.locator("tbody tr")).to_have_count(25) - assert report.get_by_role("button", name="Next", exact=True).evaluate( - "el => el.getBoundingClientRect().height >= 32" - ) - report.get_by_role("button", name="Next", exact=True).click() - expect(report.locator("tbody tr")).to_have_count(6) - expect(report).to_contain_text("Missing Series 30") - report.get_by_role("searchbox").fill("Series 07") - expect(report.locator("tbody tr")).to_have_count(1) - expect(report).to_contain_text("Missing Series 07") - assert len(analyses) == 1 - expect(report.get_by_role("link", name="Export full report")).to_have_attribute( - "href", - "/api/v1/import/mylar-path-reports/" + "a" * 32 + "/export", + + advanced = authed_page.get_by_test_id("import-advanced-options") + expect(advanced.get_by_test_id("import-advanced-options-attention-count")).to_have_text("3") + advanced.locator("summary").first.click() + page.mylar_path_section.locator("summary").click() + expect(advanced.get_by_role("button", name="Restore automatic proposals")).to_be_visible() + + authed_page.evaluate( + """() => { + const root = document.querySelector("[data-testid='import-advanced-options']"); + const stableRegions = [ + root.querySelector("[data-testid='import-advanced-options-attention-list']"), + root.querySelector("[data-testid='import-mylar-path-section']"), + ]; + const controls = stableRegions.flatMap(region => + Array.from(region.querySelectorAll("button, input, summary, [role='button']")) + ).filter(control => control.getClientRects().length > 0); + const required = [ + "[data-testid='import-setup-recheck']", + "[data-testid='import-attention-details']", + "[data-testid='import-attention-skip']", + "[data-testid='import-attention-resolve']", + ]; + if ( + required.some(selector => !root.querySelector(selector)) || + !controls.some( + control => control.textContent.trim() === "Restore automatic proposals" + ) + ) { + throw new Error("Expected every Mylar preflight control before observing refreshes."); + } + controls.forEach((control, index) => { + control.dataset.noFlashControl = String(index); + }); + window.__importStableControls = controls; + window.__importControlMutations = []; + window.__importControlObserver = new MutationObserver(records => { + const selectors = controls.map( + (_control, index) => `[data-no-flash-control="${index}"]` + ); + const touchesControl = node => { + if (!node || node.nodeType !== Node.ELEMENT_NODE) return false; + return selectors.some(selector => + node.matches(selector) || + node.closest(selector) || + node.querySelector(selector) + ); + }; + records.forEach(record => { + const nodes = [record.target, ...record.addedNodes, ...record.removedNodes]; + if (nodes.some(touchesControl)) { + window.__importControlMutations.push({ + type: record.type, + attribute: record.attributeName || "", + }); + } + }); + }); + window.__importControlObserver.observe(root, { + subtree: true, + childList: true, + characterData: true, + attributes: true, + attributeFilter: ["class", "disabled", "hidden", "style"], + }); + }""" ) - assert_no_axe_violations( - authed_page, - name="mylar-path-exceptions", - include=["[data-testid=import-mylar-path-exceptions]"], + + page.file_handling_in_place.click() + authed_page.wait_for_timeout(750) + page.file_handling_managed.click() + authed_page.wait_for_timeout(750) + advanced.get_by_role("button", name="Recheck import setup").click() + expect(advanced.get_by_role("button", name="Recheck import setup")).to_be_visible() + advanced.get_by_role("button", name="Restore automatic proposals").click() + expect(advanced.get_by_role("button", name="Restore automatic proposals")).to_be_visible() + assert len(requests) >= 5 + assert requests[-1]["auto_detect"] is True + assert requests[-1]["mappings"] == [] + + stability = authed_page.evaluate( + """() => { + window.__importControlObserver.disconnect(); + return { + mutations: window.__importControlMutations, + sameNodes: window.__importStableControls.every( + (control, index) => + control.isConnected && + document.querySelector(`[data-no-flash-control="${index}"]`) === control + ), + }; + }""" ) - report.screenshot(path=str(tmp_path / "mylar-path-exceptions.png")) - authed_page.set_viewport_size({"width": 390, "height": 844}) - expect(report).to_be_visible() - assert authed_page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") + assert stability == {"mutations": [], "sameNodes": True} @staticmethod def _identity_mylar_path_response(locations: int = 1) -> dict[str, object]: @@ -943,6 +1307,9 @@ def test_import_collection_results_retry_returns_to_execute_flow( import_page.wait_for_htmx() import_page.results_panel.wait_for(state="visible", timeout=5000) + assert import_page.retry_failed_button.count() == 0 + import_page.review_follow_up_button.click() + import_page.wait_for_htmx() assert import_page.retry_failed_button.is_visible() original_job = authed_page.request.get( @@ -1061,13 +1428,17 @@ def test_import_collection_results_places_actions_and_notes_above_detail_cards( """() => { const results = document.querySelector("[data-testid='import-collection-results']"); const children = Array.from(results.children).filter((el) => el.nodeType === Node.ELEMENT_NODE); - const sectionCardIndices = children - .map((el, index) => el.classList.contains("section-card") ? index : -1) + const detailCardIndices = children + .map((el, index) => ( + el.classList.contains("section-card") && + el.dataset.testid !== "import-results-follow-up-action" + ) ? index : -1) .filter((index) => index >= 0); return { + followUpIndex: children.findIndex((el) => el.dataset.testid === "import-results-follow-up-action"), actionIndex: children.findIndex((el) => el.dataset.testid === "import-results-action-bar"), notesIndex: children.findIndex((el) => el.dataset.testid === "import-results-follow-up-notes"), - sectionCardIndices, + detailCardIndices, leftActions: Array.from( results.querySelectorAll("[data-testid='import-results-action-bar-left'] > *"), ).map((el) => (el.textContent || "").trim()), @@ -1086,12 +1457,13 @@ def test_import_collection_results_places_actions_and_notes_above_detail_cards( }""" ) - assert state["actionIndex"] == 0 + assert state["followUpIndex"] == 0 + assert state["actionIndex"] == 1 if state["notesIndex"] >= 0: - assert state["notesIndex"] == 1 - assert state["sectionCardIndices"][0] > state["notesIndex"] + assert state["notesIndex"] == 2 + assert state["detailCardIndices"][0] > state["notesIndex"] else: - assert state["sectionCardIndices"][0] > state["actionIndex"] + assert state["detailCardIndices"][0] > state["actionIndex"] assert "Rollback import" in state["leftActions"][0] assert "View import history" in state["leftActions"] assert state["rightActions"] == ["Archive results", "View series library"] @@ -2755,6 +3127,26 @@ def test_import_collection_source_step_keeps_selection_state_and_opens_browser( import_page.file_browser_modal.wait_for(state="visible", timeout=5000) assert import_page.file_browser_title.text_content() == "Browse Files" + def test_switching_collection_source_clears_the_previous_path( + self, + authed_page, + seeded_server: str, # type: ignore[no-untyped-def] + ) -> None: + from playwright.sync_api import expect + + import_page = ImportPage(authed_page, seeded_server) + import_page.goto(tab="collection") + import_page.show_collection_source_step() + + import_page.source_filesystem_card.click() + import_page.source_path_input.fill("/imports/comics") + import_page.source_mylar3_card.click() + expect(import_page.source_path_input).to_have_value("") + + import_page.source_path_input.fill("/imports/mylar.db") + import_page.source_filesystem_card.click() + expect(import_page.source_path_input).to_have_value("") + def test_import_collection_source_step_previews_selected_layout( self, authed_page, @@ -2823,6 +3215,7 @@ def fulfill_preview(route) -> None: # type: ignore[no-untyped-def] import_page.source_filesystem_card.click() import_page.source_path_input.fill("/imports") + import_page.open_layout_options() import_page.source_layout_publisher_series.click() import_page.source_layout_analyze_button.click() import_page.source_layout_preview.wait_for(state="visible", timeout=5000) @@ -2863,6 +3256,8 @@ def test_import_collection_submits_selected_layout_for_mylar_without_folder_prev authed_page, seeded_server: str, # type: ignore[no-untyped-def] ) -> None: + from playwright.sync_api import expect + created_requests: list[dict[str, object]] = [] layout_preview_requests: list[dict[str, object]] = [] mylar_path_preview_requests: list[dict[str, object]] = [] @@ -2899,20 +3294,18 @@ def fulfill_mylar_path_preview(route) -> None: # type: ignore[no-untyped-def] import_page.source_mylar3_card.click() import_page.source_path_input.fill("/imports/mylar.db") + import_page.open_layout_options() import_page.source_layout_section.wait_for(state="visible", timeout=5000) import_page.source_layout_publisher_series.click() import_page.source_layout_fallback_checkbox.uncheck() - import_page.mylar_path_preview.get_by_text("No mapping is required.").wait_for( - state="visible", - timeout=5000, - ) + expect(import_page.start_scan_button).to_be_enabled(timeout=5000) assert import_page.source_layout_analyze_button.is_hidden() assert import_page.start_scan_button.is_enabled() assert_no_axe_violations( authed_page, name="Mylar source layout controls", - include=["[data-testid='import-collection-layout-section']"], + include=["[data-testid='import-layout-advanced']"], ) import_page.start_scan_button.click() authed_page.wait_for_timeout(100) @@ -2935,6 +3328,8 @@ def test_mylar_in_place_allows_explicit_optional_future_root_without_folder_prev authed_page, seeded_server: str, # type: ignore[no-untyped-def] ) -> None: + from playwright.sync_api import expect + created_requests: list[dict[str, object]] = [] layout_preview_requests: list[dict[str, object]] = [] @@ -2970,20 +3365,21 @@ def fulfill_mylar_path_preview(route) -> None: # type: ignore[no-untyped-def] import_page.source_path_input.fill("/imports/mylar.db") import_page.file_handling_in_place.wait_for(state="visible", timeout=5000) import_page.file_handling_in_place.click() - import_page.mylar_path_preview.get_by_text("No mapping is required.").wait_for( - state="visible", - timeout=5000, - ) + expect(import_page.start_scan_button).to_be_enabled(timeout=5000) - root_selector = authed_page.get_by_test_id("import-in-place-library-root") + import_page.open_file_management_options() + root_selector = import_page.dropdown("import-in-place-library-root") root_selector.wait_for(state="visible", timeout=5000) - root_value = root_selector.locator("option[value]:not([value=''])").first.get_attribute( - "value" - ) + root_selector.locator("[data-dropdown-select-trigger]").click() + root_panel = authed_page.locator("[data-dropdown-select-panel]:visible").first + root_value = root_panel.locator( + "[data-dropdown-option][data-value]:not([data-value=''])" + ).first.get_attribute("data-value") + root_panel.locator("[data-dropdown-option][data-value='']").click() assert root_value - assert root_selector.input_value() == "" + assert import_page.dropdown_value("import-in-place-library-root") == "" assert import_page.start_scan_button.is_enabled() - root_selector.select_option(root_value) + import_page.select_dropdown_option("import-in-place-library-root", root_value) assert import_page.start_scan_button.is_enabled() assert import_page.source_layout_analyze_button.is_hidden() assert "after path mapping" in ( @@ -3006,7 +3402,7 @@ def fulfill_mylar_path_preview(route) -> None: # type: ignore[no-untyped-def] assert created_requests[-1]["mylar3_path_map"] == {} assert created_requests[-1]["mylar3_path_map_confirmed"] is True - def test_mylar_mapping_preview_requires_confirmation_and_submits_frozen_map( + def test_mylar_mapping_preview_submits_validated_frozen_map_without_extra_confirmation( self, authed_page, seeded_server: str, # type: ignore[no-untyped-def] @@ -3091,10 +3487,9 @@ def fulfill_create(route) -> None: # type: ignore[no-untyped-def] import_page.source_mylar3_card.click() import_page.source_path_input.fill("/imports/mylar.db") + import_page.open_mylar_path_options() import_page.mylar_path_mapping_rows.first.wait_for(state="visible", timeout=5000) assert import_page.mylar_path_mapping_rows.count() == 2 - assert import_page.start_scan_button.is_disabled() - import_page.mylar_path_confirm.check() assert import_page.start_scan_button.is_enabled() assert_no_axe_violations( authed_page, @@ -3107,8 +3502,6 @@ def fulfill_create(route) -> None: # type: ignore[no-untyped-def] ) first_visible_path.fill("/comics/edited") authed_page.wait_for_timeout(750) - assert import_page.start_scan_button.is_disabled() - import_page.mylar_path_confirm.check() assert import_page.start_scan_button.is_enabled() import_page.start_scan_button.click() authed_page.wait_for_timeout(100) @@ -3117,11 +3510,13 @@ def fulfill_create(route) -> None: # type: ignore[no-untyped-def] assert created_requests[-1]["mylar3_path_map"] == frozen_map assert created_requests[-1]["mylar3_path_map_confirmed"] is True - def test_import_collection_shows_story_arc_evidence_and_submits_independent_choices( + def test_import_collection_defers_story_arc_choices_from_collection_import( self, authed_page, seeded_server: str, # type: ignore[no-untyped-def] ) -> None: + from playwright.sync_api import expect + created_requests: list[dict[str, object]] = [] def fulfill_arc_preview(route) -> None: # type: ignore[no-untyped-def] @@ -3216,36 +3611,15 @@ def fulfill_mylar_path_preview(route) -> None: # type: ignore[no-untyped-def] import_page.source_mylar3_card.click() import_page.source_path_input.fill("/imports/mylar.db") - import_page.mylar_path_preview.get_by_text("No mapping is required.").wait_for( - state="visible", - timeout=5000, - ) - import_page.story_arc_section.wait_for(state="visible", timeout=5000) - - preview_text = import_page.story_arc_preview.text_content() or "" - assert "2 arcs" in preview_text - assert "3 entries" in preview_text - assert "Knightfall" in preview_text - assert "No provider calls" in preview_text - assert "/private" not in preview_text - assert import_page.story_arc_import_toggle.is_checked() is False - assert import_page.story_arc_materialize_toggle.is_disabled() - - import_page.story_arc_import_toggle.check() - assert import_page.story_arc_materialize_toggle.is_enabled() - import_page.story_arc_materialize_toggle.check() - assert_no_axe_violations( - authed_page, - name="Story Arc Step 1 controls", - include=["[data-testid='import-story-arc-section']"], - ) + expect(import_page.start_scan_button).to_be_enabled(timeout=5000) + assert import_page.story_arc_section.count() == 0 import_page.start_scan_button.click() authed_page.wait_for_timeout(100) assert created_requests - assert created_requests[-1]["story_arc_import_requested"] is True - assert created_requests[-1]["story_arc_materialization_requested"] is True + assert created_requests[-1]["story_arc_import_requested"] is False + assert created_requests[-1]["story_arc_materialization_requested"] is False def test_import_collection_submits_validated_in_place_mode( self, @@ -3302,6 +3676,8 @@ def fulfill_create(route) -> None: # type: ignore[no-untyped-def] import_page.source_filesystem_card.click() import_page.source_path_input.fill("/comics/Existing Layout") import_page.file_handling_in_place.click() + import_page.open_file_management_options() + import_page.open_layout_options() import_page.source_layout_analyze_button.click() import_page.file_handling_in_place_ready.wait_for(state="visible", timeout=5000) @@ -3313,7 +3689,7 @@ def fulfill_create(route) -> None: # type: ignore[no-untyped-def] assert created_requests[-1]["file_handling_mode"] == "in_place" assert created_requests[-1]["source_path"] == "/comics/Existing Layout" - def test_import_collection_submits_confirmed_future_root_policy( + def test_import_collection_defers_future_root_policy_from_collection_import( self, authed_page, seeded_server: str, # type: ignore[no-untyped-def] @@ -3443,39 +3819,18 @@ def fulfill_create(route) -> None: # type: ignore[no-untyped-def] import_page.source_filesystem_card.click() import_page.source_path_input.fill("/imports") + import_page.open_layout_options() import_page.source_layout_publisher_series.click() import_page.source_layout_analyze_button.click() import_page.source_layout_preview.wait_for(state="visible", timeout=5000) - assert import_page.future_layout_toggle.is_enabled() - - import_page.future_layout_toggle.check() - import_page.future_layout_preview.wait_for(state="visible", timeout=5000) - assert "Batman (2024)" in (import_page.future_layout_preview.text_content() or "") - assert "DC Comics/Batman" in (import_page.future_layout_preview.text_content() or "") - assert policy_preview_requests - assert policy_preview_requests[-1]["examples"][0] == { # type: ignore[index] - "publisher": "DC Comics", - "series": "Batman", - "year": 2024, - "issue_number": 17, - "issue_title": "The Brave and the Bold", - } - assert_no_axe_violations( - authed_page, - name="import future layout controls", - include=["[data-testid='import-future-layout-section']"], - ) + assert import_page.future_layout_toggle.count() == 0 + assert policy_preview_requests == [] import_page.start_scan_button.click() authed_page.wait_for_timeout(100) assert created_requests payload = created_requests[-1] - assert payload["future_layout_requested"] is True + assert payload["future_layout_requested"] is False assert isinstance(payload["target_library_root_id"], int) - assert payload["future_root_policy"]["series_path_template"] == ( # type: ignore[index] - "{Publisher}/{Series}" - ) - assert payload["future_root_policy"]["comic_file_template"] == ( # type: ignore[index] - "{Series} {IssueTitle} Issue {Issue:03d}" - ) + assert payload["future_root_policy"] is None diff --git a/tests/e2e/test_import_orphaned_page.py b/tests/e2e/test_import_orphaned_page.py index 4600f99..b0920a5 100644 --- a/tests/e2e/test_import_orphaned_page.py +++ b/tests/e2e/test_import_orphaned_page.py @@ -1,4 +1,4 @@ -"""Focused browser coverage for the Import workspace unmatched tab.""" +"""Focused browser coverage for the Import workspace Follow-up tab.""" from __future__ import annotations @@ -10,7 +10,13 @@ class TestImportUnmatchedTab: - """Behavior-first E2E checks for the Import workspace unmatched tab.""" + """Behavior-first E2E checks for completed-import follow-up.""" + + @staticmethod + def _open_batman_follow_up(import_page: ImportPage) -> None: + import_page.goto(tab="follow-up") + import_page.follow_up_jobs_table.wait_for(state="visible", timeout=5000) + import_page.open_follow_up_job("/tmp/imports/batman-batch") def test_import_orphaned_renders_stable_shell( self, @@ -18,11 +24,14 @@ def test_import_orphaned_renders_stable_shell( seeded_server: str, # type: ignore[no-untyped-def] ) -> None: import_page = ImportPage(authed_page, seeded_server) - import_page.goto(tab="unmatched") + import_page.goto(tab="follow-up") assert import_page.workspace_root.is_visible() assert import_page.unmatched_panel.is_visible() assert import_page.header.is_visible() + assert import_page.follow_up_job_list.is_visible() + assert import_page.follow_up_jobs_table.is_visible() + import_page.open_follow_up_job("/tmp/imports/batman-batch") assert import_page.unmatched_view_tabs.is_visible() assert import_page.unmatched_results.is_visible() assert import_page.unmatched_table.is_visible() @@ -34,12 +43,12 @@ def test_import_orphaned_tab_switch_keeps_shell_stable( seeded_server: str, # type: ignore[no-untyped-def] ) -> None: import_page = ImportPage(authed_page, seeded_server) - import_page.goto(tab="unmatched") + import_page.goto(tab="follow-up") import_page.select_unmatched_view("dismissed") import_page.wait_for_htmx() - assert authed_page.locator("[data-testid='import-orphaned-page']").count() == 1 + assert authed_page.locator("[data-testid='import-follow-up-page']").count() == 1 assert authed_page.locator("[data-testid='import-orphaned-tabs']").count() == 1 assert authed_page.locator("[data-testid='import-orphaned-results']").count() == 1 assert import_page.workspace_root.is_visible() @@ -53,7 +62,7 @@ def test_import_orphaned_cv_search_modal_opens_without_disturbing_shell( seeded_server: str, # type: ignore[no-untyped-def] ) -> None: import_page = ImportPage(authed_page, seeded_server) - import_page.goto(tab="unmatched") + self._open_batman_follow_up(import_page) import_page.search_button_for_row("Batman and Robin Eternal").click() import_page.wait_for_htmx() @@ -71,7 +80,7 @@ def test_import_orphaned_actions_use_icon_buttons_with_tooltips( seeded_server: str, # type: ignore[no-untyped-def] ) -> None: import_page = ImportPage(authed_page, seeded_server) - import_page.goto(tab="unmatched") + self._open_batman_follow_up(import_page) action_state = authed_page.evaluate( """() => { @@ -109,7 +118,7 @@ def test_import_orphaned_recovery_actions_use_icon_buttons_and_modal_hides_root_ seeded_server: str, # type: ignore[no-untyped-def] ) -> None: import_page = ImportPage(authed_page, seeded_server) - import_page.goto(tab="unmatched") + self._open_batman_follow_up(import_page) state = authed_page.evaluate( """() => { @@ -161,7 +170,7 @@ def test_import_orphaned_dismiss_uses_app_confirm_modal_not_browser_dialog( authed_page.on("dialog", lambda dialog: browser_dialogs.append(dialog.message)) import_page = ImportPage(authed_page, seeded_server) - import_page.goto(tab="unmatched") + self._open_batman_follow_up(import_page) dismiss_button = ( import_page.unmatched_row("Batman and Robin Eternal") diff --git a/tests/e2e/test_navigation.py b/tests/e2e/test_navigation.py index ac74c0f..610fb33 100644 --- a/tests/e2e/test_navigation.py +++ b/tests/e2e/test_navigation.py @@ -372,7 +372,7 @@ def test_import_history_page_loads(self, authed_page, seeded_server: str) -> Non self._assert_no_js_errors() def test_import_orphaned_page_loads(self, authed_page, seeded_server: str) -> None: # type: ignore[no-untyped-def] - authed_page.goto(f"{seeded_server}/import?tab=unmatched") + authed_page.goto(f"{seeded_server}/import?tab=follow-up") assert "/login" not in authed_page.url self._assert_no_js_errors() diff --git a/tests/e2e/test_settings_page.py b/tests/e2e/test_settings_page.py index c670974..48a8485 100644 --- a/tests/e2e/test_settings_page.py +++ b/tests/e2e/test_settings_page.py @@ -18,6 +18,83 @@ class TestSettingsPage: """Behavior-first E2E checks for the settings shell.""" + @pytest.mark.parametrize("blocked", [False, True]) + def test_disabled_root_removal_preview_confirmation_and_cancel( + self, + authed_page, + seeded_server: str, + blocked: bool, + ) -> None: # type: ignore[no-untyped-def] + root = { + "id": 702, + "name": "Offline archive", + "path": "/offline/library", + "enabled": False, + "is_default_managed_destination": False, + "allow_managed_writes": True, + "allow_referenced_registrations": True, + "status": "unavailable", + "available": False, + "readable": False, + "writable": False, + "free_bytes": None, + "can_disable": True, + "warnings": [], + } + removed = [] + errors = [] + authed_page.on("pageerror", lambda error: errors.append(str(error))) + authed_page.route( + "**/api/v1/config/library-roots", + lambda route: route.fulfill( + json=[root], + ), + ) + authed_page.route( + "**/api/v1/config/library-roots/702/remove/preview", + lambda route: route.fulfill( + json={ + **root, + "can_remove": not blocked, + "history_count": 1, + "has_naming_policy": False, + "dependencies": {"series": int(blocked)}, + "preview_token": None if blocked else "signed-preview", + "blocking_reasons": ["1 series still uses this root. Relocate it first."] + if blocked + else [], + }, + ), + ) + + def delete_root(route): # type: ignore[no-untyped-def] + removed.append(route.request.post_data_json) + assert route.request.method == "DELETE" + route.fulfill(status=204) + + authed_page.route("**/api/v1/config/library-roots/702", delete_root) + SettingsPage(authed_page, seeded_server).goto("media") + card = authed_page.get_by_test_id("settings-media-library-root-702") + card.get_by_role("button", name="Remove Offline archive").click() + preview = card.get_by_test_id("settings-media-library-root-removal") + preview.wait_for(state="visible") + assert "No folders or files will be deleted." in preview.inner_text() + confirm = preview.get_by_role("button", name="Confirm removal") + if blocked: + assert confirm.is_disabled() + assert "1 series" in preview.inner_text() + else: + confirm.click() + dialog = authed_page.locator("#pb-confirm-dialog") + dialog.get_by_role("button", name="Cancel", exact=True).click() + assert removed == [] + assert card.is_visible() + confirm.click() + dialog.get_by_role("button", name="Remove root", exact=True).click() + card.wait_for(state="detached") + assert removed == [{"preview_token": "signed-preview", "confirmation": "REMOVE"}] + assert errors == [] + def test_media_library_root_manager_previews_and_adds_a_root( self, authed_page, diff --git a/tests/integration/test_import_api.py b/tests/integration/test_import_api.py index b8a62ca..9ca8549 100644 --- a/tests/integration/test_import_api.py +++ b/tests/integration/test_import_api.py @@ -1499,6 +1499,244 @@ async def fail(*_args, **_kwargs): } ] + async def test_corroborated_comicinfo_replaces_stale_mylar_issue_identity( + self, + db_session, + tmp_path, + ) -> None: + class ExplodingProvider: + def __getattr__(self, method_name: str): + async def fail(*_args, **_kwargs): + raise AssertionError(f"ComicVine method {method_name} must not be called") + + return fail + + series_dir = tmp_path / "comics" / "Dark Nights - Death Metal Omnibus (2023)" + file_path = series_dir / "Dark Nights - Death Metal Omnibus 01.cbz" + file_path.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(file_path, "w", zipfile.ZIP_STORED) as archive: + archive.writestr("page001.jpg", b"\xff\xd8\xff\xd9") + archive.writestr( + "ComicInfo.xml", + """ + + Dark Nights: Death Metal Omnibus + 1 + HC + Trade Paper Back + [cv_vol_id:166912] [cv_issue_id:1132072] + + """, + ) + mylar_db = tmp_path / "mylar.db" + create_mylar3_db( + mylar_db, + series=[ + { + "ComicID": "CV-166912", + "ComicName": "Dark Nights - Death Metal Omnibus", + "ComicYear": "2023", + "ComicPublisher": "DC Comics", + "ComicLocation": str(series_dir), + "Total": 1, + } + ], + issues=[ + { + "IssueID": "899000001", + "ComicID": "CV-166912", + "ComicName": "Dark Nights - Death Metal Omnibus", + "IssueName": "HC", + "Issue_Number": "1", + "Location": file_path.name, + "IssueDate": "2024-01-01", + } + ], + ) + metadata_service = AsyncMock() + metadata_service._provider = ExplodingProvider() + service = _make_import_service( + series_service=_mock_series_service({}), + metadata_service=metadata_service, + ) + job = await service.create_job( + db_session, + ImportJobCreate( + source_path=str(mylar_db), + source_type=ImportSourceType.MYLAR3, + mylar3_path_map_confirmed=True, + ), + ) + + await service.start_scan(db_session, job.id) + + series_item = await db_session.scalar( + sa_select(ImportedSeries).where(ImportedSeries.import_job_id == job.id) + ) + imported_file = await db_session.scalar( + sa_select(ImportedFile).where(ImportedFile.import_job_id == job.id) + ) + assert series_item is not None + assert imported_file is not None + assert series_item.status == ImportSeriesStatus.MATCHED + assert series_item.files_matched == 0 + assert imported_file.status == ImportedFileStatus.SAFETY_BLOCKED + + await service.allow_safety_blocked_file_once( + db_session, + job.id, + imported_file.id, + ) + await service.rematch_imported_series_files(db_session, job.id, series_item.id) + + assert series_item.files_matched == 1 + assert series_item.files_no_match == 0 + assert imported_file.status == ImportedFileStatus.MATCHED + assert imported_file.comicvine_issue_id == 1132072 + assert imported_file.matched_issue_cv_id == 1132072 + source_metadata = imported_file.diagnostics["source_metadata"] + assert source_metadata["mylar3_issue_identity_reconciliation"] == { + "recorded_comicvine_issue_id": 899000001, + "embedded_comicvine_issue_id": 1132072, + "comicvine_series_id": 166912, + "issue_number": 1.0, + "method": "corroborated_embedded_comicinfo", + } + assert "identity_conflicts" not in source_metadata + + async def test_preinspected_comicinfo_reconciles_stale_mylar_volume_during_scan( + self, + db_session, + tmp_path, + ) -> None: + class ExplodingProvider: + def __getattr__(self, method_name: str): + async def fail(*_args, **_kwargs): + raise AssertionError(f"ComicVine method {method_name} must not be called") + + return fail + + series_dir = tmp_path / "comics" / "Aliens Epic Collection (2023)" + series_dir.mkdir(parents=True, exist_ok=True) + file_specs = ( + (1, 848973001, 978613, 2), + (2, 1067468, 1067468, 2), + (3, 848973003, 1144431, 1), + ) + file_paths: dict[int, Path] = {} + for number, _recorded_issue_id, embedded_issue_id, page_count in file_specs: + suffix = f" - Volume {number}" if number == 2 else "" + file_path = series_dir / f"Aliens Epic Collection (2023) Vol {number:02}{suffix}.cbz" + file_paths[number] = file_path + with zipfile.ZipFile(file_path, "w", zipfile.ZIP_STORED) as archive: + for page in range(1, page_count + 1): + archive.writestr(f"page{page:03}.jpg", b"\xff\xd8\xff\xd9") + archive.writestr( + "ComicInfo.xml", + ( + "" + "" + "Aliens Epic Collection" + f"{number}" + f"Volume {number}" + "Marvel" + f"https://comicvine.gamespot.com/volume-{number}/" + f"4000-{embedded_issue_id}/" + "[cv_vol_id:148973]" + "" + ), + ) + mylar_db = tmp_path / "mylar.db" + create_mylar3_db( + mylar_db, + series=[ + { + "ComicID": "CV-148973", + "ComicName": "Aliens Epic Collection", + "ComicYear": "2023", + "ComicPublisher": "Marvel", + "ComicLocation": str(series_dir), + "Total": 3, + } + ], + issues=[ + { + "IssueID": str(recorded_issue_id), + "ComicID": "CV-148973", + "ComicName": "Aliens Epic Collection", + "IssueName": ( + f"Aliens Epic Collection (2023) Vol {number:02}" + if number != 2 + else "Volume 2" + ), + "Issue_Number": str(number), + "Location": file_paths[number].name, + "IssueDate": f"{2022 + number}-01-01", + } + for number, recorded_issue_id, _embedded_issue_id, _page_count in file_specs + ], + ) + metadata_service = AsyncMock() + metadata_service._provider = ExplodingProvider() + service = _make_import_service( + series_service=_mock_series_service({}), + metadata_service=metadata_service, + ) + job = await service.create_job( + db_session, + ImportJobCreate( + source_path=str(mylar_db), + source_type=ImportSourceType.MYLAR3, + mylar3_path_map_confirmed=True, + ), + ) + + await service.start_scan(db_session, job.id) + + series_item = await db_session.scalar( + sa_select(ImportedSeries).where(ImportedSeries.import_job_id == job.id) + ) + imported_files = list( + await db_session.scalars( + sa_select(ImportedFile) + .where(ImportedFile.import_job_id == job.id) + .order_by(ImportedFile.parsed_issue_number) + ) + ) + assert series_item is not None + assert series_item.status == ImportSeriesStatus.MATCHED + assert series_item.files_matched == 2 + assert series_item.files_no_match == 0 + assert [item.status for item in imported_files] == [ + ImportedFileStatus.MATCHED, + ImportedFileStatus.MATCHED, + ImportedFileStatus.SAFETY_BLOCKED, + ] + assert imported_files[0].comicvine_issue_id == 978613 + assert imported_files[0].matched_issue_cv_id == 978613 + source_metadata = imported_files[0].diagnostics["source_metadata"] + assert source_metadata["mylar3_issue_identity_reconciliation"] == { + "recorded_comicvine_issue_id": 848973001, + "embedded_comicvine_issue_id": 978613, + "comicvine_series_id": 148973, + "issue_number": 1.0, + "method": "corroborated_embedded_comicinfo", + } + assert "identity_conflicts" not in source_metadata + + await service.allow_safety_blocked_file_once( + db_session, + job.id, + imported_files[2].id, + ) + await service.rematch_imported_series_files(db_session, job.id, series_item.id) + + assert series_item.files_matched == 3 + assert series_item.files_no_match == 0 + assert imported_files[2].status == ImportedFileStatus.MATCHED + assert imported_files[2].comicvine_issue_id == 1144431 + assert imported_files[2].matched_issue_cv_id == 1144431 + async def test_agreeing_mylar_sidecar_and_comicinfo_stays_provider_free( self, db_session, diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py index 3de57a7..e9eb41c 100644 --- a/tests/integration/test_migrations.py +++ b/tests/integration/test_migrations.py @@ -1870,6 +1870,47 @@ def test_library_root_management_downgrade_refuses_unrepresentable_default( finally: engine.dispose() + def test_root_removal_protects_dependencies_without_losing_other_fk_actions(self, alembic_cfg): + cfg, sync_url = alembic_cfg + script = ScriptDirectory.from_config(cfg) + assert script.get_heads() == ["n5h6i7j8k901"] + assert script.get_revision("n5h6i7j8k901").down_revision == _IMPORT_JOB_ARCHIVE_REVISION + command.upgrade(cfg, _IMPORT_JOB_ARCHIVE_REVISION) + engine = create_engine(sync_url) + + def actions(): + with engine.connect() as conn: + return { + (table, row[3]): row[6] + for table in ( + "library_files", + "series", + "story_arcs", + "story_arc_placements", + "import_jobs", + ) + for row in conn.exec_driver_sql(f'PRAGMA foreign_key_list("{table}")') + } + + before = actions() + command.upgrade(cfg, "head") + after = actions() + for (table, column), action in before.items(): + assert after[table, column] == ( + "RESTRICT" + if column + in { + "library_root_id", + "target_library_root_id", + "preferred_library_root_id", + } + else action + ) + assert "removed_library_root_snapshot" in _get_columns(sync_url, "import_jobs") + command.downgrade(cfg, _IMPORT_JOB_ARCHIVE_REVISION) + assert actions() == before + engine.dispose() + def test_import_job_archival_extends_the_single_migration_head( self, alembic_cfg, @@ -1878,7 +1919,10 @@ def test_import_job_archival_extends_the_single_migration_head( cfg, sync_url = alembic_cfg script = ScriptDirectory.from_config(cfg) - assert script.get_heads() == [_IMPORT_JOB_ARCHIVE_REVISION] + assert ( + script.get_revision(_IMPORT_JOB_ARCHIVE_REVISION).down_revision + == _IMPORT_FILE_DELETE_REFERENCE_INDEX_REVISION + ) command.upgrade(cfg, "head") engine = create_engine(sync_url) @@ -2050,7 +2094,7 @@ def test_series_preferred_root_backfills_only_managed_destinations( assert columns["preferred_library_root_id"]["nullable"] is True assert len(preferred_fks) == 1 assert preferred_fks[0]["referred_table"] == "library_roots" - assert preferred_fks[0]["options"].get("ondelete") == "SET NULL" + assert preferred_fks[0]["options"].get("ondelete") == "RESTRICT" assert [tuple(row) for row in rows] == [ ("Managed Series", roots["Managed"]), ("Referenced Series", None), diff --git a/tests/integration/test_series_folder_schema.py b/tests/integration/test_series_folder_schema.py index b6121fa..f0f63a2 100644 --- a/tests/integration/test_series_folder_schema.py +++ b/tests/integration/test_series_folder_schema.py @@ -7,7 +7,8 @@ from __future__ import annotations import pytest -from sqlalchemy import select +from sqlalchemy import select, text +from sqlalchemy.exc import IntegrityError from pullbox.models.issue import Issue, IssueStatus, IssueType from pullbox.models.library import LibraryRoot @@ -116,8 +117,9 @@ async def test_library_root_series_backref(self, db_session) -> None: titles = {s.title for s in root.series} assert titles == {"Batman", "Saga", "Invincible"} - async def test_series_library_root_set_null_on_delete(self, db_session) -> None: - """Deleting a library root sets series.library_root_id to NULL.""" + async def test_series_library_root_prevents_deletion(self, db_session) -> None: + """A root cannot silently detach the series still using it.""" + await db_session.execute(text("PRAGMA foreign_keys=ON")) root = LibraryRoot(name="Comics", path="/comics", enabled=True) db_session.add(root) await db_session.flush() @@ -133,13 +135,17 @@ async def test_series_library_root_set_null_on_delete(self, db_session) -> None: await db_session.flush() series_id = series.id - await db_session.delete(root) - await db_session.flush() + root_id = root.id + await db_session.commit() + with pytest.raises(IntegrityError): + await db_session.delete(root) + await db_session.flush() + await db_session.rollback() # Re-fetch the series reloaded = await db_session.get(Series, series_id) assert reloaded is not None - assert reloaded.library_root_id is None + assert reloaded.library_root_id == root_id async def test_multiple_series_in_same_root(self, db_session) -> None: """Multiple series can reference the same library root.""" diff --git a/tests/services/test_reader_state_service.py b/tests/services/test_reader_state_service.py index d01a512..23e6b8b 100644 --- a/tests/services/test_reader_state_service.py +++ b/tests/services/test_reader_state_service.py @@ -537,6 +537,29 @@ async def test_snapshot_derives_progress_completion_and_explicit_unread_state( assert unread.after.is_continue_candidate is True +@pytest.mark.asyncio +async def test_snapshot_keeps_started_large_books_visibly_in_progress( + db_session: AsyncSession, +) -> None: + user_id, issue_id = await _seed_user_and_issue(db_session) + + progress = await update_reader_progress( + db_session, + user_id=user_id, + issue_id=issue_id, + revision="large-compendium", + page_index=0, + page_count=1_250, + completion_candidate=False, + expected_revision="large-compendium", + expected_page_count=1_250, + ) + + assert progress.after.has_progress is True + assert progress.after.position_percent == 1 + assert progress.after.is_continue_candidate is True + + @pytest.mark.asyncio async def test_concurrent_initial_progress_writes_create_one_state_row(tmp_path) -> None: engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'reader-state.db'}") diff --git a/tests/ui/test_dashboard_ui_routes.py b/tests/ui/test_dashboard_ui_routes.py index 7e24f5b..23d2cf9 100644 --- a/tests/ui/test_dashboard_ui_routes.py +++ b/tests/ui/test_dashboard_ui_routes.py @@ -359,6 +359,30 @@ async def test_dashboard_continue_shelf_is_bounded_to_eight_cards( assert "Open reading queue" in response.text assert 'hx-trigger="every 3s"' not in response.text + async def test_dashboard_continue_shelf_shows_progress_for_large_compendium( + self, + authenticated_client, + sec_db, + sec_user, + ) -> None: # type: ignore[no-untyped-def] + from tests.ui.test_reading_ui_routes import _seed_reading_items + + await _seed_reading_items( + sec_db, + sec_user, + count=1, + mode="continue", + last_page_index=0, + page_count=1_250, + ) + + response = await authenticated_client.get("/") + + assert response.status_code == 200 + assert "Page 1 of 1250 · 1%" in response.text + assert 'aria-valuenow="1"' in response.text + assert 'style="width: 1%"' in response.text + async def test_reader_gate_hides_dashboard_continue_shelf( self, authenticated_client, diff --git a/tests/ui/test_import_collection_shell_ui_routes.py b/tests/ui/test_import_collection_shell_ui_routes.py index 6ef731e..677d88f 100644 --- a/tests/ui/test_import_collection_shell_ui_routes.py +++ b/tests/ui/test_import_collection_shell_ui_routes.py @@ -758,11 +758,21 @@ async def test_shared_log_viewer_keys_namespace_persisted_and_stream_rows(self) assert "'stream:' + entry._streamToken" in template assert "'synthetic:' + idx" in template - async def test_import_review_shell_reprocesses_htmx_after_morph_swaps(self) -> None: + async def test_import_review_shell_morphs_without_resetting_the_viewport(self) -> None: script = Path("src/pullbox/ui/static/js/pullbox.js").read_text() review_template = Path( "src/pullbox/ui/templates/partials/import_step_review.html" ).read_text() + related_templates = [ + review_template, + Path( + "src/pullbox/ui/templates/partials/import_story_arc_review_table.html" + ).read_text(), + Path("src/pullbox/ui/templates/partials/import_review_footer_dock.html").read_text(), + ] + source_cleanup_template = Path( + "src/pullbox/ui/templates/partials/import_source_cleanup_modal.html" + ).read_text() assert 'settledTarget.id === "import-step-review"' in script assert 'settledTarget.id === "conflicts-content"' in script @@ -775,6 +785,34 @@ async def test_import_review_shell_reprocesses_htmx_after_morph_swaps(self) -> N assert 'performHtmxSwap("GET", this.buildRefreshUrl(),' in script assert "openConflictView: function () {" not in script assert "x-on:import:open-conflicts.window" not in review_template + load_start = script.index("function loadImportReviewShell(url)") + load_end = script.index("function pbFormatDurationMs", load_start) + loader = script[load_start:load_end] + assert "captureImportReviewViewport(currentShell)" in loader + assert "Idiomorph.morph(currentShell, nextShell" in loader + assert "restoreImportReviewViewport(viewportState" in loader + assert "destroyAlpineTree(currentShell)" in loader + assert "Alpine.initTree(activeShell)" in loader + assert "currentShell.replaceWith(nextShell)" not in loader + for template in related_templates: + assert 'hx-swap="outerHTML"' not in template + assert 'hx-target="#import-step-review-shell"' in source_cleanup_template + assert 'hx-swap="morph:outerHTML"' in source_cleanup_template + + async def test_htmx_swaps_initialize_alpine_once_before_settle(self) -> None: + script = Path("src/pullbox/ui/static/js/pullbox.js").read_text() + after_swap_start = script.index('document.addEventListener("htmx:afterSwap"') + after_swap_end = script.index("function _isPrimaryUnmodifiedClick", after_swap_start) + after_swap_listener = script[after_swap_start:after_swap_end] + after_settle_start = script.index('document.addEventListener("htmx:afterSettle"') + after_settle_end = script.index( + 'document.addEventListener("htmx:oobAfterSwap"', after_settle_start + ) + after_settle_listener = script[after_settle_start:after_settle_end] + + assert "function initializePreparedAlpineSwap(detail)" in script + assert "initializePreparedAlpineSwap(e.detail || {});" in after_swap_listener + assert "Alpine.initTree(settledTarget)" not in after_settle_listener async def test_import_review_bulk_selection_uses_canonical_shell_refresh( self, @@ -794,6 +832,90 @@ async def test_import_review_bulk_selection_uses_canonical_shell_refresh( ) assert "selectCurrentPageMatched" not in script + async def test_import_review_uses_the_expanded_review_workspace_as_primary_surface( + self, + ) -> None: + script = Path("src/pullbox/ui/static/js/pullbox.js").read_text() + template = Path("src/pullbox/ui/templates/partials/import_step_review.html").read_text() + + assert "importAllReady: async function () {" not in script + assert 'data-testid="import-review-guided-summary"' not in template + assert 'data-testid="import-review-import-ready"' not in template + assert "Import all ready comics" not in template + assert 'data-testid="import-review-details"' not in template + assert "Review details" not in template + assert 'data-testid="import-review-action-bar"' in template + assert "data-import-review-selection-summary" in template + assert "data-import-review-import-button" in template + assert 'data-testid="import-review-series-filters"' in template + + async def test_import_review_row_expansion_is_stable_and_action_scoped(self) -> None: + script = Path("src/pullbox/ui/static/js/pullbox.js").read_text() + template = Path("src/pullbox/ui/templates/partials/import_step_review.html").read_text() + + assert 'id="import-review-series-row-{{ active_review_view }}-{{ item.id }}"' in template + assert 'data-import-review-row-key="{{ active_review_view }}:{{ item.id }}"' in template + assert 'data-import-review-pending-subitems="{{ pending_subitem_count }}"' in template + assert "importReviewRowExpansionData" not in template + assert '@click="toggle()"' not in template + assert 'x-show="expanded"' not in template + assert "data-import-review-expand-action" in template + assert "data-import-review-detail-row" in template + assert "function toggleImportReviewRow(button)" in script + assert "function setImportReviewRowExpanded(row, expanded)" in script + assert "expandedRows:" in script + assert "previousPendingSubitems > 0 && nextPendingSubitems === 0" in script + assert "captureImportReviewViewport(target, requestElement)" in script + + async def test_import_review_restores_expanded_rows_before_swap_can_paint(self) -> None: + script = Path("src/pullbox/ui/static/js/pullbox.js").read_text() + after_swap_start = script.index('document.addEventListener("htmx:afterSwap"') + after_swap_end = script.index("function _isPrimaryUnmodifiedClick", after_swap_start) + after_swap_listener = script[after_swap_start:after_swap_end] + + assert "function restoreImportReviewExpansionState(state, shell)" in script + assert "restoreImportReviewExpansionState(" in after_swap_listener + assert "pendingImportReviewViewportState" in after_swap_listener + + async def test_import_review_cancel_survives_shell_morphs_and_uses_global_dialog( + self, + ) -> None: + script = Path("src/pullbox/ui/static/js/pullbox.js").read_text() + template = Path("src/pullbox/ui/templates/partials/import_step_review.html").read_text() + + assert 'data-testid="import-review-cancel"' in template + assert 'onclick="cancelImportReview(this)"' in template + assert 'data-import-review-cancel-job-id="{{ job.id }}"' in template + assert "data-import-review-cancel-label" in template + assert "showCancelModal" not in template + assert 'class="modal-shell"' not in template + assert "function cancelImportReview(button)" in script + assert "window.cancelImportReview = cancelImportReview;" in script + assert 'confirmText: "Cancel Import"' in script + assert 'method: "DELETE"' in script + + async def test_import_source_uses_automatic_nonblocking_defaults( + self, + authenticated_client, + ) -> None: # type: ignore[no-untyped-def] + script = Path("src/pullbox/ui/static/js/pullbox.js").read_text() + template = Path("src/pullbox/ui/templates/partials/import_step_source.html").read_text() + response = await authenticated_client.get("/import") + + assert response.status_code == 200 + assert 'data-testid="import-mylar-path-confirm"' not in response.text + assert 'data-testid="import-mylar-unresolved-confirm"' not in response.text + assert "missing or stale Mylar references" not in template + assert 'data-testid="import-layout-advanced"' in template + assert 'data-testid="import-story-arc-section"' not in response.text + assert 'data-testid="import-future-layout-section"' not in response.text + assert "Start library scan" in template + assert "Analyze library" not in template + assert "Recheck import setup" in template + assert "Analyze Mylar paths" not in template + assert 'mylar3_allow_unresolved_paths: this.sourceType === "mylar3" &&' in script + assert "this.mylarPathPreview.can_continue_with_unresolved" in script + async def test_import_conflict_commit_state_requires_visible_group_match(self) -> None: script = Path("src/pullbox/ui/static/js/pullbox.js").read_text() @@ -823,7 +945,8 @@ async def test_import_collection_renders_standardized_shell( assert 'data-testid="import-collection-footer-dock"' in response.text assert "importCollectionFooterData({" in response.text assert 'data-testid="import-tab-collection"' in response.text - assert 'data-testid="import-tab-unmatched"' in response.text + assert 'data-testid="import-tab-follow-up"' in response.text + assert 'href="/import?tab=follow-up"' in response.text assert 'data-testid="import-tab-history"' in response.text assert 'data-testid="import-collection-page"' in response.text assert 'data-testid="import-collection-stepper"' in response.text @@ -834,39 +957,129 @@ async def test_import_collection_renders_standardized_shell( assert 'data-testid="import-collection-source-mylar3"' in response.text assert "sourceType === 'filesystem' ? '/imports' : '/imports/mylar.db'" in response.text assert 'data-testid="import-collection-source-browse"' in response.text - assert "Collection imports preserve source files." in response.text - assert "Files and folders in the selected source stay untouched" in response.text + assert "Collection imports preserve source files." not in response.text + assert "Files and folders in the selected source stay untouched" not in response.text assert 'data-testid="import-mylar-path-section"' in response.text - assert 'data-testid="import-mylar-path-analyze"' in response.text + assert 'data-testid="import-setup-recheck"' in response.text + assert response.text.index('data-testid="import-setup-recheck"') < response.text.index( + 'data-testid="import-mylar-path-section"' + ) + assert 'data-testid="import-mylar-path-analyze"' not in response.text assert 'data-testid="import-mylar-path-mapping-row"' in response.text assert 'data-testid="import-mylar-path-add"' in response.text - assert 'data-testid="import-mylar-path-confirm"' in response.text - assert 'data-testid="import-mylar-path-total"' in response.text - assert 'data-testid="import-mylar-path-unmapped"' in response.text - assert 'data-testid="import-mylar-path-invalid"' in response.text - assert 'data-testid="import-mylar-path-identity-groups"' in response.text + assert 'data-testid="import-mylar-path-confirm"' not in response.text + assert 'data-testid="import-mylar-unresolved-confirm"' not in response.text + assert 'data-testid="import-mylar-stale-reference-notice"' not in response.text + assert 'data-testid="import-mylar-path-technical-summary"' not in response.text + assert 'data-testid="import-mylar-path-total"' not in response.text + assert 'data-testid="import-mylar-path-unmapped"' not in response.text + assert 'data-testid="import-mylar-path-invalid"' not in response.text + assert 'data-testid="import-mylar-path-identity-groups"' not in response.text assert 'data-testid="import-mylar-path-mapping-blockers"' in response.text assert 'data-testid="import-mylar-path-mapping-examples"' in response.text - assert 'data-testid="import-mylar-path-warnings"' in response.text + assert 'data-testid="import-mylar-path-warnings"' not in response.text + assert 'data-testid="import-mylar-path-problem-groups"' in response.text + assert 'data-testid="import-mylar-path-optional-follow-up"' not in response.text + assert 'data-testid="import-mylar-path-exception-details"' not in response.text + assert 'data-testid="import-mylar-register-reference-root"' not in response.text assert "Path stored in Mylar" in response.text assert "Path visible inside Pullbox" in response.text assert 'data-testid="import-file-handling-section"' in response.text + assert 'data-testid="import-file-handling-choice-section"' in response.text assert 'data-testid="import-file-handling-managed"' in response.text assert 'data-testid="import-file-handling-in-place"' in response.text assert "Copy into Pullbox library" in response.text assert "Keep files in place" in response.text assert "rename, convert, rewrite metadata, or change permissions on them." in response.text + assert 'data-testid="import-source-preservation"' not in response.text + assert 'data-testid="import-advanced-options"' in response.text + assert 'data-testid="import-advanced-options-attention-count"' in response.text + assert 'data-testid="import-advanced-options-checking"' in response.text + assert ( + "sourceType === 'mylar3' && mylarPathPreviewLoading && !mylarPathPreview" + in response.text + ) + assert "Checking..." in response.text + assert 'data-testid="import-advanced-options-attention-list"' in response.text + assert 'data-testid="import-advanced-options-attention-icon"' in response.text + assert 'data-testid="import-attention-status-row"' in response.text + assert 'data-testid="import-attention-resolve"' in response.text + assert 'data-testid="import-attention-skip"' in response.text + assert 'data-testid="import-attention-details"' in response.text + resolve_button = response.text[ + response.text.index('data-testid="import-attention-resolve"') - 500 : + ] + details_button = response.text[ + response.text.index('data-testid="import-attention-details"') - 300 : + ] + skip_button = response.text[ + response.text.index('data-testid="import-attention-skip"') - 300 : + ] + assert 'class="btn-primary btn-sm' in resolve_button + assert 'class="btn-ghost btn-sm' in details_button + assert 'class="btn-ghost btn-sm' in skip_button + assert "Skip for this import" in response.text + assert 'data-testid="import-attention-action-error"' in response.text + assert 'data-testid="import-attention-details-modal"' in response.text + assert 'data-testid="import-attention-details-status"' in response.text + assert 'data-testid="import-attention-details-paths"' in response.text + assert 'data-testid="import-attention-details-steps"' in response.text + assert 'data-testid="import-attention-details-ok"' in response.text + assert 'role="dialog"' in response.text + assert 'aria-modal="true"' in response.text + assert "M12 9v3.75m9.303 3.376c.866 1.5-.217 3.374-1.948 3.374H4.645" in response.text + assert 'data-testid="import-advanced-file-management"' in response.text + assert 'data-testid="import-advanced-file-management-summary"' in response.text + assert 'data-testid="import-advanced-file-management-chevron"' in response.text + assert 'data-testid="import-advanced-file-management-copy"' in response.text + assert 'data-testid="import-mylar-path-summary"' in response.text + assert 'data-testid="import-mylar-path-summary-chevron"' in response.text + assert 'data-testid="import-mylar-path-summary-copy"' in response.text + assert 'data-testid="import-advanced-scan-options"' in response.text + assert "Advanced Options" in response.text + assert "Where new files go" in response.text + assert "Advanced file management" not in response.text + assert "Advanced scan options" not in response.text + assert "Check existing Mylar library access" not in response.text + assert "Manual path mapping" in response.text + assert "Mylar library access details" not in response.text + assert "Diagnostic report" not in response.text + assert "Optional troubleshooting details" not in response.text + assert "Some stored locations need a verified container-path mapping" not in response.text + assert ( + "Some folders or files are missing inside an accessible library root" + not in response.text + ) + assert "Automatic analysis could not resolve every location" not in response.text assert 'data-testid="import-managed-library-root"' in response.text + assert response.text.count('data-dropdown-select-contract="v1"') >= 2 + assert " 1 || !this.hasSelectedManagedDestination()" in ( + source_controller + ) assert 'fetch("/api/v1/import/story-arc-preview"' in source_controller assert 'fetch("/api/v1/import/mylar-path-preview"' in source_controller assert "mylarPathPreviewRequestId" in source_controller + assert "advancedAttentionItems: function" in source_controller + assert "advancedAttentionCount: function" in source_controller + assert "return this.advancedAttentionItems().length" in source_controller + assert "this.mylarPathPreview.attention_items" in source_controller + assert "resolveAdvancedAttention: async function" in source_controller + assert "skipAdvancedAttention: async function" in source_controller + assert "restoreSkippedAttentionActions: function" in source_controller + assert "persistSkippedAttentionActions: function" in source_controller + assert "sessionStorage" in source_controller + assert "openAdvancedAttentionDetails: function" in source_controller + assert "closeAdvancedAttentionDetails: function" in source_controller + assert 'case "register_reference_root"' in source_controller + assert 'case "remove_ineffective_mapping"' in source_controller + assert 'case "select_managed_destination"' in source_controller + assert 'case "switch_to_managed_copy"' not in source_controller + attention_start = source_controller.index("advancedAttentionItems: function") + attention_end = source_controller.index("advancedAttentionCount: function", attention_start) + attention_controller = source_controller[attention_start:attention_end] + assert "can_register_reference_root" not in attention_controller + assert 'code: "source_outside_library_root"' in attention_controller + assert 'kind: "register_reference_root"' in attention_controller + resolve_start = source_controller.index("resolveAdvancedAttention: async function") + resolve_end = source_controller.index( + "skipAdvancedAttention: async function", + resolve_start, + ) + resolve_controller = source_controller[resolve_start:resolve_end] + assert 'case "acknowledge_unavailable"' not in resolve_controller assert "clearMylarPathPreview: function" in source_controller assert "mylarPathMappingChanged: function" in source_controller assert "this.mylarPathPreview.can_confirm" in source_controller - assert "this.mylarPathPreview.requires_confirmation" in source_controller + assert "this.mylarPathPreview.requires_confirmation" not in source_controller assert 'mylar3_path_map: this.sourceType === "mylar3"' in source_controller assert 'mylar3_path_map_confirmed: this.sourceType === "mylar3"' in source_controller assert "storyArcImportRequested: false" in source_controller assert "storyArcMaterializationRequested: false" in source_controller - assert "story_arc_import_requested: this.storyArcImportRequested" in source_controller - assert ( - "story_arc_materialization_requested: this.storyArcMaterializationRequested" - in source_controller + assert source_controller.count("this.scheduleStoryArcPreview();") == 0 + assert "story_arc_import_requested: false" in source_controller + assert "story_arc_materialization_requested: false" in source_controller + + async def test_import_source_switch_and_recheck_preserve_ui_contract(self) -> None: + script = Path("src/pullbox/ui/static/js/pullbox.js").read_text() + start = script.index("function importSourceData") + end = script.index("function importJobLogViewerData", start) + source_controller = script[start:end] + select_start = source_controller.index("selectSourceType: function") + select_end = source_controller.index("selectImportSource: function", select_start) + select_source = source_controller[select_start:select_end] + preview_start = source_controller.index("previewMylarPaths: async function") + preview_end = source_controller.index( + "mylarPathMappingEvidence: function", + preview_start, ) + preview = source_controller[preview_start:preview_end] + + assert "previousSourceType" in select_source + assert 'this.sourcePath = "";' in select_source + assert "previousSourceType !== sourceType" in select_source + assert "this.mylarPathPreview = null;" not in preview + + async def test_import_review_renders_detailed_statuses_without_a_disclosure_gate( + self, + authenticated_client, + sec_db, + ) -> None: # type: ignore[no-untyped-def] + job_id = await _seed_import_review_job(sec_db) + + response = await authenticated_client.get(f"/import/{job_id}/review-partial") + + assert response.status_code == 200 + assert 'data-testid="import-review-details"' not in response.text + assert 'data-testid="import-review-needs-attention-action"' not in response.text + assert 'data-testid="import-review-story-arc-follow-up-action"' not in response.text + assert "Review details" not in response.text + assert 'data-testid="import-review-series-filters"' in response.text + assert "data-import-review-import-button" in response.text async def test_split_series_review_requires_future_destination_without_relocation(self) -> None: template = Path("src/pullbox/ui/templates/partials/import_step_review.html").read_text() @@ -949,7 +1229,8 @@ async def test_split_series_review_requires_future_destination_without_relocatio assert 'data-testid="import-review-split-series"' in template assert 'data-testid="import-review-split-series-item"' in template - assert 'data-testid="import-review-preferred-root"' in template + assert 'root_testid="import-review-preferred-root"' in template + assert "dropdown_select(" in template assert "Existing files remain in place" in template assert "splitSeriesRequiresPreferredRoot" in review_controller assert "hasRequiredPreferredRoot" in review_controller @@ -1162,6 +1443,10 @@ async def test_import_progress_partial_uses_shared_progress_contract( assert 'data-log-viewer-contract="v1"' not in response.text assert 'data-testid="import-progress-log-download"' in response.text assert f'href="/api/v1/import/{job_id}/logs/download"' in response.text + log_download = response.text.split( + 'data-testid="import-progress-log-download"', maxsplit=1 + )[1].split("", maxsplit=1)[0] + assert 'class="btn-ghost btn-sm inline-flex items-center gap-2 shrink-0"' in (log_download) async def test_import_progress_partial_hydrates_review_snapshot( self, @@ -1595,16 +1880,20 @@ async def test_import_review_partial_renders_series_no_match_diagnostics( assert 'id="import-step-review-shell"' in response.text assert "reviewToken:" in response.text assert 'hx-target="#import-step-review-shell"' in response.text - assert 'hx-swap="outerHTML"' in response.text + assert 'hx-swap="morph:outerHTML"' in response.text assert "data-import-review-toolbar-selection-summary" in response.text assert 'data-testid="import-review-save-conflict-choices"' not in response.text assert 'data-testid="import-review-reset-conflict-choices"' not in response.text assert '@click="saveConflictChoices()"' not in response.text assert '@click="resetConflictChoices()"' not in response.text assert "Save conflict choices" not in response.text + assert "Review your library" not in response.text + assert "Import everything Pullbox understands now." not in response.text action_bar_index = response.text.index('data-testid="import-review-action-bar"') status_bar_index = response.text.index('data-testid="import-review-series-filters"') assert action_bar_index < status_bar_index + assert 'data-testid="import-review-guided-summary"' not in response.text + assert 'data-testid="import-review-details"' not in response.text async def test_import_review_partial_explains_selected_layout_review( self, @@ -1666,7 +1955,7 @@ async def test_import_review_partial_explains_incompatible_mylar_path( assert "The mapped Mylar comic folder is not available to Pullbox." in response.text assert "Correct the Mylar path mapping and retry this import." in response.text - async def test_import_review_repeats_confirmed_mylar_mapping_snapshot( + async def test_import_review_hides_confirmed_mylar_mapping_snapshot( self, authenticated_client, sec_db, @@ -1688,19 +1977,20 @@ async def test_import_review_repeats_confirmed_mylar_mapping_snapshot( response = await authenticated_client.get(f"/import/{job_id}/review-partial") assert response.status_code == 200 - assert 'data-testid="import-review-mylar-path-snapshot"' in response.text - assert "Confirmed Mylar path mapping" in response.text - assert "/books/current" in response.text - assert "/comics/current" in response.text - assert "/books/archive" in response.text - assert "/comics/archive" in response.text + assert 'data-testid="import-review-mylar-path-snapshot"' not in response.text + assert "Confirmed Mylar path mapping" not in response.text + assert "frozen Step 1 snapshot" not in response.text async def test_import_review_partial_renders_matched_file_target_tables( self, authenticated_client, sec_db, ) -> None: # type: ignore[no-untyped-def] - from pullbox.models.import_job import ImportedFile, ImportedFileStatus, ImportedSeries + from pullbox.models.import_job import ( + ImportedFile, + ImportedFileStatus, + ImportedSeries, + ) job_id = await _seed_import_review_job(sec_db) async with sec_db() as session: @@ -2316,7 +2606,11 @@ async def test_import_series_details_partial_duplicate_copy_rows_are_read_only( ) -> None: # type: ignore[no-untyped-def] from sqlalchemy import select - from pullbox.models.import_job import ImportedFile, ImportedFileStatus, ImportedSeries + from pullbox.models.import_job import ( + ImportedFile, + ImportedFileStatus, + ImportedSeries, + ) job_id = await _seed_import_review_job(sec_db) async with sec_db() as session: @@ -2633,7 +2927,7 @@ async def test_import_review_needs_issue_match_view_uses_reconcile_action( ) assert response.status_code == 200 - assert "Reconcile for Import" in response.text + assert "Review your library" not in response.text assert "Needs Issue Match" in response.text assert "Needs issue" in response.text assert 'data-testid="import-review-reconcile-action"' in response.text @@ -2671,7 +2965,7 @@ async def test_import_review_needs_series_match_view_keeps_comicvine_search( ) assert response.status_code == 200 - assert "Reconcile for Import" in response.text + assert "Review your library" not in response.text assert "Needs Series Match" in response.text assert 'data-testid="import-review-search-cv-action"' in response.text assert 'data-testid="import-review-reconcile-action"' not in response.text @@ -2716,10 +3010,11 @@ async def test_import_review_safety_blocked_view_shows_allow_and_skip_actions( ) assert response.status_code == 200 - assert "Blocked Files" in response.text + assert "Safety Review" in response.text assert "Safety review" in response.text assert "Oversized Omnibus.cbz" in response.text assert 'data-testid="import-review-safety-category-summary"' in response.text + assert 'data-testid="import-review-safety-category-details"' in response.text assert "Decompression-size limit" in response.text assert "Code: archive_decompressed_size_limit" in response.text assert "Retry alone will not help" in response.text @@ -2782,6 +3077,99 @@ async def test_import_review_non_overrideable_safety_block_hides_allow_action( assert 'data-testid="import-review-skip-safety-file"' in response.text assert "/safety/skip?status=safety_blocked" in response.text + async def test_import_review_one_page_category_offers_source_preserving_bulk_skip( + self, + authenticated_client, + sec_db, + tmp_path, + ) -> None: # type: ignore[no-untyped-def] + from sqlalchemy import select + + from pullbox.core.library_file_ownership import build_file_identity_signature + from pullbox.models.config import SystemConfig + from pullbox.models.import_job import ( + ImportedFile, + ImportedFileStatus, + ImportedSeries, + ImportJob, + ) + + job_id = await _seed_import_review_job(sec_db) + source = tmp_path / "possible-cover.cbz" + source.write_bytes(b"one-page source") + async with sec_db() as session: + series = await session.get(ImportedSeries, 1) + assert series is not None + series.diagnostics = {"safety_blocked_files": 1} + job = await session.get(ImportJob, job_id) + assert job is not None + job.source_path = str(tmp_path) + trash_config = await session.scalar( + select(SystemConfig).where(SystemConfig.key == "utility_trash_folder") + ) + if trash_config is None: + session.add( + SystemConfig( + key="utility_trash_folder", + value=str(tmp_path / "trash"), + value_type="string", + ) + ) + else: + trash_config.value = str(tmp_path / "trash") + imported_file = ImportedFile( + import_job_id=job_id, + import_series_id=series.id, + file_path=str(source), + file_name=source.name, + file_size=source.stat().st_size, + file_format="cbz", + status=ImportedFileStatus.SAFETY_BLOCKED, + include_in_import=True, + source_signature=build_file_identity_signature(source), + diagnostics={ + "safety_block": { + "kind": "file_safety_blocked", + "code": "single_page_comic", + "category": "single_page_comic", + "reason": "The archive contains one image page.", + "details": [], + "overrideable": True, + } + }, + ) + session.add(imported_file) + await session.flush() + file_id = imported_file.id + await session.commit() + + response = await authenticated_client.get( + f"/import/{job_id}/review-partial?status=safety_blocked" + ) + assert response.status_code == 200 + assert "One-page archive" in response.text + assert 'data-testid="import-review-safety-bulk-skip-preview-single_page_comic"' in ( + response.text + ) + assert 'data-testid="import-review-source-cleanup-preview"' not in response.text + + preview = await authenticated_client.get( + f"/import/{job_id}/safety/categories/single_page_comic/skip-preview" + ) + assert preview.status_code == 200 + assert "Skip these files from this import" in preview.text + assert "SKIP FILES" not in preview.text + assert "Source files will not be changed or deleted." in preview.text + + cleanup_preview = await authenticated_client.get( + f"/import/{job_id}/files/{file_id}/safety/source-cleanup-preview" + ) + assert cleanup_preview.status_code == 200 + assert 'data-testid="import-source-cleanup-modal"' in cleanup_preview.text + assert "Move source to Trash" in cleanup_preview.text + assert "This changes the source library" in cleanup_preview.text + assert "Type MOVE TO TRASH" not in cleanup_preview.text + async def test_import_review_keeps_approved_file_in_safety_view_while_rematching( self, authenticated_client, @@ -2817,10 +3205,69 @@ async def test_import_review_keeps_approved_file_in_safety_view_while_rematching assert "Preparing this file for matching" in response.text assert "Preparing match" in response.text assert 'data-testid="import-review-safety-rematch-spinner"' in response.text + assert 'id="import-safety-rematch-poll"' in response.text + assert f'hx-get="/import/{job_id}/review-rematch-status"' in response.text assert 'hx-trigger="every 2s [window.pullboxLiveUpdatesEnabled()]"' in response.text assert 'data-testid="import-review-allow-safety-file"' not in response.text assert 'data-testid="import-review-skip-safety-file"' not in response.text + pending_response = await authenticated_client.get(f"/import/{job_id}/review-rematch-status") + assert pending_response.status_code == 200 + assert 'id="import-safety-rematch-poll"' in pending_response.text + assert 'hx-trigger="every 2s [window.pullboxLiveUpdatesEnabled()]"' in ( + pending_response.text + ) + assert "HX-Trigger" not in pending_response.headers + + async with sec_db() as session: + refreshed_series = await session.get(ImportedSeries, 1) + assert refreshed_series is not None + diagnostics = dict(refreshed_series.diagnostics or {}) + diagnostics.pop("rematch_pending", None) + refreshed_series.diagnostics = diagnostics + approved_file = await session.scalar( + select(ImportedFile).where( + ImportedFile.import_job_id == job_id, + ImportedFile.status == ImportedFileStatus.SAFETY_APPROVED, + ) + ) + assert approved_file is not None + approved_file.status = ImportedFileStatus.MATCHED + await session.commit() + + completed_response = await authenticated_client.get( + f"/import/{job_id}/review-rematch-status" + ) + assert completed_response.status_code == 200 + assert 'id="import-safety-rematch-poll"' in completed_response.text + assert "hx-trigger=" not in completed_response.text + assert completed_response.headers["HX-Trigger"] == "import:review-refresh" + + async def test_import_review_rematch_poll_escapes_job_id_in_html_attribute( + self, + ) -> None: + from unittest.mock import AsyncMock, patch + + from pullbox.ui.import_routes import import_review_rematch_status + + session = AsyncMock() + session.get.return_value = object() + hostile_job_id = '1" autofocus onfocus="alert(1)' + + with patch( + "pullbox.ui.import_routes.has_pending_import_safety_rematch", + new=AsyncMock(return_value=True), + ): + response = await import_review_rematch_status( # type: ignore[arg-type] + hostile_job_id, + object(), # type: ignore[arg-type] + session, + ) + + body = response.body.decode("utf-8") + assert hostile_job_id not in body + assert "" autofocus onfocus="alert(1)" in body + async def test_import_review_returns_to_all_when_safety_tab_becomes_empty( self, authenticated_client, @@ -2836,6 +3283,74 @@ async def test_import_review_returns_to_all_when_safety_tab_becomes_empty( assert 'name="review_status_filter" value=""' in response.text assert 'data-import-review-view="series"' in response.text + async def test_import_review_reports_remaining_subitems_after_each_safety_skip( + self, + authenticated_client, + sec_db, + ) -> None: # type: ignore[no-untyped-def] + from pullbox.api.middleware import SESSION_COOKIE_NAME + from pullbox.models.import_job import ImportedFile, ImportedFileStatus, ImportedSeries + from pullbox.services.auth_service import AuthService + + job_id = await _seed_import_review_job(sec_db) + async with sec_db() as session: + series = await session.get(ImportedSeries, 1) + assert series is not None + files = list( + ( + await session.execute( + select(ImportedFile) + .where(ImportedFile.import_series_id == series.id) + .order_by(ImportedFile.id.asc()) + ) + ) + .scalars() + .all() + ) + assert len(files) == 3 + for imp_file in files[:2]: + imp_file.status = ImportedFileStatus.SAFETY_BLOCKED + imp_file.include_in_import = False + imp_file.error_message = "Archive requires safety review" + imp_file.diagnostics = { + "safety_block": { + "kind": "file_safety_blocked", + "reason": "Archive requires safety review", + "overrideable": True, + } + } + series.files_matched = 1 + series.diagnostics = {"safety_blocked_files": 2} + await session.commit() + series_id = series.id + blocked_file_ids = [imp_file.id for imp_file in files[:2]] + + response = await authenticated_client.get(f"/import/{job_id}/review-partial") + assert response.status_code == 200 + assert f'id="import-review-series-row-series-{series_id}"' in response.text + assert 'data-import-review-pending-subitems="2"' in response.text + + session_token = authenticated_client.cookies.get(SESSION_COOKIE_NAME) + assert session_token + csrf_header = {"x-csrf-token": AuthService.get_csrf_token_from_session(session_token) or ""} + first_skip = await authenticated_client.post( + f"/import/{job_id}/files/{blocked_file_ids[0]}/safety/skip?status=series", + headers=csrf_header, + ) + assert first_skip.status_code == 200 + assert f'id="import-review-series-row-series-{series_id}"' in first_skip.text + assert 'data-import-review-pending-subitems="1"' in first_skip.text + assert files[0].file_name not in first_skip.text + assert files[1].file_name in first_skip.text + + final_skip = await authenticated_client.post( + f"/import/{job_id}/files/{blocked_file_ids[1]}/safety/skip?status=series", + headers=csrf_header, + ) + assert final_skip.status_code == 200 + assert f'id="import-review-series-row-series-{series_id}"' in final_skip.text + assert 'data-import-review-pending-subitems="0"' in final_skip.text + async def test_import_review_allow_safety_file_post_refreshes_review( self, authenticated_client, diff --git a/tests/ui/test_import_dropdown_contract.py b/tests/ui/test_import_dropdown_contract.py new file mode 100644 index 0000000..6e3be43 --- /dev/null +++ b/tests/ui/test_import_dropdown_contract.py @@ -0,0 +1,30 @@ +"""Import-workflow dropdown contract coverage.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +IMPORT_TEMPLATE_ROOT = Path("src/pullbox/ui/templates") +IMPORT_WORKFLOW_TEMPLATES = sorted( + [ + IMPORT_TEMPLATE_ROOT / "pages/import.html", + *(IMPORT_TEMPLATE_ROOT / "partials").glob("import*.html"), + IMPORT_TEMPLATE_ROOT / "partials/issue_import_progress_modal.html", + ] +) + + +@pytest.mark.parametrize( + "template_path", + IMPORT_WORKFLOW_TEMPLATES, + ids=lambda path: path.name, +) +def test_import_workflow_has_no_native_dropdowns(template_path: Path) -> None: + """Every import screen must use the shared dropdown-select contract.""" + template = template_path.read_text(encoding="utf-8") + + assert " None: # type: ignore[no-untyped-def] + response = await authenticated_client.get("/import?tab=history") + + assert response.status_code == 200 + assert len(re.findall(r'(?\n Show current\n " in archived_response.text + @pytest.mark.parametrize("source_type", ["filesystem", "mylar3"]) async def test_step_four_explains_that_import_continues_in_background( self, diff --git a/tests/ui/test_import_orphaned_shell_ui_routes.py b/tests/ui/test_import_orphaned_shell_ui_routes.py index 23f4284..6d62953 100644 --- a/tests/ui/test_import_orphaned_shell_ui_routes.py +++ b/tests/ui/test_import_orphaned_shell_ui_routes.py @@ -1,4 +1,4 @@ -"""Route-contract tests for the unified Import unmatched tab.""" +"""Route-contract tests for the unified Import Follow-up tab.""" from __future__ import annotations @@ -15,8 +15,8 @@ @pytest.mark.asyncio -class TestImportUnmatchedTabRouteContracts: - """Verify unmatched series is mounted under the unified Import workspace.""" +class TestImportFollowUpTabRouteContracts: + """Verify Follow-up is mounted under the unified Import workspace.""" async def test_legacy_unmatched_route_redirects_to_unified_import_page( self, @@ -25,7 +25,7 @@ async def test_legacy_unmatched_route_redirects_to_unified_import_page( response = await authenticated_client.get("/import/orphaned") assert response.status_code == 307 - assert response.headers["location"] == "/import?tab=unmatched&view=all" + assert response.headers["location"] == "/import?tab=follow-up&view=all" async def test_import_orphaned_renders_standardized_shell( self, @@ -40,12 +40,11 @@ async def test_import_orphaned_renders_standardized_shell( assert 'data-testid="import-header-gauges"' not in response.text assert 'data-testid="import-tabs"' in response.text assert 'data-testid="import-footer-dock"' in response.text - assert 'data-testid="import-orphaned-page"' in response.text + assert 'data-testid="import-follow-up-page"' in response.text assert 'data-testid="import-orphaned-body"' in response.text - assert 'data-testid="import-orphaned-tabs"' in response.text - assert 'data-testid="import-orphaned-view"' in response.text assert 'data-testid="import-orphaned-results"' in response.text - assert 'data-testid="import-orphaned-table-shell"' in response.text + assert 'data-testid="import-follow-up-job-list"' in response.text + assert 'data-testid="import-follow-up-view"' in response.text assert 'data-testid="import-orphaned-modal-host"' in response.text async def test_import_orphaned_hx_request_returns_results_bundle( @@ -66,4 +65,4 @@ async def test_import_orphaned_hx_request_returns_results_bundle( assert 'data-testid="page-footer-dock"' in response.text assert 'hx-swap-oob="outerHTML"' in response.text assert 'data-testid="import-content"' not in response.text - assert 'data-testid="import-orphaned-page"' not in response.text + assert 'data-testid="import-follow-up-page"' not in response.text diff --git a/tests/ui/test_import_redirect_routes.py b/tests/ui/test_import_redirect_routes.py index 02f59d4..6c4a74d 100644 --- a/tests/ui/test_import_redirect_routes.py +++ b/tests/ui/test_import_redirect_routes.py @@ -26,4 +26,4 @@ async def test_import_orphaned_redirect_preserves_legacy_filters() -> None: ) assert response.status_code == 307 - assert response.headers["location"] == "/import?tab=unmatched&view=dismissed&page=3" + assert response.headers["location"] == "/import?tab=follow-up&view=dismissed&page=3" diff --git a/tests/ui/test_import_results_context.py b/tests/ui/test_import_results_context.py index c3fdf7f..bd959cc 100644 --- a/tests/ui/test_import_results_context.py +++ b/tests/ui/test_import_results_context.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import UTC, datetime +from unittest.mock import AsyncMock, patch import pytest @@ -18,7 +19,13 @@ ImportSourceType, ) from pullbox.models.issue import Issue -from pullbox.models.library import FileFormat, LibraryFile, LibraryRoot, MatchConfidence +from pullbox.models.library import ( + FileFormat, + LibraryFile, + LibraryFileStorageMode, + LibraryRoot, + MatchConfidence, +) from pullbox.models.series import IssueCatalogState, Series from pullbox.models.story_arc import ( ImportedStoryArcStatus, @@ -39,6 +46,29 @@ ) +@pytest.mark.asyncio +async def test_results_context_counts_optional_mylar_source_cleanup(db_session) -> None: # type: ignore[no-untyped-def] + from pullbox.ui.import_results_context import load_import_results_context + + job = ImportJob( + source_path="/tmp/mylar.db", + source_type=ImportSourceType.MYLAR3, + status=ImportJobStatus.COMPLETED, + ) + db_session.add(job) + await db_session.flush() + + counter = AsyncMock(side_effect=[2, 3]) + with patch( + "pullbox.ui.import_results_context.count_misplaced_source_cleanup_files", + counter, + ): + context = await load_import_results_context(db_session, job) + + assert context["misplaced_source_restore_count"] == 2 + assert context["misplaced_source_duplicate_count"] == 3 + + @pytest.mark.asyncio async def test_load_import_results_context_splits_unmatched_queue_counts(db_session) -> None: # type: ignore[no-untyped-def] from pullbox.ui.import_results_context import load_import_results_context @@ -103,6 +133,52 @@ async def test_load_import_results_context_splits_unmatched_queue_counts(db_sess assert context["identified_series_file_no_match_count"] == 1 assert context["no_match_count"] == 1 assert context["unmatched_queue_count"] == 2 + assert context["cleanup_needs_review_count"] == 0 + assert context["follow_up_group_count"] == 1 + + +@pytest.mark.asyncio +async def test_results_count_retryable_failed_inspection_as_safe_next_step(db_session) -> None: # type: ignore[no-untyped-def] + from pullbox.ui.import_results_context import load_import_results_context + + job = ImportJob( + source_path="/tmp/comics", + source_type=ImportSourceType.FILESYSTEM, + status=ImportJobStatus.COMPLETED, + ) + db_session.add(job) + await db_session.flush() + imported_series = ImportedSeries( + import_job_id=job.id, + raw_series_name="Archive Retry", + status=ImportSeriesStatus.IMPORTED, + ) + db_session.add(imported_series) + await db_session.flush() + db_session.add( + ImportedFile( + import_job_id=job.id, + import_series_id=imported_series.id, + file_path="/tmp/comics/archive.cbr", + file_name="archive.cbr", + file_size=1024, + file_format="cbr", + status=ImportedFileStatus.FAILED, + diagnostics={ + "source_revalidation": { + "category": ImportSafetyCategory.ARCHIVE_INSPECTION_FAILED.value, + "retryable": True, + } + }, + ) + ) + await db_session.flush() + + context = await load_import_results_context(db_session, job) + + assert context["cleanup_safe_action_count"] == 1 + assert context["cleanup_needs_review_count"] == 0 + assert context["cleanup_action_summaries"][0]["action"] == "retry_source_inspection" @pytest.mark.asyncio @@ -575,6 +651,39 @@ async def test_arc_only_results_count_only_completed_verified_story_arc_placemen assert context["referenced_files_registered"] == 1 assert context["rollback_managed_candidates"] == 1 assert context["rollback_reference_candidates"] == 1 + assert context["story_arcs_created_count"] == 1 + assert context["story_arcs_follow_up_count"] == 0 + + +@pytest.mark.asyncio +async def test_results_report_unresolved_story_arcs_as_optional_follow_up( + db_session, +) -> None: # type: ignore[no-untyped-def] + from pullbox.ui.import_results_context import load_import_results_context + + job = ImportJob( + source_path="/imports/mylar.db", + source_type=ImportSourceType.MYLAR3, + status=ImportJobStatus.COMPLETED, + ) + db_session.add(job) + await db_session.flush() + db_session.add( + ImportedStoryArc( + import_job=job, + source_kind=StoryArcSourceKind.FOLDER, + source_key=f"folder:follow-up:{job.id}", + source_ordinal=1, + name="Review Later Arc", + status=ImportedStoryArcStatus.NEEDS_REVIEW, + ) + ) + await db_session.flush() + + context = await load_import_results_context(db_session, job) + + assert context["story_arcs_created_count"] == 0 + assert context["story_arcs_follow_up_count"] == 1 @pytest.mark.asyncio @@ -686,6 +795,149 @@ async def test_completed_results_group_large_safety_backlog_without_loading_ever assert context["cleanup_action_summaries"][0]["affected_file_count"] == 10 +@pytest.mark.asyncio +async def test_completed_results_offer_mixed_folder_ownership_recovery(db_session) -> None: # type: ignore[no-untyped-def] + from pullbox.ui.import_results_context import load_import_results_context + + job = ImportJob( + source_path="/imports/mylar.db", + source_type=ImportSourceType.MYLAR3, + status=ImportJobStatus.COMPLETED, + ) + source_series = ImportedSeries( + import_job=job, + raw_series_name="Fritzi Ritz", + cv_title="Fritzi Ritz", + status=ImportSeriesStatus.IMPORTED, + ) + target = Series( + title="Action Comics", + sort_title="action comics", + year_start=1938, + monitored=True, + ) + db_session.add_all([job, source_series, target]) + await db_session.flush() + issue = Issue(series_id=target.id, issue_number=1002, issue_number_text="1002") + target_import_series = ImportedSeries( + import_job=job, + raw_series_name="Action Comics", + cv_title="Action Comics", + status=ImportSeriesStatus.IMPORTED, + series_id=target.id, + ) + db_session.add_all([issue, target_import_series]) + await db_session.flush() + db_session.add( + ImportedFile( + import_job=job, + import_series=source_series, + file_path="/comics/Fritzi Ritz/Action Comics 1002.cbz", + file_name="Action Comics 1002.cbz", + file_size=1024, + file_format="cbz", + status=ImportedFileStatus.NO_MATCH, + diagnostics={ + "metadata_signals": { + "series_name": "comicinfo", + "issue_number": "comicinfo", + }, + "source_metadata": {"comicinfo": {"series": "Action Comics", "number": "1002"}}, + }, + ) + ) + await db_session.flush() + + context = await load_import_results_context(db_session, job) + + summaries = {item["action"]: item for item in context["cleanup_action_summaries"]} + recovery = summaries["resolve_mixed_folder_files"] + assert recovery["label"] == "Resolve mixed-folder files" + assert recovery["affected_file_count"] == 1 + assert recovery["button_label"] == "Resolve and retry" + assert context["clean_library_mixed_folder_repair_count"] == 1 + + +@pytest.mark.asyncio +async def test_completed_reference_import_offers_separate_clean_library_root( + db_session, + tmp_path, +) -> None: # type: ignore[no-untyped-def] + from pullbox.core.library_file_ownership import build_file_identity_signature + from pullbox.ui.import_results_context import load_import_results_context + + source_path = tmp_path / "mylar" / "Batman" / "Batman 001.cbr" + source_path.parent.mkdir(parents=True) + source_path.write_bytes(b"legacy") + clean_path = tmp_path / "clean" + clean_path.mkdir() + source_root = LibraryRoot(name="Mylar", path=str(tmp_path / "mylar")) + clean_root = LibraryRoot(name="Clean library", path=str(clean_path)) + nested_root = LibraryRoot(name="Unsafe nested", path=str(source_path.parent)) + series = Series(title="Batman", sort_title="batman", year_start=2016) + db_session.add_all([source_root, clean_root, nested_root, series]) + await db_session.flush() + issue = Issue(series_id=series.id, issue_number=1.0, issue_number_text="1") + job = ImportJob( + source_path="/imports/mylar.db", + source_type=ImportSourceType.MYLAR3, + status=ImportJobStatus.COMPLETED, + ) + db_session.add_all([issue, job]) + await db_session.flush() + imported_series = ImportedSeries( + import_job_id=job.id, + raw_series_name=series.title, + status=ImportSeriesStatus.IMPORTED, + series_id=series.id, + ) + library_file = LibraryFile( + file_path=str(source_path), + file_name=source_path.name, + file_size=source_path.stat().st_size, + file_format=FileFormat.CBR, + file_modified_at=datetime.now(UTC), + match_confidence=MatchConfidence.HIGH, + issue_id=issue.id, + library_root_id=source_root.id, + storage_mode=LibraryFileStorageMode.REFERENCED, + source_signature=build_file_identity_signature(source_path), + ) + db_session.add_all([imported_series, library_file]) + await db_session.flush() + db_session.add( + ImportedFile( + import_job_id=job.id, + import_series_id=imported_series.id, + file_path=str(source_path), + file_name=source_path.name, + file_size=source_path.stat().st_size, + file_format="cbr", + status=ImportedFileStatus.IMPORTED, + matched_issue_id=issue.id, + library_file_id=library_file.id, + ) + ) + await db_session.flush() + + context = await load_import_results_context(db_session, job) + + assert context["clean_library_reference_count"] == 1 + assert context["clean_library_reference_bytes"] == source_path.stat().st_size + assert context["clean_library_mixed_folder_repair_count"] == 0 + assert context["clean_library_target_roots"] == [ + { + "id": clean_root.id, + "name": "Clean library", + "path": str(clean_path), + "rename_on_import": True, + "normalize_to_cbz": False, + "update_comicinfo": False, + "skip_existing": False, + } + ] + + @pytest.mark.asyncio async def test_failed_results_keep_a_bounded_safety_exception_list(db_session) -> None: # type: ignore[no-untyped-def] from pullbox.ui.import_results_context import load_import_results_context diff --git a/tests/ui/test_import_review_context.py b/tests/ui/test_import_review_context.py index 7b71e38..53fa462 100644 --- a/tests/ui/test_import_review_context.py +++ b/tests/ui/test_import_review_context.py @@ -116,6 +116,72 @@ async def test_safety_review_count_uses_job_series_index(db_session) -> None: # assert "SCAN import_files" not in plan +@pytest.mark.asyncio +async def test_safety_review_reports_distinct_series_and_file_counts(db_session) -> None: # type: ignore[no-untyped-def] + from pullbox.ui.import_review_context import load_import_review_context + + job = ImportJob( + source_path="/tmp/import", + source_type=ImportSourceType.FILESYSTEM, + status=ImportJobStatus.REVIEW, + ) + first = ImportedSeries( + import_job=job, + raw_series_name="First", + status=ImportSeriesStatus.MATCHED, + file_count=2, + ) + second = ImportedSeries( + import_job=job, + raw_series_name="Second", + status=ImportSeriesStatus.MATCHED, + file_count=1, + ) + db_session.add_all([job, first, second]) + await db_session.flush() + db_session.add_all( + [ + ImportedFile( + import_job_id=job.id, + import_series_id=first.id, + file_path="/tmp/import/first-cover.cbz", + file_name="first-cover.cbz", + file_format="cbz", + status=ImportedFileStatus.SAFETY_BLOCKED, + ), + ImportedFile( + import_job_id=job.id, + import_series_id=first.id, + file_path="/tmp/import/first-corrupt.cbz", + file_name="first-corrupt.cbz", + file_format="cbz", + status=ImportedFileStatus.SAFETY_BLOCKED, + ), + ImportedFile( + import_job_id=job.id, + import_series_id=second.id, + file_path="/tmp/import/second-cover.cbz", + file_name="second-cover.cbz", + file_format="cbz", + status=ImportedFileStatus.SAFETY_BLOCKED, + ), + ] + ) + await db_session.flush() + + context = await load_import_review_context( + db_session, + job, + status="safety_blocked", + page=1, + sort=None, + ) + + assert context["status_counts"]["safety_blocked"] == 2 + assert context["safety_review_series_count"] == 2 + assert context["safety_review_file_count"] == 3 + + @pytest.mark.asyncio async def test_load_import_review_context_filters_matched_rows_and_selection_state( db_session, diff --git a/tests/ui/test_import_review_summary.py b/tests/ui/test_import_review_summary.py index 6ec777b..a4f0f49 100644 --- a/tests/ui/test_import_review_summary.py +++ b/tests/ui/test_import_review_summary.py @@ -129,6 +129,10 @@ async def test_load_import_review_summary_uses_persisted_review_rows( assert summary["matched_series_selected"] == 1 assert summary["selected_items_total"] == 1 assert summary["importable_items_total"] == 1 + assert summary["ready_to_import_total"] == 1 + assert summary["needs_attention_total"] == 2 + assert summary["needs_attention_files_total"] == 2 + assert summary["deferred_follow_up_total"] == 0 assert summary["duplicate_files_importable"] == 1 safety_summary = await load_import_safety_failure_summary(db_session, job, page_size=1) assert safety_summary == [ diff --git a/tests/ui/test_import_safety_bulk_ui.py b/tests/ui/test_import_safety_bulk_ui.py index be73495..260add6 100644 --- a/tests/ui/test_import_safety_bulk_ui.py +++ b/tests/ui/test_import_safety_bulk_ui.py @@ -216,12 +216,9 @@ async def test_bulk_safety_preview_is_signed_exact_sanitized_and_read_only( assert "/private/import" not in response.text assert r"C:\Users\Adam" not in response.text assert _preview_token(response.text) - assert 'name="confirmation"' in response.text - assert 'pattern="ALLOW ONCE"' in response.text - assert 'autocomplete="off"' in response.text - assert 'aria-describedby="safety-bulk-confirmation-help-decompression_size_limit"' in ( - response.text - ) + assert 'name="confirmation"' not in response.text + assert 'pattern="ALLOW ONCE"' not in response.text + assert "Type ALLOW ONCE" not in response.text async with sec_db() as session: rows = [await session.get(ImportedFile, file_id) for file_id in seeded["file_ids"]] @@ -231,7 +228,7 @@ async def test_bulk_safety_preview_is_signed_exact_sanitized_and_read_only( @pytest.mark.asyncio -async def test_bulk_safety_confirmation_must_match_exact_text_before_mutation( +async def test_bulk_safety_action_relies_on_signed_preview_without_typed_phrase( authenticated_client: AsyncClient, sec_db: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch, @@ -252,22 +249,18 @@ async def test_bulk_safety_confirmation_must_match_exact_text_before_mutation( response = await authenticated_client.post( f"/import/{seeded['job_id']}/safety/categories/{category}/allow-once", - data={ - "preview_token": _preview_token(preview.text), - "confirmation": "allow once", - }, + data={"preview_token": _preview_token(preview.text)}, headers=_csrf_header_for(authenticated_client), ) assert response.status_code == 200 - assert 'role="alert"' in response.text - assert "Type ALLOW ONCE exactly" in response.text - assert triggered_rematches == [] + assert triggered_rematches == [seeded["job_id"]] async with sec_db() as session: rows = [await session.get(ImportedFile, file_id) for file_id in seeded["file_ids"]] assert all(row is not None for row in rows) - assert all(row.status == ImportedFileStatus.SAFETY_BLOCKED for row in rows if row) - assert (await session.execute(select(AuditLog))).scalars().all() == [] + assert [row.status for row in rows if row].count(ImportedFileStatus.SAFETY_APPROVED) == 2 + assert [row.status for row in rows if row].count(ImportedFileStatus.SAFETY_BLOCKED) == 1 + assert len((await session.execute(select(AuditLog))).scalars().all()) == 2 @pytest.mark.asyncio @@ -293,10 +286,7 @@ async def test_confirmed_bulk_safety_action_audits_actor_refreshes_and_triggers_ response = await authenticated_client.post( f"/import/{seeded['job_id']}/safety/categories/{category}/allow-once", - data={ - "preview_token": _preview_token(preview.text), - "confirmation": "ALLOW ONCE", - }, + data={"preview_token": _preview_token(preview.text)}, headers=_csrf_header_for(authenticated_client), ) @@ -346,10 +336,7 @@ async def test_stale_bulk_safety_preview_refreshes_review_without_mutation( response = await authenticated_client.post( f"/import/{seeded['job_id']}/safety/categories/{category}/allow-once", - data={ - "preview_token": _preview_token(preview.text) + "tampered", - "confirmation": "ALLOW ONCE", - }, + data={"preview_token": _preview_token(preview.text) + "tampered"}, headers=_csrf_header_for(authenticated_client), ) @@ -381,7 +368,7 @@ async def test_dangerous_bulk_safety_routes_fail_closed_without_mutation( ) apply = await authenticated_client.post( f"/import/{seeded['job_id']}/safety/categories/{category}/allow-once", - data={"preview_token": "not-a-preview", "confirmation": "ALLOW ONCE"}, + data={"preview_token": "not-a-preview"}, headers=_csrf_header_for(authenticated_client), ) @@ -410,7 +397,7 @@ async def test_bulk_safety_ui_rejects_api_key_only_operator_actions( ) apply = await unauthenticated_client.post( f"/import/{seeded['job_id']}/safety/categories/{category}/allow-once", - data={"preview_token": "not-a-preview", "confirmation": "ALLOW ONCE"}, + data={"preview_token": "not-a-preview"}, headers=headers, ) diff --git a/tests/ui/test_import_story_arc_review.py b/tests/ui/test_import_story_arc_review.py index da71f2f..92b93e6 100644 --- a/tests/ui/test_import_story_arc_review.py +++ b/tests/ui/test_import_story_arc_review.py @@ -4,6 +4,7 @@ import os import sys +from pathlib import Path import pytest from sqlalchemy import event @@ -211,9 +212,6 @@ async def test_story_arc_step_three_shows_safe_draft_and_separate_policy_confirm assert 'data-testid="import-story-arc-policy-' in response.text assert "Import logical arc and memberships" in response.text assert "Create or reference a separate arc folder" in response.text - assert 'data-testid="import-story-arc-step-one-intent"' in response.text - assert "Logical arcs requested; separate arc files remain off" in response.text - assert "Review each detected arc below" in response.text assert "Policy needs confirmation" in response.text assert "Legacy move mapped to copy" in response.text assert "Detected Mylar settings" in response.text @@ -330,6 +328,8 @@ async def test_story_arc_review_partial_shows_entry_evidence_without_private_pat assert response.status_code == 200 assert 'data-testid="import-story-arc-entry-review"' in response.text assert 'data-testid="import-story-arc-entry-filter"' in response.text + assert response.text.count('data-dropdown-select-contract="v1"') >= 5 + assert " None: + script = Path("src/pullbox/ui/static/js/pullbox.js").read_text(encoding="utf-8") + start = script.index("confirmStoryArcPolicy: async function") + end = script.index("refreshReviewSummary: async function", start) + controller = script[start:end] + + assert "if (materialize && rootId === null)" in controller + assert "Choose an approved library root before confirming this policy." in controller + assert "rootTrigger.focus()" in controller + + @pytest.mark.asyncio @pytest.mark.parametrize( ("params", "field"), diff --git a/tests/ui/test_import_ui_routes.py b/tests/ui/test_import_ui_routes.py index 222cf15..ec09e91 100644 --- a/tests/ui/test_import_ui_routes.py +++ b/tests/ui/test_import_ui_routes.py @@ -40,7 +40,7 @@ from pullbox.models.issue import Issue, IssueStatus from pullbox.models.library import LibraryRoot from pullbox.models.publisher import Publisher -from pullbox.models.series import Series, SeriesStatus +from pullbox.models.series import IssueCatalogState, Series, SeriesStatus from pullbox.models.user import APIKey, User from pullbox.services.auth_service import AuthService @@ -250,7 +250,7 @@ async def test_import_page_handler( assert ctx["tab"] == "collection" assert "library_roots" in ctx assert "recent_jobs" in ctx - assert "unmatched_count" in ctx + assert "follow_up_count" in ctx @pytest.mark.asyncio async def test_import_page_empty_state( @@ -697,7 +697,7 @@ async def test_results_partial( assert "failed_count" in ctx assert "duplicate_count" in ctx - def test_results_template_shows_retry_for_file_only_failures(self) -> None: + def test_follow_up_template_shows_retry_for_file_only_failures(self) -> None: from types import SimpleNamespace from pullbox.ui.routes import templates @@ -734,13 +734,14 @@ def test_results_template_shows_retry_for_file_only_failures(self) -> None: resume_step=5, resume_job_id=26, resume_progress_snapshot={}, + follow_up_workspace=True, ) assert 'data-testid="import-results-retry-action"' in html assert "Retry failed" in html assert "2000AD prog 2481.cbz" in html - def test_results_template_presents_bounded_completed_cleanup_actions(self) -> None: + def test_results_template_links_to_bounded_follow_up_workspace(self) -> None: from types import SimpleNamespace from pullbox.ui.routes import templates @@ -787,6 +788,13 @@ def test_results_template_presents_bounded_completed_cleanup_actions(self) -> No "examples": ("missing-1.cbz", "missing-2.cbz", "missing-3.cbz"), } ], + clean_library_reference_count=116061, + clean_library_reference_series_count=19420, + clean_library_reference_bytes=987654321, + clean_library_mixed_folder_repair_count=0, + clean_library_target_roots=[ + {"id": 9, "name": "Clean library", "path": "/library-clean"} + ], safety_category_summaries=[ { "category": "source_missing", @@ -798,17 +806,303 @@ def test_results_template_presents_bounded_completed_cleanup_actions(self) -> No } ], remaining_conflict_files=100, + story_arcs_created_count=2, + story_arcs_follow_up_count=3, + follow_up_group_count=4, resume_step=5, resume_job_id=35, resume_progress_snapshot={}, ) - assert 'data-testid="import-results-recovery-dashboard"' in html - assert "38,383" in html - assert "Dismiss stale Mylar references" in html - assert 'data-testid="import-cleanup-apply-dismiss_missing_references"' in html - assert 'data-testid="import-cleanup-review-dismiss_missing_references"' in html + assert 'data-testid="import-results-follow-up-action"' in html + assert 'href="/import?tab=follow-up&job_id=35"' in html + assert "4 follow-up groups" in html + assert "The import finished with follow-up still worth checking." in html + assert "The import finished cleanly" not in html + assert 'data-testid="import-results-recovery-dashboard"' not in html + assert "Dismiss stale Mylar references" not in html assert 'data-testid="import-results-archive-action"' in html + assert 'data-testid="import-results-clean-library"' not in html + assert 'data-testid="import-results-story-arcs"' not in html + + def test_history_owns_optional_clean_library_organization(self) -> None: + from types import SimpleNamespace + + from pullbox.ui.routes import templates + + job = SimpleNamespace( + id=35, + source_path="/imports/mylar.db", + source_type=SimpleNamespace(value="mylar3"), + status=SimpleNamespace(value="completed"), + progress_snapshot={}, + removed_library_root_snapshot=None, + series_found=4, + series_imported=4, + series_failed=0, + series_no_match=0, + created_at=datetime.now(UTC), + import_completed_at=datetime.now(UTC), + archived_at=None, + ) + html = templates.env.get_template("partials/import_history_results.html").render( + jobs=[job], + sort="-created_at", + page=1, + search_query="", + show_archived=False, + history_has_live_jobs=False, + job_control_states={35: {"can_view_results": True}}, + job_history_metrics={ + 35: { + "series_found": 4, + "series_imported": 4, + "series_failed": 0, + "series_no_match": 0, + } + }, + job_resume_steps={35: None}, + clean_library_job_ids={35}, + ) + + assert 'data-testid="import-history-organize-35"' in html + assert 'hx-get="/import/35/clean-library-panel"' in html + + def test_clean_library_modal_preserves_the_organization_contract(self) -> None: + from types import SimpleNamespace + + from pullbox.ui.routes import templates + + html = templates.env.get_template("partials/import_clean_library_modal.html").render( + job=SimpleNamespace(id=35), + clean_library_reference_count=10, + clean_library_reference_series_count=3, + clean_library_reference_bytes=2048, + clean_library_mixed_folder_repair_count=0, + clean_library_target_roots=[ + { + "id": 9, + "name": "Clean library", + "path": "/library-clean", + "rename_on_import": True, + "normalize_to_cbz": True, + "update_comicinfo": True, + "skip_existing": True, + } + ], + ) + + assert 'data-testid="import-clean-library-modal"' in html + assert 'class="modal-shell"' in html + assert 'role="dialog"' in html + assert 'aria-modal="true"' in html + assert 'aria-labelledby="import-clean-library-title"' in html + assert 'id="import-clean-library-title"' in html + assert 'x-init="initializeCleanLibraryModal()"' in html + assert 'x-ref="cleanLibraryDialog"' in html + assert '@keydown.tab.window="trapCleanLibraryModalFocus($event)"' in html + assert 'data-testid="clean-library-close-action"' in html + assert 'data-testid="clean-library-cancel-action"' in html + assert 'x-ref="cleanLibraryInitialFocus"' in html + assert 'class="space-y-4 px-5 py-5"' in html + assert 'data-testid="clean-library-target-root"' in html + assert 'data-dropdown-select-contract="v1"' in html + assert 'data-testid="clean-library-policy-summary"' in html + assert 'data-testid="clean-library-settings-action"' in html + settings_action = html.index('data-testid="clean-library-settings-action"') + assert 'class="btn-ghost btn-sm"' in html[settings_action : settings_action + 180] + assert 'data-testid="clean-library-build-action"' in html + assert 'data-testid="clean-library-progress"' in html + assert 'class="app-progress"' in html + assert "Build clean library" in html + assert "Preview clean library" not in html + + script = Path("src/pullbox/ui/static/js/pullbox.js").read_text() + start = script.index("function importResultsData") + end = script.index("function conflictResolutionData", start) + results_controller = script[start:end] + assert "initializeCleanLibraryModal" in results_controller + assert "closeCleanLibraryModal" in results_controller + assert "trapCleanLibraryModalFocus" in results_controller + assert "cleanLibraryReturnFocus" in results_controller + assert "refreshCleanLibraryProgress" in results_controller + assert "scheduleCleanLibraryProgressPoll" in results_controller + build_start = results_controller.index("buildCleanLibrary: async function") + build_end = results_controller.index("archiveImport: async function", build_start) + assert "pbConfirm" not in results_controller[build_start:build_end] + + def test_completed_cover_details_offer_explicit_post_import_source_cleanup(self) -> None: + from types import SimpleNamespace + + from pullbox.ui.routes import templates + + html = templates.env.get_template("partials/import_completed_cleanup_files.html").render( + job_id=35, + cleanup_action="skip_probable_covers", + cleanup_page=SimpleNamespace( + total=1, + page=1, + total_pages=1, + items=[ + SimpleNamespace( + id=91, + file_name="possible-cover.cbz", + error_message="The archive contains one image page.", + diagnostics={ + "safety_block": {"reason": "The archive contains one image page."} + }, + ) + ], + ), + ) + + assert 'data-testid="import-results-source-cleanup-preview-91"' in html + assert "Move source to Trash" in html + assert "return_to=follow-up" in html + + def test_results_template_offers_optional_misplaced_mylar_cleanup(self) -> None: + from types import SimpleNamespace + + from pullbox.ui.routes import templates + + html = templates.env.get_template("partials/import_results.html").render( + job=SimpleNamespace( + id=35, + status=SimpleNamespace(value="completed"), + archived_at=None, + ), + can_rollback=False, + imported_count=1, + failed_count=0, + duplicate_count=1, + no_match_count=0, + unmatched_queue_count=0, + failed_series=[], + files_total=2, + files_imported=1, + files_matched=0, + files_duplicate=1, + files_already_owned=0, + files_conflict=0, + files_no_match=0, + orphaned_file_no_match_count=0, + identified_series_file_no_match_count=0, + catalog_sync_pending_count=0, + catalog_sync_failed_count=0, + catalog_sync_attention_count=0, + catalog_sync_series=[], + files_failed=0, + failed_files=[], + files_safety_blocked=0, + safety_category_summaries=[], + cleanup_action_summaries=[], + cleanup_no_action_count=1, + cleanup_safe_action_count=0, + cleanup_needs_review_count=0, + misplaced_source_restore_count=1, + misplaced_source_duplicate_count=1, + resume_step=5, + resume_job_id=35, + resume_progress_snapshot={}, + follow_up_workspace=True, + ) + + assert 'data-testid="import-results-misplaced-source-cleanup"' in html + assert 'data-testid="import-results-recovery-dashboard"' not in html + assert "Already handled" not in html + assert "Organize misplaced files" in html + assert "Move all verified files" in html + assert "Remove identical duplicates" in html + assert "Pullbox does not edit the Mylar database" in html + assert 'data-testid="review-misplaced-source-restore_recorded_path"' in html + assert 'data-testid="review-misplaced-source-trash_identical_duplicate"' in html + + def test_misplaced_cleanup_file_list_uses_explicit_per_file_actions(self) -> None: + from types import SimpleNamespace + + from pullbox.ui.routes import templates + + canonical_html = templates.env.get_template( + "partials/import_misplaced_source_cleanup_files.html" + ).render( + job_id=35, + cleanup_action="restore_recorded_path", + cleanup_page=SimpleNamespace( + total=1, + page=1, + total_pages=1, + items=[SimpleNamespace(id=91, file_name="Absolute Batman #001.cbz")], + ), + ) + duplicate_html = templates.env.get_template( + "partials/import_misplaced_source_cleanup_files.html" + ).render( + job_id=35, + cleanup_action="trash_identical_duplicate", + cleanup_page=SimpleNamespace( + total=1, + page=1, + total_pages=1, + items=[SimpleNamespace(id=92, file_name="Absolute Batman #001 variant.cbz")], + ), + ) + + assert "Move file" in canonical_html + assert 'class="btn-ghost btn-sm"' in canonical_html + assert "Current path" in canonical_html + assert "Proposed path" in canonical_html + assert "Move duplicate to Trash" in duplicate_html + assert 'class="btn-ghost btn-sm"' in duplicate_html + + def test_misplaced_cleanup_modal_explains_exact_source_change(self) -> None: + from types import SimpleNamespace + + from pullbox.ui.routes import templates + + preview = SimpleNamespace( + job_id=35, + file_id=91, + action=SimpleNamespace(value="restore_recorded_path"), + file_name="Absolute Batman #001.cbz", + source_path="/comics/Crossed/Absolute Batman #001.cbz", + destination_path="/comics/Absolute Batman/Absolute Batman #001.cbz", + can_apply=True, + unavailable_reason="", + preview_token="signed-preview", + ) + html = templates.env.get_template( + "partials/import_misplaced_source_cleanup_modal.html" + ).render(preview=preview, error="") + + assert "This changes the source library" in html + assert "Pullbox does not edit the Mylar database" in html + assert "/comics/Crossed/Absolute Batman #001.cbz" in html + assert "/comics/Absolute Batman/Absolute Batman #001.cbz" in html + assert 'value="signed-preview"' in html + assert "Move misplaced file" in html + assert "reference-only" in html + + def test_misplaced_cleanup_bulk_modal_requires_one_time_confirmation(self) -> None: + from types import SimpleNamespace + + from pullbox.ui.routes import templates + + preview = SimpleNamespace( + job_id=35, + affected_count=2, + unavailable_count=1, + examples=("Absolute Batman #001.cbz", "Babyteeth Vol 01.cbz"), + preview_token="signed-bulk-preview", + ) + html = templates.env.get_template( + "partials/import_misplaced_source_cleanup_bulk_modal.html" + ).render(preview=preview) + + assert "Move all verified files" in html + assert "one-time confirmation" in html + assert "does not enable managed writes" in html + assert "1 additional candidate" in html + assert 'value="signed-bulk-preview"' in html def test_failed_results_template_preserves_bounded_safety_actions(self) -> None: from types import SimpleNamespace @@ -851,6 +1145,7 @@ def test_failed_results_template_preserves_bounded_safety_actions(self) -> None: resume_step=5, resume_job_id=35, resume_progress_snapshot={}, + follow_up_workspace=True, ) assert 'data-testid="import-results-safety-exceptions"' in html @@ -896,13 +1191,14 @@ def test_results_template_explains_changed_source_failures(self) -> None: resume_step=5, resume_job_id=27, resume_progress_snapshot={}, + follow_up_workspace=True, ) assert 'data-testid="import-results-source-changed-note"' in html assert "4 source files changed after discovery" in html assert "Recheck only those files" in html - def test_results_template_surfaces_background_catalog_sync(self) -> None: + def test_results_template_surfaces_pending_background_catalog_sync(self) -> None: from types import SimpleNamespace from pullbox.ui.routes import templates @@ -952,13 +1248,77 @@ def test_results_template_surfaces_background_catalog_sync(self) -> None: ) assert 'data-testid="import-results-catalog-sync-note"' in html - assert "3 series still need ComicVine catalog sync follow-up" in html - assert "2 syncing in the background" in html - assert "1 needs metadata retry" in html - assert "Batman" in html + assert "2 series are still syncing metadata in the background" in html + assert "metadata retry" not in html + assert "Batman" not in html + assert "Daredevil" not in html + + def test_follow_up_template_surfaces_exhausted_catalog_sync(self) -> None: + from types import SimpleNamespace + + from pullbox.ui.routes import templates + + html = templates.env.get_template("partials/import_results.html").render( + job=SimpleNamespace( + id=32, + status=SimpleNamespace(value="completed"), + archived_at=None, + removed_library_root_snapshot=None, + ), + can_rollback=False, + imported_count=3, + failed_count=0, + duplicate_count=0, + no_match_count=0, + unmatched_queue_count=0, + failed_series=[], + files_total=3, + files_imported=3, + files_matched=3, + files_duplicate=0, + files_already_owned=0, + files_conflict=0, + files_no_match=0, + orphaned_file_no_match_count=0, + identified_series_file_no_match_count=0, + catalog_sync_pending_count=1, + catalog_sync_failed_count=1, + catalog_sync_attention_count=2, + catalog_sync_series=[ + SimpleNamespace( + id=10, + title="Batman", + issue_catalog_state=SimpleNamespace(value="hydrating"), + issue_catalog_error=None, + ), + SimpleNamespace( + id=11, + title="Daredevil", + issue_catalog_state=SimpleNamespace(value="failed"), + issue_catalog_error="ComicVine timed out", + ), + ], + files_failed=0, + failed_files=[], + files_safety_blocked=0, + safety_blocked_files=[], + cleanup_action_summaries=[], + cleanup_no_action_count=0, + cleanup_safe_action_count=0, + cleanup_needs_review_count=0, + misplaced_source_restore_count=0, + misplaced_source_duplicate_count=0, + story_arcs_follow_up_count=0, + follow_up_workspace=True, + ) + + assert 'data-testid="import-results-catalog-sync-note"' not in html + assert 'data-testid="import-follow-up-metadata"' in html + assert "1 series need metadata retry" in html assert "Daredevil" in html + assert "Batman" not in html - def test_results_template_distinguishes_owned_artifacts_and_rollback_candidates( + def test_results_template_hides_internal_ownership_and_optional_follow_up( self, ) -> None: from types import SimpleNamespace @@ -1001,12 +1361,10 @@ def test_results_template_distinguishes_owned_artifacts_and_rollback_candidates( resume_progress_snapshot={}, ) - assert 'data-testid="import-results-ownership-summary"' in html - assert "Managed artifacts" in html - assert "In-place references" in html - assert 'data-testid="import-results-rollback-summary"' in html - assert "2 managed artifacts will be fingerprint-checked" in html - assert "1 reference will be detached" in html + assert "File outcomes" in html + assert 'data-testid="import-results-ownership-summary"' not in html + assert 'data-testid="import-results-rollback-summary"' not in html + assert 'data-testid="import-results-future-organization"' not in html def test_results_template_identifies_incomplete_rollback(self) -> None: from types import SimpleNamespace @@ -2153,13 +2511,140 @@ async def test_orphaned_page_full_render( call_args = mock_templates.TemplateResponse.call_args assert call_args[0][1] == "pages/import.html" ctx = call_args[0][2] - assert ctx["tab"] == "unmatched" - assert ctx["total"] == 5 - assert len(ctx["items"]) == 5 - assert ctx["orphaned_count"] == 5 - assert ctx["dismissed_count"] == 1 + assert ctx["tab"] == "follow-up" + assert ctx["total"] == 1 + assert ctx["items"] == [] + assert len(ctx["follow_up_jobs"]) == 1 + assert ctx["follow_up_job_count"] == 1 assert ctx["view"] == "all" + @pytest.mark.asyncio + async def test_follow_up_page_scopes_matching_and_cleanup_to_one_import( + self, + _db_factory: async_sessionmaker[AsyncSession], + ) -> None: + from unittest.mock import MagicMock + + from pullbox.ui.routes import import_page + + job_id = await _seed_orphan_data( + _db_factory, + no_match_count=3, + recovery_pending_count=1, + skipped_count=1, + imported_issue_recovery_count=1, + ) + request = MagicMock() + request.url.path = "/import" + request.state.csrf_token = "test" + request.headers = {} + + async with _db_factory() as session: + with patch("pullbox.ui.routes.templates") as mock_templates: + mock_templates.TemplateResponse.return_value = MagicMock() + await import_page( + request, + MagicMock(), + session, + tab="follow-up", + view="all", + page=1, + job_id=job_id, + ) + + ctx = mock_templates.TemplateResponse.call_args[0][2] + assert ctx["tab"] == "follow-up" + assert ctx["selected_follow_up_job"].id == job_id + assert ctx["orphaned_count"] == 5 + assert len(ctx["items"]) == 5 + assert ctx["follow_up_group_count"] > 0 + + @pytest.mark.asyncio + async def test_follow_up_jobs_exclude_no_action_and_pending_hydration( + self, + _db_factory: async_sessionmaker[AsyncSession], + ) -> None: + from pullbox.ui.import_follow_up import load_import_follow_up_context + + async with _db_factory() as session: + resolved_job = ImportJob( + source_path="/tmp/resolved", + source_type=ImportSourceType.FILESYSTEM, + status=ImportJobStatus.COMPLETED, + ) + actionable_job = ImportJob( + source_path="/tmp/actionable", + source_type=ImportSourceType.FILESYSTEM, + status=ImportJobStatus.COMPLETED, + ) + nonrenderable_metadata_job = ImportJob( + source_path="/tmp/nonrenderable-metadata", + source_type=ImportSourceType.FILESYSTEM, + status=ImportJobStatus.COMPLETED, + ) + session.add_all([resolved_job, actionable_job, nonrenderable_metadata_job]) + await session.flush() + + hydrating = Series( + title="Still Syncing", + sort_title="Still Syncing", + issue_catalog_state=IssueCatalogState.HYDRATING, + ) + failed = Series( + title="Needs Retry", + sort_title="Needs Retry", + issue_catalog_state=IssueCatalogState.FAILED, + ) + duplicate_failed = Series( + title="Duplicate Metadata Failure", + sort_title="Duplicate Metadata Failure", + issue_catalog_state=IssueCatalogState.FAILED, + ) + session.add_all([hydrating, failed, duplicate_failed]) + await session.flush() + + resolved_series = ImportedSeries( + import_job_id=resolved_job.id, + raw_series_name="Still Syncing", + status=ImportSeriesStatus.IMPORTED, + series_id=hydrating.id, + ) + actionable_series = ImportedSeries( + import_job_id=actionable_job.id, + raw_series_name="Needs Retry", + status=ImportSeriesStatus.IMPORTED, + series_id=failed.id, + ) + nonrenderable_series = ImportedSeries( + import_job_id=nonrenderable_metadata_job.id, + raw_series_name="Duplicate Metadata Failure", + status=ImportSeriesStatus.DUPLICATE, + series_id=duplicate_failed.id, + ) + session.add_all([resolved_series, actionable_series, nonrenderable_series]) + await session.flush() + session.add( + ImportedFile( + import_job_id=resolved_job.id, + import_series_id=resolved_series.id, + file_path="/tmp/resolved/duplicate.cbz", + file_name="duplicate.cbz", + file_format="cbz", + status=ImportedFileStatus.DUPLICATE_FILE, + ) + ) + await session.commit() + + context = await load_import_follow_up_context( + session, + view="all", + requested_page=1, + job_id=None, + ) + + assert context["follow_up_job_count"] == 1 + assert [job.id for job in context["follow_up_jobs"]] == [actionable_job.id] + def test_orphaned_table_marks_recovery_pending_rows_as_identified(self) -> None: """Recovery-pending rows stay in the active queue with guided actions.""" from pullbox.ui.routes import templates @@ -2426,6 +2911,18 @@ def test_dropdown_wrap_mode_uses_trigger_width_for_panel(self) -> None: assert "this.wrapOptions" in dropdown_controller assert "? Math.ceil(triggerRect.width)" in dropdown_controller + def test_dropdown_contract_tracks_reactive_options_and_disabled_state(self) -> None: + """Reactive import dropdowns keep the shared control synchronized.""" + source = Path("src/pullbox/ui/static/js/pullbox.js").read_text() + start = source.index("function dropdownSelectData") + end = source.index("function _readSearchHistory") + dropdown_controller = source[start:end] + + assert "syncExternalOptions: function" in dropdown_controller + assert "normalizedOptions = normalizedNext;" in dropdown_controller + assert "syncExternalDisabled: function" in dropdown_controller + assert "if (disabled && this.open)" in dropdown_controller + def test_orphaned_table_uses_centered_status_pills_and_icon_actions(self) -> None: """Unmatched rows use centered pills and standard icon action buttons.""" from pullbox.ui.routes import templates @@ -2463,7 +2960,7 @@ def test_orphaned_table_uses_centered_status_pills_and_icon_actions(self) -> Non assert 'data-tip="Search ComicVine"' in html assert 'data-tip="Dismiss"' in html assert 'aria-label="Search ComicVine for Persephone"' in html - assert 'aria-label="Dismiss unmatched series Persephone"' in html + assert 'aria-label="Dismiss Persephone from import follow-up"' in html assert "Search CV" not in html def test_orphaned_table_uses_app_confirm_modal_for_dismiss(self) -> None: @@ -2593,7 +3090,7 @@ async def test_orphaned_page_dismissed_tab( ) ctx = mock_templates.TemplateResponse.call_args[0][2] - assert ctx["tab"] == "unmatched" + assert ctx["tab"] == "follow-up" assert ctx["total"] == 3 assert ctx["orphaned_count"] == 2 assert ctx["dismissed_count"] == 3 @@ -2659,7 +3156,7 @@ async def test_orphaned_page_empty_state( ) ctx = mock_templates.TemplateResponse.call_args[0][2] - assert ctx["tab"] == "unmatched" + assert ctx["tab"] == "follow-up" assert ctx["total"] == 0 assert ctx["items"] == [] assert ctx["orphaned_count"] == 0 diff --git a/tests/ui/test_reading_ui_routes.py b/tests/ui/test_reading_ui_routes.py index 1816aa6..257cf8f 100644 --- a/tests/ui/test_reading_ui_routes.py +++ b/tests/ui/test_reading_ui_routes.py @@ -27,6 +27,8 @@ async def _seed_reading_items( count: int, mode: str, unavailable_last: bool = False, + last_page_index: int = 1, + page_count: int = 5, ) -> list[int]: now = datetime.now(UTC) async with factory() as session: @@ -72,9 +74,13 @@ async def _seed_reading_items( IssueReaderState( user_id=user.id, issue_id=issue.id, - last_page_index=1 if mode == "continue" else (4 if mode == "read" else None), + last_page_index=( + last_page_index + if mode == "continue" + else (page_count - 1 if mode == "read" else None) + ), content_revision="revision" if mode in {"continue", "read"} else None, - page_count=5 if mode in {"continue", "read"} else None, + page_count=page_count if mode in {"continue", "read"} else None, progress_updated_at=opened_at if mode in {"continue", "read"} else None, last_opened_at=opened_at if mode in {"continue", "read"} else None, completed_at=opened_at if mode == "read" else None, diff --git a/tests/ui/test_settings_shell_ui_routes.py b/tests/ui/test_settings_shell_ui_routes.py index 74624d8..18302e8 100644 --- a/tests/ui/test_settings_shell_ui_routes.py +++ b/tests/ui/test_settings_shell_ui_routes.py @@ -519,6 +519,24 @@ async def test_settings_media_exposes_safe_multi_library_root_management( assert 'data-testid="settings-media-library-root-reference-role"' in response.text assert 'data-testid="settings-media-library-root-managed-role"' in response.text assert 'data-testid="settings-media-library-root-default"' in response.text + assert 'data-testid="settings-media-library-root-actions"' in response.text + assert 'class="flex flex-wrap justify-end gap-2 sm:col-span-2"' in response.text + assert 'data-testid="settings-media-library-root-make-default"' in response.text + assert 'data-testid="settings-media-library-root-toggle-enabled"' in response.text + make_default_start = response.text.index( + 'data-testid="settings-media-library-root-make-default"' + ) + toggle_enabled_start = response.text.index( + 'data-testid="settings-media-library-root-toggle-enabled"' + ) + assert ( + 'class="btn-ghost btn-sm"' + in response.text[make_default_start : make_default_start + 180] + ) + assert ( + 'class="btn-ghost btn-sm"' + in response.text[toggle_enabled_start : toggle_enabled_start + 180] + ) assert "Reference existing files" in response.text assert "Allow managed files" in response.text assert "Default managed destination" in response.text @@ -530,6 +548,10 @@ async def test_settings_media_exposes_safe_multi_library_root_management( assert "Confirm rebind" in response.text assert "/rebind/preview" in response.text assert 'confirmation: "REBIND"' in response.text + assert 'data-testid="settings-media-library-root-removal"' in response.text + assert 'confirmation: "REMOVE"' in response.text + assert "No folders or files will be deleted." in response.text + assert 'method: "DELETE"' in response.text assert "Delete library root" not in response.text assert "ordinary edits cannot change them" in response.text assert "bootstraps the default managed destination" in response.text diff --git a/tests/ui/test_ui_router_contract.py b/tests/ui/test_ui_router_contract.py index 5c91493..e08cd87 100644 --- a/tests/ui/test_ui_router_contract.py +++ b/tests/ui/test_ui_router_contract.py @@ -118,6 +118,12 @@ def test_ui_router_manifest_remains_stable_during_decomposition() -> None: "import_review_partial", "import_review_partial", ), + ( + "/import/{job_id}/review-rematch-status", + ("GET",), + "import_review_rematch_status", + "import_review_rematch_status", + ), ( "/import/{job_id}/safety/categories/{category}/preview", ("GET",), @@ -130,6 +136,30 @@ def test_ui_router_manifest_remains_stable_during_decomposition() -> None: "import_review_allow_safety_category_once", "import_review_allow_safety_category_once", ), + ( + "/import/{job_id}/safety/categories/{category}/skip-preview", + ("GET",), + "import_review_preview_safety_category_skip", + "import_review_preview_safety_category_skip", + ), + ( + "/import/{job_id}/safety/categories/{category}/skip", + ("POST",), + "import_review_skip_safety_category", + "import_review_skip_safety_category", + ), + ( + "/import/{job_id}/files/{file_id}/safety/source-cleanup-preview", + ("GET",), + "import_review_source_cleanup_preview", + "import_review_source_cleanup_preview", + ), + ( + "/import/{job_id}/files/{file_id}/safety/source-cleanup", + ("POST",), + "import_review_source_cleanup", + "import_review_source_cleanup", + ), ( "/import/{job_id}/files/{file_id}/safety/allow-once", ("POST",), @@ -154,12 +184,48 @@ def test_ui_router_manifest_remains_stable_during_decomposition() -> None: "import_results_partial", "import_results_partial", ), + ( + "/import/{job_id}/clean-library-panel", + ("GET",), + "import_clean_library_panel", + "import_clean_library_panel", + ), ( "/import/{job_id}/cleanup/{action}/files", ("GET",), "import_completed_cleanup_files_partial", "import_completed_cleanup_files_partial", ), + ( + "/import/{job_id}/misplaced-source-cleanup/{action}/files", + ("GET",), + "import_misplaced_source_cleanup_files_partial", + "import_misplaced_source_cleanup_files_partial", + ), + ( + "/import/{job_id}/misplaced-source-cleanup/restore-recorded-path/preview-all", + ("GET",), + "import_misplaced_source_cleanup_preview_all", + "import_misplaced_source_cleanup_preview_all", + ), + ( + "/import/{job_id}/misplaced-source-cleanup/restore-recorded-path/apply-all", + ("POST",), + "import_misplaced_source_cleanup_apply_all", + "import_misplaced_source_cleanup_apply_all", + ), + ( + "/import/{job_id}/files/{file_id}/misplaced-source-cleanup/{action}/preview", + ("GET",), + "import_misplaced_source_cleanup_preview", + "import_misplaced_source_cleanup_preview", + ), + ( + "/import/{job_id}/files/{file_id}/misplaced-source-cleanup/{action}", + ("POST",), + "import_misplaced_source_cleanup_apply", + "import_misplaced_source_cleanup_apply", + ), ( "/import/{job_id}/series/{series_id}/details-partial", ("GET",), diff --git a/tests/ui/test_xss_template_contracts.py b/tests/ui/test_xss_template_contracts.py index 3173b8a..9a97ea9 100644 --- a/tests/ui/test_xss_template_contracts.py +++ b/tests/ui/test_xss_template_contracts.py @@ -97,6 +97,12 @@ "partials/import_history_results.html", "{{ job.created_at|localtime|safe if job.created_at else '—' }}", ), + ( + "partials/import_follow_up_jobs.html", + '' + "{{ (follow_up_job.import_completed_at or " + "follow_up_job.created_at)|localtime|safe }}", + ), } diff --git a/tests/unit/test_archive_reader.py b/tests/unit/test_archive_reader.py index c12956f..12d6846 100644 --- a/tests/unit/test_archive_reader.py +++ b/tests/unit/test_archive_reader.py @@ -2,6 +2,7 @@ from __future__ import annotations +import zipfile from typing import TYPE_CHECKING import py7zr @@ -77,6 +78,25 @@ def test_read_nested_file_from_cb7(self, tmp_path: Path) -> None: assert b"Batman" in xml_bytes +def test_read_comicinfo_prefers_canonical_root_member(tmp_path: Path) -> None: + archive = tmp_path / "duplicate-comicinfo.cbz" + with zipfile.ZipFile(archive, "w") as payload: + payload.writestr( + "Legacy/ComicInfo.xml", + "Wrong nested metadata", + ) + payload.writestr( + "ComicInfo.xml", + "Babyteeth1", + ) + + comicinfo = ArchiveReader(archive).read_comicinfo() + + assert comicinfo is not None + assert comicinfo.series == "Babyteeth" + assert comicinfo.number == "1" + + class TestArchiveReaderCbr: """CBR archives should use the central RAR backend before opening.""" diff --git a/tests/unit/test_collection_scanner.py b/tests/unit/test_collection_scanner.py index 808673a..ad0055e 100644 --- a/tests/unit/test_collection_scanner.py +++ b/tests/unit/test_collection_scanner.py @@ -1116,6 +1116,41 @@ async def test_ordered_mixed_series_folder_is_not_collapsed_to_the_folder_name( assert {item.source_folder_cohort_key for item in discovered_files} == {"Axis"} assert sorted(item.source_ordinal for item in discovered_files) == [1, 2] + @pytest.mark.asyncio + async def test_folder_import_splits_babyteeth_files_out_of_aliens_folder( + self, + tmp_path: Path, + ) -> None: + folder = tmp_path / "Aliens Epic Collection (2023)" + folder.mkdir() + _make_cbz( + folder / "Aliens Epic Collection Vol 01.cbz", + "Aliens Epic Collection1" + "[cv_vol_id:148973] [cv_issue_id:1067467]", + ) + for number, issue_id, suffix in ( + (1, 644570, "Volume 1 - Born"), + (3, 727350, "Vol. 3 - Cradle"), + (4, 1166031, "Vol. 4 - Grave"), + ): + _make_cbz( + folder / f"Babyteeth (2017) Vol {number:02} - {suffix}.cbz", + f"Babyteeth{number}" + f"[cv_vol_id:171891] [cv_issue_id:{issue_id}]", + ) + + results = await _scan_all(CollectionScanner(), tmp_path) + + by_title = {result.raw_series_name: result for result in results} + assert set(by_title) == {"Aliens Epic Collection", "Babyteeth"} + assert by_title["Aliens Epic Collection"].file_count == 1 + assert by_title["Babyteeth"].file_count == 3 + assert {file.parsed_issue_number for file in by_title["Babyteeth"].files} == { + 1.0, + 3.0, + 4.0, + } + @pytest.mark.asyncio async def test_folder_cohort_ordinals_are_global_and_path_stable(self, tmp_path: Path) -> None: _make_series_dir(tmp_path, "Zulu", files=["Zulu 001.cbz"]) diff --git a/tests/unit/test_dashboard_helper_edges.py b/tests/unit/test_dashboard_helper_edges.py new file mode 100644 index 0000000..db0a88f --- /dev/null +++ b/tests/unit/test_dashboard_helper_edges.py @@ -0,0 +1,369 @@ +"""Boundary coverage for dashboard scoring and user-facing labels.""" + +from __future__ import annotations + +from datetime import UTC, date, datetime, timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from sqlalchemy.exc import DatabaseError + +from pullbox.services import dashboard_helpers as helpers +from pullbox.services.dashboard_intelligence_service import ( + DashboardIntelligenceService, + _download_client_label, + _hour_bucket_start, +) + + +def _storage(**overrides: object) -> SimpleNamespace: + values: dict[str, object] = { + "state": "healthy", + "runway_to_degraded_days": 90.0, + "daily_growth_bytes": 10.0, + "previous_daily_growth_bytes": 8.0, + } + values.update(overrides) + return SimpleNamespace(**values) + + +@pytest.mark.parametrize( + ("current", "previous", "expected"), + [(None, 90.0, 0), (90.0, None, 0), (90.0, 85.0, 0), (60.0, 90.0, 90)], +) +def test_rate_drop_score_handles_missing_rising_and_falling_rates( + current: float | None, + previous: float | None, + expected: int, +) -> None: + assert helpers.rate_drop_score(current, previous) == expected + + +@pytest.mark.parametrize( + ("value", "expected"), + [(None, "info"), (95.0, "healthy"), (80.0, "watch"), (50.0, "critical")], +) +def test_rate_state_boundaries(value: float | None, expected: str) -> None: + assert helpers.rate_state_from_percent(value) == expected + + +def test_dashboard_interpretations_cover_each_actionable_state() -> None: + assert "cleanly" in helpers.flow_through_interpretation(95.0) + assert "mostly" in helpers.flow_through_interpretation(80.0) + assert "stalling" in helpers.flow_through_interpretation(50.0) + + debt = SimpleNamespace(total=2, oldest_at=None) + assert "baseline" in helpers.review_debt_interpretation(debt) + + risk = SimpleNamespace(next_72h_count=1, nearest_release_date=None) + assert "quick check" in helpers.release_risk_interpretation(risk) + + reliability = SimpleNamespace(rate=95.0, worst_client_label=None) + assert "enough history" in helpers.client_reliability_interpretation(reliability) + reliability.worst_client_label = "SABnzbd" + assert "nothing looks alarming" in helpers.client_reliability_interpretation(reliability) + + +@pytest.mark.parametrize( + ("storage", "expected"), + [ + (_storage(state="unhealthy"), "Storage is already past the safe line."), + (_storage(runway_to_degraded_days=None), "Waiting on enough snapshots to project runway."), + (_storage(runway_to_degraded_days=90.0), "You still have room"), + (_storage(runway_to_degraded_days=30.0), "runway is shrinking"), + (_storage(runway_to_degraded_days=7.0), "close enough"), + ], +) +def test_storage_interpretation_tracks_runway(storage: SimpleNamespace, expected: str) -> None: + assert expected in helpers.storage_interpretation(storage) + + +def test_storage_growth_projection_and_acceleration_boundaries() -> None: + snapshots = [ + SimpleNamespace(snapshot_date=date(2026, 1, 1), used_bytes=100), + SimpleNamespace(snapshot_date=date(2026, 1, 3), used_bytes=180), + SimpleNamespace(snapshot_date=date(2026, 1, 5), used_bytes=260), + SimpleNamespace(snapshot_date=date(2026, 1, 7), used_bytes=400), + ] + assert helpers.storage_growth_rates(snapshots) == (50.0, 40.0) + assert ( + helpers.project_days_remaining(threshold_bytes=100, used_bytes=100, daily_growth_bytes=10.0) + == 0.0 + ) + assert helpers.storage_is_accelerating( + _storage(daily_growth_bytes=1.0, previous_daily_growth_bytes=0.0) + ) + assert helpers.storage_is_accelerating( + _storage(daily_growth_bytes=11.0, previous_daily_growth_bytes=8.0) + ) + assert helpers.storage_trend_score(_storage(daily_growth_bytes=None)) == 10 + assert ( + helpers.storage_trend_score( + _storage(daily_growth_bytes=4.0, previous_daily_growth_bytes=None) + ) + == 30 + ) + assert ( + helpers.storage_trend_score( + _storage(daily_growth_bytes=16.0, previous_daily_growth_bytes=8.0) + ) + == 100 + ) + + +@pytest.mark.parametrize( + ("storage", "severity", "imminence"), + [ + (_storage(state="unhealthy"), 100, 20), + (_storage(state="degraded"), 80, 20), + (_storage(runway_to_degraded_days=None), 0, 20), + (_storage(runway_to_degraded_days=5.0), 85, 95), + (_storage(runway_to_degraded_days=15.0), 65, 70), + (_storage(runway_to_degraded_days=30.0), 45, 45), + (_storage(runway_to_degraded_days=60.0), 0, 20), + ], +) +def test_storage_priority_boundaries( + storage: SimpleNamespace, + severity: int, + imminence: int, +) -> None: + assert helpers.storage_severity(storage) == severity + assert helpers.storage_imminence(storage) == imminence + + +def test_dashboard_date_labels_handle_missing_and_boundary_values() -> None: + now = datetime(2026, 1, 10, tzinfo=UTC) + assert helpers.age_in_days(None, now) == 0 + assert helpers.oldest_age_label(None, now) == "Fresh queue" + assert helpers.oldest_age_label(now - timedelta(days=1), now) == "Oldest item is 1 day old" + assert helpers.days_until(None, date(2026, 1, 10)) == 999 + assert helpers.release_time_label(None, date(2026, 1, 10)) == "Soon" + assert helpers.release_time_label(date(2026, 1, 13), date(2026, 1, 10)) == "Due in 3 days" + + +def test_storage_labels_and_search_drift_cover_sparse_baselines() -> None: + assert helpers.storage_runway_label(_storage(state="unhealthy")) == ( + "Past the unhealthy threshold" + ) + assert helpers.storage_runway_label(_storage(runway_to_degraded_days=0.0)) == ( + "At the degraded threshold" + ) + assert helpers.trend_label(5, 5.0, noun="item") == "Flat vs last week" + assert not helpers.search_yield_is_drifting( + SimpleNamespace(rate=50.0, previous_rate=80.0, searches=2, previous_searches=10) + ) + + +@pytest.mark.asyncio +async def test_dashboard_metric_facades_forward_to_loader(monkeypatch: pytest.MonkeyPatch) -> None: + service = DashboardIntelligenceService(AsyncMock()) + loader = MagicMock() + async_methods = [ + "load_snapshot", + "load_reference_metrics", + "latest_rollup_timestamp", + "has_terminal_download_history", + "load_download_summary", + "load_client_reliability", + "load_client_reliability_window", + "load_review_debt", + "load_release_risk", + "load_search_yield", + "load_import_failures", + "load_health_summary", + "load_storage_summary", + "load_failure_clusters", + "load_unmatched_clusters", + "ensure_daily_storage_snapshot", + "should_refresh_rollups", + "persist_rollups", + ] + for name in async_methods: + setattr(loader, name, AsyncMock(return_value=name)) + monkeypatch.setattr(service, "_metric_loader", lambda: loader) + now = datetime(2026, 1, 2, 3, 4, 5, tzinfo=UTC) + before = now - timedelta(days=1) + snapshot = object() + storage = object() + + assert await service._load_snapshot(now) == "load_snapshot" + assert await service._load_reference_metrics(now) == "load_reference_metrics" + assert await service._latest_rollup_timestamp() == "latest_rollup_timestamp" + assert await service._has_terminal_download_history() == "has_terminal_download_history" + assert await service._load_download_summary(before, before, now) == "load_download_summary" + assert await service._load_client_reliability(before, before, now) == "load_client_reliability" + assert ( + await service._load_client_reliability_window(before, now) + == "load_client_reliability_window" + ) + assert await service._load_review_debt(None) == "load_review_debt" + assert await service._load_release_risk(now.date(), None) == "load_release_risk" + assert await service._load_search_yield(before, before, now) == "load_search_yield" + assert await service._load_import_failures(before, before, now) == "load_import_failures" + assert await service._load_health_summary(None) == "load_health_summary" + assert await service._load_storage_summary(now) == "load_storage_summary" + assert await service._load_failure_clusters(before, now) == "load_failure_clusters" + assert await service._load_unmatched_clusters() == "load_unmatched_clusters" + await service._ensure_daily_storage_snapshot(storage, now) # type: ignore[arg-type] + assert await service._should_refresh_rollups(snapshot, now) == "should_refresh_rollups" # type: ignore[arg-type] + await service._persist_rollups(snapshot, now) # type: ignore[arg-type] + + +def test_dashboard_presentation_and_priority_facades_forward( + monkeypatch: pytest.MonkeyPatch, +) -> None: + service = DashboardIntelligenceService(AsyncMock()) + snapshot = object() + priorities = [object()] + presentation = MagicMock() + priority = MagicMock() + for name in ( + "build_briefing", + "build_scorecards", + "build_watch_items", + "build_exceptions", + "build_live_pulse", + ): + getattr(presentation, name).return_value = name + for name in ( + "build_priorities", + "build_health_priority", + "build_storage_priority", + "build_client_failure_priority", + "build_review_debt_priority", + "build_release_risk_priority", + "build_search_yield_priority", + "build_import_failure_priority", + "build_unmatched_growth_priority", + ): + getattr(priority, name).return_value = name + monkeypatch.setattr(service, "_presentation_builder", lambda: presentation) + monkeypatch.setattr(service, "_priority_builder", lambda: priority) + + assert service._build_briefing(snapshot, priorities) == "build_briefing" # type: ignore[arg-type] + assert service._build_scorecards(snapshot) == "build_scorecards" # type: ignore[arg-type] + assert service._build_watch_items(snapshot) == "build_watch_items" # type: ignore[arg-type] + assert service._build_exceptions(snapshot) == "build_exceptions" # type: ignore[arg-type] + assert service._build_live_pulse(snapshot) == "build_live_pulse" # type: ignore[arg-type] + assert service._build_priorities(snapshot) == "build_priorities" # type: ignore[arg-type] + assert service._build_health_priority(snapshot) == "build_health_priority" # type: ignore[arg-type] + assert service._build_storage_priority(snapshot) == "build_storage_priority" # type: ignore[arg-type] + assert ( + service._build_client_failure_priority(snapshot) == "build_client_failure_priority" # type: ignore[arg-type] + ) + assert service._build_review_debt_priority(snapshot) == "build_review_debt_priority" # type: ignore[arg-type] + assert service._build_release_risk_priority(snapshot) == "build_release_risk_priority" # type: ignore[arg-type] + assert service._build_search_yield_priority(snapshot) == "build_search_yield_priority" # type: ignore[arg-type] + assert service._build_import_failure_priority(snapshot) == "build_import_failure_priority" # type: ignore[arg-type] + assert ( + service._build_unmatched_growth_priority(snapshot) == "build_unmatched_growth_priority" # type: ignore[arg-type] + ) + + +def test_dashboard_client_labels_and_hour_bucket_cover_fallbacks() -> None: + assert _download_client_label("sabnzbd") == "SABnzbd" + assert _download_client_label("custom_client") == "Custom Client" + assert _hour_bucket_start(datetime(2026, 1, 2, 3, 4, 5, 6, tzinfo=UTC)) == datetime( + 2026, + 1, + 2, + 3, + tzinfo=UTC, + ) + + +@pytest.mark.asyncio +async def test_dashboard_cache_rollback_failure_is_non_fatal() -> None: + session = AsyncMock() + session.rollback.side_effect = RuntimeError("closed") + + await DashboardIntelligenceService(session)._rollback_after_cache_error() + + session.rollback.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_capture_rollups_loads_and_persists_one_snapshot() -> None: + service = DashboardIntelligenceService(AsyncMock()) + now = datetime(2026, 1, 2, 3, tzinfo=UTC) + snapshot = SimpleNamespace(storage=object()) + service._load_snapshot = AsyncMock(return_value=snapshot) # type: ignore[method-assign] + service._ensure_daily_storage_snapshot = AsyncMock() # type: ignore[method-assign] + service._persist_rollups = AsyncMock() # type: ignore[method-assign] + + await service.capture_rollups(now=now) + + service._ensure_daily_storage_snapshot.assert_awaited_once_with(snapshot.storage, now) # type: ignore[attr-defined] + service._persist_rollups.assert_awaited_once_with(snapshot, now) # type: ignore[attr-defined] + + +class _CacheSession: + def __init__(self, commit_error: Exception | None = None) -> None: + self.commit_error = commit_error + self.execute = AsyncMock() + self.commit = AsyncMock(side_effect=commit_error) + self.rollback = AsyncMock() + + async def __aenter__(self) -> _CacheSession: + return self + + async def __aexit__(self, *_args: object) -> None: + return None + + +@pytest.mark.asyncio +async def test_best_effort_cache_write_commits_successfully( + monkeypatch: pytest.MonkeyPatch, +) -> None: + cache_session = _CacheSession() + monkeypatch.setattr( + "pullbox.database.get_session_factory", + lambda: lambda: cache_session, + ) + writer = AsyncMock() + + await DashboardIntelligenceService(AsyncMock())._run_best_effort_cache_write( + cache_key="metrics", + writer=writer, + ) + + writer.assert_awaited_once_with(cache_session) + cache_session.commit.assert_awaited_once() + cache_session.rollback.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "commit_error", + [ + DatabaseError("commit", {}, Exception("database failed")), + RuntimeError("unexpected"), + ], +) +async def test_best_effort_cache_write_contains_non_lock_failures( + monkeypatch: pytest.MonkeyPatch, + commit_error: Exception, +) -> None: + cache_session = _CacheSession(commit_error) + monkeypatch.setattr( + "pullbox.database.get_session_factory", + lambda: lambda: cache_session, + ) + service = DashboardIntelligenceService(AsyncMock()) + service._log_cache_error = MagicMock() # type: ignore[method-assign] + + await service._run_best_effort_cache_write( + cache_key="metrics", + writer=AsyncMock(), + ) + + cache_session.rollback.assert_awaited_once() + if isinstance(commit_error, DatabaseError): + service._log_cache_error.assert_called_once_with( # type: ignore[attr-defined] + cache_key="metrics", + exc=commit_error, + ) + else: + service._log_cache_error.assert_not_called() # type: ignore[attr-defined] diff --git a/tests/unit/test_direct_artifact_pack.py b/tests/unit/test_direct_artifact_pack.py index 5b9ed36..b910896 100644 --- a/tests/unit/test_direct_artifact_pack.py +++ b/tests/unit/test_direct_artifact_pack.py @@ -11,6 +11,7 @@ DirectArtifactPackError, _issue_path_token, extract_same_series_issue_files, + is_separable_issue_pack, ) if TYPE_CHECKING: @@ -132,3 +133,131 @@ def test_rejects_nested_issue_file_for_a_different_series(tmp_path: Path) -> Non ) assert caught.value.code == "direct_pack_mixed_series" + + +def test_structural_pack_detection_handles_valid_and_unreadable_archives(tmp_path: Path) -> None: + pack = tmp_path / "Alien #5-6.cbz" + _write_nested_pack(pack, "Alien #5.cbz", "Alien #6.cbz") + + assert is_separable_issue_pack(pack) is True + assert is_separable_issue_pack(tmp_path / "missing.cbz") is False + + +def test_single_expected_issue_does_not_enter_pack_extraction(tmp_path: Path) -> None: + assert ( + extract_same_series_issue_files( + tmp_path / "missing.cbz", + destination=tmp_path / "extracted", + expected_issue_numbers=frozenset({"5"}), + expected_series_titles=frozenset({"Alien"}), + ) + == {} + ) + + +def test_pack_requires_a_normalizable_series_identity(tmp_path: Path) -> None: + pack = tmp_path / "Alien #5-6.cbz" + _write_nested_pack(pack, "Alien #5.cbz", "Alien #6.cbz") + + with pytest.raises(DirectArtifactPackError) as caught: + extract_same_series_issue_files( + pack, + destination=tmp_path / "extracted", + expected_issue_numbers=frozenset({"5", "6"}), + expected_series_titles=frozenset({""}), + ) + + assert caught.value.code == "direct_pack_series_invalid" + + +def test_unreadable_pack_has_stable_failure(tmp_path: Path) -> None: + with pytest.raises(DirectArtifactPackError) as caught: + extract_same_series_issue_files( + tmp_path / "missing.cbz", + destination=tmp_path / "extracted", + expected_issue_numbers=frozenset({"5", "6"}), + expected_series_titles=frozenset({"Alien"}), + ) + + assert caught.value.code == "direct_pack_unreadable" + + +def test_pack_rejects_unsafe_nested_comic_path(tmp_path: Path) -> None: + pack = tmp_path / "Alien #5-6.cbz" + _write_nested_pack(pack, "../Alien #5.cbz", "Alien #6.cbz") + + with pytest.raises(DirectArtifactPackError) as caught: + extract_same_series_issue_files( + pack, + destination=tmp_path / "extracted", + expected_issue_numbers=frozenset({"5", "6"}), + expected_series_titles=frozenset({"Alien"}), + ) + + assert caught.value.code == "direct_pack_unsafe_member" + + +def test_pack_skips_unparseable_and_unwanted_issue_members(tmp_path: Path) -> None: + pack = tmp_path / "Alien #5-6.cbz" + _write_nested_pack(pack, "notes.cbz", "Alien #4.cbz", "Alien #5.cbz") + + with pytest.raises(DirectArtifactPackError) as caught: + extract_same_series_issue_files( + pack, + destination=tmp_path / "extracted", + expected_issue_numbers=frozenset({"5", "6"}), + expected_series_titles=frozenset({"Alien"}), + ) + + assert caught.value.code == "direct_pack_incomplete" + + +def test_pack_rejects_duplicate_files_for_one_issue(tmp_path: Path) -> None: + pack = tmp_path / "Alien #5-6.cbz" + _write_nested_pack(pack, "Alien #5.cbz", "nested/Alien #5.cbz", "Alien #6.cbz") + + with pytest.raises(DirectArtifactPackError) as caught: + extract_same_series_issue_files( + pack, + destination=tmp_path / "extracted", + expected_issue_numbers=frozenset({"5", "6"}), + expected_series_titles=frozenset({"Alien"}), + ) + + assert caught.value.code == "direct_pack_ambiguous_issue" + + +def test_pack_rejects_invalid_declared_coverage(tmp_path: Path) -> None: + pack = tmp_path / "Alien #5-6.cbz" + _write_nested_pack(pack, "Alien #5.cbz", "Alien #6.cbz") + + with pytest.raises(DirectArtifactPackError) as caught: + extract_same_series_issue_files( + pack, + destination=tmp_path / "extracted", + expected_issue_numbers=frozenset({"not-a-number", "6"}), + expected_series_titles=frozenset({"Alien"}), + ) + + assert caught.value.code == "direct_pack_coverage_invalid" + + +def test_pack_reports_destination_write_failure(tmp_path: Path) -> None: + pack = tmp_path / "Alien #5-6.cbz" + _write_nested_pack(pack, "Alien #5.cbz", "Alien #6.cbz") + destination = tmp_path / "extracted" + (destination / "issue-5.cbz").mkdir(parents=True) + + with pytest.raises(DirectArtifactPackError) as caught: + extract_same_series_issue_files( + pack, + destination=destination, + expected_issue_numbers=frozenset({"5", "6"}), + expected_series_titles=frozenset({"Alien"}), + ) + + assert caught.value.code == "direct_pack_extract_failed" + + +def test_issue_path_token_falls_back_for_unusual_text() -> None: + assert _issue_path_token("Special Edition") == "Special Edition" diff --git a/tests/unit/test_direct_artifact_quarantine.py b/tests/unit/test_direct_artifact_quarantine.py index 5a86226..9f90e99 100644 --- a/tests/unit/test_direct_artifact_quarantine.py +++ b/tests/unit/test_direct_artifact_quarantine.py @@ -13,8 +13,12 @@ from pullbox.services.direct_artifact_quarantine import ( DirectArtifactQuarantine, DirectArtifactValidationError, + DirectQuarantineWorkspace, + _detect_comic_suffix, + _ensure_private_directory, validate_direct_artifact, ) +from pullbox.utilities.executors.integrity_checker import IntegrityResult if TYPE_CHECKING: from pathlib import Path @@ -178,3 +182,226 @@ def test_quarantine_rejects_symlinked_attempt_directory(tmp_path: Path) -> None: quarantine.prepare(acquisition_id=1, artifact_id=2) assert caught.value.code == "unsafe_quarantine_destination" + + +@pytest.mark.parametrize(("acquisition_id", "artifact_id"), [(0, 1), (1, 0), (-1, 2)]) +def test_quarantine_rejects_non_positive_identifiers( + tmp_path: Path, + acquisition_id: int, + artifact_id: int, +) -> None: + with pytest.raises(ValueError, match="must be positive"): + DirectArtifactQuarantine(tmp_path / "direct").prepare( + acquisition_id=acquisition_id, + artifact_id=artifact_id, + ) + + +def test_validation_error_exposes_safe_diagnostics() -> None: + safety_block = {"overrideable": True, "reason": "too large"} + error = DirectArtifactValidationError( + code="too_large", + message="Review required", + retryable=True, + safety_block=safety_block, + ) + safety_block["reason"] = "mutated" + + assert error.overrideable is True + assert error.safety_block == {"overrideable": True, "reason": "too large"} + assert "code='too_large'" in repr(error) + + +def test_finalize_rejects_missing_empty_and_existing_payloads(tmp_path: Path) -> None: + quarantine = DirectArtifactQuarantine(tmp_path / "direct") + missing = quarantine.prepare(acquisition_id=1, artifact_id=1) + with pytest.raises(DirectArtifactValidationError, match="safe quarantine"): + quarantine.finalize(missing, filename_hint="issue.cbz") + + empty = quarantine.prepare(acquisition_id=2, artifact_id=2) + empty.partial_path.touch() + with pytest.raises(DirectArtifactValidationError, match="safe quarantine"): + quarantine.finalize(empty, filename_hint="issue.cbz") + + occupied = quarantine.prepare(acquisition_id=3, artifact_id=3) + _write_cbz(occupied.partial_path) + (occupied.directory / "artifact-3.cbz").write_bytes(b"occupied") + with pytest.raises(DirectArtifactValidationError, match="safe quarantine"): + quarantine.finalize(occupied, filename_hint="issue.cbz") + + +def test_finalize_reports_atomic_write_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + quarantine = DirectArtifactQuarantine(tmp_path / "direct") + workspace = quarantine.prepare(acquisition_id=1, artifact_id=2) + _write_cbz(workspace.partial_path) + + def fail_replace(*_args: object) -> None: + raise OSError("read only") + + monkeypatch.setattr("pullbox.services.direct_artifact_quarantine.os.replace", fail_replace) + + with pytest.raises(DirectArtifactValidationError) as caught: + quarantine.finalize(workspace, filename_hint="issue.cbz") + + assert caught.value.code == "artifact_quarantine_unwritable" + + +@pytest.mark.parametrize( + ("header", "hint", "suffix"), + [ + (b"PK\x03\x04payload", "book.epub", ".epub"), + (b"Rar!\x1a\x07payload", "book.bin", ".cbr"), + (b"7z\xbc\xaf\x27\x1cpayload", None, ".cb7"), + (b"%PDF payload", None, ".pdf"), + (b"\x00" * 257 + b"ustar", None, ".cbt"), + ], +) +def test_detect_comic_suffix_uses_content_signatures( + tmp_path: Path, + header: bytes, + hint: str | None, + suffix: str, +) -> None: + payload = tmp_path / "payload.part" + payload.write_bytes(header) + + assert _detect_comic_suffix(payload, filename_hint=hint) == suffix + + +def test_detect_comic_suffix_reports_unreadable_payload(tmp_path: Path) -> None: + with pytest.raises(DirectArtifactValidationError) as caught: + _detect_comic_suffix(tmp_path / "missing.part", filename_hint=None) + + assert caught.value.code == "artifact_quarantine_unreadable" + assert caught.value.retryable is True + assert caught.value.intervention is False + + +def test_private_directory_must_remain_beneath_parent(tmp_path: Path) -> None: + parent = tmp_path / "parent" + outside = tmp_path / "outside" + parent.mkdir() + + with pytest.raises(DirectArtifactValidationError, match="safe quarantine"): + _ensure_private_directory(outside, parent=parent) + + +def test_cleanup_ignores_invalid_workspace(tmp_path: Path) -> None: + root = tmp_path / "root" + root.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + workspace = DirectQuarantineWorkspace( + root=root, + directory=outside, + partial_path=outside / "payload.part", + artifact_id=1, + ) + + DirectArtifactQuarantine(root).cleanup(workspace) + + assert outside.exists() + + +@pytest.mark.asyncio +async def test_direct_artifact_rejects_disallowed_extension(tmp_path: Path) -> None: + path = tmp_path / "payload.exe" + path.write_bytes(b"binary") + session = AsyncMock() + session.get.return_value = None + + with pytest.raises(DirectArtifactValidationError) as caught: + await validate_direct_artifact(session, path) + + assert caught.value.code == "artifact_file_type_unsupported" + + +@pytest.mark.asyncio +async def test_direct_artifact_rejects_non_resource_safety_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + path = tmp_path / "payload.cbz" + _write_cbz(path) + session = AsyncMock() + session.get.return_value = None + + def reject_archive(*_args: object, **_kwargs: object) -> None: + raise FileSafetyError("unsafe archive member") + + monkeypatch.setattr( + "pullbox.services.direct_artifact_quarantine.run_safety_checks", + reject_archive, + ) + + with pytest.raises(DirectArtifactValidationError) as caught: + await validate_direct_artifact(session, path) + + assert caught.value.code == "artifact_safety_rejected" + assert caught.value.intervention is False + + +@pytest.mark.asyncio +async def test_direct_artifact_rejects_corrupt_non_pack( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + path = tmp_path / "payload.cbz" + _write_cbz(path) + session = AsyncMock() + session.get.return_value = None + monkeypatch.setattr( + "pullbox.services.direct_artifact_quarantine.check_file_integrity", + AsyncMock(return_value=IntegrityResult(status="corrupt")), + ) + monkeypatch.setattr( + "pullbox.services.direct_artifact_quarantine.is_separable_issue_pack", + lambda _path: False, + ) + + with pytest.raises(DirectArtifactValidationError) as caught: + await validate_direct_artifact(session, path) + + assert caught.value.code == "artifact_integrity_failed" + + +@pytest.mark.asyncio +async def test_direct_artifact_reports_unreadable_final_stat( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class UnreadablePath: + suffix = ".cbz" + + def stat(self) -> object: + raise OSError("gone") + + session = AsyncMock() + monkeypatch.setattr( + "pullbox.services.direct_artifact_quarantine.get_allowed_extensions", + AsyncMock(return_value={".cbz"}), + ) + monkeypatch.setattr( + "pullbox.services.direct_artifact_quarantine.is_dangerous_file_blocking_enabled", + AsyncMock(return_value=True), + ) + monkeypatch.setattr( + "pullbox.services.direct_artifact_quarantine.get_archive_size_limit_bytes", + AsyncMock(return_value=1024), + ) + monkeypatch.setattr( + "pullbox.services.direct_artifact_quarantine.run_safety_checks", + lambda *_args, **_kwargs: None, + ) + monkeypatch.setattr( + "pullbox.services.direct_artifact_quarantine.check_file_integrity", + AsyncMock(return_value=IntegrityResult(status="healthy", page_count=1)), + ) + + with pytest.raises(DirectArtifactValidationError) as caught: + await validate_direct_artifact(session, UnreadablePath()) # type: ignore[arg-type] + + assert caught.value.code == "artifact_quarantine_unreadable" + assert caught.value.retryable is True diff --git a/tests/unit/test_direct_provider_source_origin_edges.py b/tests/unit/test_direct_provider_source_origin_edges.py new file mode 100644 index 0000000..3055182 --- /dev/null +++ b/tests/unit/test_direct_provider_source_origin_edges.py @@ -0,0 +1,180 @@ +"""Boundary tests for direct-provider source-origin validation.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest + +from pullbox.models.direct_acquisition import DirectProviderConfig +from pullbox.providers.direct.contract import DIRECT_PROVIDER_PROTOCOL_V1 +from pullbox.services.direct_provider_source_origin import ( + DirectProviderSourceOriginError, + _normalize_domain, + _origin_hostname, + _public_configuration, + _resolve_source_addresses, + configured_direct_provider_source_domain, + effective_direct_provider_source_domains, + validate_direct_provider_source_origin, +) + + +def _manifest(*, source_domains: list[str] | None = None) -> dict[str, object]: + return { + "protocol_version": DIRECT_PROVIDER_PROTOCOL_V1, + "provider_id": "pullbox.test", + "display_name": "Test Provider", + "description": "Source origin fixture.", + "provider_version": "1.0.0", + "supported_protocol_versions": [DIRECT_PROVIDER_PROTOCOL_V1], + "publisher": "Pullbox", + "license": "GPL-3.0-or-later", + "source_domains": source_domains or ["source.example"], + "capabilities": { + "search": True, + "resolve": True, + "browser_challenge": True, + "health": True, + "quota": False, + "configuration_schema": True, + }, + "configuration_schema": { + "type": "object", + "properties": { + "source_url": { + "type": "string", + "format": "uri", + "x-pullbox-source-origin": True, + } + }, + "additionalProperties": False, + }, + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize("raw_url", ["", " ", "x" * 2_001, 42]) +async def test_source_origin_requires_a_bounded_string(raw_url: object) -> None: + with pytest.raises(DirectProviderSourceOriginError, match="bounded URL"): + await validate_direct_provider_source_origin(raw_url) # type: ignore[arg-type] + + +@pytest.mark.asyncio +async def test_source_origin_rejects_malformed_port_and_url_shape() -> None: + with pytest.raises(DirectProviderSourceOriginError, match="malformed"): + await validate_direct_provider_source_origin("https://source.example:bad") + with pytest.raises(DirectProviderSourceOriginError, match="one HTTPS origin"): + await validate_direct_provider_source_origin("https://source.example/path") + + +@pytest.mark.asyncio +async def test_source_origin_dns_failures_are_fail_open_but_bad_answers_fail_closed() -> None: + async def unavailable(_host: str, _port: int) -> tuple[str, ...]: + raise OSError("dns unavailable") + + async def empty(_host: str, _port: int) -> tuple[str, ...]: + return () + + async def invalid(_host: str, _port: int) -> tuple[str, ...]: + return ("not-an-ip",) + + async def private(_host: str, _port: int) -> tuple[str, ...]: + return ("127.0.0.1",) + + for resolver in (unavailable, empty): + validated = await validate_direct_provider_source_origin( + "https://SOURCE.example./", + resolver=resolver, + ) + assert validated.url == "https://source.example" + assert validated.host == "source.example" + + with pytest.raises(DirectProviderSourceOriginError, match="invalid public"): + await validate_direct_provider_source_origin("https://source.example", resolver=invalid) + with pytest.raises(DirectProviderSourceOriginError, match="public network"): + await validate_direct_provider_source_origin("https://source.example", resolver=private) + + +@pytest.mark.asyncio +async def test_source_origin_accepts_only_global_dns_answers() -> None: + async def public(_host: str, _port: int) -> tuple[str, ...]: + return ("1.1.1.1", "2606:4700:4700::1111") + + validated = await validate_direct_provider_source_origin( + "https://source.example:443", + resolver=public, + ) + + assert validated.host == "source.example" + + +def test_effective_domains_survive_invalid_manifest_and_normalize_values() -> None: + config = DirectProviderConfig( + provider_id="pullbox.invalid", + display_name="Invalid", + endpoint="http://provider:8780", + manifest_snapshot={ + "source_domains": [".SOURCE.Example.", "source.example", 4], + }, + ) + + assert effective_direct_provider_source_domains(config) == ("source.example",) + assert configured_direct_provider_source_domain(config) is None + + +def test_configured_source_origin_precedes_legacy_domain_fallback() -> None: + config = DirectProviderConfig( + provider_id="pullbox.test", + display_name="Test", + endpoint="http://provider:8780", + configuration_metadata={ + "public_values": { + "source_url": "https://CUSTOM.example/", + "domain": "https://legacy.example", + } + }, + manifest_snapshot=_manifest(source_domains=[".SOURCE.EXAMPLE.", "source.example"]), + ) + + assert effective_direct_provider_source_domains(config) == ( + "source.example", + "custom.example", + ) + assert configured_direct_provider_source_domain(config) == "custom.example" + + +def test_public_configuration_and_origin_helpers_fail_closed() -> None: + config = DirectProviderConfig( + provider_id="pullbox.test", + display_name="Test", + endpoint="http://provider:8780", + configuration_metadata=[], # type: ignore[arg-type] + ) + + assert _public_configuration(config) == {} + config.configuration_metadata = {"public_values": []} + assert _public_configuration(config) == {} + assert _origin_hostname("https://source.example:bad") is None + assert _origin_hostname("https://source.example/path") is None + assert _normalize_domain(" .SOURCE.Example. ") == "source.example" + + +@pytest.mark.asyncio +async def test_default_resolver_deduplicates_socket_addresses( + monkeypatch: pytest.MonkeyPatch, +) -> None: + records = [ + (None, None, None, None, ("1.1.1.1", 443)), + (None, None, None, None, ("1.1.1.1", 443)), + (None, None, None, None, ("8.8.8.8", 443)), + ] + monkeypatch.setattr( + "pullbox.services.direct_provider_source_origin.asyncio.to_thread", + AsyncMock(return_value=records), + ) + + assert await _resolve_source_addresses("source.example", 443) == ( + "1.1.1.1", + "8.8.8.8", + ) diff --git a/tests/unit/test_file_ops.py b/tests/unit/test_file_ops.py index a17a233..affeda3 100644 --- a/tests/unit/test_file_ops.py +++ b/tests/unit/test_file_ops.py @@ -32,6 +32,7 @@ LibraryRootPolicySource, MatchConfidence, ) +from pullbox.models.matching_suggestion import MatchingSuggestion from pullbox.models.publisher import Publisher from pullbox.models.series import Series @@ -1451,6 +1452,116 @@ async def test_existing_file_ownership_cannot_flip_during_registration( class TestReplacementRegistration: """Explicit replacement refreshes the existing library file row.""" + @pytest.mark.asyncio + async def test_source_preserving_replacement_never_stages_referenced_artifact( + self, + session: AsyncSession, + issue: Issue, + source_file: Path, + comics_dir_config: Path, + ) -> None: + from pullbox.core.file_ops import _stage_replacement_file + + root = (await session.scalars(select(LibraryRoot).limit(1))).one() + referenced = LibraryFile( + file_path=str(source_file), + file_name=source_file.name, + file_size=source_file.stat().st_size, + file_format=FileFormat.CBZ, + file_modified_at=datetime.fromtimestamp(source_file.stat().st_mtime, tz=UTC), + match_confidence=MatchConfidence.HIGH, + issue_id=issue.id, + library_root_id=root.id, + storage_mode=LibraryFileStorageMode.REFERENCED, + ) + session.add(referenced) + await session.flush() + issue.library_file = referenced + prepared_copy = comics_dir_config / ".staging" / "Batman 017.cbz" + prepared_copy.parent.mkdir() + prepared_copy.write_bytes(b"prepared managed copy") + + stash = await _stage_replacement_file( + issue, + prepared_copy, + replace_existing_library_file=True, + replacement_trash_dir=None, + preserve_replaced_artifact=True, + ) + + assert stash is not None + assert stash.library_file is referenced + assert stash.staged_path is None + assert source_file.exists() + assert source_file.read_bytes() != prepared_copy.read_bytes() + + @pytest.mark.asyncio + async def test_source_preserving_replacement_keeps_library_file_identity_and_dependents( + self, + session: AsyncSession, + issue: Issue, + source_file: Path, + comics_dir_config: Path, + ) -> None: + from pullbox.core.file_ops import register_library_file + + target_root = (await session.scalars(select(LibraryRoot).limit(1))).one() + source_root = LibraryRoot( + name="Legacy Mylar", + path=str(source_file.parent), + allow_managed_writes=False, + ) + session.add(source_root) + await session.flush() + issue.series.path = str(source_file.parent / "Batman") + issue.series.library_root_id = source_root.id + issue.series.preferred_library_root_id = source_root.id + referenced = LibraryFile( + file_path=str(source_file), + file_name=source_file.name, + file_size=source_file.stat().st_size, + file_format=FileFormat.CBZ, + file_modified_at=datetime.fromtimestamp(source_file.stat().st_mtime, tz=UTC), + match_confidence=MatchConfidence.HIGH, + issue_id=issue.id, + library_root_id=source_root.id, + storage_mode=LibraryFileStorageMode.REFERENCED, + ) + session.add(referenced) + await session.flush() + issue.library_file = referenced + suggestion = MatchingSuggestion( + library_file_id=referenced.id, + parent_series_id=issue.series_id, + suggested_title="Batman Annual", + ) + session.add(suggestion) + await session.flush() + referenced_id = referenced.id + suggestion_id = suggestion.id + + registered = await register_library_file( + session, + source_file, + issue, + MatchConfidence.HIGH, + move_to_library=True, + library_root_id=target_root.id, + loaded_issue=issue, + transfer_method="copy", + replace_existing_library_file=True, + preserve_replaced_artifact=True, + ) + + assert registered.id == referenced_id + assert registered.storage_mode is LibraryFileStorageMode.MANAGED + assert registered.library_root_id == target_root.id + assert registered.file_path != str(source_file) + assert source_file.exists() + preserved_suggestion = await session.get(MatchingSuggestion, suggestion_id) + assert preserved_suggestion is not None + assert preserved_suggestion.library_file_id == registered.id + @pytest.mark.asyncio async def test_replacement_same_path_updates_existing_metadata( self, diff --git a/tests/unit/test_file_safety_archive.py b/tests/unit/test_file_safety_archive.py index d41841b..44f0649 100644 --- a/tests/unit/test_file_safety_archive.py +++ b/tests/unit/test_file_safety_archive.py @@ -72,6 +72,20 @@ def test_run_safety_checks_rejects_unreadable_zip_archive(tmp_path: Path) -> Non ) +def test_run_safety_checks_classifies_zero_byte_archive_before_reader_failure( + tmp_path: Path, +) -> None: + archive = tmp_path / "empty.cbz" + archive.touch() + + with pytest.raises(FileSafetyError, match="zero_byte_file"): + run_safety_checks( + archive, + block_dangerous=True, + max_archive_size=2000 * 1024 * 1024, + ) + + def test_ensure_zip_archive_inspectable_ignores_non_zip_and_accepts_valid_zip( tmp_path: Path, ) -> None: @@ -137,6 +151,29 @@ def counting_zip_file(*args: Any, **kwargs: Any) -> Any: ) +def test_run_safety_checks_prefers_canonical_root_comicinfo(tmp_path: Path) -> None: + archive = tmp_path / "duplicate-comicinfo.cbz" + with zipfile.ZipFile(archive, "w") as payload: + payload.writestr("Legacy/ComicInfo.xml", "Wrong") + payload.writestr( + "ComicInfo.xml", + "Babyteeth1", + ) + payload.writestr("page001.jpg", b"ok") + + report = run_safety_checks( + archive, + block_dangerous=True, + max_archive_size=2000 * 1024 * 1024, + ).archives[0] + + assert report.comicinfo_entry_count == 2 + assert report.comicinfo_entry == "ComicInfo.xml" + assert report.comicinfo is not None + assert report.comicinfo.series == "Babyteeth" + assert report.comicinfo.number == "1" + + def test_run_safety_checks_rejects_dangerous_files_on_disk(tmp_path: Path) -> None: payload = tmp_path / "nested" / "setup.exe" payload.parent.mkdir() diff --git a/tests/unit/test_github_actions_security_contracts.py b/tests/unit/test_github_actions_security_contracts.py index 61b72b3..e9fe82f 100644 --- a/tests/unit/test_github_actions_security_contracts.py +++ b/tests/unit/test_github_actions_security_contracts.py @@ -983,6 +983,8 @@ def test_grype_config_tracks_current_dhi_runtime() -> None: assert "CVE-2026-11824" in config_text assert "CVE-2026-14456" in config_text assert "CVE-2026-66046" in config_text + assert "CVE-2026-76956" in config_text + assert "CVE-2026-76957" in config_text assert "3.14.6" in config_text assert config.get("ignore") @@ -1009,6 +1011,25 @@ def test_grype_config_tracks_current_dhi_runtime() -> None: ("libexpat1-dev", "2.8.3-1~deb13u1+dhi2", "deb"), } + current_expat_exceptions = [ + entry + for entry in config["ignore"] + if entry.get("vulnerability") in {"CVE-2026-76956", "CVE-2026-76957"} + ] + assert "Re-review by 2026-10-07" in config_text + assert { + ( + entry["vulnerability"], + entry["package"]["name"], + entry["package"]["version"], + entry["package"]["type"], + ) + for entry in current_expat_exceptions + } == { + (cve, "libexpat1", "2.8.3-1~deb13u1+dhi2", "deb") + for cve in {"CVE-2026-76956", "CVE-2026-76957"} + } + def test_grype_current_dhi_zlib_and_libuuid_exceptions_are_exact_and_expiring() -> None: config_text = GRYPE_CONFIG.read_text(encoding="utf-8") diff --git a/tests/unit/test_import_catalog_hydration.py b/tests/unit/test_import_catalog_hydration.py index 12c7d4e..282995c 100644 --- a/tests/unit/test_import_catalog_hydration.py +++ b/tests/unit/test_import_catalog_hydration.py @@ -2,12 +2,15 @@ from __future__ import annotations +import asyncio +from datetime import UTC, datetime, timedelta from typing import Any from unittest.mock import AsyncMock, MagicMock from sqlalchemy import select as sa_select from sqlalchemy.ext.asyncio import async_sessionmaker +from pullbox.core.exceptions import ProviderError from pullbox.models.import_job import ( ImportedSeries, ImportJob, @@ -143,6 +146,16 @@ async def upsert_comicvine_profile(self, hydrate_session, comicvine_id: int, ser return series +class RateLimitedBatchCatalogHydrationSeriesServiceStub(BatchCatalogHydrationSeriesServiceStub): + async def prefetch_comicvine_profiles(self, comicvine_ids: list[int]): + self.profile_batch_calls.append(list(comicvine_ids)) + raise ProviderError( + "comicvine", + "HTTP 420: /volumes/", + details={"status_code": 420, "retryable": True}, + ) + + async def test_run_pending_catalog_hydration_recovers_hydrating_series_after_restart( db_session, ) -> None: @@ -274,6 +287,150 @@ async def test_run_pending_catalog_hydration_chunks_provider_batches( assert service.catalog_batch_calls == [[6201, 6202], [6203]] +async def test_retryable_provider_failure_pauses_hydration_without_failing_backlog( + db_session, + monkeypatch, +) -> None: + series_rows = [ + Series( + title=f"Rate limited {comicvine_id}", + sort_title=f"rate limited {comicvine_id}", + comicvine_id=comicvine_id, + issue_catalog_state=IssueCatalogState.HYDRATING, + ) + for comicvine_id in (6301, 6302, 6303) + ] + db_session.add_all(series_rows) + await db_session.commit() + session_factory = async_sessionmaker( + db_session.bind, + class_=type(db_session), + expire_on_commit=False, + ) + service = RateLimitedBatchCatalogHydrationSeriesServiceStub() + monkeypatch.setattr(hydration_module, "COMICVINE_BULK_BATCH_SIZE", 2, raising=False) + + recovered = await run_pending_catalog_hydration( + session_factory, + series_service=service, + ) + + assert recovered == 0 + assert service.profile_batch_calls == [[6301, 6302]] + for series in series_rows: + await db_session.refresh(series) + assert series.issue_catalog_state == IssueCatalogState.HYDRATING + assert series_rows[0].issue_catalog_error is not None + assert "HTTP 420" in series_rows[0].issue_catalog_error + assert series_rows[0].issue_catalog_last_checked_at is not None + assert series_rows[1].issue_catalog_last_checked_at is not None + assert series_rows[2].issue_catalog_error is None + assert series_rows[2].issue_catalog_last_checked_at is None + + assert await hydration_module.load_pending_catalog_hydration(session_factory) == [] + + +async def test_scheduled_catalog_hydration_resumes_after_provider_cooldown( + db_session, + monkeypatch, +) -> None: + series = Series( + title="Cooldown Recovery", + sort_title="cooldown recovery", + comicvine_id=6350, + issue_catalog_state=IssueCatalogState.HYDRATING, + ) + db_session.add(series) + await db_session.commit() + session_factory = async_sessionmaker( + db_session.bind, + class_=type(db_session), + expire_on_commit=False, + ) + + class TransientRateLimitService(CatalogHydrationSeriesServiceStub): + async def prefetch_comicvine_bundle( + self, + comicvine_id: int, + ) -> tuple[dict[str, int], list[Any]]: + self.prefetch_calls.append(comicvine_id) + if len(self.prefetch_calls) == 1: + raise ProviderError( + "comicvine", + "HTTP 420: /volumes/", + details={"status_code": 420, "retryable": True}, + ) + return {"comicvine_id": comicvine_id}, [] + + service = TransientRateLimitService() + monkeypatch.setattr( + hydration_module, + "CATALOG_HYDRATION_RETRY_DELAY", + timedelta(milliseconds=25), + ) + + hydration_module.schedule_catalog_hydration( + session_factory, + series_service=service, + series_id=series.id, + search_on_add=False, + ) + + try: + async with asyncio.timeout(1): + while True: + await db_session.refresh(series) + if series.issue_catalog_state is IssueCatalogState.COMPLETE: + break + await asyncio.sleep(0.01) + while hydration_module.catalog_hydration_tasks: + active_tasks = list(hydration_module.catalog_hydration_tasks) + await asyncio.gather(*active_tasks) + await asyncio.sleep(0) + finally: + pending_tasks = [ + *(task for task in hydration_module.catalog_hydration_tasks if not task.done()), + *(task for task in hydration_module.catalog_hydration_retry_tasks if not task.done()), + ] + for task in pending_tasks: + task.cancel() + if pending_tasks: + await asyncio.gather(*pending_tasks, return_exceptions=True) + + assert service.prefetch_calls == [6350, 6350] + assert series.issue_catalog_error is None + + +async def test_old_rate_limited_failed_catalog_is_resumed_after_backoff(db_session) -> None: + series = Series( + title="Legacy Rate Limit Failure", + sort_title="legacy rate limit failure", + comicvine_id=6401, + issue_catalog_state=IssueCatalogState.FAILED, + issue_catalog_error="Provider 'comicvine' error: HTTP 420: /volumes/", + issue_catalog_last_checked_at=datetime(2026, 1, 1, tzinfo=UTC), + ) + db_session.add(series) + await db_session.commit() + session_factory = async_sessionmaker( + db_session.bind, + class_=type(db_session), + expire_on_commit=False, + ) + service = CatalogHydrationSeriesServiceStub() + + recovered = await run_pending_catalog_hydration( + session_factory, + series_service=service, + ) + + assert recovered == 1 + assert service.prefetch_calls == [6401] + await db_session.refresh(series) + assert series.issue_catalog_state == IssueCatalogState.COMPLETE + assert series.issue_catalog_error is None + + async def test_catalog_hydration_does_not_recreate_series_deleted_during_fetch( db_session, ) -> None: diff --git a/tests/unit/test_import_completed_cleanup.py b/tests/unit/test_import_completed_cleanup.py index e6c0c43..2e1d6ef 100644 --- a/tests/unit/test_import_completed_cleanup.py +++ b/tests/unit/test_import_completed_cleanup.py @@ -18,15 +18,24 @@ ImportSeriesStatus, ImportSourceType, ) -from pullbox.models.issue import Issue -from pullbox.models.library import FileFormat, LibraryFile, LibraryRoot, MatchConfidence +from pullbox.models.issue import Issue, IssueStatus +from pullbox.models.library import ( + FileFormat, + LibraryFile, + LibraryFileStorageMode, + LibraryRoot, + MatchConfidence, +) from pullbox.models.series import Series +from pullbox.models.story_arc import StoryArcResolutionState, StoryArcSourceKind +from pullbox.models.story_arc_import import ImportedStoryArc, ImportedStoryArcEntry from pullbox.models.user import User from pullbox.services.import_completed_cleanup import ( CompletedImportCleanupAction, apply_completed_import_cleanup, list_completed_import_cleanup_files, preview_completed_import_cleanup, + summarize_completed_import_cleanup_scope, ) from pullbox.services.import_safety_diagnostics import ( ImportSafetyCategory, @@ -413,6 +422,463 @@ async def test_known_library_issue_conflicts_are_normalized_without_reimport( assert conflict.include_in_import is False +async def _seed_mixed_folder_candidate( + session: AsyncSession, + *, + target_title: str = "Action Comics", + target_year: int = 1938, + source_signal: str = "comicinfo", + with_library_file: bool = False, +) -> tuple[ImportJob, ImportedSeries, ImportedSeries, ImportedFile, Issue]: + job, source_series = await _seed_job(session) + source_series.raw_series_name = "Fritzi Ritz" + source_series.cv_title = "Fritzi Ritz" + + root = LibraryRoot(name="Comics", path="/comics", enabled=True) + target = Series( + title=target_title, + sort_title=target_title.lower(), + year_start=target_year, + monitored=True, + ) + session.add_all([root, target]) + await session.flush() + issue = Issue( + series_id=target.id, + issue_number=1002, + issue_number_text="1002", + comicvine_id=7001002, + ) + session.add(issue) + await session.flush() + target_import_series = ImportedSeries( + import_job_id=job.id, + raw_series_name=target.title, + raw_year=target.year_start, + cv_title=target.title, + cv_year=target.year_start, + status=ImportSeriesStatus.IMPORTED, + selected_for_import=False, + series_id=target.id, + ) + session.add(target_import_series) + await session.flush() + + mixed_file = ImportedFile( + import_job_id=job.id, + import_series_id=source_series.id, + file_path="/comics/Fritzi Ritz (1953)/Action Comics 1002.cbz", + file_name="Action Comics 1002.cbz", + file_size=2048, + file_format="cbz", + parsed_series="Fritzi Ritz", + parsed_issue_number=1002, + issue_number_raw="1002", + has_comicinfo=source_signal == "comicinfo", + status=ImportedFileStatus.NO_MATCH, + diagnostics={ + "metadata_signals": { + "series_name": source_signal, + "issue_number": source_signal, + }, + "source_metadata": { + "comicinfo": { + "series": target.title, + "number": "1002", + "year": 2026, + } + }, + }, + error_message="No issue match found.", + ) + session.add(mixed_file) + if with_library_file: + session.add( + LibraryFile( + issue_id=issue.id, + library_root_id=root.id, + file_path="/comics/Action Comics (1938)/Action Comics 1002.cbz", + file_name="Action Comics 1002.cbz", + file_size=1024, + file_format=FileFormat.CBZ, + file_modified_at=datetime.now(UTC), + match_confidence=MatchConfidence.HIGH, + ) + ) + await session.commit() + return job, source_series, target_import_series, mixed_file, issue + + +async def _seed_story_arc_entry_for_mixed_file( + session: AsyncSession, + *, + job: ImportJob, + mixed_file: ImportedFile, +) -> ImportedStoryArcEntry: + arc = ImportedStoryArc( + import_job_id=job.id, + source_kind=StoryArcSourceKind.MYLAR3, + source_key=f"mixed-folder-{mixed_file.id}", + source_ordinal=1, + name="Recovered Arc", + ) + session.add(arc) + await session.flush() + entry = ImportedStoryArcEntry( + imported_story_arc_id=arc.id, + import_file_id=mixed_file.id, + source_ordinal=1, + resolution_state=StoryArcResolutionState.AMBIGUOUS, + source_kind=StoryArcSourceKind.MYLAR3, + ) + session.add(entry) + await session.flush() + return entry + + +@pytest.mark.asyncio +async def test_mixed_folder_comicinfo_reassigns_file_and_retries_correct_series( + db_session: AsyncSession, +) -> None: + ( + job, + source_series, + target_import_series, + mixed_file, + issue, + ) = await _seed_mixed_folder_candidate(db_session) + + preview = await preview_completed_import_cleanup( + db_session, + job.id, + CompletedImportCleanupAction.RESOLVE_MIXED_FOLDER_FILES, + actor_id=42, + ) + assert preview.affected_count == 1 + assert preview.examples == ("Action Comics 1002.cbz",) + + result = await apply_completed_import_cleanup( + db_session, + job.id, + CompletedImportCleanupAction.RESOLVE_MIXED_FOLDER_FILES, + actor_id=42, + preview_token=preview.preview_token, + ) + + await db_session.refresh(job) + await db_session.refresh(source_series) + await db_session.refresh(target_import_series) + await db_session.refresh(mixed_file) + assert result.requires_import_retry is True + assert job.status is ImportJobStatus.IMPORTING + assert mixed_file.import_series_id == target_import_series.id + assert mixed_file.matched_issue_id == issue.id + assert mixed_file.matched_issue_cv_id == issue.comicvine_id + assert mixed_file.status is ImportedFileStatus.CONFIRMED + assert mixed_file.include_in_import is True + assert mixed_file.match_confidence == "high" + assert mixed_file.match_method == "completed_import_metadata_reassignment" + assert mixed_file.file_path == "/comics/Fritzi Ritz (1953)/Action Comics 1002.cbz" + assert mixed_file.diagnostics["completed_import_cleanup"]["source_preserved"] is True + assert source_series.files_no_match == 0 + assert target_import_series.status is ImportSeriesStatus.CONFIRMED + assert target_import_series.selected_for_import is True + + +@pytest.mark.asyncio +async def test_mixed_folder_summary_returns_count_and_examples_together( + db_session: AsyncSession, +) -> None: + job, _source, _target, _mixed_file, _issue = await _seed_mixed_folder_candidate(db_session) + + summary = await summarize_completed_import_cleanup_scope( + db_session, + job.id, + CompletedImportCleanupAction.RESOLVE_MIXED_FOLDER_FILES, + ) + + assert summary.affected_count == 1 + assert summary.affected_file_count == 1 + assert summary.examples == ("Action Comics 1002.cbz",) + + +@pytest.mark.asyncio +async def test_mixed_folder_target_already_owned_is_resolved_without_retry( + db_session: AsyncSession, +) -> None: + ( + job, + _source_series, + target_import_series, + mixed_file, + issue, + ) = await _seed_mixed_folder_candidate(db_session, with_library_file=True) + story_arc_entry = await _seed_story_arc_entry_for_mixed_file( + db_session, + job=job, + mixed_file=mixed_file, + ) + await db_session.commit() + target_library_file = ( + await db_session.scalars(select(LibraryFile).where(LibraryFile.issue_id == issue.id)) + ).one() + preview = await preview_completed_import_cleanup( + db_session, + job.id, + CompletedImportCleanupAction.RESOLVE_MIXED_FOLDER_FILES, + actor_id=42, + ) + + result = await apply_completed_import_cleanup( + db_session, + job.id, + CompletedImportCleanupAction.RESOLVE_MIXED_FOLDER_FILES, + actor_id=42, + preview_token=preview.preview_token, + ) + + await db_session.refresh(job) + await db_session.refresh(mixed_file) + await db_session.refresh(story_arc_entry) + assert result.requires_import_retry is False + assert job.status is ImportJobStatus.COMPLETED + assert mixed_file.import_series_id == target_import_series.id + assert mixed_file.matched_issue_id == issue.id + assert mixed_file.status is ImportedFileStatus.ALREADY_OWNED + assert mixed_file.include_in_import is False + assert mixed_file.library_file_id == target_library_file.id + assert story_arc_entry.matched_issue_id == issue.id + assert story_arc_entry.resolution_state is StoryArcResolutionState.RESOLVED + + +@pytest.mark.asyncio +async def test_mixed_folder_cleanup_refuses_ambiguous_existing_target_ownership( + db_session: AsyncSession, +) -> None: + ( + job, + _source_series, + _target_import_series, + mixed_file, + issue, + ) = await _seed_mixed_folder_candidate(db_session, with_library_file=True) + existing = ( + await db_session.scalars(select(LibraryFile).where(LibraryFile.issue_id == issue.id)) + ).one() + db_session.add( + LibraryFile( + issue_id=issue.id, + library_root_id=existing.library_root_id, + file_path="/comics/Action Comics (1938)/Action Comics 1002 variant.cbz", + file_name="Action Comics 1002 variant.cbz", + file_size=2048, + file_format=FileFormat.CBZ, + file_modified_at=datetime.now(UTC), + match_confidence=MatchConfidence.HIGH, + ) + ) + await db_session.commit() + + with pytest.raises(ValidationError, match="No files are eligible"): + await preview_completed_import_cleanup( + db_session, + job.id, + CompletedImportCleanupAction.RESOLVE_MIXED_FOLDER_FILES, + actor_id=42, + ) + await db_session.refresh(mixed_file) + assert mixed_file.status is ImportedFileStatus.NO_MATCH + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("add_other_owner", "expected_previous_status"), + ((False, IssueStatus.WANTED), (True, IssueStatus.OWNED)), + ids=("sole-owner", "other-owner-remains"), +) +async def test_mixed_folder_cleanup_corrects_wrong_referenced_issue_ownership( + db_session: AsyncSession, + add_other_owner: bool, + expected_previous_status: IssueStatus, +) -> None: + job, source_import_series = await _seed_job(db_session) + root = LibraryRoot(name="Legacy Mylar", path="/comics", allow_managed_writes=False) + wrong_series = Series( + title="Fritzi Ritz", + sort_title="fritzi ritz", + year_start=1953, + monitored=True, + ) + target_series = Series( + title="Action Comics", + sort_title="action comics", + year_start=1938, + monitored=True, + ) + db_session.add_all([root, wrong_series, target_series]) + await db_session.flush() + source_import_series.series_id = wrong_series.id + wrong_issue = Issue( + series_id=wrong_series.id, + issue_number=1002, + issue_number_text="1002", + status=IssueStatus.OWNED, + ) + target_issue = Issue( + series_id=target_series.id, + issue_number=1002, + issue_number_text="1002", + status=IssueStatus.WANTED, + ) + db_session.add_all([wrong_issue, target_issue]) + await db_session.flush() + target_import_series = ImportedSeries( + import_job_id=job.id, + raw_series_name=target_series.title, + status=ImportSeriesStatus.IMPORTED, + series_id=target_series.id, + ) + library_file = LibraryFile( + issue_id=wrong_issue.id, + library_root_id=root.id, + file_path="/comics/Fritzi Ritz/Action Comics 1002.cbz", + file_name="Action Comics 1002.cbz", + file_size=2048, + file_format=FileFormat.CBZ, + file_modified_at=datetime.now(UTC), + match_confidence=MatchConfidence.HIGH, + storage_mode=LibraryFileStorageMode.REFERENCED, + ) + db_session.add_all([target_import_series, library_file]) + await db_session.flush() + if add_other_owner: + db_session.add( + LibraryFile( + issue_id=wrong_issue.id, + library_root_id=root.id, + file_path="/comics/Fritzi Ritz/Fritzi Ritz 1002.cbz", + file_name="Fritzi Ritz 1002.cbz", + file_size=1024, + file_format=FileFormat.CBZ, + file_modified_at=datetime.now(UTC), + match_confidence=MatchConfidence.HIGH, + storage_mode=LibraryFileStorageMode.REFERENCED, + ) + ) + await db_session.flush() + mixed_file = ImportedFile( + import_job_id=job.id, + import_series_id=source_import_series.id, + file_path=library_file.file_path, + file_name=library_file.file_name, + file_size=library_file.file_size, + file_format="cbz", + status=ImportedFileStatus.IMPORTED, + matched_issue_id=wrong_issue.id, + library_file_id=library_file.id, + diagnostics={ + "metadata_signals": { + "series_name": "comicinfo", + "issue_number": "comicinfo", + }, + "source_metadata": {"comicinfo": {"series": target_series.title, "number": "1002"}}, + }, + ) + db_session.add(mixed_file) + await db_session.flush() + story_arc_entry = await _seed_story_arc_entry_for_mixed_file( + db_session, + job=job, + mixed_file=mixed_file, + ) + await db_session.commit() + + preview = await preview_completed_import_cleanup( + db_session, + job.id, + CompletedImportCleanupAction.RESOLVE_MIXED_FOLDER_FILES, + actor_id=42, + ) + result = await apply_completed_import_cleanup( + db_session, + job.id, + CompletedImportCleanupAction.RESOLVE_MIXED_FOLDER_FILES, + actor_id=42, + preview_token=preview.preview_token, + ) + + await db_session.refresh(mixed_file) + await db_session.refresh(library_file) + await db_session.refresh(wrong_issue) + await db_session.refresh(target_issue) + await db_session.refresh(story_arc_entry) + assert result.requires_import_retry is False + assert mixed_file.import_series_id == target_import_series.id + assert mixed_file.matched_issue_id == target_issue.id + assert mixed_file.library_file_id == library_file.id + assert mixed_file.status is ImportedFileStatus.IMPORTED + assert library_file.issue_id == target_issue.id + assert target_issue.status is IssueStatus.OWNED + assert wrong_issue.status is expected_previous_status + assert mixed_file.file_path == "/comics/Fritzi Ritz/Action Comics 1002.cbz" + assert story_arc_entry.matched_issue_id == target_issue.id + assert story_arc_entry.resolution_state is StoryArcResolutionState.RESOLVED + + +@pytest.mark.asyncio +async def test_mixed_folder_cleanup_leaves_ambiguous_series_and_files_untouched( + db_session: AsyncSession, +) -> None: + ( + job, + _source_series, + _target_import_series, + mixed_file, + _issue, + ) = await _seed_mixed_folder_candidate(db_session) + duplicate_title = Series( + title="Action Comics", + sort_title="action comics", + year_start=2011, + monitored=True, + ) + db_session.add(duplicate_title) + await db_session.commit() + + with pytest.raises(ValidationError, match="No files are eligible"): + await preview_completed_import_cleanup( + db_session, + job.id, + CompletedImportCleanupAction.RESOLVE_MIXED_FOLDER_FILES, + actor_id=42, + ) + await db_session.refresh(mixed_file) + assert mixed_file.status is ImportedFileStatus.NO_MATCH + + +@pytest.mark.asyncio +async def test_mixed_folder_cleanup_does_not_trust_filename_only_identity( + db_session: AsyncSession, +) -> None: + ( + job, + _source_series, + _target_import_series, + mixed_file, + _issue, + ) = await _seed_mixed_folder_candidate(db_session, source_signal="release_title") + + with pytest.raises(ValidationError, match="No files are eligible"): + await preview_completed_import_cleanup( + db_session, + job.id, + CompletedImportCleanupAction.RESOLVE_MIXED_FOLDER_FILES, + actor_id=42, + ) + await db_session.refresh(mixed_file) + assert mixed_file.status is ImportedFileStatus.NO_MATCH + + @pytest.mark.asyncio async def test_cleanup_rejects_non_completed_job(db_session: AsyncSession) -> None: job, _imported_series = await _seed_job(db_session) @@ -499,6 +965,55 @@ async def test_retry_source_inspection_prepares_existing_retry_pipeline( assert result.retry_file_ids == (blocked.id,) +@pytest.mark.asyncio +async def test_retry_source_inspection_includes_failed_completed_rechecks( + db_session: AsyncSession, +) -> None: + job, imported_series = await _seed_job(db_session) + failed = ImportedFile( + import_job_id=job.id, + import_series_id=imported_series.id, + file_path="/comics/recheck-failed.cbr", + file_name="recheck-failed.cbr", + file_size=1024, + file_format="cbr", + status=ImportedFileStatus.FAILED, + diagnostics={ + "source_revalidation": { + "kind": "source_revalidation", + "category": ImportSafetyCategory.ARCHIVE_INSPECTION_FAILED.value, + "code": ImportSafetyCategory.ARCHIVE_INSPECTION_FAILED.value, + "reason": "Pullbox could not inspect this archive.", + "retryable": True, + "overrideable": False, + } + }, + error_message="Pullbox could not inspect this archive.", + ) + db_session.add(failed) + await db_session.commit() + + preview = await preview_completed_import_cleanup( + db_session, + job.id, + CompletedImportCleanupAction.RETRY_SOURCE_INSPECTION, + actor_id=42, + ) + result = await apply_completed_import_cleanup( + db_session, + job.id, + CompletedImportCleanupAction.RETRY_SOURCE_INSPECTION, + actor_id=42, + preview_token=preview.preview_token, + ) + + assert preview.affected_file_count == 1 + assert result.retry_file_ids == (failed.id,) + await db_session.refresh(failed) + assert failed.status is ImportedFileStatus.FAILED + assert failed.diagnostics["source_revalidation"]["retryable"] is True + + @pytest.mark.asyncio async def test_cleanup_preview_cannot_be_reused_after_scope_changes( db_session: AsyncSession, diff --git a/tests/unit/test_import_duplicates.py b/tests/unit/test_import_duplicates.py index 91c8f69..762727e 100644 --- a/tests/unit/test_import_duplicates.py +++ b/tests/unit/test_import_duplicates.py @@ -170,6 +170,28 @@ def test_file_sort_and_duplicate_target_keys_prefer_existing_contracts() -> None assert duplicate_target_key(parsed_only) == ("parsed_issue", 4.0) +def test_file_sort_prefers_exact_mylar_cross_folder_canonical() -> None: + canonical = ImportedFile( + id=20, + has_comicinfo=True, + match_confidence="medium", + file_size=1024, + diagnostics={ + "mylar3_cross_folder_reconciliation": { + "role": "canonical", + } + }, + ) + ordinary = ImportedFile( + id=10, + has_comicinfo=True, + match_confidence="high", + file_size=8192, + ) + + assert max([ordinary, canonical], key=preferred_file_sort_key) is canonical + + def test_import_service_duplicate_shims_remain_available() -> None: item = ImportedSeries(status=ImportSeriesStatus.DUPLICATE, series_id=123) diff --git a/tests/unit/test_import_file_execution.py b/tests/unit/test_import_file_execution.py index 9777428..6ff3386 100644 --- a/tests/unit/test_import_file_execution.py +++ b/tests/unit/test_import_file_execution.py @@ -9,6 +9,7 @@ import os import zipfile +from datetime import UTC, datetime from pathlib import Path from tempfile import mkdtemp from types import SimpleNamespace @@ -23,6 +24,7 @@ build_file_identity_signature, build_managed_placement_signature, ) +from pullbox.models.config import SystemConfig from pullbox.models.import_job import ( ImportedFile, ImportedFileStatus, @@ -37,6 +39,7 @@ ) from pullbox.models.issue import Issue, IssueStatus, IssueType from pullbox.models.library import ( + FileFormat, LibraryFile, LibraryFileStorageMode, LibraryRoot, @@ -2807,6 +2810,163 @@ async def test_move_to_library_true(self, db_session: AsyncSession) -> None: call_kwargs = mock_register.call_args_list[0].kwargs assert call_kwargs.get("move_to_library") is True + @pytest.mark.asyncio + async def test_clean_library_adoption_replaces_reference_and_bypasses_skip_existing( + self, + db_session: AsyncSession, + tmp_path: Path, + ) -> None: + job, _imp_series, imp_files, series, issues = await _setup_full_scenario( + db_session, + num_issues=1, + ) + source_file = Path(imp_files[0].file_path) + source_root = LibraryRoot( + name="Legacy Mylar", + path=str(source_file.parent), + enabled=True, + allow_managed_writes=False, + ) + db_session.add(source_root) + await db_session.flush() + series.path = str(source_file.parent) + series.library_root_id = source_root.id + series.preferred_library_root_id = source_root.id + referenced_file = LibraryFile( + file_path=str(source_file), + file_name=source_file.name, + file_size=source_file.stat().st_size, + file_format=FileFormat.CBZ, + file_modified_at=datetime.now(UTC), + match_confidence=MatchConfidence.HIGH, + issue_id=issues[0].id, + library_root_id=source_root.id, + storage_mode=LibraryFileStorageMode.REFERENCED, + source_signature=build_file_identity_signature(source_file), + ) + db_session.add_all( + [ + referenced_file, + SystemConfig(key="skip_existing_files", value="true", value_type="bool"), + ] + ) + await db_session.flush() + source_job = ImportJob( + source_path=str(source_file.parent), + source_type=ImportSourceType.MYLAR3, + status=ImportJobStatus.COMPLETED, + file_handling_mode=ImportFileHandlingMode.IN_PLACE, + ) + db_session.add(source_job) + await db_session.flush() + source_imported_series = ImportedSeries( + import_job_id=source_job.id, + raw_series_name=series.title, + raw_year=series.year_start, + status=ImportSeriesStatus.IMPORTED, + series_id=series.id, + file_count=1, + ) + db_session.add(source_imported_series) + await db_session.flush() + source_imported_file = ImportedFile( + import_job_id=source_job.id, + import_series_id=source_imported_series.id, + file_path=str(source_file), + file_name=source_file.name, + file_size=source_file.stat().st_size, + file_format="cbz", + parsed_series=series.title, + parsed_issue_number=issues[0].issue_number, + status=ImportedFileStatus.IMPORTED, + matched_issue_id=issues[0].id, + match_confidence="high", + match_method="mylar_reference", + source_signature=dict(referenced_file.source_signature), + library_file_id=referenced_file.id, + ) + db_session.add(source_imported_file) + await db_session.flush() + imp_files[0].diagnostics = { + "library_adoption": { + "schema_version": 1, + "source_import_job_id": source_job.id, + "source_imported_file_id": source_imported_file.id, + "source_library_file_id": referenced_file.id, + "source_path": referenced_file.file_path, + "source_library_root_id": source_root.id, + "source_signature": dict(referenced_file.source_signature), + "source_storage_mode": "referenced", + "source_preserved": True, + } + } + job.file_handling_mode = ImportFileHandlingMode.MANAGED_COPY + job.effective_transfer_method = "copy" + job.source_preserved = True + job.progress_snapshot = { + "clean_library_adoption": True, + "source_import_job_id": source_job.id, + } + await db_session.flush() + + target_root = await db_session.get(LibraryRoot, job.target_library_root_id) + assert target_root is not None + target_path = Path(target_root.path) / "Action Comics 1002.cbz" + mock_register = AsyncMock() + + async def _adopt_reference( + _session: AsyncSession, + _source_path: Path, + _issue: Issue, + _confidence: MatchConfidence, + **_kwargs: object, + ) -> LibraryFile: + target_path.write_bytes(source_file.read_bytes()) + referenced_file.file_path = str(target_path) + referenced_file.file_name = target_path.name + referenced_file.library_root_id = target_root.id + referenced_file.storage_mode = LibraryFileStorageMode.MANAGED + return referenced_file + + mock_register.side_effect = _adopt_reference + mock_ss = AsyncMock() + mock_ss.add_from_comicvine.return_value = series + svc = _make_service(series_service=mock_ss) + + with patch( + "pullbox.services.import_service.register_library_file", + mock_register, + ): + await svc.run_import(db_session, job.id) + + assert mock_register.call_count == 1 + call_kwargs = mock_register.call_args.kwargs + assert call_kwargs["replace_existing_library_file"] is True + assert call_kwargs["replacement_trash_dir"] is None + assert call_kwargs["preserve_replaced_artifact"] is True + assert source_file.exists() + assert series.path == str(target_path.parent) + assert series.library_root_id == target_root.id + assert series.preferred_library_root_id == target_root.id + await db_session.refresh(imp_files[0]) + assert imp_files[0].status is ImportedFileStatus.IMPORTED + action = await db_session.scalar( + select(ImportJobAction).where( + ImportJobAction.import_job_id == job.id, + ImportJobAction.action_type == "library_file_registered", + ) + ) + assert action is not None + adoption_snapshot = dict(action.payload or {}).get("adopted_reference") + assert isinstance(adoption_snapshot, dict) + assert adoption_snapshot["source_imported_file_id"] == source_imported_file.id + assert adoption_snapshot["source_library_file_id"] == referenced_file.id + assert adoption_snapshot["file_path"] == str(source_file) + assert adoption_snapshot["previous_series_path"] == str(source_file.parent) + assert adoption_snapshot["previous_series_library_root_id"] == source_root.id + assert adoption_snapshot["installed_series_path"] == str(target_path.parent) + assert adoption_snapshot["installed_series_library_root_id"] == target_root.id + @pytest.mark.asyncio async def test_mylar_managed_copy_uses_series_source_folder_as_strict_boundary( self, diff --git a/tests/unit/test_import_file_execution_protocols.py b/tests/unit/test_import_file_execution_protocols.py new file mode 100644 index 0000000..5a5ea98 --- /dev/null +++ b/tests/unit/test_import_file_execution_protocols.py @@ -0,0 +1,25 @@ +"""Runtime visibility checks for import file-execution collaborator protocols.""" + +from pullbox.services import import_file_execution_protocols as protocols + + +def test_import_file_execution_protocols_remain_named_contracts() -> None: + expected = { + "ApplyComicInfoFunc", + "BuildComicInfoPayloadFunc", + "CleanupPreparedFileFunc", + "LoadIngestPolicyFunc", + "LoadMediaSettingsFunc", + "LoadPermissionPolicyFunc", + "LoadTrashDirFunc", + "LogEventFunc", + "MoveToTrashFunc", + "PrepareImportFileFunc", + "RaiseIfCancelledFunc", + "RecordActionFunc", + "RegisterLibraryFileFunc", + "ReportFileProgressFunc", + } + + assert expected <= set(vars(protocols)) + assert all(getattr(protocols, name)._is_protocol for name in expected) diff --git a/tests/unit/test_import_file_match_outcomes.py b/tests/unit/test_import_file_match_outcomes.py index 7a4885a..595b6d6 100644 --- a/tests/unit/test_import_file_match_outcomes.py +++ b/tests/unit/test_import_file_match_outcomes.py @@ -69,6 +69,34 @@ def test_apply_matched_file_outcome_marks_new_series_file_matched() -> None: assert event.data["confidence"] == "medium" +def test_apply_matched_file_outcome_preserves_cross_folder_cleanup_evidence() -> None: + evidence = { + "recorded_path": "/comics/Absolute Batman/Absolute Batman 001.cbz", + "actual_path": "/comics/Crossed/Absolute Batman 001.cbz", + "comicvine_issue_id": 1073108, + "comicvine_series_id": 160294, + "method": "verified_cross_folder_issue_identity", + "role": "canonical", + "source_series": "Crossed Badlands", + } + imp_file = ImportedFile( + file_name="Absolute Batman 001.cbz", + parsed_issue_number=1.0, + diagnostics={"mylar3_cross_folder_reconciliation": evidence}, + ) + imp_series = ImportedSeries(raw_series_name="Absolute Batman") + + apply_matched_file_outcome( + imp_file, + imp_series, + _candidate(None, confidence="high", method="comicvine_id"), + duplicate_series=False, + duplicate_target_state=duplicate_target_state, + ) + + assert imp_file.diagnostics["mylar3_cross_folder_reconciliation"] == evidence + + def test_apply_matched_file_outcome_preserves_exact_suffix_issue_number() -> None: imp_file = ImportedFile( file_name="The Amazing Spider-Man 54.LR.cbz", diff --git a/tests/unit/test_import_history_context.py b/tests/unit/test_import_history_context.py index 62b02c7..2ecf586 100644 --- a/tests/unit/test_import_history_context.py +++ b/tests/unit/test_import_history_context.py @@ -7,12 +7,23 @@ import pytest from pullbox.models.import_job import ( + ImportedFile, + ImportedFileStatus, ImportedSeries, ImportJob, ImportJobStatus, ImportSeriesStatus, ImportSourceType, ) +from pullbox.models.issue import Issue, IssueStatus +from pullbox.models.library import ( + FileFormat, + LibraryFile, + LibraryFileStorageMode, + LibraryRoot, + MatchConfidence, +) +from pullbox.models.series import Series from pullbox.ui.import_history import ( _history_resume_step_for_job, _load_import_history_context, @@ -201,3 +212,73 @@ async def test_import_history_context_filters_and_reports_live_summary(db_sessio "/imports/needle-completed", "/imports/needle-active", ] + + +@pytest.mark.asyncio +async def test_import_history_only_offers_clean_library_for_completed_imports( + db_session, + tmp_path, +) -> None: # type: ignore[no-untyped-def] + root = LibraryRoot(name="References", path=str(tmp_path)) + db_session.add(root) + await db_session.flush() + clean_library_job_ids: dict[ImportJobStatus, int] = {} + + for index, status in enumerate((ImportJobStatus.COMPLETED, ImportJobStatus.FAILED), start=1): + job = ImportJob( + source_path=f"/imports/reference-{index}", + source_type=ImportSourceType.FILESYSTEM, + status=status, + ) + series = Series(title=f"Series {index}", sort_title=f"series {index}") + db_session.add_all([job, series]) + await db_session.flush() + issue = Issue( + series_id=series.id, + issue_number=1.0, + issue_number_text="1", + status=IssueStatus.OWNED, + ) + imported_series = ImportedSeries( + import_job_id=job.id, + raw_series_name=series.title, + status=ImportSeriesStatus.IMPORTED, + series_id=series.id, + file_count=1, + ) + db_session.add_all([issue, imported_series]) + await db_session.flush() + file_path = f"/imports/reference-{index}/issue.cbz" + library_file = LibraryFile( + file_path=file_path, + file_name="issue.cbz", + file_size=1024, + file_format=FileFormat.CBZ, + file_modified_at=datetime.now(UTC), + match_confidence=MatchConfidence.HIGH, + issue_id=issue.id, + library_root_id=root.id, + storage_mode=LibraryFileStorageMode.REFERENCED, + ) + db_session.add(library_file) + await db_session.flush() + db_session.add( + ImportedFile( + import_job_id=job.id, + import_series_id=imported_series.id, + file_path=file_path, + file_name="issue.cbz", + file_size=1024, + file_format="cbz", + status=ImportedFileStatus.IMPORTED, + matched_issue_id=issue.id, + library_file_id=library_file.id, + ) + ) + clean_library_job_ids[status] = job.id + + await db_session.commit() + + context = await _load_import_history_context(db_session) + + assert context["clean_library_job_ids"] == {clean_library_job_ids[ImportJobStatus.COMPLETED]} diff --git a/tests/unit/test_import_job_actions.py b/tests/unit/test_import_job_actions.py index 2cbd39b..9bd1302 100644 --- a/tests/unit/test_import_job_actions.py +++ b/tests/unit/test_import_job_actions.py @@ -13,7 +13,10 @@ from pullbox.core.acquisition import AcquisitionProtocol from pullbox.core.exceptions import NotFoundError -from pullbox.core.library_file_ownership import build_managed_placement_signature +from pullbox.core.library_file_ownership import ( + build_file_identity_signature, + build_managed_placement_signature, +) from pullbox.models.download import DownloadClientType, DownloadHistory, DownloadState from pullbox.models.import_job import ( ImportedFile, @@ -27,7 +30,14 @@ ImportSourceType, ) from pullbox.models.issue import Issue, IssueStatus -from pullbox.models.library import FileFormat, LibraryFile, LibraryRoot, MatchConfidence +from pullbox.models.library import ( + FileFormat, + LibraryFile, + LibraryFileStorageMode, + LibraryRoot, + MatchConfidence, +) +from pullbox.models.matching_suggestion import MatchingSuggestion from pullbox.models.series import Series from pullbox.services.import_job_actions import ( _series_issue_rollback_lock_statement, @@ -185,6 +195,161 @@ async def test_rollback_action_removes_copied_library_file_and_journal_row( assert action.rolled_back_at is not None +async def test_rollback_clean_library_adoption_restores_original_reference( + db_session: AsyncSession, + tmp_path: Path, +) -> None: + service = _make_service() + source_job = await _create_job_row(db_session) + source_job.status = ImportJobStatus.COMPLETED + adoption_job = await _create_job_row(db_session) + source_root = LibraryRoot( + name="Legacy Mylar", + path=str(tmp_path / "mylar"), + allow_managed_writes=False, + ) + managed_root = LibraryRoot(name="Clean Library", path=str(tmp_path / "clean")) + db_session.add_all([source_root, managed_root]) + await db_session.flush() + source_path = tmp_path / "mylar" / "Batman" / "Batman 001.cbr" + source_path.parent.mkdir(parents=True) + source_path.write_bytes(b"legacy source") + destination_path = tmp_path / "clean" / "Batman (2016)" / "Batman 001.cbz" + destination_path.parent.mkdir(parents=True) + destination_path.write_bytes(b"managed copy") + series = Series( + title="Batman", + sort_title="batman", + year_start=2016, + path=str(destination_path.parent), + library_root_id=managed_root.id, + preferred_library_root_id=managed_root.id, + ) + db_session.add(series) + await db_session.flush() + issue = Issue( + series_id=series.id, + issue_number=1.0, + issue_number_text="1", + status=IssueStatus.OWNED, + ) + db_session.add(issue) + await db_session.flush() + managed_file = LibraryFile( + file_path=str(destination_path), + file_name=destination_path.name, + file_size=destination_path.stat().st_size, + file_format=FileFormat.CBZ, + file_modified_at=datetime.now(UTC), + match_confidence=MatchConfidence.HIGH, + issue_id=issue.id, + library_root_id=managed_root.id, + storage_mode=LibraryFileStorageMode.MANAGED, + source_signature=build_managed_placement_signature(destination_path), + ) + db_session.add(managed_file) + await db_session.flush() + source_imported_series = ImportedSeries( + import_job_id=source_job.id, + raw_series_name=series.title, + raw_year=series.year_start, + status=ImportSeriesStatus.IMPORTED, + series_id=series.id, + file_count=1, + ) + db_session.add(source_imported_series) + await db_session.flush() + source_imported_file = ImportedFile( + import_job_id=source_job.id, + import_series_id=source_imported_series.id, + file_path=str(source_path), + file_name=source_path.name, + file_size=source_path.stat().st_size, + file_format="cbr", + status=ImportedFileStatus.IMPORTED, + matched_issue_id=issue.id, + match_confidence="high", + source_signature=build_file_identity_signature(source_path), + library_file_id=managed_file.id, + ) + db_session.add(source_imported_file) + await db_session.flush() + original_library_file_id = managed_file.id + suggestion = MatchingSuggestion( + library_file_id=managed_file.id, + parent_series_id=series.id, + suggested_title="Batman Annual", + ) + db_session.add(suggestion) + await db_session.flush() + action = ImportJobAction( + import_job_id=adoption_job.id, + sequence_no=1, + phase="import", + action_type="library_file_registered", + status=ImportJobActionStatus.COMPLETED, + payload={ + "library_file_id": managed_file.id, + "destination_path": str(destination_path), + "destination_signature": dict(managed_file.source_signature), + "original_source_path": str(source_path), + "transfer_method": "copy", + "storage_mode": "managed", + "adopted_reference": { + "schema_version": 1, + "source_imported_file_id": source_imported_file.id, + "source_library_file_id": original_library_file_id, + "file_path": str(source_path), + "file_name": source_path.name, + "file_size": source_path.stat().st_size, + "file_format": "cbr", + "file_hash": None, + "file_modified_at": datetime.now(UTC).isoformat(), + "match_confidence": "high", + "parsed_series": "Batman", + "parsed_issue_number": 1.0, + "parsed_year": 2016, + "parsed_publisher": "DC Comics", + "has_comicinfo": True, + "naming_snapshot": {}, + "storage_mode": "referenced", + "source_signature": dict(source_imported_file.source_signature), + "issue_id": issue.id, + "library_root_id": source_root.id, + "source_series_id": series.id, + "previous_series_path": str(source_path.parent), + "previous_series_library_root_id": source_root.id, + "previous_series_preferred_library_root_id": source_root.id, + "installed_series_path": str(destination_path.parent), + "installed_series_library_root_id": managed_root.id, + "installed_series_preferred_library_root_id": managed_root.id, + }, + }, + ) + db_session.add(action) + await db_session.flush() + + await service._rollback_action(db_session, _rollback_plan(action)) + + restored = await db_session.get(LibraryFile, original_library_file_id) + await db_session.refresh(source_imported_file) + assert restored is not None + assert restored is managed_file + assert restored.file_path == str(source_path) + assert restored.storage_mode is LibraryFileStorageMode.REFERENCED + assert restored.issue_id == issue.id + assert source_imported_file.library_file_id == restored.id + preserved_suggestion = await db_session.get(MatchingSuggestion, suggestion.id) + assert preserved_suggestion is not None + assert preserved_suggestion.library_file_id == restored.id + assert series.path == str(source_path.parent) + assert series.library_root_id == source_root.id + assert series.preferred_library_root_id == source_root.id + assert source_path.read_bytes() == b"legacy source" + assert not destination_path.exists() + assert action.status is ImportJobActionStatus.ROLLED_BACK + + async def test_rollback_action_preserves_managed_file_changed_after_registration( db_session: AsyncSession, tmp_path: Path, diff --git a/tests/unit/test_import_library_adoption.py b/tests/unit/test_import_library_adoption.py new file mode 100644 index 0000000..cd13828 --- /dev/null +++ b/tests/unit/test_import_library_adoption.py @@ -0,0 +1,297 @@ +"""Completed-import clean-library adoption contracts.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import TYPE_CHECKING + +import pytest +from sqlalchemy import select + +from pullbox.core.exceptions import ValidationError +from pullbox.models.import_job import ( + ImportedFile, + ImportedFileStatus, + ImportedSeries, + ImportFileHandlingMode, + ImportJob, + ImportJobStatus, + ImportSeriesStatus, + ImportSourceType, +) +from pullbox.models.issue import Issue +from pullbox.models.library import ( + FileFormat, + LibraryFile, + LibraryFileStorageMode, + LibraryRoot, + MatchConfidence, +) +from pullbox.models.series import Series +from pullbox.models.user import User +from pullbox.services.import_library_adoption import ( + create_clean_library_import, + prepare_clean_library_import, + preview_clean_library_import, +) + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + +async def _seed_referenced_import( + session: AsyncSession, +) -> tuple[ImportJob, ImportedFile, LibraryFile, LibraryRoot, LibraryRoot]: + session.add(User(id=73, username="library-organizer", password_hash="unused")) + source_root = LibraryRoot( + name="Mylar library", + path="/legacy-comics", + enabled=True, + allow_referenced_registrations=True, + allow_managed_writes=True, + ) + target_root = LibraryRoot( + name="Clean Pullbox library", + path="/pullbox-comics", + enabled=True, + allow_referenced_registrations=True, + allow_managed_writes=True, + ) + series = Series( + comicvine_id=123, + title="Action Comics", + sort_title="action comics", + year_start=1938, + monitored=True, + library_root=source_root, + ) + session.add_all([source_root, target_root, series]) + await session.flush() + issue = Issue( + series_id=series.id, + comicvine_id=456, + issue_number=1002, + issue_number_text="1002", + ) + session.add(issue) + await session.flush() + library_file = LibraryFile( + issue_id=issue.id, + library_root_id=source_root.id, + file_path="/legacy-comics/Fritzi Ritz/Action Comics 1002.cbr", + file_name="Action Comics 1002.cbr", + file_size=4096, + file_format=FileFormat.CBR, + file_modified_at=datetime.now(UTC), + match_confidence=MatchConfidence.HIGH, + storage_mode=LibraryFileStorageMode.REFERENCED, + source_signature={"size": 4096, "mtime_ns": 10}, + ) + job = ImportJob( + source_path="/imports/mylar.db", + source_type=ImportSourceType.MYLAR3, + status=ImportJobStatus.COMPLETED, + file_handling_mode=ImportFileHandlingMode.IN_PLACE, + source_preserved=True, + ) + imported_series = ImportedSeries( + import_job=job, + raw_series_name="Action Comics", + cv_id=123, + cv_title="Action Comics", + cv_year=1938, + status=ImportSeriesStatus.IMPORTED, + series_id=series.id, + ) + session.add_all([library_file, job, imported_series]) + await session.flush() + imported_file = ImportedFile( + import_job=job, + import_series=imported_series, + file_path=library_file.file_path, + file_name=library_file.file_name, + file_size=library_file.file_size, + file_format="cbr", + parsed_series="Action Comics", + parsed_issue_number=1002, + issue_number_raw="1002", + status=ImportedFileStatus.IMPORTED, + matched_issue_id=issue.id, + matched_issue_cv_id=issue.comicvine_id, + match_confidence="high", + match_method="comicinfo", + include_in_import=False, + library_file_id=library_file.id, + source_signature=dict(library_file.source_signature), + ) + session.add(imported_file) + await session.commit() + return job, imported_file, library_file, source_root, target_root + + +@pytest.mark.asyncio +async def test_preview_create_and_prepare_clean_library_import_clone_referenced_ownership( + db_session: AsyncSession, +) -> None: + ( + source_job, + _source_file, + library_file, + _source_root, + target_root, + ) = await _seed_referenced_import(db_session) + + preview = await preview_clean_library_import( + db_session, + source_job.id, + target_root_id=target_root.id, + actor_id=73, + ) + + assert preview.eligible_file_count == 1 + assert preview.eligible_series_count == 1 + assert preview.total_bytes == 4096 + assert preview.source_preserved is True + + result = await create_clean_library_import( + db_session, + source_job.id, + target_root_id=target_root.id, + actor_id=73, + preview_token=preview.preview_token, + ) + + clean_job = await db_session.get(ImportJob, result.job_id) + assert clean_job is not None + assert clean_job.status is ImportJobStatus.IMPORTING + assert clean_job.file_handling_mode is ImportFileHandlingMode.MANAGED_COPY + assert clean_job.effective_transfer_method == "copy" + assert clean_job.source_preserved is True + assert clean_job.target_library_root_id == target_root.id + assert clean_job.progress_snapshot["source_import_job_id"] == source_job.id + assert clean_job.progress_snapshot["clean_library_adoption_prepared"] is False + + cloned_files_before_prepare = list( + ( + await db_session.scalars( + select(ImportedFile).where(ImportedFile.import_job_id == clean_job.id) + ) + ).all() + ) + assert cloned_files_before_prepare == [] + + await prepare_clean_library_import(db_session, clean_job.id) + await db_session.refresh(clean_job) + assert clean_job.progress_snapshot["clean_library_adoption_prepared"] is True + + cloned_file = ( + await db_session.scalars( + select(ImportedFile).where(ImportedFile.import_job_id == clean_job.id) + ) + ).one() + assert cloned_file.status is ImportedFileStatus.CONFIRMED + assert cloned_file.include_in_import is True + assert cloned_file.library_file_id is None + adoption = cloned_file.diagnostics["library_adoption"] + assert adoption["source_library_file_id"] == library_file.id + assert adoption["source_path"] == library_file.file_path + assert adoption["source_preserved"] is True + + +@pytest.mark.asyncio +async def test_clean_library_import_requires_a_separate_managed_root( + db_session: AsyncSession, +) -> None: + ( + source_job, + _source_file, + _library_file, + source_root, + _target_root, + ) = await _seed_referenced_import(db_session) + + with pytest.raises(ValidationError, match="separate managed library root"): + await preview_clean_library_import( + db_session, + source_job.id, + target_root_id=source_root.id, + actor_id=73, + ) + + +@pytest.mark.asyncio +async def test_clean_library_import_rejects_another_active_import( + db_session: AsyncSession, +) -> None: + ( + source_job, + _source_file, + _library_file, + _source_root, + target_root, + ) = await _seed_referenced_import(db_session) + preview = await preview_clean_library_import( + db_session, + source_job.id, + target_root_id=target_root.id, + actor_id=73, + ) + db_session.add( + ImportJob( + source_path="/imports/already-running", + source_type=ImportSourceType.FILESYSTEM, + status=ImportJobStatus.SCANNING, + ) + ) + await db_session.commit() + + with pytest.raises(ValidationError, match="Only one import can be active at a time"): + await create_clean_library_import( + db_session, + source_job.id, + target_root_id=target_root.id, + actor_id=73, + preview_token=preview.preview_token, + ) + + +@pytest.mark.asyncio +async def test_clean_library_import_requires_exact_mixed_folder_repairs_first( + db_session: AsyncSession, +) -> None: + ( + source_job, + source_file, + _library_file, + _source_root, + target_root, + ) = await _seed_referenced_import(db_session) + imported_series = await db_session.get(ImportedSeries, source_file.import_series_id) + assert imported_series is not None + wrong_series = Series( + title="Fritzi Ritz", + sort_title="fritzi ritz", + year_start=1953, + monitored=True, + ) + db_session.add(wrong_series) + await db_session.flush() + imported_series.raw_series_name = wrong_series.title + imported_series.cv_title = wrong_series.title + imported_series.series_id = wrong_series.id + source_file.diagnostics = { + "metadata_signals": { + "series_name": "comicinfo", + "issue_number": "comicinfo", + }, + "source_metadata": {"comicinfo": {"series": "Action Comics", "number": "1002"}}, + } + await db_session.commit() + + with pytest.raises(ValidationError, match="Resolve mixed-folder files"): + await preview_clean_library_import( + db_session, + source_job.id, + target_root_id=target_root.id, + actor_id=73, + ) diff --git a/tests/unit/test_import_misplaced_source_cleanup.py b/tests/unit/test_import_misplaced_source_cleanup.py new file mode 100644 index 0000000..66e2026 --- /dev/null +++ b/tests/unit/test_import_misplaced_source_cleanup.py @@ -0,0 +1,689 @@ +"""Guardrails for physically cleaning up verified misplaced Mylar files.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from hashlib import sha256 +from typing import TYPE_CHECKING + +import pytest + +from pullbox.core.exceptions import ValidationError +from pullbox.core.library_file_ownership import build_file_identity_signature +from pullbox.models.config import SystemConfig +from pullbox.models.import_job import ( + ImportedFile, + ImportedFileStatus, + ImportedSeries, + ImportJob, + ImportJobAction, + ImportJobStatus, + ImportSeriesStatus, + ImportSourceType, +) +from pullbox.models.issue import Issue +from pullbox.models.library import ( + FileFormat, + LibraryFile, + LibraryFileStorageMode, + LibraryRoot, + MatchConfidence, +) +from pullbox.models.series import Series +from pullbox.models.user import User +from pullbox.services.import_misplaced_source_cleanup import ( + MisplacedSourceCleanupAction, + apply_misplaced_source_cleanup, + apply_verified_misplaced_source_cleanup, + count_misplaced_source_cleanup_files, + list_misplaced_source_cleanup_files, + preview_misplaced_source_cleanup, + preview_verified_misplaced_source_cleanup, +) + +if TYPE_CHECKING: + from pathlib import Path + + +async def _seed_recovered_file(db_session, tmp_path: Path, *, writable: bool = True): + root_path = tmp_path / "comics" + expected = root_path / "Absolute Batman (2024)" / "Absolute Batman (2024) #001.cbz" + source = root_path / "Crossed Badlands (2012)" / expected.name + expected.parent.mkdir(parents=True, exist_ok=True) + source.parent.mkdir(parents=True) + source.write_bytes(b"absolute batman issue one") + trash = tmp_path / "trash" + root = LibraryRoot( + name="Mylar", + path=str(root_path), + enabled=True, + allow_referenced_registrations=True, + allow_managed_writes=writable, + ) + user = User(id=42, username="operator", password_hash="unused") + db_session.add_all( + [ + root, + user, + SystemConfig(key="utility_trash_folder", value=str(trash), value_type="string"), + ] + ) + await db_session.flush() + series = Series( + title="Absolute Batman", + sort_title="absolute batman", + year_start=2024, + comicvine_id=160294, + library_root_id=root.id, + ) + db_session.add(series) + await db_session.flush() + issue = Issue( + series_id=series.id, + issue_number=1, + issue_number_text="1", + comicvine_id=1073108, + ) + job = ImportJob( + source_path=str(tmp_path / "mylar.db"), + source_type=ImportSourceType.MYLAR3, + status=ImportJobStatus.COMPLETED, + ) + db_session.add_all([issue, job]) + await db_session.flush() + imported_series = ImportedSeries( + import_job_id=job.id, + raw_series_name=series.title, + status=ImportSeriesStatus.IMPORTED, + series_id=series.id, + ) + db_session.add(imported_series) + await db_session.flush() + signature = build_file_identity_signature(source) + library_file = LibraryFile( + issue_id=issue.id, + library_root_id=root.id, + file_path=str(source), + file_name=source.name, + file_size=source.stat().st_size, + file_format=FileFormat.CBZ, + file_modified_at=datetime.now(UTC), + match_confidence=MatchConfidence.HIGH, + storage_mode=LibraryFileStorageMode.REFERENCED, + source_signature=signature, + ) + db_session.add(library_file) + await db_session.flush() + imported_file = ImportedFile( + import_job_id=job.id, + import_series_id=imported_series.id, + file_path=str(source), + file_name=source.name, + file_size=source.stat().st_size, + file_format="cbz", + source_signature=signature, + status=ImportedFileStatus.IMPORTED, + matched_issue_id=issue.id, + matched_issue_cv_id=issue.comicvine_id, + library_file_id=library_file.id, + diagnostics={ + "mylar3_cross_folder_reconciliation": { + "recorded_path": str(expected), + "actual_path": str(source), + "comicvine_issue_id": 1073108, + "comicvine_series_id": 160294, + "method": "verified_cross_folder_issue_identity", + "role": "canonical", + "source_series": "Crossed Badlands", + } + }, + ) + db_session.add(imported_file) + await db_session.flush() + action = ImportJobAction( + import_job_id=job.id, + sequence_no=1, + phase="import", + action_type="library_file_registered", + payload={ + "imported_file_id": imported_file.id, + "library_file_id": library_file.id, + "destination_path": str(source), + "destination_signature": signature, + "original_source_path": str(source), + "transfer_method": "leave_in_place", + "storage_mode": "referenced", + }, + ) + db_session.add(action) + await db_session.commit() + return job, imported_file, library_file, action, source, expected, trash + + +@pytest.mark.asyncio +async def test_restore_preview_and_apply_move_to_exact_missing_mylar_path( + db_session, + tmp_path: Path, +) -> None: + job, imported_file, library_file, action, source, expected, _trash = await _seed_recovered_file( + db_session, tmp_path + ) + + preview = await preview_misplaced_source_cleanup( + db_session, + job.id, + imported_file.id, + MisplacedSourceCleanupAction.RESTORE_RECORDED_PATH, + actor_id=42, + ) + assert preview.can_apply is True + assert preview.destination_path == str(expected) + assert preview.preview_token is not None + assert source.exists() + assert not expected.exists() + + result = await apply_misplaced_source_cleanup( + db_session, + job.id, + imported_file.id, + MisplacedSourceCleanupAction.RESTORE_RECORDED_PATH, + actor_id=42, + preview_token=preview.preview_token, + ) + + await db_session.refresh(imported_file) + await db_session.refresh(library_file) + await db_session.refresh(action) + assert result.final_path == expected.resolve() + assert not source.exists() + assert expected.read_bytes() == b"absolute batman issue one" + assert imported_file.file_path == str(expected.resolve()) + assert library_file.file_path == str(expected.resolve()) + assert library_file.source_signature == build_file_identity_signature(expected) + assert action.payload["destination_path"] == str(expected.resolve()) + assert action.payload["original_source_path"] == str(expected.resolve()) + + +@pytest.mark.asyncio +async def test_restore_uses_explicit_preview_as_one_time_reference_root_authorization( + db_session, + tmp_path: Path, +) -> None: + ( + job, + imported_file, + _library_file, + _action, + source, + expected, + _trash, + ) = await _seed_recovered_file(db_session, tmp_path, writable=False) + + preview = await preview_misplaced_source_cleanup( + db_session, + job.id, + imported_file.id, + MisplacedSourceCleanupAction.RESTORE_RECORDED_PATH, + actor_id=42, + ) + + assert preview.can_apply is True + assert preview.preview_token is not None + + await apply_misplaced_source_cleanup( + db_session, + job.id, + imported_file.id, + MisplacedSourceCleanupAction.RESTORE_RECORDED_PATH, + actor_id=42, + preview_token=preview.preview_token, + ) + + assert not source.exists() + assert expected.exists() + root = await db_session.get(LibraryRoot, 1) + assert root is not None + await db_session.refresh(root) + assert root.allow_managed_writes is False + + +@pytest.mark.asyncio +async def test_bulk_restore_is_actor_bound_and_restores_source_on_commit_failure( + db_session, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + job, _file, _library_file, _action, source, expected, _trash = await _seed_recovered_file( + db_session, tmp_path, writable=False + ) + preview = await preview_verified_misplaced_source_cleanup( + db_session, + job.id, + actor_id=42, + ) + assert preview.preview_token is not None + + with pytest.raises(ValidationError, match="scope changed"): + await apply_verified_misplaced_source_cleanup( + db_session, + job.id, + actor_id=43, + preview_token=preview.preview_token, + ) + assert source.exists() + assert not expected.exists() + + async def fail_commit() -> None: + raise RuntimeError("database unavailable") + + monkeypatch.setattr(db_session, "commit", fail_commit) + with pytest.raises(RuntimeError, match="database unavailable"): + await apply_verified_misplaced_source_cleanup( + db_session, + job.id, + actor_id=42, + preview_token=preview.preview_token, + ) + + assert source.exists() + assert not expected.exists() + + +@pytest.mark.asyncio +async def test_bulk_restore_moves_all_verified_files_and_excludes_unavailable_candidates( + db_session, + tmp_path: Path, +) -> None: + ( + job, + first, + _library_file, + _action, + first_source, + first_expected, + _trash, + ) = await _seed_recovered_file(db_session, tmp_path, writable=False) + root = await db_session.get(LibraryRoot, 1) + assert root is not None + first_issue = await db_session.get(Issue, first.matched_issue_id) + assert first_issue is not None + + second_source = first_source.with_name("Absolute Batman (2024) #002.cbz") + second_expected = first_expected.with_name("Absolute Batman (2024) #002.cbz") + second_source.write_bytes(b"absolute batman issue two") + issue = Issue( + series_id=first_issue.series_id, + issue_number=2, + issue_number_text="2", + comicvine_id=1073109, + ) + db_session.add(issue) + await db_session.flush() + signature = build_file_identity_signature(second_source) + library_file = LibraryFile( + issue_id=issue.id, + library_root_id=root.id, + file_path=str(second_source), + file_name=second_source.name, + file_size=second_source.stat().st_size, + file_format=FileFormat.CBZ, + file_modified_at=datetime.now(UTC), + match_confidence=MatchConfidence.HIGH, + storage_mode=LibraryFileStorageMode.REFERENCED, + source_signature=signature, + ) + db_session.add(library_file) + await db_session.flush() + second = ImportedFile( + import_job_id=job.id, + import_series_id=first.import_series_id, + file_path=str(second_source), + file_name=second_source.name, + file_size=second_source.stat().st_size, + file_format="cbz", + source_signature=signature, + status=ImportedFileStatus.IMPORTED, + matched_issue_id=issue.id, + matched_issue_cv_id=issue.comicvine_id, + library_file_id=library_file.id, + diagnostics={ + "mylar3_cross_folder_reconciliation": { + "recorded_path": str(second_expected), + "actual_path": str(second_source), + "comicvine_issue_id": issue.comicvine_id, + "comicvine_series_id": 160294, + "method": "verified_cross_folder_issue_identity", + "role": "canonical", + "source_series": "Crossed Badlands", + } + }, + ) + db_session.add(second) + await db_session.flush() + db_session.add( + ImportJobAction( + import_job_id=job.id, + sequence_no=2, + phase="import", + action_type="library_file_registered", + payload={ + "imported_file_id": second.id, + "library_file_id": library_file.id, + "destination_path": str(second_source), + "destination_signature": signature, + "original_source_path": str(second_source), + "transfer_method": "leave_in_place", + "storage_mode": "referenced", + }, + ) + ) + blocked_source = first_source.with_name("Absolute Batman (2024) #003.cbz") + blocked_expected = first_expected.with_name("Absolute Batman (2024) #003.cbz") + blocked_source.write_bytes(b"source") + blocked_expected.write_bytes(b"occupied") + blocked = ImportedFile( + import_job_id=job.id, + import_series_id=first.import_series_id, + file_path=str(blocked_source), + file_name=blocked_source.name, + file_size=blocked_source.stat().st_size, + file_format="cbz", + source_signature=build_file_identity_signature(blocked_source), + status=ImportedFileStatus.IMPORTED, + matched_issue_id=issue.id, + matched_issue_cv_id=issue.comicvine_id, + library_file_id=library_file.id, + diagnostics={ + "mylar3_cross_folder_reconciliation": { + "recorded_path": str(blocked_expected), + "actual_path": str(blocked_source), + "method": "verified_cross_folder_issue_identity", + "role": "canonical", + } + }, + ) + db_session.add(blocked) + await db_session.commit() + + preview = await preview_verified_misplaced_source_cleanup( + db_session, + job.id, + actor_id=42, + ) + + assert preview.affected_count == 2 + assert preview.unavailable_count == 1 + assert preview.preview_token is not None + + result = await apply_verified_misplaced_source_cleanup( + db_session, + job.id, + actor_id=42, + preview_token=preview.preview_token, + ) + + assert result.moved_count == 2 + assert result.skipped_count == 1 + assert first_expected.exists() + assert second_expected.exists() + assert blocked_source.exists() + assert blocked_expected.read_bytes() == b"occupied" + + +@pytest.mark.asyncio +async def test_restore_accepts_series_filename_fallback_evidence( + db_session, + tmp_path: Path, +) -> None: + ( + job, + imported_file, + _library_file, + _action, + _source, + _expected, + _trash, + ) = await _seed_recovered_file(db_session, tmp_path) + diagnostics = dict(imported_file.diagnostics) + evidence = dict(diagnostics["mylar3_cross_folder_reconciliation"]) + evidence["method"] = "verified_cross_folder_series_issue_filename" + diagnostics["mylar3_cross_folder_reconciliation"] = evidence + imported_file.diagnostics = diagnostics + await db_session.commit() + + preview = await preview_misplaced_source_cleanup( + db_session, + job.id, + imported_file.id, + MisplacedSourceCleanupAction.RESTORE_RECORDED_PATH, + actor_id=42, + ) + + assert preview.can_apply is True + assert preview.preview_token is not None + + +@pytest.mark.asyncio +async def test_restore_refuses_an_occupied_recorded_path(db_session, tmp_path: Path) -> None: + ( + job, + imported_file, + _library_file, + _action, + source, + expected, + _trash, + ) = await _seed_recovered_file(db_session, tmp_path) + expected.parent.mkdir(parents=True, exist_ok=True) + expected.write_bytes(b"different user file") + + preview = await preview_misplaced_source_cleanup( + db_session, + job.id, + imported_file.id, + MisplacedSourceCleanupAction.RESTORE_RECORDED_PATH, + actor_id=42, + ) + + assert preview.can_apply is False + assert "already exists" in preview.unavailable_reason + assert source.exists() + with pytest.raises(ValidationError, match="already exists"): + await apply_misplaced_source_cleanup( + db_session, + job.id, + imported_file.id, + MisplacedSourceCleanupAction.RESTORE_RECORDED_PATH, + actor_id=42, + preview_token="invalid", + ) + + +@pytest.mark.asyncio +async def test_restore_puts_source_back_when_database_commit_fails( + db_session, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + ( + job, + imported_file, + _library_file, + _action, + source, + expected, + _trash, + ) = await _seed_recovered_file(db_session, tmp_path) + preview = await preview_misplaced_source_cleanup( + db_session, + job.id, + imported_file.id, + MisplacedSourceCleanupAction.RESTORE_RECORDED_PATH, + actor_id=42, + ) + + async def fail_commit() -> None: + raise RuntimeError("database unavailable") + + monkeypatch.setattr(db_session, "commit", fail_commit) + with pytest.raises(RuntimeError, match="database unavailable"): + await apply_misplaced_source_cleanup( + db_session, + job.id, + imported_file.id, + MisplacedSourceCleanupAction.RESTORE_RECORDED_PATH, + actor_id=42, + preview_token=preview.preview_token or "", + ) + + assert source.read_bytes() == b"absolute batman issue one" + assert not expected.exists() + + +@pytest.mark.asyncio +async def test_identical_duplicate_can_be_moved_to_trash_separately( + db_session, + tmp_path: Path, +) -> None: + job, canonical, _library_file, _action, source, _expected, trash = await _seed_recovered_file( + db_session, tmp_path + ) + duplicate_source = source.with_name("Absolute Batman (2024) #001 variant.cbz") + duplicate_source.write_bytes(source.read_bytes()) + content_hash = sha256(source.read_bytes()).hexdigest() + canonical.content_hash = content_hash + duplicate = ImportedFile( + import_job_id=job.id, + import_series_id=canonical.import_series_id, + file_path=str(duplicate_source), + file_name=duplicate_source.name, + file_size=duplicate_source.stat().st_size, + file_format="cbz", + source_signature=build_file_identity_signature(duplicate_source), + status=ImportedFileStatus.DUPLICATE_FILE, + duplicate_of_file_id=canonical.id, + content_hash=content_hash, + diagnostics={ + "mylar3_cross_folder_reconciliation": { + "recorded_path": canonical.diagnostics["mylar3_cross_folder_reconciliation"][ + "recorded_path" + ], + "actual_path": str(duplicate_source), + "comicvine_issue_id": 1073108, + "comicvine_series_id": 160294, + "method": "verified_cross_folder_issue_identity", + "role": "identical_duplicate", + "canonical_path": str(source), + "source_series": "Crossed Badlands", + } + }, + ) + db_session.add(duplicate) + await db_session.commit() + + preview = await preview_misplaced_source_cleanup( + db_session, + job.id, + duplicate.id, + MisplacedSourceCleanupAction.TRASH_IDENTICAL_DUPLICATE, + actor_id=42, + ) + assert preview.can_apply is True + assert preview.preview_token is not None + + result = await apply_misplaced_source_cleanup( + db_session, + job.id, + duplicate.id, + MisplacedSourceCleanupAction.TRASH_IDENTICAL_DUPLICATE, + actor_id=42, + preview_token=preview.preview_token, + ) + + await db_session.refresh(duplicate) + assert not duplicate_source.exists() + assert result.final_path.is_relative_to(trash) + assert result.final_path.read_bytes() == source.read_bytes() + assert duplicate.status is ImportedFileStatus.DUPLICATE_FILE + assert duplicate.diagnostics["misplaced_source_cleanup"]["action"] == ( + "trash_identical_duplicate" + ) + assert source.exists() + + +@pytest.mark.asyncio +async def test_cleanup_scope_lists_only_pending_actions(db_session, tmp_path: Path) -> None: + job, canonical, _library_file, _action, source, _expected, _trash = await _seed_recovered_file( + db_session, tmp_path + ) + duplicate_source = source.with_name("Absolute Batman (2024) #001 variant.cbz") + duplicate_source.write_bytes(source.read_bytes()) + content_hash = sha256(source.read_bytes()).hexdigest() + canonical.content_hash = content_hash + duplicate = ImportedFile( + import_job_id=job.id, + import_series_id=canonical.import_series_id, + file_path=str(duplicate_source), + file_name=duplicate_source.name, + file_size=duplicate_source.stat().st_size, + file_format="cbz", + source_signature=build_file_identity_signature(duplicate_source), + status=ImportedFileStatus.DUPLICATE_FILE, + duplicate_of_file_id=canonical.id, + content_hash=content_hash, + diagnostics={ + "mylar3_cross_folder_reconciliation": { + "method": "verified_cross_folder_issue_identity", + "role": "identical_duplicate", + } + }, + ) + completed_duplicate = ImportedFile( + import_job_id=job.id, + import_series_id=canonical.import_series_id, + file_path=str(source.with_name("already-cleaned.cbz")), + file_name="already-cleaned.cbz", + file_size=1, + file_format="cbz", + status=ImportedFileStatus.DUPLICATE_FILE, + duplicate_of_file_id=canonical.id, + content_hash=content_hash, + diagnostics={ + "mylar3_cross_folder_reconciliation": { + "method": "verified_cross_folder_issue_identity", + "role": "identical_duplicate", + }, + "misplaced_source_cleanup": { + "action": "trash_identical_duplicate", + }, + }, + ) + db_session.add_all([duplicate, completed_duplicate]) + await db_session.commit() + + assert ( + await count_misplaced_source_cleanup_files( + db_session, + job.id, + MisplacedSourceCleanupAction.RESTORE_RECORDED_PATH, + ) + == 1 + ) + assert ( + await count_misplaced_source_cleanup_files( + db_session, + job.id, + MisplacedSourceCleanupAction.TRASH_IDENTICAL_DUPLICATE, + ) + == 1 + ) + + page = await list_misplaced_source_cleanup_files( + db_session, + job.id, + MisplacedSourceCleanupAction.TRASH_IDENTICAL_DUPLICATE, + page=1, + page_size=25, + ) + assert page.total == 1 + assert page.items == (duplicate,) diff --git a/tests/unit/test_import_mylar3_path_preflight.py b/tests/unit/test_import_mylar3_path_preflight.py index 18f0a5a..6ec9236 100644 --- a/tests/unit/test_import_mylar3_path_preflight.py +++ b/tests/unit/test_import_mylar3_path_preflight.py @@ -134,6 +134,118 @@ async def test_in_place_mapping_is_tried_before_external_identity_is_rejected( assert preview.can_confirm is True +async def test_in_place_outside_paths_are_grouped_by_unregistered_root( + db_session: AsyncSession, + tmp_path: Path, +) -> None: + source_root = tmp_path / "media" / "comics" + (source_root / "Batman (2025)").mkdir(parents=True) + (source_root / "Superman (2025)").mkdir() + managed_root = tmp_path / "pullbox-library" + managed_root.mkdir() + database = tmp_path / "mylar.db" + _write_mylar_path_database( + database, + comics=[ + ("CV-1", str(source_root / "Batman (2025)")), + ("CV-2", str(source_root / "Superman (2025)")), + ], + issues=[], + ) + db_session.add( + LibraryRoot( + name="Pullbox library", + path=str(managed_root), + enabled=True, + allow_referenced_registrations=True, + allow_managed_writes=True, + ) + ) + await db_session.flush() + + preview = await path_preflight.Mylar3PathPreflightAnalyzer().analyze( + db_session, + database, + auto_detect=True, + mappings=[], + file_handling_mode=ImportFileHandlingMode.IN_PLACE, + ) + + assert preview.resolution.outside_root == 2 + assert [group.model_dump() for group in preview.problem_groups] == [ + { + "root_path": str(source_root), + "outcome": "outside_root", + "series_count": 2, + "location_count": 2, + "reason": (f"2 series use {source_root}, which isn't registered as a library root."), + "suggested_action": ( + "Register this path for existing files, then Pullbox will analyze the paths again." + ), + "can_register_reference_root": True, + } + ] + assert len(preview.attention_items) == 1 + attention = preview.attention_items[0] + assert attention.code == "outside_root" + assert attention.blocks_import is True + assert attention.root_path == str(source_root) + assert attention.action is not None + assert attention.action.kind == "register_reference_root" + assert attention.action.root_path == str(source_root) + assert len(attention.action.fingerprint) == 64 + assert attention.details.series_count == 2 + assert attention.details.location_count == 2 + assert attention.details.known_paths[0] == str(source_root) + assert str(source_root / "Batman (2025)") in attention.details.known_paths + assert any(str(source_root) in step for step in attention.details.steps) + assert preview.attention_fingerprint + + +async def test_in_place_nested_publisher_paths_share_one_unregistered_root_group( + db_session: AsyncSession, + tmp_path: Path, +) -> None: + source_root = tmp_path / "media" / "comics" + (source_root / "DC Comics" / "Batman (2025)").mkdir(parents=True) + (source_root / "Marvel" / "Daredevil (2025)").mkdir(parents=True) + managed_root = tmp_path / "pullbox-library" + managed_root.mkdir() + database = tmp_path / "mylar.db" + _write_mylar_path_database( + database, + comics=[ + ("CV-1", str(source_root / "DC Comics" / "Batman (2025)")), + ("CV-2", str(source_root / "Marvel" / "Daredevil (2025)")), + ], + issues=[], + ) + db_session.add( + LibraryRoot( + name="Pullbox library", + path=str(managed_root), + enabled=True, + allow_referenced_registrations=True, + allow_managed_writes=True, + ) + ) + await db_session.flush() + + preview = await path_preflight.Mylar3PathPreflightAnalyzer().analyze( + db_session, + database, + auto_detect=True, + mappings=[], + file_handling_mode=ImportFileHandlingMode.IN_PLACE, + ) + + assert preview.resolution.outside_root == 2 + assert len(preview.problem_groups) == 1 + assert preview.problem_groups[0].root_path == str(source_root) + assert preview.problem_groups[0].series_count == 2 + assert preview.problem_groups[0].can_register_reference_root is True + + async def test_incomplete_automatic_mapping_cannot_be_confirmed( db_session: AsyncSession, tmp_path: Path, @@ -317,6 +429,17 @@ async def test_identity_missing_paths_have_actionable_exceptions_and_safe_contin assert data.get("can_continue_with_unresolved") is True assert data.get("requires_unresolved_acknowledgement") is True assert preview.can_confirm is False + unresolved_attention = { + item.code: item for item in preview.attention_items if item.code in {"missing", "unmapped"} + } + assert set(unresolved_attention) == {"missing", "unmapped"} + for item in unresolved_attention.values(): + assert item.blocks_import is False + assert item.action is not None + assert item.action.kind == "acknowledge_unavailable" + assert item.action.fingerprint + assert item.details.known_paths + assert item.details.steps missing = next( item for item in data["exceptions"] if item["stored_path"] == str(root / "Missing") ) @@ -379,6 +502,10 @@ async def test_entirely_unavailable_library_cannot_continue( ) assert preview.can_confirm is False assert preview.model_dump().get("can_continue_with_unresolved") is False + assert preview.attention_items + assert all(item.blocks_import for item in preview.attention_items) + assert all(item.action is None for item in preview.attention_items) + assert all(item.details.steps for item in preview.attention_items) async def test_io_errors_and_sensitive_missing_paths_are_not_skippable( diff --git a/tests/unit/test_import_path_reconciliation.py b/tests/unit/test_import_path_reconciliation.py index 0e4a1de..6b1be61 100644 --- a/tests/unit/test_import_path_reconciliation.py +++ b/tests/unit/test_import_path_reconciliation.py @@ -39,14 +39,33 @@ from scripts.mylar3_import_fixture import create_mylar3_db -def _archive(path, *, issue_id=703887, number="1", pages=2, series="Firefly: Bad Company"): +def _archive( + path, + *, + issue_id=703887, + series_id=None, + number="1", + pages=2, + series="Firefly: Bad Company", + nested_incomplete=False, +): path.parent.mkdir(parents=True, exist_ok=True) + notes = ( + f"[cv_vol_id:{series_id}] [cv_issue_id:{issue_id}]" + if series_id is not None + else "" + ) with zipfile.ZipFile(path, "w") as archive: + if nested_incomplete: + archive.writestr( + f"{series}/ComicInfo.xml", + f"{series}2019", + ) archive.writestr( "ComicInfo.xml", f"{series}{number}" f"2019https://comicvine.gamespot.com/issue/4000-{issue_id}/" - "", + f"{notes}", ) for page in range(pages): archive.writestr(f"{page}.jpg", b"page") @@ -121,6 +140,514 @@ def unexpected_sidecar_read(*args, **kwargs): assert [file.file_path for file in discovered[0].files] == [str(actual)] +async def test_scan_reconciles_unique_trusted_comicinfo_without_mylar_sidecar(db_session, tmp_path): + discovered, actual, _db = await _scan(tmp_path) + candidate = next(file for file in discovered[0].files if file.file_path == str(actual)) + candidate.metadata_diagnostics.pop("mylar3_sidecar_data", None) + candidate.metadata_diagnostics.pop("mylar3_sidecar", None) + + await validate_discovered_files_safety(db_session, discovered) + + assert [file.file_path for file in discovered[0].files] == [str(actual)] + assert candidate.metadata_diagnostics["mylar3_path_reconciliation"]["method"] == ( + "verified_same_folder_issue_identity" + ) + + +async def test_scan_reconciles_renamed_collection_with_exact_comicvine_identity( + db_session, + tmp_path, +): + folder = tmp_path / "Revival 01 (2013) [68730]" + actual = folder / ("Revival: Deluxe Collection - Revival 01 (2013) Deluxe 01 - Volume One.cbz") + _archive( + actual, + issue_id=431478, + series_id=68730, + number="1", + pages=298, + series="Revival Deluxe Edition", + ) + recorded_name = "Revival 01 (2013) Deluxe 01 - Volume One.cbz" + db = tmp_path / "mylar.db" + create_mylar3_db( + db, + series=[ + { + "ComicID": "68730", + "ComicName": "Revival 01", + "ComicYear": "2013", + "ComicLocation": str(folder), + "Total": 1, + } + ], + issues=[ + { + "IssueID": "431478", + "ComicID": "68730", + "ComicName": "Revival 01", + "IssueName": "Volume One", + "Issue_Number": "1", + "Location": recorded_name, + "IssueDate": "2013-01-01", + } + ], + ) + + discovered = await Mylar3Reader(db, include_missing_files=True).read_series() + before = actual.read_bytes() + stat = tmp_path.stat() + validate_mylar_in_place_files( + discovered, + [MylarReferenceRootBoundary(1, tmp_path, tmp_path, stat.st_dev, stat.st_ino)], + ) + + await validate_discovered_files_safety(db_session, discovered) + + assert discovered[0].file_count == 1 + assert [file.file_path for file in discovered[0].files] == [str(actual)] + recovered = discovered[0].files[0] + assert recovered.parsed_series == "Revival 01" + assert recovered.parsed_issue_number == 1 + assert recovered.comicvine_series_id == 68730 + assert recovered.comicvine_issue_id == 431478 + assert "mylar3_folder_scope_conflict" not in recovered.metadata_diagnostics + assert "mylar3_folder_scope" not in discovered[0].diagnostics + evidence = recovered.metadata_diagnostics["mylar3_path_reconciliation"] + assert evidence == { + "recorded_path": str(folder / recorded_name), + "actual_path": str(actual), + "comicvine_issue_id": 431478, + "method": "verified_same_folder_issue_identity", + "series_name_alias": { + "recorded": "Revival 01", + "actual": "Revival Deluxe Edition", + "accepted_by": "exact_comicvine_series_and_issue_identity", + }, + } + assert actual.read_bytes() == before + + +async def test_scan_reconciles_identical_cross_folder_copies_to_missing_mylar_issue( + db_session, + tmp_path, +): + comics = tmp_path / "comics" + target_folder = comics / "Absolute Batman (2024)" + wrong_folder = comics / "Crossed Badlands (2012)" + canonical = wrong_folder / "Absolute Batman (2024) #001.cbz" + duplicate = wrong_folder / "Absolute Batman (2024) #001 (c2c low res variant).cbz" + crossed = wrong_folder / "Crossed Badlands 001.cbz" + owned_target = target_folder / "Absolute Batman (2024) #002.cbz" + _archive(canonical, issue_id=1073108, number="1", series="Absolute Batman") + duplicate.write_bytes(canonical.read_bytes()) + _archive(crossed, issue_id=800001, number="1", series="Crossed Badlands") + _archive(owned_target, issue_id=1073109, number="2", series="Absolute Batman") + db = tmp_path / "mylar.db" + create_mylar3_db( + db, + series=[ + { + "ComicID": "160294", + "ComicName": "Absolute Batman", + "ComicYear": "2024", + "ComicLocation": str(target_folder), + "Total": 1, + }, + { + "ComicID": "46576", + "ComicName": "Crossed Badlands", + "ComicYear": "2012", + "ComicLocation": str(wrong_folder), + "Total": 1, + }, + ], + issues=[ + { + "IssueID": "1073108", + "ComicID": "160294", + "ComicName": "Absolute Batman", + "Issue_Number": "1", + "Location": canonical.name, + }, + { + "IssueID": "1073109", + "ComicID": "160294", + "ComicName": "Absolute Batman", + "Issue_Number": "2", + "Location": owned_target.name, + }, + { + "IssueID": "800001", + "ComicID": "46576", + "ComicName": "Crossed Badlands", + "Issue_Number": "1", + "Location": crossed.name, + }, + ], + ) + + discovered = await Mylar3Reader(db, include_missing_files=True).read_series() + before = {path: path.read_bytes() for path in (canonical, duplicate, crossed, owned_target, db)} + stat = comics.stat() + validate_mylar_in_place_files( + discovered, + [MylarReferenceRootBoundary(1, comics, comics, stat.st_dev, stat.st_ino)], + ) + await validate_discovered_files_safety(db_session, discovered) + + by_cv_id = {series.mylar3_cv_id: series for series in discovered} + target = by_cv_id[160294] + source = by_cv_id[46576] + assert [file.file_path for file in target.files] == [ + str(owned_target), + str(canonical), + str(duplicate), + ] + assert [file.file_path for file in source.files] == [str(crossed)] + canonical_evidence = target.files[1].metadata_diagnostics["mylar3_cross_folder_reconciliation"] + duplicate_evidence = target.files[2].metadata_diagnostics["mylar3_cross_folder_reconciliation"] + assert canonical_evidence == { + "recorded_path": str(target_folder / canonical.name), + "actual_path": str(canonical), + "comicvine_issue_id": 1073108, + "comicvine_series_id": 160294, + "method": "verified_cross_folder_issue_identity", + "role": "canonical", + "source_series": "Crossed Badlands", + } + assert duplicate_evidence["role"] == "identical_duplicate" + assert duplicate_evidence["canonical_path"] == str(canonical) + assert "mylar3_folder_scope_conflict" not in target.files[1].metadata_diagnostics + assert "mylar3_unrecorded_file" not in target.files[1].metadata_diagnostics + assert "mylar3_folder_scope" not in source.diagnostics + assert before == {path: path.read_bytes() for path in before} + + job = ImportJob(source_path=str(db), source_type=ImportSourceType.MYLAR3) + db_session.add(job) + await db_session.flush() + pairs = await materialize_discovered_scan_results(db_session, job, discovered) + target_import = next(item for series, item in pairs if series.mylar3_cv_id == 160294) + target_rows = list( + await db_session.scalars( + select(ImportedFile) + .where(ImportedFile.import_series_id == target_import.id) + .order_by(ImportedFile.id) + ) + ) + assert [row.file_path for row in target_rows] == [ + str(owned_target), + str(canonical), + str(duplicate), + ] + assert target_rows[1].diagnostics["mylar3_cross_folder_reconciliation"]["role"] == ("canonical") + + +async def test_scan_reconciles_stale_mylar_issue_ids_from_unique_comicinfo_series_slots( + db_session, + tmp_path, +): + comics = tmp_path / "comics" + target_folder = comics / "Babyteeth (2017)" + wrong_folder = comics / "Aliens Epic Collection (2023)" + target_folder.mkdir(parents=True) + files = [ + ( + wrong_folder / "Babyteeth (2017) Vol 01 - Volume 1 - Born.cbz", + 1, + 644570, + 871891001, + ), + ( + wrong_folder / "Babyteeth (2017) Vol 03 - Vol. 3 - Cradle.cbz", + 3, + 727350, + 871891002, + ), + ( + wrong_folder / "Babyteeth (2017) Vol 04 - Vol. 4 - Grave.cbz", + 4, + 1166031, + 871891003, + ), + ] + for path, number, actual_issue_id, _recorded_issue_id in files: + _archive( + path, + issue_id=actual_issue_id, + series_id=171891, + number=str(number), + series="Babyteeth", + nested_incomplete=True, + ) + aliens = wrong_folder / "Aliens Epic Collection Vol 01.cbz" + _archive(aliens, issue_id=1067467, series_id=148973, series="Aliens Epic Collection") + db = tmp_path / "mylar.db" + create_mylar3_db( + db, + series=[ + { + "ComicID": "171891", + "ComicName": "Babyteeth", + "ComicYear": "2017", + "ComicLocation": str(target_folder), + "Total": 3, + }, + { + "ComicID": "148973", + "ComicName": "Aliens Epic Collection", + "ComicYear": "2023", + "ComicLocation": str(wrong_folder), + "Total": 1, + }, + ], + issues=[ + *[ + { + "IssueID": str(recorded_issue_id), + "ComicID": "171891", + "ComicName": "Babyteeth", + "Issue_Number": str(number), + "Location": path.name, + } + for path, number, _actual_issue_id, recorded_issue_id in files + ], + { + "IssueID": "1067467", + "ComicID": "148973", + "ComicName": "Aliens Epic Collection", + "Issue_Number": "1", + "Location": aliens.name, + }, + ], + ) + + discovered = await Mylar3Reader(db, include_missing_files=True).read_series() + before = {path: path.read_bytes() for path, *_rest in files} + stat = comics.stat() + validate_mylar_in_place_files( + discovered, + [MylarReferenceRootBoundary(1, comics, comics, stat.st_dev, stat.st_ino)], + ) + + await validate_discovered_files_safety(db_session, discovered) + + by_cv_id = {series.mylar3_cv_id: series for series in discovered} + babyteeth = by_cv_id[171891] + aliens_series = by_cv_id[148973] + assert [file.file_path for file in babyteeth.files] == [str(path) for path, *_rest in files] + assert [file.file_path for file in aliens_series.files] == [str(aliens)] + assert [file.comicvine_issue_id for file in babyteeth.files] == [ + actual_issue_id for _path, _number, actual_issue_id, _recorded_issue_id in files + ] + for recovered, (_path, _number, actual_issue_id, recorded_issue_id) in zip( + babyteeth.files, files, strict=True + ): + evidence = recovered.metadata_diagnostics["mylar3_cross_folder_reconciliation"] + assert evidence["method"] == "verified_cross_folder_series_issue_filename" + assert evidence["comicvine_issue_id"] == actual_issue_id + assert evidence["recorded_comicvine_issue_id"] == recorded_issue_id + assert evidence["comicvine_series_id"] == 171891 + assert "kind" not in babyteeth.diagnostics + assert "reason" not in babyteeth.diagnostics + assert "rejection_reason" not in babyteeth.diagnostics + assert babyteeth.diagnostics["mylar3_path"]["status"] == "reconciled" + assert babyteeth.diagnostics["mylar3_path_recovery"] == { + "status": "complete", + "recovered_file_count": 3, + "remaining_missing_file_count": 0, + } + assert before == {path: path.read_bytes() for path in before} + + job = ImportJob(source_path=str(db), source_type=ImportSourceType.MYLAR3) + db_session.add(job) + await db_session.flush() + pairs = await materialize_discovered_scan_results(db_session, job, discovered) + babyteeth_import = next(item for series, item in pairs if series.mylar3_cv_id == 171891) + assert babyteeth_import.status == ImportSeriesStatus.PENDING + babyteeth_rows = list( + await db_session.scalars( + select(ImportedFile) + .where(ImportedFile.import_series_id == babyteeth_import.id) + .order_by(ImportedFile.id) + ) + ) + assert len(babyteeth_rows) == 3 + assert all(row.status == ImportedFileStatus.PENDING for row in babyteeth_rows) + + +async def test_scan_keeps_only_unresolved_cross_folder_mylar_paths_in_file_review( + db_session, + tmp_path, +): + comics = tmp_path / "comics" + target_folder = comics / "Babyteeth (2017)" + wrong_folder = comics / "Aliens Epic Collection (2023)" + target_folder.mkdir(parents=True) + recovered = wrong_folder / "Babyteeth (2017) Vol 01 - Volume 1 - Born.cbz" + _archive( + recovered, + issue_id=644570, + series_id=171891, + number="1", + series="Babyteeth", + nested_incomplete=True, + ) + aliens = wrong_folder / "Aliens Epic Collection Vol 01.cbz" + _archive(aliens, issue_id=1067467, series_id=148973, series="Aliens Epic Collection") + missing_name = "Babyteeth (2017) Vol 02 - Volume 2.cbz" + db = tmp_path / "mylar.db" + create_mylar3_db( + db, + series=[ + { + "ComicID": "171891", + "ComicName": "Babyteeth", + "ComicYear": "2017", + "ComicLocation": str(target_folder), + "Total": 2, + }, + { + "ComicID": "148973", + "ComicName": "Aliens Epic Collection", + "ComicYear": "2023", + "ComicLocation": str(wrong_folder), + "Total": 1, + }, + ], + issues=[ + { + "IssueID": "871891001", + "ComicID": "171891", + "ComicName": "Babyteeth", + "Issue_Number": "1", + "Location": recovered.name, + }, + { + "IssueID": "871891002", + "ComicID": "171891", + "ComicName": "Babyteeth", + "Issue_Number": "2", + "Location": missing_name, + }, + { + "IssueID": "1067467", + "ComicID": "148973", + "ComicName": "Aliens Epic Collection", + "Issue_Number": "1", + "Location": aliens.name, + }, + ], + ) + + discovered = await Mylar3Reader(db, include_missing_files=True).read_series() + stat = comics.stat() + validate_mylar_in_place_files( + discovered, + [MylarReferenceRootBoundary(1, comics, comics, stat.st_dev, stat.st_ino)], + ) + await validate_discovered_files_safety(db_session, discovered) + + babyteeth = next(series for series in discovered if series.mylar3_cv_id == 171891) + assert "kind" not in babyteeth.diagnostics + assert babyteeth.diagnostics["mylar3_path"]["status"] == "partially_reconciled" + assert babyteeth.diagnostics["mylar3_path_recovery"] == { + "status": "partial", + "recovered_file_count": 1, + "remaining_missing_file_count": 1, + } + + job = ImportJob(source_path=str(db), source_type=ImportSourceType.MYLAR3) + db_session.add(job) + await db_session.flush() + pairs = await materialize_discovered_scan_results(db_session, job, discovered) + babyteeth_import = next(item for series, item in pairs if series.mylar3_cv_id == 171891) + assert babyteeth_import.status == ImportSeriesStatus.PENDING + rows = list( + await db_session.scalars( + select(ImportedFile) + .where(ImportedFile.import_series_id == babyteeth_import.id) + .order_by(ImportedFile.file_name) + ) + ) + assert {row.file_name: row.status for row in rows} == { + missing_name: ImportedFileStatus.SAFETY_BLOCKED, + recovered.name: ImportedFileStatus.PENDING, + } + + +async def test_scan_does_not_guess_between_different_cross_folder_copies( + db_session, + tmp_path, +): + comics = tmp_path / "comics" + target_folder = comics / "Absolute Batman (2024)" + wrong_folder = comics / "Crossed Badlands (2012)" + first = wrong_folder / "Absolute Batman 001 scan A.cbz" + second = wrong_folder / "Absolute Batman 001 scan B.cbz" + owned_target = target_folder / "Absolute Batman (2024) #002.cbz" + _archive(first, issue_id=1073108, number="1", series="Absolute Batman") + _archive(second, issue_id=1073108, number="1", pages=3, series="Absolute Batman") + _archive(owned_target, issue_id=1073109, number="2", series="Absolute Batman") + db = tmp_path / "mylar.db" + create_mylar3_db( + db, + series=[ + { + "ComicID": "160294", + "ComicName": "Absolute Batman", + "ComicYear": "2024", + "ComicLocation": str(target_folder), + "Total": 1, + }, + { + "ComicID": "46576", + "ComicName": "Crossed Badlands", + "ComicYear": "2012", + "ComicLocation": str(wrong_folder), + "Total": 0, + }, + ], + issues=[ + { + "IssueID": "1073108", + "ComicID": "160294", + "ComicName": "Absolute Batman", + "Issue_Number": "1", + "Location": "Absolute Batman (2024) #001.cbz", + }, + { + "IssueID": "1073109", + "ComicID": "160294", + "ComicName": "Absolute Batman", + "Issue_Number": "2", + "Location": owned_target.name, + }, + ], + ) + + discovered = await Mylar3Reader(db, include_missing_files=True).read_series() + stat = comics.stat() + validate_mylar_in_place_files( + discovered, + [MylarReferenceRootBoundary(1, comics, comics, stat.st_dev, stat.st_ino)], + ) + await validate_discovered_files_safety(db_session, discovered) + + by_cv_id = {series.mylar3_cv_id: series for series in discovered} + target = by_cv_id[160294] + source = by_cv_id[46576] + assert [file.file_name for file in target.files] == [ + "Absolute Batman (2024) #001.cbz", + owned_target.name, + ] + assert {file.file_name for file in source.files} == {first.name, second.name} + assert all( + "mylar3_cross_folder_reconciliation" not in file.metadata_diagnostics + for file in source.files + ) + + def test_missing_source_message_does_not_claim_file_changed_after_scan(): block = build_import_safety_diagnostics("missing", code="source_missing") assert block["category"] == "source_missing" @@ -308,6 +835,7 @@ async def test_saved_reconciliation_never_guesses_or_changes_review_decisions( [ "no_archive_id", "different_series", + "alias_without_exact_series_identity", "different_number", "annual", "conflict", @@ -324,6 +852,12 @@ async def test_shared_identity_rule_rejects_conflicting_evidence(db_session, tmp fresh = replace(fresh, signals={"comicvine_issue_id": MetadataSignal.RELEASE_TITLE}) elif case == "different_series": fresh = replace(fresh, comicvine_series_id=42) + elif case == "alias_without_exact_series_identity": + fresh = replace( + fresh, + series_name="Firefly Deluxe Edition", + comicvine_series_id=None, + ) elif case == "different_number": fresh = replace(fresh, issue_number=2) elif case == "annual": diff --git a/tests/unit/test_import_placement_recovery_edges.py b/tests/unit/test_import_placement_recovery_edges.py new file mode 100644 index 0000000..093cd16 --- /dev/null +++ b/tests/unit/test_import_placement_recovery_edges.py @@ -0,0 +1,281 @@ +"""Fail-closed edge coverage for durable import placement recovery.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from pullbox.core.exceptions import ConfigurationError +from pullbox.services import import_placement_recovery as recovery + + +def _action(action_id: int, **payload: object) -> SimpleNamespace: + return SimpleNamespace(id=action_id, payload=payload) + + +@pytest.mark.asyncio +async def test_direct_move_completion_requires_exact_durable_paths(monkeypatch, tmp_path) -> None: + source = tmp_path / "source.cbz" + destination = tmp_path / "destination.cbz" + destination.write_bytes(b"comic") + actions = [ + _action(1, placement_completed=False), + _action(2, placement_completed=True, transfer_method="copy"), + _action(3, placement_completed=True, transfer_method="move"), + _action( + 4, + placement_completed=True, + transfer_method="move", + original_source_path=tmp_path / "other.cbz", + artifact_source_path=source, + ), + _action( + 5, + placement_completed=True, + transfer_method="move", + original_source_path=source, + artifact_source_path=tmp_path / "other.cbz", + ), + _action( + 6, + placement_completed=True, + transfer_method="move", + original_source_path=source, + artifact_source_path=source, + destination_path=tmp_path / "missing.cbz", + ), + _action( + 7, + placement_completed=True, + transfer_method="move", + original_source_path=source, + artifact_source_path=source, + destination_path=destination, + ), + ] + monkeypatch.setattr(recovery, "_candidate_actions", AsyncMock(return_value=actions)) + + assert await recovery.has_completed_direct_move_placement_record( + AsyncMock(), job_id=1, imported_file_id=2, source_path=source + ) + + +@pytest.mark.asyncio +async def test_recovery_rejects_each_untrusted_journal_shape(monkeypatch, tmp_path) -> None: + source = tmp_path / "source.cbz" + destination = tmp_path / "destination.cbz" + destination.write_bytes(b"comic") + temp = tmp_path / "partial.tmp" + temp.write_bytes(b"partial") + signature = { + "content_digest": "digest", + "content_digest_algorithm": "sha256", + "size_bytes": 5, + } + actions = [ + _action(1, placement_completed=False), + _action(2, placement_completed=True, issue_id=9, transfer_method="copy"), + _action(3, placement_completed=True, issue_id=7, transfer_method="move"), + _action(4, placement_completed=True, issue_id=7, transfer_method="copy"), + _action( + 5, + placement_completed=True, + issue_id=7, + transfer_method="copy", + original_source_path=tmp_path / "other.cbz", + artifact_source_path=source, + destination_path=destination, + ), + _action( + 6, + placement_completed=True, + issue_id=7, + transfer_method="copy", + original_source_path=source, + artifact_source_path=source, + destination_path=source, + ), + _action( + 7, + placement_completed=True, + issue_id=7, + transfer_method="copy", + original_source_path=source, + artifact_source_path=source, + destination_path=destination, + temp_paths=[temp], + ), + _action( + 8, + placement_completed=True, + issue_id=7, + transfer_method="copy", + original_source_path=source, + artifact_source_path=source, + destination_path=tmp_path / "missing.cbz", + ), + _action( + 9, + placement_completed=True, + issue_id=7, + transfer_method="copy", + original_source_path=source, + artifact_source_path=source, + destination_path=destination, + destination_signature="invalid", + ), + _action( + 10, + placement_completed=True, + issue_id=7, + transfer_method="copy", + original_source_path=source, + artifact_source_path=source, + destination_path=destination, + destination_signature={"content_digest": "digest", "content_digest_algorithm": "md5"}, + ), + _action( + 11, + placement_completed=True, + issue_id=7, + transfer_method="copy", + original_source_path=source, + artifact_source_path=source, + destination_path=destination, + destination_signature=signature, + ), + ] + monkeypatch.setattr(recovery, "_candidate_actions", AsyncMock(return_value=actions)) + monkeypatch.setattr( + recovery, + "build_managed_placement_signature", + lambda _path: {**signature, "content_digest": "different"}, + ) + + session = AsyncMock() + assert ( + await recovery.load_completed_import_placement_recovery( + session, + job_id=1, + imported_file_id=2, + issue_id=7, + source_path=source, + transfer_method="copy", + ) + is None + ) + + +@pytest.mark.asyncio +async def test_recovery_tolerates_signature_errors_and_returns_exact_evidence( + monkeypatch, tmp_path +) -> None: + source = tmp_path / "source.cbz" + destination = tmp_path / "destination.cbz" + destination.write_bytes(b"comic") + signature = { + "content_digest": "digest", + "content_digest_algorithm": "sha256", + "size_bytes": 5, + } + candidate = _action( + 12, + placement_completed=True, + issue_id=7, + transfer_method="copy", + original_source_path=source, + artifact_source_path=source, + destination_path=destination, + destination_signature=signature, + ) + monkeypatch.setattr(recovery, "_candidate_actions", AsyncMock(return_value=[candidate])) + monkeypatch.setattr( + recovery, + "build_managed_placement_signature", + lambda _path: (_ for _ in ()).throw(ConfigurationError("unreadable")), + ) + assert ( + await recovery.load_completed_import_placement_recovery( + AsyncMock(), + job_id=1, + imported_file_id=2, + issue_id=7, + source_path=source, + transfer_method="copy", + ) + is None + ) + + monkeypatch.setattr(recovery, "build_managed_placement_signature", lambda _path: signature) + session = AsyncMock() + session.scalar.return_value = None + result = await recovery.load_completed_import_placement_recovery( + session, + job_id=1, + imported_file_id=2, + issue_id=7, + source_path=source, + transfer_method="copy", + ) + assert result == recovery.CompletedImportPlacementRecovery( + action_id=12, + destination_path=destination, + destination_signature=signature, + ) + + session.scalar.return_value = 99 + assert ( + await recovery.load_completed_import_placement_recovery( + session, + job_id=1, + imported_file_id=2, + issue_id=7, + source_path=source, + transfer_method="copy", + ) + is None + ) + + +@pytest.mark.asyncio +async def test_direct_move_recovery_preserves_a_reappeared_source(monkeypatch, tmp_path) -> None: + source = tmp_path / "source.cbz" + destination = tmp_path / "destination.cbz" + source.write_bytes(b"source") + destination.write_bytes(b"destination") + candidate = _action( + 13, + placement_completed=True, + issue_id=7, + transfer_method="move", + original_source_path=source, + artifact_source_path=source, + destination_path=destination, + destination_signature={ + "content_digest": "digest", + "content_digest_algorithm": "sha256", + }, + ) + monkeypatch.setattr(recovery, "_candidate_actions", AsyncMock(return_value=[candidate])) + + assert ( + await recovery.load_completed_import_placement_recovery( + AsyncMock(), + job_id=1, + imported_file_id=2, + issue_id=7, + source_path=source, + transfer_method="move", + ) + is None + ) + + +def test_path_comparison_fails_closed_when_resolution_errors(monkeypatch, tmp_path) -> None: + path = tmp_path / "issue.cbz" + monkeypatch.setattr( + type(path), "resolve", lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError()) + ) + assert not recovery._same_unresolved_path(path, path) diff --git a/tests/unit/test_import_reconcile_helpers.py b/tests/unit/test_import_reconcile_helpers.py index b9b5235..262a6d1 100644 --- a/tests/unit/test_import_reconcile_helpers.py +++ b/tests/unit/test_import_reconcile_helpers.py @@ -2,6 +2,13 @@ from __future__ import annotations +from datetime import date +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from pullbox.core.exceptions import NotFoundError, ValidationError from pullbox.models.import_job import ImportedFile, ImportedFileStatus, ImportedSeries from pullbox.models.issue import Issue, IssueType from pullbox.schemas.import_job import ImportReconcileDecision @@ -136,3 +143,228 @@ def test_apply_reconcile_decisions_creates_provisional_target() -> None: assert imp_file.diagnostics["kind"] == PROVIDER_MISSING_ISSUE_PLACEHOLDER_KIND assert imp_file.diagnostics["target_issue_number"] == 4.0 assert imp_file.diagnostics["target_issue_type"] == IssueType.ISSUE.value + + +@pytest.mark.asyncio +async def test_reconcile_options_load_local_and_provider_issues() -> None: + from pullbox.services.import_reconcile_helpers import issue_options_for_reconcile_series + + undetailed = Issue(id=1, series_id=99, issue_number=1.0, comicvine_id=None) + local = Issue( + id=2, + series_id=99, + issue_number=2.0, + comicvine_id=200, + release_date=date(2026, 1, 2), + issue_type=IssueType.ANNUAL, + ) + result = SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: [undetailed, local])) + session = AsyncMock() + session.execute.return_value = result + item = _series() + item.series_id = 99 + + options, local_by_id = await issue_options_for_reconcile_series(session, item, AsyncMock()) + assert options[0]["release_date"] == "2026-01-02" + assert options[0]["issue_type"] == "annual" + assert local_by_id == {200: local} + + item.series_id = None + metadata = AsyncMock() + metadata.get_issue_summaries_for_series.return_value = [ + SimpleNamespace(provider_id="", issue_number=1.0), + SimpleNamespace( + provider_id="300", + issue_number=3.0, + title="Three", + release_date="2026-01-03", + cover_url=None, + issue_type="issue", + ), + ] + options, local_by_id = await issue_options_for_reconcile_series(session, item, metadata) + assert [option["issue_cv_id"] for option in options] == [300] + assert local_by_id == {} + + +@pytest.mark.asyncio +async def test_reconcile_options_require_a_selected_series() -> None: + from pullbox.services.import_reconcile_helpers import issue_options_for_reconcile_series + + item = ImportedSeries(import_job_id=1, raw_series_name="Unknown") + with pytest.raises(ValidationError, match="Choose a ComicVine match"): + await issue_options_for_reconcile_series(AsyncMock(), item, AsyncMock()) + + +def test_reconcile_issue_hint_and_type_parsing_fail_closed() -> None: + from pullbox.services.import_reconcile_helpers import ( + archive_entry_issue_number, + coerce_issue_type, + provisional_issue_type_for_file, + ) + + imp_file = _file() + imp_file.diagnostics = { + "archive_entry_issue_hint": {"confidence": "strong", "issue_number": "bad"}, + "source_issue_type": "unknown", + "source_metadata": {"filename_parse": {"issue_type": "annual"}}, + } + assert archive_entry_issue_number(imp_file) is None + assert coerce_issue_type("unknown") is None + assert coerce_issue_type(None) is None + assert provisional_issue_type_for_file(imp_file) == IssueType.ANNUAL + + +def test_provisional_issue_number_requires_unresolved_identified_non_conflict_file() -> None: + from pullbox.services.import_reconcile_helpers import provisional_issue_number_for_file + + item = _series() + assert ( + provisional_issue_number_for_file(item, _file(status=ImportedFileStatus.MATCHED), []) + is None + ) + + unknown = ImportedSeries(import_job_id=4, raw_series_name="Unknown") + assert provisional_issue_number_for_file(unknown, _file(), []) is None + + conflict = _file() + conflict.diagnostics = {"conflict_type": "identity"} + assert provisional_issue_number_for_file(item, conflict, []) is None + + +def test_provisional_issue_number_uses_requested_and_filename_fallbacks() -> None: + from pullbox.services.import_reconcile_helpers import provisional_issue_number_for_file + + item = _series() + requested = _file() + requested.parsed_issue_number = None + requested.diagnostics = {"requested_issue_number": "4"} + assert provisional_issue_number_for_file(item, requested, [{"broken": True}]) == 4.0 + assert provisional_issue_number_for_file(item, requested, [{"issue_number": "4"}]) is None + + invalid = _file() + invalid.file_name = "King Dracula 05.cbz" + invalid.parsed_issue_number = None + invalid.diagnostics = {"requested_issue_number": "bad"} + assert provisional_issue_number_for_file(item, invalid, []) == 5.0 + + +def test_filename_issue_number_requires_parseable_matching_series() -> None: + from pullbox.services.import_reconcile_helpers import ( + filename_issue_number_for_selected_series, + ) + + item = _series() + no_number = _file() + no_number.file_name = "notes.txt" + assert filename_issue_number_for_selected_series(item, no_number) is None + + item.cv_title = None + item.raw_series_name = "" + assert filename_issue_number_for_selected_series(item, _file()) is None + + item.raw_series_name = "Different" + assert filename_issue_number_for_selected_series(item, _file()) is None + + +def test_apply_reconcile_decisions_rejects_stale_and_invalid_actions() -> None: + from pullbox.services.import_reconcile_helpers import apply_reconcile_decisions + + item = _series() + with pytest.raises(NotFoundError): + apply_reconcile_decisions( + item=item, + files=[_file()], + decisions=[ImportReconcileDecision(imported_file_id=999, action="skip")], + issue_options=[], + local_issue_by_cv_id={}, + provisional_issue_number_for_file=lambda *_args: None, + provisional_issue_type_for_file=lambda _file: IssueType.ISSUE, + ) + + locked = _file(status=ImportedFileStatus.MATCHED) + apply_reconcile_decisions( + item=item, + files=[locked], + decisions=[ImportReconcileDecision(imported_file_id=22, action="skip")], + issue_options=[], + local_issue_by_cv_id={}, + provisional_issue_number_for_file=lambda *_args: None, + provisional_issue_type_for_file=lambda _file: IssueType.ISSUE, + ) + assert locked.status == ImportedFileStatus.MATCHED + + for decision, message, provisional in [ + ( + ImportReconcileDecision(imported_file_id=22, action="provisional"), + "can only be created", + None, + ), + ( + ImportReconcileDecision( + imported_file_id=22, + action="provisional", + provisional_issue_number=6.0, + ), + "does not match", + 5.0, + ), + ( + ImportReconcileDecision(imported_file_id=22, action="assign"), + "require an issue", + None, + ), + ]: + with pytest.raises(ValidationError, match=message): + apply_reconcile_decisions( + item=item, + files=[_file()], + decisions=[decision], + issue_options=[], + local_issue_by_cv_id={}, + provisional_issue_number_for_file=lambda *_args, value=provisional: value, + provisional_issue_type_for_file=lambda _file: IssueType.ISSUE, + ) + + +def test_reconcile_rows_suggest_provider_archive_and_filename_matches() -> None: + from pullbox.services.import_reconcile_helpers import build_reconcile_file_rows + + item = _series() + provider = _file() + provider.comicvine_issue_id = 101 + archive = ImportedFile( + id=23, + import_job_id=4, + import_series_id=11, + file_path="/imports/King Dracula 05.cbz", + file_name="King Dracula 05.cbz", + status=ImportedFileStatus.NO_MATCH, + diagnostics={ + "archive_entry_issue_hint": { + "confidence": "strong", + "issue_number": 5, + } + }, + ) + parsed = ImportedFile( + id=24, + import_job_id=4, + import_series_id=11, + file_path="/imports/King Dracula 06.cbz", + file_name="King Dracula 06.cbz", + status=ImportedFileStatus.MATCHED, + parsed_issue_number=6.0, + ) + options = [ + {"issue_cv_id": 101, "issue_number": 4.0, "title": "Four"}, + {"issue_cv_id": 102, "issue_number": 5.0, "title": "Five"}, + {"issue_cv_id": 103, "issue_number": 6.0, "title": None}, + ] + + rows, remaining, completed = build_reconcile_file_rows( + item, [provider, archive, parsed], options + ) + assert [row["suggested_issue_cv_id"] for row in rows] == [101, 102, 103] + assert remaining == 2 + assert completed == 1 diff --git a/tests/unit/test_import_review_actions.py b/tests/unit/test_import_review_actions.py index da87dd4..5bc4f5b 100644 --- a/tests/unit/test_import_review_actions.py +++ b/tests/unit/test_import_review_actions.py @@ -18,6 +18,12 @@ ImportSourceType, ) from pullbox.models.series import Series +from pullbox.models.story_arc import ( + ImportedStoryArcStatus, + StoryArcResolutionState, + StoryArcSourceKind, +) +from pullbox.models.story_arc_import import ImportedStoryArc, ImportedStoryArcEntry from pullbox.services.import_review_actions import ( allow_safety_blocked_file_once, bulk_update_file_selection, @@ -673,6 +679,51 @@ async def test_allow_safety_blocked_file_once_keeps_file_in_safety_review_until_ recompute_series.assert_awaited_once_with(db_session, job) +async def test_allow_safety_blocked_file_once_recovers_skipped_series_target( + db_session: AsyncSession, +) -> None: + service = ImportService( + series_service=AsyncMock(), + metadata_service=AsyncMock(), + event_bus=AsyncMock(), + ) + job = await _create_job_row(db_session) + imported = await _create_imported_series( + db_session, + job, + status=ImportSeriesStatus.SKIPPED, + ) + imported.cv_id = 163486 + oversized = _make_file( + job, + imported, + name="East of West - The End Times Compendium.cbz", + status=ImportedFileStatus.SAFETY_BLOCKED, + include_in_import=False, + ) + oversized.diagnostics = { + "safety_block": { + "kind": "archive_decompressed_size", + "category": "decompression_size_limit", + "code": "archive_decompressed_size_limit", + "reason": "The archive exceeds Pullbox's configured decompressed-size limit.", + "overrideable": True, + } + } + db_session.add(oversized) + await db_session.flush() + + updated_series = await service.allow_safety_blocked_file_once( + db_session, + job.id, + oversized.id, + ) + + assert updated_series.status == ImportSeriesStatus.MATCHED + assert updated_series.diagnostics["rematch_pending"] is True + assert oversized.status == ImportedFileStatus.SAFETY_APPROVED + + async def test_allow_safety_blocked_file_once_rejects_non_overrideable_blocks( db_session: AsyncSession, ) -> None: @@ -840,3 +891,121 @@ async def test_skip_safety_blocked_file_marks_file_skipped_and_unselects_series( assert updated_file.include_in_import is False assert imported.selected_for_import is False assert updated_file.diagnostics["resolution"] == "skipped" + + +async def test_skip_safety_file_keeps_series_match_while_another_safety_item_remains( + db_session: AsyncSession, +) -> None: + service = ImportService( + series_service=AsyncMock(), + metadata_service=AsyncMock(), + event_bus=AsyncMock(), + ) + job = await _create_job_row(db_session) + imported = await _create_imported_series(db_session, job) + imported.cv_id = 163486 + missing_reference = _make_file( + job, + imported, + name="removed-by-mylar.cbz", + status=ImportedFileStatus.SAFETY_BLOCKED, + include_in_import=False, + ) + missing_reference.diagnostics = { + "safety_block": { + "kind": "source_revalidation", + "category": "source_missing", + "code": "source_missing", + "reason": "The recorded file is missing.", + "overrideable": False, + } + } + oversized = _make_file( + job, + imported, + name="East of West - The End Times Compendium.cbz", + status=ImportedFileStatus.SAFETY_BLOCKED, + include_in_import=False, + ) + oversized.diagnostics = { + "safety_block": { + "kind": "archive_decompressed_size", + "category": "decompression_size_limit", + "code": "archive_decompressed_size_limit", + "reason": "The archive exceeds Pullbox's configured decompressed-size limit.", + "overrideable": True, + } + } + db_session.add_all([missing_reference, oversized]) + await db_session.flush() + + updated_series = await service.skip_safety_blocked_file( + db_session, + job.id, + missing_reference.id, + ) + + assert updated_series.status == ImportSeriesStatus.MATCHED + assert updated_series.diagnostics["safety_blocked_files"] == 1 + assert missing_reference.status == ImportedFileStatus.SKIPPED + assert oversized.status == ImportedFileStatus.SAFETY_BLOCKED + + +async def test_skip_safety_blocked_file_refreshes_linked_story_arc_state( + db_session: AsyncSession, +) -> None: + job = await _create_job_row(db_session) + imported = await _create_imported_series(db_session, job, selected_for_import=True) + imp_file = _make_file( + job, + imported, + name="variant-cover.cbz", + status=ImportedFileStatus.SAFETY_BLOCKED, + ) + imp_file.diagnostics = { + "safety_block": { + "category": "single_page_comic", + "code": "single_page_comic", + "overrideable": True, + } + } + db_session.add(imp_file) + await db_session.flush() + arc = ImportedStoryArc( + import_job_id=job.id, + source_kind=StoryArcSourceKind.FOLDER, + source_key="folder:daredevil", + source_ordinal=1, + name="Daredevil", + status=ImportedStoryArcStatus.NEEDS_REVIEW, + diagnostics={"safety_incomplete": True}, + ) + db_session.add(arc) + await db_session.flush() + entry = ImportedStoryArcEntry( + imported_story_arc_id=arc.id, + import_file_id=imp_file.id, + source_ordinal=1, + source_kind=StoryArcSourceKind.FOLDER, + resolution_state=StoryArcResolutionState.AMBIGUOUS, + diagnostics={ + "safety_code": "single_page_comic", + "review_reason": "source_file_safety_blocked", + }, + ) + db_session.add(entry) + await db_session.flush() + + await skip_safety_blocked_file( + db_session, + job.id, + imp_file.id, + recompute_file_counters=AsyncMock(), + recompute_series_counters=AsyncMock(), + ) + + assert entry.resolution_state == StoryArcResolutionState.SKIPPED + assert entry.selected_for_import is False + assert "safety_code" not in entry.diagnostics + assert arc.diagnostics["safety_incomplete"] is False + assert arc.diagnostics["scan_safety_incomplete"] is True diff --git a/tests/unit/test_import_review_table_edges.py b/tests/unit/test_import_review_table_edges.py new file mode 100644 index 0000000..a41f294 --- /dev/null +++ b/tests/unit/test_import_review_table_edges.py @@ -0,0 +1,222 @@ +"""Boundary coverage for the Step 3 import review table helpers.""" + +from __future__ import annotations + +import pytest + +from pullbox.models.import_job import ( + ImportedFile, + ImportedFileStatus, + ImportedSeries, + ImportSeriesStatus, +) +from pullbox.models.issue import Issue +from pullbox.ui.import_review_tables import ( + _build_import_review_file_detail_row, + _comicvine_issue_url, + _format_import_review_issue_number, + _get_import_review_file_order_by, + _get_import_review_series_order_by, + _import_review_duplicate_reason_label, + _import_review_file_group_key, + _import_review_matched_issue_label, + _load_import_review_file_detail_groups, + _load_import_review_matched_file_targets, + _normalize_import_review_file_sort, + _normalize_import_review_series_sort, +) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (None, "confidence"), + ("", "confidence"), + ("bogus", "confidence"), + ("-year", "-year"), + ("status", "status"), + ], +) +def test_normalize_import_review_series_sort(value: str | None, expected: str) -> None: + assert _normalize_import_review_series_sort(value) == expected + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (None, "series"), + ("", "series"), + ("bogus", "series"), + ("-matched_issue", "-matched_issue"), + ("confidence", "confidence"), + ], +) +def test_normalize_import_review_file_sort(value: str | None, expected: str) -> None: + assert _normalize_import_review_file_sort(value) == expected + + +@pytest.mark.parametrize( + "sort", + [ + "found_series", + "year", + "files", + "cv_match", + "cv_year", + "cv_id", + "confidence", + "status", + "-status", + "invalid", + ], +) +def test_series_ordering_builds_stable_sql(sort: str) -> None: + clauses = _get_import_review_series_order_by(sort) + + assert clauses + assert "import_series.id DESC" in str(clauses[-1]) + + +@pytest.mark.parametrize( + "sort", + [ + "file_name", + "series", + "parsed_issue", + "matched_issue", + "confidence", + "status", + "-status", + "invalid", + ], +) +def test_file_ordering_builds_stable_sql(sort: str) -> None: + clauses = _get_import_review_file_order_by(sort) + + assert clauses + assert "import_files.id DESC" in str(clauses[-1]) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (None, None), + (True, None), + (1, "1"), + (1.5, "1.5"), + ("2", "2"), + ("Annual", "Annual"), + (" ", None), + ({"number": 3}, "{'number': 3}"), + ], +) +def test_format_import_review_issue_number(value: object, expected: str | None) -> None: + assert _format_import_review_issue_number(value) == expected + + +def test_comicvine_issue_url_uses_issue_identifier() -> None: + assert _comicvine_issue_url(42).endswith("/4000-42/") + + +def test_matched_issue_label_handles_title_number_and_missing_targets() -> None: + imp_file = ImportedFile( + import_job_id=1, + import_series_id=1, + file_path="/source/Issue.cbz", + file_name="Issue.cbz", + file_format="cbz", + matched_issue_id=7, + ) + titled = Issue(series_id=1, issue_number=4, title="The Test") + numbered = Issue(series_id=1, issue_number=5) + title_only = Issue(series_id=1, issue_number=6, title="Finale") + title_only.__dict__["issue_number"] = True + + assert _import_review_matched_issue_label(imp_file, {7: titled}) == "#4 The Test" + assert _import_review_matched_issue_label(imp_file, {7: numbered}) == "#5" + assert _import_review_matched_issue_label(imp_file, {7: title_only}) == "Finale" + assert _import_review_matched_issue_label(imp_file, {}) is None + imp_file.matched_issue_id = None + assert _import_review_matched_issue_label(imp_file, {7: title_only}) is None + + +@pytest.mark.parametrize( + ("reason", "fragment"), + [ + ("exact_duplicate", "another incoming file"), + ("hash_confirmed_duplicate", "confirmed by hash"), + ("already_owned_duplicate", "owned issue"), + ("informational_duplicate", "non-importable"), + ("unknown", "another incoming file"), + (None, "another incoming file"), + ], +) +def test_duplicate_reason_labels(reason: str | None, fragment: str) -> None: + assert fragment in _import_review_duplicate_reason_label(reason) + + +@pytest.mark.parametrize( + ("file_status", "series_status", "expected"), + [ + (ImportedFileStatus.MATCHED, ImportSeriesStatus.MATCHED, "matched"), + (ImportedFileStatus.CONFIRMED, ImportSeriesStatus.MATCHED, "matched"), + (ImportedFileStatus.CONFLICT, ImportSeriesStatus.MATCHED, "conflict"), + (ImportedFileStatus.ALREADY_OWNED, ImportSeriesStatus.MATCHED, "already_owned"), + (ImportedFileStatus.DUPLICATE_FILE, ImportSeriesStatus.MATCHED, "duplicate_file"), + (ImportedFileStatus.NO_MATCH, ImportSeriesStatus.MATCHED, "no_match"), + (ImportedFileStatus.PENDING, ImportSeriesStatus.NO_MATCH, "no_match"), + (ImportedFileStatus.PENDING, ImportSeriesStatus.MATCHED, None), + ], +) +def test_file_group_key( + file_status: ImportedFileStatus, + series_status: ImportSeriesStatus, + expected: str | None, +) -> None: + imp_file = ImportedFile( + import_job_id=1, + import_series_id=1, + file_path="/source/Issue.cbz", + file_name="Issue.cbz", + file_format="cbz", + status=file_status, + ) + imported_series = ImportedSeries( + import_job_id=1, + raw_series_name="Series", + status=series_status, + ) + + assert _import_review_file_group_key(imp_file, imported_series) == expected + + +def test_build_file_detail_row_shapes_duplicate_diagnostics() -> None: + imp_file = ImportedFile( + id=11, + import_job_id=1, + import_series_id=1, + file_path="/source/Issue.cbz", + file_name="Issue.cbz", + file_size=123, + file_format="cbz", + has_comicinfo=True, + status=ImportedFileStatus.DUPLICATE_FILE, + match_confidence="high", + diagnostics={ + "duplicate_reason": "hash_confirmed_duplicate", + "representative_file_name": "Issue-alt.cbz", + }, + ) + + row = _build_import_review_file_detail_row(imp_file, {}) + + assert row["id"] == 11 + assert row["status"] == "duplicate_file" + assert row["duplicate_keep_label"] == "Issue-alt.cbz" + assert "confirmed by hash" in str(row["duplicate_reason_label"]) + + +@pytest.mark.asyncio +async def test_review_target_loaders_return_empty_without_visible_series(db_session) -> None: # type: ignore[no-untyped-def] + assert await _load_import_review_matched_file_targets(db_session, 1, []) == {} + assert await _load_import_review_file_detail_groups(db_session, 1, []) == {} diff --git a/tests/unit/test_import_safety_bulk_review.py b/tests/unit/test_import_safety_bulk_review.py index 76629f1..c530041 100644 --- a/tests/unit/test_import_safety_bulk_review.py +++ b/tests/unit/test_import_safety_bulk_review.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import UTC, datetime +from pathlib import Path from typing import TYPE_CHECKING import pytest @@ -21,6 +22,12 @@ ImportSeriesStatus, ImportSourceType, ) +from pullbox.models.story_arc import ( + ImportedStoryArcStatus, + StoryArcResolutionState, + StoryArcSourceKind, +) +from pullbox.models.story_arc_import import ImportedStoryArc, ImportedStoryArcEntry from pullbox.models.user import User from pullbox.services.import_safety_bulk_review import ( ImportSafetyBulkInterruptedError, @@ -82,9 +89,168 @@ async def _seed_size_blocks(session: AsyncSession, *, count: int) -> int: return int(job.id) +async def _seed_one_page_blocks( + session: AsyncSession, + source_root: Path, + *, + count: int, +) -> int: + if await session.get(User, 42) is None: + session.add( + User( + id=42, + username="bulk-operator", + password_hash="not-used-in-service-tests", + ) + ) + job = ImportJob( + source_path=str(source_root), + source_type=ImportSourceType.MYLAR3, + status=ImportJobStatus.REVIEW, + ) + session.add(job) + await session.flush() + series = ImportedSeries( + import_job_id=job.id, + raw_series_name="Needs visual review", + status=ImportSeriesStatus.MATCHED, + selected_for_import=True, + ) + session.add(series) + await session.flush() + for index in range(count): + path = source_root / f"possible-cover-{index}.cbz" + path.write_bytes(b"source stays untouched") + block = build_import_safety_diagnostics( + ImportSafetyCategory.SINGLE_PAGE_COMIC.value, + code=ImportSafetyCategory.SINGLE_PAGE_COMIC.value, + overrideable_hint=True, + ) + session.add( + ImportedFile( + import_job_id=job.id, + import_series_id=series.id, + file_path=str(path), + file_name=path.name, + file_size=path.stat().st_size, + file_format="cbz", + status=ImportedFileStatus.SAFETY_BLOCKED, + include_in_import=True, + diagnostics={"safety_block": block}, + ) + ) + await session.commit() + return int(job.id) + + +async def _link_job_files_to_story_arc( + session: AsyncSession, + job_id: int, +) -> tuple[ImportedStoryArc, list[ImportedStoryArcEntry]]: + files = list( + ( + await session.scalars( + select(ImportedFile) + .where(ImportedFile.import_job_id == job_id) + .order_by(ImportedFile.id) + ) + ).all() + ) + arc = ImportedStoryArc( + import_job_id=job_id, + source_kind=StoryArcSourceKind.FOLDER, + source_key=f"folder:bulk:{job_id}", + source_ordinal=1, + name="Bulk safety arc", + status=ImportedStoryArcStatus.NEEDS_REVIEW, + diagnostics={"safety_incomplete": True}, + ) + session.add(arc) + await session.flush() + entries = [ + ImportedStoryArcEntry( + imported_story_arc_id=arc.id, + import_file_id=item.id, + source_ordinal=ordinal, + source_kind=StoryArcSourceKind.FOLDER, + resolution_state=StoryArcResolutionState.AMBIGUOUS, + diagnostics={ + "safety_code": "single_page_comic", + "review_reason": "source_file_safety_blocked", + }, + ) + for ordinal, item in enumerate(files, start=1) + ] + session.add_all(entries) + await session.commit() + return arc, entries + + +@pytest.mark.asyncio +async def test_skip_one_page_archives_uses_preview_and_preserves_sources( + db_session: AsyncSession, + tmp_path: Path, +) -> None: + job_id = await _seed_one_page_blocks(db_session, tmp_path, count=3) + arc, _entries = await _link_job_files_to_story_arc(db_session, job_id) + category = ImportSafetyCategory.SINGLE_PAGE_COMIC + + preview = await bulk_review_service.preview_import_safety_category_skip( + db_session, + job_id, + category, + actor_id=42, + ) + assert preview.affected_count == 3 + assert preview.preview_token is not None + + result = await bulk_review_service.skip_import_safety_category( + db_session, + job_id, + category, + actor_id=42, + preview_token=preview.preview_token, + page_size=2, + ) + + assert result.affected_count == 3 + assert result.pages_processed == 2 + items = list( + ( + await db_session.execute( + select(ImportedFile) + .where(ImportedFile.import_job_id == job_id) + .order_by(ImportedFile.id) + ) + ) + .scalars() + .all() + ) + assert all(item.status == ImportedFileStatus.SKIPPED for item in items) + assert all(item.include_in_import is False for item in items) + assert all(Path(item.file_path).read_bytes() == b"source stays untouched" for item in items) + refreshed_entries = list( + ( + await db_session.scalars( + select(ImportedStoryArcEntry).where( + ImportedStoryArcEntry.imported_story_arc_id == arc.id + ) + ) + ).all() + ) + refreshed_arc = await db_session.get(ImportedStoryArc, arc.id) + assert all( + entry.resolution_state == StoryArcResolutionState.SKIPPED for entry in refreshed_entries + ) + assert all("safety_code" not in entry.diagnostics for entry in refreshed_entries) + assert refreshed_arc is not None + assert refreshed_arc.diagnostics["safety_incomplete"] is False + + @pytest.mark.asyncio async def test_allow_once_processes_only_bounded_pages(db_session: AsyncSession) -> None: job_id = await _seed_size_blocks(db_session, count=5) + arc, _entries = await _link_job_files_to_story_arc(db_session, job_id) category = ImportSafetyCategory.DECOMPRESSION_SIZE_LIMIT preview = await preview_import_safety_category( db_session, @@ -115,6 +281,22 @@ async def test_allow_once_processes_only_bounded_pages(db_session: AsyncSession) ) ).scalar_one() assert approved == 5 + refreshed_entries = list( + ( + await db_session.scalars( + select(ImportedStoryArcEntry).where( + ImportedStoryArcEntry.imported_story_arc_id == arc.id + ) + ) + ).all() + ) + refreshed_arc = await db_session.get(ImportedStoryArc, arc.id) + assert all( + entry.resolution_state == StoryArcResolutionState.PENDING for entry in refreshed_entries + ) + assert all("safety_code" not in entry.diagnostics for entry in refreshed_entries) + assert refreshed_arc is not None + assert refreshed_arc.diagnostics["safety_incomplete"] is False @pytest.mark.asyncio diff --git a/tests/unit/test_import_safety_diagnostics.py b/tests/unit/test_import_safety_diagnostics.py index 1672d0f..48b0e67 100644 --- a/tests/unit/test_import_safety_diagnostics.py +++ b/tests/unit/test_import_safety_diagnostics.py @@ -179,6 +179,34 @@ def test_content_review_is_distinct_and_cannot_grant_resource_exceptions(code, o ) +def test_one_page_archive_copy_does_not_claim_the_file_is_a_cover() -> None: + block = build_import_safety_diagnostics( + "single_page_comic", + code="single_page_comic", + kind="single_page_comic", + ) + + assert block["reason"] == ( + "The archive contains one image page. It may be cover art, a damaged archive, " + "or an intentional one-page comic. Review it before importing." + ) + + +def test_missing_source_copy_only_offers_available_recovery_actions() -> None: + block = build_import_safety_diagnostics( + "source_missing", + code="source_missing", + kind="source_revalidation", + ) + + assert block["reason"] == ( + "No file was found at the recorded path, and Pullbox could not find a safe, verified " + "replacement. The saved filename may be outdated, or the file may have been moved or " + "removed. Verify the source location or skip this missing reference." + ) + assert "reconcile" not in block["reason"].casefold() + + def test_build_import_safety_diagnostics_drops_raw_paths_and_preserves_review_contract() -> None: diagnostics = build_import_safety_diagnostics( "Archive could not be inspected: /mnt/user/private/secret.cbz", diff --git a/tests/unit/test_import_safety_source_cleanup.py b/tests/unit/test_import_safety_source_cleanup.py new file mode 100644 index 0000000..20d1b80 --- /dev/null +++ b/tests/unit/test_import_safety_source_cleanup.py @@ -0,0 +1,500 @@ +"""Guardrails for explicitly removing one reviewed source artifact.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from itsdangerous import SignatureExpired +from sqlalchemy import select + +from pullbox.core.exceptions import NotFoundError, ValidationError +from pullbox.core.library_file_ownership import build_file_identity_signature +from pullbox.models.config import SystemConfig +from pullbox.models.import_job import ( + ImportedFile, + ImportedFileStatus, + ImportedSeries, + ImportJob, + ImportJobStatus, + ImportSeriesStatus, + ImportSourceType, +) +from pullbox.models.library import LibraryRoot +from pullbox.models.story_arc import ( + ImportedStoryArcStatus, + StoryArcResolutionState, + StoryArcSourceKind, +) +from pullbox.models.story_arc_import import ImportedStoryArc, ImportedStoryArcEntry +from pullbox.models.user import User +from pullbox.services.import_safety_source_cleanup import ( + _load_token, + _serializer, + move_one_page_source_to_trash, + preview_one_page_source_cleanup, +) + +if TYPE_CHECKING: + from pathlib import Path + + +async def _seed_cleanup_case( + db_session, + tmp_path: Path, + *, + allow_managed_writes: bool, + source_type: ImportSourceType = ImportSourceType.MYLAR3, + series_status: ImportSeriesStatus = ImportSeriesStatus.MATCHED, + job_status: ImportJobStatus = ImportJobStatus.REVIEW, +) -> tuple[int, int, Path, Path]: + source_root = tmp_path / "mylar" + source_root.mkdir() + source = source_root / "possible-cover.cbz" + source.write_bytes(b"one-page source") + trash = tmp_path / "trash" + db_session.add_all( + [ + User(id=42, username="operator", password_hash="unused"), + LibraryRoot( + name="Mylar source", + path=str(source_root), + enabled=True, + allow_referenced_registrations=True, + allow_managed_writes=allow_managed_writes, + is_default_managed_destination=False, + ), + SystemConfig(key="utility_trash_folder", value=str(trash), value_type="string"), + ] + ) + job = ImportJob( + source_path=str( + source_root if source_type is ImportSourceType.FILESYSTEM else tmp_path / "mylar.db" + ), + source_type=source_type, + status=job_status, + ) + db_session.add(job) + await db_session.flush() + series = ImportedSeries( + import_job_id=job.id, + raw_series_name="Possible cover", + status=series_status, + selected_for_import=True, + ) + db_session.add(series) + await db_session.flush() + imported_file = ImportedFile( + import_job_id=job.id, + import_series_id=series.id, + file_path=str(source), + file_name=source.name, + file_size=source.stat().st_size, + file_format="cbz", + status=ImportedFileStatus.SAFETY_BLOCKED, + include_in_import=True, + source_signature=build_file_identity_signature(source), + diagnostics={ + "safety_block": { + "category": "single_page_comic", + "code": "single_page_comic", + "overrideable": True, + } + }, + ) + db_session.add(imported_file) + await db_session.commit() + return job.id, imported_file.id, source, trash + + +@pytest.mark.asyncio +async def test_completed_import_can_preview_source_cleanup_as_follow_up( + db_session, + tmp_path: Path, +) -> None: + job_id, file_id, source, _trash = await _seed_cleanup_case( + db_session, + tmp_path, + allow_managed_writes=True, + source_type=ImportSourceType.FILESYSTEM, + job_status=ImportJobStatus.COMPLETED, + ) + + preview = await preview_one_page_source_cleanup(db_session, job_id, file_id, actor_id=42) + + assert preview.can_move_to_trash is True + assert preview.preview_token is not None + assert source.exists() + + +@pytest.mark.asyncio +async def test_reference_only_mylar_root_cannot_remove_source(db_session, tmp_path: Path) -> None: + job_id, file_id, source, _trash = await _seed_cleanup_case( + db_session, + tmp_path, + allow_managed_writes=False, + ) + + preview = await preview_one_page_source_cleanup(db_session, job_id, file_id, actor_id=42) + + assert preview.can_move_to_trash is False + assert preview.preview_token is None + assert "reference-only" in preview.unavailable_reason + assert source.exists() + + +@pytest.mark.asyncio +async def test_explicit_one_page_cleanup_moves_only_reviewed_source_to_trash( + db_session, + tmp_path: Path, +) -> None: + job_id, file_id, source, trash = await _seed_cleanup_case( + db_session, + tmp_path, + allow_managed_writes=True, + ) + preview = await preview_one_page_source_cleanup(db_session, job_id, file_id, actor_id=42) + assert preview.can_move_to_trash is True + assert preview.preview_token is not None + arc = ImportedStoryArc( + import_job_id=job_id, + source_kind=StoryArcSourceKind.FOLDER, + source_key=f"folder:cleanup:{job_id}", + source_ordinal=1, + name="Cleanup arc", + status=ImportedStoryArcStatus.NEEDS_REVIEW, + diagnostics={"safety_incomplete": True}, + ) + db_session.add(arc) + await db_session.flush() + entry = ImportedStoryArcEntry( + imported_story_arc_id=arc.id, + import_file_id=file_id, + source_ordinal=1, + source_kind=StoryArcSourceKind.FOLDER, + resolution_state=StoryArcResolutionState.AMBIGUOUS, + diagnostics={ + "safety_code": "single_page_comic", + "review_reason": "source_file_safety_blocked", + }, + ) + db_session.add(entry) + await db_session.commit() + + result = await move_one_page_source_to_trash( + db_session, + job_id, + file_id, + actor_id=42, + preview_token=preview.preview_token, + ) + + assert source.exists() is False + assert result.trash_path.is_file() + assert result.trash_path.is_relative_to(trash) + imported_file = await db_session.get(ImportedFile, file_id) + assert imported_file is not None + assert imported_file.status is ImportedFileStatus.SKIPPED + assert imported_file.include_in_import is False + assert entry.resolution_state is StoryArcResolutionState.SKIPPED + assert "safety_code" not in entry.diagnostics + assert arc.diagnostics["safety_incomplete"] is False + + +@pytest.mark.asyncio +async def test_folder_import_can_move_reviewed_one_page_source_to_trash( + db_session, + tmp_path: Path, +) -> None: + job_id, file_id, source, trash = await _seed_cleanup_case( + db_session, + tmp_path, + allow_managed_writes=False, + source_type=ImportSourceType.FILESYSTEM, + ) + preview = await preview_one_page_source_cleanup(db_session, job_id, file_id, actor_id=42) + assert preview.can_move_to_trash is True + assert preview.preview_token is not None + + result = await move_one_page_source_to_trash( + db_session, + job_id, + file_id, + actor_id=42, + preview_token=preview.preview_token, + ) + + assert source.exists() is False + assert result.trash_path.is_relative_to(trash) + + +@pytest.mark.asyncio +async def test_cleanup_closes_single_file_series_that_no_longer_needs_matching( + db_session, + tmp_path: Path, +) -> None: + job_id, file_id, _source, _trash = await _seed_cleanup_case( + db_session, + tmp_path, + allow_managed_writes=True, + source_type=ImportSourceType.FILESYSTEM, + series_status=ImportSeriesStatus.NO_MATCH, + ) + preview = await preview_one_page_source_cleanup(db_session, job_id, file_id, actor_id=42) + assert preview.preview_token is not None + + await move_one_page_source_to_trash( + db_session, + job_id, + file_id, + actor_id=42, + preview_token=preview.preview_token, + ) + + imported_file = await db_session.get(ImportedFile, file_id) + assert imported_file is not None + imported_series = await db_session.get(ImportedSeries, imported_file.import_series_id) + assert imported_series is not None + assert imported_series.status is ImportSeriesStatus.SKIPPED + + +@pytest.mark.asyncio +async def test_cleanup_keeps_series_in_review_when_an_unresolved_file_remains( + db_session, + tmp_path: Path, +) -> None: + job_id, file_id, source, _trash = await _seed_cleanup_case( + db_session, + tmp_path, + allow_managed_writes=True, + source_type=ImportSourceType.FILESYSTEM, + series_status=ImportSeriesStatus.NO_MATCH, + ) + imported_file = await db_session.get(ImportedFile, file_id) + assert imported_file is not None + unresolved_source = source.with_name("Daredevil 008.cbz") + unresolved_source.write_bytes(b"comic pages") + db_session.add( + ImportedFile( + import_job_id=job_id, + import_series_id=imported_file.import_series_id, + file_path=str(unresolved_source), + file_name=unresolved_source.name, + file_size=unresolved_source.stat().st_size, + file_format="cbz", + status=ImportedFileStatus.NO_MATCH, + include_in_import=False, + ) + ) + await db_session.commit() + preview = await preview_one_page_source_cleanup(db_session, job_id, file_id, actor_id=42) + assert preview.preview_token is not None + + await move_one_page_source_to_trash( + db_session, + job_id, + file_id, + actor_id=42, + preview_token=preview.preview_token, + ) + + imported_series = await db_session.get(ImportedSeries, imported_file.import_series_id) + assert imported_series is not None + assert imported_series.status is ImportSeriesStatus.NO_MATCH + + +@pytest.mark.asyncio +async def test_source_is_restored_when_cleanup_database_commit_fails( + db_session, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + job_id, file_id, source, trash = await _seed_cleanup_case( + db_session, + tmp_path, + allow_managed_writes=True, + ) + preview = await preview_one_page_source_cleanup(db_session, job_id, file_id, actor_id=42) + assert preview.preview_token is not None + + async def fail_commit() -> None: + raise RuntimeError("simulated database failure") + + monkeypatch.setattr(db_session, "commit", fail_commit) + + with pytest.raises(RuntimeError, match="simulated database failure"): + await move_one_page_source_to_trash( + db_session, + job_id, + file_id, + actor_id=42, + preview_token=preview.preview_token, + ) + + assert source.read_bytes() == b"one-page source" + assert list(trash.rglob("possible-cover.cbz")) == [] + + +@pytest.mark.asyncio +async def test_cleanup_preview_rejects_unknown_and_inactive_jobs(db_session) -> None: # type: ignore[no-untyped-def] + with pytest.raises(NotFoundError): + await preview_one_page_source_cleanup(db_session, 999, 1, actor_id=42) + + job = ImportJob( + source_path="/tmp/import", + source_type=ImportSourceType.FILESYSTEM, + status=ImportJobStatus.SCANNING, + ) + db_session.add(job) + await db_session.commit() + + with pytest.raises(ValidationError, match="during review"): + await preview_one_page_source_cleanup(db_session, job.id, 1, actor_id=42) + + +@pytest.mark.asyncio +async def test_cleanup_preview_rejects_missing_or_ineligible_file( + db_session, + tmp_path: Path, +) -> None: # type: ignore[no-untyped-def] + job_id, file_id, _source, _trash = await _seed_cleanup_case( + db_session, + tmp_path, + allow_managed_writes=True, + ) + + with pytest.raises(NotFoundError): + await preview_one_page_source_cleanup(db_session, job_id, file_id + 99, actor_id=42) + + imported_file = await db_session.get(ImportedFile, file_id) + assert imported_file is not None + imported_file.status = ImportedFileStatus.MATCHED + await db_session.commit() + with pytest.raises(ValidationError, match="one-page archive"): + await preview_one_page_source_cleanup(db_session, job_id, file_id, actor_id=42) + + +@pytest.mark.asyncio +async def test_cleanup_preview_rejects_symlinked_or_missing_source( + db_session, + tmp_path: Path, +) -> None: # type: ignore[no-untyped-def] + job_id, file_id, source, _trash = await _seed_cleanup_case( + db_session, + tmp_path, + allow_managed_writes=True, + ) + target = source.with_name("target.cbz") + target.write_bytes(source.read_bytes()) + source.unlink() + source.symlink_to(target) + + with pytest.raises(ValidationError, match="Symlinked"): + await preview_one_page_source_cleanup(db_session, job_id, file_id, actor_id=42) + + source.unlink() + with pytest.raises(ValidationError, match="changed or is unavailable"): + await preview_one_page_source_cleanup(db_session, job_id, file_id, actor_id=42) + + +@pytest.mark.asyncio +async def test_cleanup_preview_reports_unwritable_folder_source( + db_session, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: # type: ignore[no-untyped-def] + job_id, file_id, _source, _trash = await _seed_cleanup_case( + db_session, + tmp_path, + allow_managed_writes=False, + source_type=ImportSourceType.FILESYSTEM, + ) + monkeypatch.setattr("pullbox.services.import_safety_source_cleanup.os.access", lambda *_: False) + + preview = await preview_one_page_source_cleanup(db_session, job_id, file_id, actor_id=42) + + assert preview.can_move_to_trash is False + assert "permission" in preview.unavailable_reason + + +def test_source_cleanup_tokens_reject_invalid_expired_and_non_mapping_payloads( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with pytest.raises(ValidationError, match="invalid"): + _load_token("not-a-token") + + non_mapping = str(_serializer().dumps(["unexpected"])) + with pytest.raises(ValidationError, match="invalid"): + _load_token(non_mapping) + + class ExpiredSerializer: + def loads(self, *_args: object, **_kwargs: object) -> object: + raise SignatureExpired("old") + + monkeypatch.setattr( + "pullbox.services.import_safety_source_cleanup._serializer", + ExpiredSerializer, + ) + with pytest.raises(ValidationError, match="expired"): + _load_token("expired") + + +@pytest.mark.asyncio +async def test_cleanup_move_rejects_token_for_another_actor_or_missing_signature( + db_session, + tmp_path: Path, +) -> None: # type: ignore[no-untyped-def] + job_id, file_id, _source, _trash = await _seed_cleanup_case( + db_session, + tmp_path, + allow_managed_writes=True, + ) + preview = await preview_one_page_source_cleanup(db_session, job_id, file_id, actor_id=42) + assert preview.preview_token is not None + + with pytest.raises(ValidationError, match="does not match"): + await move_one_page_source_to_trash( + db_session, + job_id, + file_id, + actor_id=7, + preview_token=preview.preview_token, + ) + + missing_signature = str( + _serializer().dumps({"job_id": job_id, "file_id": file_id, "actor_id": 42}) + ) + with pytest.raises(ValidationError, match="invalid"): + await move_one_page_source_to_trash( + db_session, + job_id, + file_id, + actor_id=42, + preview_token=missing_signature, + ) + + +@pytest.mark.asyncio +async def test_cleanup_move_rechecks_availability_after_preview( + db_session, + tmp_path: Path, +) -> None: # type: ignore[no-untyped-def] + job_id, file_id, _source, _trash = await _seed_cleanup_case( + db_session, + tmp_path, + allow_managed_writes=True, + ) + preview = await preview_one_page_source_cleanup(db_session, job_id, file_id, actor_id=42) + assert preview.preview_token is not None + root = (await db_session.scalars(select(LibraryRoot))).one() + root.allow_managed_writes = False + await db_session.commit() + + with pytest.raises(ValidationError, match="reference-only"): + await move_one_page_source_to_trash( + db_session, + job_id, + file_id, + actor_id=42, + preview_token=preview.preview_token, + ) diff --git a/tests/unit/test_import_scan_materialization.py b/tests/unit/test_import_scan_materialization.py index e29fd73..a76fab6 100644 --- a/tests/unit/test_import_scan_materialization.py +++ b/tests/unit/test_import_scan_materialization.py @@ -371,6 +371,58 @@ async def test_materialize_mixed_layout_keeps_only_non_fitting_file_in_review( } +async def test_materialize_mixed_mylar_folder_keeps_foreign_file_in_review( + db_session: AsyncSession, +) -> None: + job = await _create_job(db_session) + owned_file = _discovered_file(name="Fritzi Ritz 001.cbz") + owned_file.parsed_series = "Fritzi Ritz" + foreign_file = _discovered_file(name="Batman 001.cbz") + foreign_file.parsed_series = "Batman" + foreign_file.comicvine_series_id = None + foreign_file.comicvine_issue_id = None + foreign_file.metadata_diagnostics = { + "mylar3_folder_scope_conflict": { + "expected_series": "Fritzi Ritz", + "parsed_series": "Batman", + "recorded_issue": False, + } + } + discovered = DiscoveredSeries( + raw_series_name="Fritzi Ritz", + raw_year=1953, + raw_publisher=None, + file_count=2, + sample_paths=[owned_file.file_path, foreign_file.file_path], + source_folder="/tmp/comics/Fritzi Ritz (1953)", + source_folder_relative="Fritzi Ritz (1953)", + files=[owned_file, foreign_file], + has_files=True, + mylar3_cv_id=31895, + ) + + pairs = await materialize_discovered_scan_results(db_session, job, [discovered]) + + assert pairs[0][1].status == ImportSeriesStatus.PENDING + files = ( + ( + await db_session.execute( + select(ImportedFile) + .where(ImportedFile.import_job_id == job.id) + .order_by(ImportedFile.file_name) + ) + ) + .scalars() + .all() + ) + assert {row.file_name: row.status for row in files} == { + "Batman 001.cbz": ImportedFileStatus.NO_MATCH, + "Fritzi Ritz 001.cbz": ImportedFileStatus.PENDING, + } + assert files[0].diagnostics["reason"] == "mylar3_folder_scope_conflict" + assert "another series" in files[0].diagnostics["rejection_reason"] + + async def test_materialize_discovered_scan_results_uses_comicinfo_cv_when_no_mylar_id( db_session: AsyncSession, ) -> None: diff --git a/tests/unit/test_import_service_review_edges.py b/tests/unit/test_import_service_review_edges.py new file mode 100644 index 0000000..2e956b5 --- /dev/null +++ b/tests/unit/test_import_service_review_edges.py @@ -0,0 +1,135 @@ +"""Boundary tests for the ImportService Step 3 facade.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from pullbox.core.exceptions import NotFoundError +from pullbox.models.import_job import ImportSeriesStatus +from pullbox.services.import_service_review import ImportServiceReviewMixin + + +def _service() -> ImportServiceReviewMixin: + service = ImportServiceReviewMixin() + service._recompute_file_counters = AsyncMock() # type: ignore[attr-defined] + service._recompute_series_counters = AsyncMock() # type: ignore[attr-defined] + return service + + +@pytest.mark.asyncio +async def test_resolve_conflicts_drops_internal_file_mapping( + monkeypatch: pytest.MonkeyPatch, +) -> None: + resolver = AsyncMock(return_value=([1], [2], {1: [3]})) + monkeypatch.setattr("pullbox.services.import_service_review.resolve_import_conflicts", resolver) + service = _service() + + assert await service.resolve_conflicts(AsyncMock(), 10, [(1, 3)]) == ([1], [2]) + + +@pytest.mark.asyncio +async def test_allow_safety_file_requires_its_imported_series( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "pullbox.services.import_service_review.allow_import_safety_blocked_file_once", + AsyncMock(return_value=SimpleNamespace(import_series_id=7)), + ) + session = AsyncMock() + session.get.return_value = None + + with pytest.raises(NotFoundError, match="ImportedSeries"): + await _service().allow_safety_blocked_file_once(session, 1, 2) + + +@pytest.mark.asyncio +async def test_allow_safety_file_for_retry_returns_job_and_series( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "pullbox.services.import_service_review.allow_import_safety_blocked_file_once", + AsyncMock(return_value=SimpleNamespace(import_series_id=7)), + ) + job = SimpleNamespace(id=1) + series = SimpleNamespace(id=7) + session = AsyncMock() + session.get.side_effect = [job, series] + + result = await _service().allow_safety_blocked_file_once_for_retry(session, 1, 2) + + assert result == (job, series) + session.flush.assert_awaited_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("rows", [[None], [SimpleNamespace(id=1), None]]) +async def test_allow_safety_file_for_retry_rejects_missing_parent_rows( + monkeypatch: pytest.MonkeyPatch, + rows: list[object | None], +) -> None: + monkeypatch.setattr( + "pullbox.services.import_service_review.allow_import_safety_blocked_file_once", + AsyncMock(return_value=SimpleNamespace(import_series_id=7)), + ) + session = AsyncMock() + session.get.side_effect = rows + + with pytest.raises(NotFoundError): + await _service().allow_safety_blocked_file_once_for_retry(session, 1, 2) + + +@pytest.mark.asyncio +async def test_skip_safety_file_requires_series( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "pullbox.services.import_service_review.skip_import_safety_blocked_file", + AsyncMock(return_value=SimpleNamespace(import_series_id=7)), + ) + session = AsyncMock() + session.get.return_value = None + + with pytest.raises(NotFoundError, match="ImportedSeries"): + await _service().skip_safety_blocked_file(session, 1, 2) + + +@pytest.mark.asyncio +async def test_skip_last_safety_file_closes_series( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "pullbox.services.import_service_review.skip_import_safety_blocked_file", + AsyncMock(return_value=SimpleNamespace(import_series_id=7)), + ) + series = SimpleNamespace( + files_matched=0, + files_conflict=0, + files_no_match=0, + diagnostics={}, + status=ImportSeriesStatus.NO_MATCH, + selected_for_import=True, + ) + job = SimpleNamespace(id=1) + session = AsyncMock() + session.get.side_effect = [series, job] + service = _service() + + result = await service.skip_safety_blocked_file(session, 1, 2) + + assert result is series + assert series.status is ImportSeriesStatus.SKIPPED + assert series.selected_for_import is False + service._recompute_series_counters.assert_awaited_once_with(session, job) # type: ignore[attr-defined] + session.flush.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_review_selection_requires_job() -> None: + session = AsyncMock() + session.get.return_value = None + + with pytest.raises(NotFoundError, match="ImportJob"): + await _service().get_review_selection_state(session, 404) diff --git a/tests/unit/test_import_source_metadata.py b/tests/unit/test_import_source_metadata.py index 11c5065..5fecc92 100644 --- a/tests/unit/test_import_source_metadata.py +++ b/tests/unit/test_import_source_metadata.py @@ -689,6 +689,203 @@ async def test_load_deferred_source_metadata_for_import_file_reads_archive_metad assert metadata.diagnostics["archive_metadata_deferred"] is False +@pytest.mark.asyncio +async def test_deferred_comicinfo_reconciles_stale_mylar_issue_identity( + tmp_path: Path, +) -> None: + file_path = tmp_path / "Dark Nights - Death Metal Omnibus 01.cbz" + with zipfile.ZipFile(file_path, "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr("page001.jpg", b"page") + archive.writestr("page002.jpg", b"page") + archive.writestr( + "ComicInfo.xml", + ( + "" + "" + "Dark Nights: Death Metal Omnibus" + "1" + "HC" + "Trade Paper Back" + "[cv_vol_id:166912] [cv_issue_id:1132072]" + "" + ), + ) + + imp_series = ImportedSeries( + raw_series_name="Dark Nights - Death Metal Omnibus", + raw_year=2023, + cv_id=166912, + cv_match_method="mylar3_cv_id", + diagnostics={"source_issue_type": IssueType.OMNIBUS.value}, + ) + imp_file = ImportedFile( + file_path=str(file_path), + file_name=file_path.name, + parsed_series="Dark Nights - Death Metal Omnibus", + parsed_issue_number=1.0, + parsed_year=2023, + comicvine_issue_id=899000001, + diagnostics={ + "comicvine_series_id": 166912, + "source_issue_type": IssueType.OMNIBUS.value, + "metadata_signals": { + "comicvine_series_id": MetadataSignal.MYLAR3.value, + "comicvine_issue_id": MetadataSignal.MYLAR3.value, + "issue_number": MetadataSignal.MYLAR3.value, + }, + "source_metadata": { + "archive_metadata_loaded": False, + "archive_metadata_deferred": True, + "has_comicinfo": False, + }, + }, + ) + + metadata = await load_deferred_source_metadata_for_import_file(imp_series, imp_file) + + assert metadata.comicvine_issue_id == 1132072 + assert metadata.signals["comicvine_issue_id"] == MetadataSignal.COMICINFO + assert "identity_conflicts" not in metadata.diagnostics + assert metadata.diagnostics["mylar3_issue_identity_reconciliation"] == { + "recorded_comicvine_issue_id": 899000001, + "embedded_comicvine_issue_id": 1132072, + "comicvine_series_id": 166912, + "issue_number": 1.0, + "method": "corroborated_embedded_comicinfo", + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("embedded_series_id", "embedded_issue_number", "embedded_format"), + [ + (999999, "1", "Trade Paper Back"), + (166912, "2", "Trade Paper Back"), + (166912, "1", "Annual"), + ], +) +async def test_deferred_comicinfo_keeps_uncorroborated_mylar_issue_conflict( + tmp_path: Path, + embedded_series_id: int, + embedded_issue_number: str, + embedded_format: str, +) -> None: + file_path = tmp_path / "Dark Nights - Death Metal 01.cbz" + with zipfile.ZipFile(file_path, "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr("page001.jpg", b"page") + archive.writestr("page002.jpg", b"page") + archive.writestr( + "ComicInfo.xml", + ( + "" + "" + "Dark Nights: Death Metal Omnibus" + f"{embedded_issue_number}" + f"{embedded_format}" + f"[cv_vol_id:{embedded_series_id}] " + "[cv_issue_id:1132072]" + "" + ), + ) + + imp_series = ImportedSeries( + raw_series_name="Dark Nights - Death Metal Omnibus", + raw_year=2023, + cv_id=166912, + cv_match_method="mylar3_cv_id", + diagnostics={"source_issue_type": IssueType.OMNIBUS.value}, + ) + imp_file = ImportedFile( + file_path=str(file_path), + file_name=file_path.name, + parsed_series="Dark Nights - Death Metal Omnibus", + parsed_issue_number=1.0, + parsed_year=2023, + comicvine_issue_id=899000001, + diagnostics={ + "comicvine_series_id": 166912, + "source_issue_type": IssueType.OMNIBUS.value, + "metadata_signals": { + "comicvine_series_id": MetadataSignal.MYLAR3.value, + "comicvine_issue_id": MetadataSignal.MYLAR3.value, + "issue_number": MetadataSignal.MYLAR3.value, + }, + "source_metadata": { + "archive_metadata_loaded": False, + "archive_metadata_deferred": True, + "has_comicinfo": False, + }, + }, + ) + + metadata = await load_deferred_source_metadata_for_import_file(imp_series, imp_file) + + assert metadata.comicvine_issue_id == 899000001 + assert metadata.signals["comicvine_issue_id"] == MetadataSignal.MYLAR3 + assert "mylar3_issue_identity_reconciliation" not in metadata.diagnostics + assert any( + conflict.get("field") == "comicvine_issue_id" + for conflict in metadata.diagnostics["identity_conflicts"] + ) + + +@pytest.mark.asyncio +async def test_deferred_comicinfo_quarantines_unrecorded_foreign_mylar_file( + tmp_path: Path, +) -> None: + file_path = tmp_path / "unknown-001.cbz" + with zipfile.ZipFile(file_path, "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr( + "ComicInfo.xml", + ( + "" + "" + "Action Comics" + "969" + "2016" + "" + ), + ) + + imp_series = ImportedSeries(raw_series_name="Fritzi Ritz", raw_year=1953) + imp_file = ImportedFile( + file_path=str(file_path), + file_name=file_path.name, + parsed_series="Fritzi Ritz", + parsed_issue_number=1.0, + parsed_year=1953, + diagnostics={ + "metadata_signals": {"series_name": MetadataSignal.MYLAR3.value}, + "source_metadata": { + "archive_metadata_loaded": False, + "archive_metadata_deferred": True, + "has_comicinfo": False, + "mylar3_unrecorded_file": {"expected_series": "Fritzi Ritz"}, + }, + }, + ) + + metadata = await load_deferred_source_metadata_for_import_file(imp_series, imp_file) + conflict = build_import_metadata_conflict( + metadata=metadata, + target_series_title="Fritzi Ritz", + target_series_year=1953, + target_issue_number=1.0, + target_issue_cv_id=None, + target_issue_title=None, + ) + + assert metadata.diagnostics["mylar3_folder_scope_conflict"] == { + "expected_series": "Fritzi Ritz", + "parsed_series": "Action Comics", + "recorded_issue": False, + "signal": "comicinfo", + } + assert conflict is not None + assert conflict["kind"] == "source_scope_review" + assert conflict["preserve_series_match"] is True + + @pytest.mark.asyncio async def test_load_deferred_source_metadata_reuses_safety_evidence_without_reopening_archive( tmp_path: Path, diff --git a/tests/unit/test_import_story_arc_detection.py b/tests/unit/test_import_story_arc_detection.py index 1019cdd..14be38d 100644 --- a/tests/unit/test_import_story_arc_detection.py +++ b/tests/unit/test_import_story_arc_detection.py @@ -15,6 +15,7 @@ def _file( *, arc: str | None = None, order: str | None = None, + evidence_complete: bool = True, ) -> FolderArcFileEvidence: return FolderArcFileEvidence( relative_path=relative_path, @@ -22,6 +23,7 @@ def _file( issue_number="1", story_arc=arc, story_arc_number=order, + evidence_complete=evidence_complete, ) @@ -70,6 +72,24 @@ def test_unlabelled_mixed_series_folder_is_not_silently_an_arc() -> None: assert result.proposed_name is None +def test_incomplete_file_does_not_promote_an_unlabelled_mixed_folder_to_story_arc() -> None: + result = detect_folder_story_arc( + folder_label="Daredevil (2011)", + files=( + _file("Daredevil 001.cbz", "Daredevil"), + _file( + "Daredevil variant cover.cbz", + "Daredevil variant cover", + evidence_complete=False, + ), + ), + ) + + assert result.classification == FolderArcClassification.NORMAL_MIXED_FOLDER + assert result.reason == "mixed_series_without_strong_arc_evidence" + assert result.proposed_name is None + + def test_reading_order_prefixes_alone_are_review_evidence_not_arc_identity() -> None: result = detect_folder_story_arc( folder_label="Court of Owls", diff --git a/tests/unit/test_import_story_arc_execution.py b/tests/unit/test_import_story_arc_execution.py index 0fc0759..69deb79 100644 --- a/tests/unit/test_import_story_arc_execution.py +++ b/tests/unit/test_import_story_arc_execution.py @@ -179,7 +179,7 @@ async def test_arc_only_execution_preserves_million_issue_and_missing_order( @pytest.mark.asyncio -async def test_normal_exception_after_durable_arc_page_fails_closed_without_losing_provenance( +async def test_normal_exception_after_durable_arc_page_becomes_nonfatal_follow_up( db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -245,21 +245,20 @@ async def fail_after_first_durable_action_page( if durable_action_page_committed: raise RuntimeError("/private/source/should-not-be-persisted") - with pytest.raises(RuntimeError, match="should-not-be-persisted"): - await execution_module._execute_story_arc_materialization( - db_session, - job, - job_id=int(job.id), - raise_if_cancelled=fail_after_first_durable_action_page, - record_action=record_action, - record_actions=None, - log_event=logs, - emit_progress=AsyncMock(), - estimate_remaining_seconds=lambda *_args: None, - progress_callback=None, - runtime_revision_state={"value": 0}, - job_started_at=None, - ) + returned_job, materialization = await execution_module._execute_story_arc_materialization( + db_session, + job, + job_id=int(job.id), + raise_if_cancelled=fail_after_first_durable_action_page, + record_action=record_action, + record_actions=None, + log_event=logs, + emit_progress=AsyncMock(), + estimate_remaining_seconds=lambda *_args: None, + progress_callback=None, + runtime_revision_state={"value": 0}, + job_started_at=None, + ) await db_session.refresh(job) await db_session.refresh(staged) @@ -273,10 +272,14 @@ async def fail_after_first_durable_action_page( ) ).all() ) - assert job.status is ImportJobStatus.FAILED + assert returned_job is job + assert materialization.arcs_failed == 1 + assert job.status is ImportJobStatus.IMPORTING assert job.import_completed_at is None - assert job.error_message == "Story-arc registration failed; canonical files remain imported." - assert job.progress_snapshot["status"] == ImportJobStatus.FAILED.value + assert job.error_message == ( + "Some story arcs need follow-up; canonical comics imported successfully." + ) + assert job.progress_snapshot["status"] == ImportJobStatus.IMPORTING.value assert job.progress_snapshot["phase"] == "story_arcs" assert "/private" not in str(job.progress_snapshot) assert staged.status is ImportedStoryArcStatus.CONFIRMED @@ -463,7 +466,7 @@ async def process_files( @pytest.mark.asyncio -async def test_materialization_failure_keeps_committed_canonical_registration( +async def test_materialization_failure_does_not_fail_canonical_import( db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch, managed_import_root: LibraryRoot, @@ -531,20 +534,22 @@ async def fail_materialization(*_args: object, **_kwargs: object) -> object: raising=False, ) logs = AsyncMock() - with pytest.raises(RuntimeError, match="should-not-leak"): - await _execute( - db_session, - job, - process_series_files=process_files, - log_event=logs, - ) + await _execute( + db_session, + job, + process_series_files=process_files, + log_event=logs, + ) await db_session.refresh(job) await db_session.refresh(staged) - assert job.status == ImportJobStatus.FAILED + assert job.status == ImportJobStatus.COMPLETED assert job.total_files_imported == 1 - assert job.error_message == "Story-arc registration failed; canonical files remain imported." - assert job.progress_snapshot["status"] == ImportJobStatus.FAILED.value + assert job.error_message == ( + "Some story arcs need follow-up; canonical comics imported successfully." + ) + assert job.progress_snapshot["status"] == ImportJobStatus.IMPORTING.value + assert job.progress_snapshot["phase"] == "story_arcs" assert "/private" not in str(job.progress_snapshot) assert staged.status == ImportedStoryArcStatus.CONFIRMED assert staged.materialized_story_arc_id is None @@ -557,8 +562,8 @@ async def fail_materialization(*_args: object, **_kwargs: object) -> object: ) assert failure.kwargs["failure_type"] == "RuntimeError" assert "/private" not in failure.kwargs["message"] - assert all( - len(call.args) < 4 or call.args[3] != "import_completed" for call in logs.await_args_list + assert any( + len(call.args) >= 4 and call.args[3] == "import_completed" for call in logs.await_args_list ) diff --git a/tests/unit/test_import_story_arc_review.py b/tests/unit/test_import_story_arc_review.py index 09b608b..e341c9d 100644 --- a/tests/unit/test_import_story_arc_review.py +++ b/tests/unit/test_import_story_arc_review.py @@ -10,7 +10,14 @@ from sqlalchemy import func, select from pullbox.core.exceptions import ValidationError -from pullbox.models.import_job import ImportJob, ImportJobStatus, ImportSourceType +from pullbox.models.import_job import ( + ImportedFile, + ImportedFileStatus, + ImportedSeries, + ImportJob, + ImportJobStatus, + ImportSourceType, +) from pullbox.models.library import LibraryRoot from pullbox.models.story_arc import ( ImportedStoryArcStatus, @@ -176,18 +183,8 @@ async def test_story_arc_select_and_skip_persist_only_staging_decisions(db_sessi @pytest.mark.asyncio -@pytest.mark.parametrize( - ("states", "safety_incomplete", "message"), - [ - ((StoryArcResolutionState.CONFLICT,), False, "conflict"), - ((StoryArcResolutionState.RESOLVED,), True, "safety"), - ], -) -async def test_story_arc_selection_fails_closed_for_conflict_and_safety_rows( +async def test_story_arc_selection_fails_closed_for_conflict_rows( db_session: Any, - states: tuple[StoryArcResolutionState, ...], - safety_incomplete: bool, - message: str, ) -> None: from pullbox.services.import_story_arc_review import update_import_story_arc_decision @@ -201,11 +198,10 @@ async def test_story_arc_selection_fails_closed_for_conflict_and_safety_rows( staged = await _stage_arc( db_session, job, - states=states, - safety_incomplete=safety_incomplete, + states=(StoryArcResolutionState.CONFLICT,), ) - with pytest.raises(ValidationError, match=message): + with pytest.raises(ValidationError, match="conflict"): await update_import_story_arc_decision( db_session, job.id, @@ -221,6 +217,81 @@ async def test_story_arc_selection_fails_closed_for_conflict_and_safety_rows( assert staged.selected_for_import is False +@pytest.mark.asyncio +async def test_story_arc_selection_uses_current_linked_file_safety_state( + db_session: Any, +) -> None: + from pullbox.services.import_story_arc_review import ( + load_import_story_arc_review_page, + update_import_story_arc_decision, + ) + + job = ImportJob( + source_path="/tmp/import", + source_type=ImportSourceType.FILESYSTEM, + status=ImportJobStatus.REVIEW, + ) + db_session.add(job) + await db_session.flush() + imported_series = ImportedSeries( + import_job_id=job.id, + raw_series_name="Daredevil", + ) + db_session.add(imported_series) + await db_session.flush() + imported_file = ImportedFile( + import_job_id=job.id, + import_series_id=imported_series.id, + file_path="/tmp/import/Daredevil cover.cbz", + file_name="Daredevil cover.cbz", + file_size=100, + file_format="cbz", + status=ImportedFileStatus.SAFETY_BLOCKED, + diagnostics={"safety_block": {"code": "single_page_comic"}}, + ) + db_session.add(imported_file) + await db_session.flush() + staged = await _stage_arc( + db_session, + job, + states=(), + safety_incomplete=True, + ) + entry = ImportedStoryArcEntry( + imported_story_arc_id=staged.id, + import_file_id=imported_file.id, + source_ordinal=1, + resolution_state=StoryArcResolutionState.AMBIGUOUS, + source_kind=StoryArcSourceKind.FOLDER, + diagnostics={ + "safety_code": "single_page_comic", + "review_reason": "source_file_safety_blocked", + }, + ) + db_session.add(entry) + await db_session.flush() + + blocked_page = await load_import_story_arc_review_page(db_session, job.id) + assert blocked_page.items[0].selection_blocked is True + assert blocked_page.items[0].selection_block_reason is not None + assert "safety" in blocked_page.items[0].selection_block_reason.lower() + + imported_file.status = ImportedFileStatus.SAFETY_APPROVED + imported_file.diagnostics = {"safety_exception": {"allowed_once": True}} + await db_session.flush() + + selected = await update_import_story_arc_decision( + db_session, + job.id, + staged.id, + action="select", + proposed_story_arc_id=None, + ) + + assert selected.status == ImportedStoryArcStatus.READY + assert selected.selected_for_import is True + + @pytest.mark.asyncio async def test_story_arc_review_page_is_paginated_and_preserves_order_and_counts( db_session: Any, @@ -271,7 +342,7 @@ async def test_story_arc_review_page_is_paginated_and_preserves_order_and_counts @pytest.mark.asyncio -async def test_review_summary_keeps_story_arc_counts_out_of_series_totals(db_session: Any) -> None: +async def test_review_summary_keeps_story_arcs_out_of_ready_import_totals(db_session: Any) -> None: from pullbox.ui.import_review_summary import load_import_review_summary job = ImportJob( @@ -299,7 +370,12 @@ async def test_review_summary_keeps_story_arc_counts_out_of_series_totals(db_ses assert summary["story_arc_entries_resolved"] == 1 assert summary["story_arc_entries_missing"] == 1 assert summary["selected_series_total"] == 0 - assert summary["selected_items_total"] == 1 + assert summary["selected_items_total"] == 0 + assert summary["importable_items_total"] == 0 + assert summary["deferred_story_arcs_total"] == 1 + assert summary["ready_to_import_total"] == 0 + assert summary["needs_attention_total"] == 0 + assert summary["deferred_follow_up_total"] == 1 @pytest.mark.asyncio @@ -346,6 +422,132 @@ async def _manual_match(*_args: Any, **_kwargs: Any) -> None: assert await db_session.scalar(select(func.count(IssueStoryArc.id))) == 0 +@pytest.mark.asyncio +async def test_exact_trusted_arcs_are_auto_confirmed_as_logical_follow_up( + db_session: Any, +) -> None: + from pullbox.services.import_story_arc_review import ( + auto_confirm_trusted_logical_story_arcs, + ) + + job = ImportJob( + source_path="/tmp/mylar.db", + source_type=ImportSourceType.MYLAR3, + status=ImportJobStatus.IMPORTING, + ) + db_session.add(job) + await db_session.flush() + mylar_arc = ImportedStoryArc( + import_job_id=job.id, + source_kind=StoryArcSourceKind.MYLAR3, + source_key="mylar3:trusted", + source_arc_id="trusted", + source_ordinal=1, + name="Knightfall", + status=ImportedStoryArcStatus.DETECTED, + diagnostics={"source_name_present": True, "duplicate_reading_order": False}, + ) + inferred_folder_arc = ImportedStoryArc( + import_job_id=job.id, + source_kind=StoryArcSourceKind.FOLDER, + source_key="folder:inferred", + source_ordinal=2, + name="A folder that looks ordered", + status=ImportedStoryArcStatus.NEEDS_REVIEW, + diagnostics={"reason": "ordered_mixed_folder_requires_confirmation"}, + ) + db_session.add_all([mylar_arc, inferred_folder_arc]) + await db_session.flush() + db_session.add_all( + [ + ImportedStoryArcEntry( + imported_story_arc_id=mylar_arc.id, + source_ordinal=1, + reading_order=1, + source_kind=StoryArcSourceKind.MYLAR3, + source_issue_id="12345", + resolution_state=StoryArcResolutionState.MISSING, + ), + ImportedStoryArcEntry( + imported_story_arc_id=inferred_folder_arc.id, + source_ordinal=1, + reading_order=1, + source_kind=StoryArcSourceKind.FOLDER, + resolution_state=StoryArcResolutionState.RESOLVED, + evidence={"has_comicinfo": False}, + ), + ] + ) + await db_session.flush() + + confirmed = await auto_confirm_trusted_logical_story_arcs(db_session, job.id) + + assert confirmed == 1 + assert mylar_arc.status == ImportedStoryArcStatus.CONFIRMED + assert mylar_arc.selected_for_import is True + assert mylar_arc.proposed_policy_snapshot["activation"] == "confirmed" + assert mylar_arc.proposed_policy_snapshot["placement_policy"]["mode"] == "logical" + assert inferred_folder_arc.status == ImportedStoryArcStatus.NEEDS_REVIEW + assert inferred_folder_arc.selected_for_import is False + + +@pytest.mark.asyncio +async def test_exact_comicinfo_arc_is_auto_confirmed_but_current_safety_blocks_it( + db_session: Any, +) -> None: + from pullbox.services.import_story_arc_review import ( + auto_confirm_trusted_logical_story_arcs, + ) + + job = ImportJob( + source_path="/tmp/import", + source_type=ImportSourceType.FILESYSTEM, + status=ImportJobStatus.IMPORTING, + ) + db_session.add(job) + await db_session.flush() + imported_series = ImportedSeries(import_job_id=job.id, raw_series_name="Batman") + db_session.add(imported_series) + await db_session.flush() + blocked_file = ImportedFile( + import_job_id=job.id, + import_series_id=imported_series.id, + file_path="/tmp/import/Batman 1.cbz", + file_name="Batman 1.cbz", + file_size=100, + file_format="cbz", + status=ImportedFileStatus.SAFETY_BLOCKED, + ) + db_session.add(blocked_file) + await db_session.flush() + arc = ImportedStoryArc( + import_job_id=job.id, + source_kind=StoryArcSourceKind.FOLDER, + source_key="folder:comicinfo", + source_ordinal=1, + name="Court of Owls", + status=ImportedStoryArcStatus.DETECTED, + diagnostics={"reason": "consistent_exact_arc_name"}, + ) + db_session.add(arc) + await db_session.flush() + db_session.add( + ImportedStoryArcEntry( + imported_story_arc_id=arc.id, + import_file_id=blocked_file.id, + source_ordinal=1, + reading_order=1, + source_kind=StoryArcSourceKind.FOLDER, + resolution_state=StoryArcResolutionState.RESOLVED, + evidence={"has_comicinfo": True}, + ) + ) + await db_session.flush() + + assert await auto_confirm_trusted_logical_story_arcs(db_session, job.id) == 0 + assert arc.status == ImportedStoryArcStatus.DETECTED + + @pytest.mark.asyncio async def test_compatibility_story_arc_ids_add_to_existing_durable_selection( db_session: Any, diff --git a/tests/unit/test_import_task.py b/tests/unit/test_import_task.py index 3b42d8f..db50ad1 100644 --- a/tests/unit/test_import_task.py +++ b/tests/unit/test_import_task.py @@ -557,6 +557,47 @@ async def mock_session_ctx(): mock_service.run_import.assert_called_once() + @pytest.mark.asyncio + async def test_execute_task_prepares_clean_library_before_import( + self, db_session: AsyncSession + ) -> None: + """Clean-library row materialization belongs to the durable worker.""" + job = await _create_job(db_session, status=ImportJobStatus.IMPORTING) + job.progress_snapshot = { + "mode": "import", + "phase": "queued", + "clean_library_adoption": True, + "clean_library_adoption_prepared": False, + "source_import_job_id": 73, + } + await db_session.commit() + events: list[str] = [] + mock_service = AsyncMock() + mock_service.run_import.side_effect = lambda *_args, **_kwargs: events.append("import") + + async def prepare(*_args, **_kwargs): # type: ignore[no-untyped-def] + events.append("prepare") + + @asynccontextmanager + async def mock_session_ctx(): + yield db_session + + with ( + patch("pullbox.tasks.import_task.get_session_factory") as mock_factory, + patch( + "pullbox.tasks.import_task._build_import_service", + return_value=mock_service, + ), + patch( + "pullbox.tasks.import_task.prepare_clean_library_import", + side_effect=prepare, + ), + ): + mock_factory.return_value = mock_session_ctx + await run_import_execute_task(job.id) + + assert events == ["prepare", "import"] + @pytest.mark.asyncio async def test_execute_task_schedules_comicinfo_enrichment_after_commit( self, db_session: AsyncSession diff --git a/tests/unit/test_import_workflow_state.py b/tests/unit/test_import_workflow_state.py index b79dea9..e491702 100644 --- a/tests/unit/test_import_workflow_state.py +++ b/tests/unit/test_import_workflow_state.py @@ -241,6 +241,42 @@ async def capture_progress(progress_event: ImportProgressEvent) -> None: assert job.progress_snapshot["control_state"]["transfer_method"] == "copy" +@pytest.mark.asyncio +async def test_emit_progress_preserves_and_scales_clean_library_context(db_session) -> None: + job = _job(ImportJobStatus.IMPORTING, import_started=True) + job.progress_snapshot = { + "clean_library_adoption": True, + "clean_library_adoption_prepared": True, + "source_import_job_id": 41, + "clean_library_source_snapshot": { + "file_count": 10, + "series_count": 2, + "total_bytes": 1000, + "digest": "scope-digest", + }, + } + db_session.add(job) + await db_session.flush() + + await emit_progress( + db_session, + job, + ImportProgressEvent( + job_id=job.id, + status=ImportJobStatus.IMPORTING, + mode="import", + phase="importing", + progress=20, + message="Copying files", + ), + ) + + assert job.progress_snapshot["progress"] == 24 + assert job.progress_snapshot["clean_library_adoption"] is True + assert job.progress_snapshot["clean_library_adoption_prepared"] is True + assert job.progress_snapshot["source_import_job_id"] == 41 + + @pytest.mark.asyncio async def test_emit_live_progress_advances_runtime_revision_without_snapshot_write( db_session, diff --git a/tests/unit/test_library_root_removal.py b/tests/unit/test_library_root_removal.py new file mode 100644 index 0000000..ad0b553 --- /dev/null +++ b/tests/unit/test_library_root_removal.py @@ -0,0 +1,221 @@ +"""Root removal is configuration-only and must never orphan library data.""" + +from datetime import UTC, datetime +from pathlib import Path + +import pytest +from sqlalchemy import select, text +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from pullbox.core.exceptions import ValidationError +from pullbox.models.config import SystemConfig +from pullbox.models.import_job import ImportJob, ImportJobStatus, ImportSourceType +from pullbox.models.library import FileFormat, LibraryFile, LibraryRoot +from pullbox.models.series import Series +from pullbox.models.story_arc import StoryArc +from pullbox.services import library_root_management as roots +from pullbox.services.import_retry_helpers import build_retry_import_request +from pullbox.utilities.models import JobState, UtilityJob + + +async def disabled_root(session: AsyncSession, path: Path) -> LibraryRoot: + root = LibraryRoot(name="Old library", path=str(path), enabled=False) + session.add(root) + await session.flush() + return root + + +@pytest.mark.asyncio +async def test_removal_preserves_files_and_cancelled_import_history( + db_session: AsyncSession, + tmp_path: Path, +) -> None: + root = await disabled_root(db_session, tmp_path) + untouched = tmp_path / "untracked.cbz" + untouched.write_bytes(b"user-owned file") + job = ImportJob( + source_path=str(tmp_path), + source_type=ImportSourceType.FILESYSTEM, + status=ImportJobStatus.CANCELLED, + target_library_root_id=root.id, + ) + db_session.add(job) + await db_session.flush() + preview = await roots.preview_library_root_removal(db_session, root.id, actor_id=7) + assert preview["can_remove"] is True + assert preview["history_count"] == 1 + assert await db_session.get(LibraryRoot, root.id) is root + await roots.remove_library_root( + db_session, root.id, actor_id=7, preview_token=preview["preview_token"] + ) + assert await db_session.scalar(select(LibraryRoot.id).where(LibraryRoot.id == root.id)) is None + await db_session.refresh(job) + assert job.target_library_root_id is None + assert job.removed_library_root_snapshot == { + "id": root.id, + "name": root.name, + "path": str(tmp_path), + } + assert untouched.read_bytes() == b"user-owned file" + with pytest.raises(ValidationError, match=r"removed.*new import"): + build_retry_import_request(job) + + +@pytest.mark.asyncio +async def test_offline_unused_root_is_removable(db_session: AsyncSession, tmp_path: Path) -> None: + root = await disabled_root(db_session, tmp_path / "offline") + preview = await roots.preview_library_root_removal(db_session, root.id, actor_id=7) + assert preview["can_remove"] is True + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "dependency", + ["enabled", "default", "series", "preferred", "active", "config", "arc", "utility", "followup"], +) +async def test_removal_explains_dependencies( + db_session: AsyncSession, + tmp_path: Path, + dependency: str, +) -> None: + root = await disabled_root(db_session, tmp_path) + if dependency == "enabled": + root.enabled = True + elif dependency == "default": + root.is_default_managed_destination = True + elif dependency in {"series", "preferred"}: + db_session.add( + Series( + title="Owned", + sort_title="owned", + **{ + "library_root_id" + if dependency == "series" + else "preferred_library_root_id": root.id, + }, + ) + ) + elif dependency == "active": + db_session.add( + ImportJob( + source_path=str(tmp_path), + source_type=ImportSourceType.FILESYSTEM, + status=ImportJobStatus.PAUSED, + target_library_root_id=root.id, + ) + ) + elif dependency == "arc": + db_session.add(StoryArc(name="Arc", target_library_root_id=root.id)) + elif dependency == "utility": + db_session.add( + UtilityJob( + id="paused", job_type="mass_rename", display_name="Rename", state=JobState.PAUSED + ) + ) + elif dependency == "followup": + db_session.add( + ImportJob( + source_path=str(tmp_path), + source_type=ImportSourceType.FILESYSTEM, + status=ImportJobStatus.COMPLETED, + target_library_root_id=root.id, + story_arc_placement_followup_pending=True, + ) + ) + else: + db_session.add( + SystemConfig( + key="story_arc_files_library_root_id", value=str(root.id), value_type="string" + ) + ) + await db_session.flush() + preview = await roots.preview_library_root_removal(db_session, root.id, actor_id=7) + assert preview["can_remove"] is False + assert preview["blocking_reasons"] + assert preview["preview_token"] is None + + +@pytest.mark.asyncio +async def test_confirmation_rechecks_dependencies(db_session: AsyncSession, tmp_path: Path) -> None: + root = await disabled_root(db_session, tmp_path) + preview = await roots.preview_library_root_removal(db_session, root.id, actor_id=7) + db_session.add(Series(title="New arrival", sort_title="new arrival", library_root_id=root.id)) + await db_session.flush() + with pytest.raises(ValidationError, match="series"): + await roots.remove_library_root( + db_session, root.id, actor_id=7, preview_token=preview["preview_token"] + ) + assert await db_session.get(LibraryRoot, root.id) is not None + + +@pytest.mark.asyncio +async def test_preview_cannot_be_reused_by_another_operator( + db_session: AsyncSession, tmp_path: Path +) -> None: + root = await disabled_root(db_session, tmp_path) + preview = await roots.preview_library_root_removal(db_session, root.id, actor_id=7) + with pytest.raises(ValidationError, match="preview"): + await roots.remove_library_root( + db_session, root.id, actor_id=8, preview_token=preview["preview_token"] + ) + + +@pytest.mark.asyncio +async def test_database_and_orm_reject_root_deletion_with_files( + db_session: AsyncSession, tmp_path: Path +) -> None: + await db_session.execute(text("PRAGMA foreign_keys=ON")) + root = await disabled_root(db_session, tmp_path) + file = LibraryFile( + file_path=str(tmp_path / "issue.cbz"), + file_name="issue.cbz", + file_size=1, + file_format=FileFormat.CBZ, + file_modified_at=datetime.now(UTC), + library_root_id=root.id, + ) + db_session.add(file) + await db_session.commit() + await db_session.refresh(root, ["files"]) + with pytest.raises(IntegrityError): + await db_session.delete(root) + await db_session.flush() + await db_session.rollback() + assert await db_session.scalar(select(LibraryFile.id)) is not None + + +@pytest.mark.asyncio +async def test_removal_rejects_changed_or_invalid_preview( + db_session: AsyncSession, tmp_path: Path +) -> None: + root = await disabled_root(db_session, tmp_path) + preview = await roots.preview_library_root_removal(db_session, root.id, actor_id=7) + root.name = "Changed since preview" + await db_session.flush() + for token in (preview["preview_token"], "invalid"): + with pytest.raises(ValidationError, match="preview"): + await roots.remove_library_root(db_session, root.id, actor_id=7, preview_token=token) + + +@pytest.mark.asyncio +async def test_removed_root_blocks_failed_retry_before_any_reconciliation( + db_session: AsyncSession, tmp_path: Path +) -> None: + from unittest.mock import AsyncMock + + from pullbox.services.import_orphans import retry_failed_series + + job = ImportJob( + source_path=str(tmp_path), + source_type=ImportSourceType.FILESYSTEM, + status=ImportJobStatus.COMPLETED, + removed_library_root_snapshot={"id": 1, "path": "/removed", "name": "Old"}, + ) + db_session.add(job) + await db_session.flush() + logger = AsyncMock() + with pytest.raises(ValidationError, match="library root was removed"): + await retry_failed_series(db_session, job.id, log_event=logger) + assert job.status == ImportJobStatus.COMPLETED + logger.assert_not_awaited() diff --git a/tests/unit/test_library_root_removal_migration.py b/tests/unit/test_library_root_removal_migration.py new file mode 100644 index 0000000..f9d86aa --- /dev/null +++ b/tests/unit/test_library_root_removal_migration.py @@ -0,0 +1,75 @@ +"""Root FK changes preserve data through upgrade, downgrade, and upgrade again.""" + +import importlib.util +from pathlib import Path + +import pytest +import sqlalchemy as sa +from alembic.migration import MigrationContext +from alembic.operations import Operations + + +def test_root_removal_migration_preserves_dependent_rows(monkeypatch): # type: ignore[no-untyped-def] + path = ( + Path(__file__).resolve().parents[2] + / "alembic/versions/n5h6i7j8k901_protect_library_root_removal.py" + ) + spec = importlib.util.spec_from_file_location("root_removal_migration", path) + assert spec and spec.loader + migration = importlib.util.module_from_spec(spec) + spec.loader.exec_module(migration) + engine = sa.create_engine("sqlite:///:memory:") + with engine.begin() as conn: + # Model/service tests may install the runtime Engine connect listener. + # Match the isolated Alembic process, not a running app's connection. + conn.exec_driver_sql("PRAGMA foreign_keys=OFF") + conn.exec_driver_sql("CREATE TABLE library_roots (id INTEGER PRIMARY KEY)") + conn.exec_driver_sql("INSERT INTO library_roots VALUES (1)") + for table, column, old_action in [ + ("library_files", "library_root_id", "CASCADE"), + ("series", "library_root_id", "SET NULL"), + ("story_arcs", "target_library_root_id", "SET NULL"), + ("story_arc_placements", "library_root_id", "SET NULL"), + ("import_jobs", "target_library_root_id", "SET NULL"), + ]: + conn.exec_driver_sql( + f"CREATE TABLE {table} (id INTEGER PRIMARY KEY, " + f"{column} INTEGER REFERENCES library_roots(id) ON DELETE {old_action})" + ) + conn.exec_driver_sql(f"INSERT INTO {table} VALUES (1, 1)") + conn.exec_driver_sql( + "ALTER TABLE series ADD COLUMN preferred_library_root_id " + "INTEGER REFERENCES library_roots(id) ON DELETE SET NULL" + ) + conn.exec_driver_sql("UPDATE series SET preferred_library_root_id=1") + conn.exec_driver_sql( + "CREATE TABLE child (id INTEGER PRIMARY KEY, " + "file_id INTEGER REFERENCES library_files(id) ON DELETE CASCADE)" + ) + conn.exec_driver_sql("INSERT INTO child VALUES (1, 1)") + monkeypatch.setattr(migration, "op", Operations(MigrationContext.configure(conn))) + for upgrade in (True, False, True): + migration.upgrade() if upgrade else migration.downgrade() + for table in migration._TABLES: + # SQLite's FK list includes inline actions that SQLAlchemy's + # SQL-text reflection does not always recover correctly. + keys = conn.exec_driver_sql(f'PRAGMA foreign_key_list("{table}")').all() + assert keys + assert all( + k[6] + == ( + "RESTRICT" + if upgrade + else "CASCADE" + if table == "library_files" + else "SET NULL" + ) + for k in keys + ) + assert conn.exec_driver_sql(f"SELECT count(*) FROM {table}").scalar() == 1 + assert conn.exec_driver_sql("SELECT file_id FROM child").scalar() == 1 + assert conn.exec_driver_sql("PRAGMA foreign_key_check").all() == [] + conn.exec_driver_sql("UPDATE import_jobs SET removed_library_root_snapshot='{\"id\": 1}'") + with pytest.raises(RuntimeError, match="import history"): + migration.downgrade() + engine.dispose() diff --git a/tests/unit/test_matching_service_edges.py b/tests/unit/test_matching_service_edges.py new file mode 100644 index 0000000..b12fb80 --- /dev/null +++ b/tests/unit/test_matching_service_edges.py @@ -0,0 +1,229 @@ +"""Failure and fallback coverage for local library matching.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from pullbox.core.archive import ArchiveError +from pullbox.core.events import EventBus +from pullbox.core.exceptions import NotFoundError +from pullbox.core.release_parser import parse_release_title +from pullbox.core.source_metadata import SourceMetadata +from pullbox.models.issue import IssueType +from pullbox.models.library import LibraryFile, MatchConfidence +from pullbox.services.matching_service import MatchingService, _find_issue, _get_candidate_series + + +def _library_file(path: str, *, name: str = "Unknown.cbz") -> LibraryFile: + return LibraryFile( + id=7, + file_path=path, + file_name=name, + file_size=1, + file_format="cbz", + match_confidence=MatchConfidence.UNMATCHED, + library_root_id=1, + ) + + +@pytest.mark.asyncio +async def test_match_file_reports_unmatched_after_both_strategies_fail() -> None: + service = MatchingService(EventBus()) + service._match_from_comicinfo = AsyncMock(return_value=MatchConfidence.UNMATCHED) # type: ignore[method-assign] + service._match_from_filename = AsyncMock(return_value=MatchConfidence.UNMATCHED) # type: ignore[method-assign] + + result = await service.match_file(AsyncMock(), _library_file("/missing.cbz")) + + assert result is MatchConfidence.UNMATCHED + + +@pytest.mark.asyncio +async def test_manual_match_and_unmatch_reject_missing_rows() -> None: + session = AsyncMock() + session.get.return_value = None + service = MatchingService(EventBus()) + + with pytest.raises(NotFoundError, match="LibraryFile"): + await service.manual_match(session, 1, 2) + with pytest.raises(NotFoundError, match="LibraryFile"): + await service.unmatch_file(session, 1) + + library_file = _library_file("/missing.cbz") + session.get.side_effect = [library_file, None] + with pytest.raises(NotFoundError, match="Issue"): + await service.manual_match(session, 1, 2) + + +@pytest.mark.asyncio +async def test_comicinfo_matching_handles_missing_unreadable_and_sparse_sources( + tmp_path, +) -> None: # type: ignore[no-untyped-def] + service = MatchingService(EventBus()) + session = AsyncMock() + + assert ( + await service._match_from_comicinfo(session, _library_file(str(tmp_path / "missing.cbz"))) + is MatchConfidence.UNMATCHED + ) + + source = tmp_path / "source.cbz" + source.touch() + service._extractor.from_archive_path = MagicMock(side_effect=ArchiveError("broken")) + assert ( + await service._match_from_comicinfo(session, _library_file(str(source))) + is MatchConfidence.UNMATCHED + ) + + service._extractor.from_archive_path = MagicMock( + return_value=SourceMetadata(original_title=source.name) + ) + assert ( + await service._match_from_comicinfo(session, _library_file(str(source))) + is MatchConfidence.UNMATCHED + ) + + service._extractor.from_archive_path = MagicMock( + return_value=SourceMetadata( + original_title=source.name, + diagnostics={"has_comicinfo": True}, + ) + ) + assert ( + await service._match_from_comicinfo(session, _library_file(str(source))) + is MatchConfidence.UNMATCHED + ) + + +@pytest.mark.asyncio +async def test_filename_matching_handles_unparsed_and_nonstandard_releases() -> None: + service = MatchingService(EventBus()) + service._extractor.from_release_title = MagicMock( + return_value=SourceMetadata(original_title="unknown") + ) + assert ( + await service._match_from_filename(AsyncMock(), _library_file("/source.cbz")) + is MatchConfidence.UNMATCHED + ) + + parsed = parse_release_title("Batman Omnibus (2025).cbz") + assert parsed is not None + service._extractor.from_release_title = MagicMock( + return_value=SourceMetadata( + original_title="Batman Omnibus (2025).cbz", + series_name="Batman", + issue_type=IssueType.OMNIBUS, + parsed_release=parsed, + ) + ) + service._create_suggestion_if_variant = AsyncMock() # type: ignore[method-assign] + + result = await service._match_from_filename(AsyncMock(), _library_file("/source.cbz")) + + assert result is MatchConfidence.UNMATCHED + service._create_suggestion_if_variant.assert_awaited_once() # type: ignore[attr-defined] + + +@pytest.mark.asyncio +async def test_variant_suggestion_stops_for_standard_same_base_and_missing_parent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + service = MatchingService(EventBus()) + session = AsyncMock() + library_file = _library_file("/source.cbz") + standard = parse_release_title("Batman 001.cbz") + same_base = parse_release_title("Batman Omnibus.cbz") + no_parent = parse_release_title("Batman Annual 001.cbz") + assert standard is not None and same_base is not None and no_parent is not None + + await service._create_suggestion_if_variant(session, library_file, standard) + await service._create_suggestion_if_variant(session, library_file, same_base) + monkeypatch.setattr( + "pullbox.services.matching_service._get_candidate_series", + AsyncMock(return_value=[]), + ) + await service._create_suggestion_if_variant(session, library_file, no_parent) + + session.add.assert_not_called() + + +def test_apply_metadata_only_overwrites_present_signals() -> None: + library_file = _library_file("/source.cbz") + + MatchingService._apply_metadata( + library_file, + SourceMetadata( + original_title="Batman 001.cbz", + series_name="Batman", + publisher="DC Comics", + year=2025, + issue_number=1, + ), + ) + + assert library_file.parsed_series == "Batman" + assert library_file.parsed_publisher == "DC Comics" + assert library_file.parsed_year == 2025 + assert library_file.parsed_issue_number == 1 + + +@pytest.mark.asyncio +async def test_metadata_candidate_matching_handles_missing_series_and_candidate_misses( + monkeypatch: pytest.MonkeyPatch, +) -> None: + service = MatchingService(EventBus()) + session = AsyncMock() + library_file = _library_file("/source.cbz") + + assert ( + await service._match_metadata_to_candidates( + session, + library_file, + SourceMetadata(original_title="unknown"), + allow_series_only_medium=False, + ) + is MatchConfidence.UNMATCHED + ) + + candidate = SimpleNamespace(id=4, title="Superman", alternate_names=[]) + monkeypatch.setattr( + "pullbox.services.matching_service._get_candidate_series", + AsyncMock(return_value=[candidate]), + ) + service._name_matcher = MagicMock( + match=MagicMock(return_value=SimpleNamespace(is_match=False, similarity=0.0)) + ) + assert ( + await service._match_metadata_to_candidates( + session, + library_file, + SourceMetadata(original_title="Batman 001", series_name="Batman", issue_number=1), + allow_series_only_medium=False, + ) + is MatchConfidence.UNMATCHED + ) + + +@pytest.mark.asyncio +async def test_candidate_loader_falls_back_to_all_series() -> None: + first_result = MagicMock() + first_result.scalars.return_value.all.return_value = [] + fallback = [SimpleNamespace(title="Batman")] + second_result = MagicMock() + second_result.scalars.return_value.all.return_value = fallback + session = AsyncMock() + session.execute.side_effect = [first_result, second_result] + + assert await _get_candidate_series(session, "Batman") == fallback + assert session.execute.await_count == 2 + + +@pytest.mark.asyncio +async def test_exact_issue_lookup_fails_closed_on_bad_or_conflicting_text() -> None: + session = AsyncMock() + + assert await _find_issue(session, 1, 1, issue_number_text="invalid") is None + assert await _find_issue(session, 1, 1, issue_number_text="2") is None + session.execute.assert_not_awaited() diff --git a/tests/unit/test_mylar3_reader.py b/tests/unit/test_mylar3_reader.py index 22f10d8..d5be65d 100644 --- a/tests/unit/test_mylar3_reader.py +++ b/tests/unit/test_mylar3_reader.py @@ -174,6 +174,100 @@ async def test_stale_path_reconciliation_does_not_merge_conflicting_records(tmp_ assert "mylar3_path_reconciliation" not in actual.metadata_diagnostics +async def test_mixed_series_folder_quarantines_unrecorded_foreign_files(tmp_path): + db = tmp_path / "mylar.db" + folder = tmp_path / "comics" / "Fritzi Ritz (1953)" + fritzi = folder / "Fritzi Ritz 001 (1953).cbz" + batman = folder / "Batman 001 (2011).cbz" + create_minimal_cbz(fritzi) + create_minimal_cbz(batman) + _create_mylar_db( + db, + [ + { + "ComicID": "CV-31895", + "ComicName": "Fritzi Ritz", + "ComicYear": "1953", + "ComicLocation": str(folder), + "Total": 1, + } + ], + issues=[ + { + "IssueID": "900001", + "ComicID": "31895", + "ComicName": "Fritzi Ritz", + "Issue_Number": "1", + "Location": fritzi.name, + } + ], + ) + + results = await Mylar3Reader(db).read_series() + + assert len(results) == 1 + series = results[0] + files = {file.file_name: file for file in series.files} + assert files[fritzi.name].comicvine_series_id == 31895 + assert files[fritzi.name].comicvine_issue_id == 900001 + assert files[batman.name].parsed_series == "Batman" + assert files[batman.name].comicvine_series_id is None + assert files[batman.name].metadata_signals["series_name"] == "release_title" + assert files[batman.name].metadata_diagnostics["mylar3_folder_scope_conflict"] == { + "expected_series": "Fritzi Ritz", + "parsed_series": "Batman", + "recorded_issue": False, + } + assert series.diagnostics["mylar3_folder_scope"] == { + "review_required": True, + "unrecorded_file_count": 1, + "conflicting_file_count": 1, + "examples": [batman.name], + } + + +async def test_mixed_series_folder_quarantines_unparseable_named_foreign_file(tmp_path): + db = tmp_path / "mylar.db" + folder = tmp_path / "comics" / "X-Men Unlimited Infinity Comic (2021)" + intended = folder / "X-Men Unlimited Infinity Comic 087 (2023).cbz" + foreign = folder / "XMen - S1_ABRIL_BR0078 - 1995.cbz" + create_minimal_cbz(intended) + create_minimal_cbz(foreign) + _create_mylar_db( + db, + [ + { + "ComicID": "CV-139055", + "ComicName": "X-Men Unlimited Infinity Comic", + "ComicYear": "2021", + "ComicLocation": str(folder), + "Total": 1, + } + ], + issues=[ + { + "IssueID": "1000087", + "ComicID": "139055", + "ComicName": "X-Men Unlimited Infinity Comic", + "Issue_Number": "87", + "Location": intended.name, + } + ], + ) + + results = await Mylar3Reader(db).read_series() + + files = {file.file_name: file for file in results[0].files} + assert files[intended.name].comicvine_series_id == 139055 + assert files[foreign.name].parsed_series == "XMen - S1_ABRIL_BR0078" + assert files[foreign.name].comicvine_series_id is None + assert files[foreign.name].metadata_diagnostics["mylar3_folder_scope_conflict"] == { + "expected_series": "X-Men Unlimited Infinity Comic", + "parsed_series": "XMen - S1_ABRIL_BR0078", + "recorded_issue": False, + } + + @pytest.mark.asyncio async def test_selected_layout_applies_to_mapped_mylar_paths_without_overriding_identity( tmp_path: Path, diff --git a/tests/unit/test_naming_policy_service_edges.py b/tests/unit/test_naming_policy_service_edges.py new file mode 100644 index 0000000..9ad7c81 --- /dev/null +++ b/tests/unit/test_naming_policy_service_edges.py @@ -0,0 +1,154 @@ +"""Service-level coverage for naming scope persistence and revision guards.""" + +from __future__ import annotations + +import pytest + +from pullbox.core.exceptions import PullboxError, ValidationError +from pullbox.models.library import LibraryRoot +from pullbox.schemas.config import NamingSettingsUpdate +from pullbox.services.library_root_policy_service import ( + LibraryRootNotFoundError, + clear_library_root_policy, + preview_library_root_policy, + update_library_root_policy, +) +from pullbox.services.naming_settings import get_naming_settings, save_naming_settings + + +@pytest.mark.asyncio +async def test_global_naming_save_persists_complete_policy_and_rejects_stale_edits( + db_session, +) -> None: + current = await get_naming_settings(db_session) + updated_policy = current.policy.model_copy( + update={"series_path_template": "{Publisher}/{Series} ({Year})"} + ) + saved = await save_naming_settings( + db_session, + NamingSettingsUpdate( + expected_fingerprint=current.fingerprint, + policy=updated_policy, + ), + ) + assert saved.policy.series_path_template == "{Publisher}/{Series} ({Year})" + + with pytest.raises(PullboxError, match="changed after you loaded"): + await save_naming_settings( + db_session, + NamingSettingsUpdate( + expected_fingerprint=current.fingerprint, + policy=current.policy, + ), + ) + with pytest.raises(ValidationError, match="Select a library"): + await save_naming_settings( + db_session, + NamingSettingsUpdate( + expected_fingerprint=saved.fingerprint, + policy=saved.policy, + use_global=True, + ), + ) + + +@pytest.mark.asyncio +async def test_library_naming_override_updates_then_returns_to_global(db_session, tmp_path) -> None: + root = LibraryRoot(name="Comics", path=str(tmp_path), enabled=True) + db_session.add(root) + await db_session.flush() + + inherited = await get_naming_settings(db_session, root.id) + custom_policy = inherited.policy.model_copy( + update={"comic_file_template": "{Series} #{Issue:03d}"} + ) + custom = await save_naming_settings( + db_session, + NamingSettingsUpdate( + library_root_id=root.id, + expected_fingerprint=inherited.fingerprint, + policy=custom_policy, + ), + ) + assert custom.use_global is False + assert custom.revision == 1 + + revised_policy = custom.policy.model_copy(update={"colon_replacement": "space"}) + revised = await save_naming_settings( + db_session, + NamingSettingsUpdate( + library_root_id=root.id, + expected_fingerprint=custom.fingerprint, + policy=revised_policy, + ), + ) + assert revised.revision == 2 + assert revised.source == "manual" + + restored = await save_naming_settings( + db_session, + NamingSettingsUpdate( + library_root_id=root.id, + expected_fingerprint=revised.fingerprint, + policy=revised.policy, + use_global=True, + ), + ) + assert restored.use_global is True + assert restored.revision == 0 + + +@pytest.mark.asyncio +async def test_root_policy_preview_handles_invalid_examples_and_missing_roots( + db_session, tmp_path +) -> None: + root = LibraryRoot(name="Preview", path=str(tmp_path), enabled=True) + db_session.add(root) + await db_session.flush() + global_state = await get_naming_settings(db_session) + + preview = await preview_library_root_policy( + db_session, + root.id, + definition=global_state.policy.model_dump(), + examples=[ + {"series": "", "issue_number": 1}, + {"series": "Batman", "issue_number": True}, + ], + ) + assert preview["current_scope"] == "global_default" + assert preview["proposed_series_paths"] + + with pytest.raises(LibraryRootNotFoundError): + await preview_library_root_policy( + db_session, + 999999, + definition=global_state.policy.model_dump(), + ) + + +@pytest.mark.asyncio +async def test_root_policy_low_level_revision_contract(db_session, tmp_path) -> None: + root = LibraryRoot(name="Direct", path=str(tmp_path), enabled=True) + db_session.add(root) + await db_session.flush() + definition = (await get_naming_settings(db_session)).policy.model_dump() + + created = await update_library_root_policy( + db_session, + root.id, + expected_revision=0, + definition=definition, + ) + assert created["revision"] == 1 + + with pytest.raises(PullboxError, match="changed after it was loaded"): + await update_library_root_policy( + db_session, + root.id, + expected_revision=0, + definition=definition, + ) + + cleared = await clear_library_root_policy(db_session, root.id, expected_revision=1) + assert cleared["scope"] == "global_default" diff --git a/tests/unit/test_provider_composition_edges.py b/tests/unit/test_provider_composition_edges.py new file mode 100644 index 0000000..e441cdb --- /dev/null +++ b/tests/unit/test_provider_composition_edges.py @@ -0,0 +1,100 @@ +"""Provider composition coverage for every supported download-client lane.""" + +from __future__ import annotations + +import pytest + +from pullbox.composition import providers +from pullbox.models.client import DownloadClientConfig +from pullbox.models.download import DownloadClientType +from pullbox.providers.base import ProviderRegistry + + +def _client(name: str, client_type: DownloadClientType, port: int) -> DownloadClientConfig: + return DownloadClientConfig( + name=name, + client_type=client_type, + url=f"http://localhost:{port}", + enabled=True, + priority=port, + api_key="encrypted-api-key", + username="user", + password="encrypted-password", + category="comics", + sab_priority="normal", + sab_post_processing="2", + nzbget_priority="high", + nzbget_post_processing="pp2", + qbt_content_layout="Original", + qbt_ratio_limit=1.5, + qbt_seeding_time_limit=60, + transmission_download_dir="/downloads", + transmission_bandwidth_priority=1, + transmission_seed_ratio_limit=1.5, + transmission_seed_idle_limit=30, + deluge_label="comics", + deluge_max_ratio=1.5, + deluge_move_completed_path="/downloads/completed", + ) + + +@pytest.mark.asyncio +async def test_download_client_composition_builds_and_reuses_every_supported_client( + db_session, monkeypatch +) -> None: + providers._download_client_cache.clear() + monkeypatch.setattr(providers, "decrypt_secret", lambda value: f"plain:{value}") + db_session.add_all( + [ + _client("SABnzbd", DownloadClientType.SABNZBD, 8080), + _client("NZBGet", DownloadClientType.NZBGET, 6789), + _client("qBittorrent", DownloadClientType.QBITTORRENT, 8081), + _client("Transmission", DownloadClientType.TRANSMISSION, 9091), + _client("Deluge", DownloadClientType.DELUGE, 8112), + ] + ) + await db_session.flush() + + first_registry = ProviderRegistry() + assert await providers.register_download_clients(db_session, first_registry) == [] + first_items = first_registry.get_download_client_items() + assert len(first_items) == 5 + + second_registry = ProviderRegistry() + assert await providers.register_download_clients(db_session, second_registry) == [] + second_items = second_registry.get_download_client_items() + assert [item[1] for item in second_items] == [item[1] for item in first_items] + + +@pytest.mark.asyncio +async def test_download_client_composition_reports_secret_failures(db_session, monkeypatch) -> None: + providers._download_client_cache.clear() + client = _client("Broken SABnzbd", DownloadClientType.SABNZBD, 8080) + db_session.add(client) + await db_session.flush() + monkeypatch.setattr( + providers, + "decrypt_secret", + lambda _value: (_ for _ in ()).throw(ValueError("invalid ciphertext")), + ) + + failures = await providers.register_download_clients(db_session, ProviderRegistry()) + + assert failures == [ + { + "config_id": str(client.id), + "name": "Broken SABnzbd", + "client_type": "sabnzbd", + "url": "http://localhost:8080", + "status": "unhealthy", + "message": ( + "Configuration error: saved credentials could not be loaded. " + "Re-save this client in Settings > Download Clients." + ), + } + ] + + +@pytest.mark.asyncio +async def test_empty_indexer_composition_returns_no_registry(db_session) -> None: + assert await providers.build_registry(db_session) is None diff --git a/tests/unit/test_release_discord_notifications.py b/tests/unit/test_release_discord_notifications.py index bf74260..87207bb 100644 --- a/tests/unit/test_release_discord_notifications.py +++ b/tests/unit/test_release_discord_notifications.py @@ -2,7 +2,11 @@ from __future__ import annotations +import json from pathlib import Path +from types import SimpleNamespace + +import pytest from pullbox import release_discord_delivery as delivery from pullbox.release_discord_delivery import delivery_task @@ -38,6 +42,11 @@ def test_delivery_tasks_are_stable_per_channel() -> None: assert delivery_task("announcements") == "pullbox-discord-announcements" +def test_delivery_task_rejects_unknown_channels() -> None: + with pytest.raises(ValueError, match="Unsupported Discord channel"): + delivery_task("support") + + def test_release_workflow_only_posts_after_a_successful_reservation() -> None: workflow = Path(".github/workflows/release.yml").read_text(encoding="utf-8") @@ -75,6 +84,159 @@ def fake_request(method, url, token, payload=None): } +def test_github_request_serializes_payload_and_headers(monkeypatch) -> None: + captured: dict[str, object] = {} + + class Response: + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self) -> bytes: + return b'{"id": 42}' + + def fake_urlopen(request): + captured["request"] = request + return Response() + + monkeypatch.setattr(delivery, "urlopen", fake_urlopen) + + assert delivery._request( + "POST", "https://api.github.test/resource", "secret", {"value": 1} + ) == {"id": 42} + request = captured["request"] + assert request.method == "POST" + assert json.loads(request.data) == {"value": 1} + assert request.get_header("Authorization") == "Bearer secret" + + +def test_delivery_reservation_creates_and_marks_an_in_progress_deployment(monkeypatch) -> None: + calls: list[tuple[str, str, object]] = [] + + def fake_request(method, url, _token, payload=None): + calls.append((method, url, payload)) + if method == "GET": + return [] + if url.endswith("/deployments"): + return {"id": 42} + return {} + + monkeypatch.setattr(delivery, "_request", fake_request) + + deployment_id = delivery.reserve_delivery( + api_url="https://api.github.test", + repository="pullboxapp/pullbox", + token="token", + ref="abc123", + run_url="https://github.test/run/1", + version="1.2.3", + channel="changelog", + ) + + assert deployment_id == 42 + assert calls[1][2]["payload"] == {"version": "1.2.3"} + assert calls[2][2]["state"] == "in_progress" + + +@pytest.mark.parametrize("payload", [{"version": "1.2.3"}, json.dumps({"version": "1.2.3"})]) +def test_delivery_reservation_skips_a_previous_success(monkeypatch, payload) -> None: + responses = iter([[{"id": 42, "payload": payload}], [{"state": "success"}]]) + monkeypatch.setattr(delivery, "_request", lambda *_args, **_kwargs: next(responses)) + + assert ( + delivery.reserve_delivery( + api_url="https://api.github.test", + repository="pullboxapp/pullbox", + token="token", + ref="abc123", + run_url="https://github.test/run/1", + version="1.2.3", + channel="changelog", + ) + is None + ) + + +def test_delivery_reservation_rejects_an_ambiguous_previous_attempt(monkeypatch) -> None: + responses = iter( + [ + [ + {"id": 42, "payload": {"version": "older"}}, + {"id": 43, "payload": {"version": "1.2.3"}}, + ], + [{"state": "failure"}], + ] + ) + monkeypatch.setattr(delivery, "_request", lambda *_args, **_kwargs: next(responses)) + + with pytest.raises(RuntimeError, match="already pending or failed"): + delivery.reserve_delivery( + api_url="https://api.github.test", + repository="pullboxapp/pullbox", + token="token", + ref="abc123", + run_url="https://github.test/run/1", + version="1.2.3", + channel="announcements", + ) + + +def test_delivery_cli_reserves_skips_and_records_success(monkeypatch, capsys) -> None: + monkeypatch.setattr( + delivery, + "os", + SimpleNamespace( + environ={ + "GITHUB_TOKEN": "token", + "GITHUB_API_URL": "https://api.github.test", + "GITHUB_REPOSITORY": "pullboxapp/pullbox", + "GITHUB_SERVER_URL": "https://github.test", + "GITHUB_RUN_ID": "9", + "PULLBOX_RELEASE_SHA": "abc123", + } + ), + ) + monkeypatch.setattr(delivery, "reserve_delivery", lambda **_kwargs: None) + assert delivery.main(["reserve", "--channel", "changelog", "--version", "1.2.3"]) == 0 + assert capsys.readouterr().out.strip() == "skip=true" + + monkeypatch.setattr(delivery, "reserve_delivery", lambda **_kwargs: 42) + assert delivery.main(["reserve", "--channel", "changelog", "--version", "1.2.3"]) == 0 + assert capsys.readouterr().out.strip() == "deployment_id=42" + + recorded: dict[str, object] = {} + monkeypatch.setattr(delivery, "record_delivery", lambda **kwargs: recorded.update(kwargs)) + assert ( + delivery.main( + [ + "success", + "--channel", + "announcements", + "--version", + "1.2.3", + "--deployment-id", + "42", + ] + ) + == 0 + ) + assert recorded["deployment_id"] == 42 + assert recorded["description"] == "Posted Discord announcements" + + +def test_delivery_cli_requires_deployment_id_for_success(monkeypatch) -> None: + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("GITHUB_API_URL", "https://api.github.test") + monkeypatch.setenv("GITHUB_REPOSITORY", "pullboxapp/pullbox") + monkeypatch.setenv("GITHUB_SERVER_URL", "https://github.test") + monkeypatch.setenv("GITHUB_RUN_ID", "9") + + with pytest.raises(SystemExit, match="2"): + delivery.main(["success", "--channel", "changelog", "--version", "1.2.3"]) + + def test_changelog_payload_is_an_embed_with_mentions_disabled() -> None: payload = changelog_payload( "1.2.3", CHANGELOG, "https://github.com/pullboxapp/pullbox/releases/tag/v1.2.3" diff --git a/tests/unit/test_release_validator_edges.py b/tests/unit/test_release_validator_edges.py new file mode 100644 index 0000000..cf2b138 --- /dev/null +++ b/tests/unit/test_release_validator_edges.py @@ -0,0 +1,152 @@ +"""Boundary tests for release-validator configuration and classification.""" + +from __future__ import annotations + +from dataclasses import replace +from typing import TYPE_CHECKING, cast + +import pytest + +from pullbox.models.issue import IssueType +from pullbox.models.library import MatchConfidence +from pullbox.services.release_validator import ( + ReleaseValidator, + _compile_ignore_pattern, + validator_kwargs_from_eval_kwargs, +) +from tests.conftest import make_release + +if TYPE_CHECKING: + from pullbox.services.search_types import SearchEvalKwargs + + +def test_validator_kwargs_extracts_every_supported_option() -> None: + source = cast( + "SearchEvalKwargs", + { + "ignore_words": ["sample"], + "fuzzy_high_threshold": 0.9, + "fuzzy_low_threshold": 0.6, + "year_tolerance": 2, + "warn_issue_mb": 900, + "warn_collection_mb": 25, + "unrelated": "ignored", + }, + ) + + assert validator_kwargs_from_eval_kwargs(source) == { + "ignore_words": ["sample"], + "fuzzy_high_threshold": 0.9, + "fuzzy_low_threshold": 0.6, + "year_tolerance": 2, + "warn_issue_mb": 900, + "warn_collection_mb": 25, + } + assert validator_kwargs_from_eval_kwargs(cast("SearchEvalKwargs", {})) == {} + + +def test_ignore_pattern_accepts_release_separators() -> None: + pattern = _compile_ignore_pattern("covers only") + + assert pattern.search("Batman.COVERS-ONLY.cbz".lower()) + assert _compile_ignore_pattern("sampler").search("Batman sampler") + + +@pytest.mark.parametrize( + ("match_type", "similarity", "year_match", "expected"), + [ + ("exact", 1.0, True, MatchConfidence.HIGH), + ("alternate", 1.0, None, MatchConfidence.MEDIUM), + ("exact", 1.0, False, MatchConfidence.LOW), + ("starts_with", 1.0, True, MatchConfidence.HIGH), + ("starts_with", 1.0, None, MatchConfidence.MEDIUM), + ("starts_with", 1.0, False, MatchConfidence.LOW), + ("token_set", 0.9, True, MatchConfidence.MEDIUM), + ("token_subset", 0.9, False, MatchConfidence.LOW), + ("fuzzy", 0.9, True, MatchConfidence.MEDIUM), + ("fuzzy", 0.9, None, MatchConfidence.LOW), + ("fuzzy", 0.7, True, MatchConfidence.LOW), + ], +) +def test_compute_confidence_covers_each_quality_boundary( + match_type: str, + similarity: float, + year_match: bool | None, + expected: MatchConfidence, +) -> None: + assert ( + ReleaseValidator._compute_confidence( + match_type=match_type, + similarity=similarity, + year_match=year_match, + ) + == expected + ) + + +@pytest.mark.parametrize( + ("category", "reason_fragment"), + [ + ("Audio", "Non-comic category"), + ("5000, 5040", "Non-comic category"), + ], +) +def test_validate_all_results_preserves_category_rejections( + category: str, + reason_fragment: str, +) -> None: + validator = ReleaseValidator() + release = replace(make_release("Batman 005 [2024] [Digital]"), category=category) + + matched, rejected = validator.validate_all_results( + [release], + wanted_series="Batman", + wanted_issue=5, + wanted_year=2024, + ) + + assert matched == [] + assert reason_fragment in (rejected[0].rejection_reason or "") + + +def test_book_category_remains_eligible_for_matching() -> None: + validator = ReleaseValidator() + release = replace( + make_release("Batman 005 [2024] [Digital]"), + category="Books/Comics", + ) + + matched = validator.validate_results( + [release], + wanted_series="Batman", + wanted_issue=5, + wanted_year=2024, + ) + + assert len(matched) == 1 + + +def test_non_comic_title_tag_is_rejected_before_semantic_matching() -> None: + validator = ReleaseValidator() + + matched, rejected = validator.validate_all_results( + [make_release("Batman 005 [2024] [Audiobook]")], + wanted_series="Batman", + wanted_issue=5, + wanted_year=2024, + wanted_issue_type=IssueType.ISSUE, + ) + + assert matched == [] + assert "Non-comic content" in (rejected[0].rejection_reason or "") + + +def test_reject_builds_safe_parse_fallback() -> None: + release = make_release("unparseable") + + result = ReleaseValidator._reject(release, "No parse") + + assert result.is_match is False + assert result.parsed.original_title == "unparseable" + assert result.parsed.series_name is None + assert result.rejection_reason == "No parse" diff --git a/tests/unit/test_semantic_matching_edges.py b/tests/unit/test_semantic_matching_edges.py new file mode 100644 index 0000000..e1dbdd0 --- /dev/null +++ b/tests/unit/test_semantic_matching_edges.py @@ -0,0 +1,140 @@ +"""Boundary coverage for semantic matching normalization and scoring.""" + +from __future__ import annotations + +from pullbox.core.source_metadata import MetadataSignal, SourceMetadata +from pullbox.models.issue import IssueType +from pullbox.models.library import MatchConfidence +from pullbox.providers.base import SeriesSearchResult +from pullbox.services.semantic_matching import ( + SearchPolicy, + SemanticMatchEngine, + _allows_implicit_issue_one, + _as_float, + _as_int, + _is_safe_single_word_collection_prefix, + _normalize_volume_number, + semantic_config_from_eval_kwargs, +) + + +def test_numeric_matching_configuration_coercion_is_bounded() -> None: + assert [_as_float(value) for value in (None, True, 2, 2.5, "3.5", "bad", object())] == [ + None, + 1.0, + 2.0, + 2.5, + 3.5, + None, + None, + ] + assert [_as_int(value) for value in (None, True, 2, 2.5, "3", "bad", object())] == [ + None, + 1, + 2, + 2, + 3, + None, + None, + ] + config = semantic_config_from_eval_kwargs( + { + "ignore_words": "not-a-list", + "fuzzy_high_threshold": "0.9", + "year_tolerance": "2", + } + ) + assert config.ignore_words == [] + assert config.fuzzy_high_threshold == 0.9 + assert config.year_tolerance == 2 + + +def test_volume_number_normalization_accepts_tokens_and_rejects_noise() -> None: + assert _normalize_volume_number("2") == 2.0 + assert _normalize_volume_number("Volume 03.5 Deluxe") == 3.5 + assert _normalize_volume_number("Deluxe") is None + + +def test_semantic_confidence_boundaries_are_explicit() -> None: + engine = SemanticMatchEngine() + assert ( + engine._compute_confidence(match_type="starts_with", similarity=1.0, year_match=None) + == MatchConfidence.MEDIUM + ) + assert ( + engine._compute_confidence(match_type="starts_with", similarity=1.0, year_match=False) + == MatchConfidence.LOW + ) + assert ( + engine._compute_confidence(match_type="token_set", similarity=1.0, year_match=True) + == MatchConfidence.MEDIUM + ) + assert ( + engine._compute_confidence(match_type="fuzzy", similarity=0.99, year_match=False) + == MatchConfidence.LOW + ) + + +def test_series_shape_scoring_rewards_credible_size_and_penalizes_bad_shape() -> None: + engine = SemanticMatchEngine() + collection = SourceMetadata( + original_title="Example Vol 2", + series_name="Example", + issue_number=2.0, + issue_type=IssueType.TPB, + issue_count_hint=2, + ) + compact = SeriesSearchResult("1", "Example", 2026, None, 2, "ended", None, None) + oversized = SeriesSearchResult("2", "Example", 2026, None, 12, "continuing", None, None) + assert engine._series_shape_adjustment(collection, compact) > 0 + assert engine._series_shape_adjustment(collection, oversized) < 0 + + standard = collection.model_copy( + update={"issue_type": IssueType.ISSUE, "issue_count_hint": None, "issue_number": None} + ) + singleton = SeriesSearchResult("3", "Example", 2026, None, 1, "complete", None, None) + short = SeriesSearchResult("4", "Example", 2026, None, 3, "finished", None, None) + assert engine._series_shape_adjustment(standard, singleton) < 0 + assert engine._series_shape_adjustment(standard, short) < 0 + + +def test_single_word_collection_prefix_requires_exact_safe_shape() -> None: + metadata = SourceMetadata( + original_title="Taproot A Story About A Gardener", + series_name="Taproot A Story About A Gardener", + year=2026, + issue_type=IssueType.TPB, + signals={"issue_type": MetadataSignal.RELEASE_TITLE}, + ) + assert _is_safe_single_word_collection_prefix( + policy=SearchPolicy(), + metadata=metadata, + wanted_series="Taproot", + wanted_issue=1.0, + wanted_year=2026, + wanted_issue_type=IssueType.TPB, + wanted_series_issue_count=1, + ) + assert not _is_safe_single_word_collection_prefix( + policy=SearchPolicy(), + metadata=metadata, + wanted_series="Taproot Deluxe", + wanted_issue=1.0, + wanted_year=2026, + wanted_issue_type=IssueType.TPB, + wanted_series_issue_count=1, + ) + assert not _is_safe_single_word_collection_prefix( + policy=SearchPolicy(), + metadata=metadata.model_copy(update={"series_name": "Elsewhere Deluxe Edition"}), + wanted_series="Taproot", + wanted_issue=1.0, + wanted_year=2026, + wanted_issue_type=IssueType.TPB, + wanted_series_issue_count=1, + ) + + +def test_implicit_issue_one_rejects_collection_types() -> None: + metadata = SourceMetadata(original_title="Example", issue_type=IssueType.TPB) + assert not _allows_implicit_issue_one(metadata) diff --git a/tests/unit/test_source_metadata.py b/tests/unit/test_source_metadata.py index 24e3038..67f14dc 100644 --- a/tests/unit/test_source_metadata.py +++ b/tests/unit/test_source_metadata.py @@ -408,6 +408,20 @@ def test_tpb_volume_subtitle_release_uses_base_series_and_volume_issue(self) -> "issue_number": 9.0, } + def test_volume_subtitle_with_preparsed_issue_still_uses_base_series(self) -> None: + metadata = SourceMetadataExtractor().from_release_title( + "Babyteeth (2017) Vol 03 - Vol. 3 - Cradle.cbz" + ) + + assert metadata.series_name == "Babyteeth" + assert metadata.issue_number == 3.0 + assert metadata.issue_type == IssueType.VOLUME + assert metadata.diagnostics["volume_subtitle_hint"] == { + "base_series": "Babyteeth", + "subtitle": "Vol 3 - Cradle", + "issue_number": 3.0, + } + def test_publisher_prefixed_volume_subtitle_release_uses_actual_base_series(self) -> None: metadata = SourceMetadataExtractor().from_release_title( "Image.Comics-Drifter.Vol.01.Out.Of.The.Night.2015.Retail.Comic.eBook-BitBook" diff --git a/tests/unit/test_story_arc_models.py b/tests/unit/test_story_arc_models.py index 5ebf16c..a614f19 100644 --- a/tests/unit/test_story_arc_models.py +++ b/tests/unit/test_story_arc_models.py @@ -149,7 +149,7 @@ def test_story_arc_has_policy_monitoring_and_provenance_columns() -> None: assert columns.sync_enabled.default.arg is False assert columns.policy_schema_version.nullable is True assert columns.revision.default.arg == 1 - assert next(iter(columns.target_library_root_id.foreign_keys)).ondelete == "SET NULL" + assert next(iter(columns.target_library_root_id.foreign_keys)).ondelete == "RESTRICT" assert next(iter(columns.source_import_job_id.foreign_keys)).ondelete == "SET NULL" for column_name in ("source_kind", "lifecycle"):