diff --git a/appinfo/routes.php b/appinfo/routes.php
index be5641a9..a78b0e13 100644
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -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'],
diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php
index 321e5edd..c260e88c 100644
--- a/lib/Controller/ApiController.php
+++ b/lib/Controller/ApiController.php
@@ -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
*
diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php
index d9ff8e11..5ade7ec2 100644
--- a/lib/Controller/PageController.php
+++ b/lib/Controller/PageController.php
@@ -88,4 +88,12 @@ class PageController extends Controller {
public function videos() {
return $this->main();
}
+
+ /**
+ * @NoAdminRequired
+ * @NoCSRFRequired
+ */
+ public function archive() {
+ return $this->main();
+ }
}
diff --git a/lib/Db/TimelineQueryDays.php b/lib/Db/TimelineQueryDays.php
index 01974df9..70a6f725 100644
--- a/lib/Db/TimelineQueryDays.php
+++ b/lib/Db/TimelineQueryDays.php
@@ -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
diff --git a/lib/Util.php b/lib/Util.php
index 35b9be51..414d40be 100644
--- a/lib/Util.php
+++ b/lib/Util.php
@@ -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
diff --git a/src/App.vue b/src/App.vue
index 4c44e891..15932ae2 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -19,6 +19,10 @@
:title="t('memories', 'Videos')">
+
+
+
@@ -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) {
diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue
index 413f2d59..88971d29 100644
--- a/src/components/Timeline.vue
+++ b/src/components/Timeline.vue
@@ -111,6 +111,25 @@
{{ t('memories', 'Favorite') }}
+
+
+
+ {{ t('memories', 'Archive') }}
+
+
+
+ {{ t('memories', 'Unarchive') }}
+
+
+
+
+
@@ -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) {
(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
diff --git a/src/router.ts b/src/router.ts
index e6cb2d01..ff0a63b3 100644
--- a/src/router.ts
+++ b/src/router.ts
@@ -81,5 +81,14 @@
rootTitle: t('memories', 'Videos'),
}),
},
+
+ {
+ path: '/archive',
+ component: Timeline,
+ name: 'archive',
+ props: route => ({
+ rootTitle: t('memories', 'Archive'),
+ }),
+ },
],
})
\ No newline at end of file
diff --git a/src/services/DavRequests.ts b/src/services/DavRequests.ts
index fb651f6c..017c3312 100644
--- a/src/services/DavRequests.ts
+++ b/src/services/DavRequests.ts
@@ -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