Add explicit Documents & Texts tab and native Image-to-PDF converter (/api/convert_pdf)

This commit is contained in:
drjones
2026-07-20 21:52:36 -07:00
parent 2d3ef6a06c
commit 031fccb3ff
2 changed files with 154 additions and 0 deletions

View File

@@ -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> <h2>⌕ <b>photo library</b> — search, browse & bulk edit</h2>
<div class="view-tabs" id="viewTabs"> <div class="view-tabs" id="viewTabs">
<button class="view-btn active" data-view="tagged">Tagged</button> <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="untagged">Untagged</button>
<button class="view-btn" data-view="failed">Failed</button> <button class="view-btn" data-view="failed">Failed</button>
<button class="view-btn" data-view="videos">Videos</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> <select id="bulkCatSelect"></select>
<button id="btnBulkApply" class="btn-primary">Apply category to selected</button> <button id="btnBulkApply" class="btn-primary">Apply category to selected</button>
<button id="btnDownloadSelected" class="btn-primary">⬇ Download selected (ZIP)</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="btnExportSelected">Export selected (JSON)</button>
<button id="btnExportAllCsv">Export all matches (CSV)</button> <button id="btnExportAllCsv">Export all matches (CSV)</button>
<button id="btnDownloadAllZip">⬇ Download all matches (ZIP)</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>
</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 --> <!-- UNTAGGED -->
<div id="view-untagged" class="lib-view"> <div id="view-untagged" class="lib-view">
<div class="search-toolbar"><span class="search-count" id="untaggedCount">—</span></div> <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> <label>file path</label>
<div class="editor-path" id="lbPath">—</div> <div class="editor-path" id="lbPath">—</div>
<button id="lbCopyPath" style="margin-top:6px">⧉ Copy path</button> <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> <label>category</label>
<select id="lbCat"></select> <select id="lbCat"></select>
@@ -1470,6 +1480,7 @@ function switchView(view){
document.querySelectorAll(".lib-view").forEach(el=>el.classList.remove("active")); document.querySelectorAll(".lib-view").forEach(el=>el.classList.remove("active"));
$("view-"+view).classList.add("active"); $("view-"+view).classList.add("active");
if(view === "tagged") doSearch(true); if(view === "tagged") doSearch(true);
else if(view === "docs") loadDocuments(true);
else if(view === "untagged") loadUntagged(true); else if(view === "untagged") loadUntagged(true);
else if(view === "failed") loadFailed(); else if(view === "failed") loadFailed();
else if(view === "videos") loadVideos(true); 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)}); 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 ---- */ /* ---- videos view ---- */
let videoItems = [], videoTotal = 0, videoLoading = false; let videoItems = [], videoTotal = 0, videoLoading = false;
async function loadVideos(reset){ async function loadVideos(reset){

View File

@@ -1407,6 +1407,95 @@ class Handler(BaseHTTPRequestHandler):
paths = [r["path"] for r in filtered[:MAX_SELECT]] paths = [r["path"] for r in filtered[:MAX_SELECT]]
self._json({"paths": paths, "total": len(filtered), "capped": len(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"): elif self.path.startswith("/api/search"):
parsed_url = urllib.parse.urlparse(self.path) parsed_url = urllib.parse.urlparse(self.path)
params = urllib.parse.parse_qs(parsed_url.query) params = urllib.parse.parse_qs(parsed_url.query)