From 9eded21136ffa8ba5f945d07310973a20d22c8e1 Mon Sep 17 00:00:00 2001 From: Winzlieb Date: Sun, 17 Aug 2025 10:53:18 +0200 Subject: [PATCH 01/38] add support for embedded tags in metadata display Signed-off-by: Winzlieb --- lib/Db/ExifFields.php | 7 ++++ src/components/Metadata.vue | 64 +++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/lib/Db/ExifFields.php b/lib/Db/ExifFields.php index e5629f5dd..bb386c5a2 100644 --- a/lib/Db/ExifFields.php +++ b/lib/Db/ExifFields.php @@ -46,6 +46,13 @@ class ExifFields 'Aperture' => true, 'ImageUniqueID' => true, + // Tags + 'TagsList' => true, + 'Keywords' => true, + 'Subject' => true, + 'HierarchicalSubject' => true, + + // GPS info 'GPSLatitude' => true, 'GPSLongitude' => true, diff --git a/src/components/Metadata.vue b/src/components/Metadata.vue index 6a8b16bf9..4a6bd5724 100644 --- a/src/components/Metadata.vue +++ b/src/components/Metadata.vue @@ -60,6 +60,40 @@ + +
+
+ +
+ +
+
{{ t('memories', 'Tags') }} ({{ embeddedTags.length }})
+
+ +
+
+ +
+ + + {{ t('memories', 'Edit') }} + + + +
+
@@ -79,6 +113,10 @@ import type { Component } from 'vue'; import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'; import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'; +import NcChip from '@nextcloud/vue/dist/Components/NcChip.js'; +import NcPopover from '@nextcloud/vue/dist/Components/NcPopover.js'; +import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'; + const NcAvatar = () => import('@nextcloud/vue/dist/Components/NcAvatar.js'); import axios from '@nextcloud/axios'; @@ -117,9 +155,13 @@ export default defineComponent({ NcActions, NcActionButton, NcAvatar, + NcChip, + NcPopover, + NcButton, AlbumsList, Cluster, EditIcon, + TagIcon, }, mixins: [UserConfig], @@ -358,6 +400,11 @@ export default defineComponent({ return Number(this.exif.GPSLongitude); }, + embeddedTags(): string[][] { + const ensureArray = (v: string | string[] | undefined | null) => v ? (Array.isArray(v) ? v : [v]) : undefined; + return ensureArray(this.exif.TagsList)?.map((tag) => tag.split('/')) || ensureArray(this.exif.HierarchicalSubject)?.map((tag) => tag.split('|')) || ensureArray(this.exif.Keywords)?.map((tag) => [tag]) || ensureArray(this.exif.Subject)?.map((tag) => [tag]) || []; + }, + tagNames(): string[] { return Object.values(this.baseInfo?.tags || {}).map((tag: string) => this.t('recognize', tag)); }, @@ -604,4 +651,21 @@ export default defineComponent({ min-height: 200px; max-height: 250px; } + +.tags-container { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 4px; + + :deep .chip { + margin: 0; + } +} + +.tag-path { + padding: 8px 12px; + font-size: 0.9em; + white-space: nowrap; +} From c4943734b4e7f8ec42d60464e8f3bd5cc13fb591 Mon Sep 17 00:00:00 2001 From: Winzlieb Date: Sun, 17 Aug 2025 11:06:43 +0200 Subject: [PATCH 02/38] add new optional fields for embedded tags and subjects in typings Signed-off-by: Winzlieb --- src/typings/data.d.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/typings/data.d.ts b/src/typings/data.d.ts index 631ac3608..095f0ad5d 100644 --- a/src/typings/data.d.ts +++ b/src/typings/data.d.ts @@ -161,6 +161,11 @@ declare module '@typings' { FNumber?: number; FocalLength?: number; + TagsList?: string[]; + Keywords?: string[]; + Subject?: string[]; + HierarchicalSubject?: string[]; + GPSAltitude?: number; GPSLatitude?: number; GPSLongitude?: number; From ead51b87b3452dd7b27dfc74d4d83c8a9a70c47c Mon Sep 17 00:00:00 2001 From: Winzlieb Date: Sun, 17 Aug 2025 11:07:52 +0200 Subject: [PATCH 03/38] add RatingStars component for interactive star rating functionality Signed-off-by: Winzlieb --- src/components/RatingStars.vue | 191 +++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 src/components/RatingStars.vue diff --git a/src/components/RatingStars.vue b/src/components/RatingStars.vue new file mode 100644 index 000000000..a4d2cff68 --- /dev/null +++ b/src/components/RatingStars.vue @@ -0,0 +1,191 @@ + + + + + \ No newline at end of file From a8029d409fc450326371fcce8f66faa0f2bf6166 Mon Sep 17 00:00:00 2001 From: Winzlieb Date: Sun, 17 Aug 2025 12:44:38 +0200 Subject: [PATCH 04/38] implement rating functionality in Metadata component with editable star ratings Signed-off-by: Winzlieb --- src/components/Metadata.vue | 41 +++++++++++++++++++++++++++++++++++-- src/typings/data.d.ts | 2 ++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/components/Metadata.vue b/src/components/Metadata.vue index 4a6bd5724..86962da93 100644 --- a/src/components/Metadata.vue +++ b/src/components/Metadata.vue @@ -85,7 +85,7 @@
-
+
{{ t('memories', 'Edit') }} @@ -95,6 +95,10 @@
+
+ +
+
@@ -126,6 +130,7 @@ import { DateTime } from 'luxon'; import UserConfig from '@mixins/UserConfig'; import Cluster from '@components/frame/Cluster.vue'; import AlbumsList from '@components/modal/AlbumsList.vue'; +import RatingStars from '@components/RatingStars.vue'; import EditIcon from 'vue-material-design-icons/Pencil.vue'; import CalendarIcon from 'vue-material-design-icons/Calendar.vue'; @@ -139,6 +144,7 @@ import * as dav from '@services/dav'; import { API } from '@services/API'; import type { IAlbum, IFace, IImageInfo, IPhoto, IExif } from '@typings'; +import { showError } from '@nextcloud/dialogs'; interface TopField { id?: string; @@ -162,6 +168,7 @@ export default defineComponent({ Cluster, EditIcon, TagIcon, + RatingStars, }, mixins: [UserConfig], @@ -171,6 +178,7 @@ export default defineComponent({ filename: '', exif: {} as IExif, baseInfo: {} as IImageInfo, + lock: false, error: false, loading: 0, @@ -246,7 +254,7 @@ export default defineComponent({ }, canEdit(): boolean { - return this.baseInfo?.permissions?.includes('U'); + return this.baseInfo?.permissions?.includes('U') && !this.lock; }, /** Title EXIF value */ @@ -400,6 +408,10 @@ export default defineComponent({ return Number(this.exif.GPSLongitude); }, + rating(): number | null { + return typeof this.exif.Rating === 'number' ? this.exif.Rating : null; + }, + embeddedTags(): string[][] { const ensureArray = (v: string | string[] | undefined | null) => v ? (Array.isArray(v) ? v : [v]) : undefined; return ensureArray(this.exif.TagsList)?.map((tag) => tag.split('/')) || ensureArray(this.exif.HierarchicalSubject)?.map((tag) => tag.split('|')) || ensureArray(this.exif.Keywords)?.map((tag) => [tag]) || ensureArray(this.exif.Subject)?.map((tag) => [tag]) || []; @@ -516,6 +528,31 @@ export default defineComponent({ _m.modals.editMetadata([_m.viewer.currentPhoto!], [4]); }, + async updateExif(fileid: number, fields: Partial) { + this.lock = true; + try { + const raw = this.exif ?? {}; + //optimistically update the exif + this.exif = { ...raw, ...fields }; + await axios.patch(API.IMAGE_SETEXIF(fileid), { raw: this.exif }); + } + catch (e) { + console.error('Failed to save metadata for', fileid, e); + if (e.response?.data?.message) { + showError(e.response.data.message); + } else { + showError(e); + } + } finally { + this.lock = false; + utils.bus.emit('files:file:updated', { fileid }); + } + }, + + updateRating(rating: number) { + this.updateExif(this.fileid!, { Rating: rating === this.exif.Rating ? undefined : rating }); + }, + handleFileUpdated({ fileid }: utils.BusEvent['files:file:updated']) { if (fileid && this.fileid === fileid) { this.refresh(); diff --git a/src/typings/data.d.ts b/src/typings/data.d.ts index 095f0ad5d..75d499e8b 100644 --- a/src/typings/data.d.ts +++ b/src/typings/data.d.ts @@ -161,6 +161,8 @@ declare module '@typings' { FNumber?: number; FocalLength?: number; + Rating?: number; + TagsList?: string[]; Keywords?: string[]; Subject?: string[]; From e7aa210991efdbbbaa4648ab7a8a227ca60c1e92 Mon Sep 17 00:00:00 2001 From: Winzlieb Date: Sun, 17 Aug 2025 21:39:42 +0200 Subject: [PATCH 05/38] include EXIF data and implement minimum rating filter for photo queries Signed-off-by: Winzlieb --- lib/Db/TimelineQuery.php | 1 + lib/Db/TimelineQueryDays.php | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/lib/Db/TimelineQuery.php b/lib/Db/TimelineQuery.php index 77e659b00..f645b521b 100644 --- a/lib/Db/TimelineQuery.php +++ b/lib/Db/TimelineQuery.php @@ -23,6 +23,7 @@ class TimelineQuery 'm.datetaken', 'm.dayid', 'm.w', 'm.h', 'm.liveid', 'm.isvideo', 'm.video_duration', + 'm.exif', 'f.etag', 'f.name AS basename', 'f.size', 'm.epoch', // auid 'mimetypes.mimetype', diff --git a/lib/Db/TimelineQueryDays.php b/lib/Db/TimelineQueryDays.php index 867c06cf8..f626f91aa 100644 --- a/lib/Db/TimelineQueryDays.php +++ b/lib/Db/TimelineQueryDays.php @@ -77,6 +77,7 @@ public function getDays( * @param bool $hidden If the query should include hidden files * @param bool $monthView If the query should be in month view (dayIds are monthIds) * @param bool $reverse If the query should be in reverse order + * @param int $minRating The minimum rating to include * @param array $queryTransforms The query transformations to apply * * @return array An array of day responses @@ -88,6 +89,7 @@ public function getDay( bool $hidden, bool $monthView, bool $reverse, + int $minRating = 0, array $queryTransforms = [], ): array { // Check if we have any dayIds @@ -169,6 +171,11 @@ public function getDay( $day = array_reverse($day); } + // Filter by rating + if ($minRating > 0) { + $day = array_filter($day, fn ($photo) => $photo['rating'] >= $minRating); + } + return $day; } @@ -314,6 +321,12 @@ private function postProcessDayPhoto(array &$row, bool $monthView = false): void $row['dayid'] = (int) $row['dayid']; $row['w'] = (int) $row['w']; $row['h'] = (int) $row['h']; + //parse json of exif if exif exists + if ($row['exif'] ?? null) { + $row['exif'] = json_decode($row['exif'], true); + $row['rating'] = (int) $row['exif']['Rating'] ?? 0; + } + // Optional fields if (!$row['isvideo']) { From ddc68e87d230ca03e7c4530e11129834fb735c10 Mon Sep 17 00:00:00 2001 From: Winzlieb Date: Sun, 17 Aug 2025 21:40:03 +0200 Subject: [PATCH 06/38] add minimum rating parameter to DaysController Signed-off-by: Winzlieb --- lib/Controller/DaysController.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/Controller/DaysController.php b/lib/Controller/DaysController.php index f4c9c5db7..872d5d3cf 100644 --- a/lib/Controller/DaysController.php +++ b/lib/Controller/DaysController.php @@ -68,6 +68,7 @@ public function day(array $dayIds): Http\Response $this->isHidden(), $this->isMonthView(), $this->isReverse(), + $this->getMinRating(), $this->getTransformations(), ); @@ -168,6 +169,7 @@ private function preloadDays(array &$days): void $this->isHidden(), $this->isMonthView(), $this->isReverse(), + $this->getMinRating(), $this->getTransformations(), ); @@ -215,4 +217,9 @@ private function isReverse(): bool { return null !== $this->request->getParam('reverse'); } + + private function getMinRating(): int + { + return (int) $this->request->getParam('minRating') ?? 0; + } } From 11ce542a9fa87a318754e21acf7ccce8905994b7 Mon Sep 17 00:00:00 2001 From: Winzlieb Date: Sun, 17 Aug 2025 21:40:51 +0200 Subject: [PATCH 07/38] add minRating filter to event bus and DaysFilterType enum Signed-off-by: Winzlieb --- src/services/API.ts | 1 + src/services/utils/event-bus.ts | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/src/services/API.ts b/src/services/API.ts index 95df54657..d54e8fed8 100644 --- a/src/services/API.ts +++ b/src/services/API.ts @@ -28,6 +28,7 @@ export const enum DaysFilterType { PLACE = 'places', TAG = 'tags', MAP_BOUNDS = 'mapbounds', + RATING = 'minRating', FACE_RECT = 'facerect', RECURSIVE = 'recursive', diff --git a/src/services/utils/event-bus.ts b/src/services/utils/event-bus.ts index 7c26adfa8..e2c0cc1a7 100644 --- a/src/services/utils/event-bus.ts +++ b/src/services/utils/event-bus.ts @@ -27,6 +27,12 @@ export type BusEvent = { value: IConfig[keyof IConfig]; } | null; + /** Filters have been updated */ + 'memories:filters:changed': { + minRating: number; + tags: string[]; + }; + /** * Remove these photos from the timeline. * Each photo object is required to have the `d` (day) property. From 0527cda3034d9e2ea19da964eab723b9b91b6839 Mon Sep 17 00:00:00 2001 From: Winzlieb Date: Mon, 18 Aug 2025 00:58:55 +0200 Subject: [PATCH 08/38] Implement FilterComponent for filtering memories by minimum rating and tags Signed-off-by: Winzlieb --- src/components/FilterComponent.vue | 220 +++++++++++++++++++++++++++++ src/typings/data.d.ts | 6 + 2 files changed, 226 insertions(+) create mode 100644 src/components/FilterComponent.vue diff --git a/src/components/FilterComponent.vue b/src/components/FilterComponent.vue new file mode 100644 index 000000000..81186e5a5 --- /dev/null +++ b/src/components/FilterComponent.vue @@ -0,0 +1,220 @@ + + + + + \ No newline at end of file diff --git a/src/typings/data.d.ts b/src/typings/data.d.ts index 75d499e8b..183ba1e07 100644 --- a/src/typings/data.d.ts +++ b/src/typings/data.d.ts @@ -172,4 +172,10 @@ declare module '@typings' { GPSLatitude?: number; GPSLongitude?: number; } + + + export type IFilters = { + minRating: number; + tags: string[]; + } } From f85e56b5a2ceb641618b25403fca2bcf220317b0 Mon Sep 17 00:00:00 2001 From: Winzlieb Date: Mon, 18 Aug 2025 01:00:00 +0200 Subject: [PATCH 09/38] Integrate filtering functionality in Timeline and RowHead components Signed-off-by: Winzlieb --- src/components/Timeline.vue | 40 ++++++++++- src/components/frame/RowHead.vue | 82 +++++++++++++++++++++- src/components/top-matter/EmptyContent.vue | 24 ++++++- 3 files changed, 142 insertions(+), 4 deletions(-) diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue index 9e4811c4b..e4afdc7e1 100644 --- a/src/components/Timeline.vue +++ b/src/components/Timeline.vue @@ -14,7 +14,7 @@ - + 0 || this.filters.tags.length > 0; + }, + /** Show the empty content box and hide the scrollbar */ showEmpty(): boolean { return !this.loading && this.empty; @@ -610,6 +623,16 @@ export default defineComponent({ const query: { [key in DaysFilterType]?: string } = {}; const set = (filter: DaysFilterType, value: string = '1') => (query[filter] = value); + // Rating + if (this.filters.minRating > 0) { + set(DaysFilterType.RATING, this.filters.minRating.toString()); + } + + // Tags + if (this.filters.tags.length > 0) { + set(DaysFilterType.TAG, this.filters.tags.join(',')); + } + // Favorites if (this.routeIsFavorites) { set(DaysFilterType.FAVORITES); @@ -1452,6 +1475,19 @@ export default defineComponent({ this.processDay(day.dayid, newDetail!); } }, + + onFiltersChanged(filters: IFilters) { + this.filters = filters; + this.refresh(); + }, + + resetFilters() { + this.filters = { + minRating: 0, + tags: [], + }; + this.refresh(); + }, }, }); diff --git a/src/components/frame/RowHead.vue b/src/components/frame/RowHead.vue index 5c4d845e4..3eda365e6 100644 --- a/src/components/frame/RowHead.vue +++ b/src/components/frame/RowHead.vue @@ -5,7 +5,32 @@
- {{ name }} + {{ name }} +
+ + +
+ + + +
@@ -13,17 +38,26 @@ @@ -57,6 +114,9 @@ export default defineComponent({ padding-top: 10px; padding-left: 3px; font-size: 0.9em; + position: relative; + display: flex; + align-items: flex-start; > div { position: relative; @@ -89,6 +149,26 @@ export default defineComponent({ font-size: 1.075em; } + .filter-container { + z-index: 1000; + margin-left: 24px; + + .filter-button { + opacity: 0.7; + transition: opacity 0.2s ease; + + &:hover { + opacity: 1; + } + + &.active { + opacity: 1; + color: var(--color-primary); + background-color: var(--color-primary-element-light); + } + } + } + @mixin visible { .select { display: flex; diff --git a/src/components/top-matter/EmptyContent.vue b/src/components/top-matter/EmptyContent.vue index 2701b00d2..e34529f4d 100644 --- a/src/components/top-matter/EmptyContent.vue +++ b/src/components/top-matter/EmptyContent.vue @@ -7,6 +7,11 @@ + @@ -14,6 +19,7 @@ import { defineComponent } from 'vue'; import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'; +import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'; import * as strings from '@services/strings'; @@ -28,7 +34,7 @@ export default defineComponent({ components: { NcEmptyContent, - + NcButton, PeopleIcon, ArchiveIcon, ImageMultipleIcon, @@ -36,10 +42,26 @@ export default defineComponent({ MapIcon, }, + emits: ['reset-filters'], + + props: { + hasFilters: { + type: Boolean, + default: false, + }, + }, + computed: { emptyViewDescription(): string { return strings.emptyDescription(this.$route.name!); }, + + }, + + methods: { + resetFilters() { + this.$emit('reset-filters'); + }, }, }); From 9f44701de799a4eb1c3fe603461aff7c7e237415 Mon Sep 17 00:00:00 2001 From: Winzlieb Date: Mon, 18 Aug 2025 10:10:19 +0200 Subject: [PATCH 10/38] Add support for caching embedded tags from EXIF data on a per user basis Signed-off-by: Winzlieb --- lib/Db/TimelineWrite.php | 7 +- lib/Db/TimelineWriteEmbeddedTags.php | 223 ++++++++++++++++++ .../Version801000Date20250818060711.php | 111 +++++++++ 3 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 lib/Db/TimelineWriteEmbeddedTags.php create mode 100644 lib/Migration/Version801000Date20250818060711.php diff --git a/lib/Db/TimelineWrite.php b/lib/Db/TimelineWrite.php index b7f224281..9a85387db 100644 --- a/lib/Db/TimelineWrite.php +++ b/lib/Db/TimelineWrite.php @@ -14,6 +14,7 @@ use Psr\Log\LoggerInterface; const DELETE_TABLES = ['memories', 'memories_livephoto', 'memories_places', 'memories_failures']; +const DELETE_ALL = ['memories_embedded_tags']; const TRUNCATE_TABLES = ['memories_mapclusters']; class TimelineWrite @@ -22,6 +23,7 @@ class TimelineWrite use TimelineWriteMap; use TimelineWriteOrphans; use TimelineWritePlaces; + use TimelineWriteEmbeddedTags; public function __construct( protected IDBConnection $connection, @@ -193,6 +195,9 @@ public function processFile( // Clear failures if successful if ($updated) { $this->clearFailures($file); + + // Process embedded tags + $this->processEmbeddedTags($file, $exif); } return $updated; @@ -255,7 +260,7 @@ public function cleanupStale(): void */ public function clear(): void { - foreach (array_merge(DELETE_TABLES, TRUNCATE_TABLES) as $table) { + foreach (array_merge(DELETE_TABLES, DELETE_ALL, TRUNCATE_TABLES) as $table) { SQL::truncate($this->connection, $table, false); } } diff --git a/lib/Db/TimelineWriteEmbeddedTags.php b/lib/Db/TimelineWriteEmbeddedTags.php new file mode 100644 index 000000000..3bfeb22c7 --- /dev/null +++ b/lib/Db/TimelineWriteEmbeddedTags.php @@ -0,0 +1,223 @@ +getOwner()->getUID(); + + // Extract embedded tags from EXIF data + $embeddedTags = $this->extractEmbeddedTags($exif); + + if (empty($embeddedTags)) { + return; + } + + // Insert or update tags + foreach ($embeddedTags as $tagPath) { + $this->ensureTagExists($userId, $tagPath); + } + } + + /** + * Extract embedded tags from EXIF data + * + * @param array $exif EXIF data + * @return array Array of tag paths + */ + private function extractEmbeddedTags(array $exif): array + { + $embeddedTags = []; + + // Helper function to ensure we have an array + $ensureArray = function ($value) { + if (empty($value)) { + return []; + } + return is_array($value) ? $value : [$value]; + }; + + // Extract from TagsList (split by '/') + if (!empty($exif['TagsList'])) { + $tagsList = $ensureArray($exif['TagsList']); + foreach ($tagsList as $tag) { + $embeddedTags[] = explode('/', $tag); + } + } + + // Extract from HierarchicalSubject (split by '|') + if (empty($embeddedTags) && !empty($exif['HierarchicalSubject'])) { + $hierarchicalSubject = $ensureArray($exif['HierarchicalSubject']); + foreach ($hierarchicalSubject as $tag) { + $embeddedTags[] = explode('|', $tag); + } + } + + // Extract from Keywords (as individual tags) + if (empty($embeddedTags) && !empty($exif['Keywords'])) { + $keywords = $ensureArray($exif['Keywords']); + foreach ($keywords as $tag) { + $embeddedTags[] = [$tag]; + } + } + + // Extract from Subject (as individual tags) + if (empty($embeddedTags) && !empty($exif['Subject'])) { + $subject = $ensureArray($exif['Subject']); + foreach ($subject as $tag) { + $embeddedTags[] = [$tag]; + } + } + + return $embeddedTags; + } + + /** + * Ensure a tag exists in the database + * + * @param string $userId User ID + * @param array $tagPath Tag path as an array + */ + private function ensureTagExists(string $userId, array $tagPath): void + { + $level = 0; + $parentTagId = null; // Track parent tag ID instead of name + $fullPath = ''; + + foreach ($tagPath as $index => $tagPart) { + // Skip empty tag parts + if (empty($tagPart)) { + continue; + } + + // Trim whitespace + $tagPart = trim($tagPart); + if (empty($tagPart)) { + continue; + } + + // Build the full path + $fullPath = $fullPath ? $fullPath . '/' . $tagPart : $tagPart; + + // Check if tag already exists + $query = $this->connection->getQueryBuilder(); + $existingTag = $query->select('id') + ->from('memories_embedded_tags') + ->where($query->expr()->eq('user_id', $query->createNamedParameter($userId, IQueryBuilder::PARAM_STR))) + ->andWhere($query->expr()->eq('path', $query->createNamedParameter($fullPath, IQueryBuilder::PARAM_STR))) + ->executeQuery() + ->fetch(); + + // If tag doesn't exist, insert it + if (!$existingTag) { + $query = $this->connection->getQueryBuilder(); + $query->insert('memories_embedded_tags') + ->values([ + 'user_id' => $query->createNamedParameter($userId, IQueryBuilder::PARAM_STR), + 'tag' => $query->createNamedParameter($tagPart, IQueryBuilder::PARAM_STR), + 'parent_tag_id' => $query->createNamedParameter($parentTagId, $parentTagId === null ? IQueryBuilder::PARAM_NULL : IQueryBuilder::PARAM_INT), + 'path' => $query->createNamedParameter($fullPath, IQueryBuilder::PARAM_STR), + 'level' => $query->createNamedParameter($level, IQueryBuilder::PARAM_INT), + ]) + ->executeStatement(); + + // Get the ID of the newly inserted tag + $query = $this->connection->getQueryBuilder(); + $newTagId = $query->select('id') + ->from('memories_embedded_tags') + ->where($query->expr()->eq('user_id', $query->createNamedParameter($userId, IQueryBuilder::PARAM_STR))) + ->andWhere($query->expr()->eq('path', $query->createNamedParameter($fullPath, IQueryBuilder::PARAM_STR))) + ->executeQuery() + ->fetchOne(); + + $parentTagId = (int)$newTagId; + } else { + // If tag exists, get its ID for the next iteration + $parentTagId = (int)$existingTag['id']; + } + + // Update for next iteration + $level++; + } + } + + /** + * Delete a tag by path + * + * @param string $userId User ID + * @param string $path Tag path + * @param bool $recursive Whether to delete child tags + * @return bool True if tag was deleted + */ + public function deleteTagByPath(string $userId, string $path, bool $recursive = false): bool + { + // If not recursive, check if tag has children + if (!$recursive) { + // Get the tag ID first + $query = $this->connection->getQueryBuilder(); + $tagId = $query->select('id') + ->from('memories_embedded_tags') + ->where($query->expr()->eq('user_id', $query->createNamedParameter($userId, IQueryBuilder::PARAM_STR))) + ->andWhere($query->expr()->eq('path', $query->createNamedParameter($path, IQueryBuilder::PARAM_STR))) + ->executeQuery() + ->fetchOne(); + + if (!$tagId) { + // Tag not found + return false; + } + + $query = $this->connection->getQueryBuilder(); + $hasChildren = $query->select('id') + ->from('memories_embedded_tags') + ->where($query->expr()->eq('user_id', $query->createNamedParameter($userId, IQueryBuilder::PARAM_STR))) + ->andWhere($query->expr()->eq('parent_tag_id', $query->createNamedParameter($tagId, IQueryBuilder::PARAM_INT))) + ->executeQuery() + ->fetch(); + + if ($hasChildren) { + // Tag has children and recursive is false, so don't delete + return false; + } + } + + // Delete the tag (and children if recursive) + $query = $this->connection->getQueryBuilder(); + $expr = $query->expr(); + $conditions = [ + $expr->eq('user_id', $query->createNamedParameter($userId, IQueryBuilder::PARAM_STR)), + ]; + + if ($recursive) { + // If recursive, delete all tags that start with this path + $conditions[] = $expr->orX( + $expr->eq('path', $query->createNamedParameter($path, IQueryBuilder::PARAM_STR)), + $expr->like('path', $query->createNamedParameter($path . '/%', IQueryBuilder::PARAM_STR)) + ); + } else { + // If not recursive, delete only this exact tag + $conditions[] = $expr->eq('path', $query->createNamedParameter($path, IQueryBuilder::PARAM_STR)); + } + + $query->delete('memories_embedded_tags') + ->where(...$conditions) + ->executeStatement(); + + return true; + } +} \ No newline at end of file diff --git a/lib/Migration/Version801000Date20250818060711.php b/lib/Migration/Version801000Date20250818060711.php new file mode 100644 index 000000000..602e13dbc --- /dev/null +++ b/lib/Migration/Version801000Date20250818060711.php @@ -0,0 +1,111 @@ +hasTable('memories_embedded_tags')) { + $table = $schema->createTable('memories_embedded_tags'); + + // Add columns + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + ]); + $table->addColumn('user_id', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('tag', Types::STRING, [ + 'notnull' => true, + 'length' => 255, + ]); + $table->addColumn('parent_tag_id', Types::BIGINT, [ + 'notnull' => false, + ]); + $table->addColumn('path', Types::STRING, [ + 'notnull' => true, + 'length' => 1024, + ]); + $table->addColumn('level', Types::INTEGER, [ + 'notnull' => true, + ]); + $table->addColumn('created_at', Types::DATETIME, [ + 'notnull' => true, + 'default' => 'CURRENT_TIMESTAMP', + ]); + + // Add primary key + $table->setPrimaryKey(['id']); + + // Add indexes + $table->addIndex(['user_id', 'tag'], 'memories_et_user_tag_idx', [], ['lengths' => [null, 255]]); + + // Add unique constraint to ensure tags are unique per user + $table->addUniqueIndex(['user_id', 'path'], 'memories_et_user_path_unique', [], ['lengths' => [null, 768]]); + + // Add index on parent_tag_id for better performance + $table->addIndex(['parent_tag_id'], 'memories_et_parent_id_idx'); + } + + // Add foreign key constraint after the table is created + if ($schema->hasTable('memories_embedded_tags')) { + $table = $schema->getTable('memories_embedded_tags'); + + // Check if the foreign key already exists + $foreignKeys = $table->getForeignKeys(); + $foreignKeyExists = false; + + foreach ($foreignKeys as $foreignKey) { + if ($foreignKey->getLocalColumns() === ['parent_tag_id']) { + $foreignKeyExists = true; + break; + } + } + + // Add the foreign key if it doesn't exist + if (!$foreignKeyExists) { + $table->addForeignKeyConstraint( + $schema->getTable('memories_embedded_tags'), // target table (self-reference) + ['parent_tag_id'], // local columns + ['id'], // target columns + ['onDelete' => 'SET NULL'] // when parent is deleted, set children's parent_tag_id to NULL + ); + } + } + + return $schema; + } + + /** + * @param \Closure(): ISchemaWrapper $schemaClosure + */ + public function postSchemaChange(IOutput $output, \Closure $schemaClosure, array $options): void {} + + +} From 4af7464e8b874eca48536487e427b18227a0ace6 Mon Sep 17 00:00:00 2001 From: Winzlieb Date: Mon, 18 Aug 2025 11:20:19 +0200 Subject: [PATCH 11/38] fix uneeded callbacls Signed-off-by: Winzlieb --- src/components/frame/RowHead.vue | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/frame/RowHead.vue b/src/components/frame/RowHead.vue index 3eda365e6..03c63976e 100644 --- a/src/components/frame/RowHead.vue +++ b/src/components/frame/RowHead.vue @@ -26,8 +26,6 @@ From a3cbbb0ddfcf5a40c1990244d4855dbe6fadabdb Mon Sep 17 00:00:00 2001 From: Winzlieb Date: Mon, 18 Aug 2025 14:53:47 +0200 Subject: [PATCH 12/38] remove unused filter-related data and methods Signed-off-by: Winzlieb --- src/components/frame/RowHead.vue | 28 ++-------------------------- 1 file changed, 2 insertions(+), 26 deletions(-) diff --git a/src/components/frame/RowHead.vue b/src/components/frame/RowHead.vue index 03c63976e..fdbaf3e44 100644 --- a/src/components/frame/RowHead.vue +++ b/src/components/frame/RowHead.vue @@ -25,7 +25,6 @@ @@ -66,42 +65,19 @@ export default defineComponent({ }, emits: { - click: (item: IHeadRow) => true, - 'filter-change': (filters: any) => true, - }, - - data() { - return { - currentFilters: { - minRating: 0, - tags: [], - }, - }; + click: (item: IHeadRow) => true }, computed: { name() { return utils.getHeadRowName(this.item); - }, - - hasActiveFilters() { - return this.currentFilters.minRating > 0 || this.currentFilters.tags.length > 0; - }, + } }, methods: { click() { this.$emit('click', this.item); }, - - onFilterChange(filters: any) { - this.currentFilters = { ...filters }; - this.$emit('filter-change', filters); - }, - - t(app: string, text: string, vars?: any) { - return t('memories', text, vars); - }, }, }); From c3658811f4086bb1ede4b652153e6d4f3b964a27 Mon Sep 17 00:00:00 2001 From: Winzlieb Date: Mon, 18 Aug 2025 14:54:42 +0200 Subject: [PATCH 13/38] Refactor embedded tag extraction into Exif class Signed-off-by: Winzlieb --- lib/Db/TimelineWriteEmbeddedTags.php | 56 ++------------------------ lib/Exif.php | 60 ++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 53 deletions(-) diff --git a/lib/Db/TimelineWriteEmbeddedTags.php b/lib/Db/TimelineWriteEmbeddedTags.php index 3bfeb22c7..ee708a36c 100644 --- a/lib/Db/TimelineWriteEmbeddedTags.php +++ b/lib/Db/TimelineWriteEmbeddedTags.php @@ -4,6 +4,7 @@ namespace OCA\Memories\Db; +use OCA\Memories\Exif; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\Files\File; use OCP\IDBConnection; @@ -22,7 +23,7 @@ public function processEmbeddedTags(File $file, array $exif): void $userId = $file->getOwner()->getUID(); // Extract embedded tags from EXIF data - $embeddedTags = $this->extractEmbeddedTags($exif); + $embeddedTags = Exif::extractEmbeddedTags($exif); if (empty($embeddedTags)) { return; @@ -34,58 +35,7 @@ public function processEmbeddedTags(File $file, array $exif): void } } - /** - * Extract embedded tags from EXIF data - * - * @param array $exif EXIF data - * @return array Array of tag paths - */ - private function extractEmbeddedTags(array $exif): array - { - $embeddedTags = []; - - // Helper function to ensure we have an array - $ensureArray = function ($value) { - if (empty($value)) { - return []; - } - return is_array($value) ? $value : [$value]; - }; - - // Extract from TagsList (split by '/') - if (!empty($exif['TagsList'])) { - $tagsList = $ensureArray($exif['TagsList']); - foreach ($tagsList as $tag) { - $embeddedTags[] = explode('/', $tag); - } - } - - // Extract from HierarchicalSubject (split by '|') - if (empty($embeddedTags) && !empty($exif['HierarchicalSubject'])) { - $hierarchicalSubject = $ensureArray($exif['HierarchicalSubject']); - foreach ($hierarchicalSubject as $tag) { - $embeddedTags[] = explode('|', $tag); - } - } - - // Extract from Keywords (as individual tags) - if (empty($embeddedTags) && !empty($exif['Keywords'])) { - $keywords = $ensureArray($exif['Keywords']); - foreach ($keywords as $tag) { - $embeddedTags[] = [$tag]; - } - } - - // Extract from Subject (as individual tags) - if (empty($embeddedTags) && !empty($exif['Subject'])) { - $subject = $ensureArray($exif['Subject']); - foreach ($subject as $tag) { - $embeddedTags[] = [$tag]; - } - } - - return $embeddedTags; - } + /** * Ensure a tag exists in the database diff --git a/lib/Exif.php b/lib/Exif.php index de600bb95..457217c89 100644 --- a/lib/Exif.php +++ b/lib/Exif.php @@ -324,6 +324,66 @@ public static function getBUID(string $basename, mixed $imageUniqueID, int $size return md5($basename.$sfx); } + /** + * Extract embedded tags from EXIF data + * + * @param array $exif EXIF data + * @param bool $flatten Whether to flatten the tags into a single array (hierarchy is represented by /) + * @return array Array of tag paths + */ + public static function extractEmbeddedTags(array $exif, bool $flatten = false): array + { + $embeddedTags = []; + + // Helper function to ensure we have an array + $ensureArray = function ($value) { + if (empty($value)) { + return []; + } + return is_array($value) ? $value : [$value]; + }; + + // Extract from TagsList (split by '/') + if (!empty($exif['TagsList'])) { + $tagsList = $ensureArray($exif['TagsList']); + foreach ($tagsList as $tag) { + $embeddedTags[] = explode('/', $tag); + } + } + + // Extract from HierarchicalSubject (split by '|') + if (empty($embeddedTags) && !empty($exif['HierarchicalSubject'])) { + $hierarchicalSubject = $ensureArray($exif['HierarchicalSubject']); + foreach ($hierarchicalSubject as $tag) { + $embeddedTags[] = explode('|', $tag); + } + } + + // Extract from Keywords (as individual tags) + if (empty($embeddedTags) && !empty($exif['Keywords'])) { + $keywords = $ensureArray($exif['Keywords']); + foreach ($keywords as $tag) { + $embeddedTags[] = [$tag]; + } + } + + // Extract from Subject (as individual tags) + if (empty($embeddedTags) && !empty($exif['Subject'])) { + $subject = $ensureArray($exif['Subject']); + foreach ($subject as $tag) { + $embeddedTags[] = [$tag]; + } + } + + if ($flatten) { + $embeddedTags = array_map(function ($tag) { + return implode('/', $tag); + }, $embeddedTags); + } + + return $embeddedTags; + } + /** * Get the list of MIME Types that are allowed to be edited. */ From aba54cf28da6d6785fd70c934d1a6f128ff44ea4 Mon Sep 17 00:00:00 2001 From: Winzlieb Date: Mon, 18 Aug 2025 14:55:51 +0200 Subject: [PATCH 14/38] Add routes to include new API endpoints for embedded tags. Signed-off-by: Winzlieb --- appinfo/routes.php | 4 + lib/Controller/EmbeddedTagsController.php | 158 ++++++++++++++++++++++ lib/Db/EmbeddedTagsQueryFilters.php | 128 ++++++++++++++++++ 3 files changed, 290 insertions(+) create mode 100644 lib/Controller/EmbeddedTagsController.php create mode 100644 lib/Db/EmbeddedTagsQueryFilters.php diff --git a/appinfo/routes.php b/appinfo/routes.php index 2de657210..0a291f484 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -53,6 +53,10 @@ function w($base, $param) ['name' => 'Tags#set', 'url' => '/api/tags/set/{id}', 'verb' => 'PATCH'], + ['name' => 'EmbeddedTags#flat', 'url' => '/api/embedded-tags/flat', 'verb' => 'GET'], + ['name' => 'EmbeddedTags#hierarchical', 'url' => '/api/embedded-tags/hierarchical', 'verb' => 'GET'], + ['name' => 'EmbeddedTags#count', 'url' => '/api/embedded-tags/count', 'verb' => 'GET'], + ['name' => 'Map#clusters', 'url' => '/api/map/clusters', 'verb' => 'GET'], ['name' => 'Map#init', 'url' => '/api/map/init', 'verb' => 'GET'], diff --git a/lib/Controller/EmbeddedTagsController.php b/lib/Controller/EmbeddedTagsController.php new file mode 100644 index 000000000..423601be9 --- /dev/null +++ b/lib/Controller/EmbeddedTagsController.php @@ -0,0 +1,158 @@ + + * @author Varun Patil + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCA\Memories\Controller; + +use OCA\Memories\Db\EmbeddedTagsQuery; +use OCA\Memories\Db\FsManager; +use OCA\Memories\Db\TimelineQuery; +use OCA\Memories\Exceptions; +use OCA\Memories\Util; +use OCP\App\IAppManager; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\JSONResponse; +use OCP\Files\IRootFolder; +use OCP\IConfig; +use OCP\IDBConnection; +use OCP\IRequest; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; + +class EmbeddedTagsController extends GenericApiController +{ + public function __construct( + IRequest $request, + IConfig $config, + IUserSession $userSession, + IDBConnection $connection, + IRootFolder $rootFolder, + IAppManager $appManager, + LoggerInterface $logger, + TimelineQuery $tq, + FsManager $fs, + protected EmbeddedTagsQuery $etq, + ) { + parent::__construct($request, $config, $userSession, $connection, $rootFolder, $appManager, $logger, $tq, $fs); + } + + /** + * Get tags in flat manner with optional filtering and pagination + */ + #[NoAdminRequired] + public function flat(): Http\Response + { + return Util::guardEx(function () { + // Check if user is logged in + if (!Util::isLoggedIn()) { + throw Exceptions::NotLoggedIn(); + } + + // Get query parameters + $pattern = $this->request->getParam('pattern'); + $limit = $this->request->getParam('limit'); + $offset = $this->request->getParam('offset'); + + // Validate and sanitize parameters + $limit = $limit !== null ? max(1, min(1000, (int) $limit)) : null; + $offset = $offset !== null ? max(0, (int) $offset) : null; + $pattern = $pattern !== null ? (string) $pattern : null; + + // Get tags + $tags = $this->etq->getTagsFlat($pattern, $limit, $offset); + + // Get total count for pagination + $totalCount = null; + if ($limit !== null || $offset !== null) { + $totalCount = $this->etq->getTagsCount($pattern); + } + + // Prepare response + $response = [ + 'tags' => $tags, + ]; + + if ($totalCount !== null) { + $response['pagination'] = [ + 'total' => $totalCount, + 'limit' => $limit, + 'offset' => $offset ?? 0, + ]; + } + + return new JSONResponse($response, Http::STATUS_OK); + }); + } + + /** + * Get tags in hierarchical structure + */ + #[NoAdminRequired] + public function hierarchical(): Http\Response + { + return Util::guardEx(function () { + // Check if user is logged in + if (!Util::isLoggedIn()) { + throw Exceptions::NotLoggedIn(); + } + + // Get query parameters + $pattern = $this->request->getParam('pattern'); + $pattern = $pattern !== null ? (string) $pattern : null; + + // Get tags in hierarchical structure + $tags = $this->etq->getTagsHierarchical($pattern); + + return new JSONResponse([ + 'tags' => $tags, + 'structure' => 'hierarchical' + ], Http::STATUS_OK); + }); + } + + /** + * Get tags count (useful for pagination info) + */ + #[NoAdminRequired] + public function count(): Http\Response + { + return Util::guardEx(function () { + // Check if user is logged in + if (!Util::isLoggedIn()) { + throw Exceptions::NotLoggedIn(); + } + + // Get query parameters + $pattern = $this->request->getParam('pattern'); + $pattern = $pattern !== null ? (string) $pattern : null; + + // Get count + $count = $this->etq->getTagsCount($pattern); + + return new JSONResponse([ + 'count' => $count, + 'pattern' => $pattern + ], Http::STATUS_OK); + }); + } +} \ No newline at end of file diff --git a/lib/Db/EmbeddedTagsQueryFilters.php b/lib/Db/EmbeddedTagsQueryFilters.php new file mode 100644 index 000000000..1cf8709e8 --- /dev/null +++ b/lib/Db/EmbeddedTagsQueryFilters.php @@ -0,0 +1,128 @@ + + * @author Varun Patil + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCA\Memories\Db; + +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; + +trait EmbeddedTagsQueryFilters +{ + protected IDBConnection $connection; + + /** + * Transform query to filter by pattern using LIKE or REGEXP + * + * @param IQueryBuilder $query + * @param string $pattern Pattern to search for + */ + public function transformPatternFilter(IQueryBuilder &$query, string $pattern): void + { + // Sanitize pattern input + $pattern = trim($pattern); + if (empty($pattern)) { + return; + } + + // Try to determine if this is a regex pattern or simple search + if ($this->isRegexPattern($pattern)) { + // Use REGEXP for MySQL/MariaDB or similar for other databases + $dbType = $this->connection->getDatabasePlatform()->getName(); + + if (in_array($dbType, ['mysql', 'mariadb'], true)) { + $query->andWhere($query->expr()->orX( + $query->createFunction("et.tag REGEXP " . $query->createNamedParameter($pattern)), + $query->createFunction("et.path REGEXP " . $query->createNamedParameter($pattern)) + )); + } elseif ($dbType === 'postgresql') { + $query->andWhere($query->expr()->orX( + $query->createFunction("et.tag ~ " . $query->createNamedParameter($pattern)), + $query->createFunction("et.path ~ " . $query->createNamedParameter($pattern)) + )); + } else { + // Fallback to LIKE for SQLite and others + $likePattern = '%' . $this->escapeLikePattern($pattern) . '%'; + $query->andWhere($query->expr()->orX( + $query->expr()->like('et.tag', $query->createNamedParameter($likePattern)), + $query->expr()->like('et.path', $query->createNamedParameter($likePattern)) + )); + } + } else { + // Use LIKE for simple text search + $likePattern = '%' . $this->escapeLikePattern($pattern) . '%'; + $query->andWhere($query->expr()->orX( + $query->expr()->like('et.tag', $query->createNamedParameter($likePattern)), + $query->expr()->like('et.path', $query->createNamedParameter($likePattern)) + )); + } + } + + /** + * Apply limit transformation for pagination + * + * @param IQueryBuilder $query + * @param int $limit Maximum number of results + */ + public function transformLimit(IQueryBuilder &$query, int $limit): void + { + if ($limit >= 1 && $limit <= 1000) { // Allow larger limits for tags + $query->setMaxResults($limit); + } + } + + /** + * Apply offset transformation for pagination + * + * @param IQueryBuilder $query + * @param int $offset Number of results to skip + */ + public function transformOffset(IQueryBuilder &$query, int $offset): void + { + if ($offset >= 0) { + $query->setFirstResult($offset); + } + } + + /** + * Check if the pattern looks like a regex + * + * @param string $pattern + * @return bool + */ + private function isRegexPattern(string $pattern): bool + { + // Simple heuristic: check for common regex characters + return preg_match('/[.*+?^${}()|[\]\\\\]/', $pattern) === 1; + } + + /** + * Escape special characters for LIKE pattern + * + * @param string $pattern + * @return string + */ + private function escapeLikePattern(string $pattern): string + { + return str_replace(['%', '_'], ['\\%', '\\_'], $pattern); + } +} \ No newline at end of file From 014d0b742852a98edc9fc40add616238244eaa60 Mon Sep 17 00:00:00 2001 From: Winzlieb Date: Mon, 18 Aug 2025 14:56:56 +0200 Subject: [PATCH 15/38] Added embeddedTags parameter to TimelineQueryDays Signed-off-by: Winzlieb --- lib/Controller/DaysController.php | 13 +++ lib/Db/EmbeddedTagsQuery.php | 175 ++++++++++++++++++++++++++++++ lib/Db/TimelineQueryDays.php | 7 ++ 3 files changed, 195 insertions(+) create mode 100644 lib/Db/EmbeddedTagsQuery.php diff --git a/lib/Controller/DaysController.php b/lib/Controller/DaysController.php index 872d5d3cf..29c44c819 100644 --- a/lib/Controller/DaysController.php +++ b/lib/Controller/DaysController.php @@ -69,6 +69,7 @@ public function day(array $dayIds): Http\Response $this->isMonthView(), $this->isReverse(), $this->getMinRating(), + $this->getEmbeddedTags(), $this->getTransformations(), ); @@ -170,6 +171,7 @@ private function preloadDays(array &$days): void $this->isMonthView(), $this->isReverse(), $this->getMinRating(), + $this->getEmbeddedTags(), $this->getTransformations(), ); @@ -222,4 +224,15 @@ private function getMinRating(): int { return (int) $this->request->getParam('minRating') ?? 0; } + + private function getEmbeddedTags(): array + { + $embeddedTagsParam = $this->request->getParam('embeddedTags'); + if ($embeddedTagsParam) { + // Decode URI-encoded string before splitting + $decoded = urldecode($embeddedTagsParam); + return explode(',', $decoded); + } + return []; + } } diff --git a/lib/Db/EmbeddedTagsQuery.php b/lib/Db/EmbeddedTagsQuery.php new file mode 100644 index 000000000..56d582c98 --- /dev/null +++ b/lib/Db/EmbeddedTagsQuery.php @@ -0,0 +1,175 @@ + + * @author Varun Patil + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCA\Memories\Db; + +use OCA\Memories\Util; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use OCP\IRequest; +use OCP\IUserManager; + +class EmbeddedTagsQuery +{ + use EmbeddedTagsQueryFilters; + + public const TAGS_SELECT = [ + 'id', 'user_id', 'tag', 'parent_tag_id', + 'path', 'level', 'created_at' + ]; + + public function __construct( + protected IDBConnection $connection, + protected IRequest $request, + protected IUserManager $userManager, + ) {} + + public function getBuilder(): IQueryBuilder + { + return $this->connection->getQueryBuilder(); + } + + /** + * Get all tags for a user in flat manner with optional filtering and pagination + * + * @param string|null $pattern Optional regex pattern to filter tags + * @param int|null $limit Optional limit for pagination + * @param int|null $offset Optional offset for pagination + * @return array List of tags + */ + public function getTagsFlat(?string $pattern = null, ?int $limit = null, ?int $offset = null): array + { + $query = $this->getBuilder(); + + $query->select(self::TAGS_SELECT) + ->from('memories_embedded_tags', 'et') + ->where($query->expr()->eq('user_id', $query->createNamedParameter(Util::getUID()))) + ->orderBy('path', 'ASC'); + + // Apply pattern filter if provided + if ($pattern !== null) { + $this->transformPatternFilter($query, $pattern); + } + + // Apply pagination if provided + if ($limit !== null) { + $query->setMaxResults($limit); + } + if ($offset !== null) { + $query->setFirstResult($offset); + } + + return $query->executeQuery()->fetchAll() ?: []; + } + + /** + * Get all tags for a user in hierarchical structure + * + * @param string|null $pattern Optional regex pattern to filter tags + * @return array Hierarchical structure of tags + */ + public function getTagsHierarchical(?string $pattern = null): array + { + // Get all tags first + $query = $this->getBuilder(); + + $query->select(self::TAGS_SELECT) + ->from('memories_embedded_tags', 'et') + ->where($query->expr()->eq('user_id', $query->createNamedParameter(Util::getUID()))) + ->orderBy('level', 'ASC') + ->addOrderBy('tag', 'ASC'); + + // Apply pattern filter if provided + if ($pattern !== null) { + $this->transformPatternFilter($query, $pattern); + } + + $allTags = $query->executeQuery()->fetchAll() ?: []; + + // Build hierarchical structure + return $this->buildHierarchy($allTags); + } + + /** + * Get count of tags matching pattern + * + * @param string|null $pattern Optional regex pattern to filter tags + * @return int Number of tags + */ + public function getTagsCount(?string $pattern = null): int + { + $query = $this->getBuilder(); + + $query->select($query->func()->count('*', 'count')) + ->from('memories_embedded_tags', 'et') + ->where($query->expr()->eq('user_id', $query->createNamedParameter(Util::getUID()))); + + // Apply pattern filter if provided + if ($pattern !== null) { + $this->transformPatternFilter($query, $pattern); + } + + $result = $query->executeQuery()->fetch(); + return (int) ($result['count'] ?? 0); + } + + /** + * Build hierarchical structure from flat tags array + * + * @param array $tags Flat array of tags + * @return array Hierarchical structure + */ + private function buildHierarchy(array $tags): array + { + $hierarchy = []; + $idMap = []; + + // First pass: create nodes and map by ID + foreach ($tags as $tag) { + $node = [ + 'id' => $tag['id'], + 'tag' => $tag['tag'], + 'path' => $tag['path'], + 'level' => $tag['level'], + 'created_at' => $tag['created_at'], + 'children' => [] + ]; + $idMap[$tag['id']] = &$node; + } + + // Second pass: build hierarchy + foreach ($tags as $tag) { + if ($tag['parent_tag_id'] === null) { + // Root level tag + $hierarchy[] = &$idMap[$tag['id']]; + } else { + // Child tag + if (isset($idMap[$tag['parent_tag_id']])) { + $idMap[$tag['parent_tag_id']]['children'][] = &$idMap[$tag['id']]; + } + } + } + + return $hierarchy; + } +} \ No newline at end of file diff --git a/lib/Db/TimelineQueryDays.php b/lib/Db/TimelineQueryDays.php index f626f91aa..19b6c1a54 100644 --- a/lib/Db/TimelineQueryDays.php +++ b/lib/Db/TimelineQueryDays.php @@ -90,6 +90,7 @@ public function getDay( bool $monthView, bool $reverse, int $minRating = 0, + array $embeddedTags = [], array $queryTransforms = [], ): array { // Check if we have any dayIds @@ -176,6 +177,11 @@ public function getDay( $day = array_filter($day, fn ($photo) => $photo['rating'] >= $minRating); } + // Filter by embedded tags + if ($embeddedTags && count($embeddedTags) > 0) { + $day = array_filter($day, fn ($photo) => count(array_intersect($embeddedTags, $photo['embedded_tags'])) > 0); + } + return $day; } @@ -325,6 +331,7 @@ private function postProcessDayPhoto(array &$row, bool $monthView = false): void if ($row['exif'] ?? null) { $row['exif'] = json_decode($row['exif'], true); $row['rating'] = (int) $row['exif']['Rating'] ?? 0; + $row['embedded_tags'] = Exif::extractEmbeddedTags($row['exif'], true); } From 4b7e70afe8abf4569cf46a45826cf073757069ef Mon Sep 17 00:00:00 2001 From: Winzlieb Date: Mon, 18 Aug 2025 14:57:42 +0200 Subject: [PATCH 16/38] Add embedded tags support in API and typings Signed-off-by: Winzlieb --- src/services/API.ts | 13 +++++++++++++ src/typings/cluster.d.ts | 39 +++++++++++++++++++++++++++++++++++++++ src/typings/data.d.ts | 1 + 3 files changed, 53 insertions(+) diff --git a/src/services/API.ts b/src/services/API.ts index d54e8fed8..db1171b84 100644 --- a/src/services/API.ts +++ b/src/services/API.ts @@ -27,6 +27,7 @@ export const enum DaysFilterType { FACERECOGNITION = 'facerecognition', PLACE = 'places', TAG = 'tags', + EMBEDDED_TAGS = 'embeddedTags', MAP_BOUNDS = 'mapbounds', RATING = 'minRating', @@ -109,6 +110,18 @@ export class API { return gen(`${BASE}/tags/set/{fileid}`, { fileid }); } + static EMBEDDED_TAGS_FLAT() { + return gen(`${BASE}/embedded-tags/flat`); + } + + static EMBEDDED_TAGS_HIERARCHICAL() { + return gen(`${BASE}/embedded-tags/hierarchical`); + } + + static EMBEDDED_TAGS_COUNT() { + return gen(`${BASE}/embedded-tags/count`); + } + static FACE_LIST(app: 'recognize' | 'facerecognition') { return gen(`${BASE}/clusters/${app}`); } diff --git a/src/typings/cluster.d.ts b/src/typings/cluster.d.ts index 47dd214ff..0964705ea 100644 --- a/src/typings/cluster.d.ts +++ b/src/typings/cluster.d.ts @@ -63,6 +63,45 @@ declare module '@typings' { __t: never; // cannot have empty interface } + export interface IEmbeddedTag { + /** Unique identifier for the tag */ + id: number; + /** User ID who owns the tag */ + user_id: string; + /** Tag name (leaf node) */ + tag: string; + /** Parent tag ID for hierarchy */ + parent_tag_id?: number | null; + /** Full path of the tag */ + path: string; + /** Level in hierarchy (0 for root) */ + level: number; + /** Creation timestamp */ + created_at: string; + /** Children tags (for hierarchical structure) */ + children?: IEmbeddedTag[]; + } + + export interface IEmbeddedTagsResponse { + /** Array of tags */ + tags: IEmbeddedTag[]; + /** Structure type indicator */ + structure?: 'flat' | 'hierarchical'; + /** Pagination info for flat responses */ + pagination?: { + total: number; + limit: number | null; + offset: number; + }; + } + + export interface IEmbeddedTagsCountResponse { + /** Count of matching tags */ + count: number; + /** Pattern used for filtering */ + pattern: string | null; + } + export interface IFaceRect { w: number; h: number; diff --git a/src/typings/data.d.ts b/src/typings/data.d.ts index 183ba1e07..52f1584d8 100644 --- a/src/typings/data.d.ts +++ b/src/typings/data.d.ts @@ -177,5 +177,6 @@ declare module '@typings' { export type IFilters = { minRating: number; tags: string[]; + embeddedTags: string[]; } } From b4adcd30f68c795d77f843867ebd7014eb0acb39 Mon Sep 17 00:00:00 2001 From: Winzlieb Date: Mon, 18 Aug 2025 14:58:29 +0200 Subject: [PATCH 17/38] Add EmbeddedTagSelector component for tag selection functionality Signed-off-by: Winzlieb --- src/components/EmbeddedTagSelector.vue | 123 +++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 src/components/EmbeddedTagSelector.vue diff --git a/src/components/EmbeddedTagSelector.vue b/src/components/EmbeddedTagSelector.vue new file mode 100644 index 000000000..d38b2a44b --- /dev/null +++ b/src/components/EmbeddedTagSelector.vue @@ -0,0 +1,123 @@ + + + + + \ No newline at end of file From b9b8f6aa658435bc08f9a5c89f7ca4228c08f51d Mon Sep 17 00:00:00 2001 From: Winzlieb Date: Mon, 18 Aug 2025 14:59:00 +0200 Subject: [PATCH 18/38] Add embedded tags filtering functionality to FilterComponent and Timeline Signed-off-by: Winzlieb --- src/components/FilterComponent.vue | 35 +++++++++++++++++++++++++++--- src/components/Timeline.vue | 7 ++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/components/FilterComponent.vue b/src/components/FilterComponent.vue index 81186e5a5..82f871509 100644 --- a/src/components/FilterComponent.vue +++ b/src/components/FilterComponent.vue @@ -40,7 +40,21 @@ :options-filter="tagFilter" :get-option-label="tagLabel" :placeholder="t('memories', 'Select tags...')" - @update:value="onTagsChange" + /> + + + +
+ +
@@ -69,6 +83,7 @@ import * as utils from '@services/utils'; import RatingStars from './RatingStars.vue'; import { defineComponent, type PropType } from 'vue'; +import EmbeddedTagSelector from './EmbeddedTagSelector.vue'; export default defineComponent({ name: 'FilterComponent', @@ -78,6 +93,7 @@ export default defineComponent({ NcSelectTags, RatingStars, CloseIcon, + EmbeddedTagSelector, }, props: { @@ -91,7 +107,8 @@ export default defineComponent({ type: Object as PropType, default: () => ({ minRating: 0, - tags: [] + tags: [], + embeddedTags: [], } as IFilters), }, }, @@ -103,13 +120,14 @@ export default defineComponent({ filters: { minRating: this.initialFilters.minRating || 0, tags: this.initialFilters.tags || [], + embeddedTags: this.initialFilters.embeddedTags || [], } as IFilters, }; }, computed: { hasActiveFilters() { - return this.filters.minRating > 0 || this.filters.tags.length > 0; + return this.filters.minRating > 0 || this.filters.tags.length > 0 || this.filters.embeddedTags.length > 0; }, }, @@ -119,6 +137,7 @@ export default defineComponent({ this.filters = { minRating: newFilters.minRating || 0, tags: newFilters.tags || [], + embeddedTags: newFilters.embeddedTags || [], }; }, deep: true, @@ -142,6 +161,11 @@ export default defineComponent({ (this as any).emitFilterChange(); }, + onEmbeddedTagsChange(tags: string[]) { + this.filters.embeddedTags = tags; + (this as any).emitFilterChange(); + }, + clearRating() { this.filters.minRating = 0; (this as any).emitFilterChange(); @@ -151,6 +175,7 @@ export default defineComponent({ this.filters = { minRating: 0, tags: [], + embeddedTags: [], }; (this as any).emitFilterChange(); }, @@ -209,6 +234,10 @@ export default defineComponent({ } } +.embedded-tags-filter { + width: 100%; +} + .filter-actions { display: flex; gap: 8px; diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue index e4afdc7e1..3eb482502 100644 --- a/src/components/Timeline.vue +++ b/src/components/Timeline.vue @@ -178,6 +178,7 @@ export default defineComponent({ filters: { minRating: 0, tags: [], + embeddedTags: [], } as IFilters, /** Size of outer container [w, h] */ @@ -633,6 +634,11 @@ export default defineComponent({ set(DaysFilterType.TAG, this.filters.tags.join(',')); } + // Embedded Tags + if (this.filters.embeddedTags.length > 0) { + set(DaysFilterType.EMBEDDED_TAGS, this.filters.embeddedTags.map((tag) => encodeURIComponent(tag)).join(',')); + } + // Favorites if (this.routeIsFavorites) { set(DaysFilterType.FAVORITES); @@ -1485,6 +1491,7 @@ export default defineComponent({ this.filters = { minRating: 0, tags: [], + embeddedTags: [], }; this.refresh(); }, From 4370bc3a097941881a4a7426932c0d98a791c45e Mon Sep 17 00:00:00 2001 From: Winzlieb Date: Tue, 2 Sep 2025 07:16:56 +0200 Subject: [PATCH 19/38] introduces a new FilterDropdownButton component the previous filter button implementation in RowHead.vue has been removed to streamline the codebase. Signed-off-by: Winzlieb --- src/components/FilterDropdownButton.vue | 105 ++++++++++++++++++++++++ src/components/frame/RowHead.vue | 60 +------------- 2 files changed, 108 insertions(+), 57 deletions(-) create mode 100644 src/components/FilterDropdownButton.vue diff --git a/src/components/FilterDropdownButton.vue b/src/components/FilterDropdownButton.vue new file mode 100644 index 000000000..3b3d36298 --- /dev/null +++ b/src/components/FilterDropdownButton.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/src/components/frame/RowHead.vue b/src/components/frame/RowHead.vue index fdbaf3e44..5c4d845e4 100644 --- a/src/components/frame/RowHead.vue +++ b/src/components/frame/RowHead.vue @@ -5,29 +5,7 @@
- {{ name }} -
- - -
- - - - + {{ name }}
@@ -35,26 +13,17 @@ + + From d16f878d158e1189a6558fc12ac196dd59bb0e3a Mon Sep 17 00:00:00 2001 From: Winzlieb Date: Tue, 2 Sep 2025 12:10:35 +0200 Subject: [PATCH 21/38] fix EXIF filtering options for minimum rating and embedded tags in DaysController and TimelineQuery - Implemented SQL-level filtering for minimum rating and embedded tags in DaysController. - Added methods in TimelineQuery to control EXIF filtering at SQL level based on database provider. - post-processing in TimelineQueryDays to apply filters when SQL filtering is disabled. - Introduced new transformation methods in TimelineQueryFilters for handling SQL filtering of minimum rating and embedded tags. Signed-off-by: Winzlieb --- lib/Controller/DaysController.php | 12 +++ lib/Db/TimelineQuery.php | 42 +++++++++++ lib/Db/TimelineQueryDays.php | 119 +++++++++++++++++++++++++++--- lib/Db/TimelineQueryFilters.php | 39 ++++++++++ 4 files changed, 203 insertions(+), 9 deletions(-) diff --git a/lib/Controller/DaysController.php b/lib/Controller/DaysController.php index 29c44c819..2f4328428 100644 --- a/lib/Controller/DaysController.php +++ b/lib/Controller/DaysController.php @@ -116,6 +116,18 @@ private function getTransformations(): array $transforms[] = [$this->tq, 'transformMapBoundsFilter', $bounds]; } + // Min rating filter - only if SQL filtering is enabled + if ($this->tq->shouldFilterExifBySQL() && ($minRating = $this->getMinRating())) { + $transforms[] = [$this->tq, 'transformMinRatingFilter', $minRating]; + } + + // Embedded tags filter - only if SQL filtering is enabled + if ($this->tq->shouldFilterExifBySQL() && ($embeddedTags = $this->getEmbeddedTags())) { + if (!empty($embeddedTags)) { + $transforms[] = [$this->tq, 'transformEmbeddedTagsFilter', $embeddedTags]; + } + } + // Limit number of responses for day query if ($limit = $this->request->getParam('limit')) { $transforms[] = [$this->tq, 'transformLimit', (int) $limit]; diff --git a/lib/Db/TimelineQuery.php b/lib/Db/TimelineQuery.php index f645b521b..6e6bc4c92 100644 --- a/lib/Db/TimelineQuery.php +++ b/lib/Db/TimelineQuery.php @@ -19,6 +19,11 @@ class TimelineQuery use TimelineQueryNativeX; use TimelineQuerySingleItem; + // Flag to control whether EXIF filtering happens at SQL level or PHP level + // SQL level is faster but only available on MySQL/MariaDB + // Set to false to force PHP-level filtering for debugging or compatibility + protected bool $filterExifBySQL = true; + public const TIMELINE_SELECT = [ 'm.datetaken', 'm.dayid', 'm.w', 'm.h', 'm.liveid', @@ -38,6 +43,43 @@ public function __construct( protected IUserManager $userManager, ) {} + /** + * Set whether EXIF filtering should happen at SQL level + */ + public function setFilterExifBySQL(bool $value): void + { + $this->filterExifBySQL = $value; + } + + /** + * Check if we should filter EXIF at SQL level based on flag and database provider + */ + public function shouldFilterExifBySQL(): bool + { + if (!$this->filterExifBySQL) { + return false; + } + + /** @var \OCP\IDBConnection $db */ + $dbProvider = $this->connection->getDatabaseProvider(); + + if ($dbProvider === 'pgsql') { + // PostgreSQL - could implement later if needed + return false; + } elseif ($dbProvider === 'mysql') { + // MySQL/MariaDB - supports JSON operations + return true; + } elseif ($dbProvider === 'sqlite') { + // SQLite - limited JSON support + return false; + } elseif ($dbProvider === 'oci') { + // Oracle - complex JSON support + return false; + } + + return false; + } + public function allowEmptyRoot(bool $value = true): void { $this->_rootEmptyAllowed = $value; diff --git a/lib/Db/TimelineQueryDays.php b/lib/Db/TimelineQueryDays.php index 19b6c1a54..babb13dce 100644 --- a/lib/Db/TimelineQueryDays.php +++ b/lib/Db/TimelineQueryDays.php @@ -57,8 +57,13 @@ public function getDays( // FETCH all days $rows = $this->executeQueryWithCTEs($query)->fetchAll(); - // Post process the days - $rows = $this->postProcessDays($rows, $monthView); + // Apply post-filtering if SQL filtering is disabled + if (!$this->shouldFilterExifBySQL()) { + $rows = $this->postProcessDaysWithFilters($rows, $monthView); + } else { + // Post process the days normally + $rows = $this->postProcessDays($rows, $monthView); + } // Reverse order if needed if ($reverse) { @@ -172,14 +177,9 @@ public function getDay( $day = array_reverse($day); } - // Filter by rating - if ($minRating > 0) { - $day = array_filter($day, fn ($photo) => $photo['rating'] >= $minRating); - } - - // Filter by embedded tags + // Filter by embedded tags (if not already filtered in SQL) if ($embeddedTags && count($embeddedTags) > 0) { - $day = array_filter($day, fn ($photo) => count(array_intersect($embeddedTags, $photo['embedded_tags'])) > 0); + $day = array_filter($day, fn ($photo) => count(array_intersect($embeddedTags, $photo['embedded_tags'] ?? [])) > 0); } return $day; @@ -281,6 +281,107 @@ public function filterFilecache( return $query; } + /** + * Process the days response with EXIF filtering applied after SQL query. + * + * @param array $rows the days response from SQL + * @param bool $monthView Whether the response is in month view + */ + private function postProcessDaysWithFilters(array $rows, bool $monthView): array + { + if (empty($rows)) { + return $rows; + } + + // Get filter parameters from request + $minRating = (int) $this->request->getParam('minRating') ?: 0; + $embeddedTags = $this->getEmbeddedTagsFromRequest(); + + // Get day IDs for filtering + $dayIds = array_map(fn($row) => (int) $row['dayid'], $rows); + + // Fetch all photos for these days (without EXIF filtering) + $allPhotos = $this->getDay( + $dayIds, + true, + false, + false, + $monthView, + false, + 0, // minRating - don't apply in SQL, do in PHP + [], // embeddedTags - don't apply in SQL, do in PHP + [] // transforms + ); + + // Group photos by day + $photosByDay = []; + foreach ($allPhotos as $photo) { + $dayId = (int) $photo['dayid']; + if (!isset($photosByDay[$dayId])) { + $photosByDay[$dayId] = []; + } + $photosByDay[$dayId][] = $photo; + } + + // Apply PHP filtering and recount + $filteredRows = []; + foreach ($rows as $row) { + $dayId = (int) $row['dayid']; + $dayPhotos = $photosByDay[$dayId] ?? []; + + // Apply EXIF filters + if ($minRating > 0) { + $dayPhotos = array_filter($dayPhotos, fn ($photo) => ($photo['rating'] ?? 0) >= $minRating); + } + + if (!empty($embeddedTags)) { + $dayPhotos = array_filter($dayPhotos, fn ($photo) => + count(array_intersect($embeddedTags, $photo['embedded_tags'] ?? [])) > 0 + ); + } + + // Only include days with qualifying photos + $filteredCount = count($dayPhotos); + if ($filteredCount > 0) { + $filteredRows[] = [ + 'dayid' => $dayId, + 'count' => $filteredCount, + ]; + } + } + + // Convert to months if needed + if ($monthView) { + $filteredRows = array_values(array_reduce($filteredRows, function ($carry, $item) { + $monthId = $this->dayIdToMonthId($item['dayid']); + + if (!array_key_exists($monthId, $carry)) { + $carry[$monthId] = ['dayid' => $monthId, 'count' => 0]; + } + + $carry[$monthId]['count'] += $item['count']; + + return $carry; + }, [])); + } + + return $filteredRows; + } + + /** + * Extract embedded tags from request parameter. + */ + private function getEmbeddedTagsFromRequest(): array + { + $embeddedTagsParam = $this->request->getParam('embeddedTags'); + if ($embeddedTagsParam) { + // Decode URI-encoded string before splitting + $decoded = urldecode($embeddedTagsParam); + return explode(',', $decoded); + } + return []; + } + /** * Process the days response. * diff --git a/lib/Db/TimelineQueryFilters.php b/lib/Db/TimelineQueryFilters.php index 4dac2e663..ae66977b5 100644 --- a/lib/Db/TimelineQueryFilters.php +++ b/lib/Db/TimelineQueryFilters.php @@ -11,6 +11,43 @@ trait TimelineQueryFilters { + + + public function transformMinRatingFilter(IQueryBuilder &$query, bool $aggregate, int $minRating): void + { + if ($minRating <= 0) { + return; + } + + // Check if we should filter by SQL based on flag and database provider + if (!$this->shouldFilterExifBySQL()) { + return; + } + + $query->andWhere('JSON_EXTRACT(m.exif, \'$.Rating\') >= :minRating'); + $query->setParameter('minRating', $minRating, IQueryBuilder::PARAM_INT); + } + + public function transformEmbeddedTagsFilter(IQueryBuilder &$query, bool $aggregate, array $embeddedTags): void + { + if (empty($embeddedTags)) { + return; + } + + // Check if we should filter by SQL based on flag and database provider + if (!$this->shouldFilterExifBySQL()) { + return; + } + + $or = $query->expr()->orX(); + $fields = ['Keywords', 'Subject', 'TagsList', 'HierarchicalSubject']; + foreach ($fields as $field) { + $or->add("JSON_OVERLAPS(JSON_EXTRACT(m.exif, '$.{$field}'), JSON_ARRAY(:tags))"); + } + $query->andWhere($or); + $query->setParameter('tags', $embeddedTags, IQueryBuilder::PARAM_STR_ARRAY); + } + public function transformFavoriteFilter(IQueryBuilder &$query, bool $aggregate): void { if (Util::isLoggedIn()) { @@ -71,3 +108,5 @@ private function getFavoriteVCategoryFun(IQueryBuilder &$query): IQueryFunction return SQL::subquery($query, $sub); } } + + From d88593a1dbed8f8efcd36097bdd408c7ef754269 Mon Sep 17 00:00:00 2001 From: Winzlieb Date: Tue, 2 Sep 2025 13:37:39 +0200 Subject: [PATCH 22/38] Add RatingTags component for displaying photo ratings and hierarchical tags --- src/components/RatingTags.vue | 188 ++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 src/components/RatingTags.vue diff --git a/src/components/RatingTags.vue b/src/components/RatingTags.vue new file mode 100644 index 000000000..176fa6cd7 --- /dev/null +++ b/src/components/RatingTags.vue @@ -0,0 +1,188 @@ + + + + + From 1aa9595af5389b60f678089d8b16f6c1cad9f25d Mon Sep 17 00:00:00 2001 From: Winzlieb Date: Tue, 2 Sep 2025 13:39:44 +0200 Subject: [PATCH 23/38] make Viewer component to display photo EXIF tags and ratings Signed-off-by: Winzlieb --- src/components/viewer/Viewer.vue | 70 ++++++++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 7 deletions(-) diff --git a/src/components/viewer/Viewer.vue b/src/components/viewer/Viewer.vue index 1fec53daf..64b135a94 100644 --- a/src/components/viewer/Viewer.vue +++ b/src/components/viewer/Viewer.vue @@ -37,14 +37,24 @@
-
- {{ currentPhoto.imageInfo.exif.Title }} +
+
+ {{ currentPhoto.imageInfo.exif.Title }} +
+
+ {{ currentPhoto.imageInfo.exif.Description }} +
+
+ {{ currentDateTaken }} +
-
- {{ currentPhoto.imageInfo.exif.Description }} -
-
- {{ currentDateTaken }} +
+
@@ -66,6 +76,7 @@ import * as utils from '@services/utils'; import * as nativex from '@native'; import ImageEditor from './ImageEditor.vue'; +import RatingTags from '../RatingTags.vue'; import PhotoSwipe, { type PhotoSwipeOptions } from 'photoswipe'; import 'photoswipe/style.css'; import PsImage from './PsImage'; @@ -116,6 +127,7 @@ export default defineComponent({ NcActions, NcActionButton, ImageEditor, + RatingTags, }, mixins: [UserConfig], @@ -422,6 +434,25 @@ export default defineComponent({ fileid: raw.fileid, })); }, + + /** Get current photo rating */ + currentRating(): number { + const rating = this.currentPhoto?.imageInfo?.exif?.Rating; + return typeof rating === 'number' ? rating : 0; + }, + + /** Get current photo embedded tags */ + currentTags(): string[][] { + const exif = this.currentPhoto?.imageInfo?.exif; + if (!exif) return []; + + const ensureArray = (v: string | string[] | undefined | null) => v ? (Array.isArray(v) ? v : [v]) : undefined; + return ensureArray(exif.TagsList)?.map((tag) => tag.split('/')) || + ensureArray(exif.HierarchicalSubject)?.map((tag) => tag.split('|')) || + ensureArray(exif.Keywords)?.map((tag) => [tag]) || + ensureArray(exif.Subject)?.map((tag) => [tag]) || + []; + }, }, watch: { @@ -1357,6 +1388,9 @@ export default defineComponent({ bottom: 0; left: 0; pointer-events: none; + display: flex; + justify-content: space-between; + align-items: flex-end; transition: opacity 0.2s ease-in-out; opacity: 0; @@ -1364,6 +1398,16 @@ export default defineComponent({ opacity: 1; } + .bottom-bar-left { + flex: 1; + min-width: 0; // Allow flex shrinking + } + + .bottom-bar-right { + flex-shrink: 0; + margin-left: 16px; + } + .exif { &.title { font-weight: bold; @@ -1378,6 +1422,18 @@ export default defineComponent({ line-height: 1.2em; } } + + // Mobile adjustments + @media (max-width: 768px) { + flex-direction: column; + align-items: flex-start; + gap: 8px; + + .bottom-bar-right { + margin-left: 0; + align-self: flex-end; + } + } } .fullyOpened.slideshowTimer :deep .pswp__container { From 37232a1677e791562acdc3a39fbf61fe74f0a1ac Mon Sep 17 00:00:00 2001 From: Winzlieb Date: Tue, 2 Sep 2025 15:22:54 +0200 Subject: [PATCH 24/38] Add metadata options for slideshow and gallery Signed-off-by: Winzlieb --- lib/Controller/OtherController.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/Controller/OtherController.php b/lib/Controller/OtherController.php index f2892e2f1..8e44bddb1 100644 --- a/lib/Controller/OtherController.php +++ b/lib/Controller/OtherController.php @@ -109,6 +109,9 @@ public function getUserConfig(): Http\Response 'livephoto_autoplay' => 'true' === $getAppConfig('livephotoAutoplay', 'false'), 'livephoto_loop' => 'true' === $getAppConfig('livephotoLoop', 'false'), 'sidebar_filepath' => 'true' === $getAppConfig('sidebarFilepath', false), + 'metadata_in_slideshow' => 'true' === $getAppConfig('metadataInSlideshow', 'false'), + 'metadata_in_gallery' => 'true' === $getAppConfig('metadataInGallery', 'false'), + 'enable_exif_photo_rating_in_gallery' => 'true' === $getAppConfig('enableExifPhotoRatingInGallery', 'false'), // folder settings 'folders_path' => $getAppConfig('foldersPath', '/'), From a90719fff297e3079d38c61e2fcda02eaac958aa Mon Sep 17 00:00:00 2001 From: Winzlieb Date: Tue, 2 Sep 2025 15:24:48 +0200 Subject: [PATCH 25/38] Add metadata options for gallery and photo rating in settings Signed-off-by: Winzlieb --- src/components/Settings.vue | 24 ++++++++++++++++++++++++ src/services/static-config.ts | 2 ++ src/typings/config.d.ts | 2 ++ 3 files changed, 28 insertions(+) diff --git a/src/components/Settings.vue b/src/components/Settings.vue index 19711598e..2ff7550d3 100644 --- a/src/components/Settings.vue +++ b/src/components/Settings.vue @@ -87,6 +87,22 @@ {{ t('memories', 'Show metadata in slideshow') }} + + {{ t('memories', 'Show metadata in gallery') }} + + + + {{ t('memories', 'Enable photo rating in gallery') }} + +
{{ t('memories', 'High resolution image loading behavior') }}
Date: Tue, 2 Sep 2025 15:25:23 +0200 Subject: [PATCH 26/38] Add EXIF data handling functions for rating and tags extraction - Implemented `getRatingFromExif` to extract the rating from EXIF data, returning a default of 0 if not found. - Added `getTagsFromExif` to retrieve hierarchical tags from EXIF data, supporting various tag formats. Updated typings to include an optional `exif` property Signed-off-by: Winzlieb --- src/services/utils/helpers.ts | 27 +++++++++++++++++++++++++++ src/typings/data.d.ts | 3 +++ 2 files changed, 30 insertions(+) diff --git a/src/services/utils/helpers.ts b/src/services/utils/helpers.ts index 4a51375af..59d2d1382 100644 --- a/src/services/utils/helpers.ts +++ b/src/services/utils/helpers.ts @@ -218,3 +218,30 @@ export function onDOMLoaded(callback: () => void) { setTimeout(callback, 0); } } + +/** + * Extract rating from EXIF data + * @param exif EXIF data object + * @returns Rating as number (0-5) or 0 if not found + */ +export function getRatingFromExif(exif: any): number { + const rating = exif?.Rating; + return typeof rating === 'number' ? rating : 0; +} + +/** + * Extract tags from EXIF data + * @param exif EXIF data object + * @returns Array of tag arrays (hierarchical paths) + */ +export function getTagsFromExif(exif: any): string[][] { + if (!exif) return []; + + const ensureArray = (v: string | string[] | undefined | null) => v ? (Array.isArray(v) ? v : [v]) : undefined; + + return ensureArray(exif.TagsList)?.map((tag) => tag.split('/')) || + ensureArray(exif.HierarchicalSubject)?.map((tag) => tag.split('|')) || + ensureArray(exif.Keywords)?.map((tag) => [tag]) || + ensureArray(exif.Subject)?.map((tag) => [tag]) || + []; +} diff --git a/src/typings/data.d.ts b/src/typings/data.d.ts index 52f1584d8..fb2d1f9d1 100644 --- a/src/typings/data.d.ts +++ b/src/typings/data.d.ts @@ -70,6 +70,9 @@ declare module '@typings' { /** Reference to exif object */ imageInfo?: IImageInfo | null; + /** Reference to exif object */ + exif?: IExif; + /** Face detection ID */ faceid?: number; /** Face dimensions */ From 26170ee38eb2e950f8099543880ba9a278c833bd Mon Sep 17 00:00:00 2001 From: Winzlieb Date: Tue, 2 Sep 2025 15:25:57 +0200 Subject: [PATCH 27/38] prop to conditionally hide rating stars Signed-off-by: Winzlieb --- src/components/RatingTags.vue | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/RatingTags.vue b/src/components/RatingTags.vue index 176fa6cd7..f81cba865 100644 --- a/src/components/RatingTags.vue +++ b/src/components/RatingTags.vue @@ -1,7 +1,7 @@ @@ -85,7 +107,13 @@ import { defineComponent, type PropType } from 'vue'; import * as utils from '@services/utils'; import staticConfig from '@services/static-config'; +import UserConfig from '@mixins/UserConfig'; +import axios from '@nextcloud/axios'; +import { API } from '@services/API'; +import { showError } from '@nextcloud/dialogs'; +import RatingTags from '@components/RatingTags.vue'; +import RatingStars from '@components/RatingStars.vue'; import LivePhotoIcon from '@components/icons/LivePhoto.vue'; import CheckCircleIcon from 'vue-material-design-icons/CheckCircle.vue'; import StarIcon from 'vue-material-design-icons/Star.vue'; @@ -100,7 +128,12 @@ import errorsvg from '@assets/error.svg'; export default defineComponent({ name: 'Photo', + + mixins: [UserConfig], + components: { + RatingTags, + RatingStars, LivePhotoIcon, CheckCircleIcon, VideoIcon, @@ -224,6 +257,33 @@ export default defineComponent({ } return null; }, + + /** Get current photo rating */ + currentRating(): number { + const exif = this.data.imageInfo?.exif || this.data.exif; + return utils.getRatingFromExif(exif); + }, + + /** Get current photo embedded tags */ + currentTags(): string[][] { + const exif = this.data.imageInfo?.exif || this.data.exif; + return utils.getTagsFromExif(exif); + }, + + /** Whether to show the RatingTags component */ + showRatingTags(): boolean { + return this.config.metadata_in_gallery && (this.currentRating > 0 || this.currentTags.length > 0); + }, + + /** Whether to show the interactive rating overlay */ + showInteractiveRating(): boolean { + return this.config.enable_exif_photo_rating_in_gallery && (!!this.data.exif || !!this.data.imageInfo?.exif); + }, + + /** Whether to show the metadata overlay container */ + showMetadataOverlay(): boolean { + return this.showRatingTags || this.showInteractiveRating; + }, }, methods: { @@ -344,6 +404,44 @@ export default defineComponent({ if (this.liveState.playing) this.stopVideo(); else this.playVideo(); }, + + /** Update photo rating */ + async updateRating(rating: number) { + const exif = this.data.imageInfo?.exif || this.data.exif; + if (!exif) return; + + try { + const fileid = this.data.fileid; + const currentRating = exif.Rating || 0; + const newRating = rating === currentRating ? undefined : rating; + + // Optimistically update the UI + if (newRating === undefined) { + delete exif.Rating; + } else { + exif.Rating = newRating; + } + + // Update the server + await axios.patch(API.IMAGE_SETEXIF(fileid), { + raw: { Rating: newRating } + }); + + // Emit file updated event + utils.bus.emit('files:file:updated', { fileid }); + + } catch (e) { + console.error('Failed to update rating for', this.data.fileid, e); + if (e.response?.data?.message) { + showError(e.response.data.message); + } else { + showError(this.t('memories', 'Failed to update rating')); + } + + // Revert the optimistic update on error + this.$forceUpdate(); + } + }, }, }); @@ -387,7 +485,7 @@ $icon-size: $icon-half-size * 2; .select { position: absolute; top: calc(var(--icon-dist) + 2px); - left: calc(var(--icon-dist) + 2px); + right: calc(var(--icon-dist) + 2px); z-index: 100; border-radius: 50%; display: none; @@ -552,4 +650,139 @@ div.img-outer { } } } + +// Metadata overlay +.metadata-overlay { + position: absolute; + bottom: 0; + left: 0; + right: 0; + z-index: 50; + pointer-events: none; + background: linear-gradient(180deg, transparent 0%, rgba(0, 0, 0, 0.7) 100%); + + // Hide on hover to prevent interference with selection + @media (hover: hover) and (pointer: fine) { + .p-outer:hover > .img-outer > & { + opacity: 0.3; + transition: opacity 0.2s ease; + } + } + + // Hide when selected + .p-outer.selected > .img-outer > & { + opacity: 0; + } + + // Compact styling for gallery + :deep .rating-tags { + font-size: 0.8em; + gap: 6px; + + .rating-section { + :deep .rating-stars { + gap: 0; + + .button-vue { + padding: 2px; + min-height: unset; + min-width: unset; + } + } + } + + .tags-container { + gap: 2px; + + .chip { + font-size: 0.75em; + padding: 2px 6px; + max-width: 80px; + + :deep .chip__content { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + } + + .more-tags { + font-size: 0.7em; + } + } + + @media (max-width: 768px) { + bottom: 2px; + left: 2px; + right: 2px; + padding: 6px 4px 2px; + + :deep .rating-tags { + font-size: 0.75em; + gap: 4px; + } + } +} + +// Interactive rating overlay within metadata +.interactive-rating { + position: absolute; + top: 0; + left: 0; + display: flex; + justify-content: flex-start; + align-items: flex-end; + padding: 8px 6px 4px; + opacity: 0.7; + transition: opacity 0.2s ease; + pointer-events: auto; + z-index: 10; + + // Show on hover or when photo has rating + @media (hover: hover) and (pointer: fine) { + .p-outer:hover & { + opacity: 1; + } + } + + // Always show if there's a rating + &:has(.rating-stars .button-vue.filled) { + opacity: 1; + } + + // Show on touch devices + @media (hover: none) { + .p-outer:active &, + .p-outer.touched & { + opacity: 1; + } + } + + // Hide when photo is selected + .p-outer.selected & { + opacity: 0; + pointer-events: none; + } + + // Compact rating stars styling for gallery + :deep .rating-stars { + gap: 1px !important; + + .button-vue { + padding: 2px; + min-height: 18px; + min-width: 18px; + } + } + + @media (max-width: 768px) { + padding: 6px 4px 2px; + + :deep .rating-stars .button-vue { + min-height: 16px; + min-width: 16px; + } + } +} From 5f85d4cac12067ba06f29d65d9acf8106086d519 Mon Sep 17 00:00:00 2001 From: Winzlieb Date: Tue, 2 Sep 2025 15:28:53 +0200 Subject: [PATCH 30/38] Refactor currentRating and currentTags computed properties in Viewer component and use utils.getRatingFromExif Signed-off-by: Winzlieb --- src/components/viewer/Viewer.vue | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/components/viewer/Viewer.vue b/src/components/viewer/Viewer.vue index 64b135a94..4066f49be 100644 --- a/src/components/viewer/Viewer.vue +++ b/src/components/viewer/Viewer.vue @@ -437,21 +437,14 @@ export default defineComponent({ /** Get current photo rating */ currentRating(): number { - const rating = this.currentPhoto?.imageInfo?.exif?.Rating; - return typeof rating === 'number' ? rating : 0; + const exif = this.currentPhoto?.imageInfo?.exif; + return utils.getRatingFromExif(exif); }, /** Get current photo embedded tags */ currentTags(): string[][] { const exif = this.currentPhoto?.imageInfo?.exif; - if (!exif) return []; - - const ensureArray = (v: string | string[] | undefined | null) => v ? (Array.isArray(v) ? v : [v]) : undefined; - return ensureArray(exif.TagsList)?.map((tag) => tag.split('/')) || - ensureArray(exif.HierarchicalSubject)?.map((tag) => tag.split('|')) || - ensureArray(exif.Keywords)?.map((tag) => [tag]) || - ensureArray(exif.Subject)?.map((tag) => [tag]) || - []; + return utils.getTagsFromExif(exif); }, }, From d84aaa987253a196aa401c2213a251efcc4584f0 Mon Sep 17 00:00:00 2001 From: Winzlieb Date: Wed, 3 Sep 2025 10:39:22 +0200 Subject: [PATCH 31/38] fix DaysController to filter photos by prefiltered fileIds Signed-off-by: Winzlieb --- lib/Controller/DaysController.php | 9 +++++++++ lib/Db/TimelineQueryDays.php | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/Controller/DaysController.php b/lib/Controller/DaysController.php index 2f4328428..335ead8e3 100644 --- a/lib/Controller/DaysController.php +++ b/lib/Controller/DaysController.php @@ -194,6 +194,15 @@ private function preloadDays(array &$days): void continue; } + // Only include photos that are in the fileIds array (if it exists) + $dayData = $drefMap[$dayId]; + if (isset($dayData['fileIds']) && !empty($dayData['fileIds'])) { + $photoFileId = (int) $photo['fileid']; + if (!in_array($photoFileId, $dayData['fileIds'], true)) { + continue; + } + } + if (!($drefMap[$dayId]['detail'] ?? null)) { $drefMap[$dayId]['detail'] = []; } diff --git a/lib/Db/TimelineQueryDays.php b/lib/Db/TimelineQueryDays.php index babb13dce..774c7dec6 100644 --- a/lib/Db/TimelineQueryDays.php +++ b/lib/Db/TimelineQueryDays.php @@ -346,6 +346,7 @@ private function postProcessDaysWithFilters(array $rows, bool $monthView): array $filteredRows[] = [ 'dayid' => $dayId, 'count' => $filteredCount, + 'fileIds' => array_map(fn ($photo) => $photo['fileid'], $dayPhotos), ]; } } @@ -431,7 +432,7 @@ private function postProcessDayPhoto(array &$row, bool $monthView = false): void //parse json of exif if exif exists if ($row['exif'] ?? null) { $row['exif'] = json_decode($row['exif'], true); - $row['rating'] = (int) $row['exif']['Rating'] ?? 0; + $row['rating'] = isset($row['exif']['Rating']) ? (int) $row['exif']['Rating'] : null; $row['embedded_tags'] = Exif::extractEmbeddedTags($row['exif'], true); } From 79f2709ece590171b83ee5ff0d50c3f5ed54701f Mon Sep 17 00:00:00 2001 From: Winzlieb Date: Wed, 3 Sep 2025 10:46:34 +0200 Subject: [PATCH 32/38] Enhance FilterComponent to support collaborative tags filtering - Added a new property `showCollaborativeTagsFilter` to conditionally display the tags filter section. - Updated the `hasActiveFilters` computed property to account for collaborative tags when determining active filters. Signed-off-by: Winzlieb --- src/components/FilterComponent.vue | 12 ++++++++++-- src/components/MobileHeader.vue | 3 --- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/components/FilterComponent.vue b/src/components/FilterComponent.vue index 82f871509..24d212056 100644 --- a/src/components/FilterComponent.vue +++ b/src/components/FilterComponent.vue @@ -26,7 +26,7 @@
-
+
@@ -111,6 +111,11 @@ export default defineComponent({ embeddedTags: [], } as IFilters), }, + /** Whether to show collaborative tags filter (false = only embedded tags supported) */ + showCollaborativeTagsFilter: { + type: Boolean, + default: false, + }, }, emits: ['filter-change'], @@ -127,7 +132,10 @@ export default defineComponent({ computed: { hasActiveFilters() { - return this.filters.minRating > 0 || this.filters.tags.length > 0 || this.filters.embeddedTags.length > 0; + const hasRatingFilter = this.filters.minRating > 0; + const hasTagsFilter = this.showCollaborativeTagsFilter && this.filters.tags.length > 0; + const hasEmbeddedTagsFilter = this.filters.embeddedTags.length > 0; + return hasRatingFilter || hasTagsFilter || hasEmbeddedTagsFilter; }, }, diff --git a/src/components/MobileHeader.vue b/src/components/MobileHeader.vue index 7b7a34577..16868e1f7 100644 --- a/src/components/MobileHeader.vue +++ b/src/components/MobileHeader.vue @@ -12,7 +12,6 @@
-
@@ -23,7 +22,6 @@ import { defineComponent } from 'vue'; import { generateUrl } from '@nextcloud/router'; import UploadMenuItem from '@components/header/UploadMenuItem.vue'; -import FilterDropdownButton from '@components/FilterDropdownButton.vue'; import SearchbarMenuItem from '@components/header/SearchbarMenuItem.vue'; import * as utils from '@services/utils'; @@ -34,7 +32,6 @@ export default defineComponent({ name: 'MobileHeader', components: { UploadMenuItem, - FilterDropdownButton, SearchbarMenuItem, }, From 3660ff3ec77f0083ce245f857d316d528eef72da Mon Sep 17 00:00:00 2001 From: Winzlieb Date: Sun, 21 Dec 2025 07:12:05 +0100 Subject: [PATCH 33/38] fix: EmbeddedTagSelector to synchronize selected tags with value prop Signed-off-by: Winzlieb --- src/components/EmbeddedTagSelector.vue | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/components/EmbeddedTagSelector.vue b/src/components/EmbeddedTagSelector.vue index d38b2a44b..0252002aa 100644 --- a/src/components/EmbeddedTagSelector.vue +++ b/src/components/EmbeddedTagSelector.vue @@ -82,6 +82,12 @@ export default defineComponent({ }, watch: { + value: { + immediate: true, + handler(newValue) { + this.selectedTags = newValue || []; + }, + }, selectedTags(newSelection) { this.$emit('update:value', newSelection); From 5880c25e2a1e3c7d62399dcf6b0c737e88e5872a Mon Sep 17 00:00:00 2001 From: Winzlieb Date: Sun, 21 Dec 2025 07:18:42 +0100 Subject: [PATCH 34/38] refactor: Switch to AND filtering logic in TimelineQueryDays and TimelineQueryFilters Signed-off-by: Winzlieb --- lib/Db/TimelineQueryDays.php | 2 +- lib/Db/TimelineQueryFilters.php | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/lib/Db/TimelineQueryDays.php b/lib/Db/TimelineQueryDays.php index 774c7dec6..005f3be68 100644 --- a/lib/Db/TimelineQueryDays.php +++ b/lib/Db/TimelineQueryDays.php @@ -336,7 +336,7 @@ private function postProcessDaysWithFilters(array $rows, bool $monthView): array if (!empty($embeddedTags)) { $dayPhotos = array_filter($dayPhotos, fn ($photo) => - count(array_intersect($embeddedTags, $photo['embedded_tags'] ?? [])) > 0 + count(array_intersect($embeddedTags, $photo['embedded_tags'] ?? [])) === count($embeddedTags) ); } diff --git a/lib/Db/TimelineQueryFilters.php b/lib/Db/TimelineQueryFilters.php index ae66977b5..7b1350b92 100644 --- a/lib/Db/TimelineQueryFilters.php +++ b/lib/Db/TimelineQueryFilters.php @@ -39,13 +39,21 @@ public function transformEmbeddedTagsFilter(IQueryBuilder &$query, bool $aggrega return; } - $or = $query->expr()->orX(); $fields = ['Keywords', 'Subject', 'TagsList', 'HierarchicalSubject']; - foreach ($fields as $field) { - $or->add("JSON_OVERLAPS(JSON_EXTRACT(m.exif, '$.{$field}'), JSON_ARRAY(:tags))"); + + foreach ($embeddedTags as $index => $tag) { + $tagParam = "tag_{$index}"; + $or = $query->expr()->orX(); + + foreach ($fields as $field) { + // Check if the field contains this specific tag + $or->add("JSON_CONTAINS(JSON_EXTRACT(m.exif, '$.{$field}'), JSON_QUOTE(:{$tagParam}))"); + } + + // Add AND condition for this tag + $query->andWhere($or); + $query->setParameter($tagParam, $tag, IQueryBuilder::PARAM_STR); } - $query->andWhere($or); - $query->setParameter('tags', $embeddedTags, IQueryBuilder::PARAM_STR_ARRAY); } public function transformFavoriteFilter(IQueryBuilder &$query, bool $aggregate): void From e8b6246d6a995ff7719896810f8903e1185c11ec Mon Sep 17 00:00:00 2001 From: Winzlieb Date: Sun, 21 Dec 2025 07:33:59 +0100 Subject: [PATCH 35/38] fix: FilterPanel keeps current filters across remounts Signed-off-by: Winzlieb --- src/components/FilterDropdownButton.vue | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/components/FilterDropdownButton.vue b/src/components/FilterDropdownButton.vue index 3b3d36298..15b6b3b93 100644 --- a/src/components/FilterDropdownButton.vue +++ b/src/components/FilterDropdownButton.vue @@ -17,7 +17,7 @@ @@ -31,6 +31,7 @@ import { defineComponent, type PropType } from 'vue'; import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'; import NcPopover from '@nextcloud/vue/dist/Components/NcPopover.js'; import { translate as t } from '@services/l10n'; +import { bus } from '@services/utils'; import type { IFilters } from '@typings'; @@ -66,14 +67,34 @@ export default defineComponent({ 'filter-change': (filters: IFilters) => true, }, + data: () => ({ + currentFilters: { + minRating: 0, + tags: [], + embeddedTags: [], + } as IFilters, + }), + + mounted() { + bus.on('memories:filters:changed', this.onFiltersChangedFromBus); + }, + + beforeUnmount() { + bus.off('memories:filters:changed', this.onFiltersChangedFromBus); + }, + computed: { hasActiveFilters() { - const filters = this.initialFilters; + const filters = this.currentFilters; return filters.minRating > 0 || filters.tags.length > 0 || filters.embeddedTags.length > 0; }, }, methods: { + onFiltersChangedFromBus(filters: IFilters) { + this.currentFilters = { ...filters }; + }, + onFilterChange(filters: IFilters) { this.$emit('filter-change', filters); }, From 2746b8ba8e5f05a8a02907cafb028bc1036bde0a Mon Sep 17 00:00:00 2001 From: Winzlieb Date: Sun, 21 Dec 2025 07:34:53 +0100 Subject: [PATCH 36/38] chore: Simplify data initialization and streamline emitFilterChange calls Signed-off-by: Winzlieb --- src/components/FilterComponent.vue | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/src/components/FilterComponent.vue b/src/components/FilterComponent.vue index 24d212056..cc02047f9 100644 --- a/src/components/FilterComponent.vue +++ b/src/components/FilterComponent.vue @@ -120,15 +120,13 @@ export default defineComponent({ emits: ['filter-change'], - data() { - return { - filters: { - minRating: this.initialFilters.minRating || 0, - tags: this.initialFilters.tags || [], - embeddedTags: this.initialFilters.embeddedTags || [], - } as IFilters, - }; - }, + data: () => ({ + filters: { + minRating: 0, + tags: [], + embeddedTags: [], + } as IFilters, + }), computed: { hasActiveFilters() { @@ -161,22 +159,22 @@ export default defineComponent({ onRatingChange(rating: number) { this.filters.minRating = rating; - (this as any).emitFilterChange(); + this.emitFilterChange(); }, onTagsChange(tags: string[]) { this.filters.tags = tags; - (this as any).emitFilterChange(); + this.emitFilterChange(); }, onEmbeddedTagsChange(tags: string[]) { this.filters.embeddedTags = tags; - (this as any).emitFilterChange(); + this.emitFilterChange(); }, clearRating() { this.filters.minRating = 0; - (this as any).emitFilterChange(); + this.emitFilterChange(); }, clearAllFilters() { @@ -185,7 +183,7 @@ export default defineComponent({ tags: [], embeddedTags: [], }; - (this as any).emitFilterChange(); + this.emitFilterChange(); }, tagFilter(element: any) { From 9d5b8ed6887b745c30ddd27ae8570bb0f475636b Mon Sep 17 00:00:00 2001 From: Winzlieb Date: Sun, 21 Dec 2025 07:57:45 +0100 Subject: [PATCH 37/38] fix: some design inconsistencies streamlined Signed-off-by: Winzlieb --- src/components/FilterDropdownButton.vue | 10 ++++++++++ src/components/Metadata.vue | 11 ++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/components/FilterDropdownButton.vue b/src/components/FilterDropdownButton.vue index 15b6b3b93..be10d510a 100644 --- a/src/components/FilterDropdownButton.vue +++ b/src/components/FilterDropdownButton.vue @@ -116,10 +116,20 @@ export default defineComponent({ opacity: 1; } + // Match header button colors when inside header and not active + header#header &:not(.active) { + color: var(--color-background-plain-text, var(--color-primary-text)) !important; + } + &.active { opacity: 1; color: var(--color-primary); background-color: var(--color-primary-element-light); + + &:hover { + opacity: 1; + background-color: var(--color-primary-element-hover); + } } } } diff --git a/src/components/Metadata.vue b/src/components/Metadata.vue index 75f2ced62..6c62e7633 100644 --- a/src/components/Metadata.vue +++ b/src/components/Metadata.vue @@ -74,7 +74,14 @@
@@ -104,8 +106,9 @@ export default defineComponent({ try { const response = await axios.get(API.EMBEDDED_TAGS_FLAT()); // Transform tags to simple strings for NcSelect options - this.allTags = (response.data.tags || []).map(tag => - this.showFullPath ? tag.path : tag.tag + // Each tag object has: { id, user_id, tag, parent_tag_id, path, level, created_at } + this.allTags = (response.data.tags || []).map(tagObj => + this.showFullPath ? tagObj.path : tagObj.tag ); } catch (error) { console.error('Failed to load embedded tags:', error); @@ -114,6 +117,13 @@ export default defineComponent({ this.loading = false; } }, + + handleCreate(newTag) { + // Add the newly created tag to the options list + if (!this.allTags.includes(newTag)) { + this.allTags.push(newTag); + } + }, }, }); @@ -125,5 +135,9 @@ export default defineComponent({ :deep(.vs__dropdown-menu) { max-height: 200px; } + + :deep(.v-select) { + width: 100%; + } } \ No newline at end of file diff --git a/src/components/Metadata.vue b/src/components/Metadata.vue index 6c62e7633..31c27f3e6 100644 --- a/src/components/Metadata.vue +++ b/src/components/Metadata.vue @@ -61,14 +61,14 @@
-
+
{{ t('memories', 'Tags') }} ({{ embeddedTags.length }})
-
+
+
+ {{ t('memories', 'Click edit to add tags') }} +
- + {{ t('memories', 'Edit') }} @@ -529,6 +532,10 @@ export default defineComponent({ _m.modals.editMetadata([_m.viewer.currentPhoto!], [2]); }, + editEmbeddedTags() { + _m.modals.editMetadata([_m.viewer.currentPhoto!], [6]); + }, + editEXIF() { _m.modals.editMetadata([_m.viewer.currentPhoto!], [3]); }, diff --git a/src/components/RatingStars.vue b/src/components/RatingStars.vue index a4d2cff68..a2011a1f8 100644 --- a/src/components/RatingStars.vue +++ b/src/components/RatingStars.vue @@ -11,13 +11,11 @@ > @@ -93,19 +91,15 @@ export default { } } - // In readonly mode, only show icons with "show" class - :deep .star-filled, - :deep .star-outline { - display: none; - - &.show { - display: inline-block; - } + // Filled stars: yellow/warning color + :deep .material-design-icon.star-icon { + color: var(--color-warning); } - // Style filled stars - :deep .star-filled.show { - color: var(--color-warning); + // Outline stars: inherit color (white on dark, dark on light) + :deep .material-design-icon.star-outline-icon { + color: currentColor; + opacity: 0.7; } } @@ -118,56 +112,35 @@ export default { } } - // Default state (not hovering parent): only show icons with "show" class - :deep .star-filled, - :deep .star-outline { - display: none; - - &.show { - display: inline-block; - } + // Default state (not hovering): show current rating + // Filled stars: yellow/warning color + :deep .material-design-icon.star-icon { + color: var(--color-warning); } - :deep .star-filled.show { - color: var(--color-warning); + // Outline stars: inherit color (adapts to theme/context) + :deep .material-design-icon.star-outline-icon { + color: currentColor; + opacity: 0.7; } // Hover state: when hovering the parent container, show hover preview &:hover { - // Hide all icons with "show" class (current rating) - :deep .star-filled.show, - :deep .star-outline.show { - display: none !important; - } - - // Show outline stars by default during hover - :deep .star-outline { - display: inline-block !important; - } - - :deep .star-filled { - display: none !important; - } - - // Show filled icons for hovered star and all following siblings - :deep .button-vue:hover { - .star-filled { - display: inline-block !important; - color: var(--color-warning); - } - .star-outline { - display: none !important; + // During hover, show all stars in preview mode + // All stars become yellow when hovered or after hovered star + :deep .button-vue { + .material-design-icon { + color: currentColor; + opacity: 0.7; } } - // When hovering a star, also fill all following siblings (which are visually to the left) + // Hovered star and following siblings (visually to the left) become filled/yellow + :deep .button-vue:hover, :deep .button-vue:hover ~ .button-vue { - .star-filled { - display: inline-block !important; - color: var(--color-warning); - } - .star-outline { - display: none !important; + .material-design-icon { + color: var(--color-warning) !important; + opacity: 1 !important; } } } diff --git a/src/components/modal/EditEmbeddedTags.vue b/src/components/modal/EditEmbeddedTags.vue new file mode 100644 index 000000000..ca391ecdf --- /dev/null +++ b/src/components/modal/EditEmbeddedTags.vue @@ -0,0 +1,151 @@ + + + + + + diff --git a/src/components/modal/EditEmbeddedTagsMulti.vue b/src/components/modal/EditEmbeddedTagsMulti.vue new file mode 100644 index 000000000..a1bdc3f48 --- /dev/null +++ b/src/components/modal/EditEmbeddedTagsMulti.vue @@ -0,0 +1,242 @@ + + + + + + diff --git a/src/components/modal/EditMetadataModal.vue b/src/components/modal/EditMetadataModal.vue index e0847325b..9ccf9fb29 100644 --- a/src/components/modal/EditMetadataModal.vue +++ b/src/components/modal/EditMetadataModal.vue @@ -26,6 +26,13 @@
+
+
+ {{ t('memories', 'Embedded Tags') }} +
+ +
+
{{ t('memories', 'EXIF Fields') }} @@ -68,6 +75,7 @@ import ModalMixin from './ModalMixin'; import EditDate from './EditDate.vue'; import EditTags from './EditTags.vue'; +import EditEmbeddedTags from './EditEmbeddedTags.vue'; import EditExif from './EditExif.vue'; import EditLocation from './EditLocation.vue'; import EditOrientation from './EditOrientation.vue'; @@ -90,6 +98,7 @@ export default defineComponent({ EditDate, EditTags, + EditEmbeddedTags, EditExif, EditLocation, EditOrientation, @@ -110,6 +119,7 @@ export default defineComponent({ return this.$refs as { editDate?: InstanceType; editTags?: InstanceType; + editEmbeddedTags?: InstanceType; editExif?: InstanceType; editLocation?: InstanceType; editOrientation?: InstanceType; @@ -123,7 +133,7 @@ export default defineComponent({ }, methods: { - async open(photos: IPhoto[], sections: number[] = [1, 2, 3, 4]) { + async open(photos: IPhoto[], sections: number[] = [1, 2, 3, 4, 6]) { const state = (this.state = Math.random()); this.show = true; this.processing = true; @@ -202,11 +212,15 @@ export default defineComponent({ this.processing = true; // Get exif fields diff + const embeddedTagsResult = this.refs.editEmbeddedTags?.result?.(); const exifResult = { ...(this.refs.editExif?.result?.() || {}), ...(this.refs.editLocation?.result?.() || {}), }; + // Handle multi-photo embedded tags operation separately + const embeddedTagsMultiOp = (embeddedTagsResult as any)?.multiPhotoOperation || null; + // Tags may be created which might throw let tagsResult: { add: number[]; remove: number[] } | null = null; try { @@ -236,6 +250,32 @@ export default defineComponent({ raw.Orientation = orientation; } + // Embedded tags handling + if (embeddedTagsMultiOp) { + // Multi-photo operation: add, remove, or override + const currentExif = p.imageInfo?.exif; + const currentTags = currentExif ? utils.getTagsFromExif(currentExif).map(t => t.join('/')) : []; + let newTags: string[] = []; + + if (embeddedTagsMultiOp.mode === 'add') { + // Add tags: merge with existing + newTags = [...new Set([...currentTags, ...embeddedTagsMultiOp.tags])]; + } else if (embeddedTagsMultiOp.mode === 'remove') { + // Remove tags: filter out specified tags + const tagsToRemove = new Set(embeddedTagsMultiOp.tags); + newTags = currentTags.filter(t => !tagsToRemove.has(t)); + } else if (embeddedTagsMultiOp.mode === 'override') { + // Override: replace all tags + newTags = embeddedTagsMultiOp.tags; + } + + // Convert to EXIF fields + Object.assign(raw, this.tagsToExifFields(newTags)); + } else if (embeddedTagsResult && !(embeddedTagsResult as any).multiPhotoOperation) { + // Single photo operation: use the result directly + Object.assign(raw, embeddedTagsResult); + } + exifs.set(p.fileid, raw); } @@ -354,6 +394,33 @@ export default defineComponent({ return updatable; }, + + tagsToExifFields(tags: string[]) { + // Convert tag strings to all four EXIF fields + if (tags.length === 0) { + return { + Keywords: undefined, + Subject: undefined, + TagsList: undefined, + HierarchicalSubject: undefined, + }; + } + + const tagsList = tags.map(tag => tag.replace(/\|/g, '/')); + const hierarchicalSubject = tags.map(tag => tag.replace(/\//g, '|')); + const keywords = tags.map(tag => tag.replace(/\|/g, '/')); + const subject = tags.map(tag => { + const parts = tag.split(/[\/|]/); + return parts[parts.length - 1]; + }); + + return { + Keywords: keywords, + Subject: subject, + TagsList: tagsList, + HierarchicalSubject: hierarchicalSubject, + }; + }, }, }); diff --git a/src/services/utils/helpers.ts b/src/services/utils/helpers.ts index 59d2d1382..b587f3567 100644 --- a/src/services/utils/helpers.ts +++ b/src/services/utils/helpers.ts @@ -237,11 +237,93 @@ export function getRatingFromExif(exif: any): number { export function getTagsFromExif(exif: any): string[][] { if (!exif) return []; - const ensureArray = (v: string | string[] | undefined | null) => v ? (Array.isArray(v) ? v : [v]) : undefined; + const ensureArray = (v: string | string[] | undefined | null) => v ? (Array.isArray(v) ? v : [v]) : []; - return ensureArray(exif.TagsList)?.map((tag) => tag.split('/')) || - ensureArray(exif.HierarchicalSubject)?.map((tag) => tag.split('|')) || - ensureArray(exif.Keywords)?.map((tag) => [tag]) || - ensureArray(exif.Subject)?.map((tag) => [tag]) || - []; + const allTags: string[][] = []; + const tagSet = new Set(); + + // Helper to add tags if not already present (with normalization for deduplication) + const addTags = (tags: string[][]) => { + for (const tag of tags) { + const tagPath = Array.isArray(tag) ? tag : [tag]; + // Normalize to '/' separator for deduplication key + const normalizedKey = tagPath.join('/').toLowerCase(); + if (!tagSet.has(normalizedKey)) { + tagSet.add(normalizedKey); + allTags.push(tagPath); + } + } + }; + + // Extract from TagsList (split by '/') + const tagsList = ensureArray(exif.TagsList).map((tag) => tag.split('/')); + addTags(tagsList); + + // Extract from HierarchicalSubject (split by '|') + const hierarchicalSubject = ensureArray(exif.HierarchicalSubject).map((tag) => tag.split('|')); + addTags(hierarchicalSubject); + + // Extract from Keywords (as individual tags) + const keywords = ensureArray(exif.Keywords).map((tag) => { + // Keywords might contain paths with '/' or '|' separator + return tag.includes('/') ? tag.split('/') : + tag.includes('|') ? tag.split('|') : [tag]; + }); + addTags(keywords); + + // Extract from Subject (as individual tags) + const subject = ensureArray(exif.Subject).map((tag) => [tag]); + addTags(subject); + + // Filter out tags that are components of hierarchical tags + return filterComponentTags(allTags); +} + +/** + * Filter out tags that are components of hierarchical tags + * For example, if we have "Country/Italy", don't also show "Country" or "Italy" + */ +function filterComponentTags(tags: string[][]): string[][] { + if (tags.length === 0) return tags; + + // Step 1: Collect all tags into normalized collections + const flatTags = new Set(); // Single-part tags (no separator) + const hierarchicalTags: string[][] = []; // Multi-part tags (length > 1) + const hierarchicalTagParts = new Set(); // All parts from hierarchical tags + + for (const tag of tags) { + const normalized = tag.map(part => part.toLowerCase()); + + if (normalized.length === 1) { + // Single-part tag + flatTags.add(normalized[0]); + } else { + // Multi-part hierarchical tag + hierarchicalTags.push(tag); + + // Add all parts to hierarchicalTagParts + for (const part of normalized) { + hierarchicalTagParts.add(part); + } + } + } + + // Step 2: Combination - remove flat tags that are parts of hierarchical tags + const result: string[][] = []; + + // Add flat tags that are NOT components of hierarchical tags + for (const flatTag of flatTags) { + if (!hierarchicalTagParts.has(flatTag)) { + // Find the original case from the input tags + const originalTag = tags.find(t => t.length === 1 && t[0].toLowerCase() === flatTag); + if (originalTag) { + result.push(originalTag); + } + } + } + + // Add all hierarchical tags + result.push(...hierarchicalTags); + + return result; }