Add courtlistener intergration, liquid glass redesign, UI improvements, version control, various fixes

This commit is contained in:
willchen96 2026-06-06 15:48:47 +08:00
parent d39f5806e5
commit 44e868eb42
106 changed files with 16350 additions and 7753 deletions

View file

@ -6,7 +6,12 @@ import {
attachActiveVersionPaths,
attachLatestVersionNumbers,
} from "../lib/documentVersions";
import { downloadFile, uploadFile, storageKey } from "../lib/storage";
import {
deleteFile,
downloadFile,
uploadFile,
storageKey,
} from "../lib/storage";
import { docxToPdf, convertedPdfKey } from "../lib/convert";
import { checkProjectAccess } from "../lib/access";
import { singleFileUpload } from "../lib/upload";
@ -367,6 +372,10 @@ projectsRouter.post(
.single();
if (!doc)
return void res.status(404).json({ detail: "Document not found" });
await attachActiveVersionPaths(
db,
[doc as { id: string; current_version_id?: string | null }],
);
// Already in this project — idempotent
if (doc.project_id === projectId) return void res.json(doc);
@ -381,22 +390,49 @@ projectsRouter.post(
.single();
if (error || !updated)
return void res.status(500).json({ detail: "Failed to update document" });
await attachActiveVersionPaths(
db,
[updated as { id: string; current_version_id?: string | null }],
);
return void res.json(updated);
} else {
// Belongs to another project → duplicate record AND copy the
// underlying storage objects so each project's copy is fully
// independent (edits/version bumps on one don't leak into the
// other).
if (!doc.current_version_id) {
return void res
.status(404)
.json({ detail: "Source document has no active version" });
}
const { data: srcV } = await db
.from("document_versions")
.select(
"storage_path, pdf_storage_path, version_number, filename, source, file_type, size_bytes, page_count",
)
.eq("id", doc.current_version_id)
.single();
if (!srcV?.storage_path) {
return void res
.status(404)
.json({ detail: "Source document has no active version" });
}
const activeVersionFilename =
(srcV.filename as string | null)?.trim() || "Untitled document";
const srcBytes = await downloadFile(srcV.storage_path);
if (!srcBytes) {
return void res
.status(500)
.json({ detail: "Failed to read source document bytes" });
}
const { data: copy, error } = await db
.from("documents")
.insert({
project_id: projectId,
user_id: userId,
filename: doc.filename,
file_type: doc.file_type,
size_bytes: doc.size_bytes,
page_count: doc.page_count,
structure_tree: doc.structure_tree,
status: doc.status,
})
.select("*")
@ -404,69 +440,90 @@ projectsRouter.post(
if (error || !copy)
return void res.status(500).json({ detail: "Failed to copy document" });
let copyVersionRowId: string | null = null;
if (doc.current_version_id) {
const { data: srcV } = await db
.from("document_versions")
.select(
"storage_path, pdf_storage_path, version_number, display_name, source",
)
.eq("id", doc.current_version_id)
.single();
if (srcV?.storage_path) {
const srcBytes = await downloadFile(srcV.storage_path);
if (!srcBytes) {
return void res
.status(500)
.json({ detail: "Failed to read source document bytes" });
}
const newKey = storageKey(userId, copy.id as string, doc.filename);
const contentType =
doc.file_type === "pdf"
? "application/pdf"
: "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
await uploadFile(newKey, srcBytes, contentType);
const newKey = storageKey(
userId,
copy.id as string,
activeVersionFilename,
);
let newPdfPath: string | null = null;
try {
const contentType =
((srcV.file_type as string | null) ?? doc.file_type) === "pdf"
? "application/pdf"
: "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
await uploadFile(newKey, srcBytes, contentType);
// PDFs share one object for source + display rendition. DOCX
// store the converted PDF at a separate `converted-pdfs/` key —
// copy that too if it exists so the copy renders without going
// back through libreoffice.
let newPdfPath: string | null = null;
if (srcV.pdf_storage_path) {
if (srcV.pdf_storage_path === srcV.storage_path) {
newPdfPath = newKey;
} else {
const pdfBytes = await downloadFile(srcV.pdf_storage_path);
if (pdfBytes) {
const newPdfKey = convertedPdfKey(userId, copy.id as string);
await uploadFile(newPdfKey, pdfBytes, "application/pdf");
newPdfPath = newPdfKey;
}
// PDFs share one object for source + display rendition. DOCX
// store the converted PDF at a separate `converted-pdfs/` key —
// copy that too if it exists so the copy renders without going
// back through libreoffice.
if (srcV.pdf_storage_path) {
if (srcV.pdf_storage_path === srcV.storage_path) {
newPdfPath = newKey;
} else {
const pdfBytes = await downloadFile(srcV.pdf_storage_path);
if (pdfBytes) {
const newPdfKey = convertedPdfKey(userId, copy.id as string);
await uploadFile(newPdfKey, pdfBytes, "application/pdf");
newPdfPath = newPdfKey;
}
}
const { data: newV } = await db
.from("document_versions")
.insert({
document_id: copy.id,
storage_path: newKey,
pdf_storage_path: newPdfPath,
source: (srcV.source as string | null) ?? "upload",
version_number: srcV.version_number ?? 1,
display_name: srcV.display_name ?? doc.filename,
})
.select("id")
.single();
copyVersionRowId = (newV?.id as string | null) ?? null;
if (copyVersionRowId) {
await db
.from("documents")
.update({ current_version_id: copyVersionRowId })
.eq("id", copy.id);
}
}
const { data: newV, error: newVError } = await db
.from("document_versions")
.insert({
document_id: copy.id,
storage_path: newKey,
pdf_storage_path: newPdfPath,
source: (srcV.source as string | null) ?? "upload",
version_number: srcV.version_number ?? 1,
filename: activeVersionFilename,
file_type: (srcV.file_type as string | null) ?? doc.file_type,
size_bytes:
(srcV.size_bytes as number | null) ?? doc.size_bytes ?? null,
page_count:
(srcV.page_count as number | null) ?? doc.page_count ?? null,
})
.select("id")
.single();
const copyVersionRowId = (newV?.id as string | null) ?? null;
if (newVError || !copyVersionRowId) {
throw new Error(
`Failed to create copied document version: ${newVError?.message ?? "unknown"}`,
);
}
const { data: updatedCopy, error: updateCopyError } = await db
.from("documents")
.update({
current_version_id: copyVersionRowId,
})
.eq("id", copy.id)
.select("*")
.single();
if (updateCopyError || !updatedCopy) {
throw new Error(
`Failed to activate copied document version: ${updateCopyError?.message ?? "unknown"}`,
);
}
await attachActiveVersionPaths(
db,
[updatedCopy as { id: string; current_version_id?: string | null }],
);
return void res.status(201).json(updatedCopy);
} catch (err) {
console.error("[projects/documents/copy] failed", err);
await Promise.all([
deleteFile(newKey).catch(() => {}),
newPdfPath && newPdfPath !== newKey
? deleteFile(newPdfPath).catch(() => {})
: Promise.resolve(),
db.from("documents").delete().eq("id", copy.id),
]);
return void res.status(500).json({ detail: "Failed to copy document" });
}
return void res.status(201).json(copy);
}
},
);
@ -484,20 +541,33 @@ projectsRouter.patch("/:projectId/documents/:documentId", requireAuth, async (re
const { data: doc } = await db
.from("documents")
.select("id, filename, current_version_id")
.select("id, current_version_id")
.eq("id", documentId)
.eq("project_id", projectId)
.single();
if (!doc)
return void res.status(404).json({ detail: "Document not found" });
const filename = normalizeDocumentFilename(req.body?.filename, doc.filename as string);
const active = doc.current_version_id
? await db
.from("document_versions")
.select("filename")
.eq("id", doc.current_version_id)
.eq("document_id", documentId)
.single()
: null;
const currentName =
typeof active?.data?.filename === "string" &&
active.data.filename.trim()
? active.data.filename.trim()
: "Untitled document";
const filename = normalizeDocumentFilename(req.body?.filename, currentName);
if (!filename)
return void res.status(400).json({ detail: "filename is required" });
const { data: updated, error } = await db
.from("documents")
.update({ filename, updated_at: new Date().toISOString() })
.update({ updated_at: new Date().toISOString() })
.eq("id", documentId)
.eq("project_id", projectId)
.select("*")
@ -508,12 +578,15 @@ projectsRouter.patch("/:projectId/documents/:documentId", requireAuth, async (re
if (doc.current_version_id) {
await db
.from("document_versions")
.update({ display_name: filename })
.update({ filename })
.eq("id", doc.current_version_id)
.eq("document_id", documentId);
}
res.json(updated);
res.json({
...updated,
filename,
});
});
// POST /projects/:projectId/documents
@ -714,9 +787,6 @@ export async function handleDocumentUpload(
.insert({
project_id: projectId,
user_id: userId,
filename,
file_type: suffix,
size_bytes: content.byteLength,
status: "processing",
})
.select("*")
@ -747,7 +817,6 @@ export async function handleDocumentUpload(
content.byteOffset,
content.byteOffset + content.byteLength,
) as ArrayBuffer;
const tree = await extractStructureTree(rawBuf, suffix, filename);
const pageCount = suffix === "pdf" ? await countPdfPages(rawBuf) : null;
// Convert DOCX/DOC → PDF for display. PDFs are their own rendition.
@ -785,7 +854,10 @@ export async function handleDocumentUpload(
pdf_storage_path: pdfStoragePath,
source: "upload",
version_number: 1,
display_name: filename,
filename,
file_type: suffix,
size_bytes: content.byteLength,
page_count: pageCount,
})
.select("id")
.single();
@ -799,9 +871,6 @@ export async function handleDocumentUpload(
.from("documents")
.update({
current_version_id: versionRow.id,
size_bytes: content.byteLength,
page_count: pageCount,
structure_tree: tree ?? null,
status: "ready",
updated_at: new Date().toISOString(),
})
@ -813,10 +882,15 @@ export async function handleDocumentUpload(
.eq("id", docId)
.single();
const responseDoc = updated
? {
? {
...updated,
filename,
storage_path: key,
pdf_storage_path: pdfStoragePath,
file_type: suffix,
size_bytes: content.byteLength,
page_count: pageCount,
active_version_number: 1,
}
: updated;
return void res.status(201).json(responseDoc);
@ -843,63 +917,3 @@ async function countPdfPages(buf: ArrayBuffer): Promise<number | null> {
return null;
}
}
async function extractStructureTree(
content: ArrayBuffer,
fileType: string,
filename: string,
): Promise<unknown[] | null> {
try {
if (fileType === "pdf") {
const pdfjsLib = await import(
"pdfjs-dist/legacy/build/pdf.mjs" as string
);
const pdf = await (
pdfjsLib as unknown as {
getDocument: (opts: unknown) => {
promise: Promise<{
numPages: number;
getOutline: () => Promise<{ title?: string }[]>;
}>;
};
}
).getDocument({ data: new Uint8Array(content) }).promise;
if (pdf.numPages <= 5) return null;
const outline = await pdf.getOutline();
if (outline?.length) {
return outline.map((item, i) => ({
id: `h1-${i}`,
title: item.title ?? `Item ${i + 1}`,
level: 1,
page_number: null,
children: [],
}));
}
return Array.from({ length: pdf.numPages }, (_, i) => ({
id: `page-${i + 1}`,
title: `Page ${i + 1}`,
level: 1,
page_number: i + 1,
children: [],
}));
} else {
const mammoth = await import("mammoth");
const result = await mammoth.extractRawText({
buffer: Buffer.from(content),
});
const lines = result.value.split("\n").filter((l) => l.trim());
const nodes = lines
.slice(0, 30)
.map((line, i) => ({
id: `h1-${i}`,
title: line.slice(0, 100),
level: 1,
page_number: null,
children: [],
}));
return nodes.length ? nodes : null;
}
} catch {
return null;
}
}