From 85af98cded09bcfe31414c4760466b10e99405e3 Mon Sep 17 00:00:00 2001 From: Chaim Date: Tue, 7 Jul 2026 17:30:42 +0000 Subject: [PATCH] =?UTF-8?q?feat(case-files):=20=D7=A4=D7=90=D7=A0=D7=9C=20?= =?UTF-8?q?"=D7=A7=D7=91=D7=A6=D7=99-=D7=94=D7=AA=D7=99=D7=A7"=20=D7=91?= =?UTF-8?q?=D7=98=D7=90=D7=91=20=D7=98=D7=99=D7=95=D7=98=D7=95=D7=AA=20?= =?UTF-8?q?=E2=80=94=20=D7=92=D7=99=D7=A9=D7=94=20=D7=9E=D7=94-UI=20=D7=9C?= =?UTF-8?q?=D7=9B=D7=9C=20=D7=AA=D7=AA=D7=99-=D7=94=D7=AA=D7=99=D7=A7?= =?UTF-8?q?=D7=99=D7=95=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit היו"ר לא יכל לגשת לטיוטה שהסוכן שמר בדיסק (interim-decision-draft.md) אלא ב-SSH. מימוש מוקאפ X17 מאושר (18m): פאנל דפדפן-קבצים בטאב "טיוטות והערות". Backend (web/app.py) — הכללה של ה-endpoints הקיימים ל**כל** תת-תיקייה: - `_case_file_folders` — מונה כל תת-תיקיית-תוכן תחת התיק (מקור/מחולץ/מחקר/הגהה/ טיוטות/גיבויים), רקורסיה שכבה אחת לתוך documents/, מדלג על מוסתרות (.git/.claude) ו-thumbnails. `_resolve_case_file` מאמת folder-key מול הרשימה + חוסם path-traversal. - `/local-files` מחזיר את כל התיקיות (כולל ריקות, עם files=[]); `/local-files/{folder}/{filename}` מגיש מכל תיקייה עם media-type לפי סיומת. Frontend: - `lib/api/case-files.ts` — useCaseFiles / useCaseFileText / caseFileUrl. - `CaseFilesBrowser` — עץ-תיקיות (אקורדיון, ריקות מסומנות "ריק") + viewer מובנה ל-md/txt (Markdown הקיים) + הורדה לכל קובץ. נטען בטאב "טיוטות והערות". אין response_model → אין תלות ב-api:types (מודול טיפוסים בכתב-יד). Invariants: G1, G2 (אין מסלול-קבצים מקביל — מכליל את הקיים), אבטחת-נתיב. Co-Authored-By: Claude Opus 4.8 (1M context) --- web-ui/src/app/cases/[caseNumber]/page.tsx | 8 +- .../components/cases/case-files-browser.tsx | 304 ++++++++++++++++++ web-ui/src/lib/api/case-files.ts | 66 ++++ web/app.py | 123 +++++-- 4 files changed, 466 insertions(+), 35 deletions(-) create mode 100644 web-ui/src/components/cases/case-files-browser.tsx create mode 100644 web-ui/src/lib/api/case-files.ts diff --git a/web-ui/src/app/cases/[caseNumber]/page.tsx b/web-ui/src/app/cases/[caseNumber]/page.tsx index 385f44d..45824d2 100644 --- a/web-ui/src/app/cases/[caseNumber]/page.tsx +++ b/web-ui/src/app/cases/[caseNumber]/page.tsx @@ -14,6 +14,7 @@ import { CaseHeader } from "@/components/cases/case-header"; import { CaseEditDialog } from "@/components/cases/case-edit-dialog"; import { DocumentsPanel } from "@/components/cases/documents-panel"; import { DraftsPanel } from "@/components/cases/drafts-panel"; +import { CaseFilesBrowser } from "@/components/cases/case-files-browser"; import { DecisionBlocksPanel } from "@/components/cases/decision-blocks-panel"; import { LegalArgumentsPanel } from "@/components/cases/legal-arguments-panel"; import { PositionsPanel } from "@/components/cases/positions-panel"; @@ -243,12 +244,17 @@ export default function CaseDetailPage({ - + + + + + + diff --git a/web-ui/src/components/cases/case-files-browser.tsx b/web-ui/src/components/cases/case-files-browser.tsx new file mode 100644 index 0000000..b8667c4 --- /dev/null +++ b/web-ui/src/components/cases/case-files-browser.tsx @@ -0,0 +1,304 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + useCaseFiles, + useCaseFileText, + caseFileUrl, + type CaseFileFolder, +} from "@/lib/api/case-files"; +import { Markdown } from "@/components/ui/markdown"; +import { Badge } from "@/components/ui/badge"; +import { formatDateTime } from "@/lib/format-date"; +import { + Folder, + FileText, + File as FileIcon, + Download, + ChevronDown, + Loader2, + FolderOpen, +} from "lucide-react"; + +/* ── Helpers ─────────────────────────────────────────────────── */ + +const FOLDER_LABELS: Record = { + research: "מחקר וטיוטות", + originals: "מסמכי מקור", + extracted: "טקסט מחולץ (OCR)", + proofread: "לאחר הגהה", + drafts: "טיוטות DOCX", + backup: "גיבויים", +}; + +// Extensions the inline viewer can render (everything else → download only). +const TEXT_EXT = new Set(["md", "txt", "json", "csv", "log", "markdown"]); + +function fileExt(filename: string): string { + return filename.includes(".") ? filename.split(".").pop()!.toLowerCase() : ""; +} + +function folderLabel(f: CaseFileFolder): string { + return FOLDER_LABELS[f.name] ?? f.name; +} + +function fmtSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + +const EXT_TONE: Record = { + md: "bg-teal-100 text-teal-700", + markdown: "bg-teal-100 text-teal-700", + txt: "bg-blue-100 text-blue-700", + json: "bg-blue-100 text-blue-700", + pdf: "bg-red-100 text-red-700", + docx: "bg-indigo-100 text-indigo-700", + doc: "bg-indigo-100 text-indigo-700", +}; +function extTone(ext: string): string { + return EXT_TONE[ext] ?? "bg-gray-100 text-gray-600"; +} + +type Selected = { folderKey: string; filename: string }; + +/* ── Component ───────────────────────────────────────────────── */ + +export function CaseFilesBrowser({ caseNumber }: { caseNumber: string }) { + const { data, isLoading, error } = useCaseFiles(caseNumber); + const folders = useMemo(() => data?.folders ?? [], [data?.folders]); + + const totalFiles = folders.reduce((n, f) => n + f.files.length, 0); + + // Default selection derived (not via an effect): a text file in research, + // else any text file. The chair's explicit pick overrides it. + const defaultSelected = useMemo(() => { + const pick = ( + pred: (f: CaseFileFolder, name: string) => boolean, + ): Selected | null => { + for (const f of folders) + for (const file of f.files) + if (pred(f, file.filename)) + return { folderKey: f.key, filename: file.filename }; + return null; + }; + return ( + pick((f, name) => f.name === "research" && TEXT_EXT.has(fileExt(name))) ?? + pick((_f, name) => TEXT_EXT.has(fileExt(name))) + ); + }, [folders]); + + const [userSelected, setUserSelected] = useState(null); + const selected = userSelected ?? defaultSelected; + + // A folder is open if explicitly toggled; otherwise the folder of the current + // selection (or the first non-empty folder) is open by default. + const defaultOpenKey = + selected?.folderKey ?? folders.find((f) => f.files.length)?.key ?? null; + const [openOverrides, setOpenOverrides] = useState>( + new Map(), + ); + const isOpen = (key: string) => + openOverrides.has(key) ? openOverrides.get(key)! : key === defaultOpenKey; + + const selExt = selected ? fileExt(selected.filename) : ""; + const selIsText = TEXT_EXT.has(selExt); + const { + data: fileText, + isLoading: textLoading, + error: textError, + } = useCaseFileText( + caseNumber, + selIsText ? selected?.folderKey : undefined, + selIsText ? selected?.filename : undefined, + ); + + const toggleFolder = (key: string) => { + const next = !isOpen(key); + setOpenOverrides((prev) => new Map(prev).set(key, next)); + }; + + if (isLoading) { + return ( +
+ + טוען קבצים... +
+ ); + } + if (error) { + return ( +
+ שגיאה בטעינת קבצי-התיק +
+ ); + } + + return ( +
+ {/* header */} +
+ +

קבצי-התיק

+ + כל תתי-התיקיות · {totalFiles} קבצים + +
+ +
+ {/* file tree */} +
+ {folders.map((f) => { + const open = isOpen(f.key); + return ( +
+ + + {open && f.files.length > 0 && ( +
+ {f.files.map((file) => { + const ext = fileExt(file.filename); + const isSel = + selected?.folderKey === f.key && + selected?.filename === file.filename; + const viewable = TEXT_EXT.has(ext); + return ( +
+ viewable + ? setUserSelected({ + folderKey: f.key, + filename: file.filename, + }) + : undefined + } + > + + {viewable ? ( + + ) : ( + + )} + +
+
+ {file.filename} +
+
+ {fmtSize(file.size)} ·{" "} + {formatDateTime(file.modified_at * 1000)} +
+
+ e.stopPropagation()} + className="w-7 h-7 rounded-md border border-rule bg-surface flex items-center justify-center text-ink-muted hover:text-navy shrink-0" + title="הורדה" + > + + +
+ ); + })} +
+ )} +
+ ); + })} + {folders.length === 0 && ( +
+ אין קבצים בתיק. +
+ )} +
+ + {/* viewer */} +
+ {!selected ? ( +
+ + בחר קובץ טקסט מהרשימה לצפייה +
+ ) : ( + <> +
+ + + +
+
+ {selected.filename} +
+
+ + + הורדה + +
+
+ {!selIsText ? ( +
+ קובץ מסוג {selExt || "?"} — + לא ניתן לצפייה מוטבעת. הורד כדי לפתוח. +
+ ) : textLoading ? ( +
+ + טוען תוכן... +
+ ) : textError ? ( +
+ שגיאה בטעינת תוכן הקובץ +
+ ) : selExt === "md" || selExt === "markdown" ? ( + + ) : ( +
+                    {fileText}
+                  
+ )} +
+ + )} +
+
+
+ ); +} diff --git a/web-ui/src/lib/api/case-files.ts b/web-ui/src/lib/api/case-files.ts new file mode 100644 index 0000000..12a80ab --- /dev/null +++ b/web-ui/src/lib/api/case-files.ts @@ -0,0 +1,66 @@ +/** + * Case files API — browse & read every content subfolder of a case on disk. + * + * Surfaces the agent-produced work files (drafts, OCR text, research, originals…) + * that previously lived only on the server filesystem. Backed by the pre-existing + * `/local-files` endpoints, generalized to all subfolders (path-safe on the + * server via `_resolve_case_file`). + */ + +import { useQuery } from "@tanstack/react-query"; +import { apiRequest } from "./client"; + +export type CaseFile = { + filename: string; + size: number; + /** Unix epoch seconds (float) of last modification. */ + modified_at: number; +}; + +export type CaseFileFolder = { + /** Opaque folder key for the read endpoint (e.g. "documents__research"). */ + key: string; + /** Leaf folder name (e.g. "research"). */ + name: string; + /** Case-relative path (e.g. "documents/research"). */ + path: string; + files: CaseFile[]; +}; + +export type CaseFilesResponse = { folders: CaseFileFolder[] }; + +export function useCaseFiles(caseNumber: string | undefined) { + return useQuery({ + queryKey: ["case-files", caseNumber ?? ""], + queryFn: () => + apiRequest(`/api/cases/${caseNumber}/local-files`), + enabled: !!caseNumber, + }); +} + +/** Direct URL to a single file — use as an for download/open. */ +export function caseFileUrl( + caseNumber: string, + folderKey: string, + filename: string, +): string { + return `/api/cases/${encodeURIComponent(caseNumber)}/local-files/${encodeURIComponent(folderKey)}/${encodeURIComponent(filename)}`; +} + +/** Fetch a text file's contents (md/txt/json) for the inline viewer. */ +export function useCaseFileText( + caseNumber: string | undefined, + folderKey: string | undefined, + filename: string | undefined, +) { + return useQuery({ + queryKey: ["case-file-text", caseNumber ?? "", folderKey ?? "", filename ?? ""], + queryFn: async () => { + const res = await fetch(caseFileUrl(caseNumber!, folderKey!, filename!)); + if (!res.ok) throw new Error(`שגיאה בטעינת הקובץ (${res.status})`); + return res.text(); + }, + enabled: !!caseNumber && !!folderKey && !!filename, + staleTime: 30_000, + }); +} diff --git a/web/app.py b/web/app.py index e73b3b7..def762a 100644 --- a/web/app.py +++ b/web/app.py @@ -3069,42 +3069,82 @@ async def api_learn(case_number: str): # ── Local files API — research, drafts, proofread ── +# Directories under a case that are infrastructure/cache, not chair-facing +# content. Hidden dirs (``.git``, ``.claude`` …) are skipped separately. +_CASE_FILE_EXCLUDE_DIRS = {"thumbnails"} + + +def _is_content_dir(p: Path) -> bool: + return p.is_dir() and not p.name.startswith(".") and p.name not in _CASE_FILE_EXCLUDE_DIRS + + +def _case_file_folders(case_dir: Path) -> list[tuple[str, Path]]: + """Enumerate every content subdirectory under a case dir as (key, path). + + Covers *all* subfolders (not just research) — recursing one level into + ``documents/`` — while skipping hidden (``.git``/``.claude``…) and cache + dirs. The ``key`` is the case-relative path with ``/`` encoded as ``__`` so + it survives a single URL path segment; ``_resolve_case_file`` re-validates it + against this same enumeration, so the key set is the serve allowlist (no path + traversal). + """ + folders: list[tuple[str, Path]] = [] + if not case_dir.exists(): + return folders + for child in sorted(case_dir.iterdir()): + if not _is_content_dir(child): + continue + if child.name == "documents": + folders.extend( + (f"documents__{sub.name}", sub) + for sub in sorted(child.iterdir()) + if _is_content_dir(sub) + ) + else: + folders.append((child.name, child)) + return folders + + +def _resolve_case_file(case_dir: Path, folder_key: str, filename: str) -> Path | None: + """Safely resolve (folder_key, filename) to a file inside the case dir. + + Returns None unless ``folder_key`` is one of the enumerated content folders + AND ``filename`` resolves to a regular file directly inside it (no traversal). + """ + base = dict(_case_file_folders(case_dir)).get(folder_key) + if base is None: + return None + target = (base / filename).resolve() + if target.parent != base.resolve() or not target.is_file(): + return None + return target + + @app.get("/api/cases/{case_number}/local-files") async def api_local_files(case_number: str): - """List local files from case subdirectories (research, drafts, proofread).""" + """List local files across ALL content subfolders under a case (not just + research) — so the chair can reach any agent-produced file (drafts, OCR text, + originals…) from the UI instead of only over SSH. Empty folders are returned + too (with an empty ``files`` list) so the UI can show them as such.""" case_dir = config.find_case_dir(case_number) - result = {} - for folder in ("research", "proofread"): - folder_path = case_dir / "documents" / folder - if folder_path.exists(): - files = [] - for f in sorted(folder_path.iterdir()): - if f.is_file() and not f.name.startswith("."): - stat = f.stat() - files.append({ - "filename": f.name, - "size": stat.st_size, - "modified_at": stat.st_mtime, - "folder": folder, - }) - if files: - result[folder] = files - # Drafts are at case level, not under documents - drafts_path = case_dir / "drafts" - if drafts_path.exists(): + folders = [] + for key, path in _case_file_folders(case_dir): files = [] - for f in sorted(drafts_path.iterdir()): + for f in sorted(path.iterdir()): if f.is_file() and not f.name.startswith("."): stat = f.stat() files.append({ "filename": f.name, "size": stat.st_size, "modified_at": stat.st_mtime, - "folder": "drafts", }) - if files: - result["drafts"] = files - return result + folders.append({ + "key": key, + "name": path.name, + "path": str(path.relative_to(case_dir)), + "files": files, + }) + return {"folders": folders} async def serve_blob( @@ -3174,17 +3214,32 @@ async def _seal_blob_file(dest: Path, *, bucket=storage.Bucket.DOCUMENTS) -> Non @app.get("/api/cases/{case_number}/local-files/{folder}/{filename}") async def api_read_local_file(case_number: str, folder: str, filename: str): - """Read contents of a local case file.""" - if folder not in ("research", "proofread", "drafts"): - raise HTTPException(400, "Invalid folder") + """Serve a single local case file from any content subfolder. + + ``folder`` is a key from ``/local-files`` (``documents__research``, ``drafts``…); + ``_resolve_case_file`` validates it against the folder allowlist and blocks + path traversal before the bytes are served. + """ case_dir = config.find_case_dir(case_number) - if folder == "drafts": - path = case_dir / "drafts" / filename - else: - path = case_dir / "documents" / folder / filename - if not path.exists() or not path.is_file(): + path = _resolve_case_file(case_dir, folder, filename) + if path is None: raise HTTPException(404, "קובץ לא נמצא") - return await serve_blob(path, media_type="text/plain; charset=utf-8", filename=filename) + media_type = _local_file_media_type(filename) + return await serve_blob(path, media_type=media_type, filename=filename) + + +def _local_file_media_type(filename: str) -> str: + """Best-effort content type for a served case file (text renders inline, + binaries download).""" + ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else "" + return { + "md": "text/markdown; charset=utf-8", + "txt": "text/plain; charset=utf-8", + "json": "application/json; charset=utf-8", + "pdf": "application/pdf", + "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "doc": "application/msword", + }.get(ext, "application/octet-stream") # ── Research analysis (analysis-and-research.md) — parse + edit ────