diff --git a/CHANGELOG.md b/CHANGELOG.md index bac68c8b5..bb4f16926 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,9 @@ 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)) - Many other [bug fixes](https://github.com/pulsejet/memories/milestone/18?closed=1) - Android app is now open source ([see](https://github.com/pulsejet/memories/tree/master/android)) diff --git a/l10n/zh_CN.js b/l10n/zh_CN.js index e0e1d80cd..c0ddcf78e 100644 --- a/l10n/zh_CN.js +++ b/l10n/zh_CN.js @@ -61,12 +61,20 @@ OC.L10N.register( "Timeline Path" : "时间线路径", "Square grid mode" : "方形网格模式", "Show past photos on top of timeline" : "在时间线顶部显示过去的照片", + "Stack RAW files with same name" : "堆叠同名 RAW 文件", + "Photo Viewer" : "照片浏览器", "Autoplay Live Photos" : "自动播放实时照片", + "Show full file path in sidebar" : "在侧边栏显示完整的文件路径", + "High resolution image loading behavior" : "高清图像加载偏好", + "Load high resolution image on zoom" : "在缩放时加载高清图像", + "Always load high resolution image (not recommended)" : "总是加载高清图像(不推荐)", + "Never load high resolution image" : "不加载高清图像", "Account" : "账户", "Logged in as {user}" : "以 {user} 登录", "Sign out" : "登出", "Device Folders" : "设备文件夹", "Local folders to include in the timeline view" : "要包含在时间线视图中的本地文件夹", + "Run initial device setup" : "运行设备初始化设定", "Folders Path" : "文件夹路径", "Show hidden folders" : "显示隐藏文件夹", "Sort folders oldest-first" : "将文件夹从最旧开始排序", @@ -81,6 +89,9 @@ OC.L10N.register( "Failed to update setting" : "更新设置失败", "Albums support is enabled through the Photos app." : "相册支持通过“照片”应用启用。", "Albums are disabled because the Photos app is not available." : "相册已禁用,因为“照片”应用不可用。", + "Recognize is installed and enabled for face recognition." : "Recognize 人脸识别已安装并启用。", + "Recognize is installed but not enabled for face recognition." : "Recognize 人脸识别已安装但并未启用。", + "Recognize is not installed. Face recognition and object tagging may be unavailable." : "Recognize 未安装。人脸识别和物品标记可能无法使用。", "Face Recognition is installed and enabled" : "人脸识别已安装并启用", "Preview generator is installed and enabled. Additional configuration may still be required." : "预览生成器已安装并启用。可能还需要额外的配置。", "Preview generator is not installed and configured. This may make Memories very slow." : "预览生成器未安装和配置。这可能会使“记忆”非常缓慢。", @@ -93,6 +104,11 @@ OC.L10N.register( "If you are using Imaginary for preview generation, you can ignore this section." : "如果您正在使用Imaginary生成预览,则可以忽略此部分。", "To enable RAW support, install the Camera RAW Previews app." : "要启用RAW支持,请安装Camera RAW Previews应用。", "Documentation." : "文档", + "PHP-Imagick is available [{version}]." : "PHP-Imagick 可用 [{version}]。", + "PHP-Imagick is not available." : "PHP-Imagick 不可用。", + "Image editing will not work correctly." : "图像编辑无法正常使用。", + "Thumbnail generation may not work for some formats (HEIC, TIFF)." : "缩略图生成可能不适用于某些格式(HEIC,TIFF)。", + "Thumbnails for videos will be generated with this binary." : "视频缩略图将使用此二进制文件生成。", "The following MIME types are configured for preview generation." : "为生成预览配置了以下MIME类型。", "Max preview size (trade-off between quality and storage requirements)." : "最大预览大小(质量和存储需求之间的权衡)", "Max memory for preview generation (MB)" : "生成预览时的最大内存(MB)", diff --git a/l10n/zh_CN.json b/l10n/zh_CN.json index 116c6e616..2a5de3a37 100644 --- a/l10n/zh_CN.json +++ b/l10n/zh_CN.json @@ -59,12 +59,20 @@ "Timeline Path" : "时间线路径", "Square grid mode" : "方形网格模式", "Show past photos on top of timeline" : "在时间线顶部显示过去的照片", + "Stack RAW files with same name" : "堆叠同名 RAW 文件", + "Photo Viewer" : "照片浏览器", "Autoplay Live Photos" : "自动播放实时照片", + "Show full file path in sidebar" : "在侧边栏显示完整的文件路径", + "High resolution image loading behavior" : "高清图像加载偏好", + "Load high resolution image on zoom" : "在缩放时加载高清图像", + "Always load high resolution image (not recommended)" : "总是加载高清图像(不推荐)", + "Never load high resolution image" : "不加载高清图像", "Account" : "账户", "Logged in as {user}" : "以 {user} 登录", "Sign out" : "登出", "Device Folders" : "设备文件夹", "Local folders to include in the timeline view" : "要包含在时间线视图中的本地文件夹", + "Run initial device setup" : "运行设备初始化设定", "Folders Path" : "文件夹路径", "Show hidden folders" : "显示隐藏文件夹", "Sort folders oldest-first" : "将文件夹从最旧开始排序", @@ -79,6 +87,9 @@ "Failed to update setting" : "更新设置失败", "Albums support is enabled through the Photos app." : "相册支持通过“照片”应用启用。", "Albums are disabled because the Photos app is not available." : "相册已禁用,因为“照片”应用不可用。", + "Recognize is installed and enabled for face recognition." : "Recognize 人脸识别已安装并启用。", + "Recognize is installed but not enabled for face recognition." : "Recognize 人脸识别已安装但并未启用。", + "Recognize is not installed. Face recognition and object tagging may be unavailable." : "Recognize 未安装。人脸识别和物品标记可能无法使用。", "Face Recognition is installed and enabled" : "人脸识别已安装并启用", "Preview generator is installed and enabled. Additional configuration may still be required." : "预览生成器已安装并启用。可能还需要额外的配置。", "Preview generator is not installed and configured. This may make Memories very slow." : "预览生成器未安装和配置。这可能会使“记忆”非常缓慢。", @@ -91,6 +102,11 @@ "If you are using Imaginary for preview generation, you can ignore this section." : "如果您正在使用Imaginary生成预览,则可以忽略此部分。", "To enable RAW support, install the Camera RAW Previews app." : "要启用RAW支持,请安装Camera RAW Previews应用。", "Documentation." : "文档", + "PHP-Imagick is available [{version}]." : "PHP-Imagick 可用 [{version}]。", + "PHP-Imagick is not available." : "PHP-Imagick 不可用。", + "Image editing will not work correctly." : "图像编辑无法正常使用。", + "Thumbnail generation may not work for some formats (HEIC, TIFF)." : "缩略图生成可能不适用于某些格式(HEIC,TIFF)。", + "Thumbnails for videos will be generated with this binary." : "视频缩略图将使用此二进制文件生成。", "The following MIME types are configured for preview generation." : "为生成预览配置了以下MIME类型。", "Max preview size (trade-off between quality and storage requirements)." : "最大预览大小(质量和存储需求之间的权衡)", "Max memory for preview generation (MB)" : "生成预览时的最大内存(MB)", diff --git a/lib/Controller/ImageController.php b/lib/Controller/ImageController.php index 77a28d73e..5e2c7a05e 100644 --- a/lib/Controller/ImageController.php +++ b/lib/Controller/ImageController.php @@ -259,7 +259,12 @@ public function setExif(int $id, array $raw): Http\Response // 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 @@ private function getTags(int $fileId): array // 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 1d085e660..30fc93969 100644 --- a/lib/Exif.php +++ b/lib/Exif.php @@ -334,7 +334,7 @@ public static function setExif(string $path, array $data): void $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/lib/Util.php b/lib/Util.php index 606f1f365..bbe37dbfe 100644 --- a/lib/Util.php +++ b/lib/Util.php @@ -370,11 +370,8 @@ public static function explode_exact(string $delimiter, string $string, int $cou public static function callerIsNative(): bool { // Should not use IRequest here since this method is called during registration - if (\array_key_exists('HTTP_X_REQUESTED_WITH', $_SERVER)) { - return 'gallery.memories' === $_SERVER['HTTP_X_REQUESTED_WITH']; - } - - return str_contains($_SERVER['HTTP_USER_AGENT'] ?? '', 'MemoriesNative'); + return 'gallery.memories' === ($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') + || str_contains($_SERVER['HTTP_USER_AGENT'] ?? '', 'MemoriesNative'); } /** diff --git a/src/components/ScrollerManager.vue b/src/components/ScrollerManager.vue index 4ccf4d047..540cadba7 100644 --- a/src/components/ScrollerManager.vue +++ b/src/components/ScrollerManager.vue @@ -95,6 +95,7 @@ export default defineComponent({ emits: { interactend: () => true, + scroll: (event: { current: number; previous: number }) => true, }, data: () => ({ @@ -223,11 +224,13 @@ export default defineComponent({ const scroll = this.recycler?.$el?.scrollTop || 0; // Emit scroll event - utils.bus.emit('memories.recycler.scroll', { + const event = { current: scroll, previous: this.lastKnownRecyclerScroll, dynTopMatterVisible: scroll < this.dynTopMatterHeight, - }); + }; + utils.bus.emit('memories.recycler.scroll', event); + this.$emit('scroll', event); this.lastKnownRecyclerScroll = scroll; // Get cursor px position diff --git a/src/components/SelectionManager.vue b/src/components/SelectionManager.vue index 3b543334c..9067addea 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/SwipeRefresh.vue b/src/components/SwipeRefresh.vue new file mode 100644 index 000000000..8b23f6789 --- /dev/null +++ b/src/components/SwipeRefresh.vue @@ -0,0 +1,228 @@ + + + + + diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue index 5623a6bbc..c50541185 100644 --- a/src/components/Timeline.vue +++ b/src/components/Timeline.vue @@ -1,5 +1,11 @@ + + diff --git a/src/components/viewer/Viewer.vue b/src/components/viewer/Viewer.vue index 8283fc567..c60434791 100644 --- a/src/components/viewer/Viewer.vue +++ b/src/components/viewer/Viewer.vue @@ -22,148 +22,15 @@
- {{ t('memories', 'Share') }} - - - - {{ t('memories', 'Delete') }} - - - - {{ t('memories', 'Remove from album') }} - - - - {{ t('memories', 'Play Live Photo') }} - - - - {{ t('memories', 'Favorite') }} - - - - {{ t('memories', 'Info') }} - - - - {{ t('memories', 'Edit') }} - - - - {{ t('memories', 'Download') }} - - - - {{ t('memories', 'Download Video') }} - - - - {{ t('memories', 'Download {ext}', { ext: raw.extension }) }} - - - - {{ t('memories', 'View in folder') }} - - - - {{ t('memories', 'Slideshow') }} - - - - {{ t('memories', 'Edit metadata') }} - - - - {{ t('memories', 'Add to album') }} + {{ action.name }} @@ -221,6 +88,22 @@ import SlideshowIcon from 'vue-material-design-icons/PlayBox.vue'; import EditFileIcon from 'vue-material-design-icons/FileEdit.vue'; import AlbumRemoveIcon from 'vue-material-design-icons/BookRemove.vue'; import AlbumIcon from 'vue-material-design-icons/ImageAlbum.vue'; +import RotateLeftIcon from 'vue-material-design-icons/RotateLeft.vue'; + +type IViewerAction = { + /** Identifier (optional) */ + id: string; + /** Display text */ + name: string; + /** Icon component */ + icon: any; + /** Props on icon component */ + iconArgs?: any; + /** Action to perform */ + callback: () => void; + /** Condition to check for including */ + if: boolean; +}; const SLIDESHOW_MS = 5000; const SIDEBAR_DEBOUNCE_MS = 350; @@ -234,19 +117,6 @@ export default defineComponent({ NcActions, NcActionButton, ImageEditor, - LivePhotoIcon, - ShareIcon, - DeleteIcon, - StarIcon, - StarOutlineIcon, - DownloadIcon, - InfoIcon, - OpenInNewIcon, - TuneIcon, - SlideshowIcon, - EditFileIcon, - AlbumRemoveIcon, - AlbumIcon, }, mixins: [UserConfig], @@ -358,6 +228,126 @@ export default defineComponent({ return this.list[idx]; }, + /** Get all actions to show */ + actions(): IViewerAction[] { + return [ + { + id: 'share', + name: this.t('memories', 'Share'), + icon: ShareIcon, + callback: this.shareCurrent, + if: this.canShare, + }, + { + id: 'delete', + name: this.t('memories', 'Delete'), + icon: DeleteIcon, + callback: this.deleteCurrent, + if: !this.routeIsAlbums && this.canDelete, + }, + { + id: 'remove-from-album', + name: this.t('memories', 'Remove from album'), + icon: AlbumRemoveIcon, + callback: this.deleteCurrent, + if: this.routeIsAlbums, + }, + { + id: 'play-live-photo', + name: this.t('memories', 'Play Live Photo'), + icon: LivePhotoIcon, + iconArgs: { + playing: this.liveState.playing, + spin: this.liveState.waiting, + }, + callback: this.playLivePhoto, + if: this.isLivePhoto, + }, + { + id: 'favorite', + name: this.t('memories', 'Favorite'), + icon: this.isFavorite ? StarIcon : StarOutlineIcon, + callback: this.favoriteCurrent, + if: !this.routeIsPublic && !this.isLocal, + }, + { + id: 'info', + name: this.t('memories', 'Info'), + icon: InfoIcon, + callback: this.toggleSidebar, + if: true, + }, + { + id: 'edit', + name: this.t('memories', 'Edit'), + icon: TuneIcon, + callback: this.openEditor, + if: this.canEdit && !this.isVideo, + }, + { + id: 'download', + name: this.t('memories', 'Download'), + icon: DownloadIcon, + callback: this.downloadCurrent, + if: !this.initstate.noDownload && !this.isLocal, + }, + { + id: 'download-video', + name: this.t('memories', 'Download Video'), + icon: DownloadIcon, + callback: this.downloadCurrentLiveVideo, + if: !this.initstate.noDownload && !!this.currentPhoto?.liveid, + }, + ...this.stackedRaw.map((raw) => ({ + id: `download-raw-${raw.fileid}`, + name: this.t('memories', 'Download {ext}', { ext: raw.extension }), + icon: DownloadIcon, + callback: () => this.downloadByFileId(raw.fileid), + if: true, + })), + { + id: 'view-in-folder', + name: this.t('memories', 'View in folder'), + icon: OpenInNewIcon, + callback: this.viewInFolder, + if: !this.routeIsPublic && !this.routeIsAlbums && !this.isLocal, + }, + { + id: 'slideshow', + name: this.t('memories', 'Slideshow'), + icon: SlideshowIcon, + callback: this.startSlideshow, + if: this.globalCount > 1, + }, + { + id: 'edit-metadata', + name: this.t('memories', 'Edit metadata'), + icon: EditFileIcon, + callback: () => this.editMetadata(), + if: this.canEdit, + }, + { + id: 'rotate-flip', + name: this.t('memories', 'Rotate / Flip'), + icon: RotateLeftIcon, + callback: () => this.editMetadata([5]), + if: this.canEdit && !this.isVideo, + }, + { + id: 'add-to-album', + name: this.t('memories', 'Add to album'), + icon: AlbumIcon, + callback: this.updateAlbums, + if: + this.config.albums_enabled && + !this.isLocal && + !this.routeIsPublic && + this.canShare && + !!this.currentPhoto?.imageInfo?.filename, + }, + ].filter((action) => action.if); + }, + /** Is the current slide a video */ isVideo(): boolean { return Boolean((this.currentPhoto?.flag ?? 0) & this.c.FLAG_IS_VIDEO); @@ -373,6 +363,13 @@ export default defineComponent({ return utils.isLocalPhoto(this.currentPhoto!); }, + /** Is the current photo a favorite */ + isFavorite() { + const p = this.currentPhoto; + if (!p) return false; + return Boolean(p.flag & this.c.FLAG_IS_FAVORITE); + }, + /** Show bottom bar info such as date taken */ showBottomBar(): boolean { return !this.isVideo && this.fullyOpened && Boolean(this.currentPhoto?.imageInfo); @@ -1049,17 +1046,10 @@ export default defineComponent({ this.psLivePhoto?.play(this.photoswipe!.currSlide!.content as PsContent); }, - /** Is the current photo a favorite */ - isFavorite() { - const p = this.currentPhoto; - if (!p) return false; - return Boolean(p.flag & this.c.FLAG_IS_FAVORITE); - }, - /** Favorite the current photo */ async favoriteCurrent() { const photo = this.currentPhoto!; - const val = !this.isFavorite(); + const val = !this.isFavorite; try { this.updateLoading(1); for await (const p of dav.favoritePhotos([photo], val)) { @@ -1256,8 +1246,8 @@ export default defineComponent({ /** * Edit metadata for current photo */ - editMetadata() { - _m.modals.editMetadata([this.currentPhoto!]); + editMetadata(sections?: number[]) { + _m.modals.editMetadata([this.currentPhoto!], sections); }, /** diff --git a/src/services/utils/fragment.ts b/src/services/utils/fragment.ts index abb9e4e3c..8e4cce1d8 100644 --- a/src/services/utils/fragment.ts +++ b/src/services/utils/fragment.ts @@ -117,7 +117,6 @@ export const fragment = { // If the fragment is already in the list, we can't touch it. if (list.find((f) => f.type === frag.type)) { - console.debug('Fragment already in route', frag.type); return; }