Compare commits

..

10 commits

37 changed files with 21033 additions and 3641 deletions

369
backup/db-backup-26-02-14 Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

Binary file not shown.

59
docs/Context-1.md Normal file
View file

@ -0,0 +1,59 @@
# WebCalibre: Application Context Document (v1.0)
## 1 Purpose & Vision
WebCalibre is a specialized Digital Asset Manager (DAM) designed to bridge the gap between cloud storage (OneDrive) and local metadata enrichment. It allows users to organize files while extracting and preserving deep technical metadata (EXIF for images, Document Info for PDFs) that standard file explorers ignore.
## 2 Core Technical Stack
- Framework: Next.js 15+ (App Router)
- Language: TypeScript
- Database: PostgreSQL via Prisma ORM
- Authentication: NextAuth.js (Auth.js)
- Styling: Material UI (MUI)
- Storage: Integrated with Microsoft OneDrive (via Microsoft Graph API)
- Processing:
- **Images:** sharp + exif-reader
- **PDFs: pdf-parse-new (v2 logic)
-
## 3 Application Architecture
### **A. Data Access Pattern (DAL)**
The project follows a strict Data Access Layer pattern located in src/data-access/.
- **Purpose:** All Prisma queries and database interactions are isolated here.
- **Benefit:** Components and Server Actions do not talk directly to the database; they call functions from file-nodes.ts or users.ts. This provides a single point of truth for data fetching and improves security by centralizing authorization checks.
**B. The Data Layer** (prisma/schema.prisma)
The app uses a **Recursive Tree Structure** for files and folders:
- **FileNode:** Represents both files and folders. Folders have a parentId pointing to another FileNode.
- **Metadata:** Stored as a JSONB field in the database, allowing for flexible, unstructured data from different file types.
**C. The Extraction Engine** (src/lib/metadata-extractor.ts)
A server-side utility that:
1. Identifies file type by extension.
2. Parses the file Buffer.
3. Images: Extracts camera make, model, GPS coordinates (DMS), aperture, and ISO.
4. PDFs: Extracts Author, Title, Subject, Page Count, and a Text Preview.
5. Sanitization: Normalizes raw binary buffers and complex objects into JSON-safe strings.
**D. The Transformation Layer** (src/lib/transformers.ts)
Converts raw, lowercase, or inconsistent metadata into a GUI-ready object. It handles unit conversions (e.g., Shutter Speed 0.02 → 1/50s) and coordinate mapping for Google Maps.
4. **Current Feature Roadmap & Status**
|Feature|Status|Description|
|-------|------|-----------|
|Dashboard|✅ Active|Tabular view of FileNodes. Supports "Magic Fill" and file listing.|
|Upload & Enrich|✅ FixedUploads files to OneDrive and creates sub-folders.|
|PDF Extraction|✅ Active|Uses pdf-parse-new for high-fidelity metadata.
|Library|⏳ Planned|A "Discovery" view (Gallery for images, Bookshelf for PDFs)||File Detail View|🚀 Next Up|A dedicated page for deep-diving into metadata (The "Double-Click" view).|
1. **Known Logic & UX Patterns**
- **Magic Fill:** A core "Wow" feature where the app reads the file binary before saving to suggest metadata properties.
- **Folder Logic:** Folders can be created at the "Root" (WebCalibre folder on OneDrive) or nested within existing directories.
- **Server Actions:** All database and storage mutations are handled via Next.js Server Actions for security and speed.
1. **Critical Fixes Applied**
- PDF Worker Crash: Resolved by using pdf-parse-new and disabling the web-worker in the Node.js environment.
- Folder Reset Bug: Fixed in upload-view.tsx by removing the logic that cleared the targetFolderId when toggling the "New Folder" input.

BIN
docs/Context-1.pdf Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -42,6 +42,9 @@
- [1. Where to put the fetch call?](#1-where-to-put-the-fetch-call)
- [2. Where to put `getMetadataFromJson`?](#2-where-to-put-getmetadatafromjson)
- [🏗️ How it looks all together](#-how-it-looks-all-together)
- [image metadata](#image-metadata)
- [pdf metadata](#pdf-metadata)
- [useful](#useful)
# 1. Reference
@ -2495,3 +2498,58 @@ When you put these in their proper places, your **API Route** or **Server Action
4. **Save to DB** (Prisma / DAL)
**Would you like me to help you refine the internal logic of the `extractExifMetadata` function to ensure it handles the specific "Rational" numbers (fractions) that EXIF often uses for exposure and GPS?**
$\pi r^2$
$$H_2O$$
$$\sum$$
$$2^{10}$$
$$\sum_{i=1}^{n} i = \frac{n(n+1)}{2}$$
$$\int_{a}^{b} x^2 \, dx$$
$$\iiint$$
$$\oint$$
$\underline{\overline{ \text{--- } \bullet \text{ --- } \bullet \text{ --- } }}$
$\textifsym{mm<DDD>mm}$
$\FallingEdge$
# image metadata
{"type": "JPEG", "mimeType": "image/jpeg", "dimensions": "4032x3024", "details.bigEndian": "true", "details.image.make": "Apple", "details.image.model": "iPhone 14", "details.photo.flash": "16", "details.image.gPSTag": "2586", "details.image.exifTag": "228", "details.photo.fNumber": "1.5", "details.image.dateTime": "2025-10-22T12:08:16.000Z", "details.image.software": "18.6.2", "details.photo.lensMake": "Apple", "details.photo.lensModel": "iPhone 14 back dual wide camera 5.7mm f/1.5", "details.photo.makerNote": "[Binary Data: 1710 bytes]", "details.photo.sceneType": "[Binary Data: 1 bytes]", "details.gPSInfo.gPSSpeed": "0.04381048133718738", "details.photo.colorSpace": "65535", "details.photo.offsetTime": "+02:00", "details.image.orientation": "6", "details.image.xResolution": "72", "details.image.yResolution": "72", "details.photo.exifVersion": "[Binary Data: 4 bytes]", "details.photo.focalLength": "5.7", "details.photo.subjectArea": "2209,1013,261,260", "details.image.hostComputer": "iPhone 14", "details.photo.exposureMode": "0", "details.photo.exposureTime": "0.001876172607879925", "details.photo.meteringMode": "5", "details.photo.whiteBalance": "0", "details.gPSInfo.gPSAltitude": "664.5505481120584", "details.gPSInfo.gPSLatitude": "40,25,5.48", "details.gPSInfo.gPSSpeedRef": "K", "details.photo.apertureValue": "1.1699250021066825", "details.photo.sensingMethod": "2", "details.gPSInfo.gPSDateStamp": "2025:10:22", "details.gPSInfo.gPSLongitude": "3,41,7.67", "details.gPSInfo.gPSTimeStamp": "10,8,15", "details.image.resolutionUnit": "2", "details.photo.compositeImage": "2", "details.photo.brightnessValue": "7.091505376344086", "details.photo.exposureProgram": "2", "details.photo.flashpixVersion": "[Binary Data: 4 bytes]", "details.photo.iSOSpeedRatings": "50", "details.photo.pixelXDimension": "4032", "details.photo.pixelYDimension": "3024", "details.thumbnail.compression": "6", "details.thumbnail.xResolution": "72", "details.thumbnail.yResolution": "72", "details.gPSInfo.gPSAltitudeRef": "0", "details.gPSInfo.gPSDestBearing": "345.1883852691218", "details.gPSInfo.gPSLatitudeRef": "N", "details.image.yCbCrPositioning": "1", "details.photo.dateTimeOriginal": "2025-10-22T12:08:16.000Z", "details.photo.sceneCaptureType": "0", "details.gPSInfo.gPSImgDirection": "345.1883852691218", "details.gPSInfo.gPSLongitudeRef": "W", "details.photo.dateTimeDigitized": "2025-10-22T12:08:16.000Z", "details.photo.exposureBiasValue": "0", "details.photo.lensSpecification": "1.5399999618512084,5.699999809263318,1.5,2.4", "details.photo.shutterSpeedValue": "9.058893693156405", "details.photo.offsetTimeOriginal": "+02:00", "details.photo.subSecTimeOriginal": "132", "details.thumbnail.resolutionUnit": "2", "details.gPSInfo.gPSDestBearingRef": "T", "details.photo.offsetTimeDigitized": "+02:00", "details.photo.subSecTimeDigitized": "132", "details.gPSInfo.gPSImgDirectionRef": "T", "details.photo.focalLengthIn35mmFilm": "26", "details.gPSInfo.gPSHPositioningError": "14.15490024117518", "details.photo.componentsConfiguration": "[Binary Data: 4 bytes]", "details.thumbnail.jPEGInterchangeFormat": "2990", "details.thumbnail.jPEGInterchangeFormatLength": "11238"}
# pdf metadata
{"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.

File diff suppressed because it is too large Load diff

1339
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -4,6 +4,7 @@
"private": true,
"scripts": {
"dev": "next dev",
"backfill": "tsx scripts/backfill-hashes.ts",
"build": "next build",
"start": "next start",
"lint": "eslint",
@ -21,15 +22,18 @@
"@mui/material-nextjs": "^7.3.6",
"@mui/x-data-grid": "^8.24.0",
"@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0",
"@prisma/client": "7.4.0",
"epub": "^1.3.0",
"epub2": "^3.0.2",
"exif-reader": "^2.0.3",
"next": "16.1.1",
"next-auth": "^5.0.0-beta.30",
"pdf-parse": "^2.4.5",
"pdf-parse-new": "^2.0.0",
"pg": "^8.16.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-dropzone": "^15.0.0",
"server-only": "^0.0.1",
"sharp": "^0.34.5"
},
@ -41,7 +45,8 @@
"dotenv-cli": "^11.0.0",
"eslint": "^9",
"eslint-config-next": "16.1.1",
"prisma": "^6.19.2",
"prisma": "7.4.0",
"tsx": "^4.21.0",
"typescript": "^5"
}
}

View file

@ -1,13 +1,13 @@
// prisma.config.ts
import { config } from "dotenv";
config({ path: ".env.local" });
import { defineConfig } from "prisma/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
datasource: {
// This is now the ONLY place where the DB connection string is defined
// prisma.config.ts
url: process.env.DATABASE_URL!,
// This is where Prisma 7 looks for the connection string
url: env("DATABASE_URL"),
},
});

View file

@ -1,20 +1,58 @@
-- CreateSchema
CREATE SCHEMA IF NOT EXISTS "public";
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"azureAdUserId" TEXT NOT NULL,
"name" TEXT,
"email" TEXT NOT NULL,
"displayName" TEXT,
"role" "Role" NOT NULL DEFAULT 'USER',
"emailVerified" TIMESTAMP(3),
"image" TEXT,
"azureAdUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Account" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FileNode" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"size" BIGINT,
"hash" TEXT,
"isFolder" BOOLEAN NOT NULL DEFAULT false,
"oneDriveId" TEXT,
"path" TEXT NOT NULL,
@ -29,11 +67,17 @@ CREATE TABLE "FileNode" (
CONSTRAINT "FileNode_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "User_azureAdUserId_key" ON "User"("azureAdUserId");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
-- CreateIndex
CREATE UNIQUE INDEX "FileNode_oneDriveId_key" ON "FileNode"("oneDriveId");
@ -47,8 +91,15 @@ CREATE INDEX "FileNode_orderIndex_idx" ON "FileNode"("orderIndex");
-- CreateIndex
CREATE UNIQUE INDEX "FileNode_ownerId_path_key" ON "FileNode"("ownerId", "path");
-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FileNode" ADD CONSTRAINT "FileNode_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FileNode" ADD CONSTRAINT "FileNode_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "FileNode"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "FileNode" ADD CONSTRAINT "FileNode_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "FileNode"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -1,52 +0,0 @@
/*
Warnings:
- You are about to drop the column `displayName` on the `User` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "User" DROP COLUMN "displayName",
ADD COLUMN "emailVerified" TIMESTAMP(3),
ADD COLUMN "image" TEXT,
ADD COLUMN "name" TEXT,
ALTER COLUMN "azureAdUserId" DROP NOT NULL;
-- CreateTable
CREATE TABLE "Account" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -0,0 +1,8 @@
/*
Warnings:
- A unique constraint covering the columns `[hash]` on the table `FileNode` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "FileNode_hash_key" ON "FileNode"("hash");

View file

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

View file

@ -60,6 +60,7 @@ model FileNode {
id String @id
name String
size BigInt? // Preserved your BigInt size column
hash String?
isFolder Boolean @default(false)
oneDriveId String? @unique
path String

View file

@ -0,0 +1,59 @@
import 'dotenv/config';
import { prisma } from '../src/lib/prisma';
import * as nodeCrypto from 'crypto';
function generateFileHash(buffer: Buffer): string {
return nodeCrypto.createHash('sha256').update(buffer).digest('hex');
}
/**
* Replace this with your actual OneDrive download logic!
*/
async function getFromOneDrive(oneDriveId: string): Promise<Buffer> {
// For now, let's pretend we downloaded it to test the DB update
// DELETE THESE 2 LINES when you add your real OneDrive fetch code:
console.log(` ⬇️ Downloading ${oneDriveId}...`);
return Buffer.from(`mock-data-for-${oneDriveId}`);
}
async function backfill() {
console.log('🏁 Starting backfill with Prisma Adapter...');
try {
const files = await prisma.fileNode.findMany({
where: { isFolder: false
},
});
console.log(`📂 Found ${files.length} files to process.`);
for (const file of files) {
try {
process.stdout.write(`Processing: ${file.name}... `);
// 1. Get the file content
const buffer = await getFromOneDrive(file.oneDriveId!);
// 2. Generate the hash
const hash = generateFileHash(buffer);
// 3. Update the database
await prisma.fileNode.update({
where: { id: file.id },
data: { hash: hash }
});
console.log(`✅ Success! (Hash: ${hash.substring(0, 8)}...)`);
} catch (err) {
console.log(`❌ Failed: ${err instanceof Error ? err.message : err}`);
}
}
} catch (error) {
console.error('🚨 Script Error:', error);
} finally {
await prisma.$disconnect();
console.log('🏁 Finished.');
}
}
backfill();

View file

@ -16,12 +16,14 @@ import {
uploadToOneDrive
} from "@/services/onedrive";
import { getEnrichedMetadataFromCloud } from "@/data-access/file-nodes";
//import { getEnrichedMetadataFromCloud } from "@/data-access/file-nodes";
import { getFreshAccessToken } from "@/lib/auth-utils";
import { getOneDriveFileBuffer } from "@/services/onedrive"; // Import your working service
import { extractMetadata } from "@/lib/metadata-extractor";
/**
* 1. FETCH: Get all file nodes
* Now simply calls the DAL. Error handling is left to the caller (the UI).
@ -120,134 +122,35 @@ export async function updateFileNodeAction(id: string, formData: FormData) {
}
}
// export async function getMetadataPreviewAction(fileId: string) {
// const session = await auth();
// if (!session?.user?.id) throw new Error("Unauthorized");
// try {
// console.log(`🔍 Starting enhancement for file: ${fileId}`);
// // This calls the DAL -> which calls the Service -> which calls OneDrive
// const data = await getEnrichedMetadataFromCloud(fileId);
// // This log will show you exactly what we found in your terminal!
// console.log("✅ Extracted Metadata Result:", data);
// return { success: true, data };
// } catch (error: any) {
// console.error("❌ Enhancement Action Error:", error.message);
// return { success: false, error: error.message };
// }
// }
/**
* 5. ENHANCE (Magic Fill): Extracts deep metadata from the actual file binary
*/
// export async function getMetadataPreviewAction(fileId: string) {
// const session = await auth();
// if (!session?.user?.id) throw new Error("Unauthorized");
// console.log(`\n--- 🔍 Magic Fill Started for File ID: ${fileId} ---`);
// try {
// // 1. Get the record from DB to get the oneDriveId and Name
// const node = await getFileNodeById(fileId);
// if (!node || !node.oneDriveId) {
// throw new Error("File not found or not synced with OneDrive");
// }
// // 2. Get fresh token
// const token = await getFreshAccessToken(session.user.id);
// // 3. Fetch the actual binary content from Microsoft Graph
// console.log(`📡 Fetching binary from Microsoft Graph...`);
// let response;
// try {
// response = await fetch(
// `https://graph.microsoftonline.com/v1.0/me/drive/items/${node.oneDriveId}/content`,
// {
// headers: { Authorization: `Bearer ${token}` },
// cache: 'no-store' // Ensure we aren't hitting a stale server cache
// }
// );
// } catch (err: any) {
// console.error("❌ THE ACTUAL NETWORK ERROR:");
// console.error("Message:", err.message);
// console.error("Cause/Stack:", err.cause || err.stack); // This is the gold mine
// throw new Error(`Server-side fetch failed: ${err.message}`);
// }
// if (!response.ok) {
// throw new Error(`Failed to fetch file content: ${response.statusText}`);
// }
// const arrayBuffer = await response.arrayBuffer();
// const buffer = Buffer.from(arrayBuffer);
// console.log(`📦 Downloaded ${buffer.length} bytes.`);
// // 4. Run the Metadata Utility
// const extractedData = await extractMetadata(buffer, node.name);
// // --- 🏁 THE TERMINAL LOG YOU REQUESTED ---
// console.log("✅ RAW DATA EXTRACTED FROM FILE:");
// console.dir(extractedData, { depth: null, colors: true });
// console.log(`--- 🏁 Magic Fill Finished ---\n`);
// return { success: true, data: extractedData };
// } catch (error: any) {
// console.error("❌ Magic Fill Error:", error.message);
// return { success: false, error: error.message };
// }
// }
export async function getMetadataPreviewAction(fileId: string) {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
console.log(`\n--- 🔍 Magic Fill Started for File ID: ${fileId} ---`);
try {
const node = await getFileNodeById(fileId);
if (!node || !node.oneDriveId) throw new Error("File not found");
if (!node || !node.oneDriveId) throw new Error("No OneDrive ID found");
console.log(`📡 Attempting fetch via Service for: ${node.name}`);
// 1. Get the token from our utility
const token = await getFreshAccessToken(session.user.id);
// LOG: Just check the length to be sure it's not empty
console.log(`🔑 Token retrieved (Length: ${token.length})`);
if (!token) throw new Error("Could not retrieve access token");
// Call your existing service
const buffer = await getOneDriveFileBuffer(node.oneDriveId, token);
console.log(`📡 Fetching binary for: ${node.name}...`);
console.log(`📦 Buffer received: ${buffer.length} bytes`);
// 2. Fetch the content from Microsoft Graph
const response = await fetch(
`https://graph.microsoft.com/v1.0/me/drive/items/${node.oneDriveId}/content`,
{
headers: {
'Authorization': `Bearer ${token}`,
'Accept': '*/*'
},
}
);
if (!response.ok) {
// If it fails here, we'll see the real reason from Microsoft
const errorText = await response.text();
console.error("❌ Microsoft Graph Error Response:", errorText);
throw new Error(`OneDrive Download Failed: ${response.status}`);
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
console.log(`📦 Success! Downloaded ${buffer.length} bytes.`);
// 3. Extract Metadata
const extractedData = await extractMetadata(buffer, node.name);
console.log("✅ RAW DATA EXTRACTED:");
console.dir(extractedData, { depth: null, colors: true });
console.log("✅ Extracted:", extractedData);
return { success: true, data: extractedData };
} catch (error: any) {
console.error("❌ Magic Fill Error:", error.message);
console.error("❌ Service Fetch Error:", error.message);
// If this still says ENOTFOUND, the code is fine, but the terminal is blocked.
return { success: false, error: error.message };
}
}

View file

@ -1,5 +1,5 @@
'use client';
// src/app/dashboard-view.tsx
import { useState } from "react";
import { styled } from '@mui/material/styles';
import {
@ -21,6 +21,7 @@ import {
QuickFilter,
QuickFilterControl,
QuickFilterClear,
GridEventListener,
} from "@mui/x-data-grid";
import SyncIcon from "@mui/icons-material/Sync";
import RefreshIcon from "@mui/icons-material/Refresh";
@ -156,6 +157,14 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
}
};
// --- NEW: Double Click Handler ---
const handleRowDoubleClick: GridEventListener<'rowDoubleClick'> = (params) => {
// Only navigate if it's a file. If it's a folder, we could eventually navigate into it.
if (!params.row.isFolder) {
router.push(`/dashboard/files/${params.id}`);
}
};
const columns: GridColDef[] = [
{
field: "name",
@ -163,10 +172,12 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
flex: 1.5,
minWidth: 250,
renderCell: (params) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, height: '100%' }}>
<Tooltip title={params.row.isFolder ? "" : "Double-click to view deep metadata"} arrow>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, height: '100%', cursor: 'pointer' }}>
{params.row.isFolder ? <FolderIcon sx={{ color: '#FFB020' }} /> : <InsertDriveFileIcon color="action" />}
<Typography variant="body2">{params.value}</Typography>
</Box>
</Tooltip>
)
},
{
@ -211,20 +222,20 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
<Stack direction="row" spacing={0.5} justifyContent="flex-end" alignItems="center" sx={{ height: '100%' }}>
{!isFolder && (
<>
<IconButton size="small" color="info" onClick={() => window.open(`/api/download?id=${params.row.id}&mode=inline`, '_blank')}>
<IconButton size="small" color="info" onClick={(e) => { e.stopPropagation(); window.open(`/api/download?id=${params.row.id}&mode=inline`, '_blank'); }}>
<OpenInNewIcon fontSize="small" />
</IconButton>
<IconButton size="small" color="success" onClick={() => window.location.href = `/api/download?id=${params.row.id}&mode=attachment`}>
<IconButton size="small" color="success" onClick={(e) => { e.stopPropagation(); window.location.href = `/api/download?id=${params.row.id}&mode=attachment`; }}>
<DownloadIcon fontSize="small" />
</IconButton>
</>
)}
{(isAdmin || isOwner) && (
<>
<IconButton size="small" color="primary" onClick={() => router.push(`/update/${params.row.id}`)}>
<IconButton size="small" color="primary" onClick={(e) => { e.stopPropagation(); router.push(`/update/${params.row.id}`); }}>
<EditIcon fontSize="small" />
</IconButton>
<IconButton size="small" color="error" onClick={() => handleDelete(params.row.id, params.row.name)}>
<IconButton size="small" color="error" onClick={(e) => { e.stopPropagation(); handleDelete(params.row.id, params.row.name); }}>
<DeleteIcon fontSize="small" />
</IconButton>
</>
@ -273,6 +284,7 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
showToolbar
slots={{ toolbar: CustomToolbar }}
disableRowSelectionOnClick
onRowDoubleClick={handleRowDoubleClick} // ADDED THIS HANDLER
initialState={{
columns: {
columnVisibilityModel: {
@ -280,7 +292,12 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
},
},
}}
sx={{ border: 'none' }}
sx={{
border: 'none',
'& .MuiDataGrid-row:hover': {
cursor: 'pointer',
},
}}
/>
</Box>
</Box>

View file

@ -0,0 +1,132 @@
// src/app/dashboard/files/[id]/page.tsx
import { getFileNodeById } from "@/data-access/file-nodes";
import { mapMetadata } from "@/lib/transformers";
import { notFound } from "next/navigation";
import {
Box, Container, Typography, Paper, Grid, Divider, Stack, Button
} from "@mui/material";
import DescriptionIcon from '@mui/icons-material/Description';
import PhotoIcon from '@mui/icons-material/Photo';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import Link from "next/link";
export default async function FileDetailPage(props: {
params: Promise<{ id: string }>
}) {
// 1. Unwrapping params for Next.js 15/16+
const { id } = await props.params;
// 2. Fetch data via DAL
const file = await getFileNodeById(id);
// 3. Check schema-correct property 'isFolder'
if (!file || file.isFolder) {
notFound();
}
// 4. Transform data for the UI
const data = mapMetadata(file);
// 5. Derived extension logic
const extension = file.name.split('.').pop()?.toLowerCase();
const isPDF = extension === 'pdf';
// 6. Handle BigInt for Size
const sizeKB = file.size ? (Number(file.size) / 1024).toFixed(2) : "0";
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
{/* Navigation back to dashboard */}
<Link href="/dashboard" style={{ textDecoration: 'none' }}>
<Button
startIcon={<ArrowBackIcon />}
sx={{ mb: 3 }}
>
Back to Dashboard
</Button>
</Link>
<Paper elevation={3} sx={{ p: { xs: 3, md: 5 }, borderRadius: 4 }}>
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 3 }}>
{isPDF ? (
<DescriptionIcon sx={{ color: '#f44336', fontSize: 40 }} />
) : (
<PhotoIcon sx={{ color: '#2196f3', fontSize: 40 }} />
)}
<Box>
<Typography variant="h4" fontWeight="900">{file.name}</Typography>
<Typography variant="body2" color="text.secondary">
ID: {file.id}
</Typography>
</Box>
</Stack>
<Grid container spacing={4}>
{/* Left Column: Core Info & Preview */}
<Grid item xs={12} md={7}>
<Typography variant="h6" gutterBottom fontWeight="bold">Summary</Typography>
<Divider sx={{ mb: 2 }} />
{data.textPreview && (
<Box sx={{ bgcolor: 'grey.50', p: 2, borderRadius: 2, mb: 3, border: '1px solid #ddd' }}>
<Typography variant="subtitle2" color="primary" gutterBottom sx={{ fontSize: '0.7rem', fontWeight: 'bold' }}>
DOCUMENT PREVIEW
</Typography>
<Typography variant="body2" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
"{data.textPreview}..."
</Typography>
</Box>
)}
<Stack spacing={2}>
<Box>
<Typography variant="caption" color="text.secondary">FILE TYPE</Typography>
<Typography variant="body1" fontWeight="500">{data.type}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">SIZE</Typography>
<Typography variant="body1" fontWeight="500">{sizeKB} KB</Typography>
</Box>
{data.pageCount > 0 && (
<Box>
<Typography variant="caption" color="text.secondary">PAGE COUNT</Typography>
<Typography variant="body1" fontWeight="500">{data.pageCount}</Typography>
</Box>
)}
<Box>
<Typography variant="caption" color="text.secondary">UPLOADED AT</Typography>
<Typography variant="body1" fontWeight="500">
{new Date(file.createdAt).toLocaleString()}
</Typography>
</Box>
</Stack>
</Grid>
{/* Right Column: Deep Metadata */}
<Grid item xs={12} md={5}>
<Typography variant="h6" gutterBottom fontWeight="bold">Extracted Attributes</Typography>
<Divider sx={{ mb: 2 }} />
<Stack spacing={1}>
{Object.entries(file.metadata as Record<string, any> || {}).map(([key, value]) => (
<Box key={key} sx={{ display: 'flex', justifyContent: 'space-between', py: 0.5, borderBottom: '1px solid #f0f0f0' }}>
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: '500' }}>
{key}
</Typography>
<Typography variant="body2" sx={{ textAlign: 'right', pl: 2 }}>
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
</Typography>
</Box>
))}
{/* FIXED: Properly closed logic for empty metadata */}
{Object.keys(file.metadata as object || {}).length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
No extra metadata extracted for this file.
</Typography>
)}
</Stack>
</Grid>
</Grid>
</Paper>
</Container>
);
}

View file

@ -1,12 +1,11 @@
'use client';
// src/app/update/[id]/update-view.tsx
import { useState } from "react";
import {
Box, Button, Typography, Paper, Stack,
TextField, MenuItem, IconButton, Grid, Divider,
Checkbox, CircularProgress, Chip, Tooltip
TextField, MenuItem, IconButton, Grid,
Checkbox, CircularProgress, Tooltip
} from "@mui/material";
import SaveIcon from "@mui/icons-material/Save";
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
@ -22,29 +21,37 @@ import { getMetadataPreviewAction } from "@/app/dashboard/actions";
interface MetadataPair {
key: string;
value: string;
selected: boolean; // Checkbox state
isPending?: boolean; // Visual highlight for auto-extracted fields
selected: boolean;
isPending?: boolean;
}
export default function UpdateView({
fileNode,
folders: availablefolders // Renaming 'folders' to 'availablefolders'
}: {
fileNode: any;
folders: any[];
}) {
/** * Utility to turn nested objects into flat key-value pairs for the UI
*/
const flattenObject = (obj: any, prefix = ''): Record<string, string> => {
let results: Record<string, string> = {};
for (const key in obj) {
const value = obj[key];
const newKey = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === 'object' && !Array.isArray(value)) {
Object.assign(results, flattenObject(value, newKey));
} else {
results[newKey] = String(value);
}
}
return results;
};
export default function UpdateView({ fileNode, folders: availablefolders }: { fileNode: any; folders: any[]; }) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [isExtracting, setIsExtracting] = useState(false);
// 1. Initialize Basic Info
const [name, setName] = useState(fileNode.name);
const [description, setDescription] = useState(fileNode.description || "");
const [parentId, setParentId] = useState(fileNode.parentId || "");
// 2. Initialize Metadata from DB (all checked by default)
const initialMetadata: MetadataPair[] = Object.entries(fileNode.metadata || {})
.filter(([key]) => !['type', 'mimeType'].includes(key))
.filter(([key]) => !['type', 'mimeType', 'magicFilled', 'details'].includes(key))
.map(([key, value]) => ({
key,
value: String(value),
@ -54,23 +61,23 @@ export default function UpdateView({
const [customMetadata, setCustomMetadata] = useState<MetadataPair[]>(initialMetadata);
// --- MAGIC FILL LOGIC ---
const handleMagicEnhance = async () => {
setIsExtracting(true);
try {
const result = await getMetadataPreviewAction(fileNode.id);
if (result.success) {
// Convert extracted JSON into pending rows
const extractedRows: MetadataPair[] = Object.entries(result.data ?? {})
.filter(([key]) => !['type', 'mimeType'].includes(key))
// Flatten the nested 'details' and top level props
const flatData = flattenObject(result.data);
const extractedRows: MetadataPair[] = Object.entries(flatData)
.filter(([key]) => !['type', 'mimeType', 'title'].includes(key) && !key.includes('Binary Data'))
.map(([key, value]) => ({
key,
value: String(value),
selected: true, // Default to checked as requested
selected: true,
isPending: true
}));
// Merge logic: Add only if the key doesn't already exist in our list
setCustomMetadata(prev => {
const existingKeys = new Set(prev.map(r => r.key));
const filteredNew = extractedRows.filter(r => !existingKeys.has(r.key));
@ -78,7 +85,7 @@ export default function UpdateView({
});
}
} catch (err) {
alert("Failed to extract metadata. Ensure service is configured correctly.");
alert("Failed to extract metadata.");
} finally {
setIsExtracting(false);
}
@ -92,7 +99,6 @@ export default function UpdateView({
formData.append("description", description);
formData.append("parentId", parentId);
// Convert array back to object, ONLY including selected/checked rows
const metadataObj = customMetadata.reduce((acc, curr) => {
if (curr.selected && curr.key.trim()) {
acc[curr.key.trim()] = curr.value;
@ -114,131 +120,77 @@ export default function UpdateView({
return (
<Paper sx={{ p: { xs: 3, md: 6 }, borderRadius: 4 }} elevation={3}>
<Button startIcon={<ArrowBackIcon />} onClick={() => router.back()} sx={{ mb: 2 }}>
Back
</Button>
<Button startIcon={<ArrowBackIcon />} onClick={() => router.back()} sx={{ mb: 2 }}>Back</Button>
<Typography variant="h4" fontWeight={900} color="primary" gutterBottom>
Edit File Details
</Typography>
<Typography variant="h4" fontWeight={900} color="primary" gutterBottom>Edit File Details</Typography>
{/* --- MAGIC FILL BUTTON --- */}
<Box sx={{ mb: 4, p: 2, bgcolor: '#f0f7ff', borderRadius: 2, border: '1px dashed #1976d2' }}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Box>
<Typography variant="subtitle1" fontWeight="bold">Enrich Metadata</Typography>
<Typography variant="caption" color="text.secondary">
Extract tags like GPS, Author, and Dimensions from the original file.
</Typography>
<Typography variant="caption" color="text.secondary">Extract GPS, Camera Specs, and Dimensions.</Typography>
</Box>
<Button
variant="contained"
color="secondary"
variant="contained" color="secondary"
startIcon={isExtracting ? <CircularProgress size={20} color="inherit" /> : <AutoFixHighIcon />}
onClick={handleMagicEnhance}
disabled={isExtracting}
onClick={handleMagicEnhance} disabled={isExtracting}
>
{isExtracting ? 'Extracting...' : 'Magic Fill'}
</Button>
</Stack>
</Box>
<Stack spacing={4} sx={{ mt: 2 }}>
<TextField
label="File Name"
fullWidth value={name}
onChange={(e) => setName(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
<Stack spacing={4}>
<TextField label="File Name" fullWidth value={name} onChange={(e) => setName(e.target.value)} slotProps={{ inputLabel: { shrink: true } }} />
<TextField
id="update-dest-select"
select fullWidth label="Destination Folder"
value={parentId}
onChange={(e) => setParentId(e.target.value)}
slotProps={{
select: { displayEmpty: true },
inputLabel: { shrink: true }
}}
>
<TextField select fullWidth label="Destination" value={parentId} onChange={(e) => setParentId(e.target.value)}>
<MenuItem value=""><em>-- Root --</em></MenuItem>
{availablefolders?.map((f: any) => (
<MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>
))}
{availablefolders?.map((f: any) => (<MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>))}
</TextField>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<AssignmentIcon color="primary" /> Metadata Attributes
</Typography>
<Button
startIcon={<AddCircleOutlineIcon />}
size="small"
onClick={() => setCustomMetadata([...customMetadata, { key: "", value: "", selected: true }])}
>
Add Field
</Button>
</Box>
<Stack direction="row" justifyContent="space-between" mb={2}>
<Typography variant="h6" fontWeight="700"><AssignmentIcon /> Attributes</Typography>
<Button startIcon={<AddCircleOutlineIcon />} onClick={() => setCustomMetadata([...customMetadata, { key: "", value: "", selected: true }])}>Add Field</Button>
</Stack>
<Stack spacing={2}>
{customMetadata.map((row, index) => (
<Box key={index}>
<Grid container spacing={1} alignItems="center">
<Grid size={{ xs: 1 }}>
<Tooltip title={row.selected ? "Save this field" : "Ignore this field"}>
<Checkbox
checked={row.selected}
onChange={(e) => {
<Grid item xs={1}>
<Checkbox checked={row.selected} onChange={(e) => {
const updated = [...customMetadata];
updated[index].selected = e.target.checked;
setCustomMetadata(updated);
}}
/>
</Tooltip>
}} />
</Grid>
<Grid size={{ xs: 4 }}>
<TextField
fullWidth size="small" placeholder="Key"
value={row.key}
disabled={row.isPending} // Usually best to keep extracted keys as-is
sx={{ bgcolor: row.isPending ? '#e8f5e9' : 'transparent' }}
<Grid item xs={4}>
<TextField fullWidth size="small" value={row.key} disabled={row.isPending} sx={{ bgcolor: row.isPending ? '#e8f5e9' : 'transparent' }}
onChange={(e) => {
const updated = [...customMetadata];
updated[index].key = e.target.value;
setCustomMetadata(updated);
}}
/>
}} />
</Grid>
<Grid size={{ xs: 6 }}>
<TextField
fullWidth size="small" placeholder="Value"
value={row.value}
sx={{ bgcolor: row.isPending ? '#e8f5e9' : 'transparent' }}
<Grid item xs={6}>
<TextField fullWidth size="small" value={row.value} sx={{ bgcolor: row.isPending ? '#e8f5e9' : 'transparent' }}
onChange={(e) => {
const updated = [...customMetadata];
updated[index].value = e.target.value;
setCustomMetadata(updated);
}}
/>
}} />
</Grid>
<Grid size={{ xs: 1 }}>
<IconButton color="error" onClick={() => setCustomMetadata(customMetadata.filter((_, i) => i !== index))}>
<DeleteOutlineIcon />
</IconButton>
<Grid item xs={1}>
<IconButton color="error" onClick={() => setCustomMetadata(customMetadata.filter((_, i) => i !== index))}><DeleteOutlineIcon /></IconButton>
</Grid>
</Grid>
{/* --- GOOGLE MAPS SHORTCUT --- */}
{row.key.toLowerCase().includes('latitude') && row.value && (
<Box sx={{ ml: 6, mt: 0.5 }}>
<Button
size="small"
startIcon={<MapIcon />}
href={`https://www.google.com/maps?q=${row.value},${customMetadata.find(m => m.key.toLowerCase().includes('longitude'))?.value}`}
target="_blank"
>
Verify GPS on Map
<Box sx={{ ml: 7, mt: 0.5 }}>
<Button size="small" startIcon={<MapIcon />} target="_blank"
href={`https://www.google.com/maps?q=${row.value},${customMetadata.find(m => m.key.toLowerCase().includes('longitude'))?.value}`}>
View on Map
</Button>
</Box>
)}
@ -247,21 +199,9 @@ export default function UpdateView({
</Stack>
</Box>
<TextField
label="Description"
multiline rows={4} fullWidth
value={description}
onChange={(e) => setDescription(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField label="Description" multiline rows={3} fullWidth value={description} onChange={(e) => setDescription(e.target.value)} />
<Button
variant="contained" size="large" fullWidth
startIcon={<SaveIcon />}
onClick={handleUpdate}
disabled={loading}
sx={{ py: 1.5, fontWeight: 'bold' }}
>
<Button variant="contained" size="large" fullWidth startIcon={<SaveIcon />} onClick={handleUpdate} disabled={loading}>
{loading ? "Saving..." : "Save Changes"}
</Button>
</Stack>

View file

@ -1,24 +1,28 @@
'use server';
// src/app/upload/_actions.ts
//src/app/upload/_actions.ts)
import { auth } from "@/auth";
import { revalidatePath } from "next/cache";
import { createFileNode } from "@/data-access/file-nodes";
//import { createOneDriveFolder, uploadToFolderId, ensureOneDriveFolder } from "@/services/onedrive";
import { createFileNode, getAllFolders, upsertFileNodeByHash } from "@/data-access/file-nodes";
import { prisma } from "@/lib/prisma";
import { createOneDriveFolder, uploadToFolderId, ensureOneDriveFolder } from "@/services/onedrive";
/**
* 1. CREATE VIRTUAL FOLDER
* FIXED: Changed findUnique to findFirst to avoid runtime database crashes
*/
export async function checkDuplicateAction(hash: string) {
const existing = await prisma.fileNode.findFirst({
where: { hash },
select: { name: true, parentId: true }
});
return existing;
}
export async function createFolderAction(name: string, parentId?: string | null) {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
try {
const internalId = crypto.randomUUID();
// Swapped createNode for createFileNode
const newNode = await createFileNode({
id: internalId,
oneDriveId: null,
@ -36,14 +40,13 @@ export async function createFolderAction(name: string, parentId?: string | null)
throw new Error(error.message || "Failed to create virtual folder");
}
}
/**
* 2. UPLOAD FILE (Physical UUID Folder)
*/
export async function uploadFileAction(formData: FormData) {
const session = await auth();
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;
@ -54,29 +57,21 @@ export async function uploadFileAction(formData: FormData) {
if (!file) throw new Error("No file selected");
const rootFolder = "WebCalibre";
const internalId = crypto.randomUUID(); // Used for both DB ID and OneDrive Folder Name
const internalId = crypto.randomUUID();
try {
// A. Ensure root exists
await ensureOneDriveFolder(session.user.id, rootFolder);
// B. Create the physical UUID folder on OneDrive
const subFolderRes = await createOneDriveFolder(session.user.id, rootFolder, internalId);
const subFolderData = await subFolderRes.json();
// C. Upload the file binary into that specific folder
const uploadedFileData = await uploadToFolderId(session.user.id, file, subFolderData.id);
// D. Create record in Database
// src/app/upload/_actions.ts
// ... inside uploadFileAction or createFolderAction ...
// ... inside uploadFileAction after OneDrive work is done ...
await createFileNode({
await createFileNode({
id: internalId,
oneDriveId: uploadedFileData.id,
name: file.name,
hash: hash,
description: description,
size: BigInt(file.size),
isFolder: false,
@ -84,11 +79,11 @@ await createFileNode({
ownerId: session.user.id,
parentId: parentId,
metadata: {
...customMetadata, // User's custom keys from the form
...customMetadata,
type: file.name.split('.').pop()?.toUpperCase() || "UNKNOWN",
mimeType: file.type
}
});
});
revalidatePath("/dashboard");
revalidatePath("/upload");
@ -98,3 +93,73 @@ await createFileNode({
return { success: false, error: error.message };
}
}
export async function getFoldersAction() {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
try {
const folders = await getAllFolders();
return folders;
} catch (error) {
console.error("Error in getFoldersAction:", error);
return [];
}
}
export async function executeBulkItemAction(formData: FormData) {
const session = await auth();
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;
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";
const internalId = crypto.randomUUID();
// A. Direct upload to OneDrive
await ensureOneDriveFolder(session.user.id, rootFolder);
const subFolderRes = await createOneDriveFolder(session.user.id, rootFolder, internalId);
const subFolderData = await subFolderRes.json();
const uploadedFileData = await uploadToFolderId(session.user.id, file, subFolderData.id);
// B. Write or update PostgreSQL records via the safe wrapper
const result = await upsertFileNodeByHash({
name: file.name,
hash: hash,
oneDriveId: uploadedFileData.id,
parentId: targetFolderId,
size: BigInt(file.size),
ownerId: session.user.id,
});
revalidatePath("/dashboard");
return {
success: true,
id: result.node.id,
mode: result.mode
};
} 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,30 +1,181 @@
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";
// src/app/upload/bulk/page.tsx
// 1. Rename to UploadPage to avoid conflict with the 'UploadView' import
// 2. Add 'async' so you can use 'await' inside
export default async function UploadPage() {
const session = await auth();
"use client";
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
const allFolders = await prisma.fileNode.findMany({
where: {
isFolder: true,
ownerId: session.user.id // Good practice: only show user's own folders
},
orderBy: { name: 'asc' },
select: { id: true, name: true, parentId: true }
// --- OUR UTILITIES ---
import { calculateFileHash } from '@/lib/hashing-client';
import { checkDuplicateAction } from '@/app/upload/_actions';
interface UploadQueueItem {
id: string;
file: File;
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 (
<Container maxWidth="md" sx={{ py: 8 }}>
{/* 4. Render the Client Component and pass the data */}
<UploadView user={session.user} folders={allFolders} />
</Container>
<Box sx={{ p: 4, maxWidth: 1200, mx: 'auto' }}>
<Typography variant="h4" fontWeight={800} color="primary" gutterBottom>
Bulk Uploads & Restore
</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

@ -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,
InputAdornment, 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;
@ -26,37 +31,40 @@ interface MetadataRow {
selected?: boolean;
}
export default function UploadView({ user, folders }: { user: any; folders: any[] }) {
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) {
const extractedRows: MetadataRow[] = Object.entries(result.data ?? {}).map(([k, v]) => ({
key: k,
value: String(v),
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));
@ -72,35 +80,32 @@ export default function UploadView({ user, folders }: { user: any; folders: any[
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setSelectedFile(file);
}
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, we want the file to go 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) => {
@ -111,25 +116,44 @@ export default function UploadView({ user, 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");
}
console.log("✅ Process complete. Returning to dashboard.");
router.push("/dashboard");
router.refresh();
} 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' }} elevation={3}>
<>
<Paper sx={{ p: { xs: 3, md: 6 }, borderRadius: 4, maxWidth: 800, mx: 'auto', mt: 4 }} elevation={3}>
<Typography variant="h4" fontWeight={900} color="primary" gutterBottom align="center">
Upload & Enrich
</Typography>
@ -139,14 +163,10 @@ export default function UploadView({ user, folders }: { user: any; folders: any[
<Box>
<Stack direction="row" spacing={1}>
<TextField
select
fullWidth
label="Destination Folder"
select fullWidth label="Parent Destination"
value={targetFolderId}
onChange={(e) => {
setTargetFolderId(e.target.value);
if (e.target.value) setShowNewFolderInput(false);
}}
onChange={(e) => setTargetFolderId(e.target.value)}
helperText="Choose where your file will live"
>
<MenuItem value=""><em>-- Root Directory --</em></MenuItem>
{folders?.map((f) => (
@ -155,10 +175,7 @@ export default function UploadView({ user, folders }: { user: any; folders: any[
</TextField>
<Button
variant={showNewFolderInput ? "contained" : "outlined"}
onClick={() => {
setShowNewFolderInput(!showNewFolderInput);
if (!showNewFolderInput) setTargetFolderId("");
}}
onClick={() => setShowNewFolderInput(!showNewFolderInput)}
sx={{ height: 56, minWidth: 56 }}
>
<CreateNewFolderIcon />
@ -166,12 +183,12 @@ export default function UploadView({ user, folders }: { user: any; folders: any[
</Stack>
<Collapse in={showNewFolderInput}>
<Box sx={{ mt: 2, p: 2, bgcolor: 'grey.50', borderRadius: 2 }}>
<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"
label="New Folder Name"
placeholder="Enter name to create folder..."
fullWidth size="small" placeholder="e.g. Finance 2026"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
/>
@ -182,19 +199,14 @@ export default function UploadView({ user, 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 }}
sx={{ py: 4, borderStyle: 'dashed', borderWidth: 2, borderRadius: 2 }}
>
Select File to Upload
</Button>
@ -202,9 +214,9 @@ export default function UploadView({ user, folders }: { user: any; folders: any[
<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="500">{selectedFile.name}</Typography>
<Typography variant="body1" fontWeight="600">{selectedFile.name}</Typography>
</Stack>
<IconButton onClick={() => setSelectedFile(null)} color="error">
<IconButton onClick={() => setSelectedFile(null)} color="error" size="small">
<ClearIcon />
</IconButton>
</Paper>
@ -215,19 +227,21 @@ export default function UploadView({ user, folders }: { user: any; folders: any[
<Divider sx={{ my: 4 }} />
{/* MAGIC EXTRACT */}
<Box sx={{ mb: 4, p: 2, bgcolor: '#f0f7ff', borderRadius: 2, border: '1px dashed #1976d2' }}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<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">Magic Extract</Typography>
<Typography variant="caption" color="text.secondary">Populate metadata automatically from file properties.</Typography>
<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}
variant="contained" onClick={handleMagicEnhance}
disabled={!selectedFile || isExtracting}
startIcon={isExtracting ? <CircularProgress size={20} color="inherit" /> : <AutoFixHighIcon />}
sx={{ borderRadius: 20, px: 3 }}
>
{isExtracting ? "Running..." : "Run"}
{isExtracting ? "Extracting..." : "Run"}
</Button>
</Stack>
</Box>
@ -235,76 +249,97 @@ export default function UploadView({ user, folders }: { user: any; folders: any[
{/* METADATA PREVIEW */}
<Box sx={{ mb: 4 }}>
<Typography variant="h6" fontWeight="700" sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<AssignmentIcon color="primary" /> Metadata Preview
<AssignmentIcon color="primary" /> Metadata Fields
</Typography>
<Stack spacing={2}>
{rows.map((row, index) => (
<Grid container spacing={1} key={index} alignItems="center">
<Grid size={1}>
<Checkbox
checked={row.selected}
onChange={(e) => {
<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 size={5}>
<TextField
fullWidth size="small" label="Key" value={row.key}
onChange={(e) => {
<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 size={5}>
<TextField
fullWidth size="small" label="Value" value={row.value}
onChange={(e) => {
<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 size={1}>
<Grid item xs={1}>
<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}])}
>
<Button variant="text" startIcon={<AddCircleOutlineIcon />} onClick={() => setRows([...rows, {key: "", value: "", selected: true}])}>
Add Manual Field
</Button>
</Stack>
</Box>
{/* UPLOAD BUTTON */}
{/* FINAL BUTTON */}
<Button
variant="contained"
size="large"
fullWidth
onClick={handleSave}
disabled={!canSubmit || saveStatus === 'saving'}
sx={{ py: 2, fontWeight: 'bold' }}
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>Processing Upload...</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>
</>
);
}

View file

@ -9,6 +9,7 @@ import {
import MenuIcon from '@mui/icons-material/Menu';
import LibraryBooksIcon from '@mui/icons-material/LibraryBooks';
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 SettingsIcon from '@mui/icons-material/Settings';
import Link from 'next/link';
@ -44,6 +45,7 @@ export default function Navbar({ user }: NavbarProps) {
{ text: 'Dashboard', icon: <DashboardIcon />, href: '/dashboard' },
{ text: 'Library', icon: <LibraryBooksIcon />, href: '/library' },
{ 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

View file

@ -5,12 +5,6 @@ import { getOneDriveFileBuffer } from "@/services/onedrive";
import { extractMetadata } from "@/lib/metadata-extractor";
import { prisma } from "@/lib/prisma";
/**
* FETCH: Retrieve all nodes for the dashboard.
* Centralizing this here allows us to change sort order or filters
* in one place for the entire application.
*/
export async function getAllFileNodes() {
return await prisma.fileNode.findMany({
orderBy: {
@ -19,20 +13,12 @@ export async function getAllFileNodes() {
});
}
/**
* FETCH: Get a single node by ID.
* Used by the Download route and Update pages to verify a file exists.
*/
export async function getFileNodeById(id: string) {
return await prisma.fileNode.findUnique({
where: { id },
});
}
/**
* UPDATE: Modify metadata, name, or virtual location.
* This function accepts the data object to keep the DAL flexible.
*/
export async function updateFileNode(id: string, data: any) {
return await prisma.fileNode.update({
where: { id },
@ -43,48 +29,42 @@ export async function updateFileNode(id: string, data: any) {
});
}
/**
* DELETE: Remove the record from the database.
* Cloud deletion should be handled by the Service Layer before calling this.
*/
export async function deleteFileNode(id: string) {
return await prisma.fileNode.delete({
where: { id },
});
}
/**
* MASTER CREATE: Handles both standard uploads and virtual folders.
* If no ID is provided, it generates a fresh UUID.
*/
export async function createFileNode(data: {
id?: string; // Optional: used for virtual folders/UUID storage
id?: string;
oneDriveId: string | null;
name: string;
hash?: string | null;
description?: string;
isFolder: boolean;
path: string;
ownerId: string;
parentId?: string | null; // Optional: for nested structures
parentId?: string | null;
size?: bigint;
metadata: any;
}) {
return await prisma.fileNode.create({
data: {
...data,
id: data.id ?? crypto.randomUUID(), // Use provided ID or generate new one
id: data.id ?? crypto.randomUUID(),
}
});
}
// ... other functions (getAllFileNodes, etc)
/**
* 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;
}) {
return await prisma.fileNode.upsert({
where: { oneDriveId },
update: {
@ -92,6 +72,7 @@ export async function upsertFileNode(oneDriveId: string, data: any) {
size: data.size,
isFolder: data.isFolder,
path: data.path,
hash: data.hash,
updatedAt: new Date(),
},
create: {
@ -103,29 +84,73 @@ export async function upsertFileNode(oneDriveId: string, data: any) {
path: data.path,
ownerId: data.ownerId,
metadata: data.metadata,
hash: data.hash,
}
});
}
export async function getAllFolders() {
return await prisma.fileNode.findMany({
where: {
isFolder: true
},
select: {
id: true,
name: true
},
orderBy: {
name: 'asc'
}
});
}
/**
* Logic to coordinate getting a file from the cloud and extracting its data.
* This is the "Brain" function for your metadata enrichment.
* UPSERT BY HASH: FIXED FOR SYSTEM STABILITY
*/
export async function getEnrichedMetadataFromCloud(fileId: string) {
// 1. Get the record from our DB so we know the filename (needed for extension logic)
const node = await prisma.fileNode.findUnique({
where: { id: fileId }
export async function upsertFileNodeByHash(data: {
name: string;
hash: string;
oneDriveId: string;
parentId?: string | null;
size: bigint;
ownerId: string;
}) {
// Use findFirst instead of findUnique to protect against schema constraints
const existing = await prisma.fileNode.findFirst({
where: { hash: data.hash }
});
if (!node) throw new Error("File not found in database.");
if (existing) {
// 🛡️ DISASTER RECOVERY MODE (RESTORE)
const updatedNode = await prisma.fileNode.update({
where: { id: existing.id },
data: {
oneDriveId: data.oneDriveId,
parentId: data.parentId || existing.parentId,
updatedAt: new Date(),
}
});
return { node: updatedNode, mode: 'restored' as const };
}
// 2. Fetch the bytes using the service we just created
const buffer = await getOneDriveFileBuffer(fileId);
// 3. Extract internal metadata (Title, Author, or GPS coordinates)
const deepMetadata = await extractMetadata(buffer, node.name);
return deepMetadata;
// ✨ NEW UPLOAD MODE
const fileExtension = data.name.split('.').pop()?.toUpperCase() || "UNKNOWN";
const newNode = 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: `/WebCalibre/Bulk/${data.name}`,
metadata: {
type: fileExtension,
mimeType: "application/octet-stream"
},
}
});
return { node: newNode, mode: 'created' as const };
}

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

9
src/lib/hashing.ts Normal file
View file

@ -0,0 +1,9 @@
// src/lib/hashing.ts
import crypto from 'crypto';
/**
* Generates an MD5 hash from a file buffer.
*/
export function generateFileHash(buffer: Buffer): string {
return crypto.createHash('md5').update(buffer).digest('hex');
}

View file

@ -0,0 +1,83 @@
// src/lib/metadata-extractor.ts
// 1. Update your import to use the new named export
//import * as PDFLib from 'pdf-parse';
//import { PDFParse } from 'pdf-parse';
//import {pdf} from 'pdf-parse';
import * as PdfParse from 'pdf-parse-new';
import sharp from 'sharp';
import exifReader from 'exif-reader';
/**
* Converts EXIF DMS (Degrees, Minutes, Seconds) array to Decimal Degrees.
*/
function convertDMSToDD(dms: any, ref: string): string {
if (!Array.isArray(dms) || dms.length < 3) return String(dms);
const [degrees, minutes, seconds] = dms;
let dd = degrees + (minutes / 60) + (seconds / 3600);
if (ref === 'S' || ref === 'W') {
dd = dd * -1;
}
return dd.toFixed(6);
}
/**
* RECURSIVE SANITIZER:
* Converts Buffers to strings, standardizes keys, and handles GPS conversion.
*/
function sanitizeMetadata(obj: any): any {
if (obj === null || typeof obj !== 'object') return obj;
if (obj instanceof Date) return obj.toISOString();
if (Buffer.isBuffer(obj)) return `[Binary Data: ${obj.length} bytes]`;
if (Array.isArray(obj)) return obj.map(sanitizeMetadata);
const sanitized: any = {};
for (const [key, value] of Object.entries(obj)) {
const cleanKey = key.charAt(0).toLowerCase() + key.slice(1);
// Specifically handle GPS Latitude/Longitude Arrays
if (cleanKey === 'gPSLatitude' && obj['gPSLatitudeRef']) {
sanitized[cleanKey] = convertDMSToDD(value, obj['gPSLatitudeRef']);
continue;
}
if (cleanKey === 'gPSLongitude' && obj['gPSLongitudeRef']) {
sanitized[cleanKey] = convertDMSToDD(value, obj['gPSLongitudeRef']);
continue;
}
sanitized[cleanKey] = sanitizeMetadata(value);
}
return sanitized;
}
export async function extractMetadata(buffer: Buffer, filename: string): Promise<any> {
const extension = filename.split('.').pop()?.toLowerCase();
if (extension === 'pdf') {
// Create parser instance
const parser = new PdfParse.SmartPDFParser({
oversaturationFactor: 2.0,
enableFastPath: true
});
console.log(`--- PDF Debug Start: ${filename} ---`);
const result = await parser.parse(buffer);
console.log(`Parsed ${result.numpages} pages using ${result._meta.method}`);
console.log(`Parsed ${result.info} info using ${result._meta.method}`);
console.log(`Parsed ${result.info} info using ${result._meta.method}`);
console.log(JSON.stringify(result.info, null, 2));
//const { text, numpages, info } = await pdf(buffer);
//const parser = new PDFParse(buffer);
// `text` → full document text
// `numpages` → page count
// `info` → metadata (author, creation date, etc.)
// console.log(`Pages: ${numpages}`);
// console.log(`Author: ${info.Author}`);
// console.log(text.slice(0, 200)); // preview first 200 chars
return {
type: 'PDF',
title: filename
};
}
}

View file

@ -0,0 +1,145 @@
// src/lib/metadata-extractor.ts
// 1. Update your import to use the new named export
import * as PDFLib from 'pdf-parse';
import sharp from 'sharp';
import exifReader from 'exif-reader';
/**
* Converts EXIF DMS (Degrees, Minutes, Seconds) array to Decimal Degrees.
*/
function convertDMSToDD(dms: any, ref: string): string {
if (!Array.isArray(dms) || dms.length < 3) return String(dms);
const [degrees, minutes, seconds] = dms;
let dd = degrees + (minutes / 60) + (seconds / 3600);
if (ref === 'S' || ref === 'W') {
dd = dd * -1;
}
return dd.toFixed(6);
}
/**
* RECURSIVE SANITIZER:
* Converts Buffers to strings, standardizes keys, and handles GPS conversion.
*/
function sanitizeMetadata(obj: any): any {
if (obj === null || typeof obj !== 'object') return obj;
if (obj instanceof Date) return obj.toISOString();
if (Buffer.isBuffer(obj)) return `[Binary Data: ${obj.length} bytes]`;
if (Array.isArray(obj)) return obj.map(sanitizeMetadata);
const sanitized: any = {};
for (const [key, value] of Object.entries(obj)) {
const cleanKey = key.charAt(0).toLowerCase() + key.slice(1);
// Specifically handle GPS Latitude/Longitude Arrays
if (cleanKey === 'gPSLatitude' && obj['gPSLatitudeRef']) {
sanitized[cleanKey] = convertDMSToDD(value, obj['gPSLatitudeRef']);
continue;
}
if (cleanKey === 'gPSLongitude' && obj['gPSLongitudeRef']) {
sanitized[cleanKey] = convertDMSToDD(value, obj['gPSLongitudeRef']);
continue;
}
sanitized[cleanKey] = sanitizeMetadata(value);
}
return sanitized;
}
export async function extractMetadata(buffer: Buffer, filename: string): Promise<any> {
const extension = filename.split('.').pop()?.toLowerCase();
if (extension === 'pdf') {
console.log(`--- PDF Debug Start: ${filename} ---`);
// Initialize using the data property for our Buffer
const parser = new PDFLib.PDFParse({
data: buffer,
// disableWorker: true, // Crucial for Next.js
// verbosity: 0
});
try {
const result = await parser.getInfo({ parsePageInfo: true });
// LOGGING TO TERMINAL
console.log(`✅ Total pages: ${result.total}`);
console.log(`✅ Title: ${result.info?.Title}`);
console.log(`✅ Author: ${result.info?.Author}`);
console.log(`✅ Creator: ${result.info?.Creator}`);
// Date info
const dates = result.getDateNode();
console.log(`✅ Creation Date: ${dates.CreationDate}`);
console.log('--- PDF Debug End ---');
}
// return {
// type: 'PDF',
// title: filename,
// pageCount: result.total,
// details: result.info, // This sends the raw info object back
// };
// } catch (err: any) {
// console.error("❌ PDF Parsing Error inside debug block:", err.message);
// return { type: 'PDF', error: err.message };
// } finally {
// await parser.destroy();
// }
// --- 1. PDF EXTRACTION (v2 Class-based) ---
// if (extension === 'pdf') {
// // Access PDFParse from the namespace
// const parser = new PDFLib.PDFParse({
// data: buffer,
// disableWorker: true, // Prevents the .mjs worker error
// verbosity: 0
// });
// try {
// const infoResult = await parser.getInfo({ parsePageInfo: true });
// const textResult = await parser.getText();
// return {
// type: 'PDF',
// title: filename,
// details: sanitizeMetadata(infoResult.info || {}),
// pageCount: infoResult.total || 0,
// textPreview: textResult.text ? textResult.text.substring(0, 200).replace(/\s+/g, ' ') : ""
// };
// } finally {
// // Free memory
// await parser.destroy();
// }
// }
if (['jpg', 'jpeg', 'png', 'webp'].includes(extension || '')) {
const image = sharp(buffer);
const metadata = await image.metadata();
let details = {};
if (metadata.exif) {
try {
const rawExif = exifReader(metadata.exif);
details = sanitizeMetadata(rawExif);
} catch (e) {
console.warn("EXIF Parse failed");
}
}
return {
type: `IMAGE (${metadata.format?.toUpperCase()})`,
dimensions: `${metadata.width}x${metadata.height}`,
title: filename,
details: details
};
}
return { type: 'FILE', title: filename };
} catch (error) {
console.error(`Extraction failed: ${filename}`, error);
return { type: 'FILE', title: filename, error: "Extraction failed" };
}
}

View file

@ -1,92 +1,117 @@
import * as pdf from 'pdf-parse';
import EPub from 'epub';
// src/lib/metadata-extractor.ts
// src/lib/metadata-extractor.ts
import * as PdfParse from 'pdf-parse-new';
import sharp from 'sharp';
import exifReader from 'exif-reader';
import EPub from 'epub2'; // New Import
export interface ExtractedMetadata {
title?: string;
author?: string;
subject?: string;
dimensions?: string;
pageCount?: number;
latitude?: number;
longitude?: number;
type: string;
/**
* Converts EXIF DMS array to Decimal Degrees.
*/
function convertDMSToDD(dms: any, ref: string): string {
if (!Array.isArray(dms) || dms.length < 3) return String(dms);
const [degrees, minutes, seconds] = dms;
let dd = degrees + (minutes / 60) + (seconds / 3600);
if (ref === 'S' || ref === 'W') dd = dd * -1;
return dd.toFixed(6);
}
export async function extractMetadata(buffer: Buffer, filename: string): Promise<ExtractedMetadata> {
/**
* RECURSIVE SANITIZER
*/
function sanitizeMetadata(obj: any): any {
if (obj === null || typeof obj !== 'object') return obj;
if (obj instanceof Date) return obj.toISOString();
if (Buffer.isBuffer(obj)) return `[Binary Data: ${obj.length} bytes]`;
if (Array.isArray(obj)) return obj.map(sanitizeMetadata);
const sanitized: any = {};
for (const [key, value] of Object.entries(obj)) {
const cleanKey = key.charAt(0).toLowerCase() + key.slice(1);
if (cleanKey === 'gPSLatitude' && obj['gPSLatitudeRef']) {
sanitized[cleanKey] = convertDMSToDD(value, obj['gPSLatitudeRef']);
continue;
}
if (cleanKey === 'gPSLongitude' && obj['gPSLongitudeRef']) {
sanitized[cleanKey] = convertDMSToDD(value, obj['gPSLongitudeRef']);
continue;
}
sanitized[cleanKey] = sanitizeMetadata(value);
}
return sanitized;
}
export async function extractMetadata(buffer: Buffer, filename: string): Promise<any> {
const extension = filename.split('.').pop()?.toLowerCase();
try {
// --- 1. PDF EXTRACTION ---
if (extension === 'pdf') {
// Use any to bypass the missing 'default' property error in ESM
const parsePdf = (pdf as any).default || pdf;
const data = await parsePdf(buffer);
const parser = new PdfParse.SmartPDFParser({ oversaturationFactor: 2.0, enableFastPath: true });
const result = await parser.parse(buffer);
return {
type: 'PDF',
title: data.info?.Title || filename,
author: data.info?.Author,
subject: data.info?.Subject,
pageCount: data.numpages,
title: filename,
pageCount: result.numpages || 0,
details: sanitizeMetadata(result.info || {}),
textPreview: result.text ? result.text.substring(0, 200).replace(/\s+/g, ' ') : ""
};
}
// --- 2. EPUB EXTRACTION ---
if (extension === 'epub') {
// Logic for EPub usually requires file path or custom stream handler
// Keeping placeholder for your existing EPub logic
return { type: 'EPUB', title: filename };
}
// --- 3. IMAGE EXTRACTION (Enhanced with GPS) ---
if (['jpg', 'jpeg', 'png', 'webp'].includes(extension || '')) {
// --- 2. IMAGE EXTRACTION ---
if (['jpg', 'jpeg', 'png', 'webp','heic'].includes(extension || '')) {
const image = sharp(buffer);
const metadata = await image.metadata();
let gps: { latitude?: number; longitude?: number } = {};
let details = {};
if (metadata.exif) {
try {
// Cast to any to bypass strict Exif type checking for nested GPS properties
const exif = exifReader(metadata.exif) as any;
// Debugging log to see the raw structure in your terminal
console.log("📸 FULL RAW EXIF DATA:", JSON.stringify(exif, null, 2));
if (exif.gps && exif.gps.GPSLatitude && exif.gps.GPSLongitude) {
// EXIF stores GPS as [Degrees, Minutes, Seconds]
// We convert to Decimal Degrees for Google Maps
const lat = exif.gps.GPSLatitude;
const lon = exif.gps.GPSLongitude;
let latitude = lat[0] + lat[1] / 60 + lat[2] / 3600;
let longitude = lon[0] + lon[1] / 60 + lon[2] / 3600;
// Adjust for South or West hemisphere
if (exif.gps.GPSLatitudeRef === 'S') latitude *= -1;
if (exif.gps.GPSLongitudeRef === 'W') longitude *= -1;
gps.latitude = latitude;
gps.longitude = longitude;
const rawExif = exifReader(metadata.exif);
details = sanitizeMetadata(rawExif);
} catch (e) { console.warn("EXIF Parse failed"); }
}
} catch (exifError) {
console.warn("Could not parse EXIF data for:", filename, exifError);
}
}
return {
type: `IMAGE (${metadata.format?.toUpperCase()})`,
dimensions: metadata.width && metadata.height ? `${metadata.width}x${metadata.height}` : undefined,
dimensions: `${metadata.width}x${metadata.height}`,
title: filename,
...gps
details: details
};
}
// --- 3. EPUB EXTRACTION (New Section) ---
if (extension === 'epub') {
return new Promise((resolve, reject) => {
// We initialize the EPub instance with a null image path and use the buffer
const epub = new EPub(buffer);
epub.on('error', (err) => {
console.error("EPUB Parser Error:", err);
resolve({ type: 'EPUB', title: filename, error: "Failed to parse EPUB" });
});
epub.on('end', () => {
// Standardizing the metadata for your UI
resolve({
type: 'EPUB',
title: epub.metadata.title || filename,
author: epub.metadata.creator || 'Unknown',
details: sanitizeMetadata(epub.metadata),
// Use the number of chapters/manifest items as a rough "page" guide
pageCount: epub.spine.contents.length || 0,
textPreview: epub.metadata.description
? epub.metadata.description.substring(0, 200).replace(/<[^>]*>?/gm, '')
: ""
});
});
epub.parse();
});
}
return { type: 'FILE', title: filename };
} catch (error) {
console.error(`Extraction failed for ${filename}:`, error);
return { type: 'FILE', title: filename };
} catch (error: any) {
console.error(`Extraction failed for ${filename}:`, error.message);
return { type: 'FILE', title: filename, error: error.message };
}
}

111
src/lib/transformers.ts Normal file
View file

@ -0,0 +1,111 @@
/**
* Helper: Converts [Degrees, Minutes, Seconds] to Decimal Degrees
*/
const convertDMSToDD = (dms: number[] | undefined, ref: string | undefined): number | null => {
if (!dms || dms.length < 3) return null;
const [degrees, minutes, seconds] = dms;
let dd = degrees + (minutes / 60) + (seconds / 3600);
if (ref === 'S' || ref === 'W') {
dd = dd * -1;
}
return parseFloat(dd.toFixed(6));
};
/**
* MAPPER: Transforms the raw database JSON into a structured GUI object.
* Designed to handle the lowercase keys generated by the sanitizer.
*/
export const mapImageMetadata = (metadata: any) => {
if (!metadata || !metadata.details) {
return { title: metadata?.title || "Unknown File" };
}
const { details, dimensions, title } = metadata;
const { image = {}, photo = {}, gpsInfo = {} } = details;
return {
fileName: title,
device: `${image.make || ''} ${image.model || ''}`.trim() || 'Unknown Device',
// Dates from EXIF are strings after sanitization
timestamp: photo.dateTimeOriginal ? new Date(photo.dateTimeOriginal) : null,
resolution: {
width: photo.pixelXDimension || dimensions?.split('x')[0],
height: photo.pixelYDimension || dimensions?.split('x')[1],
},
settings: {
aperture: photo.fNumber ? `f/${photo.fNumber}` : 'N/A',
shutterSpeed: photo.exposureTime
? (photo.exposureTime < 1
? `1/${Math.round(1 / photo.exposureTime)}s`
: `${photo.exposureTime}s`)
: 'N/A',
iso: photo.iSOSpeedRatings || 'N/A',
focalLength: photo.focalLength ? `${photo.focalLength}mm` : 'N/A',
},
location: {
latitude: convertDMSToDD(gpsInfo.gpsLatitude, gpsInfo.gpsLatitudeRef),
longitude: convertDMSToDD(gpsInfo.gpsLongitude, gpsInfo.gpsLongitudeRef),
altitude: gpsInfo.gpsAltitude ? Math.round(gpsInfo.gpsAltitude) : null,
mapUrl: (gpsInfo.gpsLatitude && gpsInfo.gpsLongitude)
? `https://www.google.com/maps?q=${convertDMSToDD(gpsInfo.gpsLatitude, gpsInfo.gpsLatitudeRef)},${convertDMSToDD(gpsInfo.gpsLongitude, gpsInfo.gpsLongitudeRef)}`
: null
}
};
};
// src/lib/transformers.ts
/**
* Transforms the raw database FileNode into a structured object for the UI.
*/
export const mapMetadata = (node: any) => {
if (!node) return { title: "Unknown File" };
const meta = node.metadata || {};
const fileName = node.name;
const extension = fileName.split('.').pop()?.toLowerCase();
// --- PDF Logic ---
if (extension === 'pdf') {
return {
fileName,
type: 'PDF Document',
title: meta.title || fileName,
author: meta.author || 'Unknown Author',
subject: meta.subject || 'N/A',
pageCount: meta.pageCount || 0,
keywords: meta.keywords || 'None',
textPreview: node.description || meta.textPreview || '', // description field often stores the preview
creator: meta.creator || 'N/A',
producer: meta.producer || 'N/A'
};
}
// --- Image Logic (Sharp/Exif) ---
if (['jpg', 'jpeg', 'png', 'webp'].includes(extension || '')) {
// Note: your extractor lowercase keys via the sanitizer
return {
fileName,
type: 'Image',
device: `${meta.image?.make || ''} ${meta.image?.model || ''}`.trim() || 'Unknown Device',
timestamp: meta.photo?.dateTimeOriginal ? new Date(meta.photo.dateTimeOriginal) : null,
settings: {
aperture: meta.photo?.fNumber ? `f/${meta.photo.fNumber}` : 'N/A',
iso: meta.photo?.iSOSpeedRatings || 'N/A',
focalLength: meta.photo?.focalLength ? `${meta.photo.focalLength}mm` : 'N/A',
}
};
}
// --- Default Fallback ---
return {
fileName,
type: extension?.toUpperCase() || 'File',
details: meta
};
};

View file

@ -0,0 +1,25 @@
// src/services/metadata-service.ts
import { getOneDriveFileBuffer } from "./onedrive";
import { extractMetadata } from "@/lib/metadata-extractor";
import { getFileNodeById, updateFileNode } from "@/data-access/file-nodes";
export async function enrichFileMetadata(fileId: string, token: string) {
const node = await getFileNodeById(fileId);
if (!node || !node.oneDriveId) throw new Error("Node not found");
// Fetch file from OneDrive
const buffer = await getOneDriveFileBuffer(node.oneDriveId, token);
// Extract deep metadata (PDF, JPEG, etc.)
const deepMetadata = await extractMetadata(buffer, node.name);
// Merge with existing metadata
const updatedMetadata = {
...(node.metadata as object),
...deepMetadata,
magicFilled: true
};
// Update DB
return await updateFileNode(fileId, { metadata: updatedMetadata });
}

View file

@ -171,35 +171,7 @@ export async function uploadToFolderId(userId: string, file: File, folderId: str
if (!uploadRes.ok) throw new Error("Upload failed");
return await uploadRes.json();
}
/**
* Fetches the raw binary content (the actual file bytes) from OneDrive.
*/
export async function getOneDriveFileBuffer(fileId: string): Promise<Buffer> {
// Use your existing helper that manages the Microsoft Graph access token
const token = await getAccessToken();
const response = await fetch(
`https://graph.microsoft.com/v1.0/me/drive/items/${fileId}/content`,
{
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
// Ensure we get fresh data and don't cache large file buffers
cache: 'no-store',
}
);
if (!response.ok) {
const errorText = await response.text();
console.error("OneDrive Download Error:", errorText);
throw new Error(`Failed to download file content: ${response.statusText}`);
}
// Convert the browser-style response into a Node.js Buffer
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
}
/**
@ -221,3 +193,32 @@ async function getAccessToken(): Promise<string> {
return token;
}
/**
* Fetches raw file content from OneDrive.
* Parameterized token allows this to be used in different contexts (User actions, Webhooks, etc.)
*/
export async function getOneDriveFileBuffer(oneDriveId: string, token: string): Promise<Buffer> {
const response = await fetch(
`https://graph.microsoft.com/v1.0/me/drive/items/${oneDriveId}/content`,
{
method: 'GET', // Explicit is better
headers: {
'Authorization': `Bearer ${token}`,
'Accept': '*/*'
},
// CRITICAL: Next.js tends to cache fetch calls.
// We do NOT want to cache large binary buffers in memory/disk.
cache: 'no-store',
}
);
if (!response.ok) {
const errorBody = await response.text().catch(() => "No error body");
console.error(`OneDrive Download Error (${response.status}):`, errorBody);
throw new Error(`OneDrive download failed: ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
}