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"}
## 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
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

View file

@ -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.`);

View file

@ -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,

View file

@ -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

View file

@ -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,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');
}
};
// --- 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
</Typography>
<Stack spacing={3} sx={{ mt: 4, mb: 4 }}>
{/* FOLDER SELECTION */}
<Box>
<Stack direction="row" spacing={1}>
<TextField
select
fullWidth
label="Parent Destination"
value={targetFolderId}
onChange={(e) => setTargetFolderId(e.target.value)}
helperText="Choose where your file (and new folder) will live"
>
<MenuItem value=""><em>-- Root Directory --</em></MenuItem>
{folders?.map((f) => (
<MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>
))}
</TextField>
<Button
variant={showNewFolderInput ? "contained" : "outlined"}
onClick={() => setShowNewFolderInput(!showNewFolderInput)}
sx={{ height: 56, minWidth: 56 }}
title="Create a new sub-folder"
>
<CreateNewFolderIcon />
</Button>
</Stack>
<Collapse in={showNewFolderInput}>
<Box sx={{ mt: 2, p: 2, bgcolor: 'grey.50', borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
NEW SUB-FOLDER NAME
</Typography>
<TextField
fullWidth
size="small"
placeholder="e.g. Invoices 2026"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
/>
</Box>
</Collapse>
</Box>
{/* FILE SELECTION */}
<Box>
<input
type="file"
id="file-upload-input"
style={{ display: 'none' }}
onChange={handleFileChange}
ref={fileInputRef}
/>
{!selectedFile ? (
<Button
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' }}>
<Stack direction="row" spacing={2} alignItems="center">
<CloudUploadIcon color="primary" />
<Typography variant="body1" fontWeight="600">{selectedFile.name}</Typography>
</Stack>
<IconButton onClick={() => setSelectedFile(null)} color="error" size="small">
<ClearIcon />
</IconButton>
</Paper>
)}
</Box>
</Stack>
<Divider sx={{ my: 4 }} />
{/* MAGIC EXTRACT SECTION */}
<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="caption" color="text.secondary">
Automatically pull Author, GPS, and Camera data from the file.
</Typography>
</Box>
<Button
variant="contained"
onClick={handleMagicEnhance}
disabled={!selectedFile || isExtracting}
startIcon={isExtracting ? <CircularProgress size={20} color="inherit" /> : <AutoFixHighIcon />}
sx={{ borderRadius: 20, px: 3 }}
>
{isExtracting ? "Extracting..." : "Run"}
</Button>
</Stack>
</Box>
{/* METADATA PREVIEW GRID */}
<Box sx={{ mb: 4 }}>
<Typography variant="h6" fontWeight="700" sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<AssignmentIcon color="primary" /> Metadata Fields
<>
<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
</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={3} sx={{ mt: 4, mb: 4 }}>
{/* FOLDER SELECTION */}
<Box>
<Stack direction="row" spacing={1}>
<TextField
select fullWidth label="Parent Destination"
value={targetFolderId}
onChange={(e) => setTargetFolderId(e.target.value)}
helperText="Choose where your file will live"
>
<MenuItem value=""><em>-- Root Directory --</em></MenuItem>
{folders?.map((f) => (
<MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>
))}
</TextField>
<Button
variant={showNewFolderInput ? "contained" : "outlined"}
onClick={() => setShowNewFolderInput(!showNewFolderInput)}
sx={{ height: 56, minWidth: 56 }}
>
<CreateNewFolderIcon />
</Button>
</Stack>
<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) => {
<Collapse in={showNewFolderInput}>
<Box sx={{ mt: 2, p: 2, bgcolor: 'grey.50', borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
NEW SUB-FOLDER NAME
</Typography>
<TextField
fullWidth size="small" placeholder="e.g. Finance 2026"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
/>
</Box>
</Collapse>
</Box>
{/* FILE SELECTION */}
<Box>
<input
type="file" id="file-upload-input" style={{ display: 'none' }}
onChange={handleFileChange} ref={fileInputRef}
/>
{!selectedFile ? (
<Button
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' }}>
<Stack direction="row" spacing={2} alignItems="center">
<CloudUploadIcon color="primary" />
<Typography variant="body1" fontWeight="600">{selectedFile.name}</Typography>
</Stack>
<IconButton onClick={() => setSelectedFile(null)} color="error" size="small">
<ClearIcon />
</IconButton>
</Paper>
)}
</Box>
</Stack>
<Divider sx={{ my: 4 }} />
{/* 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="caption" color="text.secondary">
Auto-pull metadata from file content.
</Typography>
</Box>
<Button
variant="contained" onClick={handleMagicEnhance}
disabled={!selectedFile || isExtracting}
startIcon={isExtracting ? <CircularProgress size={20} color="inherit" /> : <AutoFixHighIcon />}
sx={{ borderRadius: 20, px: 3 }}
>
{isExtracting ? "Extracting..." : "Run"}
</Button>
</Stack>
</Box>
{/* 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>
<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) => {
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) => {
}} />
</Grid>
<Grid item xs={5}>
<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) => {
}} />
</Grid>
<Grid item xs={5}>
<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">
<DeleteOutlineIcon />
</IconButton>
</Grid>
</Grid>
<Grid item xs={1}>
<IconButton onClick={() => setRows(rows.filter((_, i) => i !== index))} color="error" size="small">
<DeleteOutlineIcon />
</IconButton>
</Grid>
</Grid>
))}
))}
<Button variant="text" startIcon={<AddCircleOutlineIcon />} onClick={() => setRows([...rows, {key: "", value: "", selected: true}])}>
Add Manual Field
</Button>
</Stack>
</Box>
<Button
variant="text"
startIcon={<AddCircleOutlineIcon />}
onClick={() => setRows([...rows, {key: "", value: "", selected: true}])}
sx={{ alignSelf: 'flex-start', mt: 1 }}
>
Add Manual Field
</Button>
</Stack>
{/* FINAL BUTTON */}
<Button
variant="contained" size="large" fullWidth onClick={handleSave}
disabled={!canSubmit || saveStatus !== 'idle'}
sx={{ py: 2, fontWeight: 'bold', borderRadius: 2, boxShadow: 4 }}
>
{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>
{/* ACTION BUTTON */}
<Button
variant="contained"
size="large"
fullWidth
onClick={handleSave}
disabled={!canSubmit || saveStatus === 'saving'}
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"
)}
</Button>
</Paper>
<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
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
}
});
}

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('');
}