pdf and image metadata extractor working
This commit is contained in:
parent
71b567ad04
commit
552662cf16
11 changed files with 481 additions and 189 deletions
|
|
@ -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,34 @@ 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
|
||||||
BIN
docs/notes.pdf
BIN
docs/notes.pdf
Binary file not shown.
20
package-lock.json
generated
20
package-lock.json
generated
|
|
@ -23,6 +23,7 @@
|
||||||
"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",
|
||||||
|
|
@ -6053,6 +6054,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-ensure": {
|
||||||
|
"version": "0.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-ensure/-/node-ensure-0.0.0.tgz",
|
||||||
|
"integrity": "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/node-fetch-native": {
|
"node_modules/node-fetch-native": {
|
||||||
"version": "1.6.7",
|
"version": "1.6.7",
|
||||||
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
||||||
|
|
@ -6550,6 +6557,19 @@
|
||||||
"url": "https://github.com/sponsors/mehmet-kozan"
|
"url": "https://github.com/sponsors/mehmet-kozan"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pdf-parse-new": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdf-parse-new/-/pdf-parse-new-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-9RG2m8lws0H/6kn9kngrooLNJM/FW8rtLRLgtxd69fuEV/uuP7L6ez3dKt2hGW/laO/eVxGiQYYwo+wysB4saA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.3.4",
|
||||||
|
"node-ensure": "^0.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.11.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pdfjs-dist": {
|
"node_modules/pdfjs-dist": {
|
||||||
"version": "5.4.296",
|
"version": "5.4.296",
|
||||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
|
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
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,100 +1,108 @@
|
||||||
import * as pdf from 'pdf-parse';
|
// src/lib/metadata-extractor.ts
|
||||||
import EPub from 'epub';
|
// 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 sharp from 'sharp';
|
||||||
import exifReader from 'exif-reader';
|
import exifReader from 'exif-reader';
|
||||||
|
|
||||||
export interface ExtractedMetadata {
|
|
||||||
title?: string;
|
|
||||||
author?: string;
|
|
||||||
subject?: string;
|
|
||||||
dimensions?: string;
|
|
||||||
pageCount?: number;
|
|
||||||
latitude?: number;
|
|
||||||
longitude?: number;
|
|
||||||
deviceModel?: string | null;
|
|
||||||
exposureTime?: string | null;
|
|
||||||
fNumber?: number | null;
|
|
||||||
iso?: number | null;
|
|
||||||
type: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper: Converts [Degrees, Minutes, Seconds] to Decimal
|
* Converts EXIF DMS (Degrees, Minutes, Seconds) array to Decimal Degrees.
|
||||||
*/
|
*/
|
||||||
function parseGps(values: number[] | undefined, ref: string | undefined): number | null {
|
function convertDMSToDD(dms: any, ref: string): string {
|
||||||
if (!values || values.length < 3) return null;
|
if (!Array.isArray(dms) || dms.length < 3) return String(dms);
|
||||||
const decimal = values[0] + values[1] / 60 + values[2] / 3600;
|
|
||||||
const multiplier = (ref === 'S' || ref === 'W') ? -1 : 1;
|
|
||||||
return Number((decimal * multiplier).toFixed(6));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
const [degrees, minutes, seconds] = dms;
|
||||||
* Helper: Converts Rational fractions (1/4000) or Objects to readable values
|
let dd = degrees + (minutes / 60) + (seconds / 3600);
|
||||||
*/
|
|
||||||
function parseRational(value: any, asString = false): string | number | null {
|
if (ref === 'S' || ref === 'W') {
|
||||||
if (!value) return null;
|
dd = dd * -1;
|
||||||
if (typeof value === 'number') return value;
|
|
||||||
if (value.numerator && value.denominator) {
|
|
||||||
if (asString) return `${value.numerator}/${value.denominator}`;
|
|
||||||
return value.numerator / value.denominator;
|
|
||||||
}
|
}
|
||||||
return null;
|
return dd.toFixed(6);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function extractMetadata(buffer: Buffer, filename: string): Promise<ExtractedMetadata> {
|
/**
|
||||||
|
* 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();
|
const extension = filename.split('.').pop()?.toLowerCase();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// --- 1. PDF EXTRACTION ---
|
// --- 1. PDF EXTRACTION (pdf-parse-new) ---
|
||||||
if (extension === 'pdf') {
|
if (extension === 'pdf') {
|
||||||
const parsePdf = (pdf as any).default || pdf;
|
const parser = new PdfParse.SmartPDFParser({
|
||||||
const data = await parsePdf(buffer);
|
oversaturationFactor: 2.0,
|
||||||
|
enableFastPath: true
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`--- PDF Extraction Start: ${filename} ---`);
|
||||||
|
const result = await parser.parse(buffer);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'PDF',
|
type: 'PDF',
|
||||||
title: data.info?.Title || filename,
|
title: filename,
|
||||||
author: data.info?.Author,
|
// pageCount is specifically named 'numpages' in this library
|
||||||
subject: data.info?.Subject,
|
pageCount: result.numpages || 0,
|
||||||
pageCount: data.numpages,
|
// We sanitize the result.info to handle those nested Custom objects
|
||||||
|
details: sanitizeMetadata(result.info || {}),
|
||||||
|
// Use result.text for a preview if available
|
||||||
|
textPreview: result.text ? result.text.substring(0, 200).replace(/\s+/g, ' ') : ""
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 2. IMAGE EXTRACTION (Enhanced) ---
|
// --- 2. IMAGE EXTRACTION ---
|
||||||
if (['jpg', 'jpeg', 'png', 'webp'].includes(extension || '')) {
|
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 exifData: Partial<ExtractedMetadata> = {};
|
let details = {};
|
||||||
|
|
||||||
if (metadata.exif) {
|
if (metadata.exif) {
|
||||||
try {
|
try {
|
||||||
const exif = exifReader(metadata.exif) as any;
|
const rawExif = exifReader(metadata.exif);
|
||||||
console.log(" exif ");
|
details = sanitizeMetadata(rawExif);
|
||||||
console.log(exif);
|
} catch (e) {
|
||||||
exifData = {
|
console.warn("EXIF Parse failed");
|
||||||
deviceModel: exif.image?.Model || null,
|
|
||||||
exposureTime: parseRational(exif.photo?.ExposureTime, true) as string,
|
|
||||||
fNumber: parseRational(exif.photo?.FNumber) as number,
|
|
||||||
iso: exif.photo?.ISOSpeedRatings || null,
|
|
||||||
latitude: parseGps(exif.gps?.GPSLatitude, exif.gps?.GPSLatitudeRef) || undefined,
|
|
||||||
longitude: parseGps(exif.gps?.GPSLongitude, exif.gps?.GPSLongitudeRef) || undefined,
|
|
||||||
};
|
|
||||||
} 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,
|
||||||
...exifData
|
details: details
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default Fallback
|
|
||||||
return { type: 'FILE', title: filename };
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Extraction failed for ${filename}:`, error);
|
|
||||||
return { type: 'FILE', title: filename };
|
return { type: 'FILE', title: filename };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`❌ Extraction failed for ${filename}:`, error.message);
|
||||||
|
return { type: 'FILE', title: filename, error: error.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
BIN
src/lib/text.pdf
Normal file
BIN
src/lib/text.pdf
Normal file
Binary file not shown.
59
src/lib/transformers.ts
Normal file
59
src/lib/transformers.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
// src/services/metadata-service.ts
|
// src/services/metadata-service.ts
|
||||||
import { getOneDriveFileBuffer } from "./onedrive";
|
import { getOneDriveFileBuffer } from "./onedrive";
|
||||||
// 1. Change the import name
|
|
||||||
import { extractMetadata } from "@/lib/metadata-extractor";
|
import { extractMetadata } from "@/lib/metadata-extractor";
|
||||||
import { getFileNodeById, updateFileNode } from "@/data-access/file-nodes";
|
import { getFileNodeById, updateFileNode } from "@/data-access/file-nodes";
|
||||||
|
|
||||||
|
|
@ -8,16 +7,19 @@ export async function enrichFileMetadata(fileId: string, token: string) {
|
||||||
const node = await getFileNodeById(fileId);
|
const node = await getFileNodeById(fileId);
|
||||||
if (!node || !node.oneDriveId) throw new Error("Node not found");
|
if (!node || !node.oneDriveId) throw new Error("Node not found");
|
||||||
|
|
||||||
|
// Fetch file from OneDrive
|
||||||
const buffer = await getOneDriveFileBuffer(node.oneDriveId, token);
|
const buffer = await getOneDriveFileBuffer(node.oneDriveId, token);
|
||||||
|
|
||||||
// 2. Use the standard extractor (it handles PDF, Image, etc. automatically)
|
// Extract deep metadata (PDF, JPEG, etc.)
|
||||||
const deepMetadata = await extractMetadata(buffer, node.name);
|
const deepMetadata = await extractMetadata(buffer, node.name);
|
||||||
|
|
||||||
|
// Merge with existing metadata
|
||||||
const updatedMetadata = {
|
const updatedMetadata = {
|
||||||
...(node.metadata as object),
|
...(node.metadata as object),
|
||||||
...deepMetadata, // This now contains type, dimensions, gps, deviceModel, etc.
|
...deepMetadata,
|
||||||
magicFilled: true
|
magicFilled: true
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Update DB
|
||||||
return await updateFileNode(fileId, { metadata: updatedMetadata });
|
return await updateFileNode(fileId, { metadata: updatedMetadata });
|
||||||
}
|
}
|
||||||
Loading…
Reference in a new issue