Implement archive (close #38)
parent
eb60b9fb91
commit
dc2615e107
|
@ -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'],
|
||||
|
|
|
@ -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
|
||||
*
|
||||
|
|
|
@ -88,4 +88,12 @@ class PageController extends Controller {
|
|||
public function videos() {
|
||||
return $this->main();
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
* @NoCSRFRequired
|
||||
*/
|
||||
public function archive() {
|
||||
return $this->main();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -81,5 +81,14 @@
|
|||
rootTitle: t('memories', 'Videos'),
|
||||
}),
|
||||
},
|
||||
|
||||
{
|
||||
path: '/archive',
|
||||
component: Timeline,
|
||||
name: 'archive',
|
||||
props: route => ({
|
||||
rootTitle: t('memories', 'Archive'),
|
||||
}),
|
||||
},
|
||||
],
|
||||
})
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue