Add explicit Documents & Texts tab and native Image-to-PDF converter (/api/convert_pdf)
This commit is contained in:
65
index.html
65
index.html
@@ -416,6 +416,7 @@ mark{background:rgba(34,211,238,.28);color:#fff;border-radius:2px;padding:0 1px}
|
||||
<h2>⌕ <b>photo library</b> — search, browse & bulk edit</h2>
|
||||
<div class="view-tabs" id="viewTabs">
|
||||
<button class="view-btn active" data-view="tagged">Tagged</button>
|
||||
<button class="view-btn" data-view="docs">📑 Documents & Texts</button>
|
||||
<button class="view-btn" data-view="untagged">Untagged</button>
|
||||
<button class="view-btn" data-view="failed">Failed</button>
|
||||
<button class="view-btn" data-view="videos">Videos</button>
|
||||
@@ -458,6 +459,7 @@ mark{background:rgba(34,211,238,.28);color:#fff;border-radius:2px;padding:0 1px}
|
||||
<select id="bulkCatSelect"></select>
|
||||
<button id="btnBulkApply" class="btn-primary">Apply category to selected</button>
|
||||
<button id="btnDownloadSelected" class="btn-primary">⬇ Download selected (ZIP)</button>
|
||||
<button id="btnDownloadSelectedPdf" class="btn-primary">📄 Convert & Download Selected (PDF ZIP)</button>
|
||||
<button id="btnExportSelected">Export selected (JSON)</button>
|
||||
<button id="btnExportAllCsv">Export all matches (CSV)</button>
|
||||
<button id="btnDownloadAllZip">⬇ Download all matches (ZIP)</button>
|
||||
@@ -471,6 +473,13 @@ mark{background:rgba(34,211,238,.28);color:#fff;border-radius:2px;padding:0 1px}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DOCUMENTS & TEXTS -->
|
||||
<div id="view-docs" class="lib-view">
|
||||
<div class="search-toolbar"><span class="search-count" id="docsCount">—</span></div>
|
||||
<div class="search-results-grid" id="docsGrid"></div>
|
||||
<div class="load-more-row"><button id="btnLoadMoreDocs" style="display:none">Load more ▾</button></div>
|
||||
</div>
|
||||
|
||||
<!-- UNTAGGED -->
|
||||
<div id="view-untagged" class="lib-view">
|
||||
<div class="search-toolbar"><span class="search-count" id="untaggedCount">—</span></div>
|
||||
@@ -747,6 +756,7 @@ mark{background:rgba(34,211,238,.28);color:#fff;border-radius:2px;padding:0 1px}
|
||||
<label>file path</label>
|
||||
<div class="editor-path" id="lbPath">—</div>
|
||||
<button id="lbCopyPath" style="margin-top:6px">⧉ Copy path</button>
|
||||
<button id="lbDownloadPdf" class="btn-primary" style="margin-top:6px;width:100%">📄 Convert & Download PDF</button>
|
||||
|
||||
<label>category</label>
|
||||
<select id="lbCat"></select>
|
||||
@@ -1470,6 +1480,7 @@ function switchView(view){
|
||||
document.querySelectorAll(".lib-view").forEach(el=>el.classList.remove("active"));
|
||||
$("view-"+view).classList.add("active");
|
||||
if(view === "tagged") doSearch(true);
|
||||
else if(view === "docs") loadDocuments(true);
|
||||
else if(view === "untagged") loadUntagged(true);
|
||||
else if(view === "failed") loadFailed();
|
||||
else if(view === "videos") loadVideos(true);
|
||||
@@ -1739,6 +1750,60 @@ $("btnRetryAllFailed").onclick = async ()=>{
|
||||
if(j.ok) addLog({level:"info", msg:"Retrying all failed photos…", ts:new Date().toTimeString().slice(0,8)});
|
||||
};
|
||||
|
||||
/* ---- documents & text view ---- */
|
||||
let docItems = [], docTotal = 0, docLoading = false;
|
||||
async function loadDocuments(reset){
|
||||
if(docLoading) return;
|
||||
if(reset) docItems = [];
|
||||
docLoading = true;
|
||||
const j = await api("/api/documents?limit=60&offset=" + docItems.length);
|
||||
docLoading = false;
|
||||
if(!j.results) return;
|
||||
docItems.push(...j.results);
|
||||
docTotal = j.total || 0;
|
||||
$("docsCount").textContent = docTotal + " document & text item" + (docTotal===1?"":"s") + " found";
|
||||
const grid = $("docsGrid");
|
||||
grid.innerHTML = docItems.length ? "" : `<div class="search-empty">No documents, receipts, or text screenshots found.</div>`;
|
||||
docItems.forEach((rec, i)=>{
|
||||
const card = document.createElement("div");
|
||||
card.className = "result-card2";
|
||||
card.onclick = (e)=>{ if(!e.target.closest(".btn-pdf")) openQuickView(rec); };
|
||||
card.innerHTML = `<div class="result-thumb"><img loading="lazy" src="/api/thumbnail?path=${encodeURIComponent(rec.path)}">
|
||||
<span class="result-cat-badge">${escapeHtml(rec.category || "Documents")}</span></div>
|
||||
<div class="result-body">
|
||||
<div class="result-name" title="${rec.name}">${escapeHtml(rec.name)}</div>
|
||||
<div class="result-desc2">${escapeHtml(rec.desc || "")}</div>
|
||||
<button class="btn-pdf btn-primary" style="margin-top:6px;width:100%;font-size:9px">📄 Convert & Download PDF</button>
|
||||
</div>`;
|
||||
card.querySelector(".btn-pdf").onclick = (e)=>{
|
||||
e.stopPropagation();
|
||||
showToast("Converting " + os_basename(rec.path) + " to PDF…", "info");
|
||||
window.location.href = "/api/convert_pdf?path=" + encodeURIComponent(rec.path);
|
||||
};
|
||||
grid.appendChild(card);
|
||||
});
|
||||
if($("btnLoadMoreDocs")) $("btnLoadMoreDocs").style.display = docItems.length < docTotal ? "block" : "none";
|
||||
}
|
||||
if($("btnLoadMoreDocs")) $("btnLoadMoreDocs").onclick = ()=>loadDocuments(false);
|
||||
|
||||
if($("lbDownloadPdf")){
|
||||
$("lbDownloadPdf").onclick = ()=>{
|
||||
const rec = currentLbRec();
|
||||
if(!rec) return;
|
||||
showToast("Converting " + os_basename(rec.path) + " to PDF…", "info");
|
||||
window.location.href = "/api/convert_pdf?path=" + encodeURIComponent(rec.path);
|
||||
};
|
||||
}
|
||||
|
||||
if($("btnDownloadSelectedPdf")){
|
||||
$("btnDownloadSelectedPdf").onclick = ()=>{
|
||||
if(selectedPaths.size === 0) return;
|
||||
showToast(`Converting ${selectedPaths.size} image(s) to PDF ZIP…`, "info");
|
||||
const params = new URLSearchParams({paths: [...selectedPaths].join(",")});
|
||||
window.location.href = "/api/convert_pdf?" + params.toString();
|
||||
};
|
||||
}
|
||||
|
||||
/* ---- videos view ---- */
|
||||
let videoItems = [], videoTotal = 0, videoLoading = false;
|
||||
async function loadVideos(reset){
|
||||
|
||||
89
server.py
89
server.py
@@ -1407,6 +1407,95 @@ class Handler(BaseHTTPRequestHandler):
|
||||
paths = [r["path"] for r in filtered[:MAX_SELECT]]
|
||||
self._json({"paths": paths, "total": len(filtered), "capped": len(filtered) > MAX_SELECT})
|
||||
|
||||
elif self.path.startswith("/api/documents"):
|
||||
parsed_url = urllib.parse.urlparse(self.path)
|
||||
params = urllib.parse.parse_qs(parsed_url.query)
|
||||
try:
|
||||
limit = max(1, min(200, int(params.get("limit", ["60"])[0])))
|
||||
except ValueError:
|
||||
limit = 60
|
||||
try:
|
||||
offset = max(0, int(params.get("offset", ["0"])[0]))
|
||||
except ValueError:
|
||||
offset = 0
|
||||
records = load_search_index()
|
||||
docs = [r for r in records if r.get("category") == "Screenshots & Documents" or r.get("route") == "screenshot" or "text:" in (r.get("desc") or "").lower()]
|
||||
total = len(docs)
|
||||
page = [{k: v for k, v in r.items() if k not in ("_hay", "_mtime", "_ext")} for r in docs[offset:offset + limit]]
|
||||
self._json({"results": page, "total": total, "offset": offset, "limit": limit})
|
||||
|
||||
elif self.path.startswith("/api/convert_pdf"):
|
||||
parsed_url = urllib.parse.urlparse(self.path)
|
||||
params = urllib.parse.parse_qs(parsed_url.query)
|
||||
paths_param = params.get("paths", [""])[0] or params.get("path", [""])[0]
|
||||
if not paths_param:
|
||||
self._json({"error": "path parameter is required"}, 400); return
|
||||
paths = [p for p in paths_param.split(",") if p and os.path.exists(p)]
|
||||
if not paths:
|
||||
self._json({"error": "files not found"}, 404); return
|
||||
|
||||
if len(paths) == 1:
|
||||
src = paths[0]
|
||||
base_name = os.path.splitext(os.path.basename(src))[0] + ".pdf"
|
||||
tmp_pdf = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False)
|
||||
tmp_pdf.close()
|
||||
try:
|
||||
cmd = ["sips", "-s", "format", "pdf", src, "--out", tmp_pdf.name]
|
||||
r = subprocess.run(cmd, capture_output=True, timeout=30)
|
||||
if r.returncode != 0 or not os.path.exists(tmp_pdf.name):
|
||||
raise RuntimeError(f"sips failed: {r.stderr.decode(errors='replace')}")
|
||||
with open(tmp_pdf.name, "rb") as f:
|
||||
pdf_data = f.read()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/pdf")
|
||||
self.send_header("Content-Disposition", f'attachment; filename="{base_name}"')
|
||||
self.send_header("Content-Length", str(len(pdf_data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(pdf_data)
|
||||
except Exception as e:
|
||||
self._json({"error": f"Failed converting image to PDF: {e}"}, 500)
|
||||
finally:
|
||||
try: os.remove(tmp_pdf.name)
|
||||
except Exception: pass
|
||||
else:
|
||||
tmp_zip = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
|
||||
tmp_zip.close()
|
||||
tmp_dir = tempfile.mkdtemp(prefix="photon_pdf_zip_")
|
||||
try:
|
||||
used_names = set()
|
||||
with zipfile.ZipFile(tmp_zip.name, "w", zipfile.ZIP_STORED, allowZip64=True) as zf:
|
||||
for src in paths:
|
||||
base = os.path.splitext(os.path.basename(src))[0]
|
||||
pdf_name = base + ".pdf"
|
||||
i = 1
|
||||
while pdf_name in used_names:
|
||||
pdf_name = f"{base}_{i}.pdf"
|
||||
i += 1
|
||||
used_names.add(pdf_name)
|
||||
out_pdf = os.path.join(tmp_dir, pdf_name)
|
||||
cmd = ["sips", "-s", "format", "pdf", src, "--out", out_pdf]
|
||||
r = subprocess.run(cmd, capture_output=True, timeout=30)
|
||||
if r.returncode == 0 and os.path.exists(out_pdf):
|
||||
zf.write(out_pdf, arcname=pdf_name)
|
||||
size = os.path.getsize(tmp_zip.name)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/zip")
|
||||
self.send_header("Content-Disposition", f'attachment; filename="photon_documents_pdf.zip"')
|
||||
self.send_header("Content-Length", str(size))
|
||||
self.end_headers()
|
||||
with open(tmp_zip.name, "rb") as f:
|
||||
while True:
|
||||
chunk = f.read(1024 * 1024)
|
||||
if not chunk: break
|
||||
self.wfile.write(chunk)
|
||||
except Exception as e:
|
||||
self._json({"error": f"Failed converting batch PDFs: {e}"}, 500)
|
||||
finally:
|
||||
try: os.remove(tmp_zip.name)
|
||||
except Exception: pass
|
||||
try: shutil.rmtree(tmp_dir)
|
||||
except Exception: pass
|
||||
|
||||
elif self.path.startswith("/api/search"):
|
||||
parsed_url = urllib.parse.urlparse(self.path)
|
||||
params = urllib.parse.parse_qs(parsed_url.query)
|
||||
|
||||
Reference in New Issue
Block a user