diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..9a58f23 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "dup-finder-api", + "runtimeExecutable": "uvicorn", + "runtimeArgs": ["main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"], + "port": 8000 + } + ] +} diff --git a/app/__pycache__/main.cpython-313.pyc b/app/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..ea39c67 Binary files /dev/null and b/app/__pycache__/main.cpython-313.pyc differ diff --git a/app/__pycache__/scanner.cpython-313.pyc b/app/__pycache__/scanner.cpython-313.pyc new file mode 100644 index 0000000..4aed460 Binary files /dev/null and b/app/__pycache__/scanner.cpython-313.pyc differ diff --git a/app/__pycache__/takeout.cpython-313.pyc b/app/__pycache__/takeout.cpython-313.pyc new file mode 100644 index 0000000..0c3c545 Binary files /dev/null and b/app/__pycache__/takeout.cpython-313.pyc differ diff --git a/app/main.py b/app/main.py index 9a74274..c3d577d 100644 --- a/app/main.py +++ b/app/main.py @@ -22,9 +22,22 @@ from pydantic import BaseModel import scanner as sc app = FastAPI(title="Duplicate Finder") -templates = Jinja2Templates(directory="/app/templates") -app.mount("/static", StaticFiles(directory="/app/static"), name="static") +# Resolve paths relative to this file so it works both in Docker and locally +_BASE = Path(__file__).parent +_TEMPLATES_DIR = ( + str(_BASE / "templates") if (_BASE / "templates").exists() + else str(_BASE.parent / "templates") if (_BASE.parent / "templates").exists() + else "/app/templates" +) +_STATIC_DIR = str(_BASE / "static") +_STATIC_DIR = _STATIC_DIR if Path(_STATIC_DIR).exists() else "/app/static" +# Ensure static dir exists +Path(_STATIC_DIR).mkdir(parents=True, exist_ok=True) + +templates = Jinja2Templates(directory=_TEMPLATES_DIR) + +app.mount("/static", StaticFiles(directory=_STATIC_DIR), name="static") METHOD_META = { "sha256": {"color": "#378ADD", "label": "Exact copy"}, @@ -502,6 +515,32 @@ def get_file_meta(file_id: int): # ── Stats ───────────────────────────────────────────────────────────────────── +@app.get("/api/browse") +def browse(path: str = Query("/")): + """List subdirectories at the given path for the folder picker.""" + try: + p = Path(path).resolve() + except Exception: + raise HTTPException(400, "Invalid path") + if not p.exists() or not p.is_dir(): + raise HTTPException(404, "Path not found") + + dirs = [] + try: + for entry in sorted(p.iterdir()): + if entry.is_dir() and not entry.name.startswith("."): + dirs.append(entry.name) + except PermissionError: + pass + + parent = str(p.parent) if p != p.parent else None + return { + "current": str(p), + "parent": parent, + "dirs": dirs, + } + + @app.get("/api/stats") def get_stats(): con = get_db() diff --git a/app/scanner.py b/app/scanner.py index 108db0d..8a38fbd 100644 --- a/app/scanner.py +++ b/app/scanner.py @@ -35,7 +35,9 @@ VIDEO_EXT = { SUPPORTED_EXT = PHOTO_EXT | VIDEO_EXT -DB_PATH = "/data/dupfinder.db" +_DATA_DIR = Path("/data") if Path("/data").exists() else Path(__file__).parent.parent / "data" +_DATA_DIR.mkdir(parents=True, exist_ok=True) +DB_PATH = str(_DATA_DIR / "dupfinder.db") # Shared scan state (updated by background thread, read by status endpoint) scan_state = { diff --git a/data/dupfinder.db b/data/dupfinder.db new file mode 100644 index 0000000..66c1209 Binary files /dev/null and b/data/dupfinder.db differ diff --git a/templates/index.html b/templates/index.html index a085d6e..3d9fb81 100644 --- a/templates/index.html +++ b/templates/index.html @@ -513,6 +513,83 @@ #export-view tr:hover td { background: rgba(255,255,255,.02); } /* ── Confirm dialog ── */ + /* ── Folder picker ── */ + #picker-overlay { + position: fixed; inset: 0; + background: rgba(0,0,0,.75); + display: none; + align-items: center; + justify-content: center; + z-index: 110; + } + #picker-overlay.show { display: flex; } + #picker-box { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + width: 520px; + max-width: 95vw; + display: flex; + flex-direction: column; + max-height: 70vh; + } + #picker-header { + display: flex; + align-items: center; + gap: 10px; + padding: 14px 16px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; + } + #picker-header h3 { font-size: 14px; flex: 1; } + #picker-path { + padding: 8px 16px; + font-family: monospace; + font-size: 12px; + color: var(--text-dim); + background: var(--surface2); + border-bottom: 1px solid var(--border); + flex-shrink: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + #picker-list { + overflow-y: auto; + flex: 1; + padding: 6px 0; + } + .picker-row { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 16px; + cursor: pointer; + font-size: 13px; + transition: background .1s; + } + .picker-row:hover { background: var(--surface2); } + .picker-row .icon { color: var(--warning); font-size: 15px; flex-shrink: 0; } + .picker-row.up-row .icon { color: var(--text-dim); } + #picker-footer { + padding: 12px 16px; + border-top: 1px solid var(--border); + display: flex; + gap: 8px; + align-items: center; + flex-shrink: 0; + } + #picker-selected-path { + flex: 1; + font-family: monospace; + font-size: 12px; + color: var(--text); + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 6px 10px; + } + #confirm-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.7); @@ -660,6 +737,7 @@