Skip to content

Commit

Permalink
Merge pull request #1071 from pulsejet/pulsejet/cover
Browse files Browse the repository at this point in the history
Allow changing cover images on clusters
  • Loading branch information
pulsejet authored Mar 12, 2024
2 parents 8123c83 + 389aac6 commit 36725de
Show file tree
Hide file tree
Showing 21 changed files with 716 additions and 97 deletions.
10 changes: 6 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ All notable changes to this project will be documented in this file.
- **Breaking**: Prevent automatically retrying files that failed to index
- The list of files that could not be indexed can be found in the admin panel
- To retry indexing, you can run `occ memories:index --retry`
- Hide files starting with `.` in the timeline
- Support for 3GP videos ([#1055](https://github.com/pulsejet/memories/issues/1055))
- Option to show metadata in slideshow ([#819](https://github.com/pulsejet/memories/issues/819))
- Improve UX of image editor especially on mobile
- **Feature**: Allow changing cover photos of albums, tags, places and people ([#1071](https://github.com/pulsejet/memories/issues/1071))
- **Feature**: Hide files starting with `.` in the timeline
- **Feature**: Support for 3GP videos ([#1055](https://github.com/pulsejet/memories/issues/1055))
- **Feature**: Option to show metadata in slideshow ([#819](https://github.com/pulsejet/memories/issues/819))
- **Feature**: Improve UX of image editor especially on mobile
- **Fix**: The cover photo of clusters will now update automatically when files are moved ([#1071](https://github.com/pulsejet/memories/issues/1071))

## [v6.2.2] - 2024-01-10

Expand Down
1 change: 1 addition & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ function w($base, $param)

['name' => 'Clusters#list', 'url' => '/api/clusters/{backend}', 'verb' => 'GET'],
['name' => 'Clusters#preview', 'url' => '/api/clusters/{backend}/preview', 'verb' => 'GET'],
['name' => 'Clusters#setCover', 'url' => '/api/clusters/{backend}/set-cover', 'verb' => 'POST'],
['name' => 'Clusters#download', 'url' => '/api/clusters/{backend}/download', 'verb' => 'POST'],

['name' => 'Tags#set', 'url' => '/api/tags/set/{id}', 'verb' => 'PATCH'],
Expand Down
60 changes: 50 additions & 10 deletions lib/ClustersBackend/AlbumsBackend.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,40 @@ public function transformDayQuery(IQueryBuilder &$query, bool $aggregate): void

public function getClustersInternal(int $fileid = 0): array
{
// Run actual queries
$list = [];

// Personal albums
$list = array_merge($list, $this->albumsQuery->getList(Util::getUID(), false, $fileid));
// Shared albums
$list = array_merge($list, $this->albumsQuery->getList(Util::getUID(), true, $fileid));
// Transformation to add covers
$transformOwned = function (IQueryBuilder &$query): void {
$this->joinCovers(
query: $query,
clusterTable: 'pa',
clusterTableId: 'album_id',
objectTable: 'photos_albums_files',
objectTableObjectId: 'file_id',
objectTableClusterId: 'album_id',
validateFilecache: false,
);
};

// Transformation for shared albums
$transformShared = function (IQueryBuilder &$query) use ($transformOwned): void {
$transformOwned($query);
$this->joinCovers(
query: $query,
clusterTable: 'pa',
clusterTableId: 'album_id',
objectTable: 'photos_albums_files',
objectTableObjectId: 'file_id',
objectTableClusterId: 'album_id',
validateFilecache: false,
field: 'cover_owner',
user: 'pa.user',
);
};

// Get personal and shared albums
$list = array_merge(
$this->albumsQuery->getList(Util::getUID(), false, $fileid, $transformOwned),
$this->albumsQuery->getList(Util::getUID(), true, $fileid, $transformShared),
);

// Remove elements with duplicate album_id
$seenIds = [];
Expand All @@ -101,9 +128,17 @@ public function getClustersInternal(int $fileid = 0): array
return true;
});

// Add display names for users
$userManager = \OC::$server->get(\OCP\IUserManager::class);

array_walk($list, static function (array &$item) use ($userManager) {
// Fall back cover to cover_owner if available
if (empty($item['cover']) && !empty($item['cover_owner'] ?? null)) {
$item['cover'] = $item['cover_owner'];
$item['cover_etag'] = $item['cover_owner_etag'];
}
unset($item['cover_owner'], $item['cover_owner_etag']);

// Add display names for users
$user = $userManager->get($item['user']);
$item['user_display'] = $user ? $user->getDisplayName() : null;
});
Expand All @@ -117,7 +152,7 @@ public static function getClusterId(array $cluster): int|string
return $cluster['cluster_id'];
}

public function getPhotos(string $name, ?int $limit = null): array
public function getPhotos(string $name, ?int $limit = null, ?int $fileid = null): array
{
// Get album
$album = $this->albumsQuery->getIfAllowed($this->getUID(), $name);
Expand All @@ -128,14 +163,19 @@ public function getPhotos(string $name, ?int $limit = null): array
// Get files
$id = (int) $album['album_id'];

return $this->albumsQuery->getAlbumPhotos($id, $limit);
return $this->albumsQuery->getAlbumPhotos($id, $limit, $fileid);
}

public function sortPhotosForPreview(array &$photos): void
{
// Do nothing, the photos are already sorted by added date desc
}

public function getClusterIdFrom(array $photo): int
{
return (int) $photo['album_id'];
}

private function getUID(): string
{
return Util::isLoggedIn() ? Util::getUID() : '---';
Expand Down
180 changes: 177 additions & 3 deletions lib/ClustersBackend/Backend.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

namespace OCA\Memories\ClustersBackend;

use OCA\Memories\Util;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\Files\SimpleFS\ISimpleFile;

Expand Down Expand Up @@ -76,10 +77,18 @@ abstract public static function getClusterId(array $cluster): int|string;
* Get a list of photos with any extra parameters for the given cluster
* Used for preview generation and download.
*
* @param string $name Identifier for the cluster
* @param int $limit Maximum number of photos to return
* @param string $name Identifier for the cluster
* @param int $limit Maximum number of photos to return (optional)
* @param int $fileid Filter photos by file ID (optional)
*
* Setting $limit to -6 will attempt to fetch the cover photo for the cluster
* This will be returned as an array with a single element if found
*/
abstract public function getPhotos(string $name, ?int $limit = null): array;
abstract public function getPhotos(
string $name,
?int $limit = null,
?int $fileid = null,
): array;

/**
* Human readable name for the cluster.
Expand Down Expand Up @@ -127,6 +136,22 @@ public function getFileId(array $photo): int
return (int) $photo['fileid'];
}

/**
* Get the cover object ID for a photo object.
*/
public function getCoverObjId(array $photo): int
{
return $this->getFileId($photo);
}

/**
* Get the cluster ID for a photo object.
*/
public function getClusterIdFrom(array $photo): int
{
throw new \Exception('getClusterIdFrom not implemented by '.$this::class);
}

/**
* Calls the getClusters implementation and appends the
* result with the cluster_id and cluster_type values.
Expand All @@ -152,4 +177,153 @@ final public static function register(): void
{
Manager::register(static::clusterType(), static::class);
}

/**
* Set the cover photo for the given cluster.
*
* @param array $photo Photo object
* @param bool $manual Whether this is a manual selection
*/
final public function setCover(array $photo, bool $manual = false): void
{
$connection = \OC::$server->get(\OCP\IDBConnection::class);

try {
// Replace the cover object in database
$connection->beginTransaction();

$query = $connection->getQueryBuilder();
$query->delete('memories_covers')
->where($query->expr()->eq('uid', $query->createNamedParameter(Util::getUser()->getUID())))
->andWhere($query->expr()->eq('clustertype', $query->createNamedParameter($this->clusterType())))
->andWhere($query->expr()->eq('clusterid', $query->createNamedParameter($this->getClusterIdFrom($photo))))
->executeStatement()
;

$query = $connection->getQueryBuilder();
$query->insert('memories_covers')
->values([
'uid' => $query->createNamedParameter(Util::getUser()->getUID()),
'clustertype' => $query->createNamedParameter($this->clusterType()),
'clusterid' => $query->createNamedParameter($this->getClusterIdFrom($photo)),
'objectid' => $query->createNamedParameter($this->getCoverObjId($photo)),
'fileid' => $query->createNamedParameter($this->getFileId($photo)),
'auto' => $query->createNamedParameter($manual ? 0 : 1, \PDO::PARAM_INT),
'timestamp' => $query->createNamedParameter(time(), \PDO::PARAM_INT),
])
->executeStatement()
;

$connection->commit();
} catch (\Exception $e) {
$connection->rollBack();

if ($manual) {
throw $e;
}

\OC::$server->get(\Psr\Log\LoggerInterface::class)
->error('Failed to set cover', ['app' => 'memories', 'exception' => $e->getMessage()])
;
}
}

/**
* Join the list query to get covers.
*
* @param IQueryBuilder $query Query builder
* @param string $clusterTable Alias name for the cluster list
* @param string $clusterTableId Column name for the cluster ID in clusterTable
* @param string $objectTable Table name for the object mapping
* @param string $objectTableObjectId Column name for the object ID in objectTable
* @param string $objectTableClusterId Column name for the cluster ID in objectTable
* @param bool $validateCluster Whether to validate the cluster
* @param bool $validateFilecache Whether to validate the filecache
* @param mixed $user Query expression for user ID to use for the covers
*/
final protected function joinCovers(
IQueryBuilder &$query,
string $clusterTable,
string $clusterTableId,
string $objectTable,
string $objectTableObjectId,
string $objectTableClusterId,
bool $validateCluster = true,
bool $validateFilecache = true,
string $field = 'cover',
mixed $user = null,
): void {
// Create aliases for the tables
$mcov = "m_cov_{$field}";
$mcov_f = "{$mcov}_f";

// Default to current user
$user = $user ?? $query->expr()->literal(Util::getUser()->getUID());

// Clauses for the JOIN
$joinClauses = [
$query->expr()->eq("{$mcov}.uid", $user),
$query->expr()->eq("{$mcov}.clustertype", $query->expr()->literal($this->clusterType())),
$query->expr()->eq("{$mcov}.clusterid", "{$clusterTable}.{$clusterTableId}"),
];

// Subquery if the preview is still valid for this cluster
if ($validateCluster) {
$validSq = $query->getConnection()->getQueryBuilder();
$validSq->select($validSq->expr()->literal(1))
->from($objectTable, 'cov_objs')
->where($validSq->expr()->eq($query->expr()->castColumn("cov_objs.{$objectTableObjectId}", IQueryBuilder::PARAM_INT), "{$mcov}.objectid"))
->andWhere($validSq->expr()->eq("cov_objs.{$objectTableClusterId}", "{$clusterTable}.{$clusterTableId}"))
;

$joinClauses[] = $query->createFunction("EXISTS ({$validSq->getSQL()})");
}

// Subquery if the file is still in the user's timeline tree
if ($validateFilecache) {
$treeSq = $query->getConnection()->getQueryBuilder();
$treeSq->select($treeSq->expr()->literal(1))
->from('filecache', 'cov_f')
->innerJoin('cov_f', 'cte_folders', 'cov_cte_f', $treeSq->expr()->andX(
$treeSq->expr()->eq('cov_cte_f.fileid', 'cov_f.parent'),
$treeSq->expr()->eq('cov_cte_f.hidden', $treeSq->expr()->literal(0, \PDO::PARAM_INT)),
))
->where($treeSq->expr()->eq('cov_f.fileid', "{$mcov}.fileid"))
;

$joinClauses[] = $query->createFunction("EXISTS ({$treeSq->getSQL()})");
}

// LEFT JOIN to get all the covers that we can
$query->leftJoin($clusterTable, 'memories_covers', $mcov, $query->expr()->andX(...$joinClauses));

// JOIN with filecache to get the etag
$query->leftJoin($mcov, 'filecache', $mcov_f, $query->expr()->eq("{$mcov_f}.fileid", "{$mcov}.fileid"));

// SELECT the cover
$query->selectAlias($query->createFunction("MAX({$mcov}.objectid)"), $field);
$query->selectAlias($query->createFunction("MAX({$mcov_f}.etag)"), "{$field}_etag");
}

/**
* Filter the photos query to get only the cover for this user.
*
* @param IQueryBuilder $query Query builder
* @param string $objectTable Table name for the object mapping
* @param string $objectTableObjectId Column name for the object ID in objectTable
* @param string $objectTableClusterId Column name for the cluster ID in objectTable
*/
final protected function filterCover(
IQueryBuilder &$query,
string $objectTable,
string $objectTableObjectId,
string $objectTableClusterId,
): void {
$query->innerJoin($objectTable, 'memories_covers', 'm_cov', $query->expr()->andX(
$query->expr()->eq('m_cov.uid', $query->expr()->literal(Util::getUser()->getUID())),
$query->expr()->eq('m_cov.clustertype', $query->expr()->literal($this->clusterType())),
$query->expr()->eq('m_cov.clusterid', "{$objectTable}.{$objectTableClusterId}"),
$query->expr()->eq('m_cov.objectid', $query->expr()->castColumn("{$objectTable}.{$objectTableObjectId}", IQueryBuilder::PARAM_INT)),
));
}
}
Loading

0 comments on commit 36725de

Please sign in to comment.