Modified DB to allow multiple uploads of of the same file

This commit is contained in:
stephen 2026-02-15 15:10:58 +11:00
parent 5dd5ec2792
commit f37b1ee731
10 changed files with 283 additions and 212 deletions

View file

@ -2529,3 +2529,27 @@ $\FallingEdge$
{"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"} {"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 ## 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"

Binary file not shown.

View file

@ -0,0 +1,2 @@
-- DropIndex
DROP INDEX "FileNode_hash_key";

View file

@ -60,7 +60,7 @@ model FileNode {
id String @id id String @id
name String name String
size BigInt? // Preserved your BigInt size column size BigInt? // Preserved your BigInt size column
hash String? @unique // <--- Added for duplicate detection (MD5 or SHA-256) hash String?
isFolder Boolean @default(false) isFolder Boolean @default(false)
oneDriveId String? @unique oneDriveId String? @unique
path String path String

View file

@ -3,7 +3,7 @@ import { prisma } from '../src/lib/prisma';
import * as nodeCrypto from 'crypto'; import * as nodeCrypto from 'crypto';
function generateFileHash(buffer: Buffer): string { 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 { try {
const files = await prisma.fileNode.findMany({ const files = await prisma.fileNode.findMany({
where: { isFolder: false, hash: null }, where: { isFolder: false
},
}); });
console.log(`📂 Found ${files.length} files to process.`); console.log(`📂 Found ${files.length} files to process.`);

View file

@ -5,10 +5,18 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { createFileNode } from "@/data-access/file-nodes"; 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"; 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 * 1. CREATE VIRTUAL FOLDER
*/ */
@ -44,6 +52,7 @@ export async function uploadFileAction(formData: FormData) {
if (!session?.user?.id) throw new Error("Unauthorized"); if (!session?.user?.id) throw new Error("Unauthorized");
const file = formData.get("file") as File; const file = formData.get("file") as File;
const hash = formData.get("hash") as string;
const description = formData.get("description") as string || ""; const description = formData.get("description") as string || "";
const parentIdRaw = formData.get("parentId") as string | null; const parentIdRaw = formData.get("parentId") as string | null;
const parentId = (parentIdRaw === "" || parentIdRaw === "root") ? null : parentIdRaw; const parentId = (parentIdRaw === "" || parentIdRaw === "root") ? null : parentIdRaw;
@ -77,6 +86,7 @@ await createFileNode({
id: internalId, id: internalId,
oneDriveId: uploadedFileData.id, oneDriveId: uploadedFileData.id,
name: file.name, name: file.name,
hash:hash,
description: description, description: description,
size: BigInt(file.size), size: BigInt(file.size),
isFolder: false, isFolder: false,

View file

@ -1,3 +1,4 @@
// src/app/upload/page.tsx
import { auth } from "@/auth"; import { auth } from "@/auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import UploadView from "./upload-view"; // This is the Client Component import UploadView from "./upload-view"; // This is the Client Component

View file

@ -1,12 +1,14 @@
'use client'; 'use client';
// src/app/upload/upload-view.tsx
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import { import {
Box, Button, Typography, Paper, Stack, Box, Button, Typography, Paper, Stack,
TextField, IconButton, Divider, TextField, IconButton, Divider,
Grid, Grid,
CircularProgress, Checkbox, MenuItem, CircularProgress, Checkbox, MenuItem,
Collapse Collapse,
Dialog, DialogTitle, DialogContent,
DialogContentText, DialogActions
} from "@mui/material"; } from "@mui/material";
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'; import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; 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 CreateNewFolderIcon from '@mui/icons-material/CreateNewFolder';
import AssignmentIcon from '@mui/icons-material/Assignment'; import AssignmentIcon from '@mui/icons-material/Assignment';
import ClearIcon from '@mui/icons-material/Clear'; import ClearIcon from '@mui/icons-material/Clear';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { getMetadataPreviewAction } from "@/app/dashboard/actions"; 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 { interface MetadataRow {
key: string; key: string;
@ -30,34 +35,36 @@ export default function UploadView({ folders }: { user: any; folders: any[] }) {
const router = useRouter(); const router = useRouter();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// Form State
const [selectedFile, setSelectedFile] = useState<File | null>(null); const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [targetFolderId, setTargetFolderId] = useState<string>(""); const [targetFolderId, setTargetFolderId] = useState<string>("");
const [showNewFolderInput, setShowNewFolderInput] = useState(false); const [showNewFolderInput, setShowNewFolderInput] = useState(false);
const [newFolderName, setNewFolderName] = useState(""); const [newFolderName, setNewFolderName] = useState("");
const [rows, setRows] = useState<MetadataRow[]>([]); const [rows, setRows] = useState<MetadataRow[]>([]);
const [isExtracting, setIsExtracting] = useState(false);
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving'>('idle');
// Logic to determine if the "Complete" button should be active // UI Status State
const [isExtracting, setIsExtracting] = useState(false);
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);
const canSubmit = selectedFile !== null || newFolderName.trim().length > 0; const canSubmit = selectedFile !== null || newFolderName.trim().length > 0;
// --- 1. MAGIC EXTRACTION LOGIC --- // --- 1. MAGIC EXTRACTION ---
const handleMagicEnhance = async () => { const handleMagicEnhance = async () => {
if (!selectedFile) return; if (!selectedFile) return;
setIsExtracting(true); setIsExtracting(true);
try { try {
const result = await getMetadataPreviewAction(selectedFile.name); const result = await getMetadataPreviewAction(selectedFile.name);
if (result.success) { if (result.success) {
// Map data from the new extractor (PDF or Image)
const extractedRows: MetadataRow[] = Object.entries(result.data ?? {}).map(([k, v]) => ({ const extractedRows: MetadataRow[] = Object.entries(result.data ?? {}).map(([k, v]) => ({
key: k, key: k,
value: typeof v === 'object' ? JSON.stringify(v) : String(v), value: typeof v === 'object' ? JSON.stringify(v) : String(v),
isPending: true, isPending: true,
selected: true selected: true
})); }));
setRows(prev => { setRows(prev => {
const existingKeys = new Set(prev.map(r => r.key)); const existingKeys = new Set(prev.map(r => r.key));
const newUniqueRows = extractedRows.filter(r => !existingKeys.has(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<HTMLInputElement>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) setSelectedFile(file);
setSelectedFile(file);
// Optional: Auto-run magic enhance on file selection
// handleMagicEnhance();
}
}; };
// --- 2. SAVE / UPLOAD LOGIC --- // --- 2. UPLOAD EXECUTION ---
const handleSave = async () => { const executeUpload = async (preCalculatedHash?: string) => {
if (!canSubmit) return;
setSaveStatus('saving'); setSaveStatus('saving');
try { try {
let currentParentId = targetFolderId; let currentParentId = targetFolderId;
// STEP A: Create Folder if user typed a new folder name // STEP A: Handle New Folder Creation
if (newFolderName.trim()) { if (newFolderName.trim()) {
const folderResult = await createFolderAction(newFolderName, targetFolderId || null); const folderResult = await createFolderAction(newFolderName, targetFolderId || null);
if (folderResult.success) { if (folderResult.success) {
// If successful, the file goes inside this NEW folder
currentParentId = folderResult.node.id; currentParentId = folderResult.node.id;
} else { } else {
throw new Error(folderResult.error || "Failed to create folder"); 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) { if (selectedFile) {
const formData = new FormData(); const formData = new FormData();
formData.append("file", selectedFile); formData.append("file", selectedFile);
formData.append("hash", preCalculatedHash || "");
formData.append("parentId", currentParentId || "root"); formData.append("parentId", currentParentId || "root");
// Construct Metadata Object
const metadataObject = rows const metadataObject = rows
.filter(r => r.selected && r.key.trim() !== "") .filter(r => r.selected && r.key.trim() !== "")
.reduce((acc, curr) => { .reduce((acc, curr) => {
@ -116,10 +116,7 @@ export default function UploadView({ folders }: { user: any; folders: any[] }) {
formData.append("customMetadata", JSON.stringify(metadataObject)); formData.append("customMetadata", JSON.stringify(metadataObject));
const uploadResult = await uploadFileAction(formData); 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"); router.push("/dashboard");
@ -127,12 +124,35 @@ export default function UploadView({ folders }: { user: any; folders: any[] }) {
} catch (err: any) { } catch (err: any) {
console.error("Save failed:", err); console.error("Save failed:", err);
alert(err.message || "An error occurred while saving."); alert(err.message || "An error occurred while saving.");
} finally {
setSaveStatus('idle'); setSaveStatus('idle');
} }
}; };
// --- 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);
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 ( return (
<>
<Paper sx={{ p: { xs: 3, md: 6 }, borderRadius: 4, maxWidth: 800, mx: 'auto', mt: 4 }} elevation={3}> <Paper sx={{ p: { xs: 3, md: 6 }, borderRadius: 4, maxWidth: 800, mx: 'auto', mt: 4 }} elevation={3}>
<Typography variant="h4" fontWeight={900} color="primary" gutterBottom align="center"> <Typography variant="h4" fontWeight={900} color="primary" gutterBottom align="center">
Upload & Enrich Upload & Enrich
@ -143,12 +163,10 @@ export default function UploadView({ folders }: { user: any; folders: any[] }) {
<Box> <Box>
<Stack direction="row" spacing={1}> <Stack direction="row" spacing={1}>
<TextField <TextField
select select fullWidth label="Parent Destination"
fullWidth
label="Parent Destination"
value={targetFolderId} value={targetFolderId}
onChange={(e) => setTargetFolderId(e.target.value)} onChange={(e) => setTargetFolderId(e.target.value)}
helperText="Choose where your file (and new folder) will live" helperText="Choose where your file will live"
> >
<MenuItem value=""><em>-- Root Directory --</em></MenuItem> <MenuItem value=""><em>-- Root Directory --</em></MenuItem>
{folders?.map((f) => ( {folders?.map((f) => (
@ -159,7 +177,6 @@ export default function UploadView({ folders }: { user: any; folders: any[] }) {
variant={showNewFolderInput ? "contained" : "outlined"} variant={showNewFolderInput ? "contained" : "outlined"}
onClick={() => setShowNewFolderInput(!showNewFolderInput)} onClick={() => setShowNewFolderInput(!showNewFolderInput)}
sx={{ height: 56, minWidth: 56 }} sx={{ height: 56, minWidth: 56 }}
title="Create a new sub-folder"
> >
<CreateNewFolderIcon /> <CreateNewFolderIcon />
</Button> </Button>
@ -171,9 +188,7 @@ export default function UploadView({ folders }: { user: any; folders: any[] }) {
NEW SUB-FOLDER NAME NEW SUB-FOLDER NAME
</Typography> </Typography>
<TextField <TextField
fullWidth fullWidth size="small" placeholder="e.g. Finance 2026"
size="small"
placeholder="e.g. Invoices 2026"
value={newFolderName} value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)} onChange={(e) => setNewFolderName(e.target.value)}
/> />
@ -184,24 +199,19 @@ export default function UploadView({ folders }: { user: any; folders: any[] }) {
{/* FILE SELECTION */} {/* FILE SELECTION */}
<Box> <Box>
<input <input
type="file" type="file" id="file-upload-input" style={{ display: 'none' }}
id="file-upload-input" onChange={handleFileChange} ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleFileChange}
ref={fileInputRef}
/> />
{!selectedFile ? ( {!selectedFile ? (
<Button <Button
variant="outlined" variant="outlined" fullWidth startIcon={<CloudUploadIcon />}
fullWidth
startIcon={<CloudUploadIcon />}
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
sx={{ py: 4, borderStyle: 'dashed', borderWidth: 2, borderRadius: 2 }} sx={{ py: 4, borderStyle: 'dashed', borderWidth: 2, borderRadius: 2 }}
> >
Select File to Upload Select File to Upload
</Button> </Button>
) : ( ) : (
<Paper variant="outlined" sx={{ p: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between', bgcolor: 'primary.50', borderStyle: 'solid' }}> <Paper variant="outlined" sx={{ p: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between', bgcolor: 'primary.50' }}>
<Stack direction="row" spacing={2} alignItems="center"> <Stack direction="row" spacing={2} alignItems="center">
<CloudUploadIcon color="primary" /> <CloudUploadIcon color="primary" />
<Typography variant="body1" fontWeight="600">{selectedFile.name}</Typography> <Typography variant="body1" fontWeight="600">{selectedFile.name}</Typography>
@ -216,20 +226,17 @@ export default function UploadView({ folders }: { user: any; folders: any[] }) {
<Divider sx={{ my: 4 }} /> <Divider sx={{ my: 4 }} />
{/* MAGIC EXTRACT SECTION */} {/* MAGIC EXTRACT */}
<Box sx={{ mb: 4, p: 2.5, bgcolor: '#f0f7ff', borderRadius: 2, border: '1px dashed #1976d2' }}> <Box sx={{ mb: 4, p: 2.5, bgcolor: '#f0f7ff', borderRadius: 2, border: '1px dashed #1976d2' }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" spacing={2}> <Stack direction="row" justifyContent="space-between" alignItems="center" spacing={2}>
<Box> <Box>
<Typography variant="subtitle1" fontWeight="bold" color="primary.main"> <Typography variant="subtitle1" fontWeight="bold" color="primary.main">Magic Extract</Typography>
Magic Extract
</Typography>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
Automatically pull Author, GPS, and Camera data from the file. Auto-pull metadata from file content.
</Typography> </Typography>
</Box> </Box>
<Button <Button
variant="contained" variant="contained" onClick={handleMagicEnhance}
onClick={handleMagicEnhance}
disabled={!selectedFile || isExtracting} disabled={!selectedFile || isExtracting}
startIcon={isExtracting ? <CircularProgress size={20} color="inherit" /> : <AutoFixHighIcon />} startIcon={isExtracting ? <CircularProgress size={20} color="inherit" /> : <AutoFixHighIcon />}
sx={{ borderRadius: 20, px: 3 }} sx={{ borderRadius: 20, px: 3 }}
@ -239,94 +246,100 @@ export default function UploadView({ folders }: { user: any; folders: any[] }) {
</Stack> </Stack>
</Box> </Box>
{/* METADATA PREVIEW GRID */} {/* METADATA PREVIEW */}
<Box sx={{ mb: 4 }}> <Box sx={{ mb: 4 }}>
<Typography variant="h6" fontWeight="700" sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}> <Typography variant="h6" fontWeight="700" sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<AssignmentIcon color="primary" /> Metadata Fields <AssignmentIcon color="primary" /> Metadata Fields
</Typography> </Typography>
{rows.length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic', textAlign: 'center', py: 2 }}>
No metadata added yet. Run Magic Extract or add manual fields below.
</Typography>
)}
<Stack spacing={2}> <Stack spacing={2}>
{rows.map((row, index) => ( {rows.map((row, index) => (
<Grid container spacing={1} key={index} alignItems="center"> <Grid container spacing={1} key={index} alignItems="center">
<Grid item xs={1}> <Grid item xs={1}>
<Checkbox <Checkbox checked={row.selected} size="small" onChange={(e) => {
checked={row.selected}
size="small"
onChange={(e) => {
const updated = [...rows]; const updated = [...rows];
updated[index].selected = e.target.checked; updated[index].selected = e.target.checked;
setRows(updated); setRows(updated);
}} }} />
/>
</Grid> </Grid>
<Grid item xs={5}> <Grid item xs={5}>
<TextField <TextField fullWidth size="small" label="Key" value={row.key} onChange={(e) => {
fullWidth size="small" label="Property Name" value={row.key}
onChange={(e) => {
const updated = [...rows]; const updated = [...rows];
updated[index].key = e.target.value; updated[index].key = e.target.value;
setRows(updated); setRows(updated);
}} }} />
/>
</Grid> </Grid>
<Grid item xs={5}> <Grid item xs={5}>
<TextField <TextField fullWidth size="small" label="Value" value={row.value} onChange={(e) => {
fullWidth size="small" label="Value" value={row.value}
onChange={(e) => {
const updated = [...rows]; const updated = [...rows];
updated[index].value = e.target.value; updated[index].value = e.target.value;
setRows(updated); setRows(updated);
}} }} />
/>
</Grid> </Grid>
<Grid item xs={1}> <Grid item xs={1}>
<IconButton onClick={() => setRows(rows.filter((_, i) => i !== index))} color="error" size="small"> <IconButton onClick={() => setRows(rows.filter((_, i) => i !== index))} color="error">
<DeleteOutlineIcon /> <DeleteOutlineIcon />
</IconButton> </IconButton>
</Grid> </Grid>
</Grid> </Grid>
))} ))}
<Button variant="text" startIcon={<AddCircleOutlineIcon />} onClick={() => setRows([...rows, {key: "", value: "", selected: true}])}>
<Button
variant="text"
startIcon={<AddCircleOutlineIcon />}
onClick={() => setRows([...rows, {key: "", value: "", selected: true}])}
sx={{ alignSelf: 'flex-start', mt: 1 }}
>
Add Manual Field Add Manual Field
</Button> </Button>
</Stack> </Stack>
</Box> </Box>
{/* ACTION BUTTON */} {/* FINAL BUTTON */}
<Button <Button
variant="contained" variant="contained" size="large" fullWidth onClick={handleSave}
size="large" disabled={!canSubmit || saveStatus !== 'idle'}
fullWidth sx={{ py: 2, fontWeight: 'bold', borderRadius: 2, boxShadow: 4 }}
onClick={handleSave}
disabled={!canSubmit || saveStatus === 'saving'}
sx={{
py: 2,
fontWeight: 'bold',
borderRadius: 2,
boxShadow: 4
}}
> >
{saveStatus === 'saving' ? ( {saveStatus === 'hashing' ? <CircularProgress size={24} color="inherit" /> :
<Stack direction="row" spacing={2} alignItems="center"> saveStatus === 'saving' ? "Uploading to OneDrive..." :
<CircularProgress size={24} color="inherit" /> "Complete Upload & Save"}
<Typography>Creating Folder & Uploading...</Typography>
</Stack>
) : (
"Complete Upload & Save"
)}
</Button> </Button>
</Paper> </Paper>
{/* --- DUPLICATE ALERT DIALOG --- */}
<Dialog
open={duplicateDialogOpen}
onClose={() => { setDuplicateDialogOpen(false); setSaveStatus('idle'); }}
PaperProps={{ sx: { borderRadius: 3, p: 1, maxWidth: 450 } }}
>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1, color: 'warning.dark', fontWeight: 'bold' }}>
<WarningAmberIcon fontSize="large" /> Duplicate Content
</DialogTitle>
<DialogContent>
{/* FIX: Added component="div" here.
This prevents the "<div> cannot be a descendant of <p>" error
*/}
<DialogContentText component="div">
The file you selected has exactly the same content as a file already in your library:
<Box sx={{ mt: 2, p: 2, bgcolor: 'warning.50', border: '1px solid', borderColor: 'warning.light', borderRadius: 2, color: 'text.primary', fontWeight: '600' }}>
{duplicateInfo?.name}
</Box>
<Typography variant="body2" sx={{ mt: 2, color: 'text.secondary' }}>
Would you like to skip this upload or create a second copy?
</Typography>
</DialogContentText>
</DialogContent>
<DialogActions sx={{ p: 2, gap: 1 }}>
<Button
onClick={() => { setDuplicateDialogOpen(false); setSaveStatus('idle'); }}
variant="outlined" color="inherit" fullWidth
>
Cancel
</Button>
<Button
onClick={() => { setDuplicateDialogOpen(false); executeUpload(duplicateInfo?.hash); }}
variant="contained" color="warning" fullWidth
>
Upload Anyway
</Button>
</DialogActions>
</Dialog>
</>
); );
} }

View file

@ -61,6 +61,7 @@ export async function createFileNode(data: {
id?: string; // Optional: used for virtual folders/UUID storage id?: string; // Optional: used for virtual folders/UUID storage
oneDriveId: string | null; oneDriveId: string | null;
name: string; name: string;
hash?: string | null; // ✅ ADDED: For SHA-256 duplicate prevention
description?: string; description?: string;
isFolder: boolean; isFolder: boolean;
path: string; path: string;
@ -84,7 +85,15 @@ export async function createFileNode(data: {
* UPSERT: Create or Update a file node based on OneDrive ID * UPSERT: Create or Update a file node based on OneDrive ID
* Moved here because it interacts with the Database. * 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({ return await prisma.fileNode.upsert({
where: { oneDriveId }, where: { oneDriveId },
update: { update: {
@ -92,6 +101,7 @@ export async function upsertFileNode(oneDriveId: string, data: any) {
size: data.size, size: data.size,
isFolder: data.isFolder, isFolder: data.isFolder,
path: data.path, path: data.path,
hash: data.hash, // ✅ ADDED
updatedAt: new Date(), updatedAt: new Date(),
}, },
create: { create: {
@ -103,6 +113,7 @@ export async function upsertFileNode(oneDriveId: string, data: any) {
path: data.path, path: data.path,
ownerId: data.ownerId, ownerId: data.ownerId,
metadata: data.metadata, metadata: data.metadata,
hash: data.hash, // ✅ ADDED
} }
}); });
} }

View file

@ -0,0 +1,9 @@
// src/lib/hashing-client.ts
// src/lib/hashing-client.ts
export async function calculateFileHash(file: File): Promise<string> {
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('');
}