Implement archive (close #38)

pull/62/head
Varun Patil 2022-09-25 16:02:26 -07:00
parent eb60b9fb91
commit dc2615e107
9 changed files with 258 additions and 5 deletions

View File

@ -13,12 +13,14 @@ return [
],
['name' => 'page#favorites', 'url' => '/favorites', 'verb' => 'GET'],
['name' => 'page#videos', 'url' => '/videos', 'verb' => 'GET'],
['name' => 'page#archive', 'url' => '/archive', 'verb' => 'GET'],
// API
['name' => 'api#days', 'url' => '/api/days', 'verb' => 'GET'],
['name' => 'api#day', 'url' => '/api/days/{id}', 'verb' => 'GET'],
['name' => 'api#imageInfo', 'url' => '/api/info/{id}', 'verb' => 'GET'],
['name' => 'api#imageEdit', 'url' => '/api/edit/{id}', 'verb' => 'PATCH'],
['name' => 'api#archive', 'url' => '/api/archive/{id}', 'verb' => 'PATCH'],
// Config API
['name' => 'api#setUserConfig', 'url' => '/api/config/{key}', 'verb' => 'PUT'],

View File

@ -85,7 +85,7 @@ class ApiController extends Controller {
}
/** Preload a few "day" at the start of "days" response */
private function preloadDays(array &$days, Folder &$folder, bool $recursive) {
private function preloadDays(array &$days, Folder &$folder, bool $recursive, bool $archive) {
$uid = $this->userSession->getUser()->getUID();
$transforms = $this->getTransformations();
$preloaded = 0;
@ -95,6 +95,7 @@ class ApiController extends Controller {
$uid,
$day["dayid"],
$recursive,
$archive,
$transforms,
);
$day["count"] = count($day["detail"]); // make sure count is accurate
@ -145,6 +146,7 @@ class ApiController extends Controller {
// Get the folder to show
$folder = $this->getRequestFolder();
$recursive = is_null($this->request->getParam('folder'));
$archive = !is_null($this->request->getParam('archive'));
if (is_null($folder)) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
@ -154,11 +156,12 @@ class ApiController extends Controller {
$folder,
$uid,
$recursive,
$archive,
$this->getTransformations(),
);
// Preload some day responses
$this->preloadDays($list, $folder, $recursive);
$this->preloadDays($list, $folder, $recursive, $archive);
// Add subfolder info if querying non-recursively
if (!$recursive) {
@ -183,6 +186,7 @@ class ApiController extends Controller {
// Get the folder to show
$folder = $this->getRequestFolder();
$recursive = is_null($this->request->getParam('folder'));
$archive = !is_null($this->request->getParam('archive'));
if (is_null($folder)) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
@ -193,6 +197,7 @@ class ApiController extends Controller {
$uid,
intval($id),
$recursive,
$archive,
$this->getTransformations(),
);
return new JSONResponse($list, Http::STATUS_OK);
@ -312,6 +317,114 @@ class ApiController extends Controller {
return $this->imageInfo($id);
}
/**
* @NoAdminRequired
*
* Move one file to the archive folder
* @param string fileid
*/
public function archive(string $id): JSONResponse {
$user = $this->userSession->getUser();
if (is_null($user)) {
return new JSONResponse(["message" => "Not logged in"], Http::STATUS_PRECONDITION_FAILED);
}
$uid = $user->getUID();
$userFolder = $this->rootFolder->getUserFolder($uid);
// Check for permissions and get numeric Id
$file = $userFolder->getById(intval($id));
if (count($file) === 0) {
return new JSONResponse(["message" => "No such file"], Http::STATUS_NOT_FOUND);
}
$file = $file[0];
// Check if user has permissions
if (!$file->isUpdateable()) {
return new JSONResponse(["message" => "Cannot update this file"], Http::STATUS_FORBIDDEN);
}
// Create archive folder in the root of the user's configured timeline
$timelinePath = Exif::removeExtraSlash(Exif::getPhotosPath($this->config, $uid));
$timelineFolder = $userFolder->get($timelinePath);
if (is_null($timelineFolder) || !$timelineFolder instanceof Folder) {
return new JSONResponse(["message" => "Cannot get timeline"], Http::STATUS_INTERNAL_SERVER_ERROR);
}
if (!$timelineFolder->isCreatable()) {
return new JSONResponse(["message" => "Cannot create archive folder"], Http::STATUS_FORBIDDEN);
}
// Get path of current file relative to the timeline folder
// remove timelineFolder path from start of file path
$timelinePath = $timelineFolder->getPath(); // no trailing slash
if (substr($file->getPath(), 0, strlen($timelinePath)) !== $timelinePath) {
return new JSONResponse(["message" => "Files outside timeline cannot be archived"], Http::STATUS_INTERNAL_SERVER_ERROR);
}
$relativePath = substr($file->getPath(), strlen($timelinePath)); // has a leading slash
// Final path of the file including the file name
$destinationPath = '';
// Check if we want to archive or unarchive
$body = $this->request->getParams();
$unarchive = isset($body['archive']) && $body['archive'] === false;
// Get if the file is already in the archive (relativePath starts with archive)
$archiveFolderWithLeadingSlash = '/' . \OCA\Memories\Util::$ARCHIVE_FOLDER;
if (substr($relativePath, 0, strlen($archiveFolderWithLeadingSlash)) === $archiveFolderWithLeadingSlash) {
// file already in archive, remove it instead
$destinationPath = substr($relativePath, strlen($archiveFolderWithLeadingSlash));
if (!$unarchive) {
return new JSONResponse(["message" => "File already archived"], Http::STATUS_BAD_REQUEST);
}
} else {
// file not in archive, put it in there
$destinationPath = Exif::removeExtraSlash(\OCA\Memories\Util::$ARCHIVE_FOLDER . $relativePath);
if ($unarchive) {
return new JSONResponse(["message" => "File not archived"], Http::STATUS_BAD_REQUEST);
}
}
// Remove the filename
$destinationFolders = explode('/', $destinationPath);
array_pop($destinationFolders);
// Create folder tree
$folder = $timelineFolder;
foreach ($destinationFolders as $folderName) {
if ($folderName === '') {
continue;
}
try {
$existingFolder = $folder->get($folderName . '/');
if (!$existingFolder instanceof Folder) {
throw new \OCP\Files\NotFoundException('Not a folder');
}
$folder = $existingFolder;
} catch (\OCP\Files\NotFoundException $e) {
try {
$folder = $folder->newFolder($folderName);
} catch (\OCP\Files\NotPermittedException $e) {
return new JSONResponse(["message" => "Failed to create folder"], Http::STATUS_FORBIDDEN);
}
}
}
// Move file to archive folder
try {
$file->move($folder->getPath() . '/' . $file->getName());
} catch (\OCP\Files\NotPermittedException $e) {
return new JSONResponse(["message" => "Failed to move file"], Http::STATUS_FORBIDDEN);
} catch (\OCP\Files\NotFoundException $e) {
return new JSONResponse(["message" => "File not found"], Http::STATUS_INTERNAL_SERVER_ERROR);
} catch (\OCP\Files\InvalidPathException $e) {
return new JSONResponse(["message" => "Invalid path"], Http::STATUS_INTERNAL_SERVER_ERROR);
} catch (\OCP\Lock\LockedException $e) {
return new JSONResponse(["message" => "File is locked"], Http::STATUS_INTERNAL_SERVER_ERROR);
}
return new JSONResponse([], Http::STATUS_OK);
}
/**
* @NoAdminRequired
*

View File

@ -88,4 +88,12 @@ class PageController extends Controller {
public function videos() {
return $this->main();
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*/
public function archive() {
return $this->main();
}
}

View File

@ -46,7 +46,12 @@ trait TimelineQueryDays {
}
/** Get the query for oc_filecache join */
private function getFilecacheJoinQuery(IQueryBuilder &$query, Folder &$folder, bool $recursive) {
private function getFilecacheJoinQuery(
IQueryBuilder &$query,
Folder &$folder,
bool $recursive,
bool $archive
) {
// Subquery to get storage and path
$subQuery = $query->getConnection()->getQueryBuilder();
$cursor = $subQuery->select('path', 'storage')->from('filecache')->where(
@ -67,6 +72,19 @@ trait TimelineQueryDays {
}
$likePath = $likePath . '%';
$pathQuery = $query->expr()->like('f.path', $query->createNamedParameter($likePath));
// Exclude/show archive folder
$archiveLikePath = $finfo["path"] . '/' . \OCA\Memories\Util::$ARCHIVE_FOLDER . '/%';
if (!$archive) {
// Exclude archive folder
$pathQuery = $query->expr()->andX(
$pathQuery,
$query->expr()->notLike('f.path', $query->createNamedParameter($archiveLikePath))
);
} else {
// Show only archive folder
$pathQuery = $query->expr()->like('f.path', $query->createNamedParameter($archiveLikePath));
}
} else {
// If getting non-recursively folder only check for parent
$pathQuery = $query->expr()->eq('f.parent', $query->createNamedParameter($folder->getId(), IQueryBuilder::PARAM_INT));
@ -87,6 +105,7 @@ trait TimelineQueryDays {
Folder &$folder,
string $uid,
bool $recursive,
bool $archive,
array $queryTransforms = []
): array {
$query = $this->connection->getQueryBuilder();
@ -95,7 +114,7 @@ trait TimelineQueryDays {
$count = $query->func()->count($query->createFunction('DISTINCT m.fileid'), 'count');
$query->select('m.dayid', $count)
->from('memories', 'm')
->innerJoin('m', 'filecache', 'f', $this->getFilecacheJoinQuery($query, $folder, $recursive));
->innerJoin('m', 'filecache', 'f', $this->getFilecacheJoinQuery($query, $folder, $recursive, $archive));
// Group and sort by dayid
$query->groupBy('m.dayid')
@ -121,6 +140,7 @@ trait TimelineQueryDays {
string $uid,
int $dayid,
bool $recursive,
bool $archive,
array $queryTransforms = []
): array {
$query = $this->connection->getQueryBuilder();
@ -133,7 +153,7 @@ trait TimelineQueryDays {
// when using DISTINCT on selected fields
$query->select($fileid, 'f.etag', 'm.isvideo', 'vco.categoryid', 'm.datetaken')
->from('memories', 'm')
->innerJoin('m', 'filecache', 'f', $this->getFilecacheJoinQuery($query, $folder, $recursive))
->innerJoin('m', 'filecache', 'f', $this->getFilecacheJoinQuery($query, $folder, $recursive, $archive))
->andWhere($query->expr()->eq('m.dayid', $query->createNamedParameter($dayid, IQueryBuilder::PARAM_INT)));
// Add favorite field

View File

@ -10,6 +10,8 @@ class Util {
public static $TAG_DAYID_START = -(1 << 30); // the world surely didn't exist
public static $TAG_DAYID_FOLDERS = -(1 << 30) + 1;
public static $ARCHIVE_FOLDER = '.archive';
/**
* Get the path to the user's configured photos directory.
* @param IConfig $config

View File

@ -19,6 +19,10 @@
:title="t('memories', 'Videos')">
<Video slot="icon" :size="20" />
</NcAppNavigationItem>
<NcAppNavigationItem :to="{name: 'archive'}"
:title="t('memories', 'Archive')">
<ArchiveIcon slot="icon" :size="20" />
</NcAppNavigationItem>
</template>
<template #footer>
<NcAppNavigationSettings :title="t('memories', 'Settings')">
@ -64,6 +68,7 @@ import ImageMultiple from 'vue-material-design-icons/ImageMultiple.vue'
import FolderIcon from 'vue-material-design-icons/Folder.vue'
import Star from 'vue-material-design-icons/Star.vue'
import Video from 'vue-material-design-icons/Video.vue'
import ArchiveIcon from 'vue-material-design-icons/PackageDown.vue';
@Component({
components: {
@ -80,6 +85,7 @@ import Video from 'vue-material-design-icons/Video.vue'
FolderIcon,
Star,
Video,
ArchiveIcon,
},
})
export default class App extends Mixins(GlobalMixin) {

View File

@ -111,6 +111,25 @@
{{ t('memories', 'Favorite') }}
<template #icon> <Star :size="20" /> </template>
</NcActionButton>
<template>
<NcActionButton
v-if="!routeIsArchive()"
:aria-label="t('memories', 'Archive')"
@click="archiveSelection" close-after-click>
{{ t('memories', 'Archive') }}
<template #icon> <ArchiveIcon :size="20" /> </template>
</NcActionButton>
<NcActionButton
v-else
:aria-label="t('memories', 'Unarchive')"
@click="archiveSelection" close-after-click>
{{ t('memories', 'Unarchive') }}
<template #icon> <UnarchiveIcon :size="20" /> </template>
</NcActionButton>
</template>
<NcActionButton
:aria-label="t('memories', 'Edit Date/Time')"
@click="editDateSelection" close-after-click>
@ -147,6 +166,8 @@ import Delete from 'vue-material-design-icons/Delete.vue';
import Close from 'vue-material-design-icons/Close.vue';
import CheckCircle from 'vue-material-design-icons/CheckCircle.vue';
import EditIcon from 'vue-material-design-icons/ClockEdit.vue';
import ArchiveIcon from 'vue-material-design-icons/PackageDown.vue';
import UnarchiveIcon from 'vue-material-design-icons/PackageUp.vue';
const SCROLL_LOAD_DELAY = 100; // Delay in loading data when scrolling
const MAX_PHOTO_WIDTH = 175; // Max width of a photo
@ -177,6 +198,8 @@ for (const [key, value] of Object.entries(API_ROUTES)) {
Close,
CheckCircle,
EditIcon,
ArchiveIcon,
UnarchiveIcon,
}
})
export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
@ -468,6 +491,11 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
query.set('folder', this.$route.params.path || '/');
}
// Archive
if (this.routeIsArchive()) {
query.set('archive', '1');
}
// Create query string and append to URL
const queryStr = query.toString();
if (queryStr) {
@ -476,6 +504,11 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
return url;
}
/** Is archive route */
routeIsArchive() {
return this.$route.name === 'archive';
}
/** Get name of header */
getHeadName(head: IHeadRow) {
// Check cache
@ -1090,6 +1123,29 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
(<any>this.$refs.editDate).open(Array.from(this.selection.values()));
}
/**
* Archive the currently selected photos
*/
async archiveSelection() {
if (this.selection.size >= 100) {
if (!confirm(this.t("memories", "You are about to touch a large number of files. Are you sure?"))) {
return;
}
}
try {
this.loading++;
for await (const delIds of dav.archiveFilesByIds(Array.from(this.selection.keys()), !this.routeIsArchive())) {
const delPhotos = delIds.map(id => this.selection.get(id));
await this.deleteFromViewWithAnimation(delPhotos);
}
} catch (error) {
console.error(error);
} finally {
this.loading--;
}
}
/**
* Delete elements from main view with some animation
* This function looks horribly slow, probably isn't that bad

View File

@ -81,5 +81,14 @@
rootTitle: t('memories', 'Videos'),
}),
},
{
path: '/archive',
component: Timeline,
name: 'archive',
props: route => ({
rootTitle: t('memories', 'Archive'),
}),
},
],
})

View File

@ -217,6 +217,43 @@ export async function* deleteFilesByIds(fileIds: number[]) {
yield* runInParallel(calls, 10);
}
/**
* Archive or unarchive a single file
*
* @param fileid File id
* @param archive Archive or unarchive
*/
export async function archiveFile(fileid: number, archive: boolean) {
return await axios.patch(generateUrl('/apps/memories/api/archive/{fileid}', { fileid }), { archive });
}
/**
* Archive all files in a given list of Ids
*
* @param fileIds list of file ids
* @param archive Archive or unarchive
* @returns list of file ids that were deleted
*/
export async function* archiveFilesByIds(fileIds: number[], archive: boolean) {
if (fileIds.length === 0) {
return;
}
// Archive each file
const calls = fileIds.map((id) => async () => {
try {
await archiveFile(id, archive);
return id as number;
} catch (error) {
console.error('Failed to archive', id, error);
showError(t('memories', 'Failed to archive some files.'));
return 0;
}
});
yield* runInParallel(calls, 10);
}
/**
* Download a file