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