Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
3119238
feat: safely remove disabled library roots
Sep 5, 2026
acff1d5
fix: show reading progress for large compendiums
Sep 5, 2026
9bf0309
fix(import): harden large Mylar recovery
Sep 6, 2026
3b671fa
feat(import): add clean library recovery workflow
Sep 7, 2026
d02402c
fix(import): align dropdowns with shared UI contract
Sep 8, 2026
c6011e8
feat(import): simplify guided collection workflow
Sep 9, 2026
2c0aeb3
feat(import): simplify Mylar preflight issue resolution
Sep 9, 2026
7f8b6ae
feat(import): simplify managed destination choices
Sep 9, 2026
84770c6
fix(import): prevent preflight controls from flashing
Sep 9, 2026
cac9183
feat(import): recover misplaced Mylar issue files
Sep 9, 2026
a540264
fix(import): streamline review step controls
Sep 9, 2026
6d18556
fix(import): reconcile misplaced Mylar series
Sep 10, 2026
3f8430b
fix(import): reconcile stale Mylar issue IDs
Sep 10, 2026
48dbbec
fix(import): reconcile unqualified Mylar volume identities
Sep 10, 2026
1bd61aa
fix(import): recover safety-approved review items
Sep 10, 2026
e6cdde6
fix(import): reconcile renamed Mylar issue files
Sep 10, 2026
2a86fb3
feat(import): centralize completed import follow-up
Sep 10, 2026
47c8edb
build(ui): refresh generated Tailwind styles
Sep 10, 2026
75f0d84
fix(ci): restore import review contracts
Sep 10, 2026
4d99cff
fix(ui): restore async workflow state
Sep 10, 2026
a67cc3b
test(ci): restore required coverage
Sep 10, 2026
2ace463
ci(security): review current DHI Expat findings
Sep 10, 2026
5ed0d68
fix(import): restore archived history toggle
Sep 10, 2026
ed0655f
feat(import): run clean library builds in background
Sep 10, 2026
d7f2dd1
fix(settings): align library root actions
Sep 10, 2026
afd3559
fix(import): resolve folder in-place root setup
Sep 11, 2026
4a1a6e5
fix(ci): restore full release gate compliance
Sep 11, 2026
e15a7e1
fix(security): escape import rematch poll target
Sep 11, 2026
5127b62
fix(import): resume hydration and refresh arc recovery
Sep 11, 2026
dce2d88
test(import): await hydration worker shutdown
Sep 11, 2026
d1fd907
fix(import): preserve adoption file dependents
Sep 11, 2026
7cc76b8
fix(import): align follow-up action eligibility
Sep 11, 2026
c5e87eb
fix(import): guard clean library execution
Sep 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .grype.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 18 additions & 2 deletions alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
149 changes: 149 additions & 0 deletions alembic/versions/n5h6i7j8k901_protect_library_root_removal.py
Original file line number Diff line number Diff line change
@@ -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")
12 changes: 12 additions & 0 deletions docs/development/ARCHITECTURE_OVERVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading