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"}
|
{"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"
|
||||||
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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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.`);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,206 +124,222 @@ 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}>
|
<>
|
||||||
<Typography variant="h4" fontWeight={900} color="primary" gutterBottom align="center">
|
<Paper sx={{ p: { xs: 3, md: 6 }, borderRadius: 4, maxWidth: 800, mx: 'auto', mt: 4 }} elevation={3}>
|
||||||
Upload & Enrich
|
<Typography variant="h4" fontWeight={900} color="primary" gutterBottom align="center">
|
||||||
</Typography>
|
Upload & Enrich
|
||||||
|
|
||||||
<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
|
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{rows.length === 0 && (
|
<Stack spacing={3} sx={{ mt: 4, mb: 4 }}>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic', textAlign: 'center', py: 2 }}>
|
{/* FOLDER SELECTION */}
|
||||||
No metadata added yet. Run Magic Extract or add manual fields below.
|
<Box>
|
||||||
</Typography>
|
<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}>
|
<Collapse in={showNewFolderInput}>
|
||||||
{rows.map((row, index) => (
|
<Box sx={{ mt: 2, p: 2, bgcolor: 'grey.50', borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||||
<Grid container spacing={1} key={index} alignItems="center">
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
|
||||||
<Grid item xs={1}>
|
NEW SUB-FOLDER NAME
|
||||||
<Checkbox
|
</Typography>
|
||||||
checked={row.selected}
|
<TextField
|
||||||
size="small"
|
fullWidth size="small" placeholder="e.g. Finance 2026"
|
||||||
onChange={(e) => {
|
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];
|
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 fullWidth size="small" label="Key" value={row.key} onChange={(e) => {
|
||||||
<TextField
|
|
||||||
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 fullWidth size="small" label="Value" value={row.value} onChange={(e) => {
|
||||||
<TextField
|
|
||||||
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 item xs={1}>
|
||||||
|
<IconButton onClick={() => setRows(rows.filter((_, i) => i !== index))} color="error">
|
||||||
|
<DeleteOutlineIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={1}>
|
))}
|
||||||
<IconButton onClick={() => setRows(rows.filter((_, i) => i !== index))} color="error" size="small">
|
<Button variant="text" startIcon={<AddCircleOutlineIcon />} onClick={() => setRows([...rows, {key: "", value: "", selected: true}])}>
|
||||||
<DeleteOutlineIcon />
|
Add Manual Field
|
||||||
</IconButton>
|
</Button>
|
||||||
</Grid>
|
</Stack>
|
||||||
</Grid>
|
</Box>
|
||||||
))}
|
|
||||||
|
|
||||||
<Button
|
{/* FINAL BUTTON */}
|
||||||
variant="text"
|
<Button
|
||||||
startIcon={<AddCircleOutlineIcon />}
|
variant="contained" size="large" fullWidth onClick={handleSave}
|
||||||
onClick={() => setRows([...rows, {key: "", value: "", selected: true}])}
|
disabled={!canSubmit || saveStatus !== 'idle'}
|
||||||
sx={{ alignSelf: 'flex-start', mt: 1 }}
|
sx={{ py: 2, fontWeight: 'bold', borderRadius: 2, boxShadow: 4 }}
|
||||||
>
|
>
|
||||||
Add Manual Field
|
{saveStatus === 'hashing' ? <CircularProgress size={24} color="inherit" /> :
|
||||||
</Button>
|
saveStatus === 'saving' ? "Uploading to OneDrive..." :
|
||||||
</Stack>
|
"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>
|
</Box>
|
||||||
|
|
||||||
{/* ACTION BUTTON */}
|
<Typography variant="body2" sx={{ mt: 2, color: 'text.secondary' }}>
|
||||||
<Button
|
Would you like to skip this upload or create a second copy?
|
||||||
variant="contained"
|
</Typography>
|
||||||
size="large"
|
</DialogContentText>
|
||||||
fullWidth
|
</DialogContent>
|
||||||
onClick={handleSave}
|
<DialogActions sx={{ p: 2, gap: 1 }}>
|
||||||
disabled={!canSubmit || saveStatus === 'saving'}
|
<Button
|
||||||
sx={{
|
onClick={() => { setDuplicateDialogOpen(false); setSaveStatus('idle'); }}
|
||||||
py: 2,
|
variant="outlined" color="inherit" fullWidth
|
||||||
fontWeight: 'bold',
|
>
|
||||||
borderRadius: 2,
|
Cancel
|
||||||
boxShadow: 4
|
</Button>
|
||||||
}}
|
<Button
|
||||||
>
|
onClick={() => { setDuplicateDialogOpen(false); executeUpload(duplicateInfo?.hash); }}
|
||||||
{saveStatus === 'saving' ? (
|
variant="contained" color="warning" fullWidth
|
||||||
<Stack direction="row" spacing={2} alignItems="center">
|
>
|
||||||
<CircularProgress size={24} color="inherit" />
|
Upload Anyway
|
||||||
<Typography>Creating Folder & Uploading...</Typography>
|
</Button>
|
||||||
</Stack>
|
</DialogActions>
|
||||||
) : (
|
</Dialog>
|
||||||
"Complete Upload & Save"
|
</>
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</Paper>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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