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:"Library organizer
++ Copy {{ "{:,}".format(clean_library_reference_count) }} referenced file{{ '' if clean_library_reference_count == 1 else 's' }} across + {{ "{:,}".format(clean_library_reference_series_count|default(0)) }} series into a separate Pullbox-managed root. Pullbox applies current naming, conversion, and ComicInfo rules while leaving every original source file unchanged. +
+ {{ clean_library_reference_bytes|default(0)|filesizeformat }} + {% if clean_library_mixed_folder_repair_count|default(0) > 0 %} +The destination must not overlap the original source. Validate the clean library before retiring the legacy folders.
+Current import settings
+Building your clean library
+ ++ You can close this window or leave the page. The build continues in the background and remains visible in Background activity. +
+ +{{ 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 %}Import #{{ selected_follow_up_job.id }}
+Resolve the remaining matching, recovery, and cleanup groups from this import. Completed work disappears from this page automatically.
+Matching
+Only unresolved records from this import are shown.
+This import is resolved
+There is no remaining follow-up for this import. Return to all follow-up to continue.
+| Import source | +Type | +Status | +Completed | ++ |
|---|---|---|---|---|
| + + {{ follow_up_job.source_path }} + Import #{{ follow_up_job.id }} + + | +{{ 'Mylar3' if follow_up_job.source_type.value == 'mylar3' else 'Folder' }} | +{{ follow_up_job.status.value|replace('_', ' ')|title }} | + ++ + + + | +
No import follow-up
+Every current import is resolved. New matching, recovery, or cleanup work will appear here.
+{% 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 %}