mirror of
https://github.com/nextcloud/server.git
synced 2026-06-05 23:06:48 -04:00
Merge pull request #46596 from nextcloud/feat/folder-tree
feat: Navigate via folder tree
This commit is contained in:
commit
ef7d83044a
40 changed files with 763 additions and 160 deletions
|
|
@ -60,6 +60,11 @@ $application->registerRoutes(
|
|||
'url' => '/api/v1/views/{view}/{key}',
|
||||
'verb' => 'PUT'
|
||||
],
|
||||
[
|
||||
'name' => 'Api#setViewConfig',
|
||||
'url' => '/api/v1/views',
|
||||
'verb' => 'PUT'
|
||||
],
|
||||
[
|
||||
'name' => 'Api#getViewConfigs',
|
||||
'url' => '/api/v1/views',
|
||||
|
|
|
|||
|
|
@ -41,7 +41,9 @@ use OCP\Files\Events\Node\BeforeNodeCopiedEvent;
|
|||
use OCP\Files\Events\Node\BeforeNodeDeletedEvent;
|
||||
use OCP\Files\Events\Node\BeforeNodeRenamedEvent;
|
||||
use OCP\Files\Events\Node\NodeCopiedEvent;
|
||||
use OCP\Files\IRootFolder;
|
||||
use OCP\IConfig;
|
||||
use OCP\IL10N;
|
||||
use OCP\IPreview;
|
||||
use OCP\IRequest;
|
||||
use OCP\IServerContainer;
|
||||
|
|
@ -53,6 +55,7 @@ use OCP\Settings\Events\DeclarativeSettingsSetValueEvent;
|
|||
use OCP\Share\IManager as IShareManager;
|
||||
use OCP\Util;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class Application extends App implements IBootstrap {
|
||||
public const APP_ID = 'files';
|
||||
|
|
@ -80,6 +83,9 @@ class Application extends App implements IBootstrap {
|
|||
$server->getUserFolder(),
|
||||
$c->get(UserConfig::class),
|
||||
$c->get(ViewConfig::class),
|
||||
$c->get(IL10N::class),
|
||||
$c->get(IRootFolder::class),
|
||||
$c->get(LoggerInterface::class),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -7,12 +7,17 @@
|
|||
*/
|
||||
namespace OCA\Files\Controller;
|
||||
|
||||
use OC\AppFramework\Middleware\Security\Exceptions\NotLoggedInException;
|
||||
use OC\Files\Node\Node;
|
||||
use OC\Files\Search\SearchComparison;
|
||||
use OC\Files\Search\SearchQuery;
|
||||
use OCA\Files\ResponseDefinitions;
|
||||
use OCA\Files\Service\TagService;
|
||||
use OCA\Files\Service\UserConfig;
|
||||
use OCA\Files\Service\ViewConfig;
|
||||
use OCP\AppFramework\Controller;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\ApiRoute;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||
use OCP\AppFramework\Http\Attribute\OpenAPI;
|
||||
|
|
@ -24,48 +29,44 @@ use OCP\AppFramework\Http\FileDisplayResponse;
|
|||
use OCP\AppFramework\Http\JSONResponse;
|
||||
use OCP\AppFramework\Http\Response;
|
||||
use OCP\AppFramework\Http\StreamResponse;
|
||||
use OCP\Files\Cache\ICacheEntry;
|
||||
use OCP\Files\File;
|
||||
use OCP\Files\Folder;
|
||||
use OCP\Files\IRootFolder;
|
||||
use OCP\Files\NotFoundException;
|
||||
use OCP\Files\Search\ISearchComparison;
|
||||
use OCP\IConfig;
|
||||
use OCP\IL10N;
|
||||
use OCP\IPreview;
|
||||
use OCP\IRequest;
|
||||
use OCP\IUser;
|
||||
use OCP\IUserSession;
|
||||
use OCP\Share\IManager;
|
||||
use OCP\Share\IShare;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* @psalm-import-type FilesFolderTree from ResponseDefinitions
|
||||
*
|
||||
* @package OCA\Files\Controller
|
||||
*/
|
||||
class ApiController extends Controller {
|
||||
private TagService $tagService;
|
||||
private IManager $shareManager;
|
||||
private IPreview $previewManager;
|
||||
private IUserSession $userSession;
|
||||
private IConfig $config;
|
||||
private ?Folder $userFolder;
|
||||
private UserConfig $userConfig;
|
||||
private ViewConfig $viewConfig;
|
||||
|
||||
public function __construct(string $appName,
|
||||
IRequest $request,
|
||||
IUserSession $userSession,
|
||||
TagService $tagService,
|
||||
IPreview $previewManager,
|
||||
IManager $shareManager,
|
||||
IConfig $config,
|
||||
?Folder $userFolder,
|
||||
UserConfig $userConfig,
|
||||
ViewConfig $viewConfig) {
|
||||
private IUserSession $userSession,
|
||||
private TagService $tagService,
|
||||
private IPreview $previewManager,
|
||||
private IManager $shareManager,
|
||||
private IConfig $config,
|
||||
private ?Folder $userFolder,
|
||||
private UserConfig $userConfig,
|
||||
private ViewConfig $viewConfig,
|
||||
private IL10N $l10n,
|
||||
private IRootFolder $rootFolder,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
$this->userSession = $userSession;
|
||||
$this->tagService = $tagService;
|
||||
$this->previewManager = $previewManager;
|
||||
$this->shareManager = $shareManager;
|
||||
$this->config = $config;
|
||||
$this->userFolder = $userFolder;
|
||||
$this->userConfig = $userConfig;
|
||||
$this->viewConfig = $viewConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -232,6 +233,77 @@ class ApiController extends Controller {
|
|||
return new DataResponse(['files' => $files]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Folder[] $folders
|
||||
*/
|
||||
private function getTree(array $folders): array {
|
||||
$user = $this->userSession->getUser();
|
||||
if (!($user instanceof IUser)) {
|
||||
throw new NotLoggedInException();
|
||||
}
|
||||
|
||||
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
|
||||
$tree = [];
|
||||
foreach ($folders as $folder) {
|
||||
$path = $userFolder->getRelativePath($folder->getPath());
|
||||
if ($path === null) {
|
||||
continue;
|
||||
}
|
||||
$pathBasenames = explode('/', trim($path, '/'));
|
||||
$current = &$tree;
|
||||
foreach ($pathBasenames as $basename) {
|
||||
if (!isset($current['children'][$basename])) {
|
||||
$current['children'][$basename] = [
|
||||
'id' => $folder->getId(),
|
||||
];
|
||||
$displayName = $folder->getName();
|
||||
if ($displayName !== $basename) {
|
||||
$current['children'][$basename]['displayName'] = $displayName;
|
||||
}
|
||||
}
|
||||
$current = &$current['children'][$basename];
|
||||
}
|
||||
}
|
||||
return $tree['children'] ?? $tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the folder tree of the user
|
||||
*
|
||||
* @return JSONResponse<Http::STATUS_OK, FilesFolderTree, array{}>|JSONResponse<Http::STATUS_UNAUTHORIZED, array{message: string}, array{}>
|
||||
*
|
||||
* 200: Folder tree returned successfully
|
||||
* 401: Unauthorized
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[ApiRoute(verb: 'GET', url: '/api/v1/folder-tree')]
|
||||
public function getFolderTree(): JSONResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if (!($user instanceof IUser)) {
|
||||
return new JSONResponse([
|
||||
'message' => $this->l10n->t('Failed to authorize'),
|
||||
], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
|
||||
try {
|
||||
$searchQuery = new SearchQuery(
|
||||
new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'mimetype', ICacheEntry::DIRECTORY_MIMETYPE),
|
||||
0,
|
||||
0,
|
||||
[],
|
||||
$user,
|
||||
false,
|
||||
);
|
||||
/** @var Folder[] $folders */
|
||||
$folders = $userFolder->search($searchQuery);
|
||||
$tree = $this->getTree($folders);
|
||||
} catch (Throwable $th) {
|
||||
$this->logger->error($th->getMessage(), ['exception' => $th]);
|
||||
$tree = [];
|
||||
}
|
||||
return new JSONResponse($tree, Http::STATUS_OK, [], JSON_FORCE_OBJECT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current logged-in user's storage stats.
|
||||
|
|
|
|||
|
|
@ -38,6 +38,15 @@ namespace OCA\Files;
|
|||
* content: string,
|
||||
* type: string,
|
||||
* }
|
||||
*
|
||||
* @psalm-type FilesFolderTreeNode = array{
|
||||
* id: int,
|
||||
* displayName?: string,
|
||||
* children?: array<string, array{}>,
|
||||
* }
|
||||
*
|
||||
* @psalm-type FilesFolderTree = array<string, FilesFolderTreeNode>
|
||||
*
|
||||
*/
|
||||
class ResponseDefinitions {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,12 @@ class UserConfig {
|
|||
'default' => false,
|
||||
'allowed' => [true, false],
|
||||
],
|
||||
[
|
||||
// Whether to show the folder tree
|
||||
'key' => 'folder_tree',
|
||||
'default' => true,
|
||||
'allowed' => [true, false],
|
||||
],
|
||||
];
|
||||
|
||||
protected IConfig $config;
|
||||
|
|
@ -108,7 +114,7 @@ class UserConfig {
|
|||
if (!in_array($key, $this->getAllowedConfigKeys())) {
|
||||
throw new \InvalidArgumentException('Unknown config key');
|
||||
}
|
||||
|
||||
|
||||
if (!in_array($value, $this->getAllowedConfigValues($key))) {
|
||||
throw new \InvalidArgumentException('Invalid config value');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,6 +99,33 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"FolderTree": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/components/schemas/FolderTreeNode"
|
||||
}
|
||||
},
|
||||
"FolderTreeNode": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"displayName": {
|
||||
"type": "string"
|
||||
},
|
||||
"children": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"OCSMeta": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
|
@ -1928,6 +1955,65 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/files/api/v1/folder-tree": {
|
||||
"get": {
|
||||
"operationId": "api-get-folder-tree",
|
||||
"summary": "Returns the folder tree of the user",
|
||||
"tags": [
|
||||
"api"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
"description": "Required to be true for the API request to pass",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Folder tree returned successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FolderTree"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
|
|
|
|||
|
|
@ -109,12 +109,11 @@ export default defineComponent({
|
|||
return this.dirs.map((dir: string, index: number) => {
|
||||
const source = this.getFileSourceFromPath(dir)
|
||||
const node: Node | undefined = source ? this.getNodeFromSource(source) : undefined
|
||||
const to = { ...this.$route, params: { node: node?.fileid }, query: { dir } }
|
||||
return {
|
||||
dir,
|
||||
exact: true,
|
||||
name: this.getDirDisplayName(dir),
|
||||
to,
|
||||
to: this.getTo(dir, node),
|
||||
// disable drop on current directory
|
||||
disableDrop: index === this.dirs.length - 1,
|
||||
}
|
||||
|
|
@ -163,6 +162,27 @@ export default defineComponent({
|
|||
return node?.displayname || basename(path)
|
||||
},
|
||||
|
||||
getTo(dir: string, node?: Node): Record<string, unknown> {
|
||||
if (dir === '/') {
|
||||
return {
|
||||
...this.$route,
|
||||
params: { view: this.currentView?.id },
|
||||
query: {},
|
||||
}
|
||||
}
|
||||
if (node === undefined) {
|
||||
return {
|
||||
...this.$route,
|
||||
query: { dir },
|
||||
}
|
||||
}
|
||||
return {
|
||||
...this.$route,
|
||||
params: { fileid: String(node.fileid) },
|
||||
query: { dir: node.path },
|
||||
}
|
||||
},
|
||||
|
||||
onClick(to) {
|
||||
if (to?.query?.dir === this.$route.query.dir) {
|
||||
this.$emit('reload')
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import { showError, showSuccess } from '@nextcloud/dialogs'
|
|||
import { emit } from '@nextcloud/event-bus'
|
||||
import { FileType, NodeStatus } from '@nextcloud/files'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { dirname } from '@nextcloud/paths'
|
||||
import { defineComponent, inject } from 'vue'
|
||||
|
||||
import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
|
||||
|
|
@ -269,6 +270,10 @@ export default defineComponent({
|
|||
// Success 🎉
|
||||
emit('files:node:updated', this.source)
|
||||
emit('files:node:renamed', this.source)
|
||||
emit('files:node:moved', {
|
||||
node: this.source,
|
||||
oldSource: `${dirname(this.source.source)}/${oldName}`,
|
||||
})
|
||||
showSuccess(t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName }))
|
||||
|
||||
// Reset the renaming store
|
||||
|
|
|
|||
170
apps/files/src/components/FilesNavigationItem.vue
Normal file
170
apps/files/src/components/FilesNavigationItem.vue
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<template>
|
||||
<Fragment>
|
||||
<NcAppNavigationItem v-for="view in currentViews"
|
||||
:key="view.id"
|
||||
class="files-navigation__item"
|
||||
allow-collapse
|
||||
:data-cy-files-navigation-item="view.id"
|
||||
:exact="useExactRouteMatching(view)"
|
||||
:icon="view.iconClass"
|
||||
:name="view.name"
|
||||
:open="isExpanded(view)"
|
||||
:pinned="view.sticky"
|
||||
:to="generateToNavigation(view)"
|
||||
:style="style"
|
||||
@update:open="onToggleExpand(view)">
|
||||
<template v-if="view.icon" #icon>
|
||||
<NcIconSvgWrapper :svg="view.icon" />
|
||||
</template>
|
||||
|
||||
<!-- Recursively nest child views -->
|
||||
<FilesNavigationItem v-if="hasChildViews(view)"
|
||||
:parent="view"
|
||||
:level="level + 1"
|
||||
:views="filterView(views, parent.id)" />
|
||||
</NcAppNavigationItem>
|
||||
</Fragment>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { PropType } from 'vue'
|
||||
import type { View } from '@nextcloud/files'
|
||||
|
||||
import { defineComponent } from 'vue'
|
||||
import { Fragment } from 'vue-frag'
|
||||
|
||||
import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
|
||||
|
||||
import { useNavigation } from '../composables/useNavigation.js'
|
||||
import { useViewConfigStore } from '../store/viewConfig.js'
|
||||
|
||||
const maxLevel = 7 // Limit nesting to not exceed max call stack size
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FilesNavigationItem',
|
||||
|
||||
components: {
|
||||
Fragment,
|
||||
NcAppNavigationItem,
|
||||
NcIconSvgWrapper,
|
||||
},
|
||||
|
||||
props: {
|
||||
parent: {
|
||||
type: Object as PropType<View>,
|
||||
default: () => ({}),
|
||||
},
|
||||
level: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
views: {
|
||||
type: Object as PropType<Record<string, View[]>>,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
|
||||
setup() {
|
||||
const { currentView } = useNavigation()
|
||||
const viewConfigStore = useViewConfigStore()
|
||||
return {
|
||||
currentView,
|
||||
viewConfigStore,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
currentViews(): View[] {
|
||||
if (this.level >= maxLevel) { // Filter for all remaining decendants beyond the max level
|
||||
return (Object.values(this.views).reduce((acc, views) => [...acc, ...views], []) as View[])
|
||||
.filter(view => view.params?.dir.startsWith(this.parent.params?.dir))
|
||||
}
|
||||
return this.views[this.parent.id] ?? [] // Root level views have `undefined` parent ids
|
||||
},
|
||||
|
||||
style() {
|
||||
if (this.level === 0 || this.level === 1 || this.level > maxLevel) { // Left-align deepest entry with center of app navigation, do not add any more visual indentation after this level
|
||||
return null
|
||||
}
|
||||
return {
|
||||
'padding-left': '16px',
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
hasChildViews(view: View): boolean {
|
||||
if (this.level >= maxLevel) {
|
||||
return false
|
||||
}
|
||||
return this.views[view.id]?.length > 0
|
||||
},
|
||||
|
||||
/**
|
||||
* Only use exact route matching on routes with child views
|
||||
* Because if a view does not have children (like the files view) then multiple routes might be matched for it
|
||||
* Like for the 'files' view this does not work because of optional 'fileid' param so /files and /files/1234 are both in the 'files' view
|
||||
* @param view The view to check
|
||||
*/
|
||||
useExactRouteMatching(view: View): boolean {
|
||||
return this.hasChildViews(view)
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate the route to a view
|
||||
* @param view View to generate "to" navigation for
|
||||
*/
|
||||
generateToNavigation(view: View) {
|
||||
if (view.params) {
|
||||
const { dir } = view.params
|
||||
return { name: 'filelist', params: { ...view.params }, query: { dir } }
|
||||
}
|
||||
return { name: 'filelist', params: { view: view.id } }
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a view is expanded by user config
|
||||
* or fallback to the default value.
|
||||
* @param view View to check if expanded
|
||||
*/
|
||||
isExpanded(view: View): boolean {
|
||||
return typeof this.viewConfigStore.getConfig(view.id)?.expanded === 'boolean'
|
||||
? this.viewConfigStore.getConfig(view.id).expanded === true
|
||||
: view.expanded === true
|
||||
},
|
||||
|
||||
/**
|
||||
* Expand/collapse a a view with children and permanently
|
||||
* save this setting in the server.
|
||||
* @param view View to toggle
|
||||
*/
|
||||
onToggleExpand(view: View) {
|
||||
// Invert state
|
||||
const isExpanded = this.isExpanded(view)
|
||||
// Update the view expanded state, might not be necessary
|
||||
view.expanded = !isExpanded
|
||||
this.viewConfigStore.update(view.id, 'expanded', !isExpanded)
|
||||
},
|
||||
|
||||
/**
|
||||
* Return the view map with the specified view id removed
|
||||
*
|
||||
* @param viewMap Map of views
|
||||
* @param id View id
|
||||
*/
|
||||
filterView(viewMap: Record<string, View[]>, id: string): Record<string, View[]> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(viewMap)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
.filter(([viewId, _views]) => viewId !== id),
|
||||
)
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
|
@ -3,9 +3,10 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import type { View } from '@nextcloud/files'
|
||||
import type { ShallowRef } from 'vue'
|
||||
|
||||
import { getNavigation } from '@nextcloud/files'
|
||||
import { onMounted, onUnmounted, shallowRef, type ShallowRef } from 'vue'
|
||||
import { onMounted, onUnmounted, shallowRef, triggerRef } from 'vue'
|
||||
|
||||
/**
|
||||
* Composable to get the currently active files view from the files navigation
|
||||
|
|
@ -28,6 +29,7 @@ export function useNavigation() {
|
|||
*/
|
||||
function onUpdateViews() {
|
||||
views.value = navigation.views
|
||||
triggerRef(views)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
|
|
|||
5
apps/files/src/eventbus.d.ts
vendored
5
apps/files/src/eventbus.d.ts
vendored
|
|
@ -11,7 +11,12 @@ declare module '@nextcloud/event-bus' {
|
|||
|
||||
'files:favorites:removed': Node
|
||||
'files:favorites:added': Node
|
||||
|
||||
'files:node:created': Node
|
||||
'files:node:deleted': Node
|
||||
'files:node:updated': Node
|
||||
'files:node:renamed': Node
|
||||
'files:node:moved': { node: Node, oldSource: string }
|
||||
|
||||
'files:filter:added': IFileListFilter
|
||||
'files:filter:removed': string
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import registerFavoritesView from './views/favorites'
|
|||
import registerRecentView from './views/recent'
|
||||
import registerPersonalFilesView from './views/personal-files'
|
||||
import registerFilesView from './views/files'
|
||||
import { registerFolderTreeView } from './views/folderTree.ts'
|
||||
import registerPreviewServiceWorker from './services/ServiceWorker.js'
|
||||
|
||||
import { initLivePhotos } from './services/LivePhotos'
|
||||
|
|
@ -53,6 +54,7 @@ registerFavoritesView()
|
|||
registerFilesView()
|
||||
registerRecentView()
|
||||
registerPersonalFilesView()
|
||||
registerFolderTreeView()
|
||||
|
||||
// Register file list filters
|
||||
registerHiddenFilesFilter()
|
||||
|
|
|
|||
90
apps/files/src/services/FolderTree.ts
Normal file
90
apps/files/src/services/FolderTree.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { ContentsWithRoot } from '@nextcloud/files'
|
||||
|
||||
import { CancelablePromise } from 'cancelable-promise'
|
||||
import {
|
||||
davRemoteURL,
|
||||
Folder,
|
||||
} from '@nextcloud/files'
|
||||
import axios from '@nextcloud/axios'
|
||||
import { generateOcsUrl } from '@nextcloud/router'
|
||||
import { getCurrentUser } from '@nextcloud/auth'
|
||||
import { dirname, encodePath } from '@nextcloud/paths'
|
||||
|
||||
import { getContents as getFiles } from './Files.ts'
|
||||
|
||||
export const folderTreeId = 'folders'
|
||||
export const sourceRoot = `${davRemoteURL}/files/${getCurrentUser()?.uid}`
|
||||
|
||||
interface TreeNodeData {
|
||||
id: number,
|
||||
displayName?: string,
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
children?: Tree,
|
||||
}
|
||||
|
||||
interface Tree {
|
||||
[basename: string]: TreeNodeData,
|
||||
}
|
||||
|
||||
export interface TreeNode {
|
||||
source: string,
|
||||
path: string,
|
||||
fileid: number,
|
||||
basename: string,
|
||||
displayName?: string,
|
||||
}
|
||||
|
||||
const getTreeNodes = (tree: Tree, nodes: TreeNode[] = [], currentPath: string = ''): TreeNode[] => {
|
||||
for (const basename in tree) {
|
||||
const path = `${currentPath}/${basename}`
|
||||
const node: TreeNode = {
|
||||
source: `${sourceRoot}${path}`,
|
||||
path,
|
||||
fileid: tree[basename].id,
|
||||
basename,
|
||||
displayName: tree[basename].displayName,
|
||||
}
|
||||
nodes.push(node)
|
||||
if (tree[basename].children) {
|
||||
getTreeNodes(tree[basename].children, nodes, path)
|
||||
}
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
export const getFolderTreeNodes = async (): Promise<TreeNode[]> => {
|
||||
const { data: tree } = await axios.get<Tree>(generateOcsUrl('/apps/files/api/v1/folder-tree'))
|
||||
const nodes = getTreeNodes(tree)
|
||||
return nodes
|
||||
}
|
||||
|
||||
export const getContents = (path: string): CancelablePromise<ContentsWithRoot> => getFiles(path)
|
||||
|
||||
export const encodeSource = (source: string): string => {
|
||||
const { origin } = new URL(source)
|
||||
return origin + encodePath(source.slice(origin.length))
|
||||
}
|
||||
|
||||
export const getSourceParent = (source: string): string => {
|
||||
const parent = dirname(source)
|
||||
if (parent === sourceRoot) {
|
||||
return folderTreeId
|
||||
}
|
||||
return encodeSource(parent)
|
||||
}
|
||||
|
||||
export const getFolderTreeViewId = (folder: Folder): string => {
|
||||
return folder.encodedSource
|
||||
}
|
||||
|
||||
export const getFolderTreeParentId = (folder: Folder): string => {
|
||||
if (folder.dirname === '/') {
|
||||
return folderTreeId
|
||||
}
|
||||
return dirname(folder.encodedSource)
|
||||
}
|
||||
|
|
@ -44,8 +44,10 @@ export const useViewConfigStore = function(...args) {
|
|||
* @param value
|
||||
*/
|
||||
async update(view: ViewId, key: string, value: string | number | boolean) {
|
||||
axios.put(generateUrl(`/apps/files/api/v1/views/${view}/${key}`), {
|
||||
axios.put(generateUrl('/apps/files/api/v1/views'), {
|
||||
value,
|
||||
view,
|
||||
key,
|
||||
})
|
||||
|
||||
emit('files:viewconfig:updated', { view, key, value })
|
||||
|
|
|
|||
|
|
@ -4,38 +4,14 @@
|
|||
-->
|
||||
<template>
|
||||
<NcAppNavigation data-cy-files-navigation
|
||||
class="files-navigation"
|
||||
:aria-label="t('files', 'Files')">
|
||||
<template #search>
|
||||
<NcAppNavigationSearch v-model="searchQuery" :label="t('files', 'Filter filenames…')" />
|
||||
</template>
|
||||
<template #default>
|
||||
<NcAppNavigationList :aria-label="t('files', 'Views')">
|
||||
<NcAppNavigationItem v-for="view in parentViews"
|
||||
:key="view.id"
|
||||
:allow-collapse="true"
|
||||
:data-cy-files-navigation-item="view.id"
|
||||
:exact="useExactRouteMatching(view)"
|
||||
:icon="view.iconClass"
|
||||
:name="view.name"
|
||||
:open="isExpanded(view)"
|
||||
:pinned="view.sticky"
|
||||
:to="generateToNavigation(view)"
|
||||
@update:open="onToggleExpand(view)">
|
||||
<!-- Sanitized icon as svg if provided -->
|
||||
<NcIconSvgWrapper v-if="view.icon" slot="icon" :svg="view.icon" />
|
||||
|
||||
<!-- Child views if any -->
|
||||
<NcAppNavigationItem v-for="child in childViews[view.id]"
|
||||
:key="child.id"
|
||||
:data-cy-files-navigation-item="child.id"
|
||||
:exact-path="true"
|
||||
:icon="child.iconClass"
|
||||
:name="child.name"
|
||||
:to="generateToNavigation(child)">
|
||||
<!-- Sanitized icon as svg if provided -->
|
||||
<NcIconSvgWrapper v-if="child.icon" slot="icon" :svg="child.icon" />
|
||||
</NcAppNavigationItem>
|
||||
</NcAppNavigationItem>
|
||||
<FilesNavigationItem :views="viewMap" />
|
||||
</NcAppNavigationList>
|
||||
|
||||
<!-- Settings modal-->
|
||||
|
|
@ -65,7 +41,7 @@
|
|||
import type { View } from '@nextcloud/files'
|
||||
|
||||
import { emit } from '@nextcloud/event-bus'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { translate as t, getCanonicalLocale, getLanguage } from '@nextcloud/l10n'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
import IconCog from 'vue-material-design-icons/Cog.vue'
|
||||
|
|
@ -73,9 +49,9 @@ import NcAppNavigation from '@nextcloud/vue/dist/Components/NcAppNavigation.js'
|
|||
import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js'
|
||||
import NcAppNavigationList from '@nextcloud/vue/dist/Components/NcAppNavigationList.js'
|
||||
import NcAppNavigationSearch from '@nextcloud/vue/dist/Components/NcAppNavigationSearch.js'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
|
||||
import NavigationQuota from '../components/NavigationQuota.vue'
|
||||
import SettingsModal from './Settings.vue'
|
||||
import FilesNavigationItem from '../components/FilesNavigationItem.vue'
|
||||
|
||||
import { useNavigation } from '../composables/useNavigation'
|
||||
import { useFilenameFilter } from '../composables/useFilenameFilter'
|
||||
|
|
@ -83,18 +59,26 @@ import { useFiltersStore } from '../store/filters.ts'
|
|||
import { useViewConfigStore } from '../store/viewConfig.ts'
|
||||
import logger from '../logger.ts'
|
||||
|
||||
const collator = Intl.Collator(
|
||||
[getLanguage(), getCanonicalLocale()],
|
||||
{
|
||||
numeric: true,
|
||||
usage: 'sort',
|
||||
},
|
||||
)
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Navigation',
|
||||
|
||||
components: {
|
||||
IconCog,
|
||||
FilesNavigationItem,
|
||||
|
||||
NavigationQuota,
|
||||
NcAppNavigation,
|
||||
NcAppNavigationItem,
|
||||
NcAppNavigationList,
|
||||
NcAppNavigationSearch,
|
||||
NcIconSvgWrapper,
|
||||
SettingsModal,
|
||||
},
|
||||
|
||||
|
|
@ -129,28 +113,21 @@ export default defineComponent({
|
|||
return this.$route?.params?.view || 'files'
|
||||
},
|
||||
|
||||
parentViews(): View[] {
|
||||
/**
|
||||
* Map of parent ids to views
|
||||
*/
|
||||
viewMap(): Record<string, View[]> {
|
||||
return this.views
|
||||
// filter child views
|
||||
.filter(view => !view.parent)
|
||||
// sort views by order
|
||||
.sort((a, b) => {
|
||||
return a.order - b.order
|
||||
})
|
||||
},
|
||||
|
||||
childViews(): Record<string, View[]> {
|
||||
return this.views
|
||||
// filter parent views
|
||||
.filter(view => !!view.parent)
|
||||
// create a map of parents and their children
|
||||
.reduce((list, view) => {
|
||||
list[view.parent!] = [...(list[view.parent!] || []), view]
|
||||
// Sort children by order
|
||||
list[view.parent!].sort((a, b) => {
|
||||
return a.order - b.order
|
||||
.reduce((map, view) => {
|
||||
map[view.parent!] = [...(map[view.parent!] || []), view]
|
||||
// TODO Allow undefined order for natural sort
|
||||
map[view.parent!].sort((a, b) => {
|
||||
if (typeof a.order === 'number' || typeof b.order === 'number') {
|
||||
return (a.order ?? 0) - (b.order ?? 0)
|
||||
}
|
||||
return collator.compare(a.name, b.name)
|
||||
})
|
||||
return list
|
||||
return map
|
||||
}, {} as Record<string, View[]>)
|
||||
},
|
||||
},
|
||||
|
|
@ -175,16 +152,6 @@ export default defineComponent({
|
|||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Only use exact route matching on routes with child views
|
||||
* Because if a view does not have children (like the files view) then multiple routes might be matched for it
|
||||
* Like for the 'files' view this does not work because of optional 'fileid' param so /files and /files/1234 are both in the 'files' view
|
||||
* @param view The view to check
|
||||
*/
|
||||
useExactRouteMatching(view: View): boolean {
|
||||
return this.childViews[view.id]?.length > 0
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the view as active on the navigation and handle internal state
|
||||
* @param view View to set active
|
||||
|
|
@ -196,42 +163,6 @@ export default defineComponent({
|
|||
emit('files:navigation:changed', view)
|
||||
},
|
||||
|
||||
/**
|
||||
* Expand/collapse a a view with children and permanently
|
||||
* save this setting in the server.
|
||||
* @param view View to toggle
|
||||
*/
|
||||
onToggleExpand(view: View) {
|
||||
// Invert state
|
||||
const isExpanded = this.isExpanded(view)
|
||||
// Update the view expanded state, might not be necessary
|
||||
view.expanded = !isExpanded
|
||||
this.viewConfigStore.update(view.id, 'expanded', !isExpanded)
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a view is expanded by user config
|
||||
* or fallback to the default value.
|
||||
* @param view View to check if expanded
|
||||
*/
|
||||
isExpanded(view: View): boolean {
|
||||
return typeof this.viewConfigStore.getConfig(view.id)?.expanded === 'boolean'
|
||||
? this.viewConfigStore.getConfig(view.id).expanded === true
|
||||
: view.expanded === true
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate the route to a view
|
||||
* @param view View to generate "to" navigation for
|
||||
*/
|
||||
generateToNavigation(view: View) {
|
||||
if (view.params) {
|
||||
const { dir } = view.params
|
||||
return { name: 'filelist', params: view.params, query: { dir } }
|
||||
}
|
||||
return { name: 'filelist', params: { view: view.id } }
|
||||
},
|
||||
|
||||
/**
|
||||
* Open the settings modal
|
||||
*/
|
||||
|
|
@ -272,4 +203,10 @@ export default defineComponent({
|
|||
// Prevent shrinking or growing
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.files-navigation {
|
||||
:deep(.app-navigation__content > ul.app-navigation__list) {
|
||||
will-change: scroll-position;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,11 @@
|
|||
@update:checked="setConfig('grid_view', $event)">
|
||||
{{ t('files', 'Enable the grid view') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
<NcCheckboxRadioSwitch data-cy-files-settings-setting="folder_tree"
|
||||
:checked="userConfig.folder_tree"
|
||||
@update:checked="setConfig('folder_tree', $event)">
|
||||
{{ t('files', 'Enable folder tree') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
</NcAppSettingsSection>
|
||||
|
||||
<!-- Settings API-->
|
||||
|
|
|
|||
153
apps/files/src/views/folderTree.ts
Normal file
153
apps/files/src/views/folderTree.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { TreeNode } from '../services/FolderTree.ts'
|
||||
|
||||
import { Folder, Node, View, getNavigation } from '@nextcloud/files'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { subscribe } from '@nextcloud/event-bus'
|
||||
import { isSamePath } from '@nextcloud/paths'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
|
||||
import FolderSvg from '@mdi/svg/svg/folder.svg?raw'
|
||||
import FolderMultipleSvg from '@mdi/svg/svg/folder-multiple.svg?raw'
|
||||
|
||||
import {
|
||||
encodeSource,
|
||||
folderTreeId,
|
||||
getContents,
|
||||
getFolderTreeNodes,
|
||||
getFolderTreeParentId,
|
||||
getFolderTreeViewId,
|
||||
getSourceParent,
|
||||
sourceRoot,
|
||||
} from '../services/FolderTree.ts'
|
||||
|
||||
const isFolderTreeEnabled = loadState('files', 'config', { folder_tree: true }).folder_tree
|
||||
|
||||
const Navigation = getNavigation()
|
||||
|
||||
const registerTreeNodeView = (node: TreeNode) => {
|
||||
Navigation.register(new View({
|
||||
id: encodeSource(node.source),
|
||||
parent: getSourceParent(node.source),
|
||||
|
||||
name: node.displayName ?? node.basename,
|
||||
|
||||
icon: FolderSvg,
|
||||
order: 0, // TODO Allow undefined order for natural sort
|
||||
|
||||
getContents,
|
||||
|
||||
params: {
|
||||
view: folderTreeId,
|
||||
fileid: String(node.fileid), // Needed for matching exact routes
|
||||
dir: node.path,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
const registerFolderView = (folder: Folder) => {
|
||||
Navigation.register(new View({
|
||||
id: getFolderTreeViewId(folder),
|
||||
parent: getFolderTreeParentId(folder),
|
||||
|
||||
name: folder.displayname,
|
||||
|
||||
icon: FolderSvg,
|
||||
order: 0, // TODO Allow undefined order for natural sort
|
||||
|
||||
getContents,
|
||||
|
||||
params: {
|
||||
view: folderTreeId,
|
||||
fileid: String(folder.fileid),
|
||||
dir: folder.path,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
const removeFolderView = (folder: Folder) => {
|
||||
const viewId = getFolderTreeViewId(folder)
|
||||
Navigation.remove(viewId)
|
||||
}
|
||||
|
||||
const removeFolderViewSource = (source: string) => {
|
||||
const Navigation = getNavigation()
|
||||
Navigation.remove(source)
|
||||
}
|
||||
|
||||
const onCreateNode = (node: Node) => {
|
||||
if (!(node instanceof Folder)) {
|
||||
return
|
||||
}
|
||||
registerFolderView(node)
|
||||
}
|
||||
|
||||
const onDeleteNode = (node: Node) => {
|
||||
if (!(node instanceof Folder)) {
|
||||
return
|
||||
}
|
||||
removeFolderView(node)
|
||||
}
|
||||
|
||||
const onMoveNode = ({ node, oldSource }) => {
|
||||
if (!(node instanceof Folder)) {
|
||||
return
|
||||
}
|
||||
removeFolderViewSource(oldSource)
|
||||
registerFolderView(node)
|
||||
|
||||
const newPath = node.source.replace(sourceRoot, '')
|
||||
const oldPath = oldSource.replace(sourceRoot, '')
|
||||
const childViews = Navigation.views.filter(view => {
|
||||
if (!view.params?.dir) {
|
||||
return false
|
||||
}
|
||||
if (isSamePath(view.params.dir, oldPath)) {
|
||||
return false
|
||||
}
|
||||
return view.params.dir.startsWith(oldPath)
|
||||
})
|
||||
for (const view of childViews) {
|
||||
// @ts-expect-error FIXME Allow setting parent
|
||||
view.parent = getFolderTreeParentId(node)
|
||||
// @ts-expect-error dir param is defined
|
||||
view.params.dir = view.params.dir.replace(oldPath, newPath)
|
||||
}
|
||||
}
|
||||
|
||||
const registerFolderTreeRoot = () => {
|
||||
Navigation.register(new View({
|
||||
id: folderTreeId,
|
||||
|
||||
name: t('files', 'All folders'),
|
||||
caption: t('files', 'List of your files and folders.'),
|
||||
|
||||
icon: FolderMultipleSvg,
|
||||
order: 50, // Below all other views
|
||||
|
||||
getContents,
|
||||
}))
|
||||
}
|
||||
|
||||
const registerFolderTreeChildren = async () => {
|
||||
const nodes = await getFolderTreeNodes()
|
||||
for (const node of nodes) {
|
||||
registerTreeNodeView(node)
|
||||
}
|
||||
|
||||
subscribe('files:node:created', onCreateNode)
|
||||
subscribe('files:node:deleted', onDeleteNode)
|
||||
subscribe('files:node:moved', onMoveNode)
|
||||
}
|
||||
|
||||
export const registerFolderTreeView = async () => {
|
||||
if (!isFolderTreeEnabled) {
|
||||
return
|
||||
}
|
||||
registerFolderTreeRoot()
|
||||
await registerFolderTreeChildren()
|
||||
}
|
||||
|
|
@ -14,15 +14,18 @@ use OCP\AppFramework\Http;
|
|||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\Files\File;
|
||||
use OCP\Files\Folder;
|
||||
use OCP\Files\IRootFolder;
|
||||
use OCP\Files\NotFoundException;
|
||||
use OCP\Files\SimpleFS\ISimpleFile;
|
||||
use OCP\Files\StorageNotAvailableException;
|
||||
use OCP\IConfig;
|
||||
use OCP\IL10N;
|
||||
use OCP\IPreview;
|
||||
use OCP\IRequest;
|
||||
use OCP\IUser;
|
||||
use OCP\IUserSession;
|
||||
use OCP\Share\IManager;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Test\TestCase;
|
||||
|
||||
/**
|
||||
|
|
@ -53,6 +56,12 @@ class ApiControllerTest extends TestCase {
|
|||
private $userConfig;
|
||||
/** @var ViewConfig|\PHPUnit\Framework\MockObject\MockObject */
|
||||
private $viewConfig;
|
||||
/** @var IL10N|\PHPUnit\Framework\MockObject\MockObject */
|
||||
private $l10n;
|
||||
/** @var IRootFolder|\PHPUnit\Framework\MockObject\MockObject */
|
||||
private $rootFolder;
|
||||
/** @var LoggerInterface|\PHPUnit\Framework\MockObject\MockObject */
|
||||
private $logger;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
|
@ -83,6 +92,9 @@ class ApiControllerTest extends TestCase {
|
|||
->getMock();
|
||||
$this->userConfig = $this->createMock(UserConfig::class);
|
||||
$this->viewConfig = $this->createMock(ViewConfig::class);
|
||||
$this->l10n = $this->createMock(IL10N::class);
|
||||
$this->rootFolder = $this->createMock(IRootFolder::class);
|
||||
$this->logger = $this->createMock(LoggerInterface::class);
|
||||
|
||||
$this->apiController = new ApiController(
|
||||
$this->appName,
|
||||
|
|
@ -94,7 +106,10 @@ class ApiControllerTest extends TestCase {
|
|||
$this->config,
|
||||
$this->userFolder,
|
||||
$this->userConfig,
|
||||
$this->viewConfig
|
||||
$this->viewConfig,
|
||||
$this->l10n,
|
||||
$this->rootFolder,
|
||||
$this->logger,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
4
dist/core-common.js
vendored
4
dist/core-common.js
vendored
File diff suppressed because one or more lines are too long
2
dist/core-common.js.map
vendored
2
dist/core-common.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/files-init.js
vendored
4
dist/files-init.js
vendored
File diff suppressed because one or more lines are too long
2
dist/files-init.js.map
vendored
2
dist/files-init.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/files-main.js
vendored
4
dist/files-main.js
vendored
File diff suppressed because one or more lines are too long
2
dist/files-main.js.map
vendored
2
dist/files-main.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/files_reminders-init.js
vendored
4
dist/files_reminders-init.js
vendored
File diff suppressed because one or more lines are too long
2
dist/files_reminders-init.js.map
vendored
2
dist/files_reminders-init.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/files_sharing-init.js
vendored
4
dist/files_sharing-init.js
vendored
File diff suppressed because one or more lines are too long
2
dist/files_sharing-init.js.map
vendored
2
dist/files_sharing-init.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/files_sharing-personal-settings.js
vendored
4
dist/files_sharing-personal-settings.js
vendored
File diff suppressed because one or more lines are too long
2
dist/files_sharing-personal-settings.js.map
vendored
2
dist/files_sharing-personal-settings.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/files_sharing-public-file-request.js
vendored
4
dist/files_sharing-public-file-request.js
vendored
|
|
@ -1,2 +1,2 @@
|
|||
(()=>{"use strict";var e,t,r,o={38943:(e,t,r)=>{var o=r(85168),n=r(85471);const a=(0,r(35947).YK)().setApp("files_sharing").detectUser().build(),i=localStorage.getItem("nick"),l=localStorage.getItem("publicAuthPromptShown");i&&l?a.debug("Public auth prompt already shown. Current nickname is '".concat(i,"'")):(0,o.Ss)((0,n.$V)((()=>Promise.all([r.e(4208),r.e(5315)]).then(r.bind(r,45315)))),{},(()=>localStorage.setItem("publicAuthPromptShown","true")))}},n={};function a(e){var t=n[e];if(void 0!==t)return t.exports;var r=n[e]={id:e,loaded:!1,exports:{}};return o[e].call(r.exports,r,r.exports,a),r.loaded=!0,r.exports}a.m=o,e=[],a.O=(t,r,o,n)=>{if(!r){var i=1/0;for(s=0;s<e.length;s++){r=e[s][0],o=e[s][1],n=e[s][2];for(var l=!0,c=0;c<r.length;c++)(!1&n||i>=n)&&Object.keys(a.O).every((e=>a.O[e](r[c])))?r.splice(c--,1):(l=!1,n<i&&(i=n));if(l){e.splice(s--,1);var u=o();void 0!==u&&(t=u)}}return t}n=n||0;for(var s=e.length;s>0&&e[s-1][2]>n;s--)e[s]=e[s-1];e[s]=[r,o,n]},a.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return a.d(t,{a:t}),t},a.d=(e,t)=>{for(var r in t)a.o(t,r)&&!a.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},a.f={},a.e=e=>Promise.all(Object.keys(a.f).reduce(((t,r)=>(a.f[r](e,t),t)),[])),a.u=e=>e+"-"+e+".js?v="+{4254:"5c2324570f66dff0c8a1",5315:"16aff37ab8dbc31a4bc1",9480:"2fb23485ae3a8860e96b"}[e],a.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),a.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),t={},r="nextcloud:",a.l=(e,o,n,i)=>{if(t[e])t[e].push(o);else{var l,c;if(void 0!==n)for(var u=document.getElementsByTagName("script"),s=0;s<u.length;s++){var d=u[s];if(d.getAttribute("src")==e||d.getAttribute("data-webpack")==r+n){l=d;break}}l||(c=!0,(l=document.createElement("script")).charset="utf-8",l.timeout=120,a.nc&&l.setAttribute("nonce",a.nc),l.setAttribute("data-webpack",r+n),l.src=e),t[e]=[o];var p=(r,o)=>{l.onerror=l.onload=null,clearTimeout(f);var n=t[e];if(delete t[e],l.parentNode&&l.parentNode.removeChild(l),n&&n.forEach((e=>e(o))),r)return r(o)},f=setTimeout(p.bind(null,void 0,{type:"timeout",target:l}),12e4);l.onerror=p.bind(null,l.onerror),l.onload=p.bind(null,l.onload),c&&document.head.appendChild(l)}},a.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},a.nmd=e=>(e.paths=[],e.children||(e.children=[]),e),a.j=9804,(()=>{var e;a.g.importScripts&&(e=a.g.location+"");var t=a.g.document;if(!e&&t&&(t.currentScript&&(e=t.currentScript.src),!e)){var r=t.getElementsByTagName("script");if(r.length)for(var o=r.length-1;o>-1&&(!e||!/^http(s?):/.test(e));)e=r[o--].src}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/#.*$/,"").replace(/\?.*$/,"").replace(/\/[^\/]+$/,"/"),a.p=e})(),(()=>{a.b=document.baseURI||self.location.href;var e={9804:0};a.f.j=(t,r)=>{var o=a.o(e,t)?e[t]:void 0;if(0!==o)if(o)r.push(o[2]);else{var n=new Promise(((r,n)=>o=e[t]=[r,n]));r.push(o[2]=n);var i=a.p+a.u(t),l=new Error;a.l(i,(r=>{if(a.o(e,t)&&(0!==(o=e[t])&&(e[t]=void 0),o)){var n=r&&("load"===r.type?"missing":r.type),i=r&&r.target&&r.target.src;l.message="Loading chunk "+t+" failed.\n("+n+": "+i+")",l.name="ChunkLoadError",l.type=n,l.request=i,o[1](l)}}),"chunk-"+t,t)}},a.O.j=t=>0===e[t];var t=(t,r)=>{var o,n,i=r[0],l=r[1],c=r[2],u=0;if(i.some((t=>0!==e[t]))){for(o in l)a.o(l,o)&&(a.m[o]=l[o]);if(c)var s=c(a)}for(t&&t(r);u<i.length;u++)n=i[u],a.o(e,n)&&e[n]&&e[n][0](),e[n]=0;return a.O(s)},r=self.webpackChunknextcloud=self.webpackChunknextcloud||[];r.forEach(t.bind(null,0)),r.push=t.bind(null,r.push.bind(r))})(),a.nc=void 0;var i=a.O(void 0,[4208],(()=>a(38943)));i=a.O(i)})();
|
||||
//# sourceMappingURL=files_sharing-public-file-request.js.map?v=e82a5ef189b9396f6ccd
|
||||
(()=>{"use strict";var e,t,r,o={38943:(e,t,r)=>{var o=r(85168),n=r(85471);const a=(0,r(35947).YK)().setApp("files_sharing").detectUser().build(),i=localStorage.getItem("nick"),l=localStorage.getItem("publicAuthPromptShown");i&&l?a.debug("Public auth prompt already shown. Current nickname is '".concat(i,"'")):(0,o.Ss)((0,n.$V)((()=>Promise.all([r.e(4208),r.e(5315)]).then(r.bind(r,45315)))),{},(()=>localStorage.setItem("publicAuthPromptShown","true")))}},n={};function a(e){var t=n[e];if(void 0!==t)return t.exports;var r=n[e]={id:e,loaded:!1,exports:{}};return o[e].call(r.exports,r,r.exports,a),r.loaded=!0,r.exports}a.m=o,e=[],a.O=(t,r,o,n)=>{if(!r){var i=1/0;for(d=0;d<e.length;d++){r=e[d][0],o=e[d][1],n=e[d][2];for(var l=!0,c=0;c<r.length;c++)(!1&n||i>=n)&&Object.keys(a.O).every((e=>a.O[e](r[c])))?r.splice(c--,1):(l=!1,n<i&&(i=n));if(l){e.splice(d--,1);var u=o();void 0!==u&&(t=u)}}return t}n=n||0;for(var d=e.length;d>0&&e[d-1][2]>n;d--)e[d]=e[d-1];e[d]=[r,o,n]},a.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return a.d(t,{a:t}),t},a.d=(e,t)=>{for(var r in t)a.o(t,r)&&!a.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},a.f={},a.e=e=>Promise.all(Object.keys(a.f).reduce(((t,r)=>(a.f[r](e,t),t)),[])),a.u=e=>e+"-"+e+".js?v="+{4254:"5c2324570f66dff0c8a1",5315:"16aff37ab8dbc31a4bc1",9480:"2fb23485ae3a8860e96b"}[e],a.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),a.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),t={},r="nextcloud:",a.l=(e,o,n,i)=>{if(t[e])t[e].push(o);else{var l,c;if(void 0!==n)for(var u=document.getElementsByTagName("script"),d=0;d<u.length;d++){var s=u[d];if(s.getAttribute("src")==e||s.getAttribute("data-webpack")==r+n){l=s;break}}l||(c=!0,(l=document.createElement("script")).charset="utf-8",l.timeout=120,a.nc&&l.setAttribute("nonce",a.nc),l.setAttribute("data-webpack",r+n),l.src=e),t[e]=[o];var p=(r,o)=>{l.onerror=l.onload=null,clearTimeout(f);var n=t[e];if(delete t[e],l.parentNode&&l.parentNode.removeChild(l),n&&n.forEach((e=>e(o))),r)return r(o)},f=setTimeout(p.bind(null,void 0,{type:"timeout",target:l}),12e4);l.onerror=p.bind(null,l.onerror),l.onload=p.bind(null,l.onload),c&&document.head.appendChild(l)}},a.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},a.nmd=e=>(e.paths=[],e.children||(e.children=[]),e),a.j=9804,(()=>{var e;a.g.importScripts&&(e=a.g.location+"");var t=a.g.document;if(!e&&t&&(t.currentScript&&(e=t.currentScript.src),!e)){var r=t.getElementsByTagName("script");if(r.length)for(var o=r.length-1;o>-1&&(!e||!/^http(s?):/.test(e));)e=r[o--].src}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/#.*$/,"").replace(/\?.*$/,"").replace(/\/[^\/]+$/,"/"),a.p=e})(),(()=>{a.b=document.baseURI||self.location.href;var e={9804:0};a.f.j=(t,r)=>{var o=a.o(e,t)?e[t]:void 0;if(0!==o)if(o)r.push(o[2]);else{var n=new Promise(((r,n)=>o=e[t]=[r,n]));r.push(o[2]=n);var i=a.p+a.u(t),l=new Error;a.l(i,(r=>{if(a.o(e,t)&&(0!==(o=e[t])&&(e[t]=void 0),o)){var n=r&&("load"===r.type?"missing":r.type),i=r&&r.target&&r.target.src;l.message="Loading chunk "+t+" failed.\n("+n+": "+i+")",l.name="ChunkLoadError",l.type=n,l.request=i,o[1](l)}}),"chunk-"+t,t)}},a.O.j=t=>0===e[t];var t=(t,r)=>{var o,n,i=r[0],l=r[1],c=r[2],u=0;if(i.some((t=>0!==e[t]))){for(o in l)a.o(l,o)&&(a.m[o]=l[o]);if(c)var d=c(a)}for(t&&t(r);u<i.length;u++)n=i[u],a.o(e,n)&&e[n]&&e[n][0](),e[n]=0;return a.O(d)},r=self.webpackChunknextcloud=self.webpackChunknextcloud||[];r.forEach(t.bind(null,0)),r.push=t.bind(null,r.push.bind(r))})(),a.nc=void 0;var i=a.O(void 0,[4208],(()=>a(38943)));i=a.O(i)})();
|
||||
//# sourceMappingURL=files_sharing-public-file-request.js.map?v=f09d2496f1c2f72f3d82
|
||||
File diff suppressed because one or more lines are too long
4
dist/settings-vue-settings-admin-sharing.js
vendored
4
dist/settings-vue-settings-admin-sharing.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
dist/weather_status-weather-status.js
vendored
4
dist/weather_status-weather-status.js
vendored
File diff suppressed because one or more lines are too long
2
dist/weather_status-weather-status.js.map
vendored
2
dist/weather_status-weather-status.js.map
vendored
File diff suppressed because one or more lines are too long
|
|
@ -222,9 +222,9 @@ class Folder extends Node implements \OCP\Files\Folder {
|
|||
}, array_values($resultsPerCache), array_keys($resultsPerCache)));
|
||||
|
||||
// don't include this folder in the results
|
||||
$files = array_filter($files, function (FileInfo $file) {
|
||||
$files = array_values(array_filter($files, function (FileInfo $file) {
|
||||
return $file->getPath() !== $this->getPath();
|
||||
});
|
||||
}));
|
||||
|
||||
// since results were returned per-cache, they are no longer fully sorted
|
||||
$order = $query->getOrder();
|
||||
|
|
|
|||
|
|
@ -23,6 +23,11 @@ class JSONResponse extends Response {
|
|||
* @var T
|
||||
*/
|
||||
protected $data;
|
||||
/**
|
||||
* Additional `json_encode` flags
|
||||
* @var int
|
||||
*/
|
||||
protected $encodeFlags;
|
||||
|
||||
|
||||
/**
|
||||
|
|
@ -30,12 +35,20 @@ class JSONResponse extends Response {
|
|||
* @param T $data the object or array that should be transformed
|
||||
* @param S $statusCode the Http status code, defaults to 200
|
||||
* @param H $headers
|
||||
* @param int $encodeFlags Additional `json_encode` flags
|
||||
* @since 6.0.0
|
||||
* @since 30.0.0 Added `$encodeFlags` param
|
||||
*/
|
||||
public function __construct(mixed $data = [], int $statusCode = Http::STATUS_OK, array $headers = []) {
|
||||
public function __construct(
|
||||
mixed $data = [],
|
||||
int $statusCode = Http::STATUS_OK,
|
||||
array $headers = [],
|
||||
int $encodeFlags = 0,
|
||||
) {
|
||||
parent::__construct($statusCode, $headers);
|
||||
|
||||
$this->data = $data;
|
||||
$this->encodeFlags = $encodeFlags;
|
||||
$this->addHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
}
|
||||
|
||||
|
|
@ -47,7 +60,7 @@ class JSONResponse extends Response {
|
|||
* @throws \Exception If data could not get encoded
|
||||
*/
|
||||
public function render() {
|
||||
return json_encode($this->data, JSON_HEX_TAG | JSON_THROW_ON_ERROR);
|
||||
return json_encode($this->data, JSON_HEX_TAG | JSON_THROW_ON_ERROR | $this->encodeFlags, 2048);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in a new issue