Add server-side folder picker
New GET /api/browse endpoint lists subdirectories at any path. UI gets a folder icon button next to each path input that opens a browsable directory tree modal. Escape or Cancel closes it, clicking a folder navigates into it, Select confirms the choice. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
app/__pycache__/main.cpython-313.pyc
Normal file
BIN
app/__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/scanner.cpython-313.pyc
Normal file
BIN
app/__pycache__/scanner.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/takeout.cpython-313.pyc
Normal file
BIN
app/__pycache__/takeout.cpython-313.pyc
Normal file
Binary file not shown.
43
app/main.py
43
app/main.py
@@ -22,9 +22,22 @@ from pydantic import BaseModel
|
|||||||
import scanner as sc
|
import scanner as sc
|
||||||
|
|
||||||
app = FastAPI(title="Duplicate Finder")
|
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 = {
|
METHOD_META = {
|
||||||
"sha256": {"color": "#378ADD", "label": "Exact copy"},
|
"sha256": {"color": "#378ADD", "label": "Exact copy"},
|
||||||
@@ -502,6 +515,32 @@ def get_file_meta(file_id: int):
|
|||||||
|
|
||||||
# ── Stats ─────────────────────────────────────────────────────────────────────
|
# ── 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")
|
@app.get("/api/stats")
|
||||||
def get_stats():
|
def get_stats():
|
||||||
con = get_db()
|
con = get_db()
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ VIDEO_EXT = {
|
|||||||
|
|
||||||
SUPPORTED_EXT = PHOTO_EXT | 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)
|
# Shared scan state (updated by background thread, read by status endpoint)
|
||||||
scan_state = {
|
scan_state = {
|
||||||
|
|||||||
BIN
data/dupfinder.db
Normal file
BIN
data/dupfinder.db
Normal file
Binary file not shown.
@@ -513,6 +513,83 @@
|
|||||||
#export-view tr:hover td { background: rgba(255,255,255,.02); }
|
#export-view tr:hover td { background: rgba(255,255,255,.02); }
|
||||||
|
|
||||||
/* ── Confirm dialog ── */
|
/* ── 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 {
|
#confirm-overlay {
|
||||||
position: fixed; inset: 0;
|
position: fixed; inset: 0;
|
||||||
background: rgba(0,0,0,.7);
|
background: rgba(0,0,0,.7);
|
||||||
@@ -660,6 +737,7 @@
|
|||||||
<div id="first-scan-ui">
|
<div id="first-scan-ui">
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
<input type="text" id="folder-input" placeholder="/photos/MyLibrary" value="/photos">
|
<input type="text" id="folder-input" placeholder="/photos/MyLibrary" value="/photos">
|
||||||
|
<button class="btn-secondary" onclick="openPicker('folder-input')" title="Browse folders">📁</button>
|
||||||
<button class="btn-primary" id="start-scan-btn" onclick="startScan('incremental')">Start Scan</button>
|
<button class="btn-primary" id="start-scan-btn" onclick="startScan('incremental')">Start Scan</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -688,6 +766,7 @@
|
|||||||
<div class="rescan-info" id="rescan-info-text"></div>
|
<div class="rescan-info" id="rescan-info-text"></div>
|
||||||
<div class="input-row" style="margin-bottom:10px;">
|
<div class="input-row" style="margin-bottom:10px;">
|
||||||
<input type="text" id="rescan-folder-input" placeholder="/photos">
|
<input type="text" id="rescan-folder-input" placeholder="/photos">
|
||||||
|
<button class="btn-secondary" onclick="openPicker('rescan-folder-input')" title="Browse folders">📁</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="rescan-buttons">
|
<div class="rescan-buttons">
|
||||||
<div class="rescan-btn-group">
|
<div class="rescan-btn-group">
|
||||||
@@ -786,6 +865,23 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Folder picker -->
|
||||||
|
<div id="picker-overlay">
|
||||||
|
<div id="picker-box">
|
||||||
|
<div id="picker-header">
|
||||||
|
<h3>Browse for folder</h3>
|
||||||
|
<button class="btn-secondary btn-sm" onclick="closePicker()">✕</button>
|
||||||
|
</div>
|
||||||
|
<div id="picker-path">/</div>
|
||||||
|
<div id="picker-list"></div>
|
||||||
|
<div id="picker-footer">
|
||||||
|
<input type="text" id="picker-selected-path" placeholder="selected path">
|
||||||
|
<button class="btn-primary btn-sm" onclick="confirmPicker()">Select</button>
|
||||||
|
<button class="btn-secondary btn-sm" onclick="closePicker()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Confirm dialog -->
|
<!-- Confirm dialog -->
|
||||||
<div id="confirm-overlay">
|
<div id="confirm-overlay">
|
||||||
<div id="confirm-box">
|
<div id="confirm-box">
|
||||||
@@ -1363,10 +1459,72 @@ async function loadExport() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Folder picker ─────────────────────────────────────────────────────────────
|
||||||
|
let _pickerTargetId = null;
|
||||||
|
|
||||||
|
async function openPicker(inputId) {
|
||||||
|
_pickerTargetId = inputId;
|
||||||
|
const currentVal = el(inputId).value.trim() || '/';
|
||||||
|
el('picker-overlay').classList.add('show');
|
||||||
|
await pickerNavigate(currentVal);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePicker() {
|
||||||
|
el('picker-overlay').classList.remove('show');
|
||||||
|
_pickerTargetId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmPicker() {
|
||||||
|
const path = el('picker-selected-path').value.trim();
|
||||||
|
if (path && _pickerTargetId) {
|
||||||
|
el(_pickerTargetId).value = path;
|
||||||
|
}
|
||||||
|
closePicker();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickerNavigate(path) {
|
||||||
|
try {
|
||||||
|
const data = await api('GET', `/api/browse?path=${encodeURIComponent(path)}`);
|
||||||
|
el('picker-path').textContent = data.current;
|
||||||
|
el('picker-selected-path').value = data.current;
|
||||||
|
|
||||||
|
const list = el('picker-list');
|
||||||
|
list.innerHTML = '';
|
||||||
|
|
||||||
|
// Up button
|
||||||
|
if (data.parent) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'picker-row up-row';
|
||||||
|
row.innerHTML = `<span class="icon">↑</span> <span>..</span>`;
|
||||||
|
row.onclick = () => pickerNavigate(data.parent);
|
||||||
|
list.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.dirs.length === 0) {
|
||||||
|
list.innerHTML += `<div class="picker-row text-dim" style="cursor:default">No subfolders</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
data.dirs.forEach(name => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'picker-row';
|
||||||
|
const fullPath = data.current.replace(/\\/g, '/').replace(/\/$/, '') + '/' + name;
|
||||||
|
row.innerHTML = `<span class="icon">📁</span> <span>${name}</span>`;
|
||||||
|
row.onclick = () => {
|
||||||
|
el('picker-selected-path').value = fullPath;
|
||||||
|
pickerNavigate(fullPath);
|
||||||
|
};
|
||||||
|
list.appendChild(row);
|
||||||
|
});
|
||||||
|
} catch(e) {
|
||||||
|
el('picker-list').innerHTML = `<div class="picker-row text-dim">Cannot open this path.</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Keyboard shortcuts ────────────────────────────────────────────────────────
|
// ── Keyboard shortcuts ────────────────────────────────────────────────────────
|
||||||
document.addEventListener('keydown', e => {
|
document.addEventListener('keydown', e => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
if (el('confirm-overlay').classList.contains('show')) closeConfirm();
|
if (el('picker-overlay').classList.contains('show')) closePicker();
|
||||||
|
else if (el('confirm-overlay').classList.contains('show')) closeConfirm();
|
||||||
else closeDetail();
|
else closeDetail();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user