diff --git a/appinfo/routes.php b/appinfo/routes.php index a3b607f6e..dd94961ca 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -22,8 +22,35 @@ */ return [ - 'routes' => [ - ['name' => 'page#index', 'url' => '/', 'verb' => 'GET'], - ['name' => 'page#index', 'url' => '/{path}', 'verb' => 'GET', 'postfix' => 'folder', 'requirements' => ['path' => '.+']], - ] + 'routes' => [ + ['name' => 'page#index', 'url' => '/', 'verb' => 'GET'], + ['name' => 'page#index', 'url' => '/albums', 'verb' => 'GET', 'postfix' => 'albums'], + ['name' => 'page#index', 'url' => '/favorites', 'verb' => 'GET', 'postfix' => 'favorites'], + ['name' => 'page#index', 'url' => '/shared', 'verb' => 'GET', 'postfix' => 'shared'], + ['name' => 'page#index', 'url' => '/tags', 'verb' => 'GET', 'postfix' => 'tags'], + + // apis + [ + 'name' => 'albums#myAlbums', + 'url' => '/api/v1/albums/{path}', + 'verb' => 'GET', + 'requirements' => [ + 'path' => '.*', + ], + 'defaults' => [ + 'path' => '', + ], + ], + [ + 'name' => 'albums#sharedAlbums', + 'url' => '/api/v1/shared/{path}', + 'verb' => 'GET', + 'requirements' => [ + 'path' => '.*', + ], + 'defaults' => [ + 'path' => '', + ], + ], + ] ]; diff --git a/lib/Controller/AlbumsController.php b/lib/Controller/AlbumsController.php new file mode 100644 index 000000000..9b412ee81 --- /dev/null +++ b/lib/Controller/AlbumsController.php @@ -0,0 +1,161 @@ + + * + * @author Roeland Jago Douma + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Photos\Controller; + +use OCA\Files_Sharing\SharedStorage; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\Files\File; +use OCP\Files\Folder; +use OCP\Files\IRootFolder; +use OCP\FIles\Node; +use OCP\Files\NotFoundException; +use OCP\IRequest; + +class AlbumsController extends Controller { + + /** @var string */ + private $userId; + /** @var IRootFolder */ + private $rootFolder; + + public function __construct($appName, IRequest $request, string $userId, IRootFolder $rootFolder) { + parent::__construct($appName, $request); + $this->userId = $userId; + $this->rootFolder = $rootFolder; + } + + /** + * @NoAdminRequired + */ + public function myAlbums(string $path = ''): JSONResponse { + return $this->generate($path, false); + } + + /** + * @NoAdminRequired + */ + public function sharedAlbums(string $path = ''): JSONResponse { + return $this->generate($path, true); + } + + private function generate(string $path, bool $shared): JSONResponse { + $userFolder = $this->rootFolder->getUserFolder($this->userId); + + $folder = $userFolder; + if ($path !== '') { + try { + $folder = $userFolder->get($path); + } catch (NotFoundException $e) { + return new JSONResponse([], Http::STATUS_NOT_FOUND); + } + } + + $data = $this->scanCurrentFolder($folder, $shared); + $result = $this->formatData($data); + + return new JSONResponse($result, Http::STATUS_OK); + } + + private function formatData(iterable $nodes): array { + $userFolder = $this->rootFolder->getUserFolder($this->userId); + + $result = []; + /** @var Node $node */ + foreach ($nodes as $node) { + // properly format full path and make sure + // we're relative to the user home folder + $isRoot = $node === $userFolder; + $path = $userFolder->getRelativePath($node->getPath()); + + $result[] = [ + 'basename' => $isRoot ? '' : $node->getName(), + 'etag' => $node->getEtag(), + 'fileid' => $node->getId(), + 'filename' => $path, + 'etag' => $node->getEtag(), + 'lastmod' => $node->getMTime(), + 'mime' => $node->getMimetype(), + 'size' => $node->getSize(), + 'type' => $node->getType() + ]; + } + + return $result; + } + + private function scanCurrentFolder(Folder $folder, bool $shared): iterable { + $nodes = $folder->getDirectoryListing(); + + // add current folder to iterable set + yield $folder; + + foreach ($nodes as $node) { + if ($node instanceof Folder) { + yield from $this->scanFolder($node, 0, $shared); + } elseif ($node instanceof File) { + if ($this->validFile($node, $shared)) { + yield $node; + } + } + } + } + + private function validFile(File $file, bool $shared): bool { + if ($file->getMimePart() === 'image' && $this->isShared($file) === $shared) { + return true; + } + + return false; + } + + private function isShared(Node $node): bool { + return $node->getStorage()->instanceOfStorage(SharedStorage::class); + } + + private function scanFolder(Folder $folder, int $depth, bool $shared): iterable { + if ($depth > 4) { + return []; + } + + $nodes = $folder->getDirectoryListing(); + + foreach ($nodes as $node) { + if ($node instanceof File) { + if ($this->validFile($node, $shared)) { + yield $folder; + return []; + } + } + } + + foreach ($nodes as $node) { + if ($node instanceof Folder && $this->isShared($node) === $shared) { + yield from $this->scanFolder($node, $depth + 1, $shared); + } + } + } +} diff --git a/src/components/File.vue b/src/components/File.vue index b8d951776..c57261840 100644 --- a/src/components/File.vue +++ b/src/components/File.vue @@ -68,7 +68,7 @@ export default { type: String, required: true, }, - id: { + fileid: { type: Number, required: true, }, @@ -87,7 +87,7 @@ export default { return generateRemoteUrl(`dav/files/${getCurrentUser().uid}`) + this.filename }, ariaUuid() { - return `image-${this.id}` + return `image-${this.fileid}` }, ariaLabel() { return t('photos', 'Open the full size "{name}" image', { name: this.basename }) @@ -97,7 +97,7 @@ export default { created() { // Allow us to cancel the img loading on destroy // use etag to force cache reload if file changed - this.img.src = generateUrl(`/core/preview?fileId=${this.id}&x=${1024}&y=${1024}&a=true&v=${this.etag}`) + this.img.src = generateUrl(`/core/preview?fileId=${this.fileid}&x=${1024}&y=${1024}&a=true&v=${this.etag}`) this.img.addEventListener('load', () => { this.src = this.img.src }) diff --git a/src/components/Folder.vue b/src/components/Folder.vue index c04e17a98..7a283acd8 100644 --- a/src/components/Folder.vue +++ b/src/components/Folder.vue @@ -31,7 +31,7 @@ class="folder-content" role="none"> @@ -54,7 +54,7 @@ import { generateUrl } from '@nextcloud/router' import { mapGetters } from 'vuex' -import getPictures from '../services/FileList' +import getAlbumContent from '../services/AlbumContent' import cancelableRequest from '../utils/CancelableRequest' export default { @@ -70,7 +70,7 @@ export default { type: String, required: true, }, - id: { + fileid: { type: Number, required: true, }, @@ -78,6 +78,10 @@ export default { type: String, default: 'icon-folder', }, + showShared: { + type: Boolean, + default: false, + }, }, data() { @@ -96,7 +100,7 @@ export default { // files list of the current folder folderContent() { - return this.folders[this.id] + return this.folders[this.fileid] }, fileList() { return this.folderContent @@ -113,7 +117,7 @@ export default { }, ariaUuid() { - return `folder-${this.id}` + return `folder-${this.fileid}` }, ariaLabel() { return t('photos', 'Open the "{name}" sub-directory', { name: this.basename }) @@ -137,15 +141,14 @@ export default { async created() { // init cancellable request - const { request, cancel } = cancelableRequest(getPictures) + const { request, cancel } = cancelableRequest(getAlbumContent) this.cancelRequest = cancel try { // get data - const { files, folders } = await request(this.filename) - // this.cancelRequest('Stop!') - this.$store.dispatch('updateFolders', { id: this.id, files, folders }) - this.$store.dispatch('updateFiles', { folder: this.folder, files, folders }) + const { folder, folders, files } = await request(this.filename, {shared: this.showShared}) + this.$store.dispatch('updateFolders', { fileid: folder.fileid, files, folders }) + this.$store.dispatch('updateFiles', { folder, files, folders }) } catch (error) { if (error.response && error.response.status) { console.error('Failed to get folder content', this.folder, error.response) @@ -155,13 +158,13 @@ export default { }, beforeDestroy() { - this.cancelRequest() + this.cancelRequest('Navigated away') }, methods: { - generateImgSrc({ id, etag }) { + generateImgSrc({ fileid, etag }) { // use etag to force cache reload if file changed - return generateUrl(`/core/preview?fileId=${id}&x=${256}&y=${256}&a=true&v=${etag}`) + return generateUrl(`/core/preview?fileId=${fileid}&x=${256}&y=${256}&a=true&v=${etag}`) }, fetch() { diff --git a/src/components/Navigation.vue b/src/components/Navigation.vue index a0d4c92c4..977ef61c0 100644 --- a/src/components/Navigation.vue +++ b/src/components/Navigation.vue @@ -60,10 +60,6 @@ export default { type: String, default: t('photos', 'Photos'), }, - id: { - type: Number, - required: true, - }, }, computed: { @@ -99,9 +95,15 @@ export default { * so we generate a new valid route object, get the final url back * decode it and use it as a direct string, which vue-router * does not encode afterwards - * @returns {string} + * @returns {string|object} */ to() { + if (this.parentPath === '/') { + return { name: this.$route.name } + } + + // else let's build the path and make sure it's + // not url encoded (more importantly if filename have slashes) const route = Object.assign({}, this.$route, { // always remove first slash params: { path: this.parentPath.substr(1) }, diff --git a/src/router/index.js b/src/router/index.js index d4d9caa65..6cc950846 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -29,12 +29,6 @@ import Tags from '../views/Tags' Vue.use(Router) -// shortcut to properly format the path prop -const props = route => ({ - // always lead current path with a slash - path: `/${route.params.path ? route.params.path : ''}`, -}) - export default new Router({ mode: 'history', // if index.php is in the url AND we got this far, then it's working: @@ -51,11 +45,14 @@ export default new Router({ path: '/albums', component: Albums, name: 'albums', - props, + props: route => ({ + // always lead current path with a slash + path: `/${route.params.path ? route.params.path : ''}`, + }), children: [ { path: ':path*', - name: 'albumspath', + name: 'albums', component: Albums, }, ], @@ -64,11 +61,15 @@ export default new Router({ path: '/shared', component: Albums, name: 'shared', - props, + props: route => ({ + // always lead current path with a slash + path: `/${route.params.path ? route.params.path : ''}`, + showShared: true, + }), children: [ { path: ':path*', - name: 'sharedpath', + name: 'shared', component: Albums, }, ], diff --git a/src/services/AlbumContent.js b/src/services/AlbumContent.js new file mode 100644 index 000000000..f753163fc --- /dev/null +++ b/src/services/AlbumContent.js @@ -0,0 +1,60 @@ +/** + * @copyright Copyright (c) 2019 John Molakvoæ + * + * @author John Molakvoæ + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +import axios from '@nextcloud/axios' +import { generateUrl } from '@nextcloud/router' +import { genFileInfo } from '../utils/fileUtils' + +/** + * List files from a folder and filter out unwanted mimes + * + * @param {String} path the path relative to the user root + * @param {Object} [options] optional options for axios + * @param {boolean} [shared] fetch shared albums ? + * @returns {Array} the file list + */ +export default async function(path = '/', options = {}) { + const prefixPath = generateUrl(`/apps/photos/api/v1/${options.shared ? 'shared' : 'albums'}`) + + // fetch listing + const response = await axios.get(prefixPath + path, options) + + const list = response.data.map(data => genFileInfo(data, prefixPath)) + + // filter all the files and folders + let folder = {} + const folders = [] + const files = [] + for (const entry of list) { + // is this the current provided path ? + if (entry.filename === path) { + folder = entry + } else if (entry.type !== 'file') { + folders.push(entry) + } else if (entry.mime === 'image/jpeg') { + files.push(entry) + } + } + + // return current folder, subfolders and files + return { folder, folders, files } +} diff --git a/src/services/DavClient.js b/src/services/DavClient.js index c91b57e87..ff37eec4c 100644 --- a/src/services/DavClient.js +++ b/src/services/DavClient.js @@ -29,7 +29,7 @@ import { generateRemoteUrl } from '@nextcloud/router' const patcher = webdav.getPatcher() patcher.patch('request', axios) -// init webdav client +// init webdav client on default dav endpoint const remote = generateRemoteUrl(`dav`) const client = webdav.createClient(remote) diff --git a/src/services/FileList.js b/src/services/FileList.js index c473aad8e..bfe579dbe 100644 --- a/src/services/FileList.js +++ b/src/services/FileList.js @@ -25,6 +25,7 @@ import { getSingleValue, getValueForKey, parseXML, propsToStat } from 'webdav/di import { handleResponseCode, processResponsePayload } from 'webdav/dist/response' import { normaliseHREF, normalisePath } from 'webdav/dist/url' import client, { remotePath } from './DavClient' +import request from './DavRequest' import pathPosix from 'path-posix' import { genFileInfo } from '../utils/fileUtils' @@ -37,17 +38,18 @@ import { genFileInfo } from '../utils/fileUtils' */ export default async function(path, options) { - console.trace(); options = Object.assign({ method: 'PROPFIND', headers: { Accept: 'text/plain', Depth: options.deep ? 'infinity' : 1, }, + data: request, responseType: 'text', details: true, }, options) + // we also use the davclient for other endpoints than /files (like tags) const prefixPath = `/files/${getCurrentUser().uid}` /** @@ -65,7 +67,7 @@ export default async function(path, options) { return res.data }) .then(parseXML) - .then(result => getDirectoryFiles(result, remotePath, options.details)) + .then(result => getDirectoryFiles(result, remotePath + prefixPath, options.details)) .then(files => processResponsePayload(response, files, options.details)) const list = data.map(data => genFileInfo(data, prefixPath)) @@ -75,9 +77,10 @@ export default async function(path, options) { const folders = [] const files = [] for (const entry of list) { + // is this the current provided path ? if (entry.filename === path) { folder = entry - } else if (entry.type === 'directory') { + } else if (entry.type !== 'file') { folders.push(entry) } else if (entry.mime === 'image/jpeg') { files.push(entry) @@ -89,7 +92,8 @@ export default async function(path, options) { } /** - * Modified function to include the root requested folder + * ! Modified function to include the root requested folder + * ! See webdav library * Into the returned data * * @param {Object} result the request result @@ -104,7 +108,7 @@ function getDirectoryFiles(result, serverBasePath, isDetailed = false) { const responseItems = getValueForKey('response', multiStatus) return ( responseItems - // Map all items to a consistent output structure (results) + // Map all items to a consistent output structure (results) .map(item => { // HREF is the file path (in full) let href = getSingleValue(getValueForKey('href', item)) diff --git a/src/store/files.js b/src/store/files.js index 6c55309c9..a199b01f3 100644 --- a/src/store/files.js +++ b/src/store/files.js @@ -34,7 +34,9 @@ const mutations = { */ updateFiles(state, files) { files.forEach(file => { - Vue.set(state.files, file.id, file) + if (file.fileid >= 0) { + Vue.set(state.files, file.fileid, file) + } }) }, @@ -43,12 +45,15 @@ const mutations = { * * @param {Object} state the store mutations * @param {Object} data destructuring object - * @param {number} data.id current folder id + * @param {number} data.fileid current folder id * @param {Array} data.folders list of folders */ - setSubFolders(state, { id, folders }) { - if (state.files[id]) { - Vue.set(state.files[id], 'folders', [...folders.map(folder => folder.id)]) + setSubFolders(state, { fileid, folders }) { + if (state.files[fileid]) { + const subfolders = folders + .map(folder => folder.fileid) + .filter(id => id >= 0) + Vue.set(state.files[fileid], 'folders', subfolders) } }, } @@ -68,12 +73,9 @@ const actions = { * @param {Array} data.folders list of folders within current folder */ updateFiles(context, { folder, files, folders }) { - const t0 = performance.now() // we want all the FileInfo! Folders included! context.commit('updateFiles', [folder, ...files, ...folders]) - context.commit('setSubFolders', { id: folder.id, folders }) - const t1 = performance.now() - console.debug('perf: updateFiles', `${t1 - t0}ms`) + context.commit('setSubFolders', { fileid: folder.fileid, folders }) }, } diff --git a/src/store/folders.js b/src/store/folders.js index 1075900de..74a8192c2 100644 --- a/src/store/folders.js +++ b/src/store/folders.js @@ -33,19 +33,20 @@ const mutations = { * * @param {Object} state vuex state * @param {Object} data destructuring object - * @param {number} data.id current folder id + * @param {number} data.fileid current folder id * @param {Array} data.files list of files */ - updateFolders(state, { id, files }) { + updateFolders(state, { fileid, files }) { if (files.length > 0) { - const t0 = performance.now() // sort by last modified - const list = files.sort((a, b) => sortCompare(a, b, 'lastmod')) + const list = files + .sort((a, b) => sortCompare(a, b, 'lastmod')) + .filter(file => file.fileid >= 0) // Set folder list - Vue.set(state.folders, id, list.map(file => file.id)) - const t1 = performance.now() - console.debug('perf: updateFolders', `${t1 - t0}ms`) + Vue.set(state.folders, fileid, list.map(file => file.fileid)) + } else { + Vue.set(state.folders, fileid, []) } }, @@ -55,16 +56,18 @@ const mutations = { * @param {Object} state vuex state * @param {Object} data destructuring object * @param {string} data.path path of this folder - * @param {number} data.id id of this folder + * @param {number} data.fileid id of this folder */ - addPath(state, { path, id }) { - Vue.set(state.paths, path, id) + addPath(state, { path, fileid }) { + if (fileid >= 0) { + Vue.set(state.paths, path, fileid) + } }, } const getters = { folders: state => state.folders, - folder: state => id => state.folders[id], + folder: state => fileid => state.folders[fileid], folderId: state => path => state.paths[path], } @@ -74,15 +77,15 @@ const actions = { * * @param {Object} context vuex context * @param {Object} data destructuring object - * @param {number} data.id current folder id + * @param {number} data.fileid current folder id * @param {Array} data.files list of files * @param {Array} data.folders list of folders */ - updateFolders(context, { id, files, folders }) { - context.commit('updateFolders', { id, files }) + updateFolders(context, { fileid, files, folders }) { + context.commit('updateFolders', { fileid, files }) // then add each folders path indexes - folders.forEach(folder => context.commit('addPath', { path: folder.filename, id: folder.id })) + folders.forEach(folder => context.commit('addPath', { path: folder.filename, fileid: folder.fileid })) }, /** @@ -91,10 +94,10 @@ const actions = { * @param {Object} context vuex context * @param {Object} data destructuring object * @param {string} data.path path of this folder - * @param {number} data.id id of this folder + * @param {number} data.fileid id of this folder */ - addPath(context, { path, id }) { - context.commit('addPath', { path, id }) + addPath(context, { path, fileid }) { + context.commit('addPath', { path, fileid }) }, } diff --git a/src/utils/fileUtils.js b/src/utils/fileUtils.js index ce18c08fa..a04b99456 100644 --- a/src/utils/fileUtils.js +++ b/src/utils/fileUtils.js @@ -78,14 +78,14 @@ const sortCompare = function(fileInfo1, fileInfo2, key, asc = true) { } // else we sort by string, so let's sort directories first - if (fileInfo1.type === 'directory' && fileInfo2.type !== 'directory') { + if (fileInfo1.type !== 'file' && fileInfo2.type === 'file') { return asc ? -1 : 1 - } else if (fileInfo1.type !== 'directory' && fileInfo2.type === 'directory') { + } else if (fileInfo1.type === 'file' && fileInfo2.type !== 'file') { return asc ? 1 : -1 } // if this is a date, let's sort by date - if (isNumber(new Date(fileInfo1[key]).getTime()) && isNumber(new Date(fileInfo2[key])).getTime()) { + if (isNumber(new Date(fileInfo1[key]).getTime()) && isNumber(new Date(fileInfo2[key]).getTime())) { return asc ? new Date(fileInfo2[key]).getTime() - new Date(fileInfo1[key]).getTime() : new Date(fileInfo1[key]).getTime() - new Date(fileInfo2[key]).getTime() diff --git a/src/views/Albums.vue b/src/views/Albums.vue index 2338a3c7b..95f64bd34 100644 --- a/src/views/Albums.vue +++ b/src/views/Albums.vue @@ -35,17 +35,15 @@ - - + +