diff --git a/appinfo/routes.php b/appinfo/routes.php index 8b118c2b..be5641a9 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -17,6 +17,8 @@ return [ // API ['name' => 'api#days', 'url' => '/api/days', 'verb' => 'GET'], ['name' => 'api#day', 'url' => '/api/days/{id}', 'verb' => 'GET'], + ['name' => 'api#imageInfo', 'url' => '/api/info/{id}', 'verb' => 'GET'], + ['name' => 'api#imageEdit', 'url' => '/api/edit/{id}', 'verb' => 'PATCH'], // Config API ['name' => 'api#setUserConfig', 'url' => '/api/config/{key}', 'verb' => 'PUT'], diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index e00ae44b..19a4db7e 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -25,10 +25,9 @@ declare(strict_types=1); namespace OCA\Memories\Controller; -use OC\Files\Search\SearchComparison; -use OC\Files\Search\SearchQuery; use OCA\Memories\AppInfo\Application; use OCA\Memories\Db\TimelineQuery; +use OCA\Memories\Db\TimelineWrite; use OCA\Memories\Exif; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; @@ -40,7 +39,6 @@ use OCP\IRequest; use OCP\IUserSession; use OCP\Files\FileInfo; use OCP\Files\Folder; -use OCP\Files\Search\ISearchComparison; class ApiController extends Controller { private IConfig $config; @@ -48,6 +46,7 @@ class ApiController extends Controller { private IDBConnection $connection; private IRootFolder $rootFolder; private TimelineQuery $timelineQuery; + private TimelineWrite $timelineWrite; public function __construct( IRequest $request, @@ -63,6 +62,7 @@ class ApiController extends Controller { $this->connection = $connection; $this->rootFolder = $rootFolder; $this->timelineQuery = new TimelineQuery($this->connection); + $this->timelineWrite = new TimelineWrite($connection); } /** @@ -232,6 +232,84 @@ class ApiController extends Controller { ]; } + /** + * @NoAdminRequired + * + * Get image info for one file + * @param string fileid + */ + public function imageInfo(string $id): JSONResponse { + $user = $this->userSession->getUser(); + if (is_null($user)) { + return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); + } + $userFolder = $this->rootFolder->getUserFolder($user->getUID()); + + // Check for permissions and get numeric Id + $file = $userFolder->getById(intval($id)); + if (count($file) === 0) { + return new JSONResponse([], Http::STATUS_NOT_FOUND); + } + $file = $file[0]; + + // Get the image info + $info = $this->timelineQuery->getInfoById($file->getId()); + + return new JSONResponse($info, Http::STATUS_OK); + } + + /** + * @NoAdminRequired + * @NoCSRFRequired + * + * Change exif data for one file + * @param string fileid + */ + public function imageEdit(string $id): JSONResponse { + $user = $this->userSession->getUser(); + if (is_null($user)) { + return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); + } + $userFolder = $this->rootFolder->getUserFolder($user->getUID()); + + // Check for permissions and get numeric Id + $file = $userFolder->getById(intval($id)); + if (count($file) === 0) { + return new JSONResponse([], Http::STATUS_NOT_FOUND); + } + $file = $file[0]; + + // TODO: check permissions + + // Get new date from body + $body = $this->request->getParams(); + if (!isset($body['date'])) { + return new JSONResponse(["message" => "Missing date"], Http::STATUS_BAD_REQUEST); + } + + // Make sure the date is valid + try { + Exif::parseExifDate($body['date']); + } catch (\Exception $e) { + return new JSONResponse(["message" => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + + // Update date + try { + $res = Exif::updateExifDate($file, $body['date']); + if ($res === false) { + return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } catch (\Exception $e) { + return new JSONResponse(["message" => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + // Reprocess the file + $this->timelineWrite->processFile($file, true); + + return $this->imageInfo($id); + } + /** * @NoAdminRequired * diff --git a/lib/Db/TimelineQuery.php b/lib/Db/TimelineQuery.php index c2dca5dd..0ffd279a 100644 --- a/lib/Db/TimelineQuery.php +++ b/lib/Db/TimelineQuery.php @@ -14,4 +14,21 @@ class TimelineQuery { public function __construct(IDBConnection $connection) { $this->connection = $connection; } + + public function getInfoById(int $id): array { + $qb = $this->connection->getQueryBuilder(); + $qb->select('*') + ->from('memories') + ->where($qb->expr()->eq('fileid', $qb->createNamedParameter($id, \PDO::PARAM_INT))); + + $result = $qb->executeQuery(); + $row = $result->fetch(); + $result->closeCursor(); + + return [ + 'fileid' => intval($row['fileid']), + 'dayid' => intval($row['dayid']), + 'datetaken' => $row['datetaken'], + ]; + } } \ No newline at end of file diff --git a/lib/Exif.php b/lib/Exif.php index d690cdef..90933da9 100644 --- a/lib/Exif.php +++ b/lib/Exif.php @@ -243,6 +243,30 @@ class Exif { return $json[0]; } + /** + * Parse date from exif format and throw error if invalid + * + * @param string $dt + * @return int unix timestamp + */ + public static function parseExifDate($date) { + $dt = $date; + if (isset($dt) && is_string($dt) && !empty($dt)) { + $dt = explode('-', explode('+', $dt, 2)[0], 2)[0]; // get rid of timezone if present + $dt = \DateTime::createFromFormat('Y:m:d H:i:s', $dt); + if (!$dt) { + throw new \Exception("Invalid date: $date"); + } + if ($dt && $dt->getTimestamp() > -5364662400) { // 1800 A.D. + return $dt->getTimestamp(); + } else { + throw new \Exception("Date too old: $date"); + } + } else { + throw new \Exception("No date provided"); + } + } + /** * Get the date taken from either the file or exif data if available. * @param File $file @@ -255,13 +279,9 @@ class Exif { } // Check if found something - if (isset($dt) && is_string($dt) && !empty($dt)) { - $dt = explode('-', explode('+', $dt, 2)[0], 2)[0]; // get rid of timezone if present - $dt = \DateTime::createFromFormat('Y:m:d H:i:s', $dt, new \DateTimeZone("UTC")); - if ($dt && $dt->getTimestamp() > -5364662400) { // 1800 A.D. - return $dt->getTimestamp(); - } - } + try { + return self::parseExifDate($dt); + } catch (\Exception $ex) {} // Fall back to creation time $dateTaken = $file->getCreationTime(); @@ -272,4 +292,141 @@ class Exif { } return $dateTaken; } + + /** + * Update exif date using exiftool + * + * @param File $file + * @param string $newDate formatted in standard Exif format (YYYY:MM:DD HH:MM:SS) + */ + public static function updateExifDate(File &$file, string $newDate) { + // Check for local files -- this is easier + if (!$file->isEncrypted() && $file->getStorage()->isLocal()) { + $path = $file->getStorage()->getLocalFile($file->getInternalPath()); + if (is_string($path)) { + return self::updateExifDateForLocalFile($path, $newDate); + } + } + + // Use a stream + return self::updateExifDateForStreamFile($file, $newDate); + } + + /** + * Update exif date using exiftool for a local file + * + * @param string $path + * @param string $newDate formatted in standard Exif format (YYYY:MM:DD HH:MM:SS) + * @return bool + */ + private static function updateExifDateForLocalFile(string $path, string $newDate) { + $cmd = ['exiftool', '-api', 'QuickTimeUTC=1', '-overwrite_original', '-DateTimeOriginal=' . $newDate, $path]; + $proc = proc_open($cmd, [ + 1 => array('pipe', 'w'), + 2 => array('pipe', 'w'), + ], $pipes); + $stdout = self::readOrTimeout($pipes[1], 300000); + fclose($pipes[1]); + fclose($pipes[2]); + proc_terminate($proc); + if (strpos($stdout, 'error') !== false) { + error_log("Exiftool error: $stdout"); + throw new \Exception("Could not update exif date: " . $stdout); + } + return true; + } + + /** + * Update exif date for stream + * + * @param File $file + * @param string $newDate formatted in standard Exif format (YYYY:MM:DD HH:MM:SS) + */ + public static function updateExifDateForStreamFile(File &$file, string $newDate) { + // Temp file for output, so we can compare sizes before writing to the actual file + $tmpfile = tmpfile(); + + try { + // Start exiftool and output to json + $pipes = []; + $proc = proc_open([ + 'exiftool', '-api', 'QuickTimeUTC=1', + '-overwrite_original', '-DateTimeOriginal=' . $newDate, '-' + ], [ + 0 => array('pipe', 'rb'), + 1 => array('pipe', 'w'), + 2 => array('pipe', 'w'), + ], $pipes); + + // Write the file to exiftool's stdin + // Warning: this is slow for big files + $in = $file->fopen('rb'); + if (!$in) { + throw new \Exception('Could not open file'); + } + $origLen = stream_copy_to_stream($in, $pipes[0]); + fclose($in); + fclose($pipes[0]); + + // Get output from exiftool + stream_set_blocking($pipes[1], false); + $newLen = 0; + + try { + // Read and copy stdout of exiftool to the temp file + $waitedMs = 0; + $timeout = 300000; + while ($waitedMs < $timeout && !feof($pipes[1])) { + $r = stream_copy_to_stream($pipes[1], $tmpfile, 1024 * 1024); + $newLen += $r; + if ($r === 0) { + $waitedMs++; + usleep(1000); + continue; + } + } + if ($waitedMs >= $timeout) { + throw new \Exception('Timeout'); + } + } catch (\Exception $ex) { + error_log("Exiftool timeout for file stream: " . $ex->getMessage()); + throw new \Exception("Could not read from Exiftool"); + } finally { + // Close the pipes + fclose($pipes[1]); + fclose($pipes[2]); + proc_terminate($proc); + } + + // Check the new length of the file + // If the new length and old length are more different than 1KB, abort + if (abs($newLen - $origLen) > 1024) { + error_log("Exiftool error: new length $newLen, old length $origLen"); + throw new \Exception("Exiftool error: new length $newLen, old length $origLen"); + } + + // Write the temp file to the actual file + fseek($tmpfile, 0); + $out = $file->fopen('wb'); + if (!$out) { + throw new \Exception('Could not open file for writing'); + } + $wroteBytes = 0; + try { + $wroteBytes = stream_copy_to_stream($tmpfile, $out); + } finally { + fclose($out); + } + if ($wroteBytes !== $newLen) { + error_log("Exiftool error: wrote $r bytes, expected $newLen"); + throw new \Exception("Could not write to file"); + } + + // All done at this point + return true; + } finally { + // Close the temp file + fclose($tmpfile); + } + } } \ No newline at end of file diff --git a/src/components/EditDate.vue b/src/components/EditDate.vue new file mode 100644 index 00000000..9a7af468 --- /dev/null +++ b/src/components/EditDate.vue @@ -0,0 +1,160 @@ + + + + + \ No newline at end of file diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue index 80896a9e..5610fd1b 100644 --- a/src/components/Timeline.vue +++ b/src/components/Timeline.vue @@ -111,8 +111,16 @@ {{ t('memories', 'Favorite') }} + + {{ t('memories', 'Edit Date/Time') }} + + + + @@ -129,6 +137,7 @@ import * as utils from "../services/Utils"; import axios from '@nextcloud/axios' import Folder from "./Folder.vue"; import Photo from "./Photo.vue"; +import EditDate from "./EditDate.vue"; import FolderTopMatter from "./FolderTopMatter.vue"; import UserConfig from "../mixins/UserConfig"; @@ -137,6 +146,7 @@ import Download from 'vue-material-design-icons/Download.vue'; import Delete from 'vue-material-design-icons/Delete.vue'; import Close from 'vue-material-design-icons/Close.vue'; import CheckCircle from 'vue-material-design-icons/CheckCircle.vue'; +import EditIcon from 'vue-material-design-icons/ClockEdit.vue'; const SCROLL_LOAD_DELAY = 100; // Delay in loading data when scrolling const MAX_PHOTO_WIDTH = 175; // Max width of a photo @@ -155,6 +165,7 @@ for (const [key, value] of Object.entries(API_ROUTES)) { components: { Folder, Photo, + EditDate, FolderTopMatter, NcActions, NcActionButton, @@ -165,6 +176,7 @@ for (const [key, value] of Object.entries(API_ROUTES)) { Delete, Close, CheckCircle, + EditIcon, } }) export default class Timeline extends Mixins(GlobalMixin, UserConfig) { @@ -1057,6 +1069,13 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) { } } + /** + * Open the edit date dialog + */ + async editDateSelection() { + (this.$refs.editDate).open(Array.from(this.selection.values())); + } + /** * Delete elements from main view with some animation * This function looks horribly slow, probably isn't that bad diff --git a/src/services/Utils.ts b/src/services/Utils.ts index 39ab1109..8127ba3b 100644 --- a/src/services/Utils.ts +++ b/src/services/Utils.ts @@ -15,12 +15,14 @@ export function getShortDateStr(date: Date) { } /** Get long date string with optional year if same as current */ -export function getLongDateStr(date: Date, skipYear=false) { +export function getLongDateStr(date: Date, skipYear=false, time=false) { return date.toLocaleDateString(getCanonicalLocale(), { weekday: 'long', month: 'long', day: 'numeric', year: (skipYear && date.getUTCFullYear() === new Date().getUTCFullYear()) ? undefined : 'numeric', timeZone: 'UTC', + hour: time ? 'numeric' : undefined, + minute: time ? 'numeric' : undefined, }); } \ No newline at end of file