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) - [1. Where to put the fetch call?](#1-where-to-put-the-fetch-call)
- [2. Where to put `getMetadataFromJson`?](#2-where-to-put-getmetadatafromjson) - [2. Where to put `getMetadataFromJson`?](#2-where-to-put-getmetadatafromjson)
- [🏗️ How it looks all together](#-how-it-looks-all-together) - [🏗️ How it looks all together](#-how-it-looks-all-together)
- [image metadata](#image-metadata)
- [pdf metadata](#pdf-metadata)
- [useful](#useful)
# 1. Reference # 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) 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?** **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, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"backfill": "tsx scripts/backfill-hashes.ts",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
@ -21,15 +22,18 @@
"@mui/material-nextjs": "^7.3.6", "@mui/material-nextjs": "^7.3.6",
"@mui/x-data-grid": "^8.24.0", "@mui/x-data-grid": "^8.24.0",
"@prisma/adapter-pg": "^7.2.0", "@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0", "@prisma/client": "7.4.0",
"epub": "^1.3.0", "epub": "^1.3.0",
"epub2": "^3.0.2",
"exif-reader": "^2.0.3", "exif-reader": "^2.0.3",
"next": "16.1.1", "next": "16.1.1",
"next-auth": "^5.0.0-beta.30", "next-auth": "^5.0.0-beta.30",
"pdf-parse": "^2.4.5", "pdf-parse": "^2.4.5",
"pdf-parse-new": "^2.0.0",
"pg": "^8.16.3", "pg": "^8.16.3",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-dropzone": "^15.0.0",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"sharp": "^0.34.5" "sharp": "^0.34.5"
}, },
@ -41,7 +45,8 @@
"dotenv-cli": "^11.0.0", "dotenv-cli": "^11.0.0",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.1.1", "eslint-config-next": "16.1.1",
"prisma": "^6.19.2", "prisma": "7.4.0",
"tsx": "^4.21.0",
"typescript": "^5" "typescript": "^5"
} }
} }

View file

@ -1,13 +1,13 @@
// prisma.config.ts
import { config } from "dotenv"; import { config } from "dotenv";
config({ path: ".env.local" }); config({ path: ".env.local" });
import { defineConfig } from "prisma/config"; import { defineConfig, env } from "prisma/config";
export default defineConfig({ export default defineConfig({
schema: "prisma/schema.prisma", schema: "prisma/schema.prisma",
datasource: { datasource: {
// This is now the ONLY place where the DB connection string is defined // This is where Prisma 7 looks for the connection string
// prisma.config.ts url: env("DATABASE_URL"),
url: process.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 -- CreateTable
CREATE TABLE "User" ( CREATE TABLE "User" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"azureAdUserId" TEXT NOT NULL, "name" TEXT,
"email" TEXT NOT NULL, "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, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id") 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 -- CreateTable
CREATE TABLE "FileNode" ( CREATE TABLE "FileNode" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"name" TEXT NOT NULL, "name" TEXT NOT NULL,
"size" BIGINT, "size" BIGINT,
"hash" TEXT,
"isFolder" BOOLEAN NOT NULL DEFAULT false, "isFolder" BOOLEAN NOT NULL DEFAULT false,
"oneDriveId" TEXT, "oneDriveId" TEXT,
"path" TEXT NOT NULL, "path" TEXT NOT NULL,
@ -29,11 +67,17 @@ CREATE TABLE "FileNode" (
CONSTRAINT "FileNode_pkey" PRIMARY KEY ("id") CONSTRAINT "FileNode_pkey" PRIMARY KEY ("id")
); );
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "User_azureAdUserId_key" ON "User"("azureAdUserId"); CREATE UNIQUE INDEX "User_azureAdUserId_key" ON "User"("azureAdUserId");
-- CreateIndex -- 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 -- CreateIndex
CREATE UNIQUE INDEX "FileNode_oneDriveId_key" ON "FileNode"("oneDriveId"); CREATE UNIQUE INDEX "FileNode_oneDriveId_key" ON "FileNode"("oneDriveId");
@ -47,8 +91,15 @@ CREATE INDEX "FileNode_orderIndex_idx" ON "FileNode"("orderIndex");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "FileNode_ownerId_path_key" ON "FileNode"("ownerId", "path"); 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 -- AddForeignKey
ALTER TABLE "FileNode" ADD CONSTRAINT "FileNode_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; ALTER TABLE "FileNode" ADD CONSTRAINT "FileNode_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey -- 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 id String @id
name String name String
size BigInt? // Preserved your BigInt size column size BigInt? // Preserved your BigInt size column
hash String?
isFolder Boolean @default(false) isFolder Boolean @default(false)
oneDriveId String? @unique oneDriveId String? @unique
path String 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 uploadToOneDrive
} from "@/services/onedrive"; } from "@/services/onedrive";
import { getEnrichedMetadataFromCloud } from "@/data-access/file-nodes"; //import { getEnrichedMetadataFromCloud } from "@/data-access/file-nodes";
import { getFreshAccessToken } from "@/lib/auth-utils"; import { getFreshAccessToken } from "@/lib/auth-utils";
import { getOneDriveFileBuffer } from "@/services/onedrive"; // Import your working service import { getOneDriveFileBuffer } from "@/services/onedrive"; // Import your working service
import { extractMetadata } from "@/lib/metadata-extractor"; import { extractMetadata } from "@/lib/metadata-extractor";
/** /**
* 1. FETCH: Get all file nodes * 1. FETCH: Get all file nodes
* Now simply calls the DAL. Error handling is left to the caller (the UI). * 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) { export async function getMetadataPreviewAction(fileId: string) {
const session = await auth(); const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized"); if (!session?.user?.id) throw new Error("Unauthorized");
console.log(`\n--- 🔍 Magic Fill Started for File ID: ${fileId} ---`);
try { try {
const node = await getFileNodeById(fileId); 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");
// 1. Get the token from our utility console.log(`📡 Attempting fetch via Service for: ${node.name}`);
const token = await getFreshAccessToken(session.user.id);
// LOG: Just check the length to be sure it's not empty const token = await getFreshAccessToken(session.user.id);
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(`📦 Buffer received: ${buffer.length} bytes`);
console.log(`📡 Fetching binary for: ${node.name}...`);
// 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); const extractedData = await extractMetadata(buffer, node.name);
console.log("✅ RAW DATA EXTRACTED:"); console.log("✅ Extracted:", extractedData);
console.dir(extractedData, { depth: null, colors: true });
return { success: true, data: extractedData }; return { success: true, data: extractedData };
} catch (error: any) { } 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 }; return { success: false, error: error.message };
} }
} }

View file

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

View file

@ -1,24 +1,28 @@
'use server'; 'use server';
//src/app/upload/_actions.ts)
// src/app/upload/_actions.ts
import { auth } from "@/auth"; import { auth } from "@/auth";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { createFileNode } from "@/data-access/file-nodes"; import { createFileNode, getAllFolders, upsertFileNodeByHash } from "@/data-access/file-nodes";
//import { createOneDriveFolder, uploadToFolderId, ensureOneDriveFolder } from "@/services/onedrive"; import { prisma } from "@/lib/prisma";
import { createOneDriveFolder, uploadToFolderId, ensureOneDriveFolder } from "@/services/onedrive"; import { createOneDriveFolder, uploadToFolderId, ensureOneDriveFolder } from "@/services/onedrive";
/** /**
* 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) { export async function createFolderAction(name: string, parentId?: string | null) {
const session = await auth(); const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized"); if (!session?.user?.id) throw new Error("Unauthorized");
try { try {
const internalId = crypto.randomUUID(); const internalId = crypto.randomUUID();
// Swapped createNode for createFileNode
const newNode = await createFileNode({ const newNode = await createFileNode({
id: internalId, id: internalId,
oneDriveId: null, 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"); throw new Error(error.message || "Failed to create virtual folder");
} }
} }
/**
* 2. UPLOAD FILE (Physical UUID Folder)
*/
export async function uploadFileAction(formData: FormData) { export async function uploadFileAction(formData: FormData) {
const session = await auth(); const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized"); if (!session?.user?.id) throw new Error("Unauthorized");
const file = formData.get("file") as File; const file = formData.get("file") as File;
const hash = formData.get("hash") as string;
const description = formData.get("description") as string || ""; const description = formData.get("description") as string || "";
const parentIdRaw = formData.get("parentId") as string | null; const parentIdRaw = formData.get("parentId") as string | null;
const parentId = (parentIdRaw === "" || parentIdRaw === "root") ? null : parentIdRaw; const parentId = (parentIdRaw === "" || parentIdRaw === "root") ? null : parentIdRaw;
@ -54,41 +57,33 @@ export async function uploadFileAction(formData: FormData) {
if (!file) throw new Error("No file selected"); if (!file) throw new Error("No file selected");
const rootFolder = "WebCalibre"; const rootFolder = "WebCalibre";
const internalId = crypto.randomUUID(); // Used for both DB ID and OneDrive Folder Name const internalId = crypto.randomUUID();
try { try {
// A. Ensure root exists
await ensureOneDriveFolder(session.user.id, rootFolder); await ensureOneDriveFolder(session.user.id, rootFolder);
// B. Create the physical UUID folder on OneDrive
const subFolderRes = await createOneDriveFolder(session.user.id, rootFolder, internalId); const subFolderRes = await createOneDriveFolder(session.user.id, rootFolder, internalId);
const subFolderData = await subFolderRes.json(); const subFolderData = await subFolderRes.json();
// C. Upload the file binary into that specific folder
const uploadedFileData = await uploadToFolderId(session.user.id, file, subFolderData.id); const uploadedFileData = await uploadToFolderId(session.user.id, file, subFolderData.id);
// D. Create record in Database await createFileNode({
// src/app/upload/_actions.ts id: internalId,
oneDriveId: uploadedFileData.id,
// ... inside uploadFileAction or createFolderAction ... name: file.name,
// ... inside uploadFileAction after OneDrive work is done ... hash: hash,
description: description,
await createFileNode({ size: BigInt(file.size),
id: internalId, isFolder: false,
oneDriveId: uploadedFileData.id, path: `/${rootFolder}/${internalId}/${file.name}`,
name: file.name, ownerId: session.user.id,
description: description, parentId: parentId,
size: BigInt(file.size), metadata: {
isFolder: false, ...customMetadata,
path: `/${rootFolder}/${internalId}/${file.name}`, type: file.name.split('.').pop()?.toUpperCase() || "UNKNOWN",
ownerId: session.user.id, mimeType: file.type
parentId: parentId, }
metadata: { });
...customMetadata, // User's custom keys from the form
type: file.name.split('.').pop()?.toUpperCase() || "UNKNOWN",
mimeType: file.type
}
});
revalidatePath("/dashboard"); revalidatePath("/dashboard");
revalidatePath("/upload"); revalidatePath("/upload");
@ -97,4 +92,74 @@ await createFileNode({
console.error("Upload refactor error:", error); console.error("Upload refactor error:", error);
return { success: false, error: error.message }; 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"; // src/app/upload/bulk/page.tsx
import { redirect } from "next/navigation";
import UploadView from "./upload-view"; // This is the Client Component
import { Container } from "@mui/material";
import { prisma } from "@/lib/prisma";
// 1. Rename to UploadPage to avoid conflict with the 'UploadView' import "use client";
// 2. Add 'async' so you can use 'await' inside
export default async function UploadPage() {
const session = await auth();
if (!session) redirect("/");
// 3. Fetch folders. Renamed variable to 'allFolders' to avoid any confusion import React, { useState, useCallback, useEffect } from 'react';
const allFolders = await prisma.fileNode.findMany({ import { useDropzone } from 'react-dropzone';
where: { import {
isFolder: true, Box, Button, Typography, Paper, Table, TableBody,
ownerId: session.user.id // Good practice: only show user's own folders TableCell, TableContainer, TableHead, TableRow, LinearProgress, Chip,
}, Alert, Stack
orderBy: { name: 'asc' }, } from '@mui/material';
select: { id: true, name: true, parentId: true } import FolderIcon from '@mui/icons-material/Folder';
}); import CloudUploadIcon from '@mui/icons-material/CloudUpload';
// --- 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 ( return (
<Container maxWidth="md" sx={{ py: 8 }}> <Box sx={{ p: 4, maxWidth: 1200, mx: 'auto' }}>
{/* 4. Render the Client Component and pass the data */} <Typography variant="h4" fontWeight={800} color="primary" gutterBottom>
<UploadView user={session.user} folders={allFolders} /> Bulk Uploads & Restore
</Container> </Typography>
<Paper
{...getRootProps()}
sx={{
p: 6, mb: 4, textAlign: 'center', cursor: 'pointer',
border: '2px dashed', borderColor: isDragActive ? 'primary.main' : 'divider',
bgcolor: isDragActive ? 'primary.50' : 'background.paper',
transition: 'all 0.2s'
}}
>
<input {...getInputProps()} {...({ webkitdirectory: "", directory: "" } as any)} />
<FolderIcon sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
<Typography variant="h6">Drop Folders or Files Here</Typography>
<Typography variant="body2" color="text.secondary">
Perfect for camera imports or full system restores
</Typography>
</Paper>
{duplicateCount > 0 && (
<Alert severity="warning" sx={{ mb: 3 }}>
{duplicateCount} duplicate(s) found. These files already exist in your library.
</Alert>
)}
{queue.length > 0 && (
<TableContainer component={Paper} sx={{ maxHeight: 500, borderRadius: 2 }}>
<Table stickyHeader size="small">
<TableHead>
<TableRow>
<TableCell>Location / Path</TableCell>
<TableCell>Size</TableCell>
<TableCell>Status</TableCell>
<TableCell>SHA-256 Hash</TableCell>
</TableRow>
</TableHead>
<TableBody>
{queue.map((item) => (
<TableRow key={item.id} hover>
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>
{item.path}
</TableCell>
<TableCell>
{(item.file.size / 1024 / 1024).toFixed(2)} MB
</TableCell>
<TableCell>
<Chip
label={item.status.toUpperCase()}
size="small"
color={
item.status === 'duplicate' ? 'warning' :
item.status === 'ready' ? 'info' :
item.status === 'success' ? 'success' : 'default'
}
/>
</TableCell>
<TableCell sx={{ fontSize: '0.7rem', color: 'text.secondary' }}>
{item.hash ? `${item.hash.substring(0, 16)}...` : '---'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
<Stack direction="row" spacing={2} sx={{ mt: 4 }}>
<Button
variant="contained"
size="large"
startIcon={isProcessing ? <LinearProgress sx={{ width: 20 }} /> : <CloudUploadIcon />}
disabled={isProcessing || queue.length === 0}
onClick={() => setIsProcessing(true)}
>
{isProcessing ? 'Analyzing...' : `Analyze ${queue.length} Files`}
</Button>
<Button
variant="outlined"
color="inherit"
disabled={isProcessing}
onClick={() => setQueue([])}
>
Clear All
</Button>
</Stack>
</Box>
); );
} }

View file

@ -1,12 +1,14 @@
'use client'; 'use client';
// src/app/upload/upload-view.tsx
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import { import {
Box, Button, Typography, Paper, Stack, Box, Button, Typography, Paper, Stack,
TextField, IconButton, Divider, TextField, IconButton, Divider,
Grid, Grid,
CircularProgress, Checkbox, MenuItem, CircularProgress, Checkbox, MenuItem,
InputAdornment, Collapse Collapse,
Dialog, DialogTitle, DialogContent,
DialogContentText, DialogActions
} from "@mui/material"; } from "@mui/material";
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'; import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
@ -15,9 +17,12 @@ import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import CreateNewFolderIcon from '@mui/icons-material/CreateNewFolder'; import CreateNewFolderIcon from '@mui/icons-material/CreateNewFolder';
import AssignmentIcon from '@mui/icons-material/Assignment'; import AssignmentIcon from '@mui/icons-material/Assignment';
import ClearIcon from '@mui/icons-material/Clear'; import ClearIcon from '@mui/icons-material/Clear';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { getMetadataPreviewAction } from "@/app/dashboard/actions"; import { getMetadataPreviewAction } from "@/app/dashboard/actions";
import { uploadFileAction, createFolderAction } from "./_actions"; import { calculateFileHash } from "@/lib/hashing-client";
import { uploadFileAction, createFolderAction, checkDuplicateAction } from "./_actions";
interface MetadataRow { interface MetadataRow {
key: string; key: string;
@ -26,37 +31,40 @@ interface MetadataRow {
selected?: boolean; 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 router = useRouter();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// Form State
const [selectedFile, setSelectedFile] = useState<File | null>(null); const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [targetFolderId, setTargetFolderId] = useState<string>(""); const [targetFolderId, setTargetFolderId] = useState<string>("");
const [showNewFolderInput, setShowNewFolderInput] = useState(false); const [showNewFolderInput, setShowNewFolderInput] = useState(false);
const [newFolderName, setNewFolderName] = useState(""); const [newFolderName, setNewFolderName] = useState("");
const [rows, setRows] = useState<MetadataRow[]>([]); const [rows, setRows] = useState<MetadataRow[]>([]);
// UI Status State
const [isExtracting, setIsExtracting] = useState(false); const [isExtracting, setIsExtracting] = useState(false);
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving'>('idle'); 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);
// Logic to determine if the "Complete" button should be active
const canSubmit = selectedFile !== null || newFolderName.trim().length > 0; const canSubmit = selectedFile !== null || newFolderName.trim().length > 0;
// --- 1. MAGIC EXTRACTION LOGIC --- // --- 1. MAGIC EXTRACTION ---
const handleMagicEnhance = async () => { const handleMagicEnhance = async () => {
if (!selectedFile) return; if (!selectedFile) return;
setIsExtracting(true); setIsExtracting(true);
try { try {
const result = await getMetadataPreviewAction(selectedFile.name); const result = await getMetadataPreviewAction(selectedFile.name);
if (result.success) { if (result.success) {
const extractedRows: MetadataRow[] = Object.entries(result.data ?? {}).map(([k, v]) => ({ const extractedRows: MetadataRow[] = Object.entries(result.data ?? {}).map(([k, v]) => ({
key: k, key: k,
value: String(v), value: typeof v === 'object' ? JSON.stringify(v) : String(v),
isPending: true, isPending: true,
selected: true selected: true
})); }));
setRows(prev => { setRows(prev => {
const existingKeys = new Set(prev.map(r => r.key)); const existingKeys = new Set(prev.map(r => r.key));
const newUniqueRows = extractedRows.filter(r => !existingKeys.has(r.key)); const newUniqueRows = extractedRows.filter(r => !existingKeys.has(r.key));
@ -72,35 +80,32 @@ export default function UploadView({ user, folders }: { user: any; folders: any[
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) setSelectedFile(file);
setSelectedFile(file);
}
}; };
// --- 2. SAVE / UPLOAD LOGIC --- // --- 2. UPLOAD EXECUTION ---
const handleSave = async () => { const executeUpload = async (preCalculatedHash?: string) => {
if (!canSubmit) return;
setSaveStatus('saving'); setSaveStatus('saving');
try { try {
let currentParentId = targetFolderId; let currentParentId = targetFolderId;
// STEP A: Create Folder if user typed a new folder name // STEP A: Handle New Folder Creation
if (newFolderName.trim()) { if (newFolderName.trim()) {
const folderResult = await createFolderAction(newFolderName, targetFolderId || null); const folderResult = await createFolderAction(newFolderName, targetFolderId || null);
if (folderResult.success) { if (folderResult.success) {
// If successful, we want the file to go inside this NEW folder
currentParentId = folderResult.node.id; 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) { if (selectedFile) {
const formData = new FormData(); const formData = new FormData();
formData.append("file", selectedFile); formData.append("file", selectedFile);
formData.append("hash", preCalculatedHash || "");
formData.append("parentId", currentParentId || "root"); formData.append("parentId", currentParentId || "root");
// Construct Metadata Object
const metadataObject = rows const metadataObject = rows
.filter(r => r.selected && r.key.trim() !== "") .filter(r => r.selected && r.key.trim() !== "")
.reduce((acc, curr) => { .reduce((acc, curr) => {
@ -111,200 +116,230 @@ export default function UploadView({ user, folders }: { user: any; folders: any[
formData.append("customMetadata", JSON.stringify(metadataObject)); formData.append("customMetadata", JSON.stringify(metadataObject));
const uploadResult = await uploadFileAction(formData); const uploadResult = await uploadFileAction(formData);
if (!uploadResult.success) throw new Error(uploadResult.error || "Upload failed");
if (!uploadResult.success) {
throw new Error(uploadResult.error || "Upload failed");
}
} }
console.log("✅ Process complete. Returning to dashboard.");
router.push("/dashboard"); router.push("/dashboard");
router.refresh(); router.refresh();
} catch (err: any) { } catch (err: any) {
console.error("Save failed:", err); console.error("Save failed:", err);
alert(err.message || "An error occurred while saving."); alert(err.message || "An error occurred while saving.");
} finally {
setSaveStatus('idle'); setSaveStatus('idle');
} }
}; };
return ( // --- 3. SAVE HANDLER (With Hash Intercept) ---
<Paper sx={{ p: { xs: 3, md: 6 }, borderRadius: 4, maxWidth: 800, mx: 'auto' }} elevation={3}> const handleSave = async () => {
<Typography variant="h4" fontWeight={900} color="primary" gutterBottom align="center"> if (!canSubmit) return;
Upload & Enrich
</Typography> if (selectedFile) {
setSaveStatus('hashing');
// Calculate local SHA-256
const fileHash = await calculateFileHash(selectedFile);
// Check database via Server Action
const duplicate = await checkDuplicateAction(fileHash);
<Stack spacing={3} sx={{ mt: 4, mb: 4 }}> if (duplicate) {
{/* FOLDER SELECTION */} setDuplicateInfo({ name: duplicate.name, hash: fileHash });
<Box> setDuplicateDialogOpen(true);
<Stack direction="row" spacing={1}> return; // Dialog takes over from here
<TextField }
select
fullWidth await executeUpload(fileHash);
label="Destination Folder" } else {
value={targetFolderId} await executeUpload(); // Folder only
onChange={(e) => { }
setTargetFolderId(e.target.value); };
if (e.target.value) setShowNewFolderInput(false);
}} return (
> <>
<MenuItem value=""><em>-- Root Directory --</em></MenuItem> <Paper sx={{ p: { xs: 3, md: 6 }, borderRadius: 4, maxWidth: 800, mx: 'auto', mt: 4 }} elevation={3}>
{folders?.map((f) => ( <Typography variant="h4" fontWeight={900} color="primary" gutterBottom align="center">
<MenuItem key={f.id} value={f.id}>{f.name}</MenuItem> Upload & Enrich
))} </Typography>
</TextField>
<Stack spacing={3} sx={{ mt: 4, mb: 4 }}>
{/* FOLDER SELECTION */}
<Box>
<Stack direction="row" spacing={1}>
<TextField
select fullWidth label="Parent Destination"
value={targetFolderId}
onChange={(e) => setTargetFolderId(e.target.value)}
helperText="Choose where your file will live"
>
<MenuItem value=""><em>-- Root Directory --</em></MenuItem>
{folders?.map((f) => (
<MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>
))}
</TextField>
<Button
variant={showNewFolderInput ? "contained" : "outlined"}
onClick={() => setShowNewFolderInput(!showNewFolderInput)}
sx={{ height: 56, minWidth: 56 }}
>
<CreateNewFolderIcon />
</Button>
</Stack>
<Collapse in={showNewFolderInput}>
<Box sx={{ mt: 2, p: 2, bgcolor: 'grey.50', borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
NEW SUB-FOLDER NAME
</Typography>
<TextField
fullWidth size="small" placeholder="e.g. Finance 2026"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
/>
</Box>
</Collapse>
</Box>
{/* FILE SELECTION */}
<Box>
<input
type="file" id="file-upload-input" style={{ display: 'none' }}
onChange={handleFileChange} ref={fileInputRef}
/>
{!selectedFile ? (
<Button
variant="outlined" fullWidth startIcon={<CloudUploadIcon />}
onClick={() => fileInputRef.current?.click()}
sx={{ py: 4, borderStyle: 'dashed', borderWidth: 2, borderRadius: 2 }}
>
Select File to Upload
</Button>
) : (
<Paper variant="outlined" sx={{ p: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between', bgcolor: 'primary.50' }}>
<Stack direction="row" spacing={2} alignItems="center">
<CloudUploadIcon color="primary" />
<Typography variant="body1" fontWeight="600">{selectedFile.name}</Typography>
</Stack>
<IconButton onClick={() => setSelectedFile(null)} color="error" size="small">
<ClearIcon />
</IconButton>
</Paper>
)}
</Box>
</Stack>
<Divider sx={{ my: 4 }} />
{/* MAGIC EXTRACT */}
<Box sx={{ mb: 4, p: 2.5, bgcolor: '#f0f7ff', borderRadius: 2, border: '1px dashed #1976d2' }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" spacing={2}>
<Box>
<Typography variant="subtitle1" fontWeight="bold" color="primary.main">Magic Extract</Typography>
<Typography variant="caption" color="text.secondary">
Auto-pull metadata from file content.
</Typography>
</Box>
<Button <Button
variant={showNewFolderInput ? "contained" : "outlined"} variant="contained" onClick={handleMagicEnhance}
onClick={() => { disabled={!selectedFile || isExtracting}
setShowNewFolderInput(!showNewFolderInput); startIcon={isExtracting ? <CircularProgress size={20} color="inherit" /> : <AutoFixHighIcon />}
if (!showNewFolderInput) setTargetFolderId(""); sx={{ borderRadius: 20, px: 3 }}
}}
sx={{ height: 56, minWidth: 56 }}
> >
<CreateNewFolderIcon /> {isExtracting ? "Extracting..." : "Run"}
</Button> </Button>
</Stack> </Stack>
<Collapse in={showNewFolderInput}>
<Box sx={{ mt: 2, p: 2, bgcolor: 'grey.50', borderRadius: 2 }}>
<TextField
fullWidth
size="small"
label="New Folder Name"
placeholder="Enter name to create folder..."
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
/>
</Box>
</Collapse>
</Box> </Box>
{/* FILE SELECTION */} {/* METADATA PREVIEW */}
<Box> <Box sx={{ mb: 4 }}>
<input <Typography variant="h6" fontWeight="700" sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
type="file" <AssignmentIcon color="primary" /> Metadata Fields
id="file-upload-input" </Typography>
style={{ display: 'none' }} <Stack spacing={2}>
onChange={handleFileChange} {rows.map((row, index) => (
ref={fileInputRef} <Grid container spacing={1} key={index} alignItems="center">
/> <Grid item xs={1}>
{!selectedFile ? ( <Checkbox checked={row.selected} size="small" onChange={(e) => {
<Button
variant="outlined"
fullWidth
startIcon={<CloudUploadIcon />}
onClick={() => fileInputRef.current?.click()}
sx={{ py: 4, borderStyle: 'dashed', borderWidth: 2 }}
>
Select File to Upload
</Button>
) : (
<Paper variant="outlined" sx={{ p: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between', bgcolor: 'primary.50' }}>
<Stack direction="row" spacing={2} alignItems="center">
<CloudUploadIcon color="primary" />
<Typography variant="body1" fontWeight="500">{selectedFile.name}</Typography>
</Stack>
<IconButton onClick={() => setSelectedFile(null)} color="error">
<ClearIcon />
</IconButton>
</Paper>
)}
</Box>
</Stack>
<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>
<Typography variant="subtitle1" fontWeight="bold">Magic Extract</Typography>
<Typography variant="caption" color="text.secondary">Populate metadata automatically from file properties.</Typography>
</Box>
<Button
variant="contained"
onClick={handleMagicEnhance}
disabled={!selectedFile || isExtracting}
startIcon={isExtracting ? <CircularProgress size={20} color="inherit" /> : <AutoFixHighIcon />}
>
{isExtracting ? "Running..." : "Run"}
</Button>
</Stack>
</Box>
{/* METADATA PREVIEW */}
<Box sx={{ mb: 4 }}>
<Typography variant="h6" fontWeight="700" sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<AssignmentIcon color="primary" /> Metadata Preview
</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) => {
const updated = [...rows]; const updated = [...rows];
updated[index].selected = e.target.checked; updated[index].selected = e.target.checked;
setRows(updated); setRows(updated);
}} }} />
/> </Grid>
</Grid> <Grid item xs={5}>
<Grid size={5}> <TextField fullWidth size="small" label="Key" value={row.key} onChange={(e) => {
<TextField
fullWidth size="small" label="Key" value={row.key}
onChange={(e) => {
const updated = [...rows]; const updated = [...rows];
updated[index].key = e.target.value; updated[index].key = e.target.value;
setRows(updated); setRows(updated);
}} }} />
/> </Grid>
</Grid> <Grid item xs={5}>
<Grid size={5}> <TextField fullWidth size="small" label="Value" value={row.value} onChange={(e) => {
<TextField
fullWidth size="small" label="Value" value={row.value}
onChange={(e) => {
const updated = [...rows]; const updated = [...rows];
updated[index].value = e.target.value; updated[index].value = e.target.value;
setRows(updated); setRows(updated);
}} }} />
/> </Grid>
<Grid item xs={1}>
<IconButton onClick={() => setRows(rows.filter((_, i) => i !== index))} color="error">
<DeleteOutlineIcon />
</IconButton>
</Grid>
</Grid> </Grid>
<Grid size={1}> ))}
<IconButton onClick={() => setRows(rows.filter((_, i) => i !== index))} color="error"> <Button variant="text" startIcon={<AddCircleOutlineIcon />} onClick={() => setRows([...rows, {key: "", value: "", selected: true}])}>
<DeleteOutlineIcon /> Add Manual Field
</IconButton> </Button>
</Grid> </Stack>
</Grid> </Box>
))}
<Button {/* FINAL BUTTON */}
variant="text" <Button
startIcon={<AddCircleOutlineIcon />} variant="contained" size="large" fullWidth onClick={handleSave}
onClick={() => setRows([...rows, {key: "", value: "", selected: true}])} disabled={!canSubmit || saveStatus !== 'idle'}
> sx={{ py: 2, fontWeight: 'bold', borderRadius: 2, boxShadow: 4 }}
Add Manual Field >
</Button> {saveStatus === 'hashing' ? <CircularProgress size={24} color="inherit" /> :
</Stack> 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> </Box>
{/* UPLOAD BUTTON */} <Typography variant="body2" sx={{ mt: 2, color: 'text.secondary' }}>
<Button Would you like to skip this upload or create a second copy?
variant="contained" </Typography>
size="large" </DialogContentText>
fullWidth </DialogContent>
onClick={handleSave} <DialogActions sx={{ p: 2, gap: 1 }}>
disabled={!canSubmit || saveStatus === 'saving'} <Button
sx={{ py: 2, fontWeight: 'bold' }} onClick={() => { setDuplicateDialogOpen(false); setSaveStatus('idle'); }}
> variant="outlined" color="inherit" fullWidth
{saveStatus === 'saving' ? ( >
<Stack direction="row" spacing={2} alignItems="center"> Cancel
<CircularProgress size={24} color="inherit" /> </Button>
<Typography>Processing Upload...</Typography> <Button
</Stack> onClick={() => { setDuplicateDialogOpen(false); executeUpload(duplicateInfo?.hash); }}
) : ( variant="contained" color="warning" fullWidth
"Complete Upload & Save" >
)} Upload Anyway
</Button> </Button>
</Paper> </DialogActions>
</Dialog>
</>
); );
} }

View file

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

View file

@ -5,12 +5,6 @@ import { getOneDriveFileBuffer } from "@/services/onedrive";
import { extractMetadata } from "@/lib/metadata-extractor"; import { extractMetadata } from "@/lib/metadata-extractor";
import { prisma } from "@/lib/prisma"; 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() { export async function getAllFileNodes() {
return await prisma.fileNode.findMany({ return await prisma.fileNode.findMany({
orderBy: { 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) { export async function getFileNodeById(id: string) {
return await prisma.fileNode.findUnique({ return await prisma.fileNode.findUnique({
where: { id }, 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) { export async function updateFileNode(id: string, data: any) {
return await prisma.fileNode.update({ return await prisma.fileNode.update({
where: { id }, 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) { export async function deleteFileNode(id: string) {
return await prisma.fileNode.delete({ return await prisma.fileNode.delete({
where: { id }, 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: { export async function createFileNode(data: {
id?: string; // Optional: used for virtual folders/UUID storage id?: string;
oneDriveId: string | null; oneDriveId: string | null;
name: string; name: string;
hash?: string | null;
description?: string; description?: string;
isFolder: boolean; isFolder: boolean;
path: string; path: string;
ownerId: string; ownerId: string;
parentId?: string | null; // Optional: for nested structures parentId?: string | null;
size?: bigint; size?: bigint;
metadata: any; metadata: any;
}) { }) {
return await prisma.fileNode.create({ return await prisma.fileNode.create({
data: { data: {
...data, ...data,
id: data.id ?? crypto.randomUUID(), // Use provided ID or generate new one id: data.id ?? crypto.randomUUID(),
} }
}); });
} }
export async function upsertFileNode(oneDriveId: string, data: {
// ... other functions (getAllFileNodes, etc) name: string;
size: bigint;
/** isFolder: boolean;
* UPSERT: Create or Update a file node based on OneDrive ID path: string;
* Moved here because it interacts with the Database. ownerId: string;
*/ metadata: any;
export async function upsertFileNode(oneDriveId: string, data: any) { hash?: string | null;
}) {
return await prisma.fileNode.upsert({ return await prisma.fileNode.upsert({
where: { oneDriveId }, where: { oneDriveId },
update: { update: {
@ -92,6 +72,7 @@ export async function upsertFileNode(oneDriveId: string, data: any) {
size: data.size, size: data.size,
isFolder: data.isFolder, isFolder: data.isFolder,
path: data.path, path: data.path,
hash: data.hash,
updatedAt: new Date(), updatedAt: new Date(),
}, },
create: { create: {
@ -103,29 +84,73 @@ export async function upsertFileNode(oneDriveId: string, data: any) {
path: data.path, path: data.path,
ownerId: data.ownerId, ownerId: data.ownerId,
metadata: data.metadata, metadata: data.metadata,
hash: data.hash,
} }
}); });
} }
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. * UPSERT BY HASH: FIXED FOR SYSTEM STABILITY
* This is the "Brain" function for your metadata enrichment.
*/ */
export async function getEnrichedMetadataFromCloud(fileId: string) { export async function upsertFileNodeByHash(data: {
// 1. Get the record from our DB so we know the filename (needed for extension logic) name: string;
const node = await prisma.fileNode.findUnique({ hash: string;
where: { id: fileId } 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 // ✨ NEW UPLOAD MODE
const buffer = await getOneDriveFileBuffer(fileId); const fileExtension = data.name.split('.').pop()?.toUpperCase() || "UNKNOWN";
const newNode = await prisma.fileNode.create({
// 3. Extract internal metadata (Title, Author, or GPS coordinates) data: {
const deepMetadata = await extractMetadata(buffer, node.name); id: crypto.randomUUID(),
oneDriveId: data.oneDriveId,
return deepMetadata; 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'; // src/lib/metadata-extractor.ts
import EPub from 'epub'; // src/lib/metadata-extractor.ts
import * as PdfParse from 'pdf-parse-new';
import sharp from 'sharp'; import sharp from 'sharp';
import exifReader from 'exif-reader'; import exifReader from 'exif-reader';
import EPub from 'epub2'; // New Import
export interface ExtractedMetadata { /**
title?: string; * Converts EXIF DMS array to Decimal Degrees.
author?: string; */
subject?: string; function convertDMSToDD(dms: any, ref: string): string {
dimensions?: string; if (!Array.isArray(dms) || dms.length < 3) return String(dms);
pageCount?: number; const [degrees, minutes, seconds] = dms;
latitude?: number; let dd = degrees + (minutes / 60) + (seconds / 3600);
longitude?: number; if (ref === 'S' || ref === 'W') dd = dd * -1;
type: string; 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(); const extension = filename.split('.').pop()?.toLowerCase();
try { try {
// --- 1. PDF EXTRACTION --- // --- 1. PDF EXTRACTION ---
if (extension === 'pdf') { if (extension === 'pdf') {
// Use any to bypass the missing 'default' property error in ESM const parser = new PdfParse.SmartPDFParser({ oversaturationFactor: 2.0, enableFastPath: true });
const parsePdf = (pdf as any).default || pdf; const result = await parser.parse(buffer);
const data = await parsePdf(buffer);
return { return {
type: 'PDF', type: 'PDF',
title: data.info?.Title || filename, title: filename,
author: data.info?.Author, pageCount: result.numpages || 0,
subject: data.info?.Subject, details: sanitizeMetadata(result.info || {}),
pageCount: data.numpages, textPreview: result.text ? result.text.substring(0, 200).replace(/\s+/g, ' ') : ""
}; };
} }
// --- 2. EPUB EXTRACTION --- // --- 2. IMAGE EXTRACTION ---
if (extension === 'epub') { if (['jpg', 'jpeg', 'png', 'webp','heic'].includes(extension || '')) {
// 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 || '')) {
const image = sharp(buffer); const image = sharp(buffer);
const metadata = await image.metadata(); const metadata = await image.metadata();
let details = {};
let gps: { latitude?: number; longitude?: number } = {};
if (metadata.exif) { if (metadata.exif) {
try { try {
// Cast to any to bypass strict Exif type checking for nested GPS properties const rawExif = exifReader(metadata.exif);
const exif = exifReader(metadata.exif) as any; details = sanitizeMetadata(rawExif);
} catch (e) { console.warn("EXIF Parse failed"); }
// 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;
}
} catch (exifError) {
console.warn("Could not parse EXIF data for:", filename, exifError);
}
} }
return { return {
type: `IMAGE (${metadata.format?.toUpperCase()})`, type: `IMAGE (${metadata.format?.toUpperCase()})`,
dimensions: metadata.width && metadata.height ? `${metadata.width}x${metadata.height}` : undefined, dimensions: `${metadata.width}x${metadata.height}`,
title: filename, 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 }; return { type: 'FILE', title: filename };
} catch (error) { } catch (error: any) {
console.error(`Extraction failed for ${filename}:`, error); console.error(`Extraction failed for ${filename}:`, error.message);
return { type: 'FILE', title: filename }; 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"); if (!uploadRes.ok) throw new Error("Upload failed");
return await uploadRes.json(); 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);
}
/** /**
@ -220,4 +192,33 @@ async function getAccessToken(): Promise<string> {
} }
return token; 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);
} }