diff --git a/index.html b/index.html
index cdfca83..a2bea39 100644
--- a/index.html
+++ b/index.html
@@ -416,6 +416,7 @@ mark{background:rgba(34,211,238,.28);color:#fff;border-radius:2px;padding:0 1px}
—
@@ -747,6 +756,7 @@ mark{background:rgba(34,211,238,.28);color:#fff;border-radius:2px;padding:0 1px}
—
+
@@ -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 ? "" : `
No documents, receipts, or text screenshots found.
`;
+ 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 = `
})
+
${escapeHtml(rec.category || "Documents")}
+
+
${escapeHtml(rec.name)}
+
${escapeHtml(rec.desc || "")}
+
+
`;
+ 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){
diff --git a/server.py b/server.py
index 7d53039..5e70be7 100644
--- a/server.py
+++ b/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)