diff --git a/.gitignore b/.gitignore index 5965b197..f162715c 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,11 @@ test.* *.sublime-project *.sublime-workspace .DS_Store +test/folder-reset.js +test/folder-reset.js.map +test/TestHelper.js +test/TestHelper.js.map +test/frontend/translation.spec.js +test/frontend/translation.spec.js.map +/coverage/ +.nyc_output/ \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7c52ba4f..184e47f1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,7 +13,8 @@ In general, I'm happy to merge PRs, but I recommend filling a ticket and ask fir 1. Download the source files 2. install dependencies `npm install` 3. Build client `npm run run-dev` - * This will build the client with english localization and will keep building if you change the source files + * This will build the client with english localization and will keep building if you change the source files. + * Note: This process does not exit, so you need another terminal to run the next step. 4. Build the backend `npm run build-backend` * This runs `tsc` that transpiles `.ts` files to `.js` so node can run them. * To rebuild on change run `tsc -w` diff --git a/demo/images/Chars_exiftool.jpg b/demo/images/Chars_exiftool.jpg new file mode 100644 index 00000000..f899b06b Binary files /dev/null and b/demo/images/Chars_exiftool.jpg differ diff --git a/src/backend/model/fileaccess/MetadataLoader.ts b/src/backend/model/fileaccess/MetadataLoader.ts index 89c1a456..8ded9e1d 100644 --- a/src/backend/model/fileaccess/MetadataLoader.ts +++ b/src/backend/model/fileaccess/MetadataLoader.ts @@ -1,23 +1,22 @@ -import {VideoMetadata} from '../../../common/entities/VideoDTO'; -import {FaceRegion, PhotoMetadata} from '../../../common/entities/PhotoDTO'; -import {SideCar} from '../../../common/entities/MediaDTO'; -import {Config} from '../../../common/config/private/Config'; -import {Logger} from '../../Logger'; import * as fs from 'fs'; -import {imageSize} from 'image-size'; +import { imageSize } from 'image-size'; +import { Config } from '../../../common/config/private/Config'; +import { SideCar } from '../../../common/entities/MediaDTO'; +import { FaceRegion, PhotoMetadata } from '../../../common/entities/PhotoDTO'; +import { VideoMetadata } from '../../../common/entities/VideoDTO'; +import { Logger } from '../../Logger'; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore -import * as ExifReader from 'exifreader'; -import {ExifParserFactory, OrientationTypes} from 'ts-exif-parser'; -import {IptcParser} from 'ts-node-iptc'; -import {FFmpegFactory} from '../FFmpegFactory'; -import {FfprobeData} from 'fluent-ffmpeg'; -import {Utils} from '../../../common/Utils'; -import {ExtensionDecorator} from '../extension/ExtensionDecorator'; import * as exifr from 'exifr'; -import * as path from 'path'; +import { FfprobeData } from 'fluent-ffmpeg'; +import { FileHandle } from 'fs/promises'; import * as util from 'node:util'; -import {FileHandle} from 'fs/promises'; +import * as path from 'path'; +import { ExifParserFactory, OrientationTypes } from 'ts-exif-parser'; +import { IptcParser } from 'ts-node-iptc'; +import { Utils } from '../../../common/Utils'; +import { FFmpegFactory } from '../FFmpegFactory'; +import { ExtensionDecorator } from '../extension/ExtensionDecorator'; const LOG_TAG = '[MetadataLoader]'; const ffmpeg = FFmpegFactory.get(); @@ -358,33 +357,44 @@ export class MetadataLoader { } try { - // TODO: clean up the three different exif readers, - // and keep the minimum amount only - const exif: ExifReader.Tags & ExifReader.XmpTags & ExifReader.IccTags = ExifReader.load(data); - if (exif.Rating) { - metadata.rating = parseInt(exif.Rating.value as string, 10) as 0 | 1 | 2 | 3 | 4 | 5; + const exifrOptions = { + tiff: true, + xmp: true, + icc: false, + jfif: false, //not needed and not supported for png + ihdr: true, + iptc: false, //exifr reads UTF8-encoded data wrongly + exif: true, + gps: true, + translateValues: false, //don't translate orientation from numbers to strings etc. + mergeOutput: false //don't merge output, because things like Microsoft Rating (percent) and xmp.rating will be merged + }; + + const exif = await exifr.parse(data, exifrOptions); + if (exif.xmp && exif.xmp.Rating) { + metadata.rating = exif.xmp.Rating; if (metadata.rating < 0) { metadata.rating = 0; } } - if ( - exif.subject && - exif.subject.value && - exif.subject.value.length > 0 - ) { + if (exif.dc && + exif.dc.subject && + exif.dc.subject.length > 0) { + const subj = Array.isArray(exif.dc.subject) ? exif.dc.subject : [exif.dc.subject]; if (metadata.keywords === undefined) { - metadata.keywords = []; + metadata.keywords = []; } - for (const kw of exif.subject.value as ExifReader.XmpTag[]) { - if (metadata.keywords.indexOf(kw.description) === -1) { - metadata.keywords.push(kw.description); - } + for (const kw of subj) { + if (metadata.keywords.indexOf(kw) === -1) { + metadata.keywords.push(kw); + } } - } + } let orientation = OrientationTypes.TOP_LEFT; - if (exif.Orientation) { + if (exif.ifd0 && + exif.ifd0.Orientation) { orientation = parseInt( - exif.Orientation.value as any, + exif.ifd0.Orientation as any, 10 ) as number; } @@ -396,9 +406,11 @@ export class MetadataLoader { metadata.size.height = height; } - if (Config.Faces.enabled) { + if (Config.Faces.enabled && + exif["mwg-rs"] && + exif["mwg-rs"].Regions) { const faces: FaceRegion[] = []; - const regionListVal = ((exif.Regions?.value as any)?.RegionList)?.value; + const regionListVal = Array.isArray(exif["mwg-rs"].Regions.RegionList) ? exif["mwg-rs"].Regions.RegionList : [exif["mwg-rs"].Regions.RegionList]; if (regionListVal) { for (const regionRoot of regionListVal) { let type; @@ -442,16 +454,16 @@ export class MetadataLoader { /* Adobe Lightroom based face region structure */ if ( - regionRoot.value && - regionRoot.value['rdf:Description'] && - regionRoot.value['rdf:Description'].value && - regionRoot.value['rdf:Description'].value['mwg-rs:Area'] + regionRoot && + regionRoot['rdf:Description'] && + regionRoot['rdf:Description'] && + regionRoot['rdf:Description']['mwg-rs:Area'] ) { - const region = regionRoot.value['rdf:Description']; - const regionBox = region.value['mwg-rs:Area'].attributes; + const region = regionRoot['rdf:Description']; + const regionBox = region['mwg-rs:Area'].attributes; - name = region.attributes['mwg-rs:Name']; - type = region.attributes['mwg-rs:Type']; + name = region['mwg-rs:Name']; + type = region['mwg-rs:Type']; box = createFaceBox( regionBox['stArea:w'], regionBox['stArea:h'], @@ -460,18 +472,19 @@ export class MetadataLoader { ); /* Load exiftool edited face region structure, see github issue #191 */ } else if ( - regionRoot.Area && + regionRoot && regionRoot.Name && - regionRoot.Type + regionRoot.Type && + regionRoot.Area ) { - const regionBox = regionRoot.Area.value; - name = regionRoot.Name.value; - type = regionRoot.Type.value; + const regionBox = regionRoot.Area; + name = regionRoot.Name; + type = regionRoot.Type; box = createFaceBox( - regionBox.w.value, - regionBox.h.value, - regionBox.x.value, - regionBox.y.value + regionBox.w, + regionBox.h, + regionBox.x, + regionBox.y ); } diff --git a/test/backend/assets/Chars.jpg b/test/backend/assets/Chars.jpg new file mode 100644 index 00000000..80b42df9 Binary files /dev/null and b/test/backend/assets/Chars.jpg differ diff --git a/test/backend/assets/Chars.json b/test/backend/assets/Chars.json new file mode 100644 index 00000000..be866f18 --- /dev/null +++ b/test/backend/assets/Chars.json @@ -0,0 +1,49 @@ +{ + "size": { + "width": 1920, + "height": 1080 + }, + "creationDate": 1706616000000, + "fileSize": 111378, + "positionData": { + "GPSData": { + "longitude": 14.162922, + "latitude": 57.780696 + }, + "country": "Sverige", + "state": "Jönköping", + "city": "Jönköping" + }, + "keywords": [ + ], + "rating": 0, + "faces": [ + { + "box": { + "width": 206, + "height": 257, + "left": 566, + "top": 144 + }, + "name": "æÆøØåÅéÉüÜäÄöÖïÏñÑ" + }, + { + "name": "abcdefghijklmnopqrstuvwxyz", + "box": { + "width": 212, + "height": 265, + "left": 866, + "top": 144 + } + }, + { + "name": "abcdefghijklmnopqrstuvwxyz", + "box": { + "width": 212, + "height": 265, + "left": 1162, + "top": 150 + } + } + ] +} \ No newline at end of file diff --git a/test/backend/assets/Chars_exiftool.jpg b/test/backend/assets/Chars_exiftool.jpg new file mode 100644 index 00000000..eaac6e4d Binary files /dev/null and b/test/backend/assets/Chars_exiftool.jpg differ diff --git a/test/backend/assets/Chars_exiftool.json b/test/backend/assets/Chars_exiftool.json new file mode 100644 index 00000000..4c09dfec --- /dev/null +++ b/test/backend/assets/Chars_exiftool.json @@ -0,0 +1,49 @@ +{ + "size": { + "width": 1920, + "height": 1080 + }, + "creationDate": 1706616000000, + "fileSize": 111050, + "positionData": { + "GPSData": { + "longitude": 14.162922, + "latitude": 57.780696 + }, + "country": "Sverige", + "state": "Jönköping", + "city": "Jönköping" + }, + "keywords": [ + ], + "rating": 0, + "faces": [ + { + "box": { + "width": 206, + "height": 257, + "left": 566, + "top": 144 + }, + "name": "æÆøØåÅéÉüÜäÄöÖïÏñÑ" + }, + { + "name": "abcdefghijklmnopqrstuvwxyz", + "box": { + "width": 212, + "height": 265, + "left": 866, + "top": 144 + } + }, + { + "name": "abcdefghijklmnopqrstuvwxyz", + "box": { + "width": 212, + "height": 265, + "left": 1162, + "top": 150 + } + } + ] +} \ No newline at end of file diff --git a/test/backend/assets/png_with_keyword_and_dates.json b/test/backend/assets/png_with_keyword_and_dates.json new file mode 100644 index 00000000..2f4ad94e --- /dev/null +++ b/test/backend/assets/png_with_keyword_and_dates.json @@ -0,0 +1,31 @@ + +{ + "size": { + "width": 26, + "height": 26 + }, + "creationDate": 1707167247786, + "fileSize": 5758, + "keywords": [ + ], + "faces": [ + { + "name": "raspberry", + "box": { + "width": 21, + "height": 18, + "left": 3, + "top": 8 + } + }, + { + "name": "leaf", + "box": { + "width": 9, + "height": 7, + "left": 14, + "top": 1 + } + } + ] +} \ No newline at end of file diff --git a/test/backend/assets/png_with_keyword_and_dates.png b/test/backend/assets/png_with_keyword_and_dates.png new file mode 100644 index 00000000..e8c7b554 Binary files /dev/null and b/test/backend/assets/png_with_keyword_and_dates.png differ diff --git a/test/backend/assets/png_with_keyword_and_dates.png_original b/test/backend/assets/png_with_keyword_and_dates.png_original new file mode 100644 index 00000000..513fddb2 Binary files /dev/null and b/test/backend/assets/png_with_keyword_and_dates.png_original differ diff --git a/test/backend/unit/model/threading/DiskManagerWorker.spec.ts b/test/backend/unit/model/threading/DiskManagerWorker.spec.ts index 224ba312..8d23fdad 100644 --- a/test/backend/unit/model/threading/DiskManagerWorker.spec.ts +++ b/test/backend/unit/model/threading/DiskManagerWorker.spec.ts @@ -24,12 +24,11 @@ describe('DiskMangerWorker', () => { ProjectPath.ImageFolder = path.join(__dirname, '/../../../assets'); const dir = await DiskManager.scanDirectory('/'); // should match the number of media (photo/video) files in the assets folder - expect(dir.media.length).to.be.equals(11); + expect(dir.media.length).to.be.equals(14); // eslint-disable-next-line @typescript-eslint/no-var-requires const expected = require(path.join(__dirname, '/../../../assets/test image öüóőúéáű-.,.json')); const i = dir.media.findIndex(m => m.name === 'test image öüóőúéáű-.,.jpg'); expect(Utils.clone(dir.media[i].name)).to.be.deep.equal('test image öüóőúéáű-.,.jpg'); expect(Utils.clone(dir.media[i].metadata)).to.be.deep.equal(expected); }); - }); diff --git a/test/backend/unit/model/threading/MetaDataLoader.spec.ts b/test/backend/unit/model/threading/MetaDataLoader.spec.ts index 03e472af..44850f9b 100644 --- a/test/backend/unit/model/threading/MetaDataLoader.spec.ts +++ b/test/backend/unit/model/threading/MetaDataLoader.spec.ts @@ -58,6 +58,22 @@ describe('MetadataLoader', () => { const expected = require(path.join(__dirname, '/../../../assets/old_photo.json')); expect(Utils.clone(data)).to.be.deep.equal(expected); }); + it('should load jpg with special characters', async () => { + const data = await MetadataLoader.loadPhotoMetadata(path.join(__dirname, '/../../../assets/Chars.jpg')); + const expected = require(path.join(__dirname, '/../../../assets/Chars.json')); + expect(Utils.clone(data)).to.be.deep.equal(expected); + }); + it('should load jpg with special characters saved by exiftool', async () => { + const data = await MetadataLoader.loadPhotoMetadata(path.join(__dirname, '/../../../assets/Chars_exiftool.jpg')); + const expected = require(path.join(__dirname, '/../../../assets/Chars_exiftool.json')); + expect(Utils.clone(data)).to.be.deep.equal(expected); + }); + it('should load png with keyword and dates', async () => { + const data = await MetadataLoader.loadPhotoMetadata(path.join(__dirname, '/../../../assets/png_with_keyword_and_dates.png')); + const expected = require(path.join(__dirname, '/../../../assets/png_with_keyword_and_dates.json')); + expect(Utils.clone(data)).to.be.deep.equal(expected); + }); + describe('should load jpg with proper height and orientation', () => { it('jpg 1', async () => {