efore work on second problem for hash

This commit is contained in:
stephen 2026-05-28 21:38:41 +10:00
parent f37b1ee731
commit abc0e052ed
9 changed files with 5070 additions and 26 deletions

File diff suppressed because one or more lines are too long

39
package-lock.json generated
View file

@ -28,6 +28,7 @@
"pg": "^8.16.3", "pg": "^8.16.3",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-dropzone": "^15.0.0",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"sharp": "^0.34.5" "sharp": "^0.34.5"
}, },
@ -3715,6 +3716,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/attr-accept": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
"integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/available-typed-arrays": { "node_modules/available-typed-arrays": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@ -5341,6 +5351,18 @@
"node": ">=16.0.0" "node": ">=16.0.0"
} }
}, },
"node_modules/file-selector": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
"integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==",
"license": "MIT",
"dependencies": {
"tslib": "^2.7.0"
},
"engines": {
"node": ">= 12"
}
},
"node_modules/fill-range": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@ -7975,6 +7997,23 @@
"react": "^19.2.3" "react": "^19.2.3"
} }
}, },
"node_modules/react-dropzone": {
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-15.0.0.tgz",
"integrity": "sha512-lGjYV/EoqEjEWPnmiSvH4v5IoIAwQM2W4Z1C0Q/Pw2xD0eVzKPS359BQTUMum+1fa0kH2nrKjuavmTPOGhpLPg==",
"license": "MIT",
"dependencies": {
"attr-accept": "^2.2.4",
"file-selector": "^2.1.0",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">= 10.13"
},
"peerDependencies": {
"react": ">= 16.8 || 18.0.0"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",

View file

@ -33,6 +33,7 @@
"pg": "^8.16.3", "pg": "^8.16.3",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-dropzone": "^15.0.0",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"sharp": "^0.34.5" "sharp": "^0.34.5"
}, },

View file

@ -4,7 +4,7 @@
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, getAllFolders, upsertFileNodeByHash } from "@/data-access/file-nodes";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { createOneDriveFolder, uploadToFolderId, ensureOneDriveFolder } from "@/services/onedrive"; import { createOneDriveFolder, uploadToFolderId, ensureOneDriveFolder } from "@/services/onedrive";
/** /**
@ -108,3 +108,91 @@ await createFileNode({
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
} }
/**
* NEW: FETCH ALL VIRTUAL FOLDERS
* This is the exact export the compiler is looking for.
*/
export async function getFoldersAction() {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
try {
// This calls the helper in your data-access/file-nodes.ts
const folders = await getAllFolders();
return folders;
} catch (error) {
console.error("Error in getFoldersAction:", error);
return [];
}
}
/**
* EXECUTE BULK ITEM
* Orchestrates the physical upload to OneDrive and the database record
* creation/update. This is the heart of the Bulk Upload and Restore system.
*/
export async function executeBulkItemAction(formData: FormData) {
const session = await auth();
// 1. Security check
if (!session?.user?.id) {
throw new Error("Unauthorized: You must be logged in to perform bulk actions.");
}
const file = formData.get("file") as File;
const hash = formData.get("hash") as string;
const targetFolderIdRaw = formData.get("targetFolderId") as string | null;
// Standardize the target folder ID
const targetFolderId = (targetFolderIdRaw === "" || targetFolderIdRaw === "root")
? null
: targetFolderIdRaw;
if (!file || !hash) {
throw new Error("Missing required file or hash data for bulk operation.");
}
try {
const rootFolder = "WebCalibre";
// We generate a unique internal ID to serve as the physical folder name on OneDrive
const internalId = crypto.randomUUID();
// 2. Physical Storage (OneDrive)
// Ensure the root app folder exists first
await ensureOneDriveFolder(session.user.id, rootFolder);
// Create the unique subfolder for this file (to avoid collisions and match single-upload logic)
const subFolderRes = await createOneDriveFolder(session.user.id, rootFolder, internalId);
const subFolderData = await subFolderRes.json();
// Perform the actual binary upload
const uploadedFileData = await uploadToFolderId(session.user.id, file, subFolderData.id);
// 3. Database Logic (Data Access Layer)
// We use upsertFileNodeByHash to handle the Disaster Recovery case:
// If the hash matches an existing record, it updates the OneDrive link.
// If not, it creates a brand new record.
const result = await upsertFileNodeByHash({
name: file.name,
hash: hash,
oneDriveId: uploadedFileData.id,
parentId: targetFolderId,
size: BigInt(file.size),
ownerId: session.user.id,
});
// 4. Refresh UI
revalidatePath("/dashboard");
return {
success: true,
id: result.id,
mode: result.createdAt === result.updatedAt ? 'created' : 'restored'
};
} catch (error: any) {
console.error("Bulk Item Execution Failure:", error);
return {
success: false,
error: error.message || "An unexpected error occurred during upload."
};
}
}

View file

@ -0,0 +1,243 @@
"use client";
import React, { useState, useCallback, useEffect } from 'react';
import { useDropzone } from 'react-dropzone';
import {
Box, Button, Typography, Paper, Table, TableBody,
TableCell, TableContainer, TableHead, TableRow, Chip,
Stack, MenuItem, Select, IconButton, Tooltip, LinearProgress
} from '@mui/material';
import FolderIcon from '@mui/icons-material/Folder';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import DeleteIcon from '@mui/icons-material/Delete';
import { calculateFileHash } from '@/lib/hashing-client';
import { checkDuplicateAction, getFoldersAction, executeBulkItemAction } from '../_actions';
interface UploadQueueItem {
id: string;
file: File;
path: string;
hash: string | null;
status: 'queued' | 'hashing' | 'checking' | 'ready' | 'uploading' | 'success' | 'duplicate' | 'error';
targetFolderId: string;
}
export default function BulkUploadPage() {
const [queue, setQueue] = useState<UploadQueueItem[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const [isExecuting, setIsExecuting] = useState(false);
const [dbFolders, setDbFolders] = useState<{id: string, name: string}[]>([]);
useEffect(() => {
async function loadFolders() {
try {
const folders = await getFoldersAction();
setDbFolders(folders);
} catch (err) {
console.error("Failed to load folders:", err);
}
}
loadFolders();
}, []);
const onDrop = useCallback((acceptedFiles: File[]) => {
const newItems = acceptedFiles.map(file => ({
id: crypto.randomUUID(),
file,
path: (file as any).path || file.name,
hash: null,
status: 'queued' as const,
targetFolderId: ""
}));
setQueue(prev => [...prev, ...newItems]);
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop });
const updateItem = (id: string, updates: Partial<UploadQueueItem>) => {
setQueue(curr => curr.map(item => item.id === id ? { ...item, ...updates } : item));
};
const removeItem = (id: string) => {
setQueue(prev => prev.filter(item => item.id !== id));
};
const copyFirstRowDestination = () => {
if (queue.length < 2) return;
const firstFolderId = queue[0].targetFolderId;
setQueue(current => current.map(item => ({ ...item, targetFolderId: firstFolderId })));
};
useEffect(() => {
if (!isProcessing) return;
const runAnalysis = async () => {
const nextIndex = queue.findIndex(item => item.status === 'queued');
if (nextIndex === -1) { setIsProcessing(false); return; }
const item = queue[nextIndex];
try {
updateItem(item.id, { status: 'hashing' });
const hash = await calculateFileHash(item.file);
updateItem(item.id, { status: 'checking', hash });
const existing = await checkDuplicateAction(hash);
updateItem(item.id, {
status: existing ? 'duplicate' : 'ready',
targetFolderId: existing?.parentId || item.targetFolderId
});
} catch (err) {
updateItem(item.id, { status: 'error' });
}
};
runAnalysis();
}, [queue, isProcessing]);
const handleExecute = async () => {
setIsExecuting(true);
const itemsToProcess = queue.filter(i => i.status === 'ready' || i.status === 'duplicate');
const BATCH_SIZE = 3;
for (let i = 0; i < itemsToProcess.length; i += BATCH_SIZE) {
const batch = itemsToProcess.slice(i, i + BATCH_SIZE);
await Promise.all(batch.map(async (item) => {
updateItem(item.id, { status: 'uploading' });
try {
const formData = new FormData();
formData.append("file", item.file);
formData.append("hash", item.hash || "");
formData.append("targetFolderId", item.targetFolderId);
const result = await executeBulkItemAction(formData);
if (result.success) {
updateItem(item.id, { status: 'success' });
} else {
console.error(`Execution failed for ${item.path}:`, result.error);
updateItem(item.id, { status: 'error' });
}
} catch (error) {
console.error(`Network error for ${item.path}:`, error);
updateItem(item.id, { status: 'error' });
}
}));
}
setIsExecuting(false);
};
return (
<Box sx={{ p: 4, maxWidth: 1400, mx: 'auto' }}>
<Typography variant="h4" fontWeight={900} gutterBottom color="primary">
Bulk Upload & Recovery
</Typography>
{isExecuting && <LinearProgress sx={{ mb: 2 }} />}
<Paper
{...getRootProps()}
sx={{
p: 4, mb: 4, textAlign: 'center', cursor: 'pointer',
border: '2px dashed', borderColor: isDragActive ? 'primary.main' : 'divider',
bgcolor: isDragActive ? 'primary.50' : 'grey.50'
}}
>
<input {...getInputProps()} {...({ webkitdirectory: "", directory: "" } as any)} />
<FolderIcon sx={{ fontSize: 40, color: 'primary.main' }} />
<Typography>Drag Folders or Files Here</Typography>
</Paper>
{queue.length > 0 && (
<>
<TableContainer component={Paper} sx={{ mb: 3, maxHeight: 600 }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell sx={{ width: 50 }} />
<TableCell><strong>File Path</strong></TableCell>
<TableCell><strong>Status</strong></TableCell>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<strong>Destination</strong>
<IconButton size="small" onClick={copyFirstRowDestination} color="primary" disabled={isExecuting}>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Box>
</TableCell>
<TableCell align="right"><strong>Size</strong></TableCell>
</TableRow>
</TableHead>
<TableBody>
{queue.map((item) => (
<TableRow key={item.id} hover>
<TableCell>
<IconButton
size="small"
color="error"
onClick={() => removeItem(item.id)}
disabled={isExecuting || item.status === 'uploading'}
>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
<TableCell sx={{ fontSize: '0.75rem', maxWidth: 300, overflow: 'hidden' }}>
{item.path}
</TableCell>
<TableCell>
<Chip
label={item.status.toUpperCase()}
size="small"
color={
item.status === 'success' ? 'success' :
item.status === 'duplicate' ? 'warning' :
item.status === 'error' ? 'error' : 'default'
}
/>
</TableCell>
<TableCell>
<Select
value={item.targetFolderId}
onChange={(e) => updateItem(item.id, { targetFolderId: e.target.value })}
size="small" fullWidth displayEmpty
disabled={item.status === 'success' || isExecuting}
sx={{ fontSize: '0.8rem' }}
>
<MenuItem value="">Root Directory</MenuItem>
{dbFolders.map(f => <MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>)}
</Select>
</TableCell>
<TableCell align="right">
{(item.file.size / 1024 / 1024).toFixed(2)}MB
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Stack direction="row" spacing={2} justifyContent="flex-end">
<Button variant="outlined" onClick={() => setQueue([])} disabled={isExecuting}>Clear Queue</Button>
<Button
variant="contained"
startIcon={<PlayArrowIcon />}
onClick={() => setIsProcessing(true)}
disabled={isProcessing || isExecuting}
>
Analyze
</Button>
<Button
variant="contained"
color="success"
startIcon={<CloudUploadIcon />}
onClick={handleExecute}
disabled={isProcessing || isExecuting || !queue.some(i => i.status === 'ready' || i.status === 'duplicate')}
>
Execute Upload/Restore
</Button>
</Stack>
</>
)}
</Box>
);
}

View file

@ -1,31 +1,181 @@
// src/app/upload/page.tsx // src/app/upload/bulk/page.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import UploadView from "./upload-view"; // This is the Client Component
import { Container } from "@mui/material";
import { prisma } from "@/lib/prisma";
// 1. Rename to UploadPage to avoid conflict with the 'UploadView' import "use client";
// 2. Add 'async' so you can use 'await' inside
export default async function UploadPage() {
const session = await auth();
if (!session) redirect("/"); import React, { useState, useCallback, useEffect } from 'react';
import { useDropzone } from 'react-dropzone';
import {
Box, Button, Typography, Paper, Table, TableBody,
TableCell, TableContainer, TableHead, TableRow, LinearProgress, Chip,
Alert, Stack
} from '@mui/material';
import FolderIcon from '@mui/icons-material/Folder';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
// 3. Fetch folders. Renamed variable to 'allFolders' to avoid any confusion // --- OUR UTILITIES ---
const allFolders = await prisma.fileNode.findMany({ import { calculateFileHash } from '@/lib/hashing-client';
where: { import { checkDuplicateAction } from '../_actions';
isFolder: true,
ownerId: session.user.id // Good practice: only show user's own folders interface UploadQueueItem {
}, id: string;
orderBy: { name: 'asc' }, file: File;
select: { id: true, name: true, parentId: true } path: string;
}); hash: string | null;
status: 'queued' | 'hashing' | 'checking' | 'ready' | 'uploading' | 'success' | 'duplicate' | 'error';
error?: string;
}
export default function BulkUploadPage() {
const [queue, setQueue] = useState<UploadQueueItem[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
// 1. Handle File & Folder Drops
const onDrop = useCallback((acceptedFiles: File[]) => {
const newItems = acceptedFiles.map(file => ({
id: crypto.randomUUID(),
file,
path: (file as any).path || file.name, // Captures subfolder structure
hash: null,
status: 'queued' as const,
}));
setQueue(prev => [...prev, ...newItems]);
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop });
// 2. The "Processing Engine"
// This effect runs whenever the queue changes or isProcessing toggles
useEffect(() => {
if (!isProcessing) return;
const runQueue = async () => {
// Find the next file that hasn't been hashed/checked yet
const nextIndex = queue.findIndex(item => item.status === 'queued');
if (nextIndex === -1) {
setIsProcessing(false);
return;
}
const item = queue[nextIndex];
try {
// Step A: Hashing
updateItem(item.id, { status: 'hashing' });
const hash = await calculateFileHash(item.file);
// Step B: Duplicate Check
updateItem(item.id, { status: 'checking', hash });
const existing = await checkDuplicateAction(hash);
// Step C: Mark Results
updateItem(item.id, {
status: existing ? 'duplicate' : 'ready'
});
} catch (err) {
updateItem(item.id, { status: 'error', error: 'Process failed' });
}
};
runQueue();
}, [queue, isProcessing]);
const updateItem = (id: string, updates: Partial<UploadQueueItem>) => {
setQueue(current => current.map(item => item.id === id ? { ...item, ...updates } : item));
};
const duplicateCount = queue.filter(i => i.status === 'duplicate').length;
return ( return (
<Container maxWidth="md" sx={{ py: 8 }}> <Box sx={{ p: 4, maxWidth: 1200, mx: 'auto' }}>
{/* 4. Render the Client Component and pass the data */} <Typography variant="h4" fontWeight={800} color="primary" gutterBottom>
<UploadView user={session.user} folders={allFolders} /> Bulk Uploads & Restore
</Container> </Typography>
<Paper
{...getRootProps()}
sx={{
p: 6, mb: 4, textAlign: 'center', cursor: 'pointer',
border: '2px dashed', borderColor: isDragActive ? 'primary.main' : 'divider',
bgcolor: isDragActive ? 'primary.50' : 'background.paper',
transition: 'all 0.2s'
}}
>
<input {...getInputProps()} {...({ webkitdirectory: "", directory: "" } as any)} />
<FolderIcon sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
<Typography variant="h6">Drop Folders or Files Here</Typography>
<Typography variant="body2" color="text.secondary">
Perfect for camera imports or full system restores
</Typography>
</Paper>
{duplicateCount > 0 && (
<Alert severity="warning" sx={{ mb: 3 }}>
{duplicateCount} duplicate(s) found. These files already exist in your library.
</Alert>
)}
{queue.length > 0 && (
<TableContainer component={Paper} sx={{ maxHeight: 500, borderRadius: 2 }}>
<Table stickyHeader size="small">
<TableHead>
<TableRow>
<TableCell>Location / Path</TableCell>
<TableCell>Size</TableCell>
<TableCell>Status</TableCell>
<TableCell>SHA-256 Hash</TableCell>
</TableRow>
</TableHead>
<TableBody>
{queue.map((item) => (
<TableRow key={item.id} hover>
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>
{item.path}
</TableCell>
<TableCell>
{(item.file.size / 1024 / 1024).toFixed(2)} MB
</TableCell>
<TableCell>
<Chip
label={item.status.toUpperCase()}
size="small"
color={
item.status === 'duplicate' ? 'warning' :
item.status === 'ready' ? 'info' :
item.status === 'success' ? 'success' : 'default'
}
/>
</TableCell>
<TableCell sx={{ fontSize: '0.7rem', color: 'text.secondary' }}>
{item.hash ? `${item.hash.substring(0, 16)}...` : '---'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
<Stack direction="row" spacing={2} sx={{ mt: 4 }}>
<Button
variant="contained"
size="large"
startIcon={isProcessing ? <LinearProgress sx={{ width: 20 }} /> : <CloudUploadIcon />}
disabled={isProcessing || queue.length === 0}
onClick={() => setIsProcessing(true)}
>
{isProcessing ? 'Analyzing...' : `Analyze ${queue.length} Files`}
</Button>
<Button
variant="outlined"
color="inherit"
disabled={isProcessing}
onClick={() => setQueue([])}
>
Clear All
</Button>
</Stack>
</Box>
); );
} }

View file

@ -9,6 +9,7 @@ import {
import MenuIcon from '@mui/icons-material/Menu'; import MenuIcon from '@mui/icons-material/Menu';
import LibraryBooksIcon from '@mui/icons-material/LibraryBooks'; import LibraryBooksIcon from '@mui/icons-material/LibraryBooks';
import CloudUploadIcon from '@mui/icons-material/CloudUpload'; import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import CloudSyncIcon from '@mui/icons-material/CloudSync'; // Great icon for Bulk/Restore
import DashboardIcon from '@mui/icons-material/Dashboard'; import DashboardIcon from '@mui/icons-material/Dashboard';
import SettingsIcon from '@mui/icons-material/Settings'; import SettingsIcon from '@mui/icons-material/Settings';
import Link from 'next/link'; import Link from 'next/link';
@ -44,6 +45,7 @@ export default function Navbar({ user }: NavbarProps) {
{ text: 'Dashboard', icon: <DashboardIcon />, href: '/dashboard' }, { text: 'Dashboard', icon: <DashboardIcon />, href: '/dashboard' },
{ text: 'Library', icon: <LibraryBooksIcon />, href: '/library' }, { text: 'Library', icon: <LibraryBooksIcon />, href: '/library' },
{ text: 'Upload File', icon: <CloudUploadIcon />, href: '/upload' }, { text: 'Upload File', icon: <CloudUploadIcon />, href: '/upload' },
{ text: 'Bulk Upload / Restore', icon: <CloudSyncIcon />, href: '/upload/bulk' },
]; ];
// Only push Settings if the user has Admin rights // Only push Settings if the user has Admin rights

View file

@ -118,5 +118,73 @@ export async function upsertFileNode(oneDriveId: string, data: {
}); });
} }
/**
* FETCH: Get all virtual folders for selection in dropdowns.
* Used by the BulkUpload page to set destinations.
*/
export async function getAllFolders() {
return await prisma.fileNode.findMany({
where: {
isFolder: true
},
select: {
id: true,
name: true
},
orderBy: {
name: 'asc'
}
});
}
/**
* UPSERT BY HASH: The core of the Disaster Recovery process.
* If a hash exists, we update the OneDrive ID (Restoring the link).
* If not, we create a new entry.
*/
export async function upsertFileNodeByHash(data: {
name: string;
hash: string;
oneDriveId: string;
parentId?: string | null;
size: bigint;
ownerId: string;
mimeType?: string;
}) {
// First, check if we have a record with this hash
const existing = await prisma.fileNode.findFirst({
where: { hash: data.hash }
});
if (existing) {
// 🛡️ DISASTER RECOVERY MODE
// The database knows about this file, but the OneDrive link is old.
// We update the existing record with the NEW cloud ID.
return await prisma.fileNode.update({
where: { id: existing.id },
data: {
oneDriveId: data.oneDriveId,
// Optional: Update parent if the user chose a new folder during restore
parentId: data.parentId || existing.parentId,
updatedAt: new Date(),
}
});
}
// ✨ NEW UPLOAD MODE
return await prisma.fileNode.create({
data: {
id: crypto.randomUUID(),
oneDriveId: data.oneDriveId,
name: data.name,
hash: data.hash,
size: data.size,
isFolder: false,
ownerId: data.ownerId,
parentId: data.parentId || null,
path: data.name, // Simplified for now
metadata: {}, // Placeholder for extractMetadata logic
}
});
}

View file

@ -1,3 +1,4 @@
// src/lib/hashing.ts
import crypto from 'crypto'; import crypto from 'crypto';
/** /**