diff --git a/docs/notes.md b/docs/notes.md index 42f913e..b5de5ab 100644 --- a/docs/notes.md +++ b/docs/notes.md @@ -2528,4 +2528,28 @@ $\FallingEdge$ # pdf metadata {"type": "PDF", "mimeType": "application/pdf", "pageCount": "1", "textPreview": " Hello World! 3 1", "details.title": "Analysis of Electromagnetic Field Circulation", "details.author": "Stephen Lohning", "details.creator": "pdfLaTeX", "details.modDate": "D:20260205160832+11'00'", "details.subject": "Electrical Engineering", "details.keywords": "Maxwell, Electromagnetics, Integral Form, EE", "details.language": "null", "details.producer": "LaTeX", "details.creationDate": "D:20260205160832+11'00'", "details.isLinearized": "false", "details.isXFAPresent": "false", "details.trapped.name": "False", "details.pDFFormatVersion": "1.7", "details.encryptFilterName": "null", "details.isAcroFormPresent": "false", "details.isCollectionPresent": "false", "details.isSignaturesPresent": "false", "details.custom.pTEX.Fullbanner": "This is pdfTeX, Version 3.141592653-2.6-1.40.27 (TeX Live 2025/Homebrew) kpathsea version 6.4.1"} -## useful \ No newline at end of file +## useful + +"55d343c1c56047e69200aa5a5b112e0a" +"31098f02fb7539aaa0f3aaff8ed72bcc" +"fee24a48431c73b9b97c58a113bf48de" +"6b29face2612d15077ebd87c83a7784c" +"89b1f392c82379da4ca95597e3c50504" +"4624eb595836af9cf8f5c86670c954cc" +"4806abc5a27c875617ecce612307d908" +"9f0d1962e233712e6318e2cc2a7acf81" +"191c673a6e51338eed5a5d4de59b2722" + +"b0968485b66dac0e6a0a9c908f56c848eac51af649d85ed37def7c362c0d81aa" +"95c0679215501185bcc7de5bd3735312b2da923828e2ecf3fa2ad1fc23700777" +"964edb4b4fb7b6371954ac6f392a55c8ca4ba746f0861a69a11de2deb23be14d" +"4b3b50dee06813859e0235a1c56924729705fa260eff0705f78692d65baf3e1e" +"c3ec806c4aaf7764242549ae95391149630cb817dcc6a7150873aa55f65522b1" +"845d21b536cfa4f5439b80bdda7e1212339c35a691fa32ae4f73a772f262c44e" +"091321649c8c386cd12aba401e3d9312ef9977d54430437bea38d02e1ef0ac21" +"7d57e399378b7e80fe405eb6286bb779867bf047d520497ab24ef5545106797b" +"7362b24f8b323d5f7198d1ab9ee9361dcbcd44e286512f83b4c51aca68815bd1" + +"understanding-a-i.pdf" 5502317 "11d374882dd9c2db41aeb61cfc651ffc79ef1092e17320bd192d4611d1965b18" +"understanding-a-i.pdf" 5502317 "11d374882dd9c2db41aeb61cfc651ffc79ef1092e17320bd192d4611d1965b18" +"understanding-a-i.pdf" 5502317 "11d374882dd9c2db41aeb61cfc651ffc79ef1092e17320bd192d4611d1965b18" \ No newline at end of file diff --git a/docs/notes.pdf b/docs/notes.pdf index 9f3b223..ee41f01 100644 Binary files a/docs/notes.pdf and b/docs/notes.pdf differ diff --git a/prisma/migrations/20260214023739_remove_unique_hash_constraint/migration.sql b/prisma/migrations/20260214023739_remove_unique_hash_constraint/migration.sql new file mode 100644 index 0000000..2fde9f2 --- /dev/null +++ b/prisma/migrations/20260214023739_remove_unique_hash_constraint/migration.sql @@ -0,0 +1,2 @@ +-- DropIndex +DROP INDEX "FileNode_hash_key"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3dfc6ff..a341fdd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -60,7 +60,7 @@ model FileNode { id String @id name String size BigInt? // Preserved your BigInt size column - hash String? @unique // <--- Added for duplicate detection (MD5 or SHA-256) + hash String? isFolder Boolean @default(false) oneDriveId String? @unique path String diff --git a/scripts/backfill-hashes.ts b/scripts/backfill-hashes.ts index 727cc99..759d36c 100644 --- a/scripts/backfill-hashes.ts +++ b/scripts/backfill-hashes.ts @@ -3,7 +3,7 @@ import { prisma } from '../src/lib/prisma'; import * as nodeCrypto from 'crypto'; function generateFileHash(buffer: Buffer): string { - return nodeCrypto.createHash('md5').update(buffer).digest('hex'); + return nodeCrypto.createHash('sha256').update(buffer).digest('hex'); } /** @@ -21,7 +21,8 @@ async function backfill() { try { const files = await prisma.fileNode.findMany({ - where: { isFolder: false, hash: null }, + where: { isFolder: false + }, }); console.log(`📂 Found ${files.length} files to process.`); diff --git a/src/app/upload/_actions.ts b/src/app/upload/_actions.ts index 8de09f0..dd3f287 100644 --- a/src/app/upload/_actions.ts +++ b/src/app/upload/_actions.ts @@ -5,10 +5,18 @@ import { auth } from "@/auth"; import { revalidatePath } from "next/cache"; import { createFileNode } from "@/data-access/file-nodes"; -//import { createOneDriveFolder, uploadToFolderId, ensureOneDriveFolder } from "@/services/onedrive"; - +import { prisma } from "@/lib/prisma"; import { createOneDriveFolder, uploadToFolderId, ensureOneDriveFolder } from "@/services/onedrive"; - +/** + * NEW: CHECK FOR DUPLICATE HASH + */ +export async function checkDuplicateAction(hash: string) { + const existing = await prisma.fileNode.findUnique({ + where: { hash }, + select: { name: true } + }); + return existing; +} /** * 1. CREATE VIRTUAL FOLDER */ @@ -44,6 +52,7 @@ export async function uploadFileAction(formData: FormData) { if (!session?.user?.id) throw new Error("Unauthorized"); const file = formData.get("file") as File; + const hash = formData.get("hash") as string; const description = formData.get("description") as string || ""; const parentIdRaw = formData.get("parentId") as string | null; const parentId = (parentIdRaw === "" || parentIdRaw === "root") ? null : parentIdRaw; @@ -77,6 +86,7 @@ await createFileNode({ id: internalId, oneDriveId: uploadedFileData.id, name: file.name, + hash:hash, description: description, size: BigInt(file.size), isFolder: false, diff --git a/src/app/upload/page.tsx b/src/app/upload/page.tsx index f71fcff..6433bb2 100644 --- a/src/app/upload/page.tsx +++ b/src/app/upload/page.tsx @@ -1,3 +1,4 @@ +// src/app/upload/page.tsx import { auth } from "@/auth"; import { redirect } from "next/navigation"; import UploadView from "./upload-view"; // This is the Client Component diff --git a/src/app/upload/upload-view.tsx b/src/app/upload/upload-view.tsx index f72093b..dd5eaa0 100644 --- a/src/app/upload/upload-view.tsx +++ b/src/app/upload/upload-view.tsx @@ -1,12 +1,14 @@ 'use client'; - +// src/app/upload/upload-view.tsx import { useState, useRef } from "react"; import { Box, Button, Typography, Paper, Stack, TextField, IconButton, Divider, Grid, CircularProgress, Checkbox, MenuItem, - Collapse + Collapse, + Dialog, DialogTitle, DialogContent, + DialogContentText, DialogActions } from "@mui/material"; import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'; import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; @@ -15,9 +17,12 @@ import CloudUploadIcon from '@mui/icons-material/CloudUpload'; import CreateNewFolderIcon from '@mui/icons-material/CreateNewFolder'; import AssignmentIcon from '@mui/icons-material/Assignment'; import ClearIcon from '@mui/icons-material/Clear'; +import WarningAmberIcon from '@mui/icons-material/WarningAmber'; + import { useRouter } from "next/navigation"; import { getMetadataPreviewAction } from "@/app/dashboard/actions"; -import { uploadFileAction, createFolderAction } from "./_actions"; +import { calculateFileHash } from "@/lib/hashing-client"; +import { uploadFileAction, createFolderAction, checkDuplicateAction } from "./_actions"; interface MetadataRow { key: string; @@ -30,34 +35,36 @@ export default function UploadView({ folders }: { user: any; folders: any[] }) { const router = useRouter(); const fileInputRef = useRef(null); + // Form State const [selectedFile, setSelectedFile] = useState(null); const [targetFolderId, setTargetFolderId] = useState(""); const [showNewFolderInput, setShowNewFolderInput] = useState(false); const [newFolderName, setNewFolderName] = useState(""); const [rows, setRows] = useState([]); + + // UI Status State const [isExtracting, setIsExtracting] = useState(false); - const [saveStatus, setSaveStatus] = useState<'idle' | 'saving'>('idle'); + const [saveStatus, setSaveStatus] = useState<'idle' | 'hashing' | 'saving'>('idle'); + + // Duplicate Dialog State + const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false); + const [duplicateInfo, setDuplicateInfo] = useState<{ name: string; hash: string } | null>(null); - // Logic to determine if the "Complete" button should be active const canSubmit = selectedFile !== null || newFolderName.trim().length > 0; - // --- 1. MAGIC EXTRACTION LOGIC --- + // --- 1. MAGIC EXTRACTION --- const handleMagicEnhance = async () => { if (!selectedFile) return; - setIsExtracting(true); try { const result = await getMetadataPreviewAction(selectedFile.name); - if (result.success) { - // Map data from the new extractor (PDF or Image) const extractedRows: MetadataRow[] = Object.entries(result.data ?? {}).map(([k, v]) => ({ key: k, value: typeof v === 'object' ? JSON.stringify(v) : String(v), isPending: true, selected: true })); - setRows(prev => { const existingKeys = new Set(prev.map(r => r.key)); const newUniqueRows = extractedRows.filter(r => !existingKeys.has(r.key)); @@ -73,39 +80,32 @@ export default function UploadView({ folders }: { user: any; folders: any[] }) { const handleFileChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; - if (file) { - setSelectedFile(file); - // Optional: Auto-run magic enhance on file selection - // handleMagicEnhance(); - } + if (file) setSelectedFile(file); }; - // --- 2. SAVE / UPLOAD LOGIC --- - const handleSave = async () => { - if (!canSubmit) return; + // --- 2. UPLOAD EXECUTION --- + const executeUpload = async (preCalculatedHash?: string) => { setSaveStatus('saving'); - try { let currentParentId = targetFolderId; - // STEP A: Create Folder if user typed a new folder name + // STEP A: Handle New Folder Creation if (newFolderName.trim()) { const folderResult = await createFolderAction(newFolderName, targetFolderId || null); if (folderResult.success) { - // If successful, the file goes inside this NEW folder currentParentId = folderResult.node.id; } else { throw new Error(folderResult.error || "Failed to create folder"); } } - // STEP B: Upload File if a file is selected + // STEP B: Handle File Upload if (selectedFile) { const formData = new FormData(); formData.append("file", selectedFile); + formData.append("hash", preCalculatedHash || ""); formData.append("parentId", currentParentId || "root"); - // Construct Metadata Object const metadataObject = rows .filter(r => r.selected && r.key.trim() !== "") .reduce((acc, curr) => { @@ -116,10 +116,7 @@ export default function UploadView({ folders }: { user: any; folders: any[] }) { formData.append("customMetadata", JSON.stringify(metadataObject)); const uploadResult = await uploadFileAction(formData); - - if (!uploadResult.success) { - throw new Error(uploadResult.error || "Upload failed"); - } + if (!uploadResult.success) throw new Error(uploadResult.error || "Upload failed"); } router.push("/dashboard"); @@ -127,206 +124,222 @@ export default function UploadView({ folders }: { user: any; folders: any[] }) { } catch (err: any) { console.error("Save failed:", err); alert(err.message || "An error occurred while saving."); - } finally { setSaveStatus('idle'); } }; - return ( - - - Upload & Enrich - + // --- 3. SAVE HANDLER (With Hash Intercept) --- + const handleSave = async () => { + if (!canSubmit) return; + + if (selectedFile) { + setSaveStatus('hashing'); + // Calculate local SHA-256 + const fileHash = await calculateFileHash(selectedFile); + // Check database via Server Action + const duplicate = await checkDuplicateAction(fileHash); - - {/* FOLDER SELECTION */} - - - setTargetFolderId(e.target.value)} - helperText="Choose where your file (and new folder) will live" - > - -- Root Directory -- - {folders?.map((f) => ( - {f.name} - ))} - + if (duplicate) { + setDuplicateInfo({ name: duplicate.name, hash: fileHash }); + setDuplicateDialogOpen(true); + return; // Dialog takes over from here + } + + await executeUpload(fileHash); + } else { + await executeUpload(); // Folder only + } + }; + + return ( + <> + + + Upload & Enrich + + + + {/* FOLDER SELECTION */} + + + setTargetFolderId(e.target.value)} + helperText="Choose where your file will live" + > + -- Root Directory -- + {folders?.map((f) => ( + {f.name} + ))} + + + + + + + + NEW SUB-FOLDER NAME + + setNewFolderName(e.target.value)} + /> + + + + + {/* FILE SELECTION */} + + + {!selectedFile ? ( + + ) : ( + + + + {selectedFile.name} + + setSelectedFile(null)} color="error" size="small"> + + + + )} + + + + + + {/* MAGIC EXTRACT */} + + + + Magic Extract + + Auto-pull metadata from file content. + + - - - - - NEW SUB-FOLDER NAME - - setNewFolderName(e.target.value)} - /> - - - {/* FILE SELECTION */} - - - {!selectedFile ? ( - - ) : ( - - - - {selectedFile.name} - - setSelectedFile(null)} color="error" size="small"> - - - - )} - - - - - - {/* MAGIC EXTRACT SECTION */} - - - - - Magic Extract - - - Automatically pull Author, GPS, and Camera data from the file. - - - - - - - {/* METADATA PREVIEW GRID */} - - - Metadata Fields - - - {rows.length === 0 && ( - - No metadata added yet. Run Magic Extract or add manual fields below. + {/* METADATA PREVIEW */} + + + Metadata Fields - )} - - - {rows.map((row, index) => ( - - - { + + {rows.map((row, index) => ( + + + { const updated = [...rows]; updated[index].selected = e.target.checked; setRows(updated); - }} - /> - - - { + }} /> + + + { const updated = [...rows]; updated[index].key = e.target.value; setRows(updated); - }} - /> - - - { + }} /> + + + { const updated = [...rows]; updated[index].value = e.target.value; setRows(updated); - }} - /> + }} /> + + + setRows(rows.filter((_, i) => i !== index))} color="error"> + + + - - setRows(rows.filter((_, i) => i !== index))} color="error" size="small"> - - - - - ))} - - - + ))} + + + + + {/* FINAL BUTTON */} + + + + {/* --- DUPLICATE ALERT DIALOG --- */} + { setDuplicateDialogOpen(false); setSaveStatus('idle'); }} + PaperProps={{ sx: { borderRadius: 3, p: 1, maxWidth: 450 } }} +> + + Duplicate Content + + + {/* FIX: Added component="div" here. + This prevents the "
cannot be a descendant of

" error + */} + + The file you selected has exactly the same content as a file already in your library: + + + {duplicateInfo?.name} - {/* ACTION BUTTON */} - - + + Would you like to skip this upload or create a second copy? + + + + + + + +

+ ); } \ No newline at end of file diff --git a/src/data-access/file-nodes.ts b/src/data-access/file-nodes.ts index 3c4620b..74954f4 100644 --- a/src/data-access/file-nodes.ts +++ b/src/data-access/file-nodes.ts @@ -61,6 +61,7 @@ export async function createFileNode(data: { id?: string; // Optional: used for virtual folders/UUID storage oneDriveId: string | null; name: string; + hash?: string | null; // ✅ ADDED: For SHA-256 duplicate prevention description?: string; isFolder: boolean; path: string; @@ -84,7 +85,15 @@ export async function createFileNode(data: { * UPSERT: Create or Update a file node based on OneDrive ID * Moved here because it interacts with the Database. */ -export async function upsertFileNode(oneDriveId: string, data: any) { +export async function upsertFileNode(oneDriveId: string, data: { + name: string; + size: bigint; + isFolder: boolean; + path: string; + ownerId: string; + metadata: any; + hash?: string | null; // ✅ ADDED: Keep hash in sync during upserts +}) { return await prisma.fileNode.upsert({ where: { oneDriveId }, update: { @@ -92,6 +101,7 @@ export async function upsertFileNode(oneDriveId: string, data: any) { size: data.size, isFolder: data.isFolder, path: data.path, + hash: data.hash, // ✅ ADDED updatedAt: new Date(), }, create: { @@ -103,6 +113,7 @@ export async function upsertFileNode(oneDriveId: string, data: any) { path: data.path, ownerId: data.ownerId, metadata: data.metadata, + hash: data.hash, // ✅ ADDED } }); } diff --git a/src/lib/hashing-client.ts b/src/lib/hashing-client.ts new file mode 100644 index 0000000..84e1793 --- /dev/null +++ b/src/lib/hashing-client.ts @@ -0,0 +1,9 @@ +// src/lib/hashing-client.ts +// src/lib/hashing-client.ts +export async function calculateFileHash(file: File): Promise { + const arrayBuffer = await file.arrayBuffer(); + // Native browser API (SubtleCrypto) + const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); +} \ No newline at end of file