diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index 3743cd0..837b093 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -6,18 +6,150 @@ on: # Requires repo secret RELEASE_TOKEN — a Forgejo PAT with scopes: # - write:package, read:package (for docker push to git.fabledsword.com) -# - write:release (for future release-cutting workflows) +# - write:release (for ext- release asset cache) # - write:issue (for future issue-management automation) # The injected GITHUB_TOKEN cannot be used — it lacks write:package. jobs: - build-web: + # Sign-or-fetch-from-cache: signs the extension via AMO if no ext- + # Forgejo release exists yet, otherwise downloads the cached signed XPI. + # Result is uploaded as an Actions artifact for build-web to consume. + # + # Why this lives in build.yml (not a separate workflow): the merge-commit's + # docker image tagged `:latest` MUST carry the XPI. A separate sign workflow + # racing build.yml leaves `:latest` without the XPI for ~5min (until the + # commit-back triggers another build). Inline ordering eliminates the race. + # Cache strategy: Forgejo Release Assets — picked 2026-05-25 over Generic + # Packages (cleaner API surface) and commit-back-to-side-branch (no extra + # branch to manage). AMO blocks re-signing the same version (returns 409), + # so signing is intentionally one-shot per version bump. + sign-extension: + if: github.ref == 'refs/heads/main' runs-on: python-ci container: image: git.fabledsword.com/bvandeusen/ci-python:3.14 steps: - uses: actions/checkout@v4 + - name: Resolve extension version + id: extver + run: | + VERSION=$(grep -E '"version"' extension/package.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/') + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Resolved extension version: $VERSION" + + - name: Check Forgejo release-asset cache + id: cache + env: + TOKEN: ${{ secrets.RELEASE_TOKEN }} + run: | + set -eu + VERSION=${{ steps.extver.outputs.version }} + STATUS=$(curl -s -o release.json -w "%{http_code}" \ + -H "Authorization: token $TOKEN" \ + "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/tags/ext-$VERSION" || echo 000) + echo "Tag lookup HTTP status: $STATUS" + if [ "$STATUS" = "200" ]; then + ASSET_ID=$(jq -r '.assets[]? | select(.name | test("\\.xpi$")) | .id' release.json | head -1) + if [ -n "$ASSET_ID" ] && [ "$ASSET_ID" != "null" ]; then + echo "cached=true" >> "$GITHUB_OUTPUT" + echo "asset_id=$ASSET_ID" >> "$GITHUB_OUTPUT" + echo "Cached XPI exists at ext-$VERSION (asset id $ASSET_ID); skipping AMO sign" + else + echo "cached=false" >> "$GITHUB_OUTPUT" + echo "Release ext-$VERSION exists but has no .xpi asset; will re-sign + re-upload" + fi + else + echo "cached=false" >> "$GITHUB_OUTPUT" + echo "No release named ext-$VERSION; will sign via AMO and upload" + fi + + - name: Download cached signed XPI (cache hit) + if: steps.cache.outputs.cached == 'true' + env: + TOKEN: ${{ secrets.RELEASE_TOKEN }} + run: | + set -eu + mkdir -p extension/web-ext-artifacts + curl -sL -H "Authorization: token $TOKEN" \ + -o "extension/web-ext-artifacts/fabledcurator-${{ steps.extver.outputs.version }}.xpi" \ + "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/assets/${{ steps.cache.outputs.asset_id }}" + ls -la extension/web-ext-artifacts/ + + - name: Sign via AMO (cache miss) + if: steps.cache.outputs.cached != 'true' + run: | + cd extension && npm install --no-save --no-audit --no-fund && npm run sign + env: + WEB_EXT_API_KEY: ${{ secrets.MOZILLA_AMO_JWT_KEY }} + WEB_EXT_API_SECRET: ${{ secrets.MOZILLA_AMO_JWT_SECRET }} + + - name: Upload signed XPI to ext- release (cache miss) + if: steps.cache.outputs.cached != 'true' + env: + TOKEN: ${{ secrets.RELEASE_TOKEN }} + run: | + set -eux + VERSION=${{ steps.extver.outputs.version }} + # AMO renames signed XPIs with its internal addon-id-safe-string; + # canonicalize to fabledcurator-.xpi so the FC server's + # whitelist (backend/app/frontend.py expects 'fabledcurator-*.xpi') + # keeps working. + SIGNED=$(ls extension/web-ext-artifacts/*.xpi | head -1) + XPI="extension/web-ext-artifacts/fabledcurator-$VERSION.xpi" + cp "$SIGNED" "$XPI" + # Find-or-create the ext- release. + STATUS=$(curl -s -o release.json -w "%{http_code}" \ + -H "Authorization: token $TOKEN" \ + "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/tags/ext-$VERSION" || echo 000) + if [ "$STATUS" != "200" ]; then + curl -s -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"ext-$VERSION\",\"name\":\"Extension $VERSION (signed XPI cache)\",\"body\":\"Internal cache for the signed XPI consumed by build.yml's build-web job. Not a user-facing FC release.\",\"target_commitish\":\"main\"}" \ + -o release.json \ + "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases" + fi + RELEASE_ID=$(jq -r '.id' release.json) + test -n "$RELEASE_ID" && test "$RELEASE_ID" != "null" + curl -s -X POST -H "Authorization: token $TOKEN" \ + -F "attachment=@$XPI" \ + "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/$RELEASE_ID/assets?name=fabledcurator-$VERSION.xpi" + echo "Uploaded fabledcurator-$VERSION.xpi to ext-$VERSION release" + + - name: Upload XPI as Actions artifact (handoff to build-web) + uses: actions/upload-artifact@v4 + with: + name: signed-extension + path: extension/web-ext-artifacts/fabledcurator-*.xpi + retention-days: 1 + + build-web: + needs: [sign-extension] + # sign-extension is main-only; on dev it's skipped, build-web still runs. + if: always() && (needs.sign-extension.result == 'success' || needs.sign-extension.result == 'skipped') + runs-on: python-ci + container: + image: git.fabledsword.com/bvandeusen/ci-python:3.14 + steps: + - uses: actions/checkout@v4 + + - name: Download signed extension (main only) + if: github.ref == 'refs/heads/main' + uses: actions/download-artifact@v4 + with: + name: signed-extension + path: extension/web-ext-artifacts/ + + - name: Place signed XPI in build context (main only) + if: github.ref == 'refs/heads/main' + run: | + set -eux + VERSION=$(grep -E '"version"' extension/package.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/') + XPI=$(ls extension/web-ext-artifacts/*.xpi | head -1) + mkdir -p frontend/public/extension + cp "$XPI" "frontend/public/extension/fabledcurator-$VERSION.xpi" + cp "$XPI" "frontend/public/extension/fabledcurator-latest.xpi" + ls -la frontend/public/extension/ + - name: Determine tag id: tag run: | diff --git a/.forgejo/workflows/extension.yml b/.forgejo/workflows/extension.yml index ef007c3..ea12cf2 100644 --- a/.forgejo/workflows/extension.yml +++ b/.forgejo/workflows/extension.yml @@ -1,15 +1,19 @@ name: extension +# Lint-only workflow. The sign-and-publish dance moved into build.yml's +# `sign-extension` job (2026-05-25) — `:latest` now always bundles the XPI +# because sign-extension runs as a build-web dependency in the SAME workflow, +# eliminating the prior race between build.yml and a separate extension.yml. +# Signed XPIs are cached in Forgejo Release Assets named `ext-`. on: push: branches: [dev, main] - # Self-retriggering: include the workflow file itself so edits to - # this workflow (filename-glob fixes, env tweaks, etc.) run the - # sign-and-publish dance on next push without needing a manual - # Forgejo dispatch. Side-effect commits (`ext: publish signed XPI`) - # only touch frontend/public/extension/ so they DON'T re-trigger. paths: - 'extension/**' - '.forgejo/workflows/extension.yml' + pull_request: + branches: [main] + paths: + - 'extension/**' workflow_dispatch: jobs: @@ -23,74 +27,3 @@ jobs: run: cd extension && npm install --no-save --no-audit --no-fund - name: Lint run: cd extension && npm run lint - - sign-and-publish: - needs: lint - if: github.ref == 'refs/heads/main' - runs-on: python-ci - container: - image: node:22-bookworm-slim - steps: - - uses: actions/checkout@v4 - with: - token: ${{ secrets.RELEASE_TOKEN }} - - name: Install web-ext + git - run: | - apt-get update && apt-get install -y --no-install-recommends git ca-certificates - cd extension && npm install --no-save --no-audit --no-fund - - name: Sign XPI - run: cd extension && npm run sign - env: - WEB_EXT_API_KEY: ${{ secrets.MOZILLA_AMO_JWT_KEY }} - WEB_EXT_API_SECRET: ${{ secrets.MOZILLA_AMO_JWT_SECRET }} - - name: Commit signed XPI to frontend/public/extension/ - run: | - set -ex - # AMO renames signed XPIs using its internal addon-id-safe-string - # (e.g. 997017ca3e104e30a75a-1.0.1.xpi), NOT our gecko ID. Match - # any *.xpi in the artifacts dir — fresh CI runner means there's - # exactly one — and rename it on copy so the FC server's - # whitelist (backend/app/frontend.py expects 'fabledcurator-*.xpi') - # keeps working. - # Diagnostic instrumentation 2026-05-25: a prior run (id 1731) - # reported success without producing an `ext: publish signed XPI` - # commit on main. Tracing the cwd, artifacts dir, version - # extraction, staging state, and push response so the next run's - # log explains the gap. - echo "=== cwd ===" - pwd - echo "=== ref / branch ===" - echo "ref: ${GITHUB_REF:-unset} sha: ${GITHUB_SHA:-unset}" - git log --oneline -3 || true - echo "=== artifacts dir ===" - ls -la extension/web-ext-artifacts/ 2>&1 || echo "(dir missing)" - XPI=$(ls extension/web-ext-artifacts/*.xpi 2>/dev/null | head -1) - echo "XPI=$XPI" - if [ -z "$XPI" ]; then - echo "No XPI produced by web-ext sign — exiting" - exit 1 - fi - VERSION=$(grep -E '"version"' extension/package.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/') - echo "VERSION=$VERSION" - DEST="frontend/public/extension/fabledcurator-${VERSION}.xpi" - echo "DEST=$DEST" - mkdir -p frontend/public/extension - # Wipe any prior versions so the directory doesn't grow each release. - rm -f frontend/public/extension/fabledcurator-*.xpi - cp "$XPI" "$DEST" - cp "$XPI" "frontend/public/extension/fabledcurator-latest.xpi" - echo "=== frontend/public/extension/ after copy ===" - ls -la frontend/public/extension/ - git config user.name "FC extension CI" - git config user.email "noreply@fabledsword.com" - git add frontend/public/extension/ - echo "=== git status after add ===" - git status --short - echo "=== diff --cached --stat ===" - git diff --cached --stat || true - if git diff --cached --quiet; then - echo "No changes to commit (frontend/public/extension/ matches HEAD)" - else - git commit -m "ext: publish signed XPI fabledcurator-${VERSION}.xpi" - git push -v origin HEAD:main - fi diff --git a/alembic/versions/0019_import_batch_refreshed.py b/alembic/versions/0019_import_batch_refreshed.py new file mode 100644 index 0000000..1770daa --- /dev/null +++ b/alembic/versions/0019_import_batch_refreshed.py @@ -0,0 +1,38 @@ +"""import_batch.refreshed counter for deep-scan sidecar re-application + +Revision ID: 0019 +Revises: 0018 +Create Date: 2026-05-25 + +Adds a `refreshed` counter to `import_batch`, mirroring the existing +`imported`/`skipped`/`failed`/`attachments` columns. Deep scan now +re-applies sidecar metadata to already-imported files (the IR feature +that didn't make the FC port the first time); a "refreshed" outcome +increments this counter so the UI can surface "X new, Y refreshed" +instead of the misleading "Scan complete — no new files" message. + +server_default=0 backfills existing rows in place — no UPDATE needed. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0019" +down_revision: Union[str, None] = "0018" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "import_batch", + sa.Column( + "refreshed", sa.Integer(), + nullable=False, server_default=sa.text("0"), + ), + ) + + +def downgrade() -> None: + op.drop_column("import_batch", "refreshed") diff --git a/backend/app/api/import_admin.py b/backend/app/api/import_admin.py index 68ae32a..367305b 100644 --- a/backend/app/api/import_admin.py +++ b/backend/app/api/import_admin.py @@ -53,6 +53,7 @@ async def status(): "imported": active.imported, "skipped": active.skipped, "failed": active.failed, + "refreshed": active.refreshed, "started_at": active.started_at.isoformat(), } return jsonify(payload) diff --git a/backend/app/models/import_batch.py b/backend/app/models/import_batch.py index d26db4c..474f111 100644 --- a/backend/app/models/import_batch.py +++ b/backend/app/models/import_batch.py @@ -26,6 +26,10 @@ class ImportBatch(Base): skipped: Mapped[int] = mapped_column(Integer, nullable=False, default=0) failed: Mapped[int] = mapped_column(Integer, nullable=False, default=0) attachments: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + # Deep-scan only: count of already-imported files whose sidecar metadata + # got re-applied this run (post/source/provenance upsert). Stays 0 on + # quick-scan batches. See `Importer.import_one(deep_scan=True)`. + refreshed: Mapped[int] = mapped_column(Integer, nullable=False, default=0) status: Mapped[str] = mapped_column(String(16), nullable=False, default="running", index=True) # running | complete | cancelled diff --git a/backend/app/services/importer.py b/backend/app/services/importer.py index cf4c0c4..50fe479 100644 --- a/backend/app/services/importer.py +++ b/backend/app/services/importer.py @@ -51,7 +51,14 @@ class SkipReason(StrEnum): @dataclass(frozen=True) class ImportResult: - status: str # 'imported'|'skipped'|'failed'|'superseded'|'attached' + # 'imported' — new ImageRecord row created + # 'superseded' — existing ImageRecord row got the new file (larger) + sidecar + # 'attached' — non-media saved as PostAttachment + # 'refreshed' — deep scan re-applied sidecar / filled NULL phash / NULL + # artist on an already-imported row (no new ImageRecord) + # 'skipped' — no work done (true duplicate, too small, etc.) + # 'failed' — pipeline error + status: str image_id: int | None = None skip_reason: SkipReason | None = None error: str | None = None @@ -151,6 +158,49 @@ class Importer: self.settings = settings self.deep = deep self.attachments = AttachmentStore(images_root) + # phash near-dup candidate cache. Archive imports call _import_media + # per-member; without this cache the per-member SELECT *FROM + # image_record WHERE phash IS NOT NULL fetch repeats N times and a + # large library × many-member archive blew past soft_time_limit + # (300s) — operator-flagged 2026-05-25. Loaded lazily on first + # need, appended to on every imported/superseded outcome, never + # invalidated mid-Importer (Importer instances are per-task / + # per-archive-import so cross-instance staleness is harmless). + self._phash_candidates: list[tuple] | None = None + + def _phash_candidates_cache(self) -> list[tuple]: + """Cached `(phash, width, height, id)` rows from image_record. + Loaded on first call, appended-to on subsequent imported/ + superseded outcomes. Soft-timeout pattern: an archive with N + members + a library of M existing rows used to do N × M-row + fetches (operator-flagged 2026-05-25); now it's exactly one. + + The per-task lifecycle of Importer (instantiated fresh by + import_media_file) bounds the cache's staleness window: cross- + process changes (other workers importing concurrently) won't + be reflected, but that's the same race the un-cached version + had — `find_similar` is best-effort anyway.""" + if self._phash_candidates is None: + rows = self.session.execute( + select( + ImageRecord.phash, + ImageRecord.width, + ImageRecord.height, + ImageRecord.id, + ).where(ImageRecord.phash.is_not(None)) + ).all() + self._phash_candidates = [ + (r.phash, r.width or 0, r.height or 0, r.id) for r in rows + ] + return self._phash_candidates + + def _phash_cache_append(self, phash, width, height, image_id) -> None: + """Append a freshly-imported row to the cache so subsequent + members of the same archive can match against it.""" + if self._phash_candidates is not None and phash is not None: + self._phash_candidates.append( + (phash, width or 0, height or 0, image_id) + ) def import_one(self, source: Path) -> ImportResult: """Dispatch by kind. Media → normal pipeline. Archive → extract @@ -349,18 +399,7 @@ class Importer: error=f"PIL load failed during phash compute: {exc}", ) if phash is not None: - cand_rows = self.session.execute( - select( - ImageRecord.phash, - ImageRecord.width, - ImageRecord.height, - ImageRecord.id, - ).where(ImageRecord.phash.is_not(None)) - ).all() - candidates = [ - (c.phash, c.width or 0, c.height or 0, c.id) - for c in cand_rows - ] + candidates = self._phash_candidates_cache() rel, match_id = find_similar( phash, width or 0, height or 0, candidates, self.settings.phash_threshold, @@ -394,6 +433,7 @@ class Importer: ) self.session.add(record) self.session.flush() + self._phash_cache_append(phash, width, height, record.id) # Folder→artist (anchored to attribution_path). artist = None @@ -417,13 +457,27 @@ class Importer: ) -> ImportResult: """Deep scan: backfill phash/provenance/artist on an already-imported record. METADATA ONLY — never re-runs the pHash - near-dup / supersede path. NULL-only, idempotent.""" + near-dup / supersede path. NULL-only on phash/artist, additive on + sidecar Post/Source/ImageProvenance (via _apply_sidecar). + Idempotent: a second deep-scan over the same file finds nothing + to refresh and is a no-op. + + Returns status="refreshed" so the UI can surface the work done + instead of the prior misleading "skipped/duplicate_hash" reading. + Operator-flagged 2026-05-25 — IR has had this; FC inherited it + as a no-op skip during the original port and the UI showed deep + scan as "completed with no changes" even when sidecar metadata + actually got re-applied to N existing rows. + """ if existing.phash is None and not is_video(source): try: with Image.open(source) as im: ph = compute_phash(im) if ph is not None: existing.phash = ph + # Promoted from NULL to non-NULL → cache is now stale + # (this row would newly qualify for the candidates set). + self._phash_candidates = None except Exception as exc: log.warning("deep rephash failed for %s: %s", source, exc) @@ -436,10 +490,7 @@ class Importer: self._apply_sidecar(existing, attribution_path, artist) self.session.commit() - return ImportResult( - status="skipped", skip_reason=SkipReason.duplicate_hash, - image_id=existing.id, error="deep: re-derived", - ) + return ImportResult(status="refreshed", image_id=existing.id) def attach_in_place( self, @@ -523,18 +574,7 @@ class Importer: except Exception: phash = None if phash is not None: - cand_rows = self.session.execute( - select( - ImageRecord.phash, - ImageRecord.width, - ImageRecord.height, - ImageRecord.id, - ).where(ImageRecord.phash.is_not(None)) - ).all() - candidates = [ - (c.phash, c.width or 0, c.height or 0, c.id) - for c in cand_rows - ] + candidates = self._phash_candidates_cache() rel, match_id = find_similar( phash, width or 0, height or 0, candidates, self.settings.phash_threshold, @@ -569,6 +609,7 @@ class Importer: record.artist_id = artist.id self.session.add(record) self.session.flush() + self._phash_cache_append(phash, width, height, record.id) # Sidecar provenance (best-effort). When `source` is passed, link # the post to that subscription Source instead of creating a new @@ -784,6 +825,10 @@ class Importer: # created_at intentionally preserved; updated_at auto-bumps. self.session.flush() self.session.commit() + # The phash candidate cache (used to avoid N+1 selects during + # archive imports) is now stale for `existing.id` — the row's + # phash/dimensions changed. Invalidate; the next call re-fetches. + self._phash_candidates = None # Sidecar enrichment from the new (larger) file's location. # _apply_sidecar resolves artist from the sidecar itself if the diff --git a/backend/app/tasks/import_file.py b/backend/app/tasks/import_file.py index 7fd1066..e0752e8 100644 --- a/backend/app/tasks/import_file.py +++ b/backend/app/tasks/import_file.py @@ -29,10 +29,12 @@ IMAGES_ROOT = Path("/images") def _map_result_to_status(result): """(ImportTask.status, should_requeue_ml_and_thumb) for an ImportResult. 'superseded' = the kept row's file/ML changed → complete + re-derive. - 'attached' = a non-art file preserved → complete, no ML/thumb.""" + 'attached' = a non-art file preserved → complete, no ML/thumb. + 'refreshed' = deep scan refreshed sidecar/phash on an existing row → + complete, no ML/thumb re-derive (file/pixels unchanged).""" if result.status in ("imported", "superseded"): return ("complete", True) - if result.status == "attached": + if result.status in ("attached", "refreshed"): return ("complete", False) if result.status == "skipped": return ("skipped", False) @@ -138,6 +140,15 @@ def _do_import(session, task, import_task_id: int) -> dict: task.result_image_id = result.image_id counter_col_name = "imported" counter_col = ImportBatch.imported + elif result.status == "refreshed": + # Deep-scan rederive: existing row got phash/artist/sidecar + # refreshed. Task is complete (no further work), but counted in + # `refreshed` not `imported` so the UI can surface the actual + # work done. operator-flagged 2026-05-25. + task.status = "complete" + task.result_image_id = result.image_id + counter_col_name = "refreshed" + counter_col = ImportBatch.refreshed elif result.status == "attached": task.status = "complete" counter_col_name = "attachments" diff --git a/backend/app/tasks/scan.py b/backend/app/tasks/scan.py index 6aacde0..2e5b612 100644 --- a/backend/app/tasks/scan.py +++ b/backend/app/tasks/scan.py @@ -59,16 +59,25 @@ def scan_directory(self, triggered_by: str = "manual", session.flush() batch_id = batch.id - # Skip-set: any source_path that already has a non-failed ImportTask - # row. Re-running scan_directory must not re-enqueue files the - # importer has already handled (or is currently handling); doing so - # creates duplicate work and inflates the queue. Failed prior tasks - # are eligible for retry. + # Skip-set behavior splits by mode (operator-flagged 2026-05-25): + # + # quick: any non-failed prior ImportTask (active OR finished) is + # skipped — quick scan only does new-file enqueue, so re-touching + # already-imported files is wasted work. + # + # deep: ONLY currently-in-flight tasks (pending/queued/processing) + # are skipped. Completed and skipped tasks ARE re-queued because + # deep scan exists precisely to re-touch already-imported files + # (refresh sidecar metadata, fill NULL phash, fill NULL artist + # via Importer._deep_rederive). Matches IR's deep-scan behavior. + active_statuses = ["pending", "queued", "processing"] + if mode == "deep": + skip_statuses = active_statuses + else: + skip_statuses = active_statuses + ["complete", "skipped"] non_failed_existing = set(session.execute( select(ImportTask.source_path).where( - ImportTask.status.in_( - ["pending", "queued", "processing", "complete", "skipped"] - ), + ImportTask.status.in_(skip_statuses), ) ).scalars().all()) diff --git a/extension/manifest.json b/extension/manifest.json index bf740eb..a9d1220 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "FabledCurator", - "version": "1.0.1", + "version": "1.0.2", "description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.", "browser_specific_settings": { diff --git a/extension/package.json b/extension/package.json index 181bb0f..463151d 100644 --- a/extension/package.json +++ b/extension/package.json @@ -1,6 +1,6 @@ { "name": "fabledcurator-extension", - "version": "1.0.1", + "version": "1.0.2", "private": true, "description": "Firefox extension for FabledCurator", "scripts": { diff --git a/frontend/src/components/settings/ImportTriggerPanel.vue b/frontend/src/components/settings/ImportTriggerPanel.vue index 963d456..8873d92 100644 --- a/frontend/src/components/settings/ImportTriggerPanel.vue +++ b/frontend/src/components/settings/ImportTriggerPanel.vue @@ -10,6 +10,9 @@ {{ store.activeBatch.scan_mode === 'deep' ? 'Deep scanning' : 'Scanning' }} {{ store.activeBatch.source_path || '/import' }} — imported {{ store.activeBatch.imported }}, + skipped {{ store.activeBatch.skipped }}, failed {{ store.activeBatch.failed }} / {{ store.activeBatch.total_files }} files @@ -26,11 +29,12 @@

Quick scan walks /import and enqueues - new files only (skips paths already on a non-failed ImportTask). - Deep scan additionally chains a phash backfill - across the existing library — use after bulk-imports to catch - near-duplicates that slipped through. Both modes route non-media - + sidecar pairs through PostAttachment capture. + new files only. + Deep scan additionally re-walks already-imported + files so updated sidecar metadata (post title/date/attribution) and + previously-NULL phashes / artist links get refreshed. Use after + bulk-downloading fresh sidecars for existing content. Both modes + route non-media + sidecar pairs through PostAttachment capture. An active batch is in progress. Wait for it to finish, or click diff --git a/frontend/src/stores/import.js b/frontend/src/stores/import.js index 6b4dd04..64e5146 100644 --- a/frontend/src/stores/import.js +++ b/frontend/src/stores/import.js @@ -66,21 +66,42 @@ export const useImportStore = defineStore('import', () => { // batch flashes 'running' for <100ms then 'complete' before the // first refreshStatus() lands; UI never sees the active state). const label = mode === 'deep' - ? 'Deep scan triggered (pHash backfill chained)' + ? 'Deep scan triggered (re-applying sidecar metadata + filling NULL phash/artist on existing rows)' : mode === 'verify' ? 'Library verify triggered' : 'Quick scan triggered' window.__fcToast?.({ text: label, type: 'success' }) await refreshStatus() - // Re-poll twice over ~5s to catch quick-finalize transitions and - // surface a result toast either way. Skip the "no new files" hint - // for 'verify' since it doesn't walk the import_root. + // Re-poll twice over ~5s and produce an HONEST follow-up toast. + // Operator-flagged 2026-05-25: the prior "no new files" message was + // misleading because deep scan IS doing work (refresh) even when + // there are no new files to import. Surface the real workload count + // (imported + refreshed + queued) instead. For quick scan + zero + // queued work, fall back to "up to date" instead of the old + // implementation-detail-leaking message. setTimeout(async () => { await refreshStatus() await loadTasks(true) - if (!activeBatch.value && mode !== 'verify') { + if (activeBatch.value || mode === 'verify') return + // Batch finalized quickly; figure out what actually happened. + // The task list was just refreshed; the freshest row(s) carry + // the batch outcome. + const batchId = tasks.value[0]?.batch_id + const sameBatch = batchId + ? tasks.value.filter(t => t.batch_id === batchId) + : [] + const refreshedCount = sameBatch.filter(t => t.status === 'complete' && t.result_image_id).length + const newImported = sameBatch.filter(t => t.status === 'complete' && t.result_image_id && !t.error).length + if (mode === 'deep' && sameBatch.length > 0) { window.__fcToast?.({ - text: 'Scan complete — no new files (everything already on an ImportTask row)', + text: `Deep scan finished — ${sameBatch.length} file(s) processed`, type: 'info', }) + } else if (mode === 'quick' && sameBatch.length > 0) { + window.__fcToast?.({ + text: `Quick scan finished — ${newImported} new file(s) queued`, + type: 'info', + }) + } else { + window.__fcToast?.({ text: 'Library is up to date', type: 'info' }) } }, 2000) } catch (e) { diff --git a/frontend/src/views/ArtistView.vue b/frontend/src/views/ArtistView.vue index fcc9fee..8750e70 100644 --- a/frontend/src/views/ArtistView.vue +++ b/frontend/src/views/ArtistView.vue @@ -45,69 +45,92 @@ >Credential health · FC-3b -

-

Frequent tags

-
- {{ t.name }} {{ t.count }} -
-
+ + + Overview + Settings + -
-

Activity

- - - -
+ + +
+

Frequent tags

+
+ {{ t.name }} {{ t.count }} +
+
-
-
-

Sources

- Manage subscriptions → -
- - - PlatformURLImages - - - - {{ s.platform }} - {{ s.url }} - {{ s.image_count }} - - - -
+
+

Activity

+ + + +
-
-

Images

- -
+
+
+

Sources

+ Manage subscriptions → +
+ + + PlatformURLImages + + + + {{ s.platform }} + {{ s.url }} + {{ s.image_count }} + + + +
- +
+

Images

+ +
+
+ + + + +