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
|
||||
|
||||
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()
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
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); }
|
||||
|
||||
/* ── 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 @@
|
||||
<div id="first-scan-ui">
|
||||
<div class="input-row">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -688,6 +766,7 @@
|
||||
<div class="rescan-info" id="rescan-info-text"></div>
|
||||
<div class="input-row" style="margin-bottom:10px;">
|
||||
<input type="text" id="rescan-folder-input" placeholder="/photos">
|
||||
<button class="btn-secondary" onclick="openPicker('rescan-folder-input')" title="Browse folders">📁</button>
|
||||
</div>
|
||||
<div class="rescan-buttons">
|
||||
<div class="rescan-btn-group">
|
||||
@@ -786,6 +865,23 @@
|
||||
</main>
|
||||
</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 -->
|
||||
<div id="confirm-overlay">
|
||||
<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 ────────────────────────────────────────────────────────
|
||||
document.addEventListener('keydown', e => {
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user