diff --git a/lib/Controller/OtherController.php b/lib/Controller/OtherController.php index d205218e6..c1e1b9dd0 100644 --- a/lib/Controller/OtherController.php +++ b/lib/Controller/OtherController.php @@ -100,6 +100,7 @@ public function getUserConfig(): Http\Response // general settings 'timeline_path' => $getAppConfig('timelinePath', SystemConfig::get('memories.timeline.default_path')), + 'timeline_include_shared_albums' => $getAppConfig('timelineHasSharedAlbums', SystemConfig::get('memories.timeline.default_include_shared_albums')), 'enable_top_memories' => 'true' === $getAppConfig('enableTopMemories', 'true'), 'stack_raw_files' => 'true' === $getAppConfig('stackRawFiles', 'true'), diff --git a/lib/Db/FsManager.php b/lib/Db/FsManager.php index 9a904056f..97d7cd1a2 100644 --- a/lib/Db/FsManager.php +++ b/lib/Db/FsManager.php @@ -120,6 +120,7 @@ public function populateRoot(TimelineRoot &$root, bool $recursive = true): Timel $paths = [$path]; } else { $paths = Util::getTimelinePaths($uid); + $root->addSharedAlbums(Util::getTimelineIncludeSharedAlbums($uid)); } // Combined etag, for cache invalidation. diff --git a/lib/Db/TimelineQueryCTE.php b/lib/Db/TimelineQueryCTE.php index 090e91b1e..6144212d7 100644 --- a/lib/Db/TimelineQueryCTE.php +++ b/lib/Db/TimelineQueryCTE.php @@ -83,7 +83,7 @@ protected function CTE_FOLDERS(bool $hidden): string { $CLS_HIDDEN = $hidden ? 'MIN(hidden)' : '0'; - $cte = "*PREFIX*cte_folders AS ( + return "*PREFIX*cte_folders AS ( SELECT fileid, ({$CLS_HIDDEN}) AS hidden FROM @@ -91,8 +91,6 @@ protected function CTE_FOLDERS(bool $hidden): string GROUP BY fileid )"; - - return self::bundleCTEs([$this->CTE_FOLDERS_ALL($hidden), $cte]); } /** @@ -100,7 +98,7 @@ protected function CTE_FOLDERS(bool $hidden): string */ protected function CTE_FOLDERS_ARCHIVE(): string { - $cte = "*PREFIX*cte_folders(fileid) AS ( + return "*PREFIX*cte_folders(fileid) AS ( SELECT cfa.fileid FROM @@ -117,8 +115,46 @@ protected function CTE_FOLDERS_ARCHIVE(): string INNER JOIN *PREFIX*cte_folders c ON (f.parent = c.fileid) )"; + } + + protected function CTE_SHARED_ALBUM_FILES(): string + { + // Detect database type + $platform = $this->connection->getDatabasePlatform()::class; + $DISTINCT_ON = ''; + $GROUP_BY = ''; + + if (preg_match('/mysql|mariadb/i', $platform)) { + $GROUP_BY = 'GROUP BY paf.file_id'; + } elseif (preg_match('/postgres/i', $platform)) { + $DISTINCT_ON = 'DISTINCT ON (paf.file_id)'; + } else { + throw new \Exception("Unsupported database detected: {$platform}"); + } + + return "*PREFIX*cte_shared_album_files(fileid, album_path) AS ( + SELECT {$DISTINCT_ON} + paf.file_id AS fileid, + CONCAT(pa.user, '/', pa.name) AS album_path + FROM + *PREFIX*photos_albums_files paf + INNER JOIN + *PREFIX*photos_albums pa ON pa.album_id = paf.album_id + INNER JOIN + *PREFIX*photos_albums_collabs pac ON pac.album_id = paf.album_id + WHERE + pac.collaborator_id = :uid OR pa.user = :uid + {$GROUP_BY} + )"; + } - return self::bundleCTEs([$this->CTE_FOLDERS_ALL(true), $cte]); + protected function CTE_SHARED_ALBUM_FILES_EMPTY(): string + { + return '*PREFIX*cte_shared_album_files(fileid, album_path) AS ( + SELECT + 0 AS fileid, + NULL AS album_path + )'; } /** diff --git a/lib/Db/TimelineQueryDays.php b/lib/Db/TimelineQueryDays.php index 285558a38..9b51bb932 100644 --- a/lib/Db/TimelineQueryDays.php +++ b/lib/Db/TimelineQueryDays.php @@ -7,6 +7,7 @@ use OCA\Memories\ClustersBackend; use OCA\Memories\Exif; use OCA\Memories\Settings\SystemConfig; +use OCA\Memories\Util; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; @@ -146,7 +147,7 @@ public function getDay( $query->innerJoin('m', 'filecache', 'f', $query->expr()->eq('m.fileid', 'f.fileid')); // Filter for files in the timeline path - $query = $this->filterFilecache($query, null, $recursive, $archive, $hidden); + $query = $this->filterFilecache($query, null, $recursive, $archive, $hidden, /* sel_src= */ true); // FETCH all photos in this day $day = $this->executeQueryWithCTEs($query)->fetchAll(); @@ -170,14 +171,18 @@ public function executeQueryWithCTEs(IQueryBuilder $query, string $psql = ''): \ $params = $query->getParameters(); $types = $query->getParameterTypes(); - // Get SQL - $CTE_SQL = \array_key_exists('cteFoldersArchive', $params) - ? $this->CTE_FOLDERS_ARCHIVE() - : $this->CTE_FOLDERS(\array_key_exists('cteIncludeHidden', $params)); + // Get CTE SQL + if (\array_key_exists('cteFoldersArchive', $params)) { + $ctes = [$this->CTE_FOLDERS_ALL(true), $this->CTE_FOLDERS_ARCHIVE()]; + } else { + $hidden = \array_key_exists('cteIncludeHidden', $params); + $ctes = [$this->CTE_FOLDERS_ALL($hidden), $this->CTE_FOLDERS($hidden)]; + } + $ctes[] = \array_key_exists('cteSharedAlbumFiles', $params) ? $this->CTE_SHARED_ALBUM_FILES() : $this->CTE_SHARED_ALBUM_FILES_EMPTY(); // Add WITH clause if needed - if (str_contains($sql, 'cte_folders')) { - $sql = $CTE_SQL.' '.$sql; + if (str_contains($sql, 'cte_folders') || str_contains($sql, 'cte_saf')) { + $sql = $this->bundleCTEs($ctes).' '.$sql; } return $this->connection->executeQuery($sql, $params, $types); @@ -198,6 +203,7 @@ public function filterFilecache( bool $recursive = true, bool $archive = false, bool $hidden = false, + bool $sel_src = false, ): IQueryBuilder { // Get the timeline root object if (null === $root) { @@ -241,20 +247,27 @@ public function filterFilecache( // Filter by folder (recursive or otherwise) if ($recursive) { // This are used later by the execution function - $this->addSubfolderJoinParams($query, $root, $archive, $hidden); + $this->addSubfolderJoinParams($query, $root, $archive, $hidden, !$archive && $root->includeSharedAlbums()); - // Subquery to test parent folder - $sq = $query->getConnection()->getQueryBuilder(); - $sq->select($sq->expr()->literal(1)) - ->from('cte_folders', 'cte_f') - ->where($sq->expr()->eq($parent, 'cte_f.fileid')) - ; + // Join to select files in one of the timeline folders + $query->leftJoin('m', 'cte_folders', 'cte_f', $query->expr()->eq($parent, 'cte_f.fileid')); - // Filter files in one of the timeline folders - $query->andWhere(SQL::exists($query, $sq)); + // Join to optionally select files in shared albums + $query->leftJoin('m', 'cte_shared_album_files', 'cte_saf', $query->expr()->eq('m.fileid', 'cte_saf.fileid')); + if ($sel_src) { + $query->selectAlias('cte_saf.album_path', 'album_path'); + $query->selectAlias('cte_f.fileid', 'timeline_path'); + } + + // Include either via the timeline paths or shared albums + $query->andWhere($query->expr()->orX($query->expr()->isNotNull('cte_f.fileid'), $query->expr()->isNotNull('cte_saf.album_path'))); } else { // If getting non-recursively folder only check for parent $query->andWhere($query->expr()->eq($parent, $query->createNamedParameter($root->getOneId(), IQueryBuilder::PARAM_INT))); + if ($sel_src) { + $query->selectAlias($query->expr()->literal('NULL'), 'album_path'); + $query->selectAlias($query->expr()->literal('1'), 'timeline_path'); + } } return $query; @@ -315,6 +328,9 @@ private function postProcessDayPhoto(array &$row, bool $monthView = false): void unset($row['liveid']); } + $row['src'] = ($row['timeline_path'] ?? null) ? null : ($row['album_path'] ?? null); + unset($row['album_path'], $row['timeline_path']); + // Favorite field, may not be present if ($row['categoryid'] ?? null) { $row['isfavorite'] = 1; @@ -355,12 +371,16 @@ private function addSubfolderJoinParams( TimelineRoot &$root, bool $archive, bool $hidden, + bool $shared, ): void { // Add query parameters $query->setParameter('topFolderIds', $root->getIds(), IQueryBuilder::PARAM_INT_ARRAY); + $query->setParameter('uid', Util::getUID(), IQueryBuilder::PARAM_STR); if ($archive) { $query->setParameter('cteFoldersArchive', true, IQueryBuilder::PARAM_BOOL); + } elseif ($shared) { + $query->setParameter('cteSharedAlbumFiles', true, IQueryBuilder::PARAM_BOOL); } if ($hidden) { diff --git a/lib/Db/TimelineRoot.php b/lib/Db/TimelineRoot.php index 2ed5ff628..55e2647b3 100644 --- a/lib/Db/TimelineRoot.php +++ b/lib/Db/TimelineRoot.php @@ -14,6 +14,8 @@ class TimelineRoot /** @var array */ protected array $folderPaths = []; + protected bool $sharedAlbums = false; + /** * Add a folder to the root. * @@ -35,6 +37,11 @@ public function addFolder(FileInfo $info): void $this->setFolder($info->getId() ?? 0, $info, $path); } + public function addSharedAlbums(bool $include = true): void + { + $this->sharedAlbums = $include; + } + /** * Add mountpoints recursively. */ @@ -113,6 +120,11 @@ public function isEmpty(): bool return empty($this->folderPaths); } + public function includeSharedAlbums(): bool + { + return $this->sharedAlbums; + } + private function setFolder(int $id, ?FileInfo $fileInfo, ?string $path): void { if (null !== $path) { diff --git a/lib/Settings/SystemConfig.php b/lib/Settings/SystemConfig.php index 7814f0718..b0ed1e046 100644 --- a/lib/Settings/SystemConfig.php +++ b/lib/Settings/SystemConfig.php @@ -44,6 +44,9 @@ class SystemConfig // If set to '_empty_', the user is prompted to select a path 'memories.timeline.default_path' => '_empty_', + // Whether to include files accessible via shared albums in the timeline by default + 'memories.timeline.default_include_shared_albums' => true, + // Default viewer high resolution image loading condition // Valid values: 'always' | 'zoom' | 'never' 'memories.viewer.high_res_cond_default' => 'zoom', diff --git a/lib/Util.php b/lib/Util.php index 6933b24b1..2069d0613 100644 --- a/lib/Util.php +++ b/lib/Util.php @@ -321,6 +321,18 @@ public static function getTimelinePaths(string $uid): array ); } + public static function getTimelineIncludeSharedAlbums(string $uid): bool + { + return 'true' === \OC::$server->get(IConfig::class) + ->getUserValue( + $uid, + Application::APPNAME, + 'timelineIncludeSharedAlbums', + SystemConfig::get('memories.timeline.default_include_shared_albums') ? 'true' : 'false', + ) + ; + } + /** * Run a callback in a transaction. * It returns the same type as the return type of the closure. diff --git a/src/components/SelectionManager.vue b/src/components/SelectionManager.vue index 2444a406b..3c44f1616 100644 --- a/src/components/SelectionManager.vue +++ b/src/components/SelectionManager.vue @@ -200,7 +200,7 @@ export default defineComponent({ name: t('memories', 'Delete'), icon: DeleteIcon, callback: this.deleteSelection.bind(this), - if: () => !this.routeIsAlbums, + if: () => !this.routeIsAlbums && this.selectionInTimelinePath(), }, { name: t('memories', 'Remove from album'), @@ -212,7 +212,7 @@ export default defineComponent({ name: t('memories', 'Share'), icon: ShareIcon, callback: this.shareSelection.bind(this), - if: () => !this.routeIsAlbums, + if: () => !this.routeIsAlbums && this.selectionInTimelinePath(), }, { name: t('memories', 'Download'), @@ -230,7 +230,7 @@ export default defineComponent({ name: t('memories', 'Archive'), icon: ArchiveIcon, callback: this.archiveSelection.bind(this), - if: () => !this.routeIsArchiveFolder() && !this.routeIsAlbums, + if: () => !this.routeIsArchiveFolder() && !this.routeIsAlbums && this.selectionInTimelinePath(), }, { name: t('memories', 'Unarchive'), @@ -242,17 +242,19 @@ export default defineComponent({ name: t('memories', 'Edit metadata'), icon: EditFileIcon, callback: this.editMetadataSelection.bind(this), + if: () => !this.routeIsAlbums && this.selectionInTimelinePath(), }, { name: t('memories', 'Rotate / Flip'), icon: RotateLeftIcon, callback: () => this.editMetadataSelection(this.selection, [5]), + if: () => !this.routeIsAlbums && this.selectionInTimelinePath(), }, { name: t('memories', 'View in folder'), icon: OpenInNewIcon, callback: this.viewInFolder.bind(this), - if: () => this.selection.size === 1 && !this.routeIsAlbums, + if: () => this.selection.size === 1 && !this.routeIsAlbums && this.selectionInTimelinePath(), }, { name: t('memories', 'Set as cover image'), @@ -264,13 +266,13 @@ export default defineComponent({ name: t('memories', 'Move to folder'), icon: FolderMoveIcon, callback: this.moveToFolder.bind(this), - if: () => !this.routeIsAlbums && !this.routeIsArchiveFolder(), + if: () => !this.routeIsAlbums && !this.routeIsArchiveFolder() && this.selectionInTimelinePath(), }, { name: t('memories', 'Add to album'), icon: AlbumsIcon, callback: this.addToAlbum.bind(this), - if: (self: any) => self.config.albums_enabled && !self.routeIsAlbums, + if: (self: any) => self.config.albums_enabled && !self.routeIsAlbums && this.selectionInTimelinePath(), }, { id: 'face-move', @@ -335,6 +337,10 @@ export default defineComponent({ return false; }, + selectionInTimelinePath() { + return !Array.from(this.selection.values()).some((photo) => photo.src); + }, + /** Trigger to update props from selection set */ selectionChanged() { this.show = this.selection.size > 0; diff --git a/src/components/Settings.vue b/src/components/Settings.vue index cc9786e32..ee2c4f2fe 100644 --- a/src/components/Settings.vue +++ b/src/components/Settings.vue @@ -17,6 +17,14 @@ readonly /> + + {{ t('memories', 'Include shared albums in timeline') }} + + {{ t('memories', 'Square grid mode') }} @@ -310,6 +318,10 @@ export default defineComponent({ }, // General settings + async updateTimelineIncludeSharedAlbums() { + await this.updateSetting('timeline_include_shared_albums', 'timelineIncludeSharedAlbums'); + }, + async updateSquareThumbs() { await this.updateSetting('square_thumbs'); }, diff --git a/src/components/viewer/Viewer.vue b/src/components/viewer/Viewer.vue index ab0e41581..b10a1d317 100644 --- a/src/components/viewer/Viewer.vue +++ b/src/components/viewer/Viewer.vue @@ -310,7 +310,7 @@ export default defineComponent({ name: this.t('memories', 'View in folder'), icon: OpenInNewIcon, callback: this.viewInFolder, - if: !this.routeIsPublic && !this.routeIsAlbums && !this.isLocal, + if: !this.routeIsPublic && !this.routeIsAlbums && !this.isLocal && !this.currentPhoto?.src, }, { id: 'slideshow', diff --git a/src/services/dav/base.ts b/src/services/dav/base.ts index fb632e766..e50d7077a 100644 --- a/src/services/dav/base.ts +++ b/src/services/dav/base.ts @@ -40,7 +40,7 @@ export async function getFiles(photos: IPhoto[], opts?: GetFilesOpts): Promise !utils.isLocalPhoto(photo)); // Cache and uncached photos - const cache: IFileInfo[] = []; + var cache: IFileInfo[] = []; const rest: IPhoto[] = []; // Partition photos with and without cache @@ -61,8 +61,22 @@ export async function getFiles(photos: IPhoto[], opts?: GetFilesOpts): Promise photo.src == null))); } async function getFilesInternal1(photos: IPhoto[]): Promise { diff --git a/src/services/static-config.ts b/src/services/static-config.ts index 897fb447f..1a3c698f5 100644 --- a/src/services/static-config.ts +++ b/src/services/static-config.ts @@ -132,6 +132,7 @@ class StaticConfig { // general settings timeline_path: '_unknown_', + timeline_include_shared_albums: true, enable_top_memories: true, stack_raw_files: true, dedup_identical: false, diff --git a/src/typings/config.d.ts b/src/typings/config.d.ts index 9849f16e9..07818b311 100644 --- a/src/typings/config.d.ts +++ b/src/typings/config.d.ts @@ -19,6 +19,7 @@ declare module '@typings' { // general settings timeline_path: string; + timeline_include_shared_albums: boolean; enable_top_memories: boolean; stack_raw_files: boolean; dedup_identical: boolean; diff --git a/src/typings/data.d.ts b/src/typings/data.d.ts index d95dc2676..a4fedc2d0 100644 --- a/src/typings/data.d.ts +++ b/src/typings/data.d.ts @@ -99,6 +99,8 @@ declare module '@typings' { /** Stacked RAW photos */ stackraw?: IPhoto[]; + + src?: string; }; export interface IImageInfo {