diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ff1217e..bb4f1692 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] - **Feature**: RAW files are now hidden (stacked) when another file with the same basename exists ([#537](https://github.com/pulsejet/memories/issues/537), [#152](https://github.com/pulsejet/memories/issues/152), [#419](https://github.com/pulsejet/memories/issues/419)) +- **Feature**: Bulk rotating of images. You can now rotate images losslessly by editing the rotation EXIF metadata. ([#856](https://github.com/pulsejet/memories/issues/856)) - **Feature**: Icon animation when playing live photos ([#898](https://github.com/pulsejet/memories/issues/898)) - **Feature**: Swipe to refresh on timeline ([#547](https://github.com/pulsejet/memories/issues/547)) - **Bugfix**: Allow switching video to direct on Safari ([#650](https://github.com/pulsejet/memories/issues/650)) diff --git a/lib/Controller/ImageController.php b/lib/Controller/ImageController.php index 77a28d73..5e2c7a05 100644 --- a/lib/Controller/ImageController.php +++ b/lib/Controller/ImageController.php @@ -259,7 +259,12 @@ class ImageController extends GenericApiController // Set the exif data Exif::setFileExif($file, $raw); - return new JSONResponse([], Http::STATUS_OK); + // If rotation changed then update the previews + if ($raw['Orientation'] ?? false) { + $this->deletePreviews($file); + } + + return $this->info($id, true); }); } @@ -452,4 +457,22 @@ class ImageController extends GenericApiController // Get the tag names return array_map(static fn ($t) => $t->getName(), $visible); } + + /** + * Invalidate previews for a file. + */ + private function deletePreviews(\OCP\Files\File $file): void + { + try { + $previewRoot = new \OC\Preview\Storage\Root( + \OC::$server->get(IRootFolder::class), + \OC::$server->get(\OC\SystemConfig::class), + ); + + $fileId = (string) $file->getId(); + $previewRoot->getFolder($fileId)->delete(); + } catch (\Exception $e) { + return; + } + } } diff --git a/lib/Exif.php b/lib/Exif.php index 1d085e66..30fc9396 100644 --- a/lib/Exif.php +++ b/lib/Exif.php @@ -334,7 +334,7 @@ class Exif $data['SourceFile'] = $path; $raw = json_encode([$data], JSON_UNESCAPED_UNICODE); $cmd = array_merge(self::getExiftool(), [ - '-overwrite_original', + '-overwrite_original', '-n', '-api', 'LargeFileSupport=1', '-json=-', $path, ]); diff --git a/src/components/SelectionManager.vue b/src/components/SelectionManager.vue index 3b543334..9067adde 100644 --- a/src/components/SelectionManager.vue +++ b/src/components/SelectionManager.vue @@ -62,6 +62,7 @@ import MoveIcon from 'vue-material-design-icons/ImageMove.vue'; import AlbumsIcon from 'vue-material-design-icons/ImageAlbum.vue'; import AlbumRemoveIcon from 'vue-material-design-icons/BookRemove.vue'; import FolderMoveIcon from 'vue-material-design-icons/FolderMove.vue'; +import RotateLeftIcon from 'vue-material-design-icons/RotateLeft.vue'; import type { IDay, IHeadRow, IPhoto, IRow } from '@typings'; @@ -231,6 +232,11 @@ export default defineComponent({ icon: EditFileIcon, callback: this.editMetadataSelection.bind(this), }, + { + name: t('memories', 'Rotate / Flip'), + icon: RotateLeftIcon, + callback: () => this.editMetadataSelection(this.selection, [5]), + }, { name: t('memories', 'View in folder'), icon: OpenInNewIcon, diff --git a/src/components/modal/EditMetadataModal.vue b/src/components/modal/EditMetadataModal.vue index 9cd5459b..3f7e07bf 100644 --- a/src/components/modal/EditMetadataModal.vue +++ b/src/components/modal/EditMetadataModal.vue @@ -39,6 +39,13 @@ + +
+
+ {{ t('memories', 'Rotation') }} +
+ +
@@ -65,6 +72,7 @@ import EditDate from './EditDate.vue'; import EditTags from './EditTags.vue'; import EditExif from './EditExif.vue'; import EditLocation from './EditLocation.vue'; +import EditOrientation from './EditOrientation.vue'; import { showError } from '@nextcloud/dialogs'; import axios from '@nextcloud/axios'; @@ -86,6 +94,7 @@ export default defineComponent({ EditTags, EditExif, EditLocation, + EditOrientation, }, mixins: [UserConfig, ModalMixin], @@ -105,6 +114,7 @@ export default defineComponent({ editTags?: InstanceType; editExif?: InstanceType; editLocation?: InstanceType; + editOrientation?: InstanceType; }; }, }, @@ -225,6 +235,12 @@ export default defineComponent({ raw.CreateDate = date; } + // Orientation + const orientation = this.refs.editOrientation?.result?.(p); + if (orientation !== null) { + raw.Orientation = orientation; + } + exifs.set(p.fileid, raw); } @@ -266,8 +282,20 @@ export default defineComponent({ // Update EXIF if required const raw = exifs.get(fileid) ?? {}; if (Object.keys(raw).length > 0) { - await axios.patch(API.IMAGE_SETEXIF(fileid), { raw }); + const info = await axios.patch(API.IMAGE_SETEXIF(fileid), { raw }); dirty = true; + + // Update image size + p.h = info.data?.h ?? p.h; + p.w = info.data?.w ?? p.w; + + // If orientation was updated we need to change + // the ETag so that the preview is updated. + // Deliberately don't change the tag otherwise, + // so there's no need to re-download the image. + if (raw.Orientation) { + p.etag = info.data?.etag ?? p.etag; + } } // Update tags if required diff --git a/src/components/modal/EditOrientation.vue b/src/components/modal/EditOrientation.vue new file mode 100644 index 00000000..b44626f7 --- /dev/null +++ b/src/components/modal/EditOrientation.vue @@ -0,0 +1,251 @@ + + + + + diff --git a/src/components/viewer/Viewer.vue b/src/components/viewer/Viewer.vue index 8283fc56..8ad40922 100644 --- a/src/components/viewer/Viewer.vue +++ b/src/components/viewer/Viewer.vue @@ -155,6 +155,17 @@ + + {{ t('memories', 'Rotate / Flip') }} + +