Merge pull request 'feat(case-files): פאנל "קבצי-התיק" — גישה מה-UI לכל תתי-התיקיות' (#414) from worktree-case-files-browser into main
This commit was merged in pull request #414.
This commit is contained in:
@@ -14,6 +14,7 @@ import { CaseHeader } from "@/components/cases/case-header";
|
|||||||
import { CaseEditDialog } from "@/components/cases/case-edit-dialog";
|
import { CaseEditDialog } from "@/components/cases/case-edit-dialog";
|
||||||
import { DocumentsPanel } from "@/components/cases/documents-panel";
|
import { DocumentsPanel } from "@/components/cases/documents-panel";
|
||||||
import { DraftsPanel } from "@/components/cases/drafts-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 { DecisionBlocksPanel } from "@/components/cases/decision-blocks-panel";
|
||||||
import { LegalArgumentsPanel } from "@/components/cases/legal-arguments-panel";
|
import { LegalArgumentsPanel } from "@/components/cases/legal-arguments-panel";
|
||||||
import { PositionsPanel } from "@/components/cases/positions-panel";
|
import { PositionsPanel } from "@/components/cases/positions-panel";
|
||||||
@@ -243,12 +244,17 @@ export default function CaseDetailPage({
|
|||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="drafts" className="mt-0">
|
<TabsContent value="drafts" className="mt-0 space-y-4">
|
||||||
<Card className="bg-surface border-rule shadow-sm">
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
<CardContent className="px-6 py-5">
|
<CardContent className="px-6 py-5">
|
||||||
<DraftsPanel caseNumber={caseNumber} status={data?.status} />
|
<DraftsPanel caseNumber={caseNumber} status={data?.status} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5">
|
||||||
|
<CaseFilesBrowser caseNumber={caseNumber} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="agents" className="mt-0">
|
<TabsContent value="agents" className="mt-0">
|
||||||
|
|||||||
304
web-ui/src/components/cases/case-files-browser.tsx
Normal file
304
web-ui/src/components/cases/case-files-browser.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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<Selected | null>(() => {
|
||||||
|
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<Selected | null>(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<Map<string, boolean>>(
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center justify-center py-10 text-ink-faint">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin me-2" />
|
||||||
|
<span>טוען קבצים...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-10 text-red-500 text-sm">
|
||||||
|
שגיאה בטעינת קבצי-התיק
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-rule rounded-xl overflow-hidden">
|
||||||
|
{/* header */}
|
||||||
|
<div className="flex items-center gap-2 px-4 py-3 border-b border-rule-soft bg-parchment">
|
||||||
|
<FolderOpen className="w-4 h-4 text-gold-deep" />
|
||||||
|
<h3 className="text-sm font-bold text-navy">קבצי-התיק</h3>
|
||||||
|
<span className="text-[11px] text-ink-muted ms-auto">
|
||||||
|
כל תתי-התיקיות · {totalFiles} קבצים
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-[320px_1fr]">
|
||||||
|
{/* file tree */}
|
||||||
|
<div className="border-b md:border-b-0 md:border-e border-rule-soft max-h-[560px] overflow-y-auto">
|
||||||
|
{folders.map((f) => {
|
||||||
|
const open = isOpen(f.key);
|
||||||
|
return (
|
||||||
|
<div key={f.key} className="border-b border-rule-soft last:border-b-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleFolder(f.key)}
|
||||||
|
aria-expanded={open}
|
||||||
|
className="w-full flex items-center gap-2 px-3.5 py-2.5 hover:bg-sand-soft/60 text-start"
|
||||||
|
>
|
||||||
|
<Folder className="w-4 h-4 text-gold-deep shrink-0" />
|
||||||
|
<span className="text-[0.78rem] font-bold text-navy">
|
||||||
|
{folderLabel(f)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] font-mono text-ink-faint hidden sm:inline">
|
||||||
|
{f.path}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-semibold rounded-full px-2 ms-auto ${f.files.length ? "bg-gray-100 text-ink-muted" : "text-ink-light"}`}
|
||||||
|
>
|
||||||
|
{f.files.length || "ריק"}
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={`w-4 h-4 text-ink-faint shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && f.files.length > 0 && (
|
||||||
|
<div className="pb-1 bg-parchment/50">
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={file.filename}
|
||||||
|
className={`flex items-center gap-2.5 py-1.5 pe-3.5 ps-5 cursor-pointer border-s-2 ${isSel ? "bg-gold-wash border-gold" : "border-transparent hover:bg-sand-soft/60"}`}
|
||||||
|
onClick={() =>
|
||||||
|
viewable
|
||||||
|
? setUserSelected({
|
||||||
|
folderKey: f.key,
|
||||||
|
filename: file.filename,
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`w-6 h-6 rounded-md flex items-center justify-center shrink-0 ${extTone(ext)}`}
|
||||||
|
>
|
||||||
|
{viewable ? (
|
||||||
|
<FileText className="w-3.5 h-3.5" />
|
||||||
|
) : (
|
||||||
|
<FileIcon className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-[11.5px] font-medium text-ink-soft truncate">
|
||||||
|
{file.filename}
|
||||||
|
</div>
|
||||||
|
<div className="text-[9.5px] text-ink-light">
|
||||||
|
{fmtSize(file.size)} ·{" "}
|
||||||
|
{formatDateTime(file.modified_at * 1000)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={caseFileUrl(caseNumber, f.key, file.filename)}
|
||||||
|
download={file.filename}
|
||||||
|
onClick={(e) => 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="הורדה"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{folders.length === 0 && (
|
||||||
|
<div className="text-center py-10 text-ink-faint text-sm">
|
||||||
|
אין קבצים בתיק.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* viewer */}
|
||||||
|
<div className="min-w-0 flex flex-col">
|
||||||
|
{!selected ? (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center py-16 text-ink-faint text-sm">
|
||||||
|
<FileText className="w-8 h-8 mb-2 opacity-40" />
|
||||||
|
בחר קובץ טקסט מהרשימה לצפייה
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2.5 px-4 py-3 border-b border-rule-soft">
|
||||||
|
<span
|
||||||
|
className={`w-7 h-7 rounded-md flex items-center justify-center shrink-0 ${extTone(selExt)}`}
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[12.5px] font-bold text-navy truncate">
|
||||||
|
{selected.filename}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={caseFileUrl(
|
||||||
|
caseNumber,
|
||||||
|
selected.folderKey,
|
||||||
|
selected.filename,
|
||||||
|
)}
|
||||||
|
download={selected.filename}
|
||||||
|
className="ms-auto inline-flex items-center gap-1.5 text-[11.5px] font-semibold text-navy bg-parchment border border-rule rounded-md px-3 py-1.5 hover:bg-sand-soft"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
הורדה
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4 overflow-y-auto max-h-[520px] bg-[#fdfcf9]">
|
||||||
|
{!selIsText ? (
|
||||||
|
<div className="text-center py-12 text-sm text-ink-muted">
|
||||||
|
קובץ מסוג <Badge variant="outline">{selExt || "?"}</Badge> —
|
||||||
|
לא ניתן לצפייה מוטבעת. הורד כדי לפתוח.
|
||||||
|
</div>
|
||||||
|
) : textLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12 text-ink-faint">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin me-2" />
|
||||||
|
טוען תוכן...
|
||||||
|
</div>
|
||||||
|
) : textError ? (
|
||||||
|
<div className="text-center py-12 text-red-500 text-sm">
|
||||||
|
שגיאה בטעינת תוכן הקובץ
|
||||||
|
</div>
|
||||||
|
) : selExt === "md" || selExt === "markdown" ? (
|
||||||
|
<Markdown content={fileText ?? ""} />
|
||||||
|
) : (
|
||||||
|
<pre className="whitespace-pre-wrap text-xs text-ink-soft font-mono leading-relaxed">
|
||||||
|
{fileText}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
web-ui/src/lib/api/case-files.ts
Normal file
66
web-ui/src/lib/api/case-files.ts
Normal file
@@ -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<CaseFilesResponse>(`/api/cases/${caseNumber}/local-files`),
|
||||||
|
enabled: !!caseNumber,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Direct URL to a single file — use as an <a href> 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
121
web/app.py
121
web/app.py
@@ -3069,42 +3069,82 @@ async def api_learn(case_number: str):
|
|||||||
# ── Local files API — research, drafts, proofread ──
|
# ── 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")
|
@app.get("/api/cases/{case_number}/local-files")
|
||||||
async def api_local_files(case_number: str):
|
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)
|
case_dir = config.find_case_dir(case_number)
|
||||||
result = {}
|
folders = []
|
||||||
for folder in ("research", "proofread"):
|
for key, path in _case_file_folders(case_dir):
|
||||||
folder_path = case_dir / "documents" / folder
|
|
||||||
if folder_path.exists():
|
|
||||||
files = []
|
files = []
|
||||||
for f in sorted(folder_path.iterdir()):
|
for f in sorted(path.iterdir()):
|
||||||
if f.is_file() and not f.name.startswith("."):
|
if f.is_file() and not f.name.startswith("."):
|
||||||
stat = f.stat()
|
stat = f.stat()
|
||||||
files.append({
|
files.append({
|
||||||
"filename": f.name,
|
"filename": f.name,
|
||||||
"size": stat.st_size,
|
"size": stat.st_size,
|
||||||
"modified_at": stat.st_mtime,
|
"modified_at": stat.st_mtime,
|
||||||
"folder": folder,
|
|
||||||
})
|
})
|
||||||
if files:
|
folders.append({
|
||||||
result[folder] = files
|
"key": key,
|
||||||
# Drafts are at case level, not under documents
|
"name": path.name,
|
||||||
drafts_path = case_dir / "drafts"
|
"path": str(path.relative_to(case_dir)),
|
||||||
if drafts_path.exists():
|
"files": files,
|
||||||
files = []
|
|
||||||
for f in sorted(drafts_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:
|
return {"folders": folders}
|
||||||
result["drafts"] = files
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def serve_blob(
|
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}")
|
@app.get("/api/cases/{case_number}/local-files/{folder}/{filename}")
|
||||||
async def api_read_local_file(case_number: str, folder: str, filename: str):
|
async def api_read_local_file(case_number: str, folder: str, filename: str):
|
||||||
"""Read contents of a local case file."""
|
"""Serve a single local case file from any content subfolder.
|
||||||
if folder not in ("research", "proofread", "drafts"):
|
|
||||||
raise HTTPException(400, "Invalid folder")
|
``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)
|
case_dir = config.find_case_dir(case_number)
|
||||||
if folder == "drafts":
|
path = _resolve_case_file(case_dir, folder, filename)
|
||||||
path = case_dir / "drafts" / filename
|
if path is None:
|
||||||
else:
|
|
||||||
path = case_dir / "documents" / folder / filename
|
|
||||||
if not path.exists() or not path.is_file():
|
|
||||||
raise HTTPException(404, "קובץ לא נמצא")
|
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 ────
|
# ── Research analysis (analysis-and-research.md) — parse + edit ────
|
||||||
|
|||||||
Reference in New Issue
Block a user