diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e3fbcae..f2d9fa36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ This file is manually updated. Please file an issue if something is missing. +## Unreleased + +- **Feature**: Use GPS location data for timezone calculation. + Many cameras do not store the timezone in EXIF data. This feature allows Memories to use the GPS location data to calculate the timezone. To take advantage of this, you will need to run `occ memories:places-setup` followed by `occ memories:index --clear` (or `occ memories:index -f`) to reindex your photos. + ## v4.12.4 + v4.12.5 (2023-03-23) - These releases significantly overhaul the application logic for better maintainability. If you run into any regressions, please [file a bug report](https://github.com/pulsejet/memories/issues). diff --git a/lib/ClustersBackend/PlacesBackend.php b/lib/ClustersBackend/PlacesBackend.php index 167c891e..b4bdd2a7 100644 --- a/lib/ClustersBackend/PlacesBackend.php +++ b/lib/ClustersBackend/PlacesBackend.php @@ -71,6 +71,9 @@ class PlacesBackend extends Backend $count = $query->func()->count($query->createFunction('DISTINCT m.fileid'), 'count'); $query->select('e.osm_id', 'e.name', $count)->from('memories_planet', 'e'); + // WHERE these are not special clusters (e.g. timezone) + $query->where($query->expr()->gt('e.admin_level', $query->createNamedParameter(0))); + // WHERE there are items with this osm_id $query->innerJoin('e', 'memories_places', 'mp', $query->expr()->eq('mp.osm_id', 'e.osm_id')); diff --git a/lib/Command/PlacesSetup.php b/lib/Command/PlacesSetup.php index 0b6f1b4a..62351b2d 100644 --- a/lib/Command/PlacesSetup.php +++ b/lib/Command/PlacesSetup.php @@ -202,9 +202,10 @@ class PlacesSetup extends Command $boundaries = $data['geometry']; // Skip some places - if ($adminLevel <= 1 || $adminLevel >= 10) { + if ($adminLevel > -2 && ($adminLevel <= 1 || $adminLevel >= 10)) { // <=1: These are too general, e.g. "Earth"? or invalid // >=10: These are too specific, e.g. "Community Board" + // <-1: These are special, e.g. "Timezone" = -7 continue; } diff --git a/lib/Db/TimelineQuerySingleItem.php b/lib/Db/TimelineQuerySingleItem.php index b8b6169b..946c7eb5 100644 --- a/lib/Db/TimelineQuerySingleItem.php +++ b/lib/Db/TimelineQuerySingleItem.php @@ -72,6 +72,7 @@ trait TimelineQuerySingleItem ->from('memories_places', 'mp') ->innerJoin('mp', 'memories_planet', 'e', $qb->expr()->eq('mp.osm_id', 'e.osm_id')) ->where($qb->expr()->eq('mp.fileid', $qb->createNamedParameter($id, \PDO::PARAM_INT))) + ->andWhere($qb->expr()->gt('e.admin_level', $qb->createNamedParameter(0))) ->orderBy('e.admin_level', 'DESC') ; $places = $qb->executeQuery()->fetchAll(\PDO::FETCH_COLUMN); diff --git a/lib/Db/TimelineWrite.php b/lib/Db/TimelineWrite.php index 623878b8..4dbf0a92 100644 --- a/lib/Db/TimelineWrite.php +++ b/lib/Db/TimelineWrite.php @@ -10,7 +10,6 @@ use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\Files\File; use OCP\IDBConnection; use OCP\IPreview; -use Psr\Log\LoggerInterface; require_once __DIR__.'/../ExifFields.php'; @@ -136,77 +135,44 @@ class TimelineWrite return 2; } - // Get more parameters + // Video parameters + $videoDuration = round((float) ($isvideo ? ($exif['Duration'] ?? $exif['TrackDuration'] ?? 0) : 0)); + + // Process location data + // This also modifies the exif array in-place to set the LocationTZID + [$lat, $lon, $mapCluster] = $this->processExifLocation($fileId, $exif, $prevRow); + + // Get date parameters (after setting timezone offset) $dateTaken = Exif::getDateTaken($file, $exif); - $dayId = floor($dateTaken / 86400); - $dateTaken = gmdate('Y-m-d H:i:s', $dateTaken); + + // Store the acutal epoch with the EXIF data + $exif['DateTimeEpoch'] = $dateTaken->getTimestamp(); + + // Store the date taken in the database as UTC (local date) only + // Basically, assume everything happens in Greenwich + $dateLocalUtc = Exif::forgetTimezone($dateTaken)->getTimestamp(); + $dateTakenStr = gmdate('Y-m-d H:i:s', $dateLocalUtc); + + // We need to use the local time in UTC for the dayId + // This way two photos in different timezones on the same date locally + // end up in the same dayId group + $dayId = floor($dateLocalUtc / 86400); + + // Get size of image [$w, $h] = Exif::getDimensions($exif); + + // Get live photo ID of video part $liveid = $this->livePhoto->getLivePhotoId($file, $exif); - // Video parameters - $videoDuration = 0; - if ($isvideo) { - $videoDuration = round((float) ($exif['Duration'] ?? $exif['TrackDuration'] ?? 0)); - } - - // Clean up EXIF to keep only useful metadata - $filteredExif = []; - foreach ($exif as $key => $value) { - // Truncate any fields > 2048 chars - if (\is_string($value) && \strlen($value) > 2048) { - $value = substr($value, 0, 2048); - } - - // Only keep fields in the whitelist - if (\array_key_exists($key, EXIF_FIELDS_LIST)) { - $filteredExif[$key] = $value; - } - } - - // Store JSON string - $exifJson = json_encode($filteredExif); - - // Store error if data > 64kb - if (\is_string($exifJson)) { - if (\strlen($exifJson) > 65535) { - $exifJson = json_encode(['error' => 'Exif data too large']); - } - } else { - $exifJson = json_encode(['error' => 'Exif data encoding error']); - } - - // Store location data - $lat = \array_key_exists('GPSLatitude', $exif) ? (float) $exif['GPSLatitude'] : null; - $lon = \array_key_exists('GPSLongitude', $exif) ? (float) $exif['GPSLongitude'] : null; - $oldLat = $prevRow ? (float) $prevRow['lat'] : null; - $oldLon = $prevRow ? (float) $prevRow['lon'] : null; - $mapCluster = $prevRow ? (int) $prevRow['mapcluster'] : -1; - - if ($lat || $lon || $oldLat || $oldLon) { - try { - $mapCluster = $this->mapGetCluster($mapCluster, $lat, $lon, $oldLat, $oldLon); - } catch (\Error $e) { - $logger = \OC::$server->get(LoggerInterface::class); - $logger->log(3, 'Error updating map cluster data: '.$e->getMessage(), ['app' => 'memories']); - } - - try { - $this->updatePlacesData($fileId, $lat, $lon); - } catch (\Error $e) { - $logger = \OC::$server->get(LoggerInterface::class); - $logger->log(3, 'Error updating places data: '.$e->getMessage(), ['app' => 'memories']); - } - } - - // NULL if invalid - $mapCluster = $mapCluster <= 0 ? null : $mapCluster; + // Get exif json + $exifJson = $this->getExifJson($exif); // Parameters for insert or update $params = [ 'fileid' => $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT), 'objectid' => $query->createNamedParameter((string) $fileId, IQueryBuilder::PARAM_STR), 'dayid' => $query->createNamedParameter($dayId, IQueryBuilder::PARAM_INT), - 'datetaken' => $query->createNamedParameter($dateTaken, IQueryBuilder::PARAM_STR), + 'datetaken' => $query->createNamedParameter($dateTakenStr, IQueryBuilder::PARAM_STR), 'mtime' => $query->createNamedParameter($mtime, IQueryBuilder::PARAM_INT), 'isvideo' => $query->createNamedParameter($isvideo, IQueryBuilder::PARAM_INT), 'video_duration' => $query->createNamedParameter($videoDuration, IQueryBuilder::PARAM_INT), @@ -284,4 +250,38 @@ class TimelineWrite $this->connection->executeStatement($p->getTruncateTableSQL('*PREFIX*'.$table, false)); } } + + /** + * Convert EXIF data to filtered JSON string. + */ + private function getExifJson(array $exif): string + { + // Clean up EXIF to keep only useful metadata + $filteredExif = []; + foreach ($exif as $key => $value) { + // Truncate any fields > 2048 chars + if (\is_string($value) && \strlen($value) > 2048) { + $value = substr($value, 0, 2048); + } + + // Only keep fields in the whitelist + if (\array_key_exists($key, EXIF_FIELDS_LIST)) { + $filteredExif[$key] = $value; + } + } + + // Store JSON string + $exifJson = json_encode($filteredExif); + + // Store error if data > 64kb + if (\is_string($exifJson)) { + if (\strlen($exifJson) > 65535) { + $exifJson = json_encode(['error' => 'Exif data too large']); + } + } else { + $exifJson = json_encode(['error' => 'Exif data encoding error']); + } + + return $exifJson; + } } diff --git a/lib/Db/TimelineWritePlaces.php b/lib/Db/TimelineWritePlaces.php index 86c77c71..c1915429 100644 --- a/lib/Db/TimelineWritePlaces.php +++ b/lib/Db/TimelineWritePlaces.php @@ -6,26 +6,76 @@ namespace OCA\Memories\Db; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; +use Psr\Log\LoggerInterface; trait TimelineWritePlaces { protected IDBConnection $connection; + /** + * Process the location part of exif data. + * + * Also update the exif data with the tzid from location (LocationTZID) + * + * @param int $fileId The file ID + * @param array $exif The exif data + * @param array|bool $prevRow The previous row of data + * + * @return array Update values + */ + protected function processExifLocation(int $fileId, array &$exif, mixed $prevRow): array + { + // Store location data + $lat = \array_key_exists('GPSLatitude', $exif) ? (float) $exif['GPSLatitude'] : null; + $lon = \array_key_exists('GPSLongitude', $exif) ? (float) $exif['GPSLongitude'] : null; + $oldLat = $prevRow ? (float) $prevRow['lat'] : null; + $oldLon = $prevRow ? (float) $prevRow['lon'] : null; + $mapCluster = $prevRow ? (int) $prevRow['mapcluster'] : -1; + $osmIds = []; + + if ($lat || $lon || $oldLat || $oldLon) { + try { + $mapCluster = $this->mapGetCluster($mapCluster, $lat, $lon, $oldLat, $oldLon); + } catch (\Error $e) { + $logger = \OC::$server->get(LoggerInterface::class); + $logger->log(3, 'Error updating map cluster data: '.$e->getMessage(), ['app' => 'memories']); + } + + try { + $osmIds = $this->updatePlacesData($fileId, $lat, $lon); + } catch (\Error $e) { + $logger = \OC::$server->get(LoggerInterface::class); + $logger->log(3, 'Error updating places data: '.$e->getMessage(), ['app' => 'memories']); + } + } + + // NULL if invalid + $mapCluster = $mapCluster <= 0 ? null : $mapCluster; + + // Set tzid from location if not present + $this->setTzidFromLocation($exif, $osmIds); + + // Return update values + return [$lat, $lon, $mapCluster, $osmIds]; + } + /** * Add places data for a file. * * @param int $fileId The file ID * @param null|float $lat The latitude of the file * @param null|float $lon The longitude of the file + * + * @return array The list of osm_id of the places */ - protected function updatePlacesData(int $fileId, $lat, $lon): void + protected function updatePlacesData(int $fileId, $lat, $lon): array { // Get GIS type $gisType = \OCA\Memories\Util::placesGISType(); // Check if valid if ($gisType <= 0) { - return; + return []; } // Delete previous records @@ -37,7 +87,7 @@ trait TimelineWritePlaces // Just remove from if the point is no longer valid if (null === $lat || null === $lon) { - return; + return []; } // Construct WHERE clause depending on GIS type @@ -47,7 +97,7 @@ trait TimelineWritePlaces } elseif (2 === $gisType) { $where = "POINT('{$lon},{$lat}') <@ geometry"; } else { - return; + return []; } // Make query to memories_planet table @@ -82,5 +132,36 @@ trait TimelineWritePlaces } $this->connection->commit(); + + // Return list of osm_id + return array_map(fn ($row) => $row['osm_id'], $rows); + } + + /** + * Set timezone offset from location if not present. + * + * @param array $exif The exif data + * @param array $osmIds The list of osm_id of the places + */ + private function setTzidFromLocation(array &$exif, array $osmIds): void + { + // Make sure we have some places + if (empty($osmIds)) { + return; + } + + // Get timezone offset from places + $query = $this->connection->getQueryBuilder(); + $query->select('name') + ->from('memories_planet') + ->where($query->expr()->in('osm_id', $query->createNamedParameter($osmIds, IQueryBuilder::PARAM_INT_ARRAY))) + ->andWhere($query->expr()->eq('admin_level', $query->createNamedParameter(-7, IQueryBuilder::PARAM_INT))) + ; + + // Get name of timezone + $tzName = $query->executeQuery()->fetchOne(); + if ($tzName) { + $exif['LocationTZID'] = $tzName; + } } } diff --git a/lib/Exif.php b/lib/Exif.php index d7bf31fa..1f2da684 100644 --- a/lib/Exif.php +++ b/lib/Exif.php @@ -157,12 +157,8 @@ class Exif /** * Parse date from exif format and throw error if invalid. - * - * @param array $exif - * - * @return int unix timestamp */ - public static function parseExifDate(array $exif) + public static function parseExifDate(array $exif): \DateTime { // Get date from exif $exifDate = $exif['SubSecDateTimeOriginal'] ?? $exif['DateTimeOriginal'] ?? $exif['CreateDate'] ?? null; @@ -171,13 +167,16 @@ class Exif } // Get timezone from exif - $exifTz = $exif["OffsetTimeOriginal"] ?? $exif["OffsetTime"] ?? null; try { - $parseTz = new \DateTimeZone($exifTz); + $exifTz = $exif['OffsetTimeOriginal'] ?? $exif['OffsetTime'] ?? $exif['LocationTZID'] ?? null; + $exifTz = new \DateTimeZone($exifTz); } catch (\Error $e) { - $parseTz = new \DateTimeZone('UTC'); + $exifTz = null; } + // Force UTC if no timezone found + $parseTz = $exifTz ?? new \DateTimeZone('UTC'); + // https://github.com/pulsejet/memories/pull/397 // https://github.com/pulsejet/memories/issues/485 @@ -199,23 +198,28 @@ class Exif } } + // If we couldn't parse the date, throw an error if (!$parsedDate) { throw new \Exception("Invalid date: {$exifDate}"); } + // Filter out dates before 1800 A.D. if ($parsedDate->getTimestamp() < -5364662400) { // 1800 A.D. throw new \Exception("Date too old: {$exifDate}"); } - return $parsedDate->getTimestamp(); + // Force the timezone to be the same as parseTz + if ($exifTz) { + $parsedDate->setTimezone($exifTz); + } + + return $parsedDate; } /** * Get the date taken from either the file or exif data if available. - * - * @return int unix timestamp */ - public static function getDateTaken(File $file, array $exif) + public static function getDateTaken(File $file, array $exif): \DateTime { try { return self::parseExifDate($exif); @@ -224,7 +228,24 @@ class Exif } // Fall back to modification time - return $file->getMtime(); + try { + $parseTz = new \DateTimeZone(getenv('TZ')); // debian + } catch (\Error $e) { + $parseTz = new \DateTimeZone('UTC'); + } + + $dt = new \DateTime('@'.$file->getMtime(), $parseTz); + $dt->setTimezone($parseTz); + + return self::forgetTimezone($dt); + } + + /** + * Convert time to local date in UTC. + */ + public static function forgetTimezone(\DateTime $date): \DateTime + { + return new \DateTime($date->format('Y-m-d H:i:s'), new \DateTimeZone('UTC')); } /** diff --git a/lib/ExifFields.php b/lib/ExifFields.php index a827c589..690fe258 100644 --- a/lib/ExifFields.php +++ b/lib/ExifFields.php @@ -9,6 +9,10 @@ const EXIF_FIELDS_LIST = [ 'OffsetTime' => true, 'ModifyDate' => true, + // Generated date fields + 'DateTimeEpoch' => true, + 'LocationTZID' => true, + // Camera Info 'Make' => true, 'Model' => true, diff --git a/package-lock.json b/package-lock.json index a45f6100..d95e78ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "leaflet": "^1.9.3", "leaflet-edgebuffer": "^1.0.6", "moment": "^2.29.4", + "moment-timezone": "^0.5.42", "path-posix": "^1.0.0", "photoswipe": "^5.3.6", "plyr": "^3.7.7", @@ -7248,6 +7249,17 @@ "node": "*" } }, + "node_modules/moment-timezone": { + "version": "0.5.42", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.42.tgz", + "integrity": "sha512-tjI9goqwzkflKSTxJo+jC/W8riTFwEjjunssmFvAWlvNVApjbkJM7UHggyKO0q1Fd/kZVKY77H7C9A0XKhhAFw==", + "dependencies": { + "moment": "^2.29.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/mpd-parser": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-1.0.1.tgz", @@ -16810,6 +16822,14 @@ "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==" }, + "moment-timezone": { + "version": "0.5.42", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.42.tgz", + "integrity": "sha512-tjI9goqwzkflKSTxJo+jC/W8riTFwEjjunssmFvAWlvNVApjbkJM7UHggyKO0q1Fd/kZVKY77H7C9A0XKhhAFw==", + "requires": { + "moment": "^2.29.4" + } + }, "mpd-parser": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-1.0.1.tgz", diff --git a/package.json b/package.json index 56fbbeb7..60a5b920 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "leaflet": "^1.9.3", "leaflet-edgebuffer": "^1.0.6", "moment": "^2.29.4", + "moment-timezone": "^0.5.42", "path-posix": "^1.0.0", "photoswipe": "^5.3.6", "plyr": "^3.7.7", diff --git a/src/components/Metadata.vue b/src/components/Metadata.vue index 25f3b0b6..94298cb8 100644 --- a/src/components/Metadata.vue +++ b/src/components/Metadata.vue @@ -58,7 +58,9 @@ import NcLoadingIcon from "@nextcloud/vue/dist/Components/NcLoadingIcon"; import axios from "@nextcloud/axios"; import { subscribe, unsubscribe } from "@nextcloud/event-bus"; import { getCanonicalLocale } from "@nextcloud/l10n"; + import moment from "moment"; +import "moment-timezone"; import * as utils from "../services/Utils"; @@ -176,26 +178,45 @@ export default defineComponent({ /** Date taken info */ dateOriginal(): moment.Moment | null { - const m = moment.utc(this.baseInfo.datetaken * 1000); + const epoch = this.exif.DateTimeEpoch || this.baseInfo.datetaken; + const m = moment.utc(epoch * 1000); if (!m.isValid()) return null; m.locale(getCanonicalLocale()); - // set timezeon - const tz = this.exif["OffsetTimeOriginal"] || this.exif["OffsetTime"]; - if (tz) m.utcOffset(tz); + // The fallback to datetaken can be eventually removed + // and then this can be discarded + if (this.exif.DateTimeEpoch) { + const tzOffset = + this.exif["OffsetTimeOriginal"] || this.exif["OffsetTime"]; + const tzId = this.exif["LocationTZID"]; + + if (tzOffset) { + m.utcOffset(tzOffset); + } else if (tzId) { + m.tz(tzId); + } + } return m; }, dateOriginalStr(): string | null { - if (!this.dateOriginal) return null; - return utils.getLongDateStr(this.dateOriginal.toDate(), true); + return utils.getLongDateStr( + new Date(this.baseInfo.datetaken * 1000), + true + ); }, dateOriginalTime(): string[] | null { if (!this.dateOriginal) return null; - return [this.dateOriginal.format("h:mm A Z")]; + let format = "h:mm A"; + const fields = ["OffsetTimeOriginal", "OffsetTime", "LocationTZID"]; + if (fields.some((key) => this.exif[key])) { + format += " Z"; + } + + return [this.dateOriginal.format(format)]; }, /** Camera make and model info */