diff --git a/composer.json b/composer.json index e1a17af..84e83a1 100755 --- a/composer.json +++ b/composer.json @@ -5,7 +5,7 @@ "require": { "xp-framework/compiler": "^9.3", - "xp-framework/imaging": "^11.0", + "xp-framework/imaging": "^11.1", "xp-framework/command": "^12.0", "xp-framework/networking": "^10.4", "xp-forge/marshalling": "^2.4", diff --git a/src/main/handlebars/layout.handlebars b/src/main/handlebars/layout.handlebars index a113bf5..72a3622 100755 --- a/src/main/handlebars/layout.handlebars +++ b/src/main/handlebars/layout.handlebars @@ -680,7 +680,7 @@ left: 0; font-size: .9rem; display: grid; - grid-template-columns: repeat(6, max-content); + grid-template-columns: repeat(8, max-content); gap: 1px; justify-content: center; pointer-events: none; @@ -761,8 +761,12 @@ border-radius: 0; } - .meta div:nth-child(2) { - grid-column: span 3; + .meta div:nth-child(1) { + grid-column: span 2; + } + + .meta div:nth-child(4) { + grid-column: span 4; } .meta div { @@ -811,8 +815,10 @@
Lightbox
+
+
ISO
mm
diff --git a/src/main/js/lightbox.js b/src/main/js/lightbox.js index eaebe74..49350fc 100755 --- a/src/main/js/lightbox.js +++ b/src/main/js/lightbox.js @@ -1,5 +1,14 @@ class Lightbox { + #meta($meta, dataset) { + if ('' !== (dataset.make ?? '')) { + $meta.querySelectorAll('output').forEach($o => $o.value = dataset[$o.name]); + $meta.style.visibility = 'visible'; + } else { + $meta.style.visibility = 'hidden'; + } + } + /** Opens the given lightbox, loading the image and filling in meta data */ #open($target, $link, offset) { const $full = $target.querySelector('img'); @@ -7,22 +16,20 @@ class Lightbox { // Use opening image... $full.src = $img.src; - $target.dataset.offset = offset; $target.showModal(); - // Overlay meta data if present - const $meta = $target.querySelector('.meta'); - if ('' !== ($img.dataset.make ?? '')) { - $meta.querySelectorAll('output').forEach($o => $o.value = $img.dataset[$o.name]); - $meta.style.visibility = 'visible'; - } else { - $meta.style.visibility = 'hidden'; - } - // ...then replace by larger version + this.#meta($target.querySelector('.meta'), $img.dataset); + $target.dataset.offset = offset; $full.src = $link.href; } + #navigate($target, $link, offset) { + this.#meta($target.querySelector('.meta'), $link.querySelector('img').dataset); + $target.dataset.offset = offset; + $target.querySelector('img').src = $link.href; + } + /** Attach all of the given elements to open the lightbox specified by the given DOM element */ attach(selector, $target) { $target.addEventListener('click', e => { @@ -42,7 +49,7 @@ class Lightbox { e.stopPropagation(); if (offset >= 0 && offset < selector.length) { - this.#open($target, selector.item(offset), offset); + this.#navigate($target, selector.item(offset), offset); } }); @@ -72,7 +79,7 @@ class Lightbox { e.stopPropagation(); if (offset >= 0 && offset < selector.length) { - this.#open($target, selector.item(offset), offset); + this.#navigate($target, selector.item(offset), offset); } }); diff --git a/src/main/php/de/thekid/dialog/import/Cover.php b/src/main/php/de/thekid/dialog/import/Cover.php index 7efe100..afa5c0a 100755 --- a/src/main/php/de/thekid/dialog/import/Cover.php +++ b/src/main/php/de/thekid/dialog/import/Cover.php @@ -6,6 +6,9 @@ /** Imports the cover image */ class Cover extends Source { + /** Returns this source's name */ + public function name(): string { return '@cover'; } + public function entryFrom(Description $description): array { $date= $description->meta['date']; return [ @@ -15,7 +18,7 @@ public function entryFrom(Description $description): array { 'title' => $description->meta['title'], 'keywords' => $description->meta['keywords'] ?? [], 'content' => $description->content, - 'locations' => [...$description->locations(($date instanceof Date ? $date : new Date($date)->getTimeZone())], + 'locations' => [...$description->locations(($date instanceof Date ? $date : new Date($date))->getTimeZone())], 'is' => ['cover' => true], ]; } diff --git a/src/main/php/de/thekid/dialog/import/LocalDirectory.php b/src/main/php/de/thekid/dialog/import/LocalDirectory.php index 573f853..dbb383f 100755 --- a/src/main/php/de/thekid/dialog/import/LocalDirectory.php +++ b/src/main/php/de/thekid/dialog/import/LocalDirectory.php @@ -2,8 +2,7 @@ use Generator; use de\thekid\dialog\processing\{Files, Images, Videos, ResizeTo}; -use io\{File, Folder}; -use lang\{Throwable, IllegalArgumentException}; +use lang\Throwable; use util\cmd\{Command, Arg}; use util\log\Logging; use webservices\rest\{Endpoint, RestUpload}; @@ -19,29 +18,12 @@ * - cover.md: The image to use for the cover page */ class LocalDirectory extends Command { - private static $implementations= [ - 'content.md' => new Content(...), - 'journey.md' => new Journey(...), - 'cover.md' => new Cover(...), - ]; private $source, $api; /** Sets origin folder, e.g. `./imports/album` */ #[Arg(position: 0)] public function from(string $origin): void { - foreach (self::$implementations as $source => $implementation) { - $file= new File($origin, $source); - if (!$file->exists()) continue; - - $this->source= $implementation(new Folder($origin), $file); - return; - } - - throw new IllegalArgumentException(sprintf( - 'Cannot locate any of [%s] in %s', - implode(', ', array_keys(self::$implementations)), - $origin - )); + $this->source= Source::in($origin); } /** Sets API url, e.g. `http://user:pass@localhost:8080/api` */ diff --git a/src/main/php/de/thekid/dialog/import/LookupWeather.php b/src/main/php/de/thekid/dialog/import/LookupWeather.php index c509107..e1fb354 100755 --- a/src/main/php/de/thekid/dialog/import/LookupWeather.php +++ b/src/main/php/de/thekid/dialog/import/LookupWeather.php @@ -20,7 +20,7 @@ public function execute(Endpoint $api) { // Infer date range from first and last images $dates= []; foreach ($this->images as $image) { - $dates[]= new Date(strtr($image['meta']['dateTime'], ['+00:00' => '']), $tz); + $dates[]= new Date($image['meta']['dateTime'], $tz); } usort($dates, fn($a, $b) => $b->compareTo($a)); $first= current($dates); diff --git a/src/main/php/de/thekid/dialog/import/Source.php b/src/main/php/de/thekid/dialog/import/Source.php index a4d2213..aeee970 100755 --- a/src/main/php/de/thekid/dialog/import/Source.php +++ b/src/main/php/de/thekid/dialog/import/Source.php @@ -20,6 +20,26 @@ public function __construct( $this->name= $this->origin->dirname; } + /** Creates a source from a given origin folder */ + public static function in(string|Folder $origin): self { + static $implementations= [ + 'content.md' => new Content(...), + 'journey.md' => new Journey(...), + 'cover.md' => new Cover(...), + ]; + + foreach ($implementations as $source => $new) { + $file= new File($origin, $source); + if ($file->exists()) return $new($origin instanceof Folder ? $origin : new Folder($origin), $file); + } + + throw new IllegalArgumentException(sprintf( + 'Cannot locate any of [%s] in %s', + implode(', ', array_keys($implementations)), + $origin + )); + } + /** Returns this source's name */ public function name(): string { return $this->name; } @@ -29,26 +49,22 @@ public function parent(): ?string { return strstr($this->name(), '/', true) ?: n /** Sets a parent for this source */ public function nestedIn(string $parent): self { $this->name= $parent.'/'.$this->name; return $this; } + /** Returns this source's origin */ + public function origin(): Folder { return $this->origin; } + /** Yields all the media files in this source */ protected function mediaIn(Files $files): iterable { - static $processed= '/^(thumb|preview|full|video|screen)-/'; - $images= []; foreach ($this->entry['images'] ?? [] as $image) { $images[$image['name']]= $image; } - foreach ($this->origin->entries() as $path) { - $name= $path->name(); - if ($path->isFile() && !preg_match($processed, $name) && ($processing= $files->processing($name))) { - $file= $path->asFile(); - $name= $file->filename; - - if (!isset($images[$name]) || $file->lastModified() > $images[$name]['modified']) { - yield new UploadMedia($this->entry['slug'], $file, $processing); - } - unset($images[$name]); + foreach ($files->in($this->origin) as $file => $processing) { + $name= $file->filename; + if (!isset($images[$name]) || $file->lastModified() > $images[$name]['modified']) { + yield new UploadMedia($this->entry['slug'], $file, $processing); } + unset($images[$name]); } foreach ($images as $rest) { diff --git a/src/main/php/de/thekid/dialog/processing/Files.php b/src/main/php/de/thekid/dialog/processing/Files.php index 089128e..8aacd72 100755 --- a/src/main/php/de/thekid/dialog/processing/Files.php +++ b/src/main/php/de/thekid/dialog/processing/Files.php @@ -1,20 +1,47 @@ $extensions, Processing $processing): self { $this->patterns['/('.implode('|', array_map(preg_quote(...), $extensions)).')$/i']= $processing; + $this->processed= null; return $this; } + /** Returns a (cached) pattern to match all processed files */ + public function processed(): string { + if (null !== $this->processed) return $this->processed; + $prefixes= []; + foreach ($this->patterns as $processing) { + foreach ($processing->prefixes() as $prefix) { + $prefixes[$prefix]= true; + } + } + return $this->processed= '/^('.implode('|', array_keys($prefixes)).')-/'; + } + /** Returns processing instance based on filename, or NULL */ public function processing(string $filename): ?Processing { - foreach ($this->patterns as $pattern => $processing) { - if (preg_match($pattern, $filename)) return $processing; + if (!preg_match($this->processed(), $filename)) { + foreach ($this->patterns as $pattern => $processing) { + if (preg_match($pattern, $filename)) return $processing; + } } return null; } + + /** Yields files and their associated processing in a given folder */ + public function in(Folder $origin): iterable { + foreach ($origin->entries() as $path) { + if ($path->isFile() && ($processing= $this->processing($path->name()))) { + yield $path->asFile() => $processing; + } + } + } } \ No newline at end of file diff --git a/src/main/php/de/thekid/dialog/processing/Images.php b/src/main/php/de/thekid/dialog/processing/Images.php index 84449d8..d416d4d 100755 --- a/src/main/php/de/thekid/dialog/processing/Images.php +++ b/src/main/php/de/thekid/dialog/processing/Images.php @@ -1,9 +1,10 @@ { $r+= [ 'width' => $exif->width, 'height' => $exif->height, - 'dateTime' => $exif->dateTime?->toString('c', self::$UTC) ?? gmdate('c'), + 'dateTime' => $exif->dateTime?->toString(self::DATEFORMAT), 'make' => $exif->make, 'model' => $exif->model, + 'lensModel' => $exif->lensModel, 'apertureFNumber' => $exif->apertureFNumber, 'exposureTime' => $exif->exposureTime, 'isoSpeedRatings' => $exif->isoSpeedRatings, @@ -34,6 +36,14 @@ public function meta(File $source): array { 'flashUsed' => $exif->flashUsed(), ]; } + + // Merge in XMP segment + if ($xmp= $meta?->segmentsOf(XMPSegment::class)) { + foreach ($xmp[0]->document()->getElementsByTagNameNS(self::RDF, 'Description')[0]->attributes as $attr) { + $r[lcfirst($attr->name)]= $attr->value; + } + } + $r['lensModel']??= $r['lens'] ?? '(Unknown Lens)'; return $r; } finally { $source->close(); diff --git a/src/main/php/de/thekid/dialog/processing/Processing.php b/src/main/php/de/thekid/dialog/processing/Processing.php index 7eb63e4..b531532 100755 --- a/src/main/php/de/thekid/dialog/processing/Processing.php +++ b/src/main/php/de/thekid/dialog/processing/Processing.php @@ -4,12 +4,15 @@ use util\TimeZone; abstract class Processing { - protected static $UTC= new TimeZone('UTC'); + protected const DATEFORMAT= 'd.m.Y H:i'; protected $targets= []; /** Returns processing kind */ public abstract function kind(): string; + /** Returns prefixes used by the targets */ + public function prefixes(): array { return array_keys($this->targets); } + /** * Adds a conversion target with a given prefix and conversion target. * Fluent interface. diff --git a/src/main/php/de/thekid/dialog/processing/Videos.php b/src/main/php/de/thekid/dialog/processing/Videos.php index c39442b..cd19524 100755 --- a/src/main/php/de/thekid/dialog/processing/Videos.php +++ b/src/main/php/de/thekid/dialog/processing/Videos.php @@ -16,8 +16,12 @@ class Videos extends Processing { public function __construct(private string $executable= 'ffmpeg') { } + /** Returns processing kind */ public function kind(): string { return 'video'; } + /** Returns prefixes used by the targets */ + public function prefixes(): array { return [...parent::prefixes(), 'video', 'screen']; } + /** Executes a given external command and returns its exit code */ private function execute(string $command, array $args): void { $p= new Process($command, $args, null, null, [STDIN, STDOUT, STDERR]); @@ -27,7 +31,7 @@ private function execute(string $command, array $args): void { } public function meta(File $source): array { - static $MAP= [ + static $mdta= [ 'mdta:com.apple.quicktime.make' => 'make', 'mdta:com.apple.quicktime.model' => 'model', 'mdta:com.android.manufacturer' => 'make', @@ -49,21 +53,21 @@ public function meta(File $source): array { // Normalize meta data from iOS and Android devices $r= []; foreach ($meta as $key => $value) { - if ($mapped= $MAP[$key] ?? null) { + if ($mapped= $mdta[$key] ?? null) { $r[$mapped]= $value[0]; } } // Prefer original creation date from iOS, converting it to local time if ($date= $meta['mdta:com.apple.quicktime.creationdate'][0] ?? null) { - $r['dateTime']= new Date(preg_replace('/[+-][0-9]{4}$/', '', $date))->toString('c', self::$UTC); + $r['dateTime']= new Date(preg_replace('/[+-][0-9]{4}$/', '', $date))->toString(self::DATEFORMAT); } // Aggregate information from movie header: Duration and creation time // Time info is the number of seconds since 1904-01-01 00:00:00 UTC if (isset($meta['mvhd'])) { $r['duration']= round($meta['mvhd']['duration'] / $meta['mvhd']['scale'], 3); - $r['dateTime']??= new Date($meta['mvhd']['created'] - 2082844800)->toString('c', self::$UTC); + $r['dateTime']??= new Date($meta['mvhd']['created'] - 2082844800)->toString(self::DATEFORMAT); } return $r; diff --git a/src/test/php/de/thekid/dialog/unittest/FilesTest.php b/src/test/php/de/thekid/dialog/unittest/FilesTest.php index a5e8cb6..b7b88ec 100755 --- a/src/test/php/de/thekid/dialog/unittest/FilesTest.php +++ b/src/test/php/de/thekid/dialog/unittest/FilesTest.php @@ -1,10 +1,15 @@ matching(['.jpg', '.jpeg'], $processing); + } + #[Test] public function can_create() { new Files(); @@ -13,16 +18,18 @@ public function can_create() { #[Test, Values(['test.jpg', 'IMG_1234.JPG', '20221119-iOS.jpeg'])] public function matching_jpeg_files($filename) { $processing= new Images(); - $fixture= new Files()->matching(['.jpg', '.jpeg'], $processing); - - Assert::equals($processing, $fixture->processing($filename)); + Assert::equals($processing, $this->fixtureWith($processing)->processing($filename)); } #[Test, Values(['test-jpg', 'IMG_1234JPG', 'jpeg', '.jpeg-file'])] public function unmatched_jpeg_files($filename) { $processing= new Images(); - $fixture= new Files()->matching(['.jpg', '.jpeg'], $processing); + Assert::null($this->fixtureWith($processing)->processing($filename)); + } - Assert::null($fixture->processing($filename)); + #[Test] + public function processed_pattern() { + $processing= new Images()->targeting('preview', new ResizeTo(720, 'jpg')); + Assert::equals('/^(preview)-/', $this->fixtureWith($processing)->processed()); } } \ No newline at end of file diff --git a/src/test/php/de/thekid/dialog/unittest/VideoMetaTest.php b/src/test/php/de/thekid/dialog/unittest/VideoMetaTest.php index a3f4af7..5ccbbc7 100755 --- a/src/test/php/de/thekid/dialog/unittest/VideoMetaTest.php +++ b/src/test/php/de/thekid/dialog/unittest/VideoMetaTest.php @@ -66,7 +66,7 @@ public function creation_date_from_mvhd() { $meta= $this->videos->meta($this->file($this->atom('moov', [ $this->atom('mvhd', pack('cc3NNNN', 0, 0, 0, 0, 3777782036, 3777782037, 1000, 3500)), ]))); - Assert::equals('2023-09-17T07:53:56+00:00', $meta['dateTime']); + Assert::equals('17.09.2023 07:53', $meta['dateTime']); } #[Test] @@ -86,7 +86,7 @@ public function ios_creation_date_prefererred() { $this->list('data', ['2023-09-17T08:33:56+00:00']), ]) ]))); - Assert::equals('2023-09-17T08:33:56+00:00', $meta['dateTime']); + Assert::equals('17.09.2023 08:33', $meta['dateTime']); } #[Test, Values(['com.apple.quicktime.make', 'com.android.manufacturer'])]