Compare commits
10 commits
9552846a0f
...
d4390c0305
| Author | SHA1 | Date | |
|---|---|---|---|
| d4390c0305 | |||
| 0b94846b21 | |||
| f37b1ee731 | |||
| 5dd5ec2792 | |||
| 6a3470229e | |||
| 96eda53f31 | |||
| 222dcda775 | |||
| 672408081d | |||
| 552662cf16 | |||
| 71b567ad04 |
37 changed files with 21033 additions and 3641 deletions
369
backup/db-backup-26-02-14
Normal file
369
backup/db-backup-26-02-14
Normal file
File diff suppressed because one or more lines are too long
12340
docs/Ai-converstion.md
12340
docs/Ai-converstion.md
File diff suppressed because it is too large
Load diff
Binary file not shown.
59
docs/Context-1.md
Normal file
59
docs/Context-1.md
Normal 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
BIN
docs/Context-1.pdf
Normal file
Binary file not shown.
4452
docs/html-discussions-with-AI/WebCalibre-(15_02_2026 23:03:17).html
Normal file
4452
docs/html-discussions-with-AI/WebCalibre-(15_02_2026 23:03:17).html
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -42,6 +42,9 @@
|
|||
- [1. Where to put the fetch call?](#1-where-to-put-the-fetch-call)
|
||||
- [2. Where to put `getMetadataFromJson`?](#2-where-to-put-getmetadatafromjson)
|
||||
- [🏗️ How it looks all together](#️-how-it-looks-all-together)
|
||||
- [image metadata](#image-metadata)
|
||||
- [pdf metadata](#pdf-metadata)
|
||||
- [useful](#useful)
|
||||
|
||||
# 1. Reference
|
||||
|
||||
|
|
@ -2495,3 +2498,58 @@ When you put these in their proper places, your **API Route** or **Server Action
|
|||
4. **Save to DB** (Prisma / DAL)
|
||||
|
||||
**Would you like me to help you refine the internal logic of the `extractExifMetadata` function to ensure it handles the specific "Rational" numbers (fractions) that EXIF often uses for exposure and GPS?**
|
||||
|
||||
|
||||
$\pi r^2$
|
||||
|
||||
$$H_2O$$
|
||||
|
||||
$$\sum$$
|
||||
|
||||
$$2^{10}$$
|
||||
|
||||
$$\sum_{i=1}^{n} i = \frac{n(n+1)}{2}$$
|
||||
|
||||
$$\int_{a}^{b} x^2 \, dx$$
|
||||
|
||||
$$\iiint$$
|
||||
|
||||
$$\oint$$
|
||||
|
||||
$\underline{\overline{ \text{--- } \bullet \text{ --- } \bullet \text{ --- } }}$
|
||||
|
||||
$\textifsym{mm<DDD>mm}$
|
||||
|
||||
$\FallingEdge$
|
||||
|
||||
# image metadata
|
||||
{"type": "JPEG", "mimeType": "image/jpeg", "dimensions": "4032x3024", "details.bigEndian": "true", "details.image.make": "Apple", "details.image.model": "iPhone 14", "details.photo.flash": "16", "details.image.gPSTag": "2586", "details.image.exifTag": "228", "details.photo.fNumber": "1.5", "details.image.dateTime": "2025-10-22T12:08:16.000Z", "details.image.software": "18.6.2", "details.photo.lensMake": "Apple", "details.photo.lensModel": "iPhone 14 back dual wide camera 5.7mm f/1.5", "details.photo.makerNote": "[Binary Data: 1710 bytes]", "details.photo.sceneType": "[Binary Data: 1 bytes]", "details.gPSInfo.gPSSpeed": "0.04381048133718738", "details.photo.colorSpace": "65535", "details.photo.offsetTime": "+02:00", "details.image.orientation": "6", "details.image.xResolution": "72", "details.image.yResolution": "72", "details.photo.exifVersion": "[Binary Data: 4 bytes]", "details.photo.focalLength": "5.7", "details.photo.subjectArea": "2209,1013,261,260", "details.image.hostComputer": "iPhone 14", "details.photo.exposureMode": "0", "details.photo.exposureTime": "0.001876172607879925", "details.photo.meteringMode": "5", "details.photo.whiteBalance": "0", "details.gPSInfo.gPSAltitude": "664.5505481120584", "details.gPSInfo.gPSLatitude": "40,25,5.48", "details.gPSInfo.gPSSpeedRef": "K", "details.photo.apertureValue": "1.1699250021066825", "details.photo.sensingMethod": "2", "details.gPSInfo.gPSDateStamp": "2025:10:22", "details.gPSInfo.gPSLongitude": "3,41,7.67", "details.gPSInfo.gPSTimeStamp": "10,8,15", "details.image.resolutionUnit": "2", "details.photo.compositeImage": "2", "details.photo.brightnessValue": "7.091505376344086", "details.photo.exposureProgram": "2", "details.photo.flashpixVersion": "[Binary Data: 4 bytes]", "details.photo.iSOSpeedRatings": "50", "details.photo.pixelXDimension": "4032", "details.photo.pixelYDimension": "3024", "details.thumbnail.compression": "6", "details.thumbnail.xResolution": "72", "details.thumbnail.yResolution": "72", "details.gPSInfo.gPSAltitudeRef": "0", "details.gPSInfo.gPSDestBearing": "345.1883852691218", "details.gPSInfo.gPSLatitudeRef": "N", "details.image.yCbCrPositioning": "1", "details.photo.dateTimeOriginal": "2025-10-22T12:08:16.000Z", "details.photo.sceneCaptureType": "0", "details.gPSInfo.gPSImgDirection": "345.1883852691218", "details.gPSInfo.gPSLongitudeRef": "W", "details.photo.dateTimeDigitized": "2025-10-22T12:08:16.000Z", "details.photo.exposureBiasValue": "0", "details.photo.lensSpecification": "1.5399999618512084,5.699999809263318,1.5,2.4", "details.photo.shutterSpeedValue": "9.058893693156405", "details.photo.offsetTimeOriginal": "+02:00", "details.photo.subSecTimeOriginal": "132", "details.thumbnail.resolutionUnit": "2", "details.gPSInfo.gPSDestBearingRef": "T", "details.photo.offsetTimeDigitized": "+02:00", "details.photo.subSecTimeDigitized": "132", "details.gPSInfo.gPSImgDirectionRef": "T", "details.photo.focalLengthIn35mmFilm": "26", "details.gPSInfo.gPSHPositioningError": "14.15490024117518", "details.photo.componentsConfiguration": "[Binary Data: 4 bytes]", "details.thumbnail.jPEGInterchangeFormat": "2990", "details.thumbnail.jPEGInterchangeFormatLength": "11238"}
|
||||
|
||||
# pdf metadata
|
||||
{"type": "PDF", "mimeType": "application/pdf", "pageCount": "1", "textPreview": " Hello World! 3 1", "details.title": "Analysis of Electromagnetic Field Circulation", "details.author": "Stephen Lohning", "details.creator": "pdfLaTeX", "details.modDate": "D:20260205160832+11'00'", "details.subject": "Electrical Engineering", "details.keywords": "Maxwell, Electromagnetics, Integral Form, EE", "details.language": "null", "details.producer": "LaTeX", "details.creationDate": "D:20260205160832+11'00'", "details.isLinearized": "false", "details.isXFAPresent": "false", "details.trapped.name": "False", "details.pDFFormatVersion": "1.7", "details.encryptFilterName": "null", "details.isAcroFormPresent": "false", "details.isCollectionPresent": "false", "details.isSignaturesPresent": "false", "details.custom.pTEX.Fullbanner": "This is pdfTeX, Version 3.141592653-2.6-1.40.27 (TeX Live 2025/Homebrew) kpathsea version 6.4.1"}
|
||||
|
||||
## useful
|
||||
|
||||
"55d343c1c56047e69200aa5a5b112e0a"
|
||||
"31098f02fb7539aaa0f3aaff8ed72bcc"
|
||||
"fee24a48431c73b9b97c58a113bf48de"
|
||||
"6b29face2612d15077ebd87c83a7784c"
|
||||
"89b1f392c82379da4ca95597e3c50504"
|
||||
"4624eb595836af9cf8f5c86670c954cc"
|
||||
"4806abc5a27c875617ecce612307d908"
|
||||
"9f0d1962e233712e6318e2cc2a7acf81"
|
||||
"191c673a6e51338eed5a5d4de59b2722"
|
||||
|
||||
"b0968485b66dac0e6a0a9c908f56c848eac51af649d85ed37def7c362c0d81aa"
|
||||
"95c0679215501185bcc7de5bd3735312b2da923828e2ecf3fa2ad1fc23700777"
|
||||
"964edb4b4fb7b6371954ac6f392a55c8ca4ba746f0861a69a11de2deb23be14d"
|
||||
"4b3b50dee06813859e0235a1c56924729705fa260eff0705f78692d65baf3e1e"
|
||||
"c3ec806c4aaf7764242549ae95391149630cb817dcc6a7150873aa55f65522b1"
|
||||
"845d21b536cfa4f5439b80bdda7e1212339c35a691fa32ae4f73a772f262c44e"
|
||||
"091321649c8c386cd12aba401e3d9312ef9977d54430437bea38d02e1ef0ac21"
|
||||
"7d57e399378b7e80fe405eb6286bb779867bf047d520497ab24ef5545106797b"
|
||||
"7362b24f8b323d5f7198d1ab9ee9361dcbcd44e286512f83b4c51aca68815bd1"
|
||||
|
||||
"understanding-a-i.pdf" 5502317 "11d374882dd9c2db41aeb61cfc651ffc79ef1092e17320bd192d4611d1965b18"
|
||||
"understanding-a-i.pdf" 5502317 "11d374882dd9c2db41aeb61cfc651ffc79ef1092e17320bd192d4611d1965b18"
|
||||
"understanding-a-i.pdf" 5502317 "11d374882dd9c2db41aeb61cfc651ffc79ef1092e17320bd192d4611d1965b18"
|
||||
BIN
docs/notes.pdf
BIN
docs/notes.pdf
Binary file not shown.
2873
docs/notes_tmp.html
2873
docs/notes_tmp.html
File diff suppressed because it is too large
Load diff
1339
package-lock.json
generated
1339
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -4,6 +4,7 @@
|
|||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"backfill": "tsx scripts/backfill-hashes.ts",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
|
|
@ -21,15 +22,18 @@
|
|||
"@mui/material-nextjs": "^7.3.6",
|
||||
"@mui/x-data-grid": "^8.24.0",
|
||||
"@prisma/adapter-pg": "^7.2.0",
|
||||
"@prisma/client": "^7.2.0",
|
||||
"@prisma/client": "7.4.0",
|
||||
"epub": "^1.3.0",
|
||||
"epub2": "^3.0.2",
|
||||
"exif-reader": "^2.0.3",
|
||||
"next": "16.1.1",
|
||||
"next-auth": "^5.0.0-beta.30",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pdf-parse-new": "^2.0.0",
|
||||
"pg": "^8.16.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-dropzone": "^15.0.0",
|
||||
"server-only": "^0.0.1",
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
|
|
@ -41,7 +45,8 @@
|
|||
"dotenv-cli": "^11.0.0",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.1",
|
||||
"prisma": "^6.19.2",
|
||||
"prisma": "7.4.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
// prisma.config.ts
|
||||
import { config } from "dotenv";
|
||||
config({ path: ".env.local" });
|
||||
|
||||
import { defineConfig } from "prisma/config";
|
||||
import { defineConfig, env } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
datasource: {
|
||||
// This is now the ONLY place where the DB connection string is defined
|
||||
// prisma.config.ts
|
||||
url: process.env.DATABASE_URL!,
|
||||
// This is where Prisma 7 looks for the connection string
|
||||
url: env("DATABASE_URL"),
|
||||
},
|
||||
});
|
||||
|
|
@ -1,20 +1,58 @@
|
|||
-- CreateSchema
|
||||
CREATE SCHEMA IF NOT EXISTS "public";
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"azureAdUserId" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"email" TEXT NOT NULL,
|
||||
"displayName" TEXT,
|
||||
"role" "Role" NOT NULL DEFAULT 'USER',
|
||||
"emailVerified" TIMESTAMP(3),
|
||||
"image" TEXT,
|
||||
"azureAdUserId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Account" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"providerAccountId" TEXT NOT NULL,
|
||||
"refresh_token" TEXT,
|
||||
"access_token" TEXT,
|
||||
"expires_at" INTEGER,
|
||||
"token_type" TEXT,
|
||||
"scope" TEXT,
|
||||
"id_token" TEXT,
|
||||
"session_state" TEXT,
|
||||
|
||||
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Session" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sessionToken" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"expires" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "FileNode" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"size" BIGINT,
|
||||
"hash" TEXT,
|
||||
"isFolder" BOOLEAN NOT NULL DEFAULT false,
|
||||
"oneDriveId" TEXT,
|
||||
"path" TEXT NOT NULL,
|
||||
|
|
@ -29,11 +67,17 @@ CREATE TABLE "FileNode" (
|
|||
CONSTRAINT "FileNode_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_azureAdUserId_key" ON "User"("azureAdUserId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "FileNode_oneDriveId_key" ON "FileNode"("oneDriveId");
|
||||
|
|
@ -47,8 +91,15 @@ CREATE INDEX "FileNode_orderIndex_idx" ON "FileNode"("orderIndex");
|
|||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "FileNode_ownerId_path_key" ON "FileNode"("ownerId", "path");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FileNode" ADD CONSTRAINT "FileNode_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FileNode" ADD CONSTRAINT "FileNode_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "FileNode"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "FileNode" ADD CONSTRAINT "FileNode_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "FileNode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
@ -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");
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- DropIndex
|
||||
DROP INDEX "FileNode_hash_key";
|
||||
|
|
@ -60,6 +60,7 @@ model FileNode {
|
|||
id String @id
|
||||
name String
|
||||
size BigInt? // Preserved your BigInt size column
|
||||
hash String?
|
||||
isFolder Boolean @default(false)
|
||||
oneDriveId String? @unique
|
||||
path String
|
||||
|
|
|
|||
59
scripts/backfill-hashes.ts
Normal file
59
scripts/backfill-hashes.ts
Normal 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();
|
||||
|
|
@ -16,12 +16,14 @@ import {
|
|||
uploadToOneDrive
|
||||
} from "@/services/onedrive";
|
||||
|
||||
import { getEnrichedMetadataFromCloud } from "@/data-access/file-nodes";
|
||||
//import { getEnrichedMetadataFromCloud } from "@/data-access/file-nodes";
|
||||
|
||||
import { getFreshAccessToken } from "@/lib/auth-utils";
|
||||
|
||||
import { getOneDriveFileBuffer } from "@/services/onedrive"; // Import your working service
|
||||
import { extractMetadata } from "@/lib/metadata-extractor";
|
||||
|
||||
|
||||
/**
|
||||
* 1. FETCH: Get all file nodes
|
||||
* Now simply calls the DAL. Error handling is left to the caller (the UI).
|
||||
|
|
@ -120,134 +122,35 @@ export async function updateFileNodeAction(id: string, formData: FormData) {
|
|||
}
|
||||
}
|
||||
|
||||
// export async function getMetadataPreviewAction(fileId: string) {
|
||||
// const session = await auth();
|
||||
// if (!session?.user?.id) throw new Error("Unauthorized");
|
||||
|
||||
// try {
|
||||
// console.log(`🔍 Starting enhancement for file: ${fileId}`);
|
||||
|
||||
// // This calls the DAL -> which calls the Service -> which calls OneDrive
|
||||
// const data = await getEnrichedMetadataFromCloud(fileId);
|
||||
|
||||
// // This log will show you exactly what we found in your terminal!
|
||||
// console.log("✅ Extracted Metadata Result:", data);
|
||||
|
||||
// return { success: true, data };
|
||||
// } catch (error: any) {
|
||||
// console.error("❌ Enhancement Action Error:", error.message);
|
||||
// return { success: false, error: error.message };
|
||||
// }
|
||||
// }
|
||||
/**
|
||||
* 5. ENHANCE (Magic Fill): Extracts deep metadata from the actual file binary
|
||||
*/
|
||||
// export async function getMetadataPreviewAction(fileId: string) {
|
||||
// const session = await auth();
|
||||
// if (!session?.user?.id) throw new Error("Unauthorized");
|
||||
|
||||
// console.log(`\n--- 🔍 Magic Fill Started for File ID: ${fileId} ---`);
|
||||
|
||||
// try {
|
||||
// // 1. Get the record from DB to get the oneDriveId and Name
|
||||
// const node = await getFileNodeById(fileId);
|
||||
// if (!node || !node.oneDriveId) {
|
||||
// throw new Error("File not found or not synced with OneDrive");
|
||||
// }
|
||||
|
||||
// // 2. Get fresh token
|
||||
// const token = await getFreshAccessToken(session.user.id);
|
||||
|
||||
// // 3. Fetch the actual binary content from Microsoft Graph
|
||||
// console.log(`📡 Fetching binary from Microsoft Graph...`);
|
||||
// let response;
|
||||
// try {
|
||||
// response = await fetch(
|
||||
// `https://graph.microsoftonline.com/v1.0/me/drive/items/${node.oneDriveId}/content`,
|
||||
// {
|
||||
// headers: { Authorization: `Bearer ${token}` },
|
||||
// cache: 'no-store' // Ensure we aren't hitting a stale server cache
|
||||
// }
|
||||
// );
|
||||
// } catch (err: any) {
|
||||
// console.error("❌ THE ACTUAL NETWORK ERROR:");
|
||||
// console.error("Message:", err.message);
|
||||
// console.error("Cause/Stack:", err.cause || err.stack); // This is the gold mine
|
||||
// throw new Error(`Server-side fetch failed: ${err.message}`);
|
||||
// }
|
||||
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`Failed to fetch file content: ${response.statusText}`);
|
||||
// }
|
||||
|
||||
// const arrayBuffer = await response.arrayBuffer();
|
||||
// const buffer = Buffer.from(arrayBuffer);
|
||||
// console.log(`📦 Downloaded ${buffer.length} bytes.`);
|
||||
|
||||
// // 4. Run the Metadata Utility
|
||||
// const extractedData = await extractMetadata(buffer, node.name);
|
||||
|
||||
// // --- 🏁 THE TERMINAL LOG YOU REQUESTED ---
|
||||
// console.log("✅ RAW DATA EXTRACTED FROM FILE:");
|
||||
// console.dir(extractedData, { depth: null, colors: true });
|
||||
// console.log(`--- 🏁 Magic Fill Finished ---\n`);
|
||||
|
||||
// return { success: true, data: extractedData };
|
||||
// } catch (error: any) {
|
||||
// console.error("❌ Magic Fill Error:", error.message);
|
||||
// return { success: false, error: error.message };
|
||||
// }
|
||||
// }
|
||||
export async function getMetadataPreviewAction(fileId: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) throw new Error("Unauthorized");
|
||||
|
||||
console.log(`\n--- 🔍 Magic Fill Started for File ID: ${fileId} ---`);
|
||||
|
||||
try {
|
||||
const node = await getFileNodeById(fileId);
|
||||
if (!node || !node.oneDriveId) throw new Error("File not found");
|
||||
if (!node || !node.oneDriveId) throw new Error("No OneDrive ID found");
|
||||
|
||||
// 1. Get the token from our utility
|
||||
const token = await getFreshAccessToken(session.user.id);
|
||||
console.log(`📡 Attempting fetch via Service for: ${node.name}`);
|
||||
|
||||
// LOG: Just check the length to be sure it's not empty
|
||||
console.log(`🔑 Token retrieved (Length: ${token.length})`);
|
||||
const token = await getFreshAccessToken(session.user.id);
|
||||
|
||||
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);
|
||||
|
||||
console.log("✅ RAW DATA EXTRACTED:");
|
||||
console.dir(extractedData, { depth: null, colors: true });
|
||||
|
||||
console.log("✅ Extracted:", extractedData);
|
||||
return { success: true, data: extractedData };
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("❌ Magic Fill Error:", error.message);
|
||||
console.error("❌ Service Fetch Error:", error.message);
|
||||
// If this still says ENOTFOUND, the code is fine, but the terminal is blocked.
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
'use client';
|
||||
|
||||
// src/app/dashboard-view.tsx
|
||||
import { useState } from "react";
|
||||
import { styled } from '@mui/material/styles';
|
||||
import {
|
||||
|
|
@ -21,6 +21,7 @@ import {
|
|||
QuickFilter,
|
||||
QuickFilterControl,
|
||||
QuickFilterClear,
|
||||
GridEventListener,
|
||||
} from "@mui/x-data-grid";
|
||||
import SyncIcon from "@mui/icons-material/Sync";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
|
|
@ -156,6 +157,14 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
|
|||
}
|
||||
};
|
||||
|
||||
// --- NEW: Double Click Handler ---
|
||||
const handleRowDoubleClick: GridEventListener<'rowDoubleClick'> = (params) => {
|
||||
// Only navigate if it's a file. If it's a folder, we could eventually navigate into it.
|
||||
if (!params.row.isFolder) {
|
||||
router.push(`/dashboard/files/${params.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: "name",
|
||||
|
|
@ -163,10 +172,12 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
|
|||
flex: 1.5,
|
||||
minWidth: 250,
|
||||
renderCell: (params) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, height: '100%' }}>
|
||||
{params.row.isFolder ? <FolderIcon sx={{ color: '#FFB020' }} /> : <InsertDriveFileIcon color="action" />}
|
||||
<Typography variant="body2">{params.value}</Typography>
|
||||
</Box>
|
||||
<Tooltip title={params.row.isFolder ? "" : "Double-click to view deep metadata"} arrow>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, height: '100%', cursor: 'pointer' }}>
|
||||
{params.row.isFolder ? <FolderIcon sx={{ color: '#FFB020' }} /> : <InsertDriveFileIcon color="action" />}
|
||||
<Typography variant="body2">{params.value}</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
|
|
@ -211,20 +222,20 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
|
|||
<Stack direction="row" spacing={0.5} justifyContent="flex-end" alignItems="center" sx={{ height: '100%' }}>
|
||||
{!isFolder && (
|
||||
<>
|
||||
<IconButton size="small" color="info" onClick={() => window.open(`/api/download?id=${params.row.id}&mode=inline`, '_blank')}>
|
||||
<IconButton size="small" color="info" onClick={(e) => { e.stopPropagation(); window.open(`/api/download?id=${params.row.id}&mode=inline`, '_blank'); }}>
|
||||
<OpenInNewIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton size="small" color="success" onClick={() => window.location.href = `/api/download?id=${params.row.id}&mode=attachment`}>
|
||||
<IconButton size="small" color="success" onClick={(e) => { e.stopPropagation(); window.location.href = `/api/download?id=${params.row.id}&mode=attachment`; }}>
|
||||
<DownloadIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
{(isAdmin || isOwner) && (
|
||||
<>
|
||||
<IconButton size="small" color="primary" onClick={() => router.push(`/update/${params.row.id}`)}>
|
||||
<IconButton size="small" color="primary" onClick={(e) => { e.stopPropagation(); router.push(`/update/${params.row.id}`); }}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton size="small" color="error" onClick={() => handleDelete(params.row.id, params.row.name)}>
|
||||
<IconButton size="small" color="error" onClick={(e) => { e.stopPropagation(); handleDelete(params.row.id, params.row.name); }}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</>
|
||||
|
|
@ -273,6 +284,7 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
|
|||
showToolbar
|
||||
slots={{ toolbar: CustomToolbar }}
|
||||
disableRowSelectionOnClick
|
||||
onRowDoubleClick={handleRowDoubleClick} // ADDED THIS HANDLER
|
||||
initialState={{
|
||||
columns: {
|
||||
columnVisibilityModel: {
|
||||
|
|
@ -280,7 +292,12 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
|
|||
},
|
||||
},
|
||||
}}
|
||||
sx={{ border: 'none' }}
|
||||
sx={{
|
||||
border: 'none',
|
||||
'& .MuiDataGrid-row:hover': {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
|
|||
132
src/app/dashboard/files/[id]/page.tsx
Normal file
132
src/app/dashboard/files/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,12 +1,11 @@
|
|||
'use client';
|
||||
|
||||
// src/app/update/[id]/update-view.tsx
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Box, Button, Typography, Paper, Stack,
|
||||
TextField, MenuItem, IconButton, Grid, Divider,
|
||||
Checkbox, CircularProgress, Chip, Tooltip
|
||||
TextField, MenuItem, IconButton, Grid,
|
||||
Checkbox, CircularProgress, Tooltip
|
||||
} from "@mui/material";
|
||||
import SaveIcon from "@mui/icons-material/Save";
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
|
|
@ -22,29 +21,37 @@ import { getMetadataPreviewAction } from "@/app/dashboard/actions";
|
|||
interface MetadataPair {
|
||||
key: string;
|
||||
value: string;
|
||||
selected: boolean; // Checkbox state
|
||||
isPending?: boolean; // Visual highlight for auto-extracted fields
|
||||
selected: boolean;
|
||||
isPending?: boolean;
|
||||
}
|
||||
|
||||
export default function UpdateView({
|
||||
fileNode,
|
||||
folders: availablefolders // Renaming 'folders' to 'availablefolders'
|
||||
}: {
|
||||
fileNode: any;
|
||||
folders: any[];
|
||||
}) {
|
||||
/** * Utility to turn nested objects into flat key-value pairs for the UI
|
||||
*/
|
||||
const flattenObject = (obj: any, prefix = ''): Record<string, string> => {
|
||||
let results: Record<string, string> = {};
|
||||
for (const key in obj) {
|
||||
const value = obj[key];
|
||||
const newKey = prefix ? `${prefix}.${key}` : key;
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
Object.assign(results, flattenObject(value, newKey));
|
||||
} else {
|
||||
results[newKey] = String(value);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
};
|
||||
|
||||
export default function UpdateView({ fileNode, folders: availablefolders }: { fileNode: any; folders: any[]; }) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isExtracting, setIsExtracting] = useState(false);
|
||||
|
||||
// 1. Initialize Basic Info
|
||||
const [name, setName] = useState(fileNode.name);
|
||||
const [description, setDescription] = useState(fileNode.description || "");
|
||||
const [parentId, setParentId] = useState(fileNode.parentId || "");
|
||||
|
||||
// 2. Initialize Metadata from DB (all checked by default)
|
||||
const initialMetadata: MetadataPair[] = Object.entries(fileNode.metadata || {})
|
||||
.filter(([key]) => !['type', 'mimeType'].includes(key))
|
||||
.filter(([key]) => !['type', 'mimeType', 'magicFilled', 'details'].includes(key))
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
value: String(value),
|
||||
|
|
@ -54,23 +61,23 @@ export default function UpdateView({
|
|||
|
||||
const [customMetadata, setCustomMetadata] = useState<MetadataPair[]>(initialMetadata);
|
||||
|
||||
// --- MAGIC FILL LOGIC ---
|
||||
const handleMagicEnhance = async () => {
|
||||
setIsExtracting(true);
|
||||
try {
|
||||
const result = await getMetadataPreviewAction(fileNode.id);
|
||||
if (result.success) {
|
||||
// Convert extracted JSON into pending rows
|
||||
const extractedRows: MetadataPair[] = Object.entries(result.data ?? {})
|
||||
.filter(([key]) => !['type', 'mimeType'].includes(key))
|
||||
// Flatten the nested 'details' and top level props
|
||||
const flatData = flattenObject(result.data);
|
||||
|
||||
const extractedRows: MetadataPair[] = Object.entries(flatData)
|
||||
.filter(([key]) => !['type', 'mimeType', 'title'].includes(key) && !key.includes('Binary Data'))
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
value: String(value),
|
||||
selected: true, // Default to checked as requested
|
||||
selected: true,
|
||||
isPending: true
|
||||
}));
|
||||
|
||||
// Merge logic: Add only if the key doesn't already exist in our list
|
||||
setCustomMetadata(prev => {
|
||||
const existingKeys = new Set(prev.map(r => r.key));
|
||||
const filteredNew = extractedRows.filter(r => !existingKeys.has(r.key));
|
||||
|
|
@ -78,7 +85,7 @@ export default function UpdateView({
|
|||
});
|
||||
}
|
||||
} catch (err) {
|
||||
alert("Failed to extract metadata. Ensure service is configured correctly.");
|
||||
alert("Failed to extract metadata.");
|
||||
} finally {
|
||||
setIsExtracting(false);
|
||||
}
|
||||
|
|
@ -92,7 +99,6 @@ export default function UpdateView({
|
|||
formData.append("description", description);
|
||||
formData.append("parentId", parentId);
|
||||
|
||||
// Convert array back to object, ONLY including selected/checked rows
|
||||
const metadataObj = customMetadata.reduce((acc, curr) => {
|
||||
if (curr.selected && curr.key.trim()) {
|
||||
acc[curr.key.trim()] = curr.value;
|
||||
|
|
@ -114,131 +120,77 @@ export default function UpdateView({
|
|||
|
||||
return (
|
||||
<Paper sx={{ p: { xs: 3, md: 6 }, borderRadius: 4 }} elevation={3}>
|
||||
<Button startIcon={<ArrowBackIcon />} onClick={() => router.back()} sx={{ mb: 2 }}>
|
||||
Back
|
||||
</Button>
|
||||
<Button startIcon={<ArrowBackIcon />} onClick={() => router.back()} sx={{ mb: 2 }}>Back</Button>
|
||||
|
||||
<Typography variant="h4" fontWeight={900} color="primary" gutterBottom>
|
||||
Edit File Details
|
||||
</Typography>
|
||||
<Typography variant="h4" fontWeight={900} color="primary" gutterBottom>Edit File Details</Typography>
|
||||
|
||||
{/* --- MAGIC FILL BUTTON --- */}
|
||||
<Box sx={{ mb: 4, p: 2, bgcolor: '#f0f7ff', borderRadius: 2, border: '1px dashed #1976d2' }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight="bold">Enrich Metadata</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Extract tags like GPS, Author, and Dimensions from the original file.
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Extract GPS, Camera Specs, and Dimensions.</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
variant="contained" color="secondary"
|
||||
startIcon={isExtracting ? <CircularProgress size={20} color="inherit" /> : <AutoFixHighIcon />}
|
||||
onClick={handleMagicEnhance}
|
||||
disabled={isExtracting}
|
||||
onClick={handleMagicEnhance} disabled={isExtracting}
|
||||
>
|
||||
{isExtracting ? 'Extracting...' : 'Magic Fill'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Stack spacing={4} sx={{ mt: 2 }}>
|
||||
<TextField
|
||||
label="File Name"
|
||||
fullWidth value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<Stack spacing={4}>
|
||||
<TextField label="File Name" fullWidth value={name} onChange={(e) => setName(e.target.value)} slotProps={{ inputLabel: { shrink: true } }} />
|
||||
|
||||
<TextField
|
||||
id="update-dest-select"
|
||||
select fullWidth label="Destination Folder"
|
||||
value={parentId}
|
||||
onChange={(e) => setParentId(e.target.value)}
|
||||
slotProps={{
|
||||
select: { displayEmpty: true },
|
||||
inputLabel: { shrink: true }
|
||||
}}
|
||||
>
|
||||
<TextField select fullWidth label="Destination" value={parentId} onChange={(e) => setParentId(e.target.value)}>
|
||||
<MenuItem value=""><em>-- Root --</em></MenuItem>
|
||||
{availablefolders?.map((f: any) => (
|
||||
<MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>
|
||||
))}
|
||||
{availablefolders?.map((f: any) => (<MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>))}
|
||||
</TextField>
|
||||
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<AssignmentIcon color="primary" /> Metadata Attributes
|
||||
</Typography>
|
||||
<Button
|
||||
startIcon={<AddCircleOutlineIcon />}
|
||||
size="small"
|
||||
onClick={() => setCustomMetadata([...customMetadata, { key: "", value: "", selected: true }])}
|
||||
>
|
||||
Add Field
|
||||
</Button>
|
||||
</Box>
|
||||
<Stack direction="row" justifyContent="space-between" mb={2}>
|
||||
<Typography variant="h6" fontWeight="700"><AssignmentIcon /> Attributes</Typography>
|
||||
<Button startIcon={<AddCircleOutlineIcon />} onClick={() => setCustomMetadata([...customMetadata, { key: "", value: "", selected: true }])}>Add Field</Button>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={2}>
|
||||
{customMetadata.map((row, index) => (
|
||||
<Box key={index}>
|
||||
<Grid container spacing={1} alignItems="center">
|
||||
<Grid size={{ xs: 1 }}>
|
||||
<Tooltip title={row.selected ? "Save this field" : "Ignore this field"}>
|
||||
<Checkbox
|
||||
checked={row.selected}
|
||||
onChange={(e) => {
|
||||
const updated = [...customMetadata];
|
||||
updated[index].selected = e.target.checked;
|
||||
setCustomMetadata(updated);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Grid item xs={1}>
|
||||
<Checkbox checked={row.selected} onChange={(e) => {
|
||||
const updated = [...customMetadata];
|
||||
updated[index].selected = e.target.checked;
|
||||
setCustomMetadata(updated);
|
||||
}} />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 4 }}>
|
||||
<TextField
|
||||
fullWidth size="small" placeholder="Key"
|
||||
value={row.key}
|
||||
disabled={row.isPending} // Usually best to keep extracted keys as-is
|
||||
sx={{ bgcolor: row.isPending ? '#e8f5e9' : 'transparent' }}
|
||||
<Grid item xs={4}>
|
||||
<TextField fullWidth size="small" value={row.key} disabled={row.isPending} sx={{ bgcolor: row.isPending ? '#e8f5e9' : 'transparent' }}
|
||||
onChange={(e) => {
|
||||
const updated = [...customMetadata];
|
||||
updated[index].key = e.target.value;
|
||||
setCustomMetadata(updated);
|
||||
}}
|
||||
/>
|
||||
}} />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6 }}>
|
||||
<TextField
|
||||
fullWidth size="small" placeholder="Value"
|
||||
value={row.value}
|
||||
sx={{ bgcolor: row.isPending ? '#e8f5e9' : 'transparent' }}
|
||||
<Grid item xs={6}>
|
||||
<TextField fullWidth size="small" value={row.value} sx={{ bgcolor: row.isPending ? '#e8f5e9' : 'transparent' }}
|
||||
onChange={(e) => {
|
||||
const updated = [...customMetadata];
|
||||
updated[index].value = e.target.value;
|
||||
setCustomMetadata(updated);
|
||||
}}
|
||||
/>
|
||||
}} />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 1 }}>
|
||||
<IconButton color="error" onClick={() => setCustomMetadata(customMetadata.filter((_, i) => i !== index))}>
|
||||
<DeleteOutlineIcon />
|
||||
</IconButton>
|
||||
<Grid item xs={1}>
|
||||
<IconButton color="error" onClick={() => setCustomMetadata(customMetadata.filter((_, i) => i !== index))}><DeleteOutlineIcon /></IconButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* --- GOOGLE MAPS SHORTCUT --- */}
|
||||
{row.key.toLowerCase().includes('latitude') && row.value && (
|
||||
<Box sx={{ ml: 6, mt: 0.5 }}>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<MapIcon />}
|
||||
href={`https://www.google.com/maps?q=${row.value},${customMetadata.find(m => m.key.toLowerCase().includes('longitude'))?.value}`}
|
||||
target="_blank"
|
||||
>
|
||||
Verify GPS on Map
|
||||
<Box sx={{ ml: 7, mt: 0.5 }}>
|
||||
<Button size="small" startIcon={<MapIcon />} target="_blank"
|
||||
href={`https://www.google.com/maps?q=${row.value},${customMetadata.find(m => m.key.toLowerCase().includes('longitude'))?.value}`}>
|
||||
View on Map
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
|
@ -247,21 +199,9 @@ export default function UpdateView({
|
|||
</Stack>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
label="Description"
|
||||
multiline rows={4} fullWidth
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<TextField label="Description" multiline rows={3} fullWidth value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||
|
||||
<Button
|
||||
variant="contained" size="large" fullWidth
|
||||
startIcon={<SaveIcon />}
|
||||
onClick={handleUpdate}
|
||||
disabled={loading}
|
||||
sx={{ py: 1.5, fontWeight: 'bold' }}
|
||||
>
|
||||
<Button variant="contained" size="large" fullWidth startIcon={<SaveIcon />} onClick={handleUpdate} disabled={loading}>
|
||||
{loading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
|
|
|||
|
|
@ -1,24 +1,28 @@
|
|||
'use server';
|
||||
|
||||
// src/app/upload/_actions.ts
|
||||
|
||||
//src/app/upload/_actions.ts)
|
||||
import { auth } from "@/auth";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { createFileNode } from "@/data-access/file-nodes";
|
||||
//import { createOneDriveFolder, uploadToFolderId, ensureOneDriveFolder } from "@/services/onedrive";
|
||||
|
||||
import { createFileNode, getAllFolders, upsertFileNodeByHash } from "@/data-access/file-nodes";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { createOneDriveFolder, uploadToFolderId, ensureOneDriveFolder } from "@/services/onedrive";
|
||||
|
||||
/**
|
||||
* 1. CREATE VIRTUAL FOLDER
|
||||
* FIXED: Changed findUnique to findFirst to avoid runtime database crashes
|
||||
*/
|
||||
export async function checkDuplicateAction(hash: string) {
|
||||
const existing = await prisma.fileNode.findFirst({
|
||||
where: { hash },
|
||||
select: { name: true, parentId: true }
|
||||
});
|
||||
return existing;
|
||||
}
|
||||
|
||||
export async function createFolderAction(name: string, parentId?: string | null) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) throw new Error("Unauthorized");
|
||||
|
||||
try {
|
||||
const internalId = crypto.randomUUID();
|
||||
// Swapped createNode for createFileNode
|
||||
const newNode = await createFileNode({
|
||||
id: internalId,
|
||||
oneDriveId: null,
|
||||
|
|
@ -36,14 +40,13 @@ export async function createFolderAction(name: string, parentId?: string | null)
|
|||
throw new Error(error.message || "Failed to create virtual folder");
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 2. UPLOAD FILE (Physical UUID Folder)
|
||||
*/
|
||||
|
||||
export async function uploadFileAction(formData: FormData) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) throw new Error("Unauthorized");
|
||||
|
||||
const file = formData.get("file") as File;
|
||||
const hash = formData.get("hash") as string;
|
||||
const description = formData.get("description") as string || "";
|
||||
const parentIdRaw = formData.get("parentId") as string | null;
|
||||
const parentId = (parentIdRaw === "" || parentIdRaw === "root") ? null : parentIdRaw;
|
||||
|
|
@ -54,41 +57,33 @@ export async function uploadFileAction(formData: FormData) {
|
|||
if (!file) throw new Error("No file selected");
|
||||
|
||||
const rootFolder = "WebCalibre";
|
||||
const internalId = crypto.randomUUID(); // Used for both DB ID and OneDrive Folder Name
|
||||
const internalId = crypto.randomUUID();
|
||||
|
||||
try {
|
||||
// A. Ensure root exists
|
||||
await ensureOneDriveFolder(session.user.id, rootFolder);
|
||||
|
||||
// B. Create the physical UUID folder on OneDrive
|
||||
const subFolderRes = await createOneDriveFolder(session.user.id, rootFolder, internalId);
|
||||
const subFolderData = await subFolderRes.json();
|
||||
|
||||
// C. Upload the file binary into that specific folder
|
||||
const uploadedFileData = await uploadToFolderId(session.user.id, file, subFolderData.id);
|
||||
|
||||
// D. Create record in Database
|
||||
// src/app/upload/_actions.ts
|
||||
|
||||
// ... inside uploadFileAction or createFolderAction ...
|
||||
// ... inside uploadFileAction after OneDrive work is done ...
|
||||
|
||||
await createFileNode({
|
||||
id: internalId,
|
||||
oneDriveId: uploadedFileData.id,
|
||||
name: file.name,
|
||||
description: description,
|
||||
size: BigInt(file.size),
|
||||
isFolder: false,
|
||||
path: `/${rootFolder}/${internalId}/${file.name}`,
|
||||
ownerId: session.user.id,
|
||||
parentId: parentId,
|
||||
metadata: {
|
||||
...customMetadata, // User's custom keys from the form
|
||||
type: file.name.split('.').pop()?.toUpperCase() || "UNKNOWN",
|
||||
mimeType: file.type
|
||||
}
|
||||
});
|
||||
await createFileNode({
|
||||
id: internalId,
|
||||
oneDriveId: uploadedFileData.id,
|
||||
name: file.name,
|
||||
hash: hash,
|
||||
description: description,
|
||||
size: BigInt(file.size),
|
||||
isFolder: false,
|
||||
path: `/${rootFolder}/${internalId}/${file.name}`,
|
||||
ownerId: session.user.id,
|
||||
parentId: parentId,
|
||||
metadata: {
|
||||
...customMetadata,
|
||||
type: file.name.split('.').pop()?.toUpperCase() || "UNKNOWN",
|
||||
mimeType: file.type
|
||||
}
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/upload");
|
||||
|
|
@ -97,4 +92,74 @@ await createFileNode({
|
|||
console.error("Upload refactor error:", error);
|
||||
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."
|
||||
};
|
||||
}
|
||||
}
|
||||
243
src/app/upload/bulk/page.tsx
Normal file
243
src/app/upload/bulk/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,30 +1,181 @@
|
|||
import { auth } from "@/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import UploadView from "./upload-view"; // This is the Client Component
|
||||
import { Container } from "@mui/material";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
// src/app/upload/bulk/page.tsx
|
||||
|
||||
// 1. Rename to UploadPage to avoid conflict with the 'UploadView' import
|
||||
// 2. Add 'async' so you can use 'await' inside
|
||||
export default async function UploadPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session) redirect("/");
|
||||
"use client";
|
||||
|
||||
// 3. Fetch folders. Renamed variable to 'allFolders' to avoid any confusion
|
||||
const allFolders = await prisma.fileNode.findMany({
|
||||
where: {
|
||||
isFolder: true,
|
||||
ownerId: session.user.id // Good practice: only show user's own folders
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
select: { id: true, name: true, parentId: true }
|
||||
});
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import {
|
||||
Box, Button, Typography, Paper, Table, TableBody,
|
||||
TableCell, TableContainer, TableHead, TableRow, LinearProgress, Chip,
|
||||
Alert, Stack
|
||||
} from '@mui/material';
|
||||
import FolderIcon from '@mui/icons-material/Folder';
|
||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||
|
||||
// --- OUR UTILITIES ---
|
||||
import { calculateFileHash } from '@/lib/hashing-client';
|
||||
import { checkDuplicateAction } from '@/app/upload/_actions';
|
||||
|
||||
interface UploadQueueItem {
|
||||
id: string;
|
||||
file: File;
|
||||
path: string;
|
||||
hash: string | null;
|
||||
status: 'queued' | 'hashing' | 'checking' | 'ready' | 'uploading' | 'success' | 'duplicate' | 'error';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default function BulkUploadPage() {
|
||||
const [queue, setQueue] = useState<UploadQueueItem[]>([]);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
// 1. Handle File & Folder Drops
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
const newItems = acceptedFiles.map(file => ({
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
path: (file as any).path || file.name, // Captures subfolder structure
|
||||
hash: null,
|
||||
status: 'queued' as const,
|
||||
}));
|
||||
setQueue(prev => [...prev, ...newItems]);
|
||||
}, []);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop });
|
||||
|
||||
// 2. The "Processing Engine"
|
||||
// This effect runs whenever the queue changes or isProcessing toggles
|
||||
useEffect(() => {
|
||||
if (!isProcessing) return;
|
||||
|
||||
const runQueue = async () => {
|
||||
// Find the next file that hasn't been hashed/checked yet
|
||||
const nextIndex = queue.findIndex(item => item.status === 'queued');
|
||||
|
||||
if (nextIndex === -1) {
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const item = queue[nextIndex];
|
||||
|
||||
try {
|
||||
// Step A: Hashing
|
||||
updateItem(item.id, { status: 'hashing' });
|
||||
const hash = await calculateFileHash(item.file);
|
||||
|
||||
// Step B: Duplicate Check
|
||||
updateItem(item.id, { status: 'checking', hash });
|
||||
const existing = await checkDuplicateAction(hash);
|
||||
|
||||
// Step C: Mark Results
|
||||
updateItem(item.id, {
|
||||
status: existing ? 'duplicate' : 'ready'
|
||||
});
|
||||
} catch (err) {
|
||||
updateItem(item.id, { status: 'error', error: 'Process failed' });
|
||||
}
|
||||
};
|
||||
|
||||
runQueue();
|
||||
}, [queue, isProcessing]);
|
||||
|
||||
const updateItem = (id: string, updates: Partial<UploadQueueItem>) => {
|
||||
setQueue(current => current.map(item => item.id === id ? { ...item, ...updates } : item));
|
||||
};
|
||||
|
||||
const duplicateCount = queue.filter(i => i.status === 'duplicate').length;
|
||||
|
||||
return (
|
||||
<Container maxWidth="md" sx={{ py: 8 }}>
|
||||
{/* 4. Render the Client Component and pass the data */}
|
||||
<UploadView user={session.user} folders={allFolders} />
|
||||
</Container>
|
||||
<Box sx={{ p: 4, maxWidth: 1200, mx: 'auto' }}>
|
||||
<Typography variant="h4" fontWeight={800} color="primary" gutterBottom>
|
||||
Bulk Uploads & Restore
|
||||
</Typography>
|
||||
|
||||
<Paper
|
||||
{...getRootProps()}
|
||||
sx={{
|
||||
p: 6, mb: 4, textAlign: 'center', cursor: 'pointer',
|
||||
border: '2px dashed', borderColor: isDragActive ? 'primary.main' : 'divider',
|
||||
bgcolor: isDragActive ? 'primary.50' : 'background.paper',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<input {...getInputProps()} {...({ webkitdirectory: "", directory: "" } as any)} />
|
||||
<FolderIcon sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
|
||||
<Typography variant="h6">Drop Folders or Files Here</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Perfect for camera imports or full system restores
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{duplicateCount > 0 && (
|
||||
<Alert severity="warning" sx={{ mb: 3 }}>
|
||||
{duplicateCount} duplicate(s) found. These files already exist in your library.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{queue.length > 0 && (
|
||||
<TableContainer component={Paper} sx={{ maxHeight: 500, borderRadius: 2 }}>
|
||||
<Table stickyHeader size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Location / Path</TableCell>
|
||||
<TableCell>Size</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>SHA-256 Hash</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{queue.map((item) => (
|
||||
<TableRow key={item.id} hover>
|
||||
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||
{item.path}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{(item.file.size / 1024 / 1024).toFixed(2)} MB
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={item.status.toUpperCase()}
|
||||
size="small"
|
||||
color={
|
||||
item.status === 'duplicate' ? 'warning' :
|
||||
item.status === 'ready' ? 'info' :
|
||||
item.status === 'success' ? 'success' : 'default'
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontSize: '0.7rem', color: 'text.secondary' }}>
|
||||
{item.hash ? `${item.hash.substring(0, 16)}...` : '---'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
<Stack direction="row" spacing={2} sx={{ mt: 4 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={isProcessing ? <LinearProgress sx={{ width: 20 }} /> : <CloudUploadIcon />}
|
||||
disabled={isProcessing || queue.length === 0}
|
||||
onClick={() => setIsProcessing(true)}
|
||||
>
|
||||
{isProcessing ? 'Analyzing...' : `Analyze ${queue.length} Files`}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
disabled={isProcessing}
|
||||
onClick={() => setQueue([])}
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,12 +1,14 @@
|
|||
'use client';
|
||||
|
||||
// src/app/upload/upload-view.tsx
|
||||
import { useState, useRef } from "react";
|
||||
import {
|
||||
Box, Button, Typography, Paper, Stack,
|
||||
TextField, IconButton, Divider,
|
||||
Grid,
|
||||
CircularProgress, Checkbox, MenuItem,
|
||||
InputAdornment, Collapse
|
||||
Collapse,
|
||||
Dialog, DialogTitle, DialogContent,
|
||||
DialogContentText, DialogActions
|
||||
} from "@mui/material";
|
||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
||||
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
|
||||
|
|
@ -15,9 +17,12 @@ import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
|||
import CreateNewFolderIcon from '@mui/icons-material/CreateNewFolder';
|
||||
import AssignmentIcon from '@mui/icons-material/Assignment';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { getMetadataPreviewAction } from "@/app/dashboard/actions";
|
||||
import { uploadFileAction, createFolderAction } from "./_actions";
|
||||
import { calculateFileHash } from "@/lib/hashing-client";
|
||||
import { uploadFileAction, createFolderAction, checkDuplicateAction } from "./_actions";
|
||||
|
||||
interface MetadataRow {
|
||||
key: string;
|
||||
|
|
@ -26,37 +31,40 @@ interface MetadataRow {
|
|||
selected?: boolean;
|
||||
}
|
||||
|
||||
export default function UploadView({ user, folders }: { user: any; folders: any[] }) {
|
||||
export default function UploadView({ folders }: { user: any; folders: any[] }) {
|
||||
const router = useRouter();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Form State
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [targetFolderId, setTargetFolderId] = useState<string>("");
|
||||
const [showNewFolderInput, setShowNewFolderInput] = useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState("");
|
||||
const [rows, setRows] = useState<MetadataRow[]>([]);
|
||||
|
||||
// UI Status State
|
||||
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;
|
||||
|
||||
// --- 1. MAGIC EXTRACTION LOGIC ---
|
||||
// --- 1. MAGIC EXTRACTION ---
|
||||
const handleMagicEnhance = async () => {
|
||||
if (!selectedFile) return;
|
||||
|
||||
setIsExtracting(true);
|
||||
try {
|
||||
const result = await getMetadataPreviewAction(selectedFile.name);
|
||||
|
||||
if (result.success) {
|
||||
const extractedRows: MetadataRow[] = Object.entries(result.data ?? {}).map(([k, v]) => ({
|
||||
key: k,
|
||||
value: String(v),
|
||||
value: typeof v === 'object' ? JSON.stringify(v) : String(v),
|
||||
isPending: true,
|
||||
selected: true
|
||||
}));
|
||||
|
||||
setRows(prev => {
|
||||
const existingKeys = new Set(prev.map(r => r.key));
|
||||
const newUniqueRows = extractedRows.filter(r => !existingKeys.has(r.key));
|
||||
|
|
@ -72,35 +80,32 @@ export default function UploadView({ user, folders }: { user: any; folders: any[
|
|||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setSelectedFile(file);
|
||||
}
|
||||
if (file) setSelectedFile(file);
|
||||
};
|
||||
|
||||
// --- 2. SAVE / UPLOAD LOGIC ---
|
||||
const handleSave = async () => {
|
||||
if (!canSubmit) return;
|
||||
// --- 2. UPLOAD EXECUTION ---
|
||||
const executeUpload = async (preCalculatedHash?: string) => {
|
||||
setSaveStatus('saving');
|
||||
|
||||
try {
|
||||
let currentParentId = targetFolderId;
|
||||
|
||||
// STEP A: Create Folder if user typed a new folder name
|
||||
// STEP A: Handle New Folder Creation
|
||||
if (newFolderName.trim()) {
|
||||
const folderResult = await createFolderAction(newFolderName, targetFolderId || null);
|
||||
if (folderResult.success) {
|
||||
// If successful, we want the file to go inside this NEW folder
|
||||
currentParentId = folderResult.node.id;
|
||||
} else {
|
||||
throw new Error(folderResult.error || "Failed to create folder");
|
||||
}
|
||||
}
|
||||
|
||||
// STEP B: Upload File if a file is selected
|
||||
// STEP B: Handle File Upload
|
||||
if (selectedFile) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", selectedFile);
|
||||
formData.append("hash", preCalculatedHash || "");
|
||||
formData.append("parentId", currentParentId || "root");
|
||||
|
||||
// Construct Metadata Object
|
||||
const metadataObject = rows
|
||||
.filter(r => r.selected && r.key.trim() !== "")
|
||||
.reduce((acc, curr) => {
|
||||
|
|
@ -111,200 +116,230 @@ export default function UploadView({ user, folders }: { user: any; folders: any[
|
|||
formData.append("customMetadata", JSON.stringify(metadataObject));
|
||||
|
||||
const uploadResult = await uploadFileAction(formData);
|
||||
|
||||
if (!uploadResult.success) {
|
||||
throw new Error(uploadResult.error || "Upload failed");
|
||||
}
|
||||
if (!uploadResult.success) throw new Error(uploadResult.error || "Upload failed");
|
||||
}
|
||||
|
||||
console.log("✅ Process complete. Returning to dashboard.");
|
||||
router.push("/dashboard");
|
||||
router.refresh();
|
||||
} catch (err: any) {
|
||||
console.error("Save failed:", err);
|
||||
alert(err.message || "An error occurred while saving.");
|
||||
} finally {
|
||||
setSaveStatus('idle');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: { xs: 3, md: 6 }, borderRadius: 4, maxWidth: 800, mx: 'auto' }} elevation={3}>
|
||||
<Typography variant="h4" fontWeight={900} color="primary" gutterBottom align="center">
|
||||
Upload & Enrich
|
||||
</Typography>
|
||||
// --- 3. SAVE HANDLER (With Hash Intercept) ---
|
||||
const handleSave = async () => {
|
||||
if (!canSubmit) return;
|
||||
|
||||
if (selectedFile) {
|
||||
setSaveStatus('hashing');
|
||||
// Calculate local SHA-256
|
||||
const fileHash = await calculateFileHash(selectedFile);
|
||||
// Check database via Server Action
|
||||
const duplicate = await checkDuplicateAction(fileHash);
|
||||
|
||||
<Stack spacing={3} sx={{ mt: 4, mb: 4 }}>
|
||||
{/* FOLDER SELECTION */}
|
||||
<Box>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<TextField
|
||||
select
|
||||
fullWidth
|
||||
label="Destination Folder"
|
||||
value={targetFolderId}
|
||||
onChange={(e) => {
|
||||
setTargetFolderId(e.target.value);
|
||||
if (e.target.value) setShowNewFolderInput(false);
|
||||
}}
|
||||
>
|
||||
<MenuItem value=""><em>-- Root Directory --</em></MenuItem>
|
||||
{folders?.map((f) => (
|
||||
<MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
if (duplicate) {
|
||||
setDuplicateInfo({ name: duplicate.name, hash: fileHash });
|
||||
setDuplicateDialogOpen(true);
|
||||
return; // Dialog takes over from here
|
||||
}
|
||||
|
||||
await executeUpload(fileHash);
|
||||
} else {
|
||||
await executeUpload(); // Folder only
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Paper sx={{ p: { xs: 3, md: 6 }, borderRadius: 4, maxWidth: 800, mx: 'auto', mt: 4 }} elevation={3}>
|
||||
<Typography variant="h4" fontWeight={900} color="primary" gutterBottom align="center">
|
||||
Upload & Enrich
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={3} sx={{ mt: 4, mb: 4 }}>
|
||||
{/* FOLDER SELECTION */}
|
||||
<Box>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<TextField
|
||||
select fullWidth label="Parent Destination"
|
||||
value={targetFolderId}
|
||||
onChange={(e) => setTargetFolderId(e.target.value)}
|
||||
helperText="Choose where your file 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
|
||||
variant={showNewFolderInput ? "contained" : "outlined"}
|
||||
onClick={() => {
|
||||
setShowNewFolderInput(!showNewFolderInput);
|
||||
if (!showNewFolderInput) setTargetFolderId("");
|
||||
}}
|
||||
sx={{ height: 56, minWidth: 56 }}
|
||||
variant="contained" onClick={handleMagicEnhance}
|
||||
disabled={!selectedFile || isExtracting}
|
||||
startIcon={isExtracting ? <CircularProgress size={20} color="inherit" /> : <AutoFixHighIcon />}
|
||||
sx={{ borderRadius: 20, px: 3 }}
|
||||
>
|
||||
<CreateNewFolderIcon />
|
||||
{isExtracting ? "Extracting..." : "Run"}
|
||||
</Button>
|
||||
</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>
|
||||
|
||||
{/* 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 }}
|
||||
>
|
||||
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) => {
|
||||
{/* METADATA PREVIEW */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" fontWeight="700" sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<AssignmentIcon color="primary" /> Metadata Fields
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
{rows.map((row, index) => (
|
||||
<Grid container spacing={1} key={index} alignItems="center">
|
||||
<Grid item xs={1}>
|
||||
<Checkbox checked={row.selected} size="small" onChange={(e) => {
|
||||
const updated = [...rows];
|
||||
updated[index].selected = e.target.checked;
|
||||
setRows(updated);
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={5}>
|
||||
<TextField
|
||||
fullWidth size="small" label="Key" value={row.key}
|
||||
onChange={(e) => {
|
||||
}} />
|
||||
</Grid>
|
||||
<Grid item xs={5}>
|
||||
<TextField fullWidth size="small" label="Key" value={row.key} onChange={(e) => {
|
||||
const updated = [...rows];
|
||||
updated[index].key = e.target.value;
|
||||
setRows(updated);
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={5}>
|
||||
<TextField
|
||||
fullWidth size="small" label="Value" value={row.value}
|
||||
onChange={(e) => {
|
||||
}} />
|
||||
</Grid>
|
||||
<Grid item xs={5}>
|
||||
<TextField fullWidth size="small" label="Value" value={row.value} onChange={(e) => {
|
||||
const updated = [...rows];
|
||||
updated[index].value = e.target.value;
|
||||
setRows(updated);
|
||||
}}
|
||||
/>
|
||||
}} />
|
||||
</Grid>
|
||||
<Grid item xs={1}>
|
||||
<IconButton onClick={() => setRows(rows.filter((_, i) => i !== index))} color="error">
|
||||
<DeleteOutlineIcon />
|
||||
</IconButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid size={1}>
|
||||
<IconButton onClick={() => setRows(rows.filter((_, i) => i !== index))} color="error">
|
||||
<DeleteOutlineIcon />
|
||||
</IconButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
))}
|
||||
<Button
|
||||
variant="text"
|
||||
startIcon={<AddCircleOutlineIcon />}
|
||||
onClick={() => setRows([...rows, {key: "", value: "", selected: true}])}
|
||||
>
|
||||
Add Manual Field
|
||||
</Button>
|
||||
</Stack>
|
||||
))}
|
||||
<Button variant="text" startIcon={<AddCircleOutlineIcon />} onClick={() => setRows([...rows, {key: "", value: "", selected: true}])}>
|
||||
Add Manual Field
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* FINAL BUTTON */}
|
||||
<Button
|
||||
variant="contained" size="large" fullWidth onClick={handleSave}
|
||||
disabled={!canSubmit || saveStatus !== 'idle'}
|
||||
sx={{ py: 2, fontWeight: 'bold', borderRadius: 2, boxShadow: 4 }}
|
||||
>
|
||||
{saveStatus === 'hashing' ? <CircularProgress size={24} color="inherit" /> :
|
||||
saveStatus === 'saving' ? "Uploading to OneDrive..." :
|
||||
"Complete Upload & Save"}
|
||||
</Button>
|
||||
</Paper>
|
||||
|
||||
{/* --- DUPLICATE ALERT DIALOG --- */}
|
||||
<Dialog
|
||||
open={duplicateDialogOpen}
|
||||
onClose={() => { setDuplicateDialogOpen(false); setSaveStatus('idle'); }}
|
||||
PaperProps={{ sx: { borderRadius: 3, p: 1, maxWidth: 450 } }}
|
||||
>
|
||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1, color: 'warning.dark', fontWeight: 'bold' }}>
|
||||
<WarningAmberIcon fontSize="large" /> Duplicate Content
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{/* FIX: Added component="div" here.
|
||||
This prevents the "<div> cannot be a descendant of <p>" error
|
||||
*/}
|
||||
<DialogContentText component="div">
|
||||
The file you selected has exactly the same content as a file already in your library:
|
||||
|
||||
<Box sx={{ mt: 2, p: 2, bgcolor: 'warning.50', border: '1px solid', borderColor: 'warning.light', borderRadius: 2, color: 'text.primary', fontWeight: '600' }}>
|
||||
{duplicateInfo?.name}
|
||||
</Box>
|
||||
|
||||
{/* UPLOAD BUTTON */}
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
fullWidth
|
||||
onClick={handleSave}
|
||||
disabled={!canSubmit || saveStatus === 'saving'}
|
||||
sx={{ py: 2, fontWeight: 'bold' }}
|
||||
>
|
||||
{saveStatus === 'saving' ? (
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<CircularProgress size={24} color="inherit" />
|
||||
<Typography>Processing Upload...</Typography>
|
||||
</Stack>
|
||||
) : (
|
||||
"Complete Upload & Save"
|
||||
)}
|
||||
</Button>
|
||||
</Paper>
|
||||
<Typography variant="body2" sx={{ mt: 2, color: 'text.secondary' }}>
|
||||
Would you like to skip this upload or create a second copy?
|
||||
</Typography>
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ p: 2, gap: 1 }}>
|
||||
<Button
|
||||
onClick={() => { setDuplicateDialogOpen(false); setSaveStatus('idle'); }}
|
||||
variant="outlined" color="inherit" fullWidth
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => { setDuplicateDialogOpen(false); executeUpload(duplicateInfo?.hash); }}
|
||||
variant="contained" color="warning" fullWidth
|
||||
>
|
||||
Upload Anyway
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import LibraryBooksIcon from '@mui/icons-material/LibraryBooks';
|
||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import CloudSyncIcon from '@mui/icons-material/CloudSync'; // Great icon for Bulk/Restore
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import Link from 'next/link';
|
||||
|
|
@ -44,6 +45,7 @@ export default function Navbar({ user }: NavbarProps) {
|
|||
{ text: 'Dashboard', icon: <DashboardIcon />, href: '/dashboard' },
|
||||
{ text: 'Library', icon: <LibraryBooksIcon />, href: '/library' },
|
||||
{ text: 'Upload File', icon: <CloudUploadIcon />, href: '/upload' },
|
||||
{ text: 'Bulk Upload / Restore', icon: <CloudSyncIcon />, href: '/upload/bulk' },
|
||||
];
|
||||
|
||||
// Only push Settings if the user has Admin rights
|
||||
|
|
|
|||
|
|
@ -5,12 +5,6 @@ import { getOneDriveFileBuffer } from "@/services/onedrive";
|
|||
import { extractMetadata } from "@/lib/metadata-extractor";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
|
||||
/**
|
||||
* FETCH: Retrieve all nodes for the dashboard.
|
||||
* Centralizing this here allows us to change sort order or filters
|
||||
* in one place for the entire application.
|
||||
*/
|
||||
export async function getAllFileNodes() {
|
||||
return await prisma.fileNode.findMany({
|
||||
orderBy: {
|
||||
|
|
@ -19,20 +13,12 @@ export async function getAllFileNodes() {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* FETCH: Get a single node by ID.
|
||||
* Used by the Download route and Update pages to verify a file exists.
|
||||
*/
|
||||
export async function getFileNodeById(id: string) {
|
||||
return await prisma.fileNode.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* UPDATE: Modify metadata, name, or virtual location.
|
||||
* This function accepts the data object to keep the DAL flexible.
|
||||
*/
|
||||
export async function updateFileNode(id: string, data: any) {
|
||||
return await prisma.fileNode.update({
|
||||
where: { id },
|
||||
|
|
@ -43,48 +29,42 @@ export async function updateFileNode(id: string, data: any) {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE: Remove the record from the database.
|
||||
* Cloud deletion should be handled by the Service Layer before calling this.
|
||||
*/
|
||||
export async function deleteFileNode(id: string) {
|
||||
return await prisma.fileNode.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* MASTER CREATE: Handles both standard uploads and virtual folders.
|
||||
* If no ID is provided, it generates a fresh UUID.
|
||||
*/
|
||||
export async function createFileNode(data: {
|
||||
id?: string; // Optional: used for virtual folders/UUID storage
|
||||
id?: string;
|
||||
oneDriveId: string | null;
|
||||
name: string;
|
||||
hash?: string | null;
|
||||
description?: string;
|
||||
isFolder: boolean;
|
||||
path: string;
|
||||
ownerId: string;
|
||||
parentId?: string | null; // Optional: for nested structures
|
||||
parentId?: string | null;
|
||||
size?: bigint;
|
||||
metadata: any;
|
||||
}) {
|
||||
return await prisma.fileNode.create({
|
||||
data: {
|
||||
...data,
|
||||
id: data.id ?? crypto.randomUUID(), // Use provided ID or generate new one
|
||||
id: data.id ?? crypto.randomUUID(),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ... other functions (getAllFileNodes, etc)
|
||||
|
||||
/**
|
||||
* UPSERT: Create or Update a file node based on OneDrive ID
|
||||
* Moved here because it interacts with the Database.
|
||||
*/
|
||||
export async function upsertFileNode(oneDriveId: string, data: any) {
|
||||
export async function upsertFileNode(oneDriveId: string, data: {
|
||||
name: string;
|
||||
size: bigint;
|
||||
isFolder: boolean;
|
||||
path: string;
|
||||
ownerId: string;
|
||||
metadata: any;
|
||||
hash?: string | null;
|
||||
}) {
|
||||
return await prisma.fileNode.upsert({
|
||||
where: { oneDriveId },
|
||||
update: {
|
||||
|
|
@ -92,6 +72,7 @@ export async function upsertFileNode(oneDriveId: string, data: any) {
|
|||
size: data.size,
|
||||
isFolder: data.isFolder,
|
||||
path: data.path,
|
||||
hash: data.hash,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
|
|
@ -103,29 +84,73 @@ export async function upsertFileNode(oneDriveId: string, data: any) {
|
|||
path: data.path,
|
||||
ownerId: data.ownerId,
|
||||
metadata: data.metadata,
|
||||
hash: data.hash,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export async function getAllFolders() {
|
||||
return await prisma.fileNode.findMany({
|
||||
where: {
|
||||
isFolder: true
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Logic to coordinate getting a file from the cloud and extracting its data.
|
||||
* This is the "Brain" function for your metadata enrichment.
|
||||
* UPSERT BY HASH: FIXED FOR SYSTEM STABILITY
|
||||
*/
|
||||
export async function getEnrichedMetadataFromCloud(fileId: string) {
|
||||
// 1. Get the record from our DB so we know the filename (needed for extension logic)
|
||||
const node = await prisma.fileNode.findUnique({
|
||||
where: { id: fileId }
|
||||
export async function upsertFileNodeByHash(data: {
|
||||
name: string;
|
||||
hash: string;
|
||||
oneDriveId: string;
|
||||
parentId?: string | null;
|
||||
size: bigint;
|
||||
ownerId: string;
|
||||
}) {
|
||||
// Use findFirst instead of findUnique to protect against schema constraints
|
||||
const existing = await prisma.fileNode.findFirst({
|
||||
where: { hash: data.hash }
|
||||
});
|
||||
|
||||
if (!node) throw new Error("File not found in database.");
|
||||
if (existing) {
|
||||
// 🛡️ DISASTER RECOVERY MODE (RESTORE)
|
||||
const updatedNode = await prisma.fileNode.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
oneDriveId: data.oneDriveId,
|
||||
parentId: data.parentId || existing.parentId,
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
});
|
||||
return { node: updatedNode, mode: 'restored' as const };
|
||||
}
|
||||
|
||||
// 2. Fetch the bytes using the service we just created
|
||||
const buffer = await getOneDriveFileBuffer(fileId);
|
||||
|
||||
// 3. Extract internal metadata (Title, Author, or GPS coordinates)
|
||||
const deepMetadata = await extractMetadata(buffer, node.name);
|
||||
|
||||
return deepMetadata;
|
||||
// ✨ NEW UPLOAD MODE
|
||||
const fileExtension = data.name.split('.').pop()?.toUpperCase() || "UNKNOWN";
|
||||
const newNode = await prisma.fileNode.create({
|
||||
data: {
|
||||
id: crypto.randomUUID(),
|
||||
oneDriveId: data.oneDriveId,
|
||||
name: data.name,
|
||||
hash: data.hash,
|
||||
size: data.size,
|
||||
isFolder: false,
|
||||
ownerId: data.ownerId,
|
||||
parentId: data.parentId || null,
|
||||
path: `/WebCalibre/Bulk/${data.name}`,
|
||||
metadata: {
|
||||
type: fileExtension,
|
||||
mimeType: "application/octet-stream"
|
||||
},
|
||||
}
|
||||
});
|
||||
return { node: newNode, mode: 'created' as const };
|
||||
}
|
||||
9
src/lib/hashing-client.ts
Normal file
9
src/lib/hashing-client.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// src/lib/hashing-client.ts
|
||||
// src/lib/hashing-client.ts
|
||||
export async function calculateFileHash(file: File): Promise<string> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
// Native browser API (SubtleCrypto)
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
9
src/lib/hashing.ts
Normal file
9
src/lib/hashing.ts
Normal 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');
|
||||
}
|
||||
83
src/lib/metadata-extractor-old-2.ts
Normal file
83
src/lib/metadata-extractor-old-2.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
145
src/lib/metadata-extractor-old.ts
Normal file
145
src/lib/metadata-extractor-old.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,92 +1,117 @@
|
|||
import * as pdf from 'pdf-parse';
|
||||
import EPub from 'epub';
|
||||
// src/lib/metadata-extractor.ts
|
||||
// src/lib/metadata-extractor.ts
|
||||
import * as PdfParse from 'pdf-parse-new';
|
||||
import sharp from 'sharp';
|
||||
import exifReader from 'exif-reader';
|
||||
import EPub from 'epub2'; // New Import
|
||||
|
||||
export interface ExtractedMetadata {
|
||||
title?: string;
|
||||
author?: string;
|
||||
subject?: string;
|
||||
dimensions?: string;
|
||||
pageCount?: number;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
type: string;
|
||||
/**
|
||||
* Converts EXIF DMS array to Decimal Degrees.
|
||||
*/
|
||||
function convertDMSToDD(dms: any, ref: string): string {
|
||||
if (!Array.isArray(dms) || dms.length < 3) return String(dms);
|
||||
const [degrees, minutes, seconds] = dms;
|
||||
let dd = degrees + (minutes / 60) + (seconds / 3600);
|
||||
if (ref === 'S' || ref === 'W') dd = dd * -1;
|
||||
return dd.toFixed(6);
|
||||
}
|
||||
|
||||
export async function extractMetadata(buffer: Buffer, filename: string): Promise<ExtractedMetadata> {
|
||||
/**
|
||||
* RECURSIVE SANITIZER
|
||||
*/
|
||||
function sanitizeMetadata(obj: any): any {
|
||||
if (obj === null || typeof obj !== 'object') return obj;
|
||||
if (obj instanceof Date) return obj.toISOString();
|
||||
if (Buffer.isBuffer(obj)) return `[Binary Data: ${obj.length} bytes]`;
|
||||
if (Array.isArray(obj)) return obj.map(sanitizeMetadata);
|
||||
|
||||
const sanitized: any = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const cleanKey = key.charAt(0).toLowerCase() + key.slice(1);
|
||||
|
||||
if (cleanKey === 'gPSLatitude' && obj['gPSLatitudeRef']) {
|
||||
sanitized[cleanKey] = convertDMSToDD(value, obj['gPSLatitudeRef']);
|
||||
continue;
|
||||
}
|
||||
if (cleanKey === 'gPSLongitude' && obj['gPSLongitudeRef']) {
|
||||
sanitized[cleanKey] = convertDMSToDD(value, obj['gPSLongitudeRef']);
|
||||
continue;
|
||||
}
|
||||
|
||||
sanitized[cleanKey] = sanitizeMetadata(value);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export async function extractMetadata(buffer: Buffer, filename: string): Promise<any> {
|
||||
const extension = filename.split('.').pop()?.toLowerCase();
|
||||
|
||||
try {
|
||||
// --- 1. PDF EXTRACTION ---
|
||||
if (extension === 'pdf') {
|
||||
// Use any to bypass the missing 'default' property error in ESM
|
||||
const parsePdf = (pdf as any).default || pdf;
|
||||
const data = await parsePdf(buffer);
|
||||
|
||||
if (extension === 'pdf') {
|
||||
const parser = new PdfParse.SmartPDFParser({ oversaturationFactor: 2.0, enableFastPath: true });
|
||||
const result = await parser.parse(buffer);
|
||||
return {
|
||||
type: 'PDF',
|
||||
title: data.info?.Title || filename,
|
||||
author: data.info?.Author,
|
||||
subject: data.info?.Subject,
|
||||
pageCount: data.numpages,
|
||||
title: filename,
|
||||
pageCount: result.numpages || 0,
|
||||
details: sanitizeMetadata(result.info || {}),
|
||||
textPreview: result.text ? result.text.substring(0, 200).replace(/\s+/g, ' ') : ""
|
||||
};
|
||||
}
|
||||
|
||||
// --- 2. EPUB EXTRACTION ---
|
||||
if (extension === 'epub') {
|
||||
// Logic for EPub usually requires file path or custom stream handler
|
||||
// Keeping placeholder for your existing EPub logic
|
||||
return { type: 'EPUB', title: filename };
|
||||
}
|
||||
|
||||
// --- 3. IMAGE EXTRACTION (Enhanced with GPS) ---
|
||||
if (['jpg', 'jpeg', 'png', 'webp'].includes(extension || '')) {
|
||||
// --- 2. IMAGE EXTRACTION ---
|
||||
if (['jpg', 'jpeg', 'png', 'webp','heic'].includes(extension || '')) {
|
||||
const image = sharp(buffer);
|
||||
const metadata = await image.metadata();
|
||||
|
||||
let gps: { latitude?: number; longitude?: number } = {};
|
||||
|
||||
let details = {};
|
||||
if (metadata.exif) {
|
||||
try {
|
||||
// Cast to any to bypass strict Exif type checking for nested GPS properties
|
||||
const exif = exifReader(metadata.exif) as any;
|
||||
|
||||
// Debugging log to see the raw structure in your terminal
|
||||
console.log("📸 FULL RAW EXIF DATA:", JSON.stringify(exif, null, 2));
|
||||
|
||||
if (exif.gps && exif.gps.GPSLatitude && exif.gps.GPSLongitude) {
|
||||
// EXIF stores GPS as [Degrees, Minutes, Seconds]
|
||||
// We convert to Decimal Degrees for Google Maps
|
||||
const lat = exif.gps.GPSLatitude;
|
||||
const lon = exif.gps.GPSLongitude;
|
||||
|
||||
let latitude = lat[0] + lat[1] / 60 + lat[2] / 3600;
|
||||
let longitude = lon[0] + lon[1] / 60 + lon[2] / 3600;
|
||||
|
||||
// Adjust for South or West hemisphere
|
||||
if (exif.gps.GPSLatitudeRef === 'S') latitude *= -1;
|
||||
if (exif.gps.GPSLongitudeRef === 'W') longitude *= -1;
|
||||
|
||||
gps.latitude = latitude;
|
||||
gps.longitude = longitude;
|
||||
}
|
||||
} catch (exifError) {
|
||||
console.warn("Could not parse EXIF data for:", filename, exifError);
|
||||
}
|
||||
const rawExif = exifReader(metadata.exif);
|
||||
details = sanitizeMetadata(rawExif);
|
||||
} catch (e) { console.warn("EXIF Parse failed"); }
|
||||
}
|
||||
|
||||
return {
|
||||
type: `IMAGE (${metadata.format?.toUpperCase()})`,
|
||||
dimensions: metadata.width && metadata.height ? `${metadata.width}x${metadata.height}` : undefined,
|
||||
dimensions: `${metadata.width}x${metadata.height}`,
|
||||
title: filename,
|
||||
...gps
|
||||
details: details
|
||||
};
|
||||
}
|
||||
|
||||
// --- 3. EPUB EXTRACTION (New Section) ---
|
||||
if (extension === 'epub') {
|
||||
return new Promise((resolve, reject) => {
|
||||
// We initialize the EPub instance with a null image path and use the buffer
|
||||
const epub = new EPub(buffer);
|
||||
|
||||
epub.on('error', (err) => {
|
||||
console.error("EPUB Parser Error:", err);
|
||||
resolve({ type: 'EPUB', title: filename, error: "Failed to parse EPUB" });
|
||||
});
|
||||
|
||||
epub.on('end', () => {
|
||||
// Standardizing the metadata for your UI
|
||||
resolve({
|
||||
type: 'EPUB',
|
||||
title: epub.metadata.title || filename,
|
||||
author: epub.metadata.creator || 'Unknown',
|
||||
details: sanitizeMetadata(epub.metadata),
|
||||
// Use the number of chapters/manifest items as a rough "page" guide
|
||||
pageCount: epub.spine.contents.length || 0,
|
||||
textPreview: epub.metadata.description
|
||||
? epub.metadata.description.substring(0, 200).replace(/<[^>]*>?/gm, '')
|
||||
: ""
|
||||
});
|
||||
});
|
||||
|
||||
epub.parse();
|
||||
});
|
||||
}
|
||||
|
||||
return { type: 'FILE', title: filename };
|
||||
} catch (error) {
|
||||
console.error(`Extraction failed for ${filename}:`, error);
|
||||
return { type: 'FILE', title: filename };
|
||||
} catch (error: any) {
|
||||
console.error(`❌ Extraction failed for ${filename}:`, error.message);
|
||||
return { type: 'FILE', title: filename, error: error.message };
|
||||
}
|
||||
}
|
||||
111
src/lib/transformers.ts
Normal file
111
src/lib/transformers.ts
Normal 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
|
||||
};
|
||||
};
|
||||
25
src/services/metadata-service.ts
Normal file
25
src/services/metadata-service.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -171,35 +171,7 @@ export async function uploadToFolderId(userId: string, file: File, folderId: str
|
|||
if (!uploadRes.ok) throw new Error("Upload failed");
|
||||
return await uploadRes.json();
|
||||
}
|
||||
/**
|
||||
* Fetches the raw binary content (the actual file bytes) from OneDrive.
|
||||
*/
|
||||
export async function getOneDriveFileBuffer(fileId: string): Promise<Buffer> {
|
||||
// Use your existing helper that manages the Microsoft Graph access token
|
||||
const token = await getAccessToken();
|
||||
|
||||
const response = await fetch(
|
||||
`https://graph.microsoft.com/v1.0/me/drive/items/${fileId}/content`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
// Ensure we get fresh data and don't cache large file buffers
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error("OneDrive Download Error:", errorText);
|
||||
throw new Error(`Failed to download file content: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Convert the browser-style response into a Node.js Buffer
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
return Buffer.from(arrayBuffer);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
|
|
@ -220,4 +192,33 @@ async function getAccessToken(): Promise<string> {
|
|||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches raw file content from OneDrive.
|
||||
* Parameterized token allows this to be used in different contexts (User actions, Webhooks, etc.)
|
||||
*/
|
||||
export async function getOneDriveFileBuffer(oneDriveId: string, token: string): Promise<Buffer> {
|
||||
const response = await fetch(
|
||||
`https://graph.microsoft.com/v1.0/me/drive/items/${oneDriveId}/content`,
|
||||
{
|
||||
method: 'GET', // Explicit is better
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Accept': '*/*'
|
||||
},
|
||||
// CRITICAL: Next.js tends to cache fetch calls.
|
||||
// We do NOT want to cache large binary buffers in memory/disk.
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text().catch(() => "No error body");
|
||||
console.error(`OneDrive Download Error (${response.status}):`, errorBody);
|
||||
throw new Error(`OneDrive download failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
return Buffer.from(arrayBuffer);
|
||||
}
|
||||
Loading…
Reference in a new issue