Modified DB to allow multiple uploads of of the same file
This commit is contained in:
parent
5dd5ec2792
commit
f37b1ee731
10 changed files with 283 additions and 212 deletions
|
|
@ -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"}
|
||||
|
||||
## 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"
|
||||
BIN
docs/notes.pdf
BIN
docs/notes.pdf
Binary file not shown.
|
|
@ -0,0 +1,2 @@
|
|||
-- DropIndex
|
||||
DROP INDEX "FileNode_hash_key";
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.`);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<HTMLInputElement>(null);
|
||||
|
||||
// Form State
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [targetFolderId, setTargetFolderId] = useState<string>("");
|
||||
const [showNewFolderInput, setShowNewFolderInput] = useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState("");
|
||||
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;
|
||||
|
||||
// --- 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<HTMLInputElement>) => {
|
||||
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,12 +124,35 @@ 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');
|
||||
}
|
||||
};
|
||||
|
||||
// --- 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 (
|
||||
<>
|
||||
<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">
|
||||
Upload & Enrich
|
||||
|
|
@ -143,12 +163,10 @@ export default function UploadView({ folders }: { user: any; folders: any[] }) {
|
|||
<Box>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<TextField
|
||||
select
|
||||
fullWidth
|
||||
label="Parent Destination"
|
||||
select fullWidth label="Parent Destination"
|
||||
value={targetFolderId}
|
||||
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>
|
||||
{folders?.map((f) => (
|
||||
|
|
@ -159,7 +177,6 @@ export default function UploadView({ folders }: { user: any; folders: any[] }) {
|
|||
variant={showNewFolderInput ? "contained" : "outlined"}
|
||||
onClick={() => setShowNewFolderInput(!showNewFolderInput)}
|
||||
sx={{ height: 56, minWidth: 56 }}
|
||||
title="Create a new sub-folder"
|
||||
>
|
||||
<CreateNewFolderIcon />
|
||||
</Button>
|
||||
|
|
@ -171,9 +188,7 @@ export default function UploadView({ folders }: { user: any; folders: any[] }) {
|
|||
NEW SUB-FOLDER NAME
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="e.g. Invoices 2026"
|
||||
fullWidth size="small" placeholder="e.g. Finance 2026"
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
/>
|
||||
|
|
@ -184,24 +199,19 @@ export default function UploadView({ folders }: { user: any; folders: any[] }) {
|
|||
{/* FILE SELECTION */}
|
||||
<Box>
|
||||
<input
|
||||
type="file"
|
||||
id="file-upload-input"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
ref={fileInputRef}
|
||||
type="file" id="file-upload-input" style={{ display: 'none' }}
|
||||
onChange={handleFileChange} ref={fileInputRef}
|
||||
/>
|
||||
{!selectedFile ? (
|
||||
<Button
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
startIcon={<CloudUploadIcon />}
|
||||
variant="outlined" fullWidth startIcon={<CloudUploadIcon />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
sx={{ py: 4, borderStyle: 'dashed', borderWidth: 2, borderRadius: 2 }}
|
||||
>
|
||||
Select File to Upload
|
||||
</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">
|
||||
<CloudUploadIcon color="primary" />
|
||||
<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 }} />
|
||||
|
||||
{/* MAGIC EXTRACT SECTION */}
|
||||
{/* MAGIC EXTRACT */}
|
||||
<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}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight="bold" color="primary.main">
|
||||
Magic Extract
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" fontWeight="bold" color="primary.main">Magic Extract</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Automatically pull Author, GPS, and Camera data from the file.
|
||||
Auto-pull metadata from file content.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleMagicEnhance}
|
||||
variant="contained" onClick={handleMagicEnhance}
|
||||
disabled={!selectedFile || isExtracting}
|
||||
startIcon={isExtracting ? <CircularProgress size={20} color="inherit" /> : <AutoFixHighIcon />}
|
||||
sx={{ borderRadius: 20, px: 3 }}
|
||||
|
|
@ -239,94 +246,100 @@ export default function UploadView({ folders }: { user: any; folders: any[] }) {
|
|||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* METADATA PREVIEW GRID */}
|
||||
{/* METADATA PREVIEW */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" fontWeight="700" sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<AssignmentIcon color="primary" /> Metadata Fields
|
||||
</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}>
|
||||
{rows.map((row, index) => (
|
||||
<Grid container spacing={1} key={index} alignItems="center">
|
||||
<Grid item xs={1}>
|
||||
<Checkbox
|
||||
checked={row.selected}
|
||||
size="small"
|
||||
onChange={(e) => {
|
||||
<Checkbox checked={row.selected} size="small" onChange={(e) => {
|
||||
const updated = [...rows];
|
||||
updated[index].selected = e.target.checked;
|
||||
setRows(updated);
|
||||
}}
|
||||
/>
|
||||
}} />
|
||||
</Grid>
|
||||
<Grid item xs={5}>
|
||||
<TextField
|
||||
fullWidth size="small" label="Property Name" value={row.key}
|
||||
onChange={(e) => {
|
||||
<TextField fullWidth size="small" label="Key" value={row.key} onChange={(e) => {
|
||||
const updated = [...rows];
|
||||
updated[index].key = e.target.value;
|
||||
setRows(updated);
|
||||
}}
|
||||
/>
|
||||
}} />
|
||||
</Grid>
|
||||
<Grid item xs={5}>
|
||||
<TextField
|
||||
fullWidth size="small" label="Value" value={row.value}
|
||||
onChange={(e) => {
|
||||
<TextField fullWidth size="small" label="Value" value={row.value} onChange={(e) => {
|
||||
const updated = [...rows];
|
||||
updated[index].value = e.target.value;
|
||||
setRows(updated);
|
||||
}}
|
||||
/>
|
||||
}} />
|
||||
</Grid>
|
||||
<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 />
|
||||
</IconButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="text"
|
||||
startIcon={<AddCircleOutlineIcon />}
|
||||
onClick={() => setRows([...rows, {key: "", value: "", selected: true}])}
|
||||
sx={{ alignSelf: 'flex-start', mt: 1 }}
|
||||
>
|
||||
<Button variant="text" startIcon={<AddCircleOutlineIcon />} onClick={() => setRows([...rows, {key: "", value: "", selected: true}])}>
|
||||
Add Manual Field
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* ACTION BUTTON */}
|
||||
{/* FINAL BUTTON */}
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
fullWidth
|
||||
onClick={handleSave}
|
||||
disabled={!canSubmit || saveStatus === 'saving'}
|
||||
sx={{
|
||||
py: 2,
|
||||
fontWeight: 'bold',
|
||||
borderRadius: 2,
|
||||
boxShadow: 4
|
||||
}}
|
||||
variant="contained" size="large" fullWidth onClick={handleSave}
|
||||
disabled={!canSubmit || saveStatus !== 'idle'}
|
||||
sx={{ py: 2, fontWeight: 'bold', borderRadius: 2, boxShadow: 4 }}
|
||||
>
|
||||
{saveStatus === 'saving' ? (
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<CircularProgress size={24} color="inherit" />
|
||||
<Typography>Creating Folder & Uploading...</Typography>
|
||||
</Stack>
|
||||
) : (
|
||||
"Complete Upload & Save"
|
||||
)}
|
||||
{saveStatus === 'hashing' ? <CircularProgress size={24} color="inherit" /> :
|
||||
saveStatus === 'saving' ? "Uploading to OneDrive..." :
|
||||
"Complete Upload & Save"}
|
||||
</Button>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
9
src/lib/hashing-client.ts
Normal file
9
src/lib/hashing-client.ts
Normal 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('');
|
||||
}
|
||||
Loading…
Reference in a new issue