diff --git a/CHANGELOG.md b/CHANGELOG.md index 235aa014..678372fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ This file is manually updated. Please file an issue if something is missing. - **Feature**: Show EXIF metadata in sidebar ([#68](https://github.com/pulsejet/memories/issues/68)) - **Feature**: Show duration on video tiles - Fix stretched images in viewer ([#176](https://github.com/pulsejet/memories/issues/176)) +- Restore metadata after image edit ([#174](https://github.com/pulsejet/memories/issues/174)) ## v4.6.1, v3.6.1 (2022-11-07) diff --git a/appinfo/routes.php b/appinfo/routes.php index 3bee0f37..e480734f 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -52,8 +52,9 @@ return [ ['name' => 'Faces#faces', 'url' => '/api/faces', 'verb' => 'GET'], ['name' => 'Faces#preview', 'url' => '/api/faces/preview/{id}', 'verb' => 'GET'], - ['name' => 'Image#info', 'url' => '/api/info/{id}', 'verb' => 'GET'], - ['name' => 'Image#edit', 'url' => '/api/edit/{id}', 'verb' => 'PATCH'], + ['name' => 'Image#info', 'url' => '/api/image/info/{id}', 'verb' => 'GET'], + ['name' => 'Image#edit', 'url' => '/api/image/edit/{id}', 'verb' => 'PATCH'], + ['name' => 'Image#setExif', 'url' => '/api/image/set-exif/{id}', 'verb' => 'PUT'], ['name' => 'Archive#archive', 'url' => '/api/archive/{id}', 'verb' => 'PATCH'], diff --git a/lib/Controller/ImageController.php b/lib/Controller/ImageController.php index d790ee83..b793318d 100644 --- a/lib/Controller/ImageController.php +++ b/lib/Controller/ImageController.php @@ -55,6 +55,11 @@ class ImageController extends ApiBase $basic = false !== $this->request->getParam('basic', false); $info = $this->timelineQuery->getInfoById($file->getId(), $basic); + // Get latest exif data if requested + if ($this->request->getParam('current', false)) { + $info["current"] = Exif::getExifFromFile($file); + } + return new JSONResponse($info, Http::STATUS_OK); } @@ -113,4 +118,42 @@ class ImageController extends ApiBase return $this->info($id); } + + /** + * Set the exif data for a file + */ + public function setExif(string $id): JSONResponse + { + $user = $this->userSession->getUser(); + if (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((int) $id); + if (0 === \count($file)) { + return new JSONResponse([], Http::STATUS_NOT_FOUND); + } + $file = $file[0]; + + // Check if user has permissions + if (!$file->isUpdateable()) { + return new JSONResponse([], Http::STATUS_FORBIDDEN); + } + + // Get original file from body + $exif = $this->request->getParam('raw'); + $path = $file->getStorage()->getLocalFile($file->getInternalPath()); + try { + Exif::setExif($path, $exif); + } catch (\Exception $e) { + return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + // Reprocess the file + $this->timelineWrite->processFile($file, true); + + return new JSONResponse([], Http::STATUS_OK); + } } diff --git a/lib/Db/TimelineWrite.php b/lib/Db/TimelineWrite.php index 19da9a3a..a1eceba7 100644 --- a/lib/Db/TimelineWrite.php +++ b/lib/Db/TimelineWrite.php @@ -103,10 +103,6 @@ class TimelineWrite $videoDuration = round($exif['Duration'] ?? $exif['TrackDuration'] ?? 0); } - // Store raw metadata in the database - // We need to remove blacklisted fields to prevent leaking info - unset($exif['SourceFile'], $exif['FileName'], $exif['ExifToolVersion'], $exif['Directory'], $exif['FileSize'], $exif['FileModifyDate'], $exif['FileAccessDate'], $exif['FileInodeChangeDate'], $exif['FilePermissions']); - // Truncate any fields >2048 chars foreach ($exif as $key => &$value) { if (\is_string($value) && \strlen($value) > 2048) { diff --git a/lib/Exif.php b/lib/Exif.php index 756a7ac2..80313f3c 100644 --- a/lib/Exif.php +++ b/lib/Exif.php @@ -109,7 +109,12 @@ class Exif throw new \Exception('Failed to get local file path'); } - return self::getExifFromLocalPath($path); + $exif = self::getExifFromLocalPath($path); + + // We need to remove blacklisted fields to prevent leaking info + unset($exif['SourceFile'], $exif['FileName'], $exif['ExifToolVersion'], $exif['Directory'], $exif['FileSize'], $exif['FileModifyDate'], $exif['FileAccessDate'], $exif['FileInodeChangeDate'], $exif['FilePermissions'], $exif['ThumbnailImage']); + + return $exif; } /** Get exif data as a JSON object from a local file path */ @@ -357,7 +362,7 @@ class Exif private static function getExifFromLocalPathWithStaticProc(string &$path) { - fwrite(self::$staticPipes[0], "{$path}\n-json\n-api\nQuickTimeUTC=1\n-n\n-execute\n"); + fwrite(self::$staticPipes[0], "{$path}\n-json\n-b\n-api\nQuickTimeUTC=1\n-n\n-execute\n"); fflush(self::$staticPipes[0]); $readyToken = "\n{ready}\n"; @@ -379,7 +384,7 @@ class Exif private static function getExifFromLocalPathWithSeparateProc(string &$path) { $pipes = []; - $proc = proc_open(array_merge(self::getExiftool(), ['-api', 'QuickTimeUTC=1', '-n', '-json', $path]), [ + $proc = proc_open(array_merge(self::getExiftool(), ['-api', 'QuickTimeUTC=1', '-n', '-json', '-b', $path]), [ 1 => ['pipe', 'w'], 2 => ['pipe', 'w'], ], $pipes); @@ -435,4 +440,37 @@ class Exif throw new \Exception('Could not update exif date: '.$stdout); } } + + /** + * Set exif data using raw json. + * + * @param string $path to local file + * @param array $data exif data + * + * @throws \Exception on failure + */ + public static function setExif(string &$path, array &$data) + { + $data['SourceFile'] = $path; + $raw = json_encode([$data]); + $cmd = array_merge(self::getExiftool(), ['-json=-', $path]); + $proc = proc_open($cmd, [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], $pipes); + + fwrite($pipes[0], $raw); + fclose($pipes[0]); + + $stdout = self::readOrTimeout($pipes[1], 30000); + fclose($pipes[1]); + fclose($pipes[2]); + proc_terminate($proc); + if (false !== strpos($stdout, 'error')) { + error_log("Exiftool error: {$stdout}"); + + throw new \Exception('Could not set exif data: '.$stdout); + } + } } diff --git a/src/components/ImageEditor.vue b/src/components/ImageEditor.vue index 01bdedc9..17f7e246 100644 --- a/src/components/ImageEditor.vue +++ b/src/components/ImageEditor.vue @@ -9,7 +9,9 @@ import GlobalMixin from "../mixins/GlobalMixin"; import { basename, dirname, extname, join } from "path"; import { emit } from "@nextcloud/event-bus"; import { showError, showSuccess } from "@nextcloud/dialogs"; +import { generateUrl } from "@nextcloud/router"; import axios from "@nextcloud/axios"; + import FilerobotImageEditor from "filerobot-image-editor"; import { FilerobotImageEditorConfig } from "react-filerobot-image-editor"; @@ -25,6 +27,8 @@ export default class ImageEditor extends Mixins(GlobalMixin) { @Prop() mime: string; @Prop() src: string; + private exif: any = null; + private imageEditor: FilerobotImageEditor = null; get config(): FilerobotImageEditorConfig & { theme: any } { @@ -117,7 +121,7 @@ export default class ImageEditor extends Mixins(GlobalMixin) { }; } - mounted() { + async mounted() { this.imageEditor = new FilerobotImageEditor( this.$refs.editor, this.config @@ -125,6 +129,25 @@ export default class ImageEditor extends Mixins(GlobalMixin) { this.imageEditor.render(); window.addEventListener("keydown", this.handleKeydown, true); window.addEventListener("DOMNodeInserted", this.handleSfxModal); + + // Get latest exif data + try { + const res = await axios.get( + generateUrl("/apps/memories/api/image/info/{id}?basic=1¤t=1", { + id: this.fileid, + }) + ); + + this.exif = res.data?.current; + if (!this.exif) { + throw new Error("No exif data"); + } + } catch (err) { + console.error(err); + alert( + this.t("memories", "Failed to get Exif data. Metadata may be lost!") + ); + } } beforeDestroy() { @@ -132,6 +155,7 @@ export default class ImageEditor extends Mixins(GlobalMixin) { this.imageEditor.terminate(); } window.removeEventListener("keydown", this.handleKeydown, true); + window.removeEventListener("DOMNodeInserted", this.handleSfxModal); } onClose(closingReason, haveNotSavedChanges) { @@ -173,20 +197,51 @@ export default class ImageEditor extends Mixins(GlobalMixin) { // Sanity check, 0 < quality < 1 quality = Math.max(Math.min(quality, 1), 0) || 1; + if ( + !this.exif && + !confirm(this.t("memories", "No Exif data found! Continue?")) + ) { + return; + } + try { const blob = await new Promise((resolve: BlobCallback) => imageCanvas.toBlob(resolve, mimeType, quality) ); const response = await axios.put(putUrl, new File([blob], fullName)); + const fileid = + parseInt(response?.headers?.["oc-fileid"]?.split("oc")[0]) || null; + if (response.status >= 400) { + throw new Error("Failed to save image"); + } + + // Strip old and incorrect exif data + const exif = this.exif; + delete exif.Orientation; + delete exif.Rotation; + delete exif.ImageHeight; + delete exif.ImageWidth; + delete exif.ImageSize; + delete exif.ModifyDate; + delete exif.ExifImageHeight; + delete exif.ExifImageWidth; + delete exif.ExifImageSize; + + // Update exif data + await axios.put( + generateUrl("/apps/memories/api/image/set-exif/{id}", { + id: fileid, + }), + { + raw: exif, + } + ); showSuccess(this.t("memories", "Image saved successfully")); - if (putUrl !== this.src) { - emit("files:file:created", { - fileid: - parseInt(response?.headers?.["oc-fileid"]?.split("oc")[0]) || null, - }); + if (fileid !== this.fileid) { + emit("files:file:created", { fileid }); } else { - emit("files:file:updated", { fileid: this.fileid }); + emit("files:file:updated", { fileid }); } this.onClose(undefined, false); } catch (error) { diff --git a/src/components/Metadata.vue b/src/components/Metadata.vue index 960bb27d..3985713c 100644 --- a/src/components/Metadata.vue +++ b/src/components/Metadata.vue @@ -87,7 +87,7 @@ export default class Metadata extends Mixins(GlobalMixin) { let state = this.state; const res = await axios.get( - generateUrl("/apps/memories/api/info/{id}", { id: fileInfo.id }) + generateUrl("/apps/memories/api/image/info/{id}", { id: fileInfo.id }) ); if (state !== this.state) return; @@ -193,6 +193,7 @@ export default class Metadata extends Mixins(GlobalMixin) { const make = this.exif["Make"]; const model = this.exif["Model"]; if (!make || !model) return null; + if (model.startsWith(make)) return model; return `${make} ${model}`; } diff --git a/src/components/PsVideo.ts b/src/components/PsVideo.ts index d88d58c0..b9660dc7 100644 --- a/src/components/PsVideo.ts +++ b/src/components/PsVideo.ts @@ -152,7 +152,7 @@ class VideoContentSetup { // Get correct orientation axios .get( - generateUrl("/apps/memories/api/info/{id}", { + generateUrl("/apps/memories/api/image/info/{id}", { id: content.data.photo.fileid, }) ) diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue index 19050a59..e19bbc57 100644 --- a/src/components/Timeline.vue +++ b/src/components/Timeline.vue @@ -286,11 +286,13 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) { beforeDestroy() { unsubscribe(this.config_eventName, this.softRefresh); + unsubscribe("files:file:created", this.softRefresh); this.resetState(); } created() { subscribe(this.config_eventName, this.softRefresh); + subscribe("files:file:created", this.softRefresh); window.addEventListener("resize", this.handleResizeWithDelay); } diff --git a/src/components/Viewer.vue b/src/components/Viewer.vue index c3baa61f..d0bc8859 100644 --- a/src/components/Viewer.vue +++ b/src/components/Viewer.vue @@ -229,7 +229,6 @@ export default class Viewer extends Mixins(GlobalMixin) { /** Event on file changed */ handleFileUpdated({ fileid }: { fileid: number }) { - console.log("file updated", fileid); if (this.currentPhoto && this.currentPhoto.fileid === fileid) { this.currentPhoto.etag += "_"; this.photoswipe.refreshSlideContent(this.currIndex); diff --git a/src/components/modal/EditDate.vue b/src/components/modal/EditDate.vue index 985ec7a6..8cf5ec3c 100644 --- a/src/components/modal/EditDate.vue +++ b/src/components/modal/EditDate.vue @@ -143,8 +143,8 @@ import axios from "@nextcloud/axios"; import * as utils from "../../services/Utils"; import * as dav from "../../services/DavRequests"; -const INFO_API_URL = "/apps/memories/api/info/{id}"; -const EDIT_API_URL = "/apps/memories/api/edit/{id}"; +const INFO_API_URL = "/apps/memories/api/image/info/{id}"; +const EDIT_API_URL = "/apps/memories/api/image/edit/{id}"; @Component({ components: {