Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac55d0e8d8 | |||
| 47d760550d | |||
| 89a89e0ded | |||
| dc3bce7fc1 | |||
| f657582f30 | |||
| 111b952535 |
@@ -39,14 +39,26 @@ jobs:
|
|||||||
- name: Commit signed XPI to frontend/public/extension/
|
- name: Commit signed XPI to frontend/public/extension/
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
XPI=$(ls extension/web-ext-artifacts/fabledcurator-*.xpi | head -1)
|
# AMO renames signed XPIs using its internal addon-id-safe-string
|
||||||
|
# (e.g. 997017ca3e104e30a75a-1.0.1.xpi), NOT our gecko ID. The
|
||||||
|
# original 'fabledcurator-*' glob never matched and the step
|
||||||
|
# exited 1 even on a successful sign (operator-flagged
|
||||||
|
# 2026-05-25). 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.
|
||||||
|
XPI=$(ls extension/web-ext-artifacts/*.xpi 2>/dev/null | head -1)
|
||||||
if [ -z "$XPI" ]; then
|
if [ -z "$XPI" ]; then
|
||||||
echo "No XPI produced by web-ext sign — exiting"
|
echo "No XPI produced by web-ext sign — exiting"
|
||||||
|
ls -la extension/web-ext-artifacts/ || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
VERSION=$(grep -E '"version"' extension/package.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
|
||||||
|
DEST="frontend/public/extension/fabledcurator-${VERSION}.xpi"
|
||||||
mkdir -p frontend/public/extension
|
mkdir -p frontend/public/extension
|
||||||
cp "$XPI" frontend/public/extension/
|
# Wipe any prior versions so the directory doesn't grow each release.
|
||||||
# Also copy as -latest.xpi so the FC server can serve a stable URL.
|
rm -f frontend/public/extension/fabledcurator-*.xpi
|
||||||
|
cp "$XPI" "$DEST"
|
||||||
cp "$XPI" "frontend/public/extension/fabledcurator-latest.xpi"
|
cp "$XPI" "frontend/public/extension/fabledcurator-latest.xpi"
|
||||||
git config user.name "FC extension CI"
|
git config user.name "FC extension CI"
|
||||||
git config user.email "noreply@fabledsword.com"
|
git config user.email "noreply@fabledsword.com"
|
||||||
@@ -54,6 +66,6 @@ jobs:
|
|||||||
if git diff --cached --quiet; then
|
if git diff --cached --quiet; then
|
||||||
echo "No changes to commit"
|
echo "No changes to commit"
|
||||||
else
|
else
|
||||||
git commit -m "ext: publish signed XPI $(basename $XPI)"
|
git commit -m "ext: publish signed XPI fabledcurator-${VERSION}.xpi"
|
||||||
git push origin HEAD:main
|
git push origin HEAD:main
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ async def status():
|
|||||||
if active:
|
if active:
|
||||||
payload["active_batch"] = {
|
payload["active_batch"] = {
|
||||||
"id": active.id,
|
"id": active.id,
|
||||||
|
"source_path": active.source_path,
|
||||||
|
"scan_mode": active.scan_mode,
|
||||||
"total_files": active.total_files,
|
"total_files": active.total_files,
|
||||||
"imported": active.imported,
|
"imported": active.imported,
|
||||||
"skipped": active.skipped,
|
"skipped": active.skipped,
|
||||||
|
|||||||
@@ -34,10 +34,21 @@ class Embedder:
|
|||||||
if self._model is not None:
|
if self._model is not None:
|
||||||
return
|
return
|
||||||
import torch
|
import torch
|
||||||
from transformers import AutoModel, AutoProcessor
|
from transformers import AutoModel, SiglipImageProcessor
|
||||||
|
|
||||||
self._torch = torch
|
self._torch = torch
|
||||||
self._processor = AutoProcessor.from_pretrained(str(self._model_dir))
|
# FC's embedder only does IMAGE inference — never text. AutoProcessor
|
||||||
|
# loads the full processor including SiglipTokenizer, which requires
|
||||||
|
# the sentencepiece library at import time even if we never call it.
|
||||||
|
# SiglipImageProcessor loads ONLY preprocessor_config.json (image
|
||||||
|
# side) and skips the tokenizer config entirely. Operator hit the
|
||||||
|
# ImportError 2026-05-25 once the ml-worker started actually running
|
||||||
|
# tag_and_embed; switching to the image-only loader avoids the
|
||||||
|
# tokenizer dep without adding ~30 MB of unused C++ build to the
|
||||||
|
# lean ml-worker image.
|
||||||
|
self._processor = SiglipImageProcessor.from_pretrained(
|
||||||
|
str(self._model_dir)
|
||||||
|
)
|
||||||
self._model = AutoModel.from_pretrained(str(self._model_dir))
|
self._model = AutoModel.from_pretrained(str(self._model_dir))
|
||||||
self._model.eval()
|
self._model.eval()
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "FabledCurator",
|
"name": "FabledCurator",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.",
|
"description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.",
|
||||||
|
|
||||||
"browser_specific_settings": {
|
"browser_specific_settings": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "fabledcurator-extension",
|
"name": "fabledcurator-extension",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Firefox extension for FabledCurator",
|
"description": "Firefox extension for FabledCurator",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
indeterminate color="accent" size="20"
|
indeterminate color="accent" size="20"
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
Scanning {{ store.activeBatch.source_path }} —
|
{{ store.activeBatch.scan_mode === 'deep' ? 'Deep scanning' : 'Scanning' }}
|
||||||
|
{{ store.activeBatch.source_path || '/import' }} —
|
||||||
imported {{ store.activeBatch.imported }},
|
imported {{ store.activeBatch.imported }},
|
||||||
skipped {{ store.activeBatch.skipped }},
|
skipped {{ store.activeBatch.skipped }},
|
||||||
failed {{ store.activeBatch.failed }} /
|
failed {{ store.activeBatch.failed }} /
|
||||||
@@ -24,8 +25,12 @@
|
|||||||
|
|
||||||
<p class="text-body-2 mb-3">
|
<p class="text-body-2 mb-3">
|
||||||
<span v-if="!store.activeBatch">
|
<span v-if="!store.activeBatch">
|
||||||
Run a quick scan of the import directory. Deep scan (pHash dedup,
|
<strong>Quick scan</strong> walks <code>/import</code> and enqueues
|
||||||
archives) lands in FC-2d.
|
new files only (skips paths already on a non-failed ImportTask).
|
||||||
|
<strong>Deep scan</strong> 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.
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
An active batch is in progress. Wait for it to finish, or click
|
An active batch is in progress. Wait for it to finish, or click
|
||||||
@@ -34,15 +39,26 @@
|
|||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<v-btn
|
<div class="d-flex flex-wrap" style="gap: 12px;">
|
||||||
color="primary" rounded="pill"
|
<v-btn
|
||||||
:disabled="!!store.activeBatch"
|
color="primary" rounded="pill"
|
||||||
:loading="busy"
|
:disabled="!!store.activeBatch"
|
||||||
@click="trigger"
|
:loading="busy === 'quick'"
|
||||||
>
|
@click="trigger('quick')"
|
||||||
<v-icon start>mdi-magnify-scan</v-icon>
|
>
|
||||||
Quick scan
|
<v-icon start>mdi-magnify-scan</v-icon>
|
||||||
</v-btn>
|
Quick scan
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
color="secondary" rounded="pill" variant="tonal"
|
||||||
|
:disabled="!!store.activeBatch"
|
||||||
|
:loading="busy === 'deep'"
|
||||||
|
@click="trigger('deep')"
|
||||||
|
>
|
||||||
|
<v-icon start>mdi-magnify-plus-outline</v-icon>
|
||||||
|
Deep scan
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
<v-alert v-if="store.triggerError" type="error" variant="tonal" class="mt-3" closable>
|
<v-alert v-if="store.triggerError" type="error" variant="tonal" class="mt-3" closable>
|
||||||
{{ store.triggerError }}
|
{{ store.triggerError }}
|
||||||
@@ -56,12 +72,12 @@ import { ref } from 'vue'
|
|||||||
import { useImportStore } from '../../stores/import.js'
|
import { useImportStore } from '../../stores/import.js'
|
||||||
|
|
||||||
const store = useImportStore()
|
const store = useImportStore()
|
||||||
const busy = ref(false)
|
const busy = ref(null)
|
||||||
const clearing = ref(false)
|
const clearing = ref(false)
|
||||||
|
|
||||||
async function trigger() {
|
async function trigger(mode) {
|
||||||
busy.value = true
|
busy.value = mode
|
||||||
try { await store.triggerScan() } catch {} finally { busy.value = false }
|
try { await store.triggerScan(mode) } catch {} finally { busy.value = null }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onClearStuck() {
|
async function onClearStuck() {
|
||||||
|
|||||||
@@ -52,25 +52,31 @@ export const useImportStore = defineStore('import', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function triggerScan() {
|
async function triggerScan(mode = 'quick') {
|
||||||
|
if (!['quick', 'deep', 'verify'].includes(mode)) {
|
||||||
|
throw new Error(`unsupported scan mode: ${mode}`)
|
||||||
|
}
|
||||||
triggerError.value = null
|
triggerError.value = null
|
||||||
try {
|
try {
|
||||||
await api.post('/api/import/trigger', { body: { mode: 'quick' } })
|
await api.post('/api/import/trigger', { body: { mode } })
|
||||||
// Acknowledge immediately so the click isn't invisible. scan_directory
|
// Acknowledge immediately so the click isn't invisible. scan_directory
|
||||||
// can finalize the batch synchronously when every file in /import is
|
// can finalize the batch synchronously when every file in /import is
|
||||||
// already on a non-failed ImportTask (operator-flagged 2026-05-25:
|
// already on a non-failed ImportTask (operator-flagged 2026-05-25:
|
||||||
// 233k existing tasks → all paths in skip-set → files_seen=0 →
|
// 233k existing tasks → all paths in skip-set → files_seen=0 →
|
||||||
// batch flashes 'running' for <100ms then 'complete' before the
|
// batch flashes 'running' for <100ms then 'complete' before the
|
||||||
// first refreshStatus() lands; UI never sees the active state).
|
// first refreshStatus() lands; UI never sees the active state).
|
||||||
window.__fcToast?.({ text: 'Scan triggered', type: 'success' })
|
const label = mode === 'deep'
|
||||||
|
? 'Deep scan triggered (pHash backfill chained)'
|
||||||
|
: mode === 'verify' ? 'Library verify triggered' : 'Quick scan triggered'
|
||||||
|
window.__fcToast?.({ text: label, type: 'success' })
|
||||||
await refreshStatus()
|
await refreshStatus()
|
||||||
// Re-poll twice over ~5s to catch quick-finalize transitions and
|
// Re-poll twice over ~5s to catch quick-finalize transitions and
|
||||||
// surface a result toast either way.
|
// surface a result toast either way. Skip the "no new files" hint
|
||||||
|
// for 'verify' since it doesn't walk the import_root.
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
await refreshStatus()
|
await refreshStatus()
|
||||||
if (!activeBatch.value) {
|
await loadTasks(true)
|
||||||
// Either scan completed with zero new files, or it never visibly
|
if (!activeBatch.value && mode !== 'verify') {
|
||||||
// started. Fetch the freshest task to differentiate.
|
|
||||||
window.__fcToast?.({
|
window.__fcToast?.({
|
||||||
text: 'Scan complete — no new files (everything already on an ImportTask row)',
|
text: 'Scan complete — no new files (everything already on an ImportTask row)',
|
||||||
type: 'info',
|
type: 'info',
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container fluid class="py-6">
|
<v-container fluid class="py-6">
|
||||||
<v-tabs v-model="tab" color="accent" class="mb-4">
|
<!-- Sticky tabs: operator-flagged 2026-05-25 — long Import / Maintenance
|
||||||
|
panels pushed the tab strip out of the viewport, forcing a scroll-
|
||||||
|
to-top just to change tab. AppShell's TopNav is 64px sticky, so the
|
||||||
|
tab strip lives directly under it. Background uses the theme surface
|
||||||
|
token so it visually merges with the page rather than the
|
||||||
|
translucent v-tabs default. -->
|
||||||
|
<v-tabs
|
||||||
|
v-model="tab" color="accent" class="mb-4"
|
||||||
|
style="position: sticky; top: 64px; z-index: 4;
|
||||||
|
background: rgb(var(--v-theme-surface));"
|
||||||
|
>
|
||||||
<v-tab value="overview">Overview</v-tab>
|
<v-tab value="overview">Overview</v-tab>
|
||||||
<v-tab value="activity">Activity</v-tab>
|
<v-tab value="activity">Activity</v-tab>
|
||||||
<v-tab value="import">Import</v-tab>
|
<v-tab value="import">Import</v-tab>
|
||||||
@@ -28,11 +38,14 @@
|
|||||||
</v-window-item>
|
</v-window-item>
|
||||||
|
|
||||||
<v-window-item value="import">
|
<v-window-item value="import">
|
||||||
|
<!-- Order: trigger → recent tasks → filters. Tasks sit directly
|
||||||
|
below the trigger so operator sees hit/miss feedback without
|
||||||
|
scrolling past the filter card (operator-flagged 2026-05-25). -->
|
||||||
<ImportTriggerPanel />
|
<ImportTriggerPanel />
|
||||||
<v-divider class="my-6" />
|
<v-divider class="my-6" />
|
||||||
<ImportFiltersForm />
|
|
||||||
<v-divider class="my-6" />
|
|
||||||
<ImportTaskList />
|
<ImportTaskList />
|
||||||
|
<v-divider class="my-6" />
|
||||||
|
<ImportFiltersForm />
|
||||||
</v-window-item>
|
</v-window-item>
|
||||||
|
|
||||||
<v-window-item value="maintenance">
|
<v-window-item value="maintenance">
|
||||||
|
|||||||
Reference in New Issue
Block a user