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}

⌕ photo library — search, browse & bulk edit

+ @@ -458,6 +459,7 @@ mark{background:rgba(34,211,238,.28);color:#fff;border-radius:2px;padding:0 1px} + @@ -471,6 +473,13 @@ 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)