Skip to content

Commit ae00cee

Browse files
authored
Merge pull request #72 from thekid/feature/metadata
Show lens model and creation date from EXIF / XMP segments
2 parents ed4b889 + d6e2424 commit ae00cee

File tree

13 files changed

+132
-67
lines changed

13 files changed

+132
-67
lines changed

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
"require": {
77
"xp-framework/compiler": "^9.3",
8-
"xp-framework/imaging": "^11.0",
8+
"xp-framework/imaging": "^11.1",
99
"xp-framework/command": "^12.0",
1010
"xp-framework/networking": "^10.4",
1111
"xp-forge/marshalling": "^2.4",

src/main/handlebars/layout.handlebars

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -680,7 +680,7 @@
680680
left: 0;
681681
font-size: .9rem;
682682
display: grid;
683-
grid-template-columns: repeat(6, max-content);
683+
grid-template-columns: repeat(8, max-content);
684684
gap: 1px;
685685
justify-content: center;
686686
pointer-events: none;
@@ -761,8 +761,12 @@
761761
border-radius: 0;
762762
}
763763
764-
.meta div:nth-child(2) {
765-
grid-column: span 3;
764+
.meta div:nth-child(1) {
765+
grid-column: span 2;
766+
}
767+
768+
.meta div:nth-child(4) {
769+
grid-column: span 4;
766770
}
767771
768772
.meta div {
@@ -811,8 +815,10 @@
811815
<div class="image">
812816
<img alt="Lightbox" width="100%">
813817
<div class="meta">
818+
<div><output name="datetime"></output></div>
814819
<div><output name="make"></output></div>
815820
<div><output name="model"></output></div>
821+
<div><output name="lensmodel"></output></div>
816822
<div>ISO <output name="isospeedratings"></output></div>
817823
<div><output name="focallength"></output> mm</div>
818824
<div><output name="aperturefnumber"></output></div>

src/main/js/lightbox.js

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,35 @@
11
class Lightbox {
22

3+
#meta($meta, dataset) {
4+
if ('' !== (dataset.make ?? '')) {
5+
$meta.querySelectorAll('output').forEach($o => $o.value = dataset[$o.name]);
6+
$meta.style.visibility = 'visible';
7+
} else {
8+
$meta.style.visibility = 'hidden';
9+
}
10+
}
11+
312
/** Opens the given lightbox, loading the image and filling in meta data */
413
#open($target, $link, offset) {
514
const $full = $target.querySelector('img');
615
const $img = $link.querySelector('img');
716

817
// Use opening image...
918
$full.src = $img.src;
10-
$target.dataset.offset = offset;
1119
$target.showModal();
1220

13-
// Overlay meta data if present
14-
const $meta = $target.querySelector('.meta');
15-
if ('' !== ($img.dataset.make ?? '')) {
16-
$meta.querySelectorAll('output').forEach($o => $o.value = $img.dataset[$o.name]);
17-
$meta.style.visibility = 'visible';
18-
} else {
19-
$meta.style.visibility = 'hidden';
20-
}
21-
2221
// ...then replace by larger version
22+
this.#meta($target.querySelector('.meta'), $img.dataset);
23+
$target.dataset.offset = offset;
2324
$full.src = $link.href;
2425
}
2526

27+
#navigate($target, $link, offset) {
28+
this.#meta($target.querySelector('.meta'), $link.querySelector('img').dataset);
29+
$target.dataset.offset = offset;
30+
$target.querySelector('img').src = $link.href;
31+
}
32+
2633
/** Attach all of the given elements to open the lightbox specified by the given DOM element */
2734
attach(selector, $target) {
2835
$target.addEventListener('click', e => {
@@ -42,7 +49,7 @@ class Lightbox {
4249

4350
e.stopPropagation();
4451
if (offset >= 0 && offset < selector.length) {
45-
this.#open($target, selector.item(offset), offset);
52+
this.#navigate($target, selector.item(offset), offset);
4653
}
4754
});
4855

@@ -72,7 +79,7 @@ class Lightbox {
7279

7380
e.stopPropagation();
7481
if (offset >= 0 && offset < selector.length) {
75-
this.#open($target, selector.item(offset), offset);
82+
this.#navigate($target, selector.item(offset), offset);
7683
}
7784
});
7885

src/main/php/de/thekid/dialog/import/Cover.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
/** Imports the cover image */
77
class Cover extends Source {
88

9+
/** Returns this source's name */
10+
public function name(): string { return '@cover'; }
11+
912
public function entryFrom(Description $description): array<string, mixed> {
1013
$date= $description->meta['date'];
1114
return [
@@ -15,7 +18,7 @@ public function entryFrom(Description $description): array<string, mixed> {
1518
'title' => $description->meta['title'],
1619
'keywords' => $description->meta['keywords'] ?? [],
1720
'content' => $description->content,
18-
'locations' => [...$description->locations(($date instanceof Date ? $date : new Date($date)->getTimeZone())],
21+
'locations' => [...$description->locations(($date instanceof Date ? $date : new Date($date))->getTimeZone())],
1922
'is' => ['cover' => true],
2023
];
2124
}

src/main/php/de/thekid/dialog/import/LocalDirectory.php

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22

33
use Generator;
44
use de\thekid\dialog\processing\{Files, Images, Videos, ResizeTo};
5-
use io\{File, Folder};
6-
use lang\{Throwable, IllegalArgumentException};
5+
use lang\Throwable;
76
use util\cmd\{Command, Arg};
87
use util\log\Logging;
98
use webservices\rest\{Endpoint, RestUpload};
@@ -19,29 +18,12 @@
1918
* - cover.md: The image to use for the cover page
2019
*/
2120
class LocalDirectory extends Command {
22-
private static $implementations= [
23-
'content.md' => new Content(...),
24-
'journey.md' => new Journey(...),
25-
'cover.md' => new Cover(...),
26-
];
2721
private $source, $api;
2822

2923
/** Sets origin folder, e.g. `./imports/album` */
3024
#[Arg(position: 0)]
3125
public function from(string $origin): void {
32-
foreach (self::$implementations as $source => $implementation) {
33-
$file= new File($origin, $source);
34-
if (!$file->exists()) continue;
35-
36-
$this->source= $implementation(new Folder($origin), $file);
37-
return;
38-
}
39-
40-
throw new IllegalArgumentException(sprintf(
41-
'Cannot locate any of [%s] in %s',
42-
implode(', ', array_keys(self::$implementations)),
43-
$origin
44-
));
26+
$this->source= Source::in($origin);
4527
}
4628

4729
/** Sets API url, e.g. `http://user:pass@localhost:8080/api` */

src/main/php/de/thekid/dialog/import/LookupWeather.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public function execute(Endpoint $api) {
2020
// Infer date range from first and last images
2121
$dates= [];
2222
foreach ($this->images as $image) {
23-
$dates[]= new Date(strtr($image['meta']['dateTime'], ['+00:00' => '']), $tz);
23+
$dates[]= new Date($image['meta']['dateTime'], $tz);
2424
}
2525
usort($dates, fn($a, $b) => $b->compareTo($a));
2626
$first= current($dates);

src/main/php/de/thekid/dialog/import/Source.php

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,26 @@ public function __construct(
2020
$this->name= $this->origin->dirname;
2121
}
2222

23+
/** Creates a source from a given origin folder */
24+
public static function in(string|Folder $origin): self {
25+
static $implementations= [
26+
'content.md' => new Content(...),
27+
'journey.md' => new Journey(...),
28+
'cover.md' => new Cover(...),
29+
];
30+
31+
foreach ($implementations as $source => $new) {
32+
$file= new File($origin, $source);
33+
if ($file->exists()) return $new($origin instanceof Folder ? $origin : new Folder($origin), $file);
34+
}
35+
36+
throw new IllegalArgumentException(sprintf(
37+
'Cannot locate any of [%s] in %s',
38+
implode(', ', array_keys($implementations)),
39+
$origin
40+
));
41+
}
42+
2343
/** Returns this source's name */
2444
public function name(): string { return $this->name; }
2545

@@ -29,26 +49,22 @@ public function parent(): ?string { return strstr($this->name(), '/', true) ?: n
2949
/** Sets a parent for this source */
3050
public function nestedIn(string $parent): self { $this->name= $parent.'/'.$this->name; return $this; }
3151

52+
/** Returns this source's origin */
53+
public function origin(): Folder { return $this->origin; }
54+
3255
/** Yields all the media files in this source */
3356
protected function mediaIn(Files $files): iterable {
34-
static $processed= '/^(thumb|preview|full|video|screen)-/';
35-
3657
$images= [];
3758
foreach ($this->entry['images'] ?? [] as $image) {
3859
$images[$image['name']]= $image;
3960
}
4061

41-
foreach ($this->origin->entries() as $path) {
42-
$name= $path->name();
43-
if ($path->isFile() && !preg_match($processed, $name) && ($processing= $files->processing($name))) {
44-
$file= $path->asFile();
45-
$name= $file->filename;
46-
47-
if (!isset($images[$name]) || $file->lastModified() > $images[$name]['modified']) {
48-
yield new UploadMedia($this->entry['slug'], $file, $processing);
49-
}
50-
unset($images[$name]);
62+
foreach ($files->in($this->origin) as $file => $processing) {
63+
$name= $file->filename;
64+
if (!isset($images[$name]) || $file->lastModified() > $images[$name]['modified']) {
65+
yield new UploadMedia($this->entry['slug'], $file, $processing);
5166
}
67+
unset($images[$name]);
5268
}
5369

5470
foreach ($images as $rest) {
Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,47 @@
11
<?php namespace de\thekid\dialog\processing;
22

3+
use io\Folder;
4+
35
/** @test de.thekid.dialog.unittest.FilesTest */
46
class Files {
57
private $patterns= [];
8+
private $processed= null;
69

710
/** Maps file extensions to a processing instance */
811
public function matching(array<string> $extensions, Processing $processing): self {
912
$this->patterns['/('.implode('|', array_map(preg_quote(...), $extensions)).')$/i']= $processing;
13+
$this->processed= null;
1014
return $this;
1115
}
1216

17+
/** Returns a (cached) pattern to match all processed files */
18+
public function processed(): string {
19+
if (null !== $this->processed) return $this->processed;
20+
$prefixes= [];
21+
foreach ($this->patterns as $processing) {
22+
foreach ($processing->prefixes() as $prefix) {
23+
$prefixes[$prefix]= true;
24+
}
25+
}
26+
return $this->processed= '/^('.implode('|', array_keys($prefixes)).')-/';
27+
}
28+
1329
/** Returns processing instance based on filename, or NULL */
1430
public function processing(string $filename): ?Processing {
15-
foreach ($this->patterns as $pattern => $processing) {
16-
if (preg_match($pattern, $filename)) return $processing;
31+
if (!preg_match($this->processed(), $filename)) {
32+
foreach ($this->patterns as $pattern => $processing) {
33+
if (preg_match($pattern, $filename)) return $processing;
34+
}
1735
}
1836
return null;
1937
}
38+
39+
/** Yields files and their associated processing in a given folder */
40+
public function in(Folder $origin): iterable {
41+
foreach ($origin->entries() as $path) {
42+
if ($path->isFile() && ($processing= $this->processing($path->name()))) {
43+
yield $path->asFile() => $processing;
44+
}
45+
}
46+
}
2047
}

src/main/php/de/thekid/dialog/processing/Images.php

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
<?php namespace de\thekid\dialog\processing;
22

3-
use img\io\MetaDataReader;
3+
use img\io\{MetaDataReader, XMPSegment};
44
use io\File;
55

66
class Images extends Processing {
7+
private const RDF= 'http://www.w3.org/1999/02/22-rdf-syntax-ns#';
78
private $meta= new MetaDataReader();
89

910
public function kind(): string { return 'image'; }
@@ -24,16 +25,25 @@ public function meta(File $source): array<string, mixed> {
2425
$r+= [
2526
'width' => $exif->width,
2627
'height' => $exif->height,
27-
'dateTime' => $exif->dateTime?->toString('c', self::$UTC) ?? gmdate('c'),
28+
'dateTime' => $exif->dateTime?->toString(self::DATEFORMAT),
2829
'make' => $exif->make,
2930
'model' => $exif->model,
31+
'lensModel' => $exif->lensModel,
3032
'apertureFNumber' => $exif->apertureFNumber,
3133
'exposureTime' => $exif->exposureTime,
3234
'isoSpeedRatings' => $exif->isoSpeedRatings,
3335
'focalLength' => $exif->focalLength ? $this->toRounded($exif->focalLength, precision: 1) : null,
3436
'flashUsed' => $exif->flashUsed(),
3537
];
3638
}
39+
40+
// Merge in XMP segment
41+
if ($xmp= $meta?->segmentsOf(XMPSegment::class)) {
42+
foreach ($xmp[0]->document()->getElementsByTagNameNS(self::RDF, 'Description')[0]->attributes as $attr) {
43+
$r[lcfirst($attr->name)]= $attr->value;
44+
}
45+
}
46+
$r['lensModel']??= $r['lens'] ?? '(Unknown Lens)';
3747
return $r;
3848
} finally {
3949
$source->close();

src/main/php/de/thekid/dialog/processing/Processing.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@
44
use util\TimeZone;
55

66
abstract class Processing {
7-
protected static $UTC= new TimeZone('UTC');
7+
protected const DATEFORMAT= 'd.m.Y H:i';
88
protected $targets= [];
99

1010
/** Returns processing kind */
1111
public abstract function kind(): string;
1212

13+
/** Returns prefixes used by the targets */
14+
public function prefixes(): array<string> { return array_keys($this->targets); }
15+
1316
/**
1417
* Adds a conversion target with a given prefix and conversion target.
1518
* Fluent interface.

0 commit comments

Comments
 (0)