Merge pull request #55495 from nextcloud/chore/eslint-v9

chore: use ESLint v9 for linting
pull/55512/head^2
Ferdinand Thiessen 4 days ago committed by GitHub
commit 4ace101b05
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 14
      .eslintignore
  2. 62
      .eslintrc.js
  3. 2
      REUSE.toml
  4. 7
      __mocks__/@nextcloud/auth.ts
  5. 5
      __mocks__/@nextcloud/capabilities.ts
  6. 2
      __mocks__/@nextcloud/initial-state.ts
  7. 7
      __mocks__/webdav.ts
  8. 15
      __tests__/FileSystemAPIUtils.ts
  9. 16
      apps/comments/src/actions/inlineUnreadCommentsAction.spec.ts
  10. 12
      apps/comments/src/actions/inlineUnreadCommentsAction.ts
  11. 4
      apps/comments/src/comments-activity-tab.ts
  12. 3
      apps/comments/src/comments-app.js
  13. 1
      apps/comments/src/comments-tab.js
  14. 44
      apps/comments/src/components/Comment.vue
  15. 3
      apps/comments/src/init.ts
  16. 12
      apps/comments/src/mixins/CommentMixin.js
  17. 12
      apps/comments/src/mixins/CommentView.ts
  18. 8
      apps/comments/src/services/CommentsInstance.js
  19. 7
      apps/comments/src/services/DavClient.js
  20. 4
      apps/comments/src/services/EditComment.js
  21. 39
      apps/comments/src/services/GetComments.ts
  22. 2
      apps/comments/src/services/NewComment.js
  23. 8
      apps/comments/src/services/ReadComments.ts
  24. 4
      apps/comments/src/utils/cancelableRequest.js
  25. 5
      apps/comments/src/utils/davUtils.js
  26. 15
      apps/comments/src/views/ActivityCommentAction.vue
  27. 8
      apps/comments/src/views/ActivityCommentEntry.vue
  28. 35
      apps/comments/src/views/Comments.vue
  29. 81
      apps/dashboard/src/DashboardApp.vue
  30. 18
      apps/dashboard/src/components/ApiDashboardWidget.vue
  31. 14
      apps/dashboard/src/components/ApiDashboardWidgetItem.vue
  32. 11
      apps/dashboard/src/logger.ts
  33. 2
      apps/dashboard/src/main.js
  34. 64
      apps/dav/src/components/AbsenceForm.vue
  35. 27
      apps/dav/src/components/AvailabilityForm.vue
  36. 42
      apps/dav/src/components/ExampleContactSettings.vue
  37. 6
      apps/dav/src/components/ExampleContentDownloadButton.vue
  38. 41
      apps/dav/src/components/ExampleEventSettings.vue
  39. 6
      apps/dav/src/dav/client.js
  40. 13
      apps/dav/src/service/CalendarService.js
  41. 2
      apps/dav/src/service/ExampleEventService.js
  42. 10
      apps/dav/src/service/PreferenceService.js
  43. 7
      apps/dav/src/settings-example-content.js
  44. 5
      apps/dav/src/settings-personal-availability.js
  45. 7
      apps/dav/src/settings.js
  46. 10
      apps/dav/src/views/Availability.vue
  47. 24
      apps/dav/src/views/CalDavSettings.spec.js
  48. 35
      apps/dav/src/views/CalDavSettings.vue
  49. 7
      apps/dav/src/views/ExampleContentSettingsSection.vue
  50. 27
      apps/encryption/js/encryption.js
  51. 105
      apps/encryption/js/settings-admin.js
  52. 87
      apps/encryption/js/settings-personal.js
  53. 46
      apps/federatedfilesharing/src/components/AdminSettings.vue
  54. 35
      apps/federatedfilesharing/src/components/PersonalSettings.vue
  55. 6
      apps/federatedfilesharing/src/components/RemoteShareDialog.vue
  56. 4
      apps/federatedfilesharing/src/external.js
  57. 8
      apps/federatedfilesharing/src/main-admin.js
  58. 4
      apps/federatedfilesharing/src/main-personal.js
  59. 2
      apps/federatedfilesharing/src/services/dialogService.spec.ts
  60. 4
      apps/federatedfilesharing/src/services/dialogService.ts
  61. 1
      apps/federatedfilesharing/src/services/logger.ts
  62. 205
      apps/federation/js/settings-admin.js
  63. 8
      apps/files/src/FilesApp.vue
  64. 37
      apps/files/src/actions/convertAction.ts
  65. 55
      apps/files/src/actions/convertUtils.ts
  66. 16
      apps/files/src/actions/deleteAction.spec.ts
  67. 18
      apps/files/src/actions/deleteAction.ts
  68. 80
      apps/files/src/actions/deleteUtils.ts
  69. 8
      apps/files/src/actions/downloadAction.spec.ts
  70. 28
      apps/files/src/actions/downloadAction.ts
  71. 34
      apps/files/src/actions/favoriteAction.spec.ts
  72. 34
      apps/files/src/actions/favoriteAction.ts
  73. 78
      apps/files/src/actions/moveOrCopyAction.ts
  74. 29
      apps/files/src/actions/moveOrCopyActionUtils.ts
  75. 8
      apps/files/src/actions/openFolderAction.spec.ts
  76. 6
      apps/files/src/actions/openFolderAction.ts
  77. 9
      apps/files/src/actions/openInFilesAction.spec.ts
  78. 4
      apps/files/src/actions/openInFilesAction.ts
  79. 11
      apps/files/src/actions/openLocallyAction.spec.ts
  80. 23
      apps/files/src/actions/openLocallyAction.ts
  81. 12
      apps/files/src/actions/renameAction.spec.ts
  82. 12
      apps/files/src/actions/renameAction.ts
  83. 25
      apps/files/src/actions/sidebarAction.spec.ts
  84. 6
      apps/files/src/actions/sidebarAction.ts
  85. 10
      apps/files/src/actions/viewInFolderAction.spec.ts
  86. 5
      apps/files/src/actions/viewInFolderAction.ts
  87. 32
      apps/files/src/components/BreadCrumbs.vue
  88. 6
      apps/files/src/components/CustomElementRender.vue
  89. 22
      apps/files/src/components/DragAndDropNotice.vue
  90. 11
      apps/files/src/components/DragAndDropPreview.vue
  91. 58
      apps/files/src/components/FileEntry.vue
  92. 8
      apps/files/src/components/FileEntry/CollectivesIcon.vue
  93. 6
      apps/files/src/components/FileEntry/FavoriteIcon.vue
  94. 70
      apps/files/src/components/FileEntry/FileEntryActions.vue
  95. 24
      apps/files/src/components/FileEntry/FileEntryCheckbox.vue
  96. 35
      apps/files/src/components/FileEntry/FileEntryName.vue
  97. 61
      apps/files/src/components/FileEntry/FileEntryPreview.vue
  98. 31
      apps/files/src/components/FileEntryGrid.vue
  99. 22
      apps/files/src/components/FileEntryMixin.ts
  100. 10
      apps/files/src/components/FileListFilter/FileListFilter.vue
  101. Some files were not shown because too many files have changed in this diff Show More

@ -1,14 +0,0 @@
# SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
# Ignoring folders for eslint
node_modules/
3rdparty/
**/vendor/
**/l10n/
**/js/*
*.config.js
tests/lib/
apps-extra
# TODO: remove when comments files is not using handlebar templates anymore
apps/comments/src/templates.js

@ -1,62 +0,0 @@
/**
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
module.exports = {
globals: {
__webpack_nonce__: true,
_: true,
$: true,
dayNames: true,
escapeHTML: true,
firstDay: true,
moment: true,
oc_userconfig: true,
sinon: true,
},
plugins: [
'cypress',
],
extends: [
'@nextcloud/eslint-config/typescript',
'plugin:cypress/recommended',
],
rules: {
'comma-dangle': 'error',
'no-tabs': 'warn',
// TODO: make sure we fix this as this is bad vue coding style.
// Use proper sync modifier
'vue/no-mutating-props': 'warn',
'vue/custom-event-name-casing': ['error', 'kebab-case', {
// allows custom xxxx:xxx events formats
ignores: ['/^[a-z]+(?:-[a-z]+)*:[a-z]+(?:-[a-z]+)*$/u'],
}],
'vue/html-self-closing': 'error',
'jsdoc/require-jsdoc': 'off',
'jsdoc/require-param-description': 'off',
},
settings: {
jsdoc: {
mode: 'typescript',
},
'import/resolver': {
typescript: {}, // this loads <rootdir>/tsconfig.json to eslint
},
},
overrides: [
// Allow any in tests
{
files: ['**/*.spec.ts'],
rules: {
'@typescript-eslint/no-explicit-any': 'warn',
},
},
{
files: ['*.vue'],
rules: {
'no-irregular-whitespace': 'off',
'vue/no-irregular-whitespace': 'error',
},
},
],
}

@ -400,7 +400,7 @@ SPDX-FileCopyrightText = "2019 Fabian Wiktor <https://www.pexels.com/photo/green
SPDX-License-Identifier = "CC0-1.0"
[[annotations]]
path = ["openapi.json", ".envrc", "flake.nix", "flake.lock"]
path = ["openapi.json", ".envrc", "flake.nix", "flake.lock", "build/eslint-baseline.json"]
precedence = "aggregate"
SPDX-FileCopyrightText = "2025 Nextcloud GmbH and Nextcloud contributors"
SPDX-License-Identifier = "AGPL-3.0-or-later"

@ -2,7 +2,8 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export const getCurrentUser = function() {
export function getCurrentUser() {
return {
uid: 'test',
displayName: 'Test',
@ -10,8 +11,8 @@ export const getCurrentUser = function() {
}
}
export const getRequestToken = function() {
export function getRequestToken() {
return 'test-token-1234'
}
export const onRequestTokenUpdate = function() {}
export function onRequestTokenUpdate() {}

@ -2,9 +2,10 @@
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Capabilities } from '../../apps/files/src/types'
export const getCapabilities = (): Capabilities => {
import type { Capabilities } from '../../apps/files/src/types.ts'
export function getCapabilities(): Capabilities {
return {
files: {
bigfilechunking: true,

@ -3,6 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export const loadState = function(app: string, key: string, fallback?: any) {
export function loadState(app: string, key: string, fallback?: any) {
return fallback
}

@ -2,9 +2,10 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export const createClient = () => {}
export const getPatcher = () => {
export function createClient() {}
export function getPatcher() {
return {
patch: () => {}
patch: () => {},
}
}

@ -2,11 +2,11 @@
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { basename } from 'node:path'
import mime from 'mime'
import { basename } from 'node:path'
class FileSystemEntry {
private _isFile: boolean
private _fullPath: string
@ -26,11 +26,9 @@ class FileSystemEntry {
get name() {
return basename(this._fullPath)
}
}
export class FileSystemFileEntry extends FileSystemEntry {
private _contents: string
private _lastModified: number
@ -46,11 +44,9 @@ export class FileSystemFileEntry extends FileSystemEntry {
const type = mime.getType(this.name) || ''
success(new File([this._contents], this.name, { lastModified, type }))
}
}
export class FileSystemDirectoryEntry extends FileSystemEntry {
private _entries: FileSystemEntry[]
constructor(fullPath: string, entries: FileSystemEntry[]) {
@ -70,7 +66,6 @@ export class FileSystemDirectoryEntry extends FileSystemEntry {
},
}
}
}
/**
@ -79,7 +74,6 @@ export class FileSystemDirectoryEntry extends FileSystemEntry {
* File API in the same test suite.
*/
export class DataTransferItem {
private _type: string
private _entry: FileSystemEntry
@ -104,7 +98,7 @@ export class DataTransferItem {
return this._type
}
getAsFile(): File|null {
getAsFile(): File | null {
if (this._entry.isFile && this._entry instanceof FileSystemFileEntry) {
let file: File | null = null
this._entry.file((f) => {
@ -116,10 +110,9 @@ export class DataTransferItem {
// The browser will return an empty File object if the entry is a directory
return new File([], this._entry.name, { type: '' })
}
}
export const fileSystemEntryToDataTransferItem = (entry: FileSystemEntry, isFileSystemAPIAvailable = true): DataTransferItem => {
export function fileSystemEntryToDataTransferItem(entry: FileSystemEntry, isFileSystemAPIAvailable = true): DataTransferItem {
return new DataTransferItem(
entry.isFile ? 'text/plain' : 'httpd/unix-directory',
entry,

@ -2,11 +2,13 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { File, Permission, View, FileAction } from '@nextcloud/files'
import { describe, expect, test, vi } from 'vitest'
import { action } from './inlineUnreadCommentsAction'
import logger from '../logger'
import type { View } from '@nextcloud/files'
import { File, FileAction, Permission } from '@nextcloud/files'
import { describe, expect, test, vi } from 'vitest'
import logger from '../logger.js'
import { action } from './inlineUnreadCommentsAction.ts'
const view = {
id: 'files',
@ -120,6 +122,7 @@ describe('Inline unread comments action execute tests', () => {
const setActiveTabMock = vi.fn()
window.OCA = {
Files: {
// @ts-expect-error Mocking for testing
Sidebar: {
open: openMock,
setActiveTab: setActiveTabMock,
@ -146,10 +149,13 @@ describe('Inline unread comments action execute tests', () => {
})
test('Action handles sidebar open failure', async () => {
const openMock = vi.fn(() => { throw new Error('Mock error') })
const openMock = vi.fn(() => {
throw new Error('Mock error')
})
const setActiveTabMock = vi.fn()
window.OCA = {
Files: {
// @ts-expect-error Mocking for testing
Sidebar: {
open: openMock,
setActiveTab: setActiveTabMock,

@ -2,11 +2,13 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { FileAction, Node } from '@nextcloud/files'
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
import CommentProcessingSvg from '@mdi/svg/svg/comment-processing.svg?raw'
import logger from '../logger'
import type { Node } from '@nextcloud/files'
import CommentProcessingSvg from '@mdi/svg/svg/comment-processing.svg?raw'
import { FileAction } from '@nextcloud/files'
import { n, t } from '@nextcloud/l10n'
import logger from '../logger.js'
export const action = new FileAction({
id: 'comments-unread',
@ -25,7 +27,7 @@ export const action = new FileAction({
iconSvgInline: () => CommentProcessingSvg,
enabled(nodes: Node[]) {
const unread = nodes[0].attributes['comments-unread'] as number|undefined
const unread = nodes[0].attributes['comments-unread'] as number | undefined
return typeof unread === 'number' && unread > 0
},

@ -2,13 +2,13 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import moment from '@nextcloud/moment'
import { createPinia, PiniaVuePlugin } from 'pinia'
import Vue, { type ComponentPublicInstance } from 'vue'
import logger from './logger.js'
import { getComments } from './services/GetComments.js'
import { PiniaVuePlugin, createPinia } from 'pinia'
Vue.use(PiniaVuePlugin)
let ActivityTabPluginView

@ -3,6 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import logger from './logger.js'
import CommentsInstance from './services/CommentsInstance.js'
// Init Comments
@ -12,4 +13,4 @@ if (window.OCA && !window.OCA.Comments) {
// Init Comments App view
Object.assign(window.OCA.Comments, { View: CommentsInstance })
console.debug('OCA.Comments.View initialized')
logger.debug('OCA.Comments.View initialized')

@ -3,7 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
// eslint-disable-next-line n/no-missing-import, import/no-unresolved
import MessageReplyText from '@mdi/svg/svg/message-reply-text.svg?raw'
import { getCSPNonce } from '@nextcloud/auth'
import { loadState } from '@nextcloud/initial-state'

@ -3,14 +3,16 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<component :is="tag"
<component
:is="tag"
v-show="!deleted && !isLimbo"
:class="{'comment--loading': loading}"
:class="{ 'comment--loading': loading }"
class="comment">
<!-- Comment header toolbar -->
<div class="comment__side">
<!-- Author -->
<NcAvatar class="comment__avatar"
<NcAvatar
class="comment__avatar"
:display-name="actorDisplayName"
:user="actorId"
:size="32" />
@ -23,7 +25,8 @@
show if we have a message id and current user is author -->
<NcActions v-if="isOwnComment && id && !loading" class="comment__actions">
<template v-if="!editing">
<NcActionButton close-after-click
<NcActionButton
close-after-click
@click="onEdit">
<template #icon>
<IconPencilOutline :size="20" />
@ -31,7 +34,8 @@
{{ t('comments', 'Edit comment') }}
</NcActionButton>
<NcActionSeparator />
<NcActionButton close-after-click
<NcActionButton
close-after-click
@click="onDeleteWithUndo">
<template #icon>
<IconTrashCanOutline :size="20" />
@ -52,7 +56,8 @@
<div v-if="id && loading" class="comment_loading icon-loading-small" />
<!-- Relative time to the comment creation -->
<NcDateTime v-else-if="creationDateTime"
<NcDateTime
v-else-if="creationDateTime"
class="comment__timestamp"
:timestamp="timestamp"
:ignore-seconds="true" />
@ -61,19 +66,21 @@
<!-- Message editor -->
<form v-if="editor || editing" class="comment__editor" @submit.prevent>
<div class="comment__editor-group">
<NcRichContenteditable ref="editor"
<NcRichContenteditable
ref="editor"
:auto-complete="autoComplete"
:contenteditable="!loading"
:label="editor ? t('comments', 'New comment') : t('comments', 'Edit comment')"
:placeholder="t('comments', 'Write a comment …')"
:placeholder="t('comments', 'Write a comment …')"
:value="localMessage"
:user-data="userData"
aria-describedby="tab-comments__editor-description"
@update:value="updateLocalMessage"
@submit="onSubmit" />
<div class="comment__submit">
<NcButton type="tertiary-no-background"
native-type="submit"
<NcButton
variant="tertiary-no-background"
type="submit"
:aria-label="t('comments', 'Post comment')"
:disabled="isEmptyMessage"
@click="onSubmit">
@ -90,9 +97,10 @@
</form>
<!-- Message content -->
<NcRichText v-else
<NcRichText
v-else
class="comment__message"
:class="{'comment__message--expanded': expanded}"
:class="{ 'comment__message--expanded': expanded }"
:text="richContent.message"
:arguments="richContent.mentions"
@click.native="onExpand" />
@ -103,7 +111,7 @@
<script>
import { getCurrentUser } from '@nextcloud/auth'
import { translate as t } from '@nextcloud/l10n'
import { mapStores } from 'pinia'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator'
@ -112,14 +120,11 @@ import NcButton from '@nextcloud/vue/components/NcButton'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcUserBubble from '@nextcloud/vue/components/NcUserBubble'
import IconArrowRight from 'vue-material-design-icons/ArrowRight.vue'
import IconClose from 'vue-material-design-icons/Close.vue'
import IconTrashCanOutline from 'vue-material-design-icons/TrashCanOutline.vue'
import IconPencilOutline from 'vue-material-design-icons/PencilOutline.vue'
import IconTrashCanOutline from 'vue-material-design-icons/TrashCanOutline.vue'
import CommentMixin from '../mixins/CommentMixin.js'
import { mapStores } from 'pinia'
import { useDeletedCommentLimbo } from '../store/deletedCommentLimbo.js'
// Dynamic loading
@ -127,6 +132,7 @@ const NcRichContenteditable = () => import('@nextcloud/vue/components/NcRichCont
const NcRichText = () => import('@nextcloud/vue/components/NcRichText')
export default {
/* eslint vue/multi-word-component-names: "warn" */
name: 'Comment',
components: {
@ -144,6 +150,7 @@ export default {
NcRichContenteditable,
NcRichText,
},
mixins: [CommentMixin],
inheritAttrs: false,
@ -153,10 +160,12 @@ export default {
type: String,
required: true,
},
actorId: {
type: String,
required: true,
},
creationDateTime: {
type: String,
default: null,
@ -177,6 +186,7 @@ export default {
type: Function,
required: true,
},
userData: {
type: Object,
default: () => ({}),

@ -2,7 +2,8 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { registerFileAction } from '@nextcloud/files'
import { action } from './actions/inlineUnreadCommentsAction'
import { action } from './actions/inlineUnreadCommentsAction.ts'
registerFileAction(action)

@ -4,12 +4,12 @@
*/
import { showError, showUndo, TOAST_UNDO_TIMEOUT } from '@nextcloud/dialogs'
import NewComment from '../services/NewComment.js'
import { mapStores } from 'pinia'
import logger from '../logger.js'
import DeleteComment from '../services/DeleteComment.js'
import EditComment from '../services/EditComment.js'
import { mapStores } from 'pinia'
import NewComment from '../services/NewComment.js'
import { useDeletedCommentLimbo } from '../store/deletedCommentLimbo.js'
import logger from '../logger.js'
export default {
props: {
@ -62,7 +62,7 @@ export default {
this.editing = false
} catch (error) {
showError(t('comments', 'An error occurred while trying to edit the comment'))
console.error(error)
logger.error('An error occurred while trying to edit the comment', { error })
} finally {
this.loading = false
}
@ -87,7 +87,7 @@ export default {
this.$emit('delete', this.id)
} catch (error) {
showError(t('comments', 'An error occurred while trying to delete the comment'))
console.error(error)
logger.error('An error occurred while trying to delete the comment', { error })
this.deleted = false
this.deletedCommentLimboStore.removeId(this.id)
}
@ -106,7 +106,7 @@ export default {
this.localMessage = ''
} catch (error) {
showError(t('comments', 'An error occurred while trying to create the comment'))
console.error(error)
logger.error('An error occurred while trying to create the comment', { error })
} finally {
this.loading = false
}

@ -1,9 +1,9 @@
import { getCurrentUser } from '@nextcloud/auth'
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import axios from '@nextcloud/axios'
import { getCurrentUser } from '@nextcloud/auth'
import { loadState } from '@nextcloud/initial-state'
import { generateOcsUrl } from '@nextcloud/router'
import { defineComponent } from 'vue'
@ -33,8 +33,8 @@ export default defineComponent({
/**
* Autocomplete @mentions
*
* @param {string} search the query
* @param {Function} callback the callback to process the results with
* @param search the query
* @param callback the callback to process the results with
*/
async autoComplete(search, callback) {
const { data } = await axios.get(generateOcsUrl('core/autocomplete/get'), {
@ -47,7 +47,9 @@ export default defineComponent({
},
})
// Save user data so it can be used by the editor to replace mentions
data.ocs.data.forEach(user => { this.userData[user.id] = user })
data.ocs.data.forEach((user) => {
this.userData[user.id] = user
})
return callback(Object.values(this.userData))
},
@ -60,7 +62,7 @@ export default defineComponent({
genMentionsData(mentions: any[]): Record<string, object> {
Object.values(mentions)
.flat()
.forEach(mention => {
.forEach((mention) => {
this.userData[mention.mentionId] = {
// TODO: support groups
icon: 'icon-user',

@ -4,14 +4,14 @@
*/
import { getCSPNonce } from '@nextcloud/auth'
import { t, n } from '@nextcloud/l10n'
import { PiniaVuePlugin, createPinia } from 'pinia'
import { n, t } from '@nextcloud/l10n'
import { createPinia, PiniaVuePlugin } from 'pinia'
import Vue from 'vue'
import CommentsApp from '../views/Comments.vue'
import logger from '../logger.js'
Vue.use(PiniaVuePlugin)
// eslint-disable-next-line camelcase
__webpack_nonce__ = getCSPNonce()
// Add translates functions
@ -28,7 +28,6 @@ Vue.mixin({
})
export default class CommentInstance {
/**
* Initialize a new Comments instance for the desired type
*
@ -51,5 +50,4 @@ export default class CommentInstance {
const View = Vue.extend(CommentsApp)
return new View(options)
}
}

@ -3,15 +3,18 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { getRequestToken, onRequestTokenUpdate } from '@nextcloud/auth'
import { createClient } from 'webdav'
import { getRootPath } from '../utils/davUtils.js'
import { getRequestToken, onRequestTokenUpdate } from '@nextcloud/auth'
// init webdav client
const client = createClient(getRootPath())
// set CSRF token header
const setHeaders = (token) => {
/**
* @param token
*/
function setHeaders(token) {
client.setHeaders({
// Add this so the server knows it is an request from the browser
'X-Requested-With': 'XMLHttpRequest',

@ -16,7 +16,7 @@ import client from './DavClient.js'
export default async function(resourceType, resourceId, commentId, message) {
const commentPath = ['', resourceType, resourceId, commentId].join('/')
return await client.customRequest(commentPath, Object.assign({
return await client.customRequest(commentPath, {
method: 'PROPPATCH',
data: `<?xml version="1.0"?>
<d:propertyupdate
@ -28,5 +28,5 @@ export default async function(resourceType, resourceId, commentId, message) {
</d:prop>
</d:set>
</d:propertyupdate>`,
}))
})
}

@ -3,9 +3,9 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { parseXML, type DAVResult, type FileStat, type ResponseDataDetailed } from 'webdav'
import type { DAVResult, FileStat, ResponseDataDetailed } from 'webdav'
// https://github.com/perry-mitchell/webdav-client/issues/339
import { parseXML } from 'webdav'
import { processResponsePayload } from 'webdav/dist/node/response.js'
import { prepareFileFromProps } from 'webdav/dist/node/tools/dav.js'
import client from './DavClient.js'
@ -15,19 +15,19 @@ export const DEFAULT_LIMIT = 20
/**
* Retrieve the comments list
*
* @param {object} data destructuring object
* @param {string} data.resourceType the resource type
* @param {number} data.resourceId the resource ID
* @param {object} [options] optional options for axios
* @param {number} [options.offset] the pagination offset
* @param {number} [options.limit] the pagination limit, defaults to 20
* @param {Date} [options.datetime] optional date to query
* @return {{data: object[]}} the comments list
* @param data destructuring object
* @param data.resourceType the resource type
* @param data.resourceId the resource ID
* @param [options] optional options for axios
* @param [options.offset] the pagination offset
* @param [options.limit] the pagination limit, defaults to 20
* @param [options.datetime] optional date to query
* @return the comments list
*/
export const getComments = async function({ resourceType, resourceId }, options: { offset: number, limit?: number, datetime?: Date }) {
export async function getComments({ resourceType, resourceId }, options: { offset: number, limit?: number, datetime?: Date }) {
const resourcePath = ['', resourceType, resourceId].join('/')
const datetime = options.datetime ? `<oc:datetime>${options.datetime.toISOString()}</oc:datetime>` : ''
const response = await client.customRequest(resourcePath, Object.assign({
const response = await client.customRequest(resourcePath, {
method: 'REPORT',
data: `<?xml version="1.0"?>
<oc:filter-comments
@ -39,16 +39,23 @@ export const getComments = async function({ resourceType, resourceId }, options:
<oc:offset>${options.offset || 0}</oc:offset>
${datetime}
</oc:filter-comments>`,
}, options))
...options,
})
const responseData = await response.text()
const result = await parseXML(responseData)
const stat = getDirectoryFiles(result, true)
// https://github.com/perry-mitchell/webdav-client/issues/339
return processResponsePayload(response, stat, true) as ResponseDataDetailed<FileStat[]>
}
// https://github.com/perry-mitchell/webdav-client/blob/8d9694613c978ce7404e26a401c39a41f125f87f/source/operations/directoryContents.ts
const getDirectoryFiles = function(
/**
* https://github.com/perry-mitchell/webdav-client/blob/8d9694613c978ce7404e26a401c39a41f125f87f/source/operations/directoryContents.ts
*
* @param result
* @param isDetailed
*/
function getDirectoryFiles(
result: DAVResult,
isDetailed = false,
): Array<FileStat> {
@ -58,7 +65,7 @@ const getDirectoryFiles = function(
} = result
// Map all items to a consistent output structure (results)
return responseItems.map(item => {
return responseItems.map((item) => {
// Each item should contain a stat object
const props = item.propstat!.prop!

@ -4,9 +4,9 @@
*/
import { getCurrentUser } from '@nextcloud/auth'
import axios from '@nextcloud/axios'
import { getRootPath } from '../utils/davUtils.js'
import { decodeHtmlEntities } from '../utils/decodeHtmlEntities.js'
import axios from '@nextcloud/axios'
import client from './DavClient.js'
/**

@ -3,10 +3,10 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import client from './DavClient.js'
import type { Response } from 'webdav'
import client from './DavClient.js'
/**
* Mark comments older than the date timestamp as read
*
@ -14,11 +14,11 @@ import type { Response } from 'webdav'
* @param resourceId the resource ID
* @param date the date object
*/
export const markCommentsAsRead = (
export function markCommentsAsRead(
resourceType: string,
resourceId: number,
date: Date,
): Promise<Response> => {
): Promise<Response> {
const resourcePath = ['', resourceType, resourceId].join('/')
const readMarker = date.toUTCString()

@ -9,7 +9,7 @@
* @param {Function} request the axios promise request
* @return {object}
*/
const cancelableRequest = function(request) {
function cancelableRequest(request) {
const controller = new AbortController()
const signal = controller.signal
@ -22,7 +22,7 @@ const cancelableRequest = function(request) {
const fetch = async function(url, options) {
const response = await request(
url,
Object.assign({ signal }, options),
{ signal, ...options },
)
return response
}

@ -5,7 +5,10 @@
import { generateRemoteUrl } from '@nextcloud/router'
const getRootPath = function() {
/**
*
*/
function getRootPath() {
return generateRemoteUrl('dav/comments')
}

@ -4,7 +4,8 @@
-->
<template>
<Comment v-bind="editorData"
<Comment
v-bind="editorData"
:auto-complete="autoComplete"
:resource-type="resourceType"
:editor="true"
@ -15,17 +16,18 @@
</template>
<script lang="ts">
import { showError } from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'
import { defineComponent } from 'vue'
import Comment from '../components/Comment.vue'
import logger from '../logger.js'
import CommentView from '../mixins/CommentView.js'
import logger from '../logger'
import { showError } from '@nextcloud/dialogs'
import { translate as t } from '@nextcloud/l10n'
export default defineComponent({
components: {
Comment,
},
mixins: [CommentView],
props: {
reloadCallback: {
@ -33,14 +35,15 @@ export default defineComponent({
required: true,
},
},
methods: {
onNewComment() {
try {
// just force reload
this.reloadCallback()
} catch (e) {
} catch (error) {
showError(t('comments', 'Could not reload comments'))
logger.debug(e)
logger.error('Could not reload comments', { error })
}
},
},

@ -4,7 +4,8 @@
-->
<template>
<Comment ref="comment"
<Comment
ref="comment"
tag="li"
v-bind="comment.props"
:auto-complete="autoComplete"
@ -18,10 +19,10 @@
<script lang="ts">
import type { PropType } from 'vue'
import { translate as t } from '@nextcloud/l10n'
import { t } from '@nextcloud/l10n'
import Comment from '../components/Comment.vue'
import CommentView from '../mixins/CommentView'
import CommentView from '../mixins/CommentView.ts'
export default {
name: 'ActivityCommentEntry',
@ -36,6 +37,7 @@ export default {
type: Object,
required: true,
},
reloadCallback: {
type: Function as PropType<() => void>,
required: true,

@ -4,11 +4,13 @@
-->
<template>
<div v-element-visibility="onVisibilityChange"
<div
v-element-visibility="onVisibilityChange"
class="comments"
:class="{ 'icon-loading': isFirstLoading }">
<!-- Editor -->
<Comment v-bind="editorData"
<Comment
v-bind="editorData"
:auto-complete="autoComplete"
:resource-type="resourceType"
:editor="true"
@ -18,7 +20,8 @@
@new="onNewComment" />
<template v-if="!isFirstLoading">
<NcEmptyContent v-if="!hasComments && done"
<NcEmptyContent
v-if="!hasComments && done"
class="comments__empty"
:name="t('comments', 'No comments yet, start the conversation!')">
<template #icon>
@ -27,7 +30,8 @@
</NcEmptyContent>
<ul v-else>
<!-- Comments -->
<Comment v-for="comment in comments"
<Comment
v-for="comment in comments"
:key="comment.props.id"
tag="li"
v-bind="comment.props"
@ -69,20 +73,20 @@
import { showError } from '@nextcloud/dialogs'
import { translate as t } from '@nextcloud/l10n'
import { vElementVisibility as elementVisibility } from '@vueuse/components'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcButton from '@nextcloud/vue/components/NcButton'
import IconRefresh from 'vue-material-design-icons/Refresh.vue'
import IconMessageReplyTextOutline from 'vue-material-design-icons/MessageReplyTextOutline.vue'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import IconAlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue'
import IconMessageReplyTextOutline from 'vue-material-design-icons/MessageReplyTextOutline.vue'
import IconRefresh from 'vue-material-design-icons/Refresh.vue'
import Comment from '../components/Comment.vue'
import CommentView from '../mixins/CommentView'
import cancelableRequest from '../utils/cancelableRequest.js'
import { getComments, DEFAULT_LIMIT } from '../services/GetComments.ts'
import logger from '../logger.js'
import CommentView from '../mixins/CommentView.ts'
import { DEFAULT_LIMIT, getComments } from '../services/GetComments.ts'
import { markCommentsAsRead } from '../services/ReadComments.ts'
import cancelableRequest from '../utils/cancelableRequest.js'
export default {
/* eslint vue/multi-word-component-names: "warn" */
name: 'Comments',
components: {
@ -121,6 +125,7 @@ export default {
hasComments() {
return this.comments.length > 0
},
isFirstLoading() {
return this.loading && this.offset === 0
},
@ -211,7 +216,7 @@ export default {
return
}
this.error = t('comments', 'Unable to load the comments list')
console.error('Error loading the comments list', error)
logger.error('Error loading the comments list', { error })
} finally {
this.loading = false
}
@ -232,11 +237,11 @@ export default {
* @param {number} id the deleted comment
*/
onDelete(id) {
const index = this.comments.findIndex(comment => comment.props.id === id)
const index = this.comments.findIndex((comment) => comment.props.id === id)
if (index > -1) {
this.comments.splice(index, 1)
} else {
console.error('Could not find the deleted comment in the list', id)
logger.error('Could not find the deleted comment in the list', { id })
}
},

@ -6,20 +6,23 @@
<main id="app-dashboard">
<h2>{{ greeting.text }}</h2>
<ul class="statuses">
<li v-for="status in sortedRegisteredStatus"
<li
v-for="status in sortedRegisteredStatus"
:id="'status-' + status"
:key="status">
<div :ref="'status-' + status" />
</li>
</ul>
<Draggable v-model="layout"
<Draggable
v-model="layout"
class="panels"
v-bind="{swapThreshold: 0.30, delay: 500, delayOnTouchOnly: true, touchStartThreshold: 3}"
v-bind="{ swapThreshold: 0.30, delay: 500, delayOnTouchOnly: true, touchStartThreshold: 3 }"
handle=".panel--header"
@end="saveLayout">
<template v-for="panelId in layout">
<div v-if="isApiWidgetV2(panels[panelId].id)"
<div
v-if="isApiWidgetV2(panels[panelId].id)"
:key="`${panels[panelId].id}-v2`"
class="panel">
<div class="panel--header">
@ -30,7 +33,8 @@
</h2>
</div>
<div class="panel--content">
<ApiDashboardWidget :widget="apiWidgets[panels[panelId].id]"
<ApiDashboardWidget
:widget="apiWidgets[panels[panelId].id]"
:data="apiWidgetItems[panels[panelId].id]"
:loading="loadingItems" />
</div>
@ -63,7 +67,8 @@
<h2>{{ t('dashboard', 'Edit widgets') }}</h2>
<ol class="panels">
<li v-for="status in sortedAllStatuses" :key="status" :class="'panel-' + status">
<input :id="'status-checkbox-' + status"
<input
:id="'status-checkbox-' + status"
type="checkbox"
class="checkbox"
:checked="isStatusActive(status)"
@ -75,14 +80,16 @@
</label>
</li>
</ol>
<Draggable v-model="layout"
<Draggable
v-model="layout"
class="panels"
tag="ol"
v-bind="{swapThreshold: 0.30, delay: 500, delayOnTouchOnly: true, touchStartThreshold: 3}"
v-bind="{ swapThreshold: 0.30, delay: 500, delayOnTouchOnly: true, touchStartThreshold: 3 }"
handle=".draggable"
@end="saveLayout">
<li v-for="panel in sortedPanels" :key="panel.id" :class="'panel-' + panel.id">
<input :id="'panel-checkbox-' + panel.id"
<input
:id="'panel-checkbox-' + panel.id"
type="checkbox"
class="checkbox"
:checked="isActive(panel)"
@ -114,19 +121,19 @@
</template>
<script>
import { generateUrl, generateOcsUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth'
import { loadState } from '@nextcloud/initial-state'
import axios from '@nextcloud/axios'
import NcButton from '@nextcloud/vue/components/NcButton'
import { loadState } from '@nextcloud/initial-state'
import { generateOcsUrl, generateUrl } from '@nextcloud/router'
import Vue from 'vue'
import Draggable from 'vuedraggable'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcModal from '@nextcloud/vue/components/NcModal'
import NcUserStatusIcon from '@nextcloud/vue/components/NcUserStatusIcon'
import Pencil from 'vue-material-design-icons/Pencil.vue'
import Vue from 'vue'
import isMobile from './mixins/isMobile.js'
import ApiDashboardWidget from './components/ApiDashboardWidget.vue'
import { logger } from './logger.ts'
import isMobile from './mixins/isMobile.js'
const panels = loadState('dashboard', 'panels')
const firstRun = loadState('dashboard', 'firstRun')
@ -152,6 +159,7 @@ export default {
Pencil,
NcUserStatusIcon,
},
mixins: [
isMobile,
],
@ -187,6 +195,7 @@ export default {
birthdate,
}
},
computed: {
greeting() {
const time = this.timer.getHours()
@ -214,19 +223,23 @@ export default {
generic: t('dashboard', 'Good morning'),
withName: t('dashboard', 'Good morning, {name}', { name: this.displayName }, undefined, { escape: false }),
},
afternoon: {
generic: t('dashboard', 'Good afternoon'),
withName: t('dashboard', 'Good afternoon, {name}', { name: this.displayName }, undefined, { escape: false }),
},
evening: {
generic: t('dashboard', 'Good evening'),
withName: t('dashboard', 'Good evening, {name}', { name: this.displayName }, undefined, { escape: false }),
},
night: {
// Don't use "Good night" as it's not a greeting
generic: t('dashboard', 'Hello'),
withName: t('dashboard', 'Hello, {name}', { name: this.displayName }, undefined, { escape: false }),
},
birthday: {
generic: t('dashboard', 'Happy birthday 🥳🤩🎂🎉'),
withName: t('dashboard', 'Happy birthday, {name} 🥳🤩🎂🎉', { name: this.displayName }, undefined, { escape: false }),
@ -241,6 +254,7 @@ export default {
isActive() {
return (panel) => this.layout.indexOf(panel.id) > -1
},
isStatusActive() {
return (status) => this.enabledStatuses.findIndex((s) => s === status) !== -1
},
@ -248,6 +262,7 @@ export default {
sortedAllStatuses() {
return Object.keys(this.allCallbacksStatus).slice().sort(this.sortStatuses)
},
sortedPanels() {
return Object.values(this.panels).sort((a, b) => {
const indexA = this.layout.indexOf(a.id)
@ -258,6 +273,7 @@ export default {
return indexA - indexB || a.id - b.id
})
},
sortedRegisteredStatus() {
return this.registeredStatus.slice().sort(this.sortStatuses)
},
@ -267,6 +283,7 @@ export default {
callbacks() {
this.rerenderPanels()
},
callbacksStatus() {
for (const app in this.callbacksStatus) {
const element = this.$refs['status-' + app]
@ -277,7 +294,7 @@ export default {
this.callbacksStatus[app](element[0])
Vue.set(this.statuses, app, { mounted: true })
} else {
console.error('Failed to register panel in the frontend as no backend data was provided for ' + app)
logger.error('Failed to register panel in the frontend as no backend data was provided for ' + app)
}
}
},
@ -288,9 +305,9 @@ export default {
const apiWidgetIdsToFetch = Object
.values(this.apiWidgets)
.filter(widget => this.isApiWidgetV2(widget.id) && this.layout.includes(widget.id))
.map(widget => widget.id)
await Promise.all(apiWidgetIdsToFetch.map(id => this.fetchApiWidgetItems([id], true)))
.filter((widget) => this.isApiWidgetV2(widget.id) && this.layout.includes(widget.id))
.map((widget) => widget.id)
await Promise.all(apiWidgetIdsToFetch.map((id) => this.fetchApiWidgetItems([id], true)))
for (const widget of Object.values(this.apiWidgets)) {
if (widget.reload_interval > 0) {
@ -304,6 +321,7 @@ export default {
}
}
},
mounted() {
this.updateSkipLink()
window.addEventListener('scroll', this.handleScroll)
@ -316,6 +334,7 @@ export default {
window.addEventListener('scroll', this.disableFirstrunHint)
}
},
destroyed() {
window.removeEventListener('scroll', this.handleScroll)
},
@ -330,6 +349,7 @@ export default {
register(app, callback) {
Vue.set(this.callbacks, app, callback)
},
registerStatus(app, callback) {
// always save callbacks in case user enables the status later
Vue.set(this.allCallbacksStatus, app, callback)
@ -341,6 +361,7 @@ export default {
})
}
},
rerenderPanels() {
for (const app in this.callbacks) {
// TODO: Properly rerender v2 widgets
@ -361,27 +382,32 @@ export default {
})
Vue.set(this.panels[app], 'mounted', true)
} else {
console.error('Failed to register panel in the frontend as no backend data was provided for ' + app)
logger.error('Failed to register panel in the frontend as no backend data was provided for ' + app)
}
}
},
saveLayout() {
axios.post(generateOcsUrl('/apps/dashboard/api/v3/layout'), {
layout: this.layout,
})
},
saveStatuses() {
axios.post(generateOcsUrl('/apps/dashboard/api/v3/statuses'), {
statuses: this.enabledStatuses,
})
},
showModal() {
this.modal = true
this.firstRun = false
},
closeModal() {
this.modal = false
},
updateCheckbox(panel, currentValue) {
const index = this.layout.indexOf(panel.id)
if (!currentValue && index > -1) {
@ -396,16 +422,19 @@ export default {
this.saveLayout()
this.$nextTick(() => this.rerenderPanels())
},
disableFirstrunHint() {
window.removeEventListener('scroll', this.disableFirstrunHint)
setTimeout(() => {
this.firstRun = false
}, 1000)
},
updateSkipLink() {
// Make sure "Skip to main content" link points to the app content
document.getElementsByClassName('skip-navigation')[0].setAttribute('href', '#app-dashboard')
},
updateStatusCheckbox(app, checked) {
if (checked) {
this.enableStatus(app)
@ -413,11 +442,13 @@ export default {
this.disableStatus(app)
}
},
enableStatus(app) {
this.enabledStatuses.push(app)
this.registerStatus(app, this.allCallbacksStatus[app])
this.saveStatuses()
},
disableStatus(app) {
const i = this.enabledStatuses.findIndex((s) => s === app)
if (i !== -1) {
@ -433,6 +464,7 @@ export default {
}
this.saveStatuses()
},
sortStatuses(a, b) {
const al = a.toLowerCase()
const bl = b.toLowerCase()
@ -442,6 +474,7 @@ export default {
? -1
: 0
},
handleScroll() {
if (window.scrollY > 70) {
document.body.classList.add('dashboard--scrolled')
@ -449,18 +482,20 @@ export default {
document.body.classList.remove('dashboard--scrolled')
}
},
async fetchApiWidgets() {
const { data } = await axios.get(generateOcsUrl('/apps/dashboard/api/v1/widgets'))
this.apiWidgets = data.ocs.data
},
async fetchApiWidgetItems(widgetIds, merge = false) {
try {
const url = generateOcsUrl('/apps/dashboard/api/v2/widget-items')
const params = new URLSearchParams(widgetIds.map(id => ['widgets[]', id]))
const params = new URLSearchParams(widgetIds.map((id) => ['widgets[]', id]))
const response = await axios.get(`${url}?${params.toString()}`)
const widgetItems = response.data.ocs.data
if (merge) {
this.apiWidgetItems = Object.assign({}, this.apiWidgetItems, widgetItems)
this.apiWidgetItems = { ...this.apiWidgetItems, ...widgetItems }
} else {
this.apiWidgetItems = widgetItems
}
@ -468,6 +503,7 @@ export default {
this.loadingItems = false
}
},
isApiWidgetV2(id) {
for (const widget of Object.values(this.apiWidgets)) {
if (widget.id === id && widget.item_api_versions.includes(2)) {
@ -752,6 +788,7 @@ export default {
}
}
</style>
<style>
html, body {
background-attachment: fixed;

@ -3,7 +3,8 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcDashboardWidget :items="items"
<NcDashboardWidget
:items="items"
:show-more-label="showMoreLabel"
:show-more-url="showMoreUrl"
:loading="loading"
@ -13,7 +14,8 @@
<ApiDashboardWidgetItem :item="item" :icon-size="iconSize" :rounded-icons="widget.item_icons_round" />
</template>
<template #empty-content>
<NcEmptyContent v-if="items.length === 0"
<NcEmptyContent
v-if="items.length === 0"
:description="emptyContentMessage">
<template #icon>
<CheckIcon v-if="emptyContentMessage" :size="65" />
@ -44,25 +46,30 @@ export default {
NcEmptyContent,
NcButton,
},
props: {
widget: {
type: [Object, undefined],
default: undefined,
},
data: {
type: [Object, undefined],
default: undefined,
},
loading: {
type: Boolean,
required: true,
},
},
data() {
return {
iconSize: 44,
}
},
computed: {
/** @return {object[]} */
items() {
@ -84,17 +91,17 @@ export default {
// TODO: Render new button in the template
// I couldn't find a widget that makes use of the button. Furthermore, there is no convenient
// way to render such a button using the official widget component.
return this.widget?.buttons?.find(button => button.type === 'new')
return this.widget?.buttons?.find((button) => button.type === 'new')
},
/** @return {object|undefined} */
moreButton() {
return this.widget?.buttons?.find(button => button.type === 'more')
return this.widget?.buttons?.find((button) => button.type === 'more')
},
/** @return {object|undefined} */
setupButton() {
return this.widget?.buttons?.find(button => button.type === 'setup')
return this.widget?.buttons?.find((button) => button.type === 'setup')
},
/** @return {string|undefined} */
@ -107,6 +114,7 @@ export default {
return this.moreButton?.link
},
},
mounted() {
const size = window.getComputedStyle(document.body).getPropertyValue('--default-clickable-area')
const numeric = Number.parseFloat(size)

@ -34,25 +34,29 @@ const loadingImageFailed = ref(false)
</script>
<template>
<NcDashboardWidgetItem :target-url="item.link"
<NcDashboardWidgetItem
:target-url="item.link"
:overlay-icon-url="item.overlayIconUrl ? item.overlayIconUrl : ''"
:main-text="item.title"
:sub-text="item.subtitle">
<template #avatar>
<template v-if="item.iconUrl">
<NcAvatar v-if="roundedIcons"
<NcAvatar
v-if="roundedIcons"
:size="iconSize"
:url="item.iconUrl" />
<template v-else>
<img v-show="!loadingImageFailed"
<img
v-show="!loadingImageFailed"
alt=""
class="api-dashboard-widget-item__icon"
:class="{'hidden-visually': !imageLoaded }"
:class="{ 'hidden-visually': !imageLoaded }"
:src="item.iconUrl"
@error="loadingImageFailed = true"
@load="imageLoaded = true">
<!-- Placeholder while the image is loaded and also the fallback if the URL is broken -->
<IconFile v-if="!imageLoaded"
<IconFile
v-if="!imageLoaded"
:size="iconSize" />
</template>
</template>

@ -0,0 +1,11 @@
/*!
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { getLoggerBuilder } from '@nextcloud/logger'
export const logger = getLoggerBuilder()
.detectLogLevel()
.setApp('dashboard')
.build()

@ -7,10 +7,8 @@ import { getCSPNonce } from '@nextcloud/auth'
import { t } from '@nextcloud/l10n'
import VTooltip from '@nextcloud/vue/directives/Tooltip'
import Vue from 'vue'
import DashboardApp from './DashboardApp.vue'
// eslint-disable-next-line camelcase
__webpack_nonce__ = getCSPNonce()
Vue.directive('Tooltip', VTooltip)

@ -6,42 +6,47 @@
<template>
<form class="absence" @submit.prevent="saveForm">
<div class="absence__dates">
<NcDateTimePickerNative id="absence-first-day"
<NcDateTimePickerNative
id="absence-first-day"
v-model="firstDay"
:label="$t('dav', 'First day')"
class="absence__dates__picker"
:required="true" />
<NcDateTimePickerNative id="absence-last-day"
<NcDateTimePickerNative
id="absence-last-day"
v-model="lastDay"
:label="$t('dav', 'Last day (inclusive)')"
class="absence__dates__picker"
:required="true" />
</div>
<label for="replacement-search-input">{{ $t('dav', 'Out of office replacement (optional)') }}</label>
<NcSelect ref="select"
<NcSelect
ref="select"
v-model="replacementUser"
input-id="replacement-search-input"
:loading="searchLoading"
:placeholder="$t('dav', 'Name of the replacement')"
:clear-search-on-blur="() => false"
:user-select="true"
user-select
:options="options"
@search="asyncFind">
<template #no-options="{ search }">
{{ search ?$t('dav', 'No results.') : $t('dav', 'Start typing.') }}
{{ search ? $t('dav', 'No results.') : $t('dav', 'Start typing.') }}
</template>
</NcSelect>
<NcTextField :value.sync="status" :label="$t('dav', 'Short absence status')" :required="true" />
<NcTextArea :value.sync="message" :label="$t('dav', 'Long absence Message')" :required="true" />
<div class="absence__buttons">
<NcButton :disabled="loading || !valid"
type="primary"
native-type="submit">
<NcButton
:disabled="loading || !valid"
variant="primary"
type="submit">
{{ $t('dav', 'Save') }}
</NcButton>
<NcButton :disabled="loading || !valid"
type="error"
<NcButton
:disabled="loading || !valid"
variant="error"
@click="clearAbsence">
{{ $t('dav', 'Disable absence') }}
</NcButton>
@ -51,21 +56,21 @@
<script>
import { getCurrentUser } from '@nextcloud/auth'
import axios from '@nextcloud/axios'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { loadState } from '@nextcloud/initial-state'
import { generateOcsUrl } from '@nextcloud/router'
import { ShareType } from '@nextcloud/sharing'
import { formatDateAsYMD } from '../utils/date.js'
import axios from '@nextcloud/axios'
import debounce from 'debounce'
import logger from '../service/logger.js'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import NcTextArea from '@nextcloud/vue/components/NcTextArea'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcDateTimePickerNative from '@nextcloud/vue/components/NcDateTimePickerNative'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcTextArea from '@nextcloud/vue/components/NcTextArea'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import logger from '../service/logger.js'
import { formatDateAsYMD } from '../utils/date.js'
/* eslint @nextcloud/vue/no-deprecated-props: "warn" */
export default {
name: 'AbsenceForm',
components: {
@ -75,6 +80,7 @@ export default {
NcDateTimePickerNative,
NcSelect,
},
data() {
const { firstDay, lastDay, status, message, replacementUserId, replacementUserDisplayName } = loadState('dav', 'absence', {})
return {
@ -89,6 +95,7 @@ export default {
options: [],
}
},
computed: {
/**
* @return {boolean}
@ -107,6 +114,7 @@ export default {
&& lastDay >= firstDay
},
},
methods: {
resetForm() {
this.status = ''
@ -121,7 +129,7 @@ export default {
* @param {object} result select entry item
* @return {object}
*/
formatForMultiselect(result) {
formatForMultiselect(result) {
return {
user: result.uuid || result.value.shareWith,
displayName: result.name || result.label,
@ -133,13 +141,13 @@ export default {
this.searchLoading = true
await this.debounceGetSuggestions(query.trim())
},
/**
* Get suggestions
*
* @param {string} search the search query
*/
async getSuggestions(search) {
async getSuggestions(search) {
const shareType = [
ShareType.User,
]
@ -155,7 +163,7 @@ export default {
},
})
} catch (error) {
console.error('Error fetching suggestions', error)
logger.error('Error fetching suggestions', { error })
return
}
@ -164,13 +172,12 @@ export default {
data.exact = [] // removing exact from general results
const rawExactSuggestions = exact.users
const rawSuggestions = data.users
console.info('rawExactSuggestions', rawExactSuggestions)
console.info('rawSuggestions', rawSuggestions)
logger.info('AbsenceForm raw suggestions', { rawExactSuggestions, rawSuggestions })
// remove invalid data and format to user-select layout
const exactSuggestions = rawExactSuggestions
.map(share => this.formatForMultiselect(share))
.map((share) => this.formatForMultiselect(share))
const suggestions = rawSuggestions
.map(share => this.formatForMultiselect(share))
.map((share) => this.formatForMultiselect(share))
const allSuggestions = exactSuggestions.concat(suggestions)
@ -186,7 +193,7 @@ export default {
return nameCounts
}, {})
this.options = allSuggestions.map(item => {
this.options = allSuggestions.map((item) => {
// Make sure that items with duplicate displayName get the shareWith applied as a description
if (nameCounts[item.displayName] > 1 && !item.desc) {
return { ...item, desc: item.shareWithDisplayNameUnique }
@ -195,7 +202,7 @@ export default {
})
this.searchLoading = false
console.info('suggestions', this.options)
logger.info('AbsenseForm suggestions', { options: this.options })
},
/**
@ -203,7 +210,7 @@ export default {
*
* @param {...*} args the arguments
*/
debounceGetSuggestions: debounce(function(...args) {
debounceGetSuggestions: debounce(function(...args) {
this.getSuggestions(...args)
}, 300),
@ -229,6 +236,7 @@ export default {
this.loading = false
}
},
async clearAbsence() {
this.loading = true
try {

@ -4,7 +4,8 @@
-->
<template>
<div>
<CalendarAvailability :slots.sync="slots"
<CalendarAvailability
:slots.sync="slots"
:loading="loading"
:l10n-to="t('dav', 'to')"
:l10n-delete-slot="t('dav', 'Delete slot')"
@ -25,7 +26,8 @@
{{ t('dav', 'Automatically set user status to "Do not disturb" outside of availability to mute all notifications.') }}
</NcCheckboxRadioSwitch>
<NcButton :disabled="loading || saving"
<NcButton
:disabled="loading || saving"
variant="primary"
@click="save">
{{ t('dav', 'Save') }}
@ -35,26 +37,26 @@
<script setup lang="ts">
import { CalendarAvailability } from '@nextcloud/calendar-availability-vue'
import { loadState } from '@nextcloud/initial-state'
import { getCapabilities } from '@nextcloud/capabilities'
import {
showError,
showSuccess,
} from '@nextcloud/dialogs'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { onMounted, ref } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import {
findScheduleInboxAvailability,
getEmptySlots,
saveScheduleInboxAvailability,
} from '../service/CalendarService.js'
import logger from '../service/logger.js'
import {
enableUserStatusAutomation,
disableUserStatusAutomation,
enableUserStatusAutomation,
} from '../service/PreferenceService.js'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import { getCapabilities } from '@nextcloud/capabilities'
import { onMounted, ref } from 'vue'
import logger from '../service/logger.js'
import { t } from '@nextcloud/l10n'
// @ts-expect-error capabilities is missing the capability to type it...
const timezone = getCapabilities().core.user?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone
@ -95,9 +97,8 @@ async function save() {
}
showSuccess(t('dav', 'Saved availability'))
} catch (e) {
console.error('could not save availability', e)
} catch (error) {
logger.error('could not save availability', { error })
showError(t('dav', 'Failed to save availability'))
} finally {
saving.value = false

@ -5,7 +5,8 @@
<template>
<div class="example-contact-settings">
<NcCheckboxRadioSwitch :checked="enableDefaultContact"
<NcCheckboxRadioSwitch
:checked="enableDefaultContact"
type="switch"
@update:model-value="updateEnableDefaultContact">
{{ $t('dav', "Add example contact to user's address book when they first log in") }}
@ -17,15 +18,17 @@
</template>
example_contact.vcf
</ExampleContentDownloadButton>
<NcButton type="secondary"
<NcButton
variant="secondary"
@click="toggleModal">
<template #icon>
<IconUpload :size="20" />
</template>
{{ $t('dav', 'Import contact') }}
</NcButton>
<NcButton v-if="hasCustomDefaultContact"
type="tertiary"
<NcButton
v-if="hasCustomDefaultContact"
variant="tertiary"
@click="resetContact">
<template #icon>
<IconRestore :size="20" />
@ -33,14 +36,16 @@
{{ $t('dav', 'Reset to default') }}
</NcButton>
</div>
<NcDialog :open.sync="isModalOpen"
<NcDialog
:open.sync="isModalOpen"
:name="$t('dav', 'Import contacts')"
:buttons="buttons">
<div>
<p>{{ $t('dav', 'Importing a new .vcf file will delete the existing default contact and replace it with the new one. Do you want to continue?') }}</p>
</div>
</NcDialog>
<input id="example-contact-import"
<input
id="example-contact-import"
ref="exampleContactImportInput"
:disabled="loading"
type="file"
@ -49,19 +54,20 @@
@change="processFile">
</div>
</template>
<script>
import IconCancel from '@mdi/svg/svg/cancel.svg?raw'
import IconCheck from '@mdi/svg/svg/check.svg?raw'
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
import { loadState } from '@nextcloud/initial-state'
import { NcDialog, NcButton, NcCheckboxRadioSwitch } from '@nextcloud/vue'
import { showError, showSuccess } from '@nextcloud/dialogs'
import IconUpload from 'vue-material-design-icons/TrayArrowUp.vue'
import IconRestore from 'vue-material-design-icons/Restore.vue'
import { loadState } from '@nextcloud/initial-state'
import { generateUrl } from '@nextcloud/router'
import { NcButton, NcCheckboxRadioSwitch, NcDialog } from '@nextcloud/vue'
import IconAccount from 'vue-material-design-icons/Account.vue'
import IconCancel from '@mdi/svg/svg/cancel.svg?raw'
import IconCheck from '@mdi/svg/svg/check.svg?raw'
import logger from '../service/logger.js'
import IconRestore from 'vue-material-design-icons/Restore.vue'
import IconUpload from 'vue-material-design-icons/TrayArrowUp.vue'
import ExampleContentDownloadButton from './ExampleContentDownloadButton.vue'
import logger from '../service/logger.js'
const enableDefaultContact = loadState('dav', 'enableDefaultContact')
const hasCustomDefaultContact = loadState('dav', 'hasCustomDefaultContact')
@ -77,6 +83,7 @@ export default {
IconAccount,
ExampleContentDownloadButton,
},
data() {
return {
enableDefaultContact,
@ -98,11 +105,13 @@ export default {
],
}
},
computed: {
downloadUrl() {
return generateUrl('/apps/dav/api/defaultcontact/contact')
},
},
methods: {
updateEnableDefaultContact() {
axios.put(generateUrl('apps/dav/api/defaultcontact/config'), {
@ -113,12 +122,15 @@ export default {
showError(this.$t('dav', 'Error while saving settings'))
})
},
toggleModal() {
this.isModalOpen = !this.isModalOpen
},
clickImportInput() {
this.$refs.exampleContactImportInput.click()
},
resetContact() {
this.loading = true
axios.put(generateUrl('/apps/dav/api/defaultcontact/contact'))
@ -134,6 +146,7 @@ export default {
this.loading = false
})
},
processFile(event) {
this.loading = true
@ -159,6 +172,7 @@ export default {
},
}
</script>
<style lang="scss" scoped>
.example-contact-settings {
margin-block-start: 2rem;

@ -4,7 +4,7 @@
-->
<template>
<NcButton type="tertiary" :href="href">
<NcButton variant="tertiary" :href="href">
<template #icon>
<slot name="icon" />
</template>
@ -12,7 +12,8 @@
<span class="download-button__label">
<slot name="default" />
</span>
<IconDownload class="download-button__icon"
<IconDownload
class="download-button__icon"
:size="20" />
</div>
</NcButton>
@ -28,6 +29,7 @@ export default {
NcButton,
IconDownload,
},
props: {
href: {
type: String,

@ -5,13 +5,15 @@
<template>
<div class="example-event-settings">
<NcCheckboxRadioSwitch :checked="createExampleEvent"
<NcCheckboxRadioSwitch
:checked="createExampleEvent"
:disabled="savingConfig"
type="switch"
@update:model-value="updateCreateExampleEvent">
{{ t('dav', "Add example event to user's calendar when they first log in") }}
</NcCheckboxRadioSwitch>
<div v-if="createExampleEvent"
<div
v-if="createExampleEvent"
class="example-event-settings__buttons">
<ExampleContentDownloadButton :href="downloadUrl">
<template #icon>
@ -19,15 +21,17 @@
</template>
example_event.ics
</ExampleContentDownloadButton>
<NcButton type="secondary"
<NcButton
variant="secondary"
@click="showImportModal = true">
<template #icon>
<IconUpload :size="20" />
</template>
{{ t('dav', 'Import calendar event') }}
</NcButton>
<NcButton v-if="hasCustomEvent"
type="tertiary"
<NcButton
v-if="hasCustomEvent"
variant="tertiary"
:disabled="deleting"
@click="deleteCustomEvent">
<template #icon>
@ -36,21 +40,24 @@
{{ t('dav', 'Reset to default') }}
</NcButton>
</div>
<NcDialog :open.sync="showImportModal"
<NcDialog
:open.sync="showImportModal"
:name="t('dav', 'Import calendar event')">
<div class="import-event-modal">
<p>
{{ t('dav', 'Uploading a new event will overwrite the existing one.') }}
</p>
<input ref="event-file"
<input
ref="event-file"
:disabled="uploading"
type="file"
accept=".ics,text/calendar"
class="import-event-modal__file-picker"
@change="selectFile">
<div class="import-event-modal__buttons">
<NcButton :disabled="uploading || !selectedFile"
type="primary"
<NcButton
:disabled="uploading || !selectedFile"
variant="primary"
@click="uploadCustomEvent()">
<template #icon>
<IconUpload :size="20" />
@ -64,16 +71,16 @@
</template>
<script>
import { NcButton, NcCheckboxRadioSwitch, NcDialog } from '@nextcloud/vue'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { loadState } from '@nextcloud/initial-state'
import { generateUrl } from '@nextcloud/router'
import { NcButton, NcCheckboxRadioSwitch, NcDialog } from '@nextcloud/vue'
import IconCalendarBlank from 'vue-material-design-icons/CalendarBlank.vue'
import IconUpload from 'vue-material-design-icons/TrayArrowUp.vue'
import IconRestore from 'vue-material-design-icons/Restore.vue'
import IconUpload from 'vue-material-design-icons/TrayArrowUp.vue'
import ExampleContentDownloadButton from './ExampleContentDownloadButton.vue'
import * as ExampleEventService from '../service/ExampleEventService.js'
import { showError, showSuccess } from '@nextcloud/dialogs'
import logger from '../service/logger.js'
import { generateUrl } from '@nextcloud/router'
import ExampleContentDownloadButton from './ExampleContentDownloadButton.vue'
export default {
name: 'ExampleEventSettings',
@ -86,6 +93,7 @@ export default {
IconRestore,
ExampleContentDownloadButton,
},
data() {
return {
createExampleEvent: loadState('dav', 'create_example_event', false),
@ -97,15 +105,18 @@ export default {
selectedFile: undefined,
}
},
computed: {
downloadUrl() {
return generateUrl('/apps/dav/api/exampleEvent/event')
},
},
methods: {
selectFile() {
this.selectedFile = this.$refs['event-file']?.files[0]
},
async updateCreateExampleEvent() {
this.savingConfig = true
@ -124,6 +135,7 @@ export default {
this.createExampleEvent = enable
},
uploadCustomEvent() {
if (!this.selectedFile) {
return
@ -154,6 +166,7 @@ export default {
})
reader.readAsText(this.selectedFile)
},
async deleteCustomEvent() {
this.deleting = true

@ -3,10 +3,10 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { createClient } from 'webdav'
import memoize from 'lodash/fp/memoize.js'
import { generateRemoteUrl } from '@nextcloud/router'
import { getCurrentUser, getRequestToken, onRequestTokenUpdate } from '@nextcloud/auth'
import { generateRemoteUrl } from '@nextcloud/router'
import memoize from 'lodash/fp/memoize.js'
import { createClient } from 'webdav'
export const getClient = memoize((service) => {
// init webdav client

@ -1,15 +1,14 @@
import {
slotsToVavailability,
vavailabilityToSlots,
} from '@nextcloud/calendar-availability-vue'
import { parseXML } from 'webdav'
/**
* SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { getClient } from '../dav/client.js'
import logger from './logger.js'
import { parseXML } from 'webdav'
import {
slotsToVavailability,
vavailabilityToSlots,
} from '@nextcloud/calendar-availability-vue'
/**
*
@ -61,7 +60,7 @@ export async function findScheduleInboxAvailability() {
* @param {any} timezoneId -
*/
export async function saveScheduleInboxAvailability(slots, timezoneId) {
const all = [...Object.keys(slots).flatMap(dayId => slots[dayId].map(slot => ({
const all = [...Object.keys(slots).flatMap((dayId) => slots[dayId].map((slot) => ({
...slot,
day: dayId,
})))]

@ -3,8 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { generateUrl } from '@nextcloud/router'
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
/**
* Configure the creation of example events on a user's first login.

@ -25,10 +25,8 @@ export async function enableUserStatusAutomation() {
* Disable user status automation based on availability
*/
export async function disableUserStatusAutomation() {
return await axios.delete(
generateOcsUrl('/apps/provisioning_api/api/v1/config/users/{appId}/{configKey}', {
appId: 'dav',
configKey: 'user_status_automation',
}),
)
return await axios.delete(generateOcsUrl('/apps/provisioning_api/api/v1/config/users/{appId}/{configKey}', {
appId: 'dav',
configKey: 'user_status_automation',
}))
}

@ -2,14 +2,15 @@
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { t } from '@nextcloud/l10n'
import Vue from 'vue'
import { translate } from '@nextcloud/l10n'
import ExampleContentSettingsSection from './views/ExampleContentSettingsSection.vue'
Vue.mixin({
methods: {
t: translate,
$t: translate,
t,
$t: t,
},
})

@ -2,11 +2,12 @@
* SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { t } from '@nextcloud/l10n'
import Vue from 'vue'
import { translate } from '@nextcloud/l10n'
import Availability from './views/Availability.vue'
Vue.prototype.$t = translate
Vue.prototype.$t = t
const View = Vue.extend(Availability);

@ -2,12 +2,13 @@
* SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import Vue from 'vue'
import { loadState } from '@nextcloud/initial-state'
import { translate } from '@nextcloud/l10n'
import { t } from '@nextcloud/l10n'
import Vue from 'vue'
import CalDavSettings from './views/CalDavSettings.vue'
Vue.prototype.$t = translate
Vue.prototype.$t = t
const View = Vue.extend(CalDavSettings)
const CalDavSettingsView = new View({

@ -4,12 +4,14 @@
-->
<template>
<div>
<NcSettingsSection id="availability"
<NcSettingsSection
id="availability"
:name="$t('dav', 'Availability')"
:description="$t('dav', 'If you configure your working hours, other people will see when you are out of office when they book a meeting.')">
<AvailabilityForm />
</NcSettingsSection>
<NcSettingsSection v-if="!hideAbsenceSettings"
<NcSettingsSection
v-if="!hideAbsenceSettings"
id="absence"
:name="$t('dav', 'Absence')"
:description="$t('dav', 'Configure your next absence period.')">
@ -19,11 +21,12 @@
</template>
<script>
import { loadState } from '@nextcloud/initial-state'
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import AbsenceForm from '../components/AbsenceForm.vue'
import AvailabilityForm from '../components/AvailabilityForm.vue'
import { loadState } from '@nextcloud/initial-state'
/* eslint vue/multi-word-component-names: "warn" */
export default {
name: 'Availability',
components: {
@ -31,6 +34,7 @@ export default {
AbsenceForm,
AvailabilityForm,
},
data() {
return {
hideAbsenceSettings: loadState('dav', 'hide_absence_settings', true),

@ -2,9 +2,9 @@
* SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { render } from '@testing-library/vue'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import CalDavSettings from './CalDavSettings.vue'
vi.mock('@nextcloud/axios')
@ -45,29 +45,19 @@ describe('CalDavSettings', () => {
}
},
},
Vue => {
(Vue) => {
Vue.prototype.$t = vi.fn((app, text) => text)
},
)
const sendInvitations = TLUtils.getByLabelText(
'Send invitations to attendees',
)
const sendInvitations = TLUtils.getByLabelText('Send invitations to attendees')
expect(sendInvitations).toBeChecked()
const generateBirthdayCalendar = TLUtils.getByLabelText(
'Automatically generate a birthday calendar',
)
const generateBirthdayCalendar = TLUtils.getByLabelText('Automatically generate a birthday calendar')
expect(generateBirthdayCalendar).toBeChecked()
const sendEventReminders = TLUtils.getByLabelText(
'Send notifications for events',
)
const sendEventReminders = TLUtils.getByLabelText('Send notifications for events')
expect(sendEventReminders).toBeChecked()
const sendEventRemindersToSharedUsers = TLUtils.getByLabelText(
'Send reminder notifications to calendar sharees as well',
)
const sendEventRemindersToSharedUsers = TLUtils.getByLabelText('Send reminder notifications to calendar sharees as well')
expect(sendEventRemindersToSharedUsers).toBeChecked()
const sendEventRemindersPush = TLUtils.getByLabelText(
'Enable notifications for events via push',
)
const sendEventRemindersPush = TLUtils.getByLabelText('Enable notifications for events via push')
expect(sendEventRemindersPush).toBeChecked()
/*

@ -3,7 +3,8 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcSettingsSection :name="$t('dav', 'Calendar server')"
<NcSettingsSection
:name="$t('dav', 'Calendar server')"
:doc-url="userSyncCalendarsDocUrl">
<!-- Can use v-html as:
- $t passes the translated string through DOMPurify.sanitize,
@ -11,7 +12,8 @@
<!-- eslint-disable-next-line vue/no-v-html -->
<p class="settings-hint" v-html="hint" />
<p>
<NcCheckboxRadioSwitch id="caldavSendInvitations"
<NcCheckboxRadioSwitch
id="caldavSendInvitations"
:checked.sync="sendInvitations"
type="switch">
{{ $t('dav', 'Send invitations to attendees') }}
@ -23,7 +25,8 @@
<em v-html="sendInvitationsHelpText" />
</p>
<p>
<NcCheckboxRadioSwitch id="caldavGenerateBirthdayCalendar"
<NcCheckboxRadioSwitch
id="caldavGenerateBirthdayCalendar"
:checked.sync="generateBirthdayCalendar"
type="switch"
class="checkbox">
@ -38,7 +41,8 @@
</em>
</p>
<p>
<NcCheckboxRadioSwitch id="caldavSendEventReminders"
<NcCheckboxRadioSwitch
id="caldavSendEventReminders"
:checked.sync="sendEventReminders"
type="switch">
{{ $t('dav', 'Send notifications for events') }}
@ -54,18 +58,20 @@
</em>
</p>
<p class="indented">
<NcCheckboxRadioSwitch id="caldavSendEventRemindersToSharedGroupMembers"
<NcCheckboxRadioSwitch
id="caldavSendEventRemindersToSharedGroupMembers"
:checked.sync="sendEventRemindersToSharedUsers"
type="switch"
:disabled="!sendEventReminders">
{{ $t('dav', 'Send reminder notifications to calendar sharees as well' ) }}
{{ $t('dav', 'Send reminder notifications to calendar sharees as well') }}
</NcCheckboxRadioSwitch>
<em>
{{ $t('dav', 'Reminders are always sent to organizers and attendees.' ) }}
{{ $t('dav', 'Reminders are always sent to organizers and attendees.') }}
</em>
</p>
<p class="indented">
<NcCheckboxRadioSwitch id="caldavSendEventRemindersPush"
<NcCheckboxRadioSwitch
id="caldavSendEventRemindersPush"
:checked.sync="sendEventRemindersPush"
type="switch"
:disabled="!sendEventReminders">
@ -77,10 +83,10 @@
<script>
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
import { loadState } from '@nextcloud/initial-state'
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import { generateUrl } from '@nextcloud/router'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
const userSyncCalendarsDocUrl = loadState('dav', 'userSyncCalendarsDocUrl', '#')
@ -90,11 +96,13 @@ export default {
NcCheckboxRadioSwitch,
NcSettingsSection,
},
data() {
return {
userSyncCalendarsDocUrl,
}
},
computed: {
hint() {
const translated = this.$t(
@ -106,12 +114,14 @@ export default {
.replace('{calendardocopen}', `<a target="_blank" href="${userSyncCalendarsDocUrl}" rel="noreferrer noopener">`)
.replace(/\{linkclose\}/g, '</a>')
},
sendInvitationsHelpText() {
const translated = this.$t('dav', 'Please make sure to properly set up {emailopen}the email server{linkclose}.')
return translated
.replace('{emailopen}', '<a href="../admin#mail_general_settings">')
.replace('{linkclose}', '</a>')
},
sendEventRemindersHelpText() {
const translated = this.$t('dav', 'Please make sure to properly set up {emailopen}the email server{linkclose}.')
return translated
@ -119,11 +129,13 @@ export default {
.replace('{linkclose}', '</a>')
},
},
watch: {
generateBirthdayCalendar(value) {
const baseUrl = value ? '/apps/dav/enableBirthdayCalendar' : '/apps/dav/disableBirthdayCalendar'
axios.post(generateUrl(baseUrl))
},
sendInvitations(value) {
OCP.AppConfig.setValue(
'dav',
@ -131,9 +143,11 @@ export default {
value ? 'yes' : 'no',
)
},
sendEventReminders(value) {
OCP.AppConfig.setValue('dav', 'sendEventReminders', value ? 'yes' : 'no')
},
sendEventRemindersToSharedUsers(value) {
OCP.AppConfig.setValue(
'dav',
@ -141,6 +155,7 @@ export default {
value ? 'yes' : 'no',
)
},
sendEventRemindersPush(value) {
OCP.AppConfig.setValue('dav', 'sendEventRemindersPush', value ? 'yes' : 'no')
},

@ -4,7 +4,8 @@
-->
<template>
<NcSettingsSection id="example-content"
<NcSettingsSection
id="example-content"
:name="$t('dav', 'Example content')"
class="example-content-setting"
:description="$t('dav', 'Example content serves to showcase the features of Nextcloud. Default content is shipped with Nextcloud, and can be replaced by custom content.')">
@ -16,8 +17,8 @@
<script>
import { loadState } from '@nextcloud/initial-state'
import { NcSettingsSection } from '@nextcloud/vue'
import ExampleEventSettings from '../components/ExampleEventSettings.vue'
import ExampleContactSettings from '../components/ExampleContactSettings.vue'
import ExampleEventSettings from '../components/ExampleEventSettings.vue'
export default {
name: 'ExampleContentSettingsSection',
@ -26,10 +27,12 @@ export default {
ExampleContactSettings,
ExampleEventSettings,
},
computed: {
hasContactsApp() {
return loadState('dav', 'contactsEnabled')
},
hasCalendarApp() {
return loadState('dav', 'calendarEnabled')
},

@ -5,29 +5,28 @@
*/
/**
* @namespace
* @memberOf OC
* @namespace OC
*/
OC.Encryption = _.extend(OC.Encryption || {}, {
displayEncryptionWarning: function () {
displayEncryptionWarning: function() {
if (!OC.currentUser || !OC.Notification.isHidden()) {
return;
return
}
$.get(
OC.generateUrl('/apps/encryption/ajax/getStatus'),
function (result) {
if (result.status === "interactionNeeded") {
OC.Notification.show(result.data.message);
function(result) {
if (result.status === 'interactionNeeded') {
OC.Notification.show(result.data.message)
}
}
);
}
});
},
)
},
})
window.addEventListener('DOMContentLoaded', function() {
// wait for other apps/extensions to register their event handlers and file actions
// in the "ready" clause
_.defer(function() {
OC.Encryption.displayEncryptionWarning();
});
});
OC.Encryption.displayEncryptionWarning()
})
})

@ -4,82 +4,77 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
window.addEventListener('DOMContentLoaded', function () {
$('input:button[name="enableRecoveryKey"]').click(function () {
window.addEventListener('DOMContentLoaded', function() {
$('input:button[name="enableRecoveryKey"]').click(function() {
const recoveryStatus = $(this).attr('status')
const newRecoveryStatus = (1 + parseInt(recoveryStatus)) % 2
const buttonValue = $(this).attr('value')
var recoveryStatus = $(this).attr('status');
var newRecoveryStatus = (1 + parseInt(recoveryStatus)) % 2;
var buttonValue = $(this).attr('value');
var recoveryPassword = $('#encryptionRecoveryPassword').val();
var confirmPassword = $('#repeatEncryptionRecoveryPassword').val();
OC.msg.startSaving('#encryptionSetRecoveryKey .msg');
const recoveryPassword = $('#encryptionRecoveryPassword').val()
const confirmPassword = $('#repeatEncryptionRecoveryPassword').val()
OC.msg.startSaving('#encryptionSetRecoveryKey .msg')
$.post(
OC.generateUrl('/apps/encryption/ajax/adminRecovery'),
{
adminEnableRecovery: newRecoveryStatus,
recoveryPassword: recoveryPassword,
confirmPassword: confirmPassword
}
).done(function (data) {
OC.msg.finishedSuccess('#encryptionSetRecoveryKey .msg', data.data.message);
recoveryPassword,
confirmPassword,
},
).done(function(data) {
OC.msg.finishedSuccess('#encryptionSetRecoveryKey .msg', data.data.message)
if (newRecoveryStatus === 0) {
$('p[name="changeRecoveryPasswordBlock"]').addClass("hidden");
$('input:button[name="enableRecoveryKey"]').attr('value', 'Enable recovery key');
$('input:button[name="enableRecoveryKey"]').attr('status', '0');
} else {
$('input:password[name="changeRecoveryPassword"]').val("");
$('p[name="changeRecoveryPasswordBlock"]').removeClass("hidden");
$('input:button[name="enableRecoveryKey"]').attr('value', 'Disable recovery key');
$('input:button[name="enableRecoveryKey"]').attr('status', '1');
}
if (newRecoveryStatus === 0) {
$('p[name="changeRecoveryPasswordBlock"]').addClass('hidden')
$('input:button[name="enableRecoveryKey"]').attr('value', 'Enable recovery key')
$('input:button[name="enableRecoveryKey"]').attr('status', '0')
} else {
$('input:password[name="changeRecoveryPassword"]').val('')
$('p[name="changeRecoveryPasswordBlock"]').removeClass('hidden')
$('input:button[name="enableRecoveryKey"]').attr('value', 'Disable recovery key')
$('input:button[name="enableRecoveryKey"]').attr('status', '1')
}
})
.fail(function(jqXHR) {
$('input:button[name="enableRecoveryKey"]').attr('value', buttonValue)
$('input:button[name="enableRecoveryKey"]').attr('status', recoveryStatus)
OC.msg.finishedError('#encryptionSetRecoveryKey .msg', JSON.parse(jqXHR.responseText).data.message)
})
.fail(function (jqXHR) {
$('input:button[name="enableRecoveryKey"]').attr('value', buttonValue);
$('input:button[name="enableRecoveryKey"]').attr('status', recoveryStatus);
OC.msg.finishedError('#encryptionSetRecoveryKey .msg', JSON.parse(jqXHR.responseText).data.message);
});
})
});
$("#repeatEncryptionRecoveryPassword").keyup(function (event) {
$('#repeatEncryptionRecoveryPassword').keyup(function(event) {
if (event.keyCode == 13) {
$("#enableRecoveryKey").click();
$('#enableRecoveryKey').click()
}
});
})
// change recovery password
$('button:button[name="submitChangeRecoveryKey"]').click(function () {
var oldRecoveryPassword = $('#oldEncryptionRecoveryPassword').val();
var newRecoveryPassword = $('#newEncryptionRecoveryPassword').val();
var confirmNewPassword = $('#repeatedNewEncryptionRecoveryPassword').val();
OC.msg.startSaving('#encryptionChangeRecoveryKey .msg');
$('button:button[name="submitChangeRecoveryKey"]').click(function() {
const oldRecoveryPassword = $('#oldEncryptionRecoveryPassword').val()
const newRecoveryPassword = $('#newEncryptionRecoveryPassword').val()
const confirmNewPassword = $('#repeatedNewEncryptionRecoveryPassword').val()
OC.msg.startSaving('#encryptionChangeRecoveryKey .msg')
$.post(
OC.generateUrl('/apps/encryption/ajax/changeRecoveryPassword'),
{
oldPassword: oldRecoveryPassword,
newPassword: newRecoveryPassword,
confirmPassword: confirmNewPassword
}
).done(function (data) {
OC.msg.finishedSuccess('#encryptionChangeRecoveryKey .msg', data.data.message);
confirmPassword: confirmNewPassword,
},
).done(function(data) {
OC.msg.finishedSuccess('#encryptionChangeRecoveryKey .msg', data.data.message)
})
.fail(function(jqXHR) {
OC.msg.finishedError('#encryptionChangeRecoveryKey .msg', JSON.parse(jqXHR.responseText).data.message)
})
.fail(function (jqXHR) {
OC.msg.finishedError('#encryptionChangeRecoveryKey .msg', JSON.parse(jqXHR.responseText).data.message);
});
});
})
$('#encryptHomeStorage').change(function() {
$.post(
OC.generateUrl('/apps/encryption/ajax/setEncryptHomeStorage'),
{
encryptHomeStorage: this.checked
}
);
});
});
encryptHomeStorage: this.checked,
},
)
})
})

@ -5,65 +5,60 @@
*/
OC.Encryption = _.extend(OC.Encryption || {}, {
updatePrivateKeyPassword: function () {
var oldPrivateKeyPassword = $('input:password[id="oldPrivateKeyPassword"]').val();
var newPrivateKeyPassword = $('input:password[id="newPrivateKeyPassword"]').val();
OC.msg.startSaving('#ocDefaultEncryptionModule .msg');
updatePrivateKeyPassword: function() {
const oldPrivateKeyPassword = $('input:password[id="oldPrivateKeyPassword"]').val()
const newPrivateKeyPassword = $('input:password[id="newPrivateKeyPassword"]').val()
OC.msg.startSaving('#ocDefaultEncryptionModule .msg')
$.post(
OC.generateUrl('/apps/encryption/ajax/updatePrivateKeyPassword'),
{
oldPassword: oldPrivateKeyPassword,
newPassword: newPrivateKeyPassword
}
).done(function (data) {
OC.msg.finishedSuccess('#ocDefaultEncryptionModule .msg', data.message);
})
.fail(function (jqXHR) {
OC.msg.finishedError('#ocDefaultEncryptionModule .msg', JSON.parse(jqXHR.responseText).message);
});
}
});
window.addEventListener('DOMContentLoaded', function () {
newPassword: newPrivateKeyPassword,
},
).done(function(data) {
OC.msg.finishedSuccess('#ocDefaultEncryptionModule .msg', data.message)
}).fail(function(jqXHR) {
OC.msg.finishedError('#ocDefaultEncryptionModule .msg', JSON.parse(jqXHR.responseText).message)
})
},
})
window.addEventListener('DOMContentLoaded', function() {
// Trigger ajax on recoveryAdmin status change
$('input:radio[name="userEnableRecovery"]').change(
function () {
var recoveryStatus = $(this).val();
OC.msg.startAction('#userEnableRecovery .msg', 'Updating recovery keys. This can take some time...');
$.post(
OC.generateUrl('/apps/encryption/ajax/userSetRecovery'),
{
userEnableRecovery: recoveryStatus
}
).done(function (data) {
OC.msg.finishedSuccess('#userEnableRecovery .msg', data.data.message);
})
.fail(function (jqXHR) {
OC.msg.finishedError('#userEnableRecovery .msg', JSON.parse(jqXHR.responseText).data.message);
});
$('input:radio[name="userEnableRecovery"]').change(function() {
const recoveryStatus = $(this).val()
OC.msg.startAction('#userEnableRecovery .msg', 'Updating recovery keys. This can take some time...')
$.post(
OC.generateUrl('/apps/encryption/ajax/userSetRecovery'),
{
userEnableRecovery: recoveryStatus,
},
).done(function(data) {
OC.msg.finishedSuccess('#userEnableRecovery .msg', data.data.message)
})
.fail(function(jqXHR) {
OC.msg.finishedError('#userEnableRecovery .msg', JSON.parse(jqXHR.responseText).data.message)
})
// Ensure page is not reloaded on form submit
return false;
}
);
return false
})
// update private key password
$('input:password[name="changePrivateKeyPassword"]').keyup(function (event) {
var oldPrivateKeyPassword = $('input:password[id="oldPrivateKeyPassword"]').val();
var newPrivateKeyPassword = $('input:password[id="newPrivateKeyPassword"]').val();
$('input:password[name="changePrivateKeyPassword"]').keyup(function(event) {
const oldPrivateKeyPassword = $('input:password[id="oldPrivateKeyPassword"]').val()
const newPrivateKeyPassword = $('input:password[id="newPrivateKeyPassword"]').val()
if (newPrivateKeyPassword !== '' && oldPrivateKeyPassword !== '') {
$('button:button[name="submitChangePrivateKeyPassword"]').removeAttr("disabled");
$('button:button[name="submitChangePrivateKeyPassword"]').removeAttr('disabled')
if (event.which === 13) {
OC.Encryption.updatePrivateKeyPassword();
OC.Encryption.updatePrivateKeyPassword()
}
} else {
$('button:button[name="submitChangePrivateKeyPassword"]').attr("disabled", "true");
$('button:button[name="submitChangePrivateKeyPassword"]').attr('disabled', 'true')
}
});
$('button:button[name="submitChangePrivateKeyPassword"]').click(function () {
OC.Encryption.updatePrivateKeyPassword();
});
})
});
$('button:button[name="submitChangePrivateKeyPassword"]').click(function() {
OC.Encryption.updatePrivateKeyPassword()
})
})

@ -3,29 +3,34 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcSettingsSection :name="t('federatedfilesharing', 'Federated Cloud Sharing')"
<NcSettingsSection
:name="t('federatedfilesharing', 'Federated Cloud Sharing')"
:description="t('federatedfilesharing', 'Adjust how people can share between servers. This includes shares between people on this server as well if they are using federated sharing.')"
:doc-url="sharingFederatedDocUrl">
<NcCheckboxRadioSwitch type="switch"
<NcCheckboxRadioSwitch
type="switch"
:checked.sync="outgoingServer2serverShareEnabled"
@update:checked="update('outgoing_server2server_share_enabled', outgoingServer2serverShareEnabled)">
{{ t('federatedfilesharing', 'Allow people on this server to send shares to other servers (this option also allows WebDAV access to public shares)') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch type="switch"
<NcCheckboxRadioSwitch
type="switch"
:checked.sync="incomingServer2serverShareEnabled"
@update:checked="update('incoming_server2server_share_enabled', incomingServer2serverShareEnabled)">
{{ t('federatedfilesharing', 'Allow people on this server to receive shares from other servers') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch v-if="federatedGroupSharingSupported"
<NcCheckboxRadioSwitch
v-if="federatedGroupSharingSupported"
type="switch"
:checked.sync="outgoingServer2serverGroupShareEnabled"
@update:checked="update('outgoing_server2server_group_share_enabled', outgoingServer2serverGroupShareEnabled)">
{{ t('federatedfilesharing', 'Allow people on this server to send shares to groups on other servers') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch v-if="federatedGroupSharingSupported"
<NcCheckboxRadioSwitch
v-if="federatedGroupSharingSupported"
type="switch"
:checked.sync="incomingServer2serverGroupShareEnabled"
@update:checked="update('incoming_server2server_group_share_enabled', incomingServer2serverGroupShareEnabled)">
@ -35,14 +40,16 @@
<fieldset>
<legend>{{ t('federatedfilesharing', 'The lookup server is only available for global scale.') }}</legend>
<NcCheckboxRadioSwitch type="switch"
<NcCheckboxRadioSwitch
type="switch"
:checked="lookupServerEnabled"
disabled
@update:checked="showLookupServerConfirmation">
{{ t('federatedfilesharing', 'Search global and public address book for people') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch type="switch"
<NcCheckboxRadioSwitch
type="switch"
:checked="lookupServerUploadEnabled"
disabled
@update:checked="showLookupServerUploadConfirmation">
@ -55,7 +62,8 @@
<h3 class="settings-subsection__name">
{{ t('federatedfilesharing', 'Trusted federation') }}
</h3>
<NcCheckboxRadioSwitch type="switch"
<NcCheckboxRadioSwitch
type="switch"
:checked.sync="federatedTrustedShareAutoAccept"
@update:checked="update('federatedTrustedShareAutoAccept', federatedTrustedShareAutoAccept)">
{{ t('federatedfilesharing', 'Automatically accept shares from trusted federated accounts and groups by default') }}
@ -65,13 +73,14 @@
</template>
<script>
import { loadState } from '@nextcloud/initial-state'
import axios from '@nextcloud/axios'
import { DialogBuilder, DialogSeverity, showError } from '@nextcloud/dialogs'
import { generateOcsUrl } from '@nextcloud/router'
import { loadState } from '@nextcloud/initial-state'
import { confirmPassword } from '@nextcloud/password-confirmation'
import axios from '@nextcloud/axios'
import { generateOcsUrl } from '@nextcloud/router'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import logger from '../services/logger.ts'
import '@nextcloud/password-confirmation/dist/style.css'
@ -97,6 +106,7 @@ export default {
sharingFederatedDocUrl: loadState('federatedfilesharing', 'sharingFederatedDocUrl'),
}
},
methods: {
setLookupServerUploadEnabled(state) {
if (state === this.lookupServerUploadEnabled) {
@ -115,9 +125,7 @@ export default {
const dialog = new DialogBuilder(t('federatedfilesharing', 'Confirm data upload to lookup server'))
await dialog
.setSeverity(DialogSeverity.Warning)
.setText(
t('federatedfilesharing', 'When enabled, all account properties (e.g. email address) with scope visibility set to "published", will be automatically synced and transmitted to an external system and made available in a public, global address book.'),
)
.setText(t('federatedfilesharing', 'When enabled, all account properties (e.g. email address) with scope visibility set to "published", will be automatically synced and transmitted to an external system and made available in a public, global address book.'))
.addButton({
callback: () => this.setLookupServerUploadEnabled(false),
label: t('federatedfilesharing', 'Disable upload'),
@ -148,11 +156,9 @@ export default {
const dialog = new DialogBuilder(t('federatedfilesharing', 'Confirm querying lookup server'))
await dialog
.setSeverity(DialogSeverity.Warning)
.setText(
t('federatedfilesharing', 'When enabled, the search input when creating shares will be sent to an external system that provides a public and global address book.')
.setText(t('federatedfilesharing', 'When enabled, the search input when creating shares will be sent to an external system that provides a public and global address book.')
+ t('federatedfilesharing', 'This is used to retrieve the federated cloud ID to make federated sharing easier.')
+ t('federatedfilesharing', 'Moreover, email addresses of users might be sent to that system in order to verify them.'),
)
+ t('federatedfilesharing', 'Moreover, email addresses of users might be sent to that system in order to verify them.'))
.addButton({
callback: () => this.setLookupServerEnabled(false),
label: t('federatedfilesharing', 'Disable querying'),
@ -189,15 +195,17 @@ export default {
})
}
},
async handleResponse({ status, errorMessage, error }) {
if (status !== 'ok') {
showError(errorMessage)
console.error(errorMessage, error)
logger.error(errorMessage, { error })
}
},
},
}
</script>
<style scoped>
.settings-subsection {
margin-top: 20px;

@ -4,10 +4,12 @@
-->
<template>
<NcSettingsSection :name="t('federatedfilesharing', 'Federated Cloud')"
<NcSettingsSection
:name="t('federatedfilesharing', 'Federated Cloud')"
:description="t('federatedfilesharing', 'You can share with anyone who uses a {productName} server or other Open Cloud Mesh (OCM) compatible servers and services! Just put their Federated Cloud ID in the share dialog. It looks like person@cloud.example.com', { productName })"
:doc-url="docUrlFederated">
<NcInputField class="federated-cloud__cloud-id"
<NcInputField
class="federated-cloud__cloud-id"
readonly
:label="t('federatedfilesharing', 'Your Federated Cloud ID')"
:value="cloudId"
@ -29,7 +31,8 @@
<img class="social-button__icon social-button__icon--bright" :src="urlFacebookIcon">
</template>
</NcButton>
<NcButton :aria-label="t('federatedfilesharing', 'X (formerly Twitter)')"
<NcButton
:aria-label="t('federatedfilesharing', 'X (formerly Twitter)')"
:href="shareXUrl">
{{ t('federatedfilesharing', 'formerly Twitter') }}
<template #icon>
@ -48,7 +51,8 @@
<img class="social-button__icon" :src="urlBlueSkyIcon">
</template>
</NcButton>
<NcButton class="social-button__website-button"
<NcButton
class="social-button__website-button"
@click="showHtml = !showHtml">
<template #icon>
<IconWeb :size="20" />
@ -59,7 +63,8 @@
<template v-if="showHtml">
<p style="margin: 10px 0">
<a target="_blank"
<a
target="_blank"
rel="noreferrer noopener"
:href="reference"
:style="backgroundStyle">
@ -82,12 +87,12 @@ import { showSuccess } from '@nextcloud/dialogs'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { imagePath } from '@nextcloud/router'
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcInputField from '@nextcloud/vue/components/NcInputField'
import IconWeb from 'vue-material-design-icons/Web.vue'
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import IconCheck from 'vue-material-design-icons/Check.vue'
import IconClipboard from 'vue-material-design-icons/ContentCopy.vue'
import IconWeb from 'vue-material-design-icons/Web.vue'
export default {
name: 'PersonalSettings',
@ -99,6 +104,7 @@ export default {
IconClipboard,
IconWeb,
},
setup() {
return {
t,
@ -112,6 +118,7 @@ export default {
urlXIcon: imagePath('core', 'x'),
}
},
data() {
return {
color: loadState('federatedfilesharing', 'color'),
@ -122,50 +129,62 @@ export default {
isCopied: false,
}
},
computed: {
messageWithURL() {
return t('federatedfilesharing', 'Share with me through my #Nextcloud Federated Cloud ID, see {url}', { url: this.reference })
},
messageWithoutURL() {
return t('federatedfilesharing', 'Share with me through my #Nextcloud Federated Cloud ID')
},
shareMastodonUrl() {
return `https://mastodon.social/?text=${encodeURIComponent(this.messageWithoutURL)}&url=${encodeURIComponent(this.reference)}`
},
shareXUrl() {
return `https://x.com/intent/tweet?text=${encodeURIComponent(this.messageWithURL)}`
},
shareFacebookUrl() {
return `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(this.reference)}`
},
shareBlueSkyUrl() {
return `https://bsky.app/intent/compose?text=${encodeURIComponent(this.messageWithURL)}`
},
logoPathAbsolute() {
return window.location.protocol + '//' + window.location.host + this.logoPath
},
backgroundStyle() {
return `padding:10px;background-color:${this.color};color:${this.textColor};border-radius:3px;padding-inline-start:4px;`
},
linkStyle() {
return `background-image:url(${this.logoPathAbsolute});width:50px;height:30px;position:relative;top:8px;background-size:contain;display:inline-block;background-repeat:no-repeat; background-position: center center;`
},
htmlCode() {
return `<a target="_blank" rel="noreferrer noopener" href="${this.reference}" style="${this.backgroundStyle}">
<span style="${this.linkStyle}"></span>
${t('federatedfilesharing', 'Share with me via Nextcloud')}
</a>`
},
copyLinkTooltip() {
return this.isCopied ? t('federatedfilesharing', 'Cloud ID copied') : t('federatedfilesharing', 'Copy')
},
},
methods: {
async copyCloudId(): Promise<void> {
try {
await navigator.clipboard.writeText(this.cloudId)
showSuccess(t('federatedfilesharing', 'Cloud ID copied'))
} catch (e) {
} catch {
// no secure context or really old browser - need a fallback
window.prompt(t('federatedfilesharing', 'Clipboard not available. Please copy the cloud ID manually.'), this.reference)
}

@ -43,14 +43,16 @@ const buttons = computed(() => [
</script>
<template>
<NcDialog :buttons="buttons"
<NcDialog
:buttons="buttons"
:is-form="passwordRequired"
:name="t('federatedfilesharing', 'Remote share')"
@submit="emit('close', true, password)">
<p>
{{ t('federatedfilesharing', 'Do you want to add the remote share {name} from {owner}@{remote}?', { name, owner, remote }) }}
</p>
<NcPasswordField v-if="passwordRequired"
<NcPasswordField
v-if="passwordRequired"
class="remote-share-dialog__password"
:label="t('federatedfilesharing', 'Remote share password')"
:value.sync="password" />

@ -3,6 +3,7 @@
* SPDX-FileCopyrightText: 2014-2016 ownCloud, Inc.
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import axios, { isAxiosError } from '@nextcloud/axios'
import { showError, showInfo } from '@nextcloud/dialogs'
import { subscribe } from '@nextcloud/event-bus'
@ -35,9 +36,7 @@ window.OCA.Sharing.showAddExternalDialog = function(share, passwordProtected, ca
.replace(/\/$/, '') // remove trailing slash
showRemoteShareDialog(name, owner, remote, passwordProtected)
// eslint-disable-next-line n/no-callback-literal
.then((password) => callback(true, { ...share, password }))
// eslint-disable-next-line n/no-callback-literal
.catch(() => callback(false, share))
}
@ -84,7 +83,6 @@ function processIncomingShareFromUrl() {
// manually add server-to-server share
if (params.remote && params.token && params.name) {
const callbackAddShare = (result, share) => {
if (result === false) {
return

@ -1,12 +1,12 @@
/**
/*!
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import Vue from 'vue'
import { getCSPNonce } from '@nextcloud/auth'
import { translate as t } from '@nextcloud/l10n'
import { loadState } from '@nextcloud/initial-state'
import { translate as t } from '@nextcloud/l10n'
import Vue from 'vue'
import AdminSettings from './components/AdminSettings.vue'
__webpack_nonce__ = getCSPNonce()

@ -2,10 +2,10 @@
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import Vue from 'vue'
import { getCSPNonce } from '@nextcloud/auth'
import { translate as t } from '@nextcloud/l10n'
import Vue from 'vue'
import PersonalSettings from './components/PersonalSettings.vue'
__webpack_nonce__ = getCSPNonce()

@ -4,8 +4,8 @@
*/
import { describe, expect, it } from 'vitest'
import { showRemoteShareDialog } from './dialogService'
import { nextTick } from 'vue'
import { showRemoteShareDialog } from './dialogService.ts'
describe('federatedfilesharing: dialog service', () => {
it('mounts dialog', async () => {

@ -19,8 +19,8 @@ export function showRemoteShareDialog(
owner: string,
remote: string,
passwordRequired = false,
): Promise<string|void> {
const { promise, reject, resolve } = Promise.withResolvers<string|void>()
): Promise<string | void> {
const { promise, reject, resolve } = Promise.withResolvers<string | void>()
spawnDialog(RemoteShareDialog, { name, owner, remote, passwordRequired }, (status, password) => {
if (passwordRequired && status) {

@ -2,6 +2,7 @@
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { getLoggerBuilder } from '@nextcloud/logger'
const logger = getLoggerBuilder()

@ -1,119 +1,116 @@
/**
/*!
* SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.
* SPDX-License-Identifier: AGPL-3.0-only
*/
(function( $ ) {
// ocFederationAddServer
$.fn.ocFederationAddServer = function() {
/* Go easy on jquery and define some vars
/**
* @param $ - The jQuery instance
*/
(function($) {
// ocFederationAddServer
$.fn.ocFederationAddServer = function() {
/* Go easy on jquery and define some vars
========================================================================== */
var $wrapper = $(this),
// Buttons
$btnAddServer = $wrapper.find("#ocFederationAddServerButton"),
$btnSubmit = $wrapper.find("#ocFederationSubmit"),
const $wrapper = $(this),
// Inputs
$inpServerUrl = $wrapper.find("#serverUrl"),
// Buttons
$btnAddServer = $wrapper.find('#ocFederationAddServerButton'),
$btnSubmit = $wrapper.find('#ocFederationSubmit'),
// misc
$msgBox = $wrapper.find("#ocFederationAddServer .msg"),
$srvList = $wrapper.find("#listOfTrustedServers");
// Inputs
$inpServerUrl = $wrapper.find('#serverUrl'),
// misc
$msgBox = $wrapper.find('#ocFederationAddServer .msg'),
$srvList = $wrapper.find('#listOfTrustedServers')
/* Interaction
/* Interaction
========================================================================== */
$btnAddServer.on('click', function() {
$btnAddServer.addClass('hidden');
$wrapper.find(".serverUrl").removeClass('hidden');
$inpServerUrl
.focus();
});
// trigger server removal
$srvList.on('click', 'li > .icon-delete', function() {
var $this = $(this).parent();
var id = $this.attr('id');
removeServer( id );
});
$btnSubmit.on("click", function()
{
addServer($inpServerUrl.val());
});
$inpServerUrl.on("change keyup", function (e) {
var url = $(this).val();
// toggle add-button visibility based on input length
if ( url.length > 0 )
$btnSubmit.removeClass("hidden")
else
$btnSubmit.addClass("hidden")
if (e.keyCode === 13) { // add server on "enter"
addServer(url);
} else if (e.keyCode === 27) { // hide input filed again in ESC
$btnAddServer.removeClass('hidden');
$inpServerUrl.val("").addClass('hidden');
$btnSubmit.addClass('hidden');
}
});
};
/* private Functions
$btnAddServer.on('click', function() {
$btnAddServer.addClass('hidden')
$wrapper.find('.serverUrl').removeClass('hidden')
$inpServerUrl
.focus()
})
// trigger server removal
$srvList.on('click', 'li > .icon-delete', function() {
const $this = $(this).parent()
const id = $this.attr('id')
removeServer(id)
})
$btnSubmit.on('click', function() {
addServer($inpServerUrl.val())
})
$inpServerUrl.on('change keyup', function(e) {
const url = $(this).val()
// toggle add-button visibility based on input length
if (url.length > 0) { $btnSubmit.removeClass('hidden') } else { $btnSubmit.addClass('hidden') }
if (e.keyCode === 13) { // add server on "enter"
addServer(url)
} else if (e.keyCode === 27) { // hide input filed again in ESC
$btnAddServer.removeClass('hidden')
$inpServerUrl.val('').addClass('hidden')
$btnSubmit.addClass('hidden')
}
})
}
/* private Functions
========================================================================== */
function addServer( url ) {
OC.msg.startSaving('#ocFederationAddServer .msg');
$.post(
OC.getRootPath() + '/ocs/v2.php/apps/federation/trusted-servers',
{
url: url
},
null,
'json'
).done(function({ ocs }) {
var data = ocs.data;
$("#serverUrl").attr('value', '');
$("#listOfTrustedServers").prepend(
$('<li>')
.attr('id', data.id)
.html('<span class="status indeterminate"></span>' +
data.url +
'<span class="icon icon-delete"></span>')
);
OC.msg.finishedSuccess('#ocFederationAddServer .msg', data.message);
})
.fail(function (jqXHR) {
OC.msg.finishedError('#ocFederationAddServer .msg', JSON.parse(jqXHR.responseText).ocs.meta.message);
});
};
function removeServer( id ) {
$.ajax({
url: OC.getRootPath() + '/ocs/v2.php/apps/federation/trusted-servers/' + id,
type: 'DELETE',
success: function(response) {
$("#ocFederationSettings").find("#" + id).remove();
}
});
}
})( jQuery );
window.addEventListener('DOMContentLoaded', function () {
$('#ocFederationSettings').ocFederationAddServer();
});
/**
*
* @param url
*/
function addServer(url) {
OC.msg.startSaving('#ocFederationAddServer .msg')
$.post(
OC.getRootPath() + '/ocs/v2.php/apps/federation/trusted-servers',
{
url,
},
null,
'json',
).done(function({ ocs }) {
const data = ocs.data
$('#serverUrl').attr('value', '')
$('#listOfTrustedServers').prepend($('<li>')
.attr('id', data.id)
.html('<span class="status indeterminate"></span>'
+ data.url
+ '<span class="icon icon-delete"></span>'))
OC.msg.finishedSuccess('#ocFederationAddServer .msg', data.message)
})
.fail(function(jqXHR) {
OC.msg.finishedError('#ocFederationAddServer .msg', JSON.parse(jqXHR.responseText).ocs.meta.message)
})
}
/**
*
* @param id
*/
function removeServer(id) {
$.ajax({
url: OC.getRootPath() + '/ocs/v2.php/apps/federation/trusted-servers/' + id,
type: 'DELETE',
success: function(response) {
$('#ocFederationSettings').find('#' + id).remove()
},
})
}
})(jQuery)
window.addEventListener('DOMContentLoaded', function() {
$('#ocFederationSettings').ocFederationAddServer()
})

@ -4,7 +4,7 @@
-->
<template>
<NcContent app-name="files">
<Navigation v-if="!isPublic" />
<FilesNavigation v-if="!isPublic" />
<FilesList :is-public="isPublic" />
</NcContent>
</template>
@ -13,9 +13,9 @@
import { isPublicShare } from '@nextcloud/sharing/public'
import { defineComponent } from 'vue'
import NcContent from '@nextcloud/vue/components/NcContent'
import Navigation from './views/Navigation.vue'
import FilesList from './views/FilesList.vue'
import { useHotKeys } from './composables/useHotKeys'
import FilesNavigation from './views/FilesNavigation.vue'
import { useHotKeys } from './composables/useHotKeys.ts'
export default defineComponent({
name: 'FilesApp',
@ -23,7 +23,7 @@ export default defineComponent({
components: {
NcContent,
FilesList,
Navigation,
FilesNavigation,
},
setup() {

@ -2,25 +2,28 @@
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node, View } from '@nextcloud/files'
import { FileAction, registerFileAction } from '@nextcloud/files'
import { generateUrl } from '@nextcloud/router'
import AutoRenewSvg from '@mdi/svg/svg/autorenew.svg?raw'
import { getCapabilities } from '@nextcloud/capabilities'
import { FileAction, registerFileAction } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import AutoRenewSvg from '@mdi/svg/svg/autorenew.svg?raw'
import { convertFile, convertFiles } from './convertUtils'
import { generateUrl } from '@nextcloud/router'
import { convertFile, convertFiles } from './convertUtils.ts'
type ConversionsProvider = {
from: string,
to: string,
displayName: string,
from: string
to: string
displayName: string
}
export const ACTION_CONVERT = 'convert'
export const registerConvertActions = () => {
/**
*
*/
export function registerConvertActions() {
// Generate sub actions
const convertProviders = getCapabilities()?.files?.file_conversions as ConversionsProvider[] ?? []
const actions = convertProviders.map(({ to, from, displayName }) => {
@ -30,7 +33,7 @@ export const registerConvertActions = () => {
iconSvgInline: () => generateIconSvg(to),
enabled: (nodes: Node[]) => {
// Check that all nodes have the same mime type
return nodes.every(node => from === node.mime)
return nodes.every((node) => from === node.mime)
},
async exec(node: Node) {
@ -42,7 +45,7 @@ export const registerConvertActions = () => {
},
async execBatch(nodes: Node[]) {
const fileIds = nodes.map(node => node.fileid).filter(Boolean) as number[]
const fileIds = nodes.map((node) => node.fileid).filter(Boolean) as number[]
convertFiles(fileIds, to)
// Silently terminate, we'll handle the UI in the background
@ -56,10 +59,10 @@ export const registerConvertActions = () => {
// Register main action
registerFileAction(new FileAction({
id: ACTION_CONVERT,
displayName: () => t('files', 'Save as …'),
displayName: () => t('files', 'Save as …'),
iconSvgInline: () => AutoRenewSvg,
enabled: (nodes: Node[], view: View) => {
return actions.some(action => action.enabled!(nodes, view))
return actions.some((action) => action.enabled!(nodes, view))
},
async exec() {
return null
@ -71,7 +74,11 @@ export const registerConvertActions = () => {
actions.forEach(registerFileAction)
}
export const generateIconSvg = (mime: string) => {
/**
*
* @param mime
*/
export function generateIconSvg(mime: string) {
// Generate icon based on mime type
const url = generateUrl('/core/mimeicon?mime=' + encodeURIComponent(mime))
return `<svg width="32" height="32" viewBox="0 0 32 32"

@ -2,18 +2,18 @@
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { AxiosResponse, AxiosError } from '@nextcloud/axios'
import type { AxiosError, AxiosResponse } from '@nextcloud/axios'
import type { OCSResponse } from '@nextcloud/typings/ocs'
import { emit } from '@nextcloud/event-bus'
import { generateOcsUrl } from '@nextcloud/router'
import axios, { isAxiosError } from '@nextcloud/axios'
import { showError, showLoading, showSuccess } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import { n, t } from '@nextcloud/l10n'
import axios, { isAxiosError } from '@nextcloud/axios'
import { generateOcsUrl } from '@nextcloud/router'
import PQueue from 'p-queue'
import logger from '../logger.ts'
import { fetchNode } from '../services/WebdavClient.ts'
import logger from '../logger'
type ConversionResponse = {
path: string
@ -25,30 +25,40 @@ interface PromiseRejectedResult<T> {
reason: T
}
type PromiseSettledResult<T, E> = PromiseFulfilledResult<T> | PromiseRejectedResult<E>;
type PromiseSettledResult<T, E> = PromiseFulfilledResult<T> | PromiseRejectedResult<E>
type ConversionSuccess = AxiosResponse<OCSResponse<ConversionResponse>>
type ConversionError = AxiosError<OCSResponse<ConversionResponse>>
const queue = new PQueue({ concurrency: 5 })
const requestConversion = function(fileId: number, targetMimeType: string): Promise<AxiosResponse> {
/**
*
* @param fileId
* @param targetMimeType
*/
function requestConversion(fileId: number, targetMimeType: string): Promise<AxiosResponse> {
return axios.post(generateOcsUrl('/apps/files/api/v1/convert'), {
fileId,
targetMimeType,
})
}
export const convertFiles = async function(fileIds: number[], targetMimeType: string) {
const conversions = fileIds.map(fileId => queue.add(() => requestConversion(fileId, targetMimeType)))
/**
*
* @param fileIds
* @param targetMimeType
*/
export async function convertFiles(fileIds: number[], targetMimeType: string) {
const conversions = fileIds.map((fileId) => queue.add(() => requestConversion(fileId, targetMimeType)))
// Start conversion
const toast = showLoading(t('files', 'Converting files …'))
const toast = showLoading(t('files', 'Converting files …'))
// Handle results
try {
const results = await Promise.allSettled(conversions) as PromiseSettledResult<ConversionSuccess, ConversionError>[]
const failed = results.filter(result => result.status === 'rejected') as PromiseRejectedResult<ConversionError>[]
const failed = results.filter((result) => result.status === 'rejected') as PromiseRejectedResult<ConversionError>[]
if (failed.length > 0) {
const messages = failed.map(result => result.reason?.response?.data?.ocs?.meta?.message)
const messages = failed.map((result) => result.reason?.response?.data?.ocs?.meta?.message)
logger.error('Failed to convert files', { fileIds, targetMimeType, messages })
// If all failed files have the same error message, show it
@ -84,16 +94,16 @@ export const convertFiles = async function(fileIds: number[], targetMimeType: st
// might have changed as the user navigated away
const currentDir = window.OCP.Files.Router.query.dir as string
const newPaths = results
.filter(result => result.status === 'fulfilled')
.map(result => result.value.data.ocs.data.path)
.filter(path => path.startsWith(currentDir))
.filter((result) => result.status === 'fulfilled')
.map((result) => result.value.data.ocs.data.path)
.filter((path) => path.startsWith(currentDir))
// Fetch the new files
logger.debug('Files to fetch', { newPaths })
const newFiles = await Promise.all(newPaths.map(path => fetchNode(path)))
const newFiles = await Promise.all(newPaths.map((path) => fetchNode(path)))
// Inform the file list about the new files
newFiles.forEach(file => emit('files:node:created', file))
newFiles.forEach((file) => emit('files:node:created', file))
// Switch to the new files
const firstSuccess = results[0] as PromiseFulfilledResult<ConversionSuccess>
@ -109,8 +119,13 @@ export const convertFiles = async function(fileIds: number[], targetMimeType: st
}
}
export const convertFile = async function(fileId: number, targetMimeType: string) {
const toast = showLoading(t('files', 'Converting file …'))
/**
*
* @param fileId
* @param targetMimeType
*/
export async function convertFile(fileId: number, targetMimeType: string) {
const toast = showLoading(t('files', 'Converting file …'))
try {
const result = await queue.add(() => requestConversion(fileId, targetMimeType)) as AxiosResponse<OCSResponse<ConversionResponse>>

@ -2,16 +2,16 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { File, Folder, Permission, View, FileAction } from '@nextcloud/files'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import type { View } from '@nextcloud/files'
import axios from '@nextcloud/axios'
import * as capabilities from '@nextcloud/capabilities'
import * as eventBus from '@nextcloud/event-bus'
import { action } from './deleteAction'
import logger from '../logger'
import { shouldAskForConfirmation } from './deleteUtils'
import { File, FileAction, Folder, Permission } from '@nextcloud/files'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import logger from '../logger.ts'
import { action } from './deleteAction.ts'
import { shouldAskForConfirmation } from './deleteUtils.ts'
vi.mock('@nextcloud/auth')
vi.mock('@nextcloud/axios')
@ -389,7 +389,9 @@ describe('Delete action execute tests', () => {
})
test('Delete fails', async () => {
vi.spyOn(axios, 'delete').mockImplementation(() => { throw new Error('Mock error') })
vi.spyOn(axios, 'delete').mockImplementation(() => {
throw new Error('Mock error')
})
vi.spyOn(logger, 'error').mockImplementation(() => vi.fn())
vi.spyOn(eventBus, 'emit')

@ -2,17 +2,17 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { Permission, Node, View, FileAction } from '@nextcloud/files'
import { loadState } from '@nextcloud/initial-state'
import PQueue from 'p-queue'
import type { Node, View } from '@nextcloud/files'
import CloseSvg from '@mdi/svg/svg/close.svg?raw'
import NetworkOffSvg from '@mdi/svg/svg/network-off.svg?raw'
import TrashCanSvg from '@mdi/svg/svg/trash-can-outline.svg?raw'
import { FileAction, Permission } from '@nextcloud/files'
import { loadState } from '@nextcloud/initial-state'
import PQueue from 'p-queue'
import { TRASHBIN_VIEW_ID } from '../../../files_trashbin/src/files_views/trashbinView.ts'
import { askConfirmation, canDisconnectOnly, canUnshareOnly, deleteNode, displayName, shouldAskForConfirmation } from './deleteUtils.ts'
import logger from '../logger.ts'
import { askConfirmation, canDisconnectOnly, canUnshareOnly, deleteNode, displayName, shouldAskForConfirmation } from './deleteUtils.ts'
const queue = new PQueue({ concurrency: 5 })
@ -42,8 +42,8 @@ export const action = new FileAction({
}
return nodes.length > 0 && nodes
.map(node => node.permissions)
.every(permission => (permission & Permission.DELETE) !== 0)
.map((node) => node.permissions)
.every((permission) => (permission & Permission.DELETE) !== 0)
},
async exec(node: Node, view: View) {
@ -89,9 +89,9 @@ export const action = new FileAction({
}
// Map each node to a promise that resolves with the result of exec(node)
const promises = nodes.map(node => {
const promises = nodes.map((node) => {
// Create a promise that resolves with the result of exec(node)
const promise = new Promise<boolean>(resolve => {
const promise = new Promise<boolean>((resolve) => {
queue.add(async () => {
try {
await deleteNode(node)

@ -2,48 +2,74 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Capabilities } from '../types'
import type { Node, View } from '@nextcloud/files'
import type { Capabilities } from '../types.ts'
import axios from '@nextcloud/axios'
import { getCapabilities } from '@nextcloud/capabilities'
import { emit } from '@nextcloud/event-bus'
import { FileType } from '@nextcloud/files'
import { getCapabilities } from '@nextcloud/capabilities'
import { n, t } from '@nextcloud/l10n'
import axios from '@nextcloud/axios'
import { useUserConfigStore } from '../store/userconfig'
import { getPinia } from '../store'
import { getPinia } from '../store/index.ts'
import { useUserConfigStore } from '../store/userconfig.ts'
export const isTrashbinEnabled = () => (getCapabilities() as Capabilities)?.files?.undelete === true
export const canUnshareOnly = (nodes: Node[]) => {
return nodes.every(node => node.attributes['is-mount-root'] === true
/**
*
* @param nodes
*/
export function canUnshareOnly(nodes: Node[]) {
return nodes.every((node) => node.attributes['is-mount-root'] === true
&& node.attributes['mount-type'] === 'shared')
}
export const canDisconnectOnly = (nodes: Node[]) => {
return nodes.every(node => node.attributes['is-mount-root'] === true
/**
*
* @param nodes
*/
export function canDisconnectOnly(nodes: Node[]) {
return nodes.every((node) => node.attributes['is-mount-root'] === true
&& node.attributes['mount-type'] === 'external')
}
export const isMixedUnshareAndDelete = (nodes: Node[]) => {
/**
*
* @param nodes
*/
export function isMixedUnshareAndDelete(nodes: Node[]) {
if (nodes.length === 1) {
return false
}
const hasSharedItems = nodes.some(node => canUnshareOnly([node]))
const hasDeleteItems = nodes.some(node => !canUnshareOnly([node]))
const hasSharedItems = nodes.some((node) => canUnshareOnly([node]))
const hasDeleteItems = nodes.some((node) => !canUnshareOnly([node]))
return hasSharedItems && hasDeleteItems
}
export const isAllFiles = (nodes: Node[]) => {
return !nodes.some(node => node.type !== FileType.File)
/**
*
* @param nodes
*/
export function isAllFiles(nodes: Node[]) {
return !nodes.some((node) => node.type !== FileType.File)
}
export const isAllFolders = (nodes: Node[]) => {
return !nodes.some(node => node.type !== FileType.Folder)
/**
*
* @param nodes
*/
export function isAllFolders(nodes: Node[]) {
return !nodes.some((node) => node.type !== FileType.Folder)
}
export const displayName = (nodes: Node[], view: View) => {
/**
*
* @param nodes
* @param view
*/
export function displayName(nodes: Node[], view: View) {
/**
* If those nodes are all the root node of a
* share, we can only unshare them.
@ -103,17 +129,25 @@ export const displayName = (nodes: Node[], view: View) => {
return t('files', 'Delete')
}
export const shouldAskForConfirmation = () => {
/**
*
*/
export function shouldAskForConfirmation() {
const userConfig = useUserConfigStore(getPinia())
return userConfig.userConfig.show_dialog_deletion !== false
}
export const askConfirmation = async (nodes: Node[], view: View) => {
/**
*
* @param nodes
* @param view
*/
export async function askConfirmation(nodes: Node[], view: View) {
const message = view.id === 'trashbin' || !isTrashbinEnabled()
? n('files', 'You are about to permanently delete {count} item', 'You are about to permanently delete {count} items', nodes.length, { count: nodes.length })
: n('files', 'You are about to delete {count} item', 'You are about to delete {count} items', nodes.length, { count: nodes.length })
return new Promise<boolean>(resolve => {
return new Promise<boolean>((resolve) => {
// TODO: Use the new dialog API
window.OC.dialogs.confirmDestructive(
message,
@ -131,7 +165,11 @@ export const askConfirmation = async (nodes: Node[], view: View) => {
})
}
export const deleteNode = async (node: Node) => {
/**
*
* @param node
*/
export async function deleteNode(node: Node) {
await axios.delete(node.encodedSource)
// Let's delete even if it's moved to the trashbin

@ -2,14 +2,15 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { File, Folder, Permission, View, FileAction, DefaultType } from '@nextcloud/files'
import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'
import { action } from './downloadAction'
import type { View } from '@nextcloud/files'
import axios from '@nextcloud/axios'
import * as dialogs from '@nextcloud/dialogs'
import * as eventBus from '@nextcloud/event-bus'
import { DefaultType, File, FileAction, Folder, Permission } from '@nextcloud/files'
import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'
import { action } from './downloadAction.ts'
vi.mock('@nextcloud/axios')
vi.mock('@nextcloud/dialogs')
@ -22,7 +23,6 @@ const view = {
// Mock webroot variable
beforeAll(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any)._oc_webroot = ''
})

@ -2,19 +2,20 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node, View } from '@nextcloud/files'
import { FileAction, FileType, DefaultType } from '@nextcloud/files'
import { showError } from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'
import axios from '@nextcloud/axios'
import ArrowDownSvg from '@mdi/svg/svg/arrow-down.svg?raw'
import { isDownloadable } from '../utils/permissions'
import { usePathsStore } from '../store/paths'
import { getPinia } from '../store'
import { useFilesStore } from '../store/files'
import axios from '@nextcloud/axios'
import { showError } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import { DefaultType, FileAction, FileType } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import logger from '../logger.ts'
import { useFilesStore } from '../store/files.ts'
import { getPinia } from '../store/index.ts'
import { usePathsStore } from '../store/paths.ts'
import { isDownloadable } from '../utils/permissions.ts'
/**
* Trigger downloading a file.
@ -34,6 +35,7 @@ async function triggerDownload(url: string, name?: string) {
/**
* Find the longest common path prefix of both input paths
*
* @param first The first path
* @param second The second path
*/
@ -129,7 +131,7 @@ export const action = new FileAction({
}
// We can only download dav files and folders.
if (nodes.some(node => !node.isDavResource)) {
if (nodes.some((node) => !node.isDavResource)) {
return false
}
@ -144,8 +146,9 @@ export const action = new FileAction({
async exec(node: Node) {
try {
await downloadNodes([node])
} catch (e) {
} catch (error) {
showError(t('files', 'The requested file is not available.'))
logger.error('The requested file is not available.', { error })
emit('files:node:deleted', node)
}
return null
@ -154,8 +157,9 @@ export const action = new FileAction({
async execBatch(nodes: Node[], view: View, dir: string) {
try {
await downloadNodes(nodes)
} catch (e) {
} catch (error) {
showError(t('files', 'The requested files are not available.'))
logger.error('The requested files are not available.', { error })
// Try to reload the current directory to update the view
const directory = getCurrentDirectory(view, dir)!
emit('files:node:updated', directory)

@ -2,14 +2,16 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { File, Permission, View, FileAction } from '@nextcloud/files'
import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'
import { action } from './favoriteAction'
import type { View } from '@nextcloud/files'
import axios from '@nextcloud/axios'
import * as eventBus from '@nextcloud/event-bus'
import * as favoriteAction from './favoriteAction'
import logger from '../logger'
import { File, FileAction, Permission } from '@nextcloud/files'
import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'
import logger from '../logger.ts'
import { action } from './favoriteAction.ts'
import * as favoriteAction from './favoriteAction.ts'
vi.mock('@nextcloud/auth')
vi.mock('@nextcloud/axios')
@ -30,7 +32,7 @@ beforeAll(() => {
...window.OC,
TAG_FAVORITE: '_$!<Favorite>!$_',
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any)._oc_webroot = ''
})
@ -132,7 +134,9 @@ describe('Favorite action enabled tests', () => {
})
describe('Favorite action execute tests', () => {
beforeEach(() => { vi.resetAllMocks() })
beforeEach(() => {
vi.resetAllMocks()
})
test('Favorite triggers tag addition', async () => {
vi.spyOn(axios, 'post')
@ -247,7 +251,9 @@ describe('Favorite action execute tests', () => {
test('Favorite fails and show error', async () => {
const error = new Error('Mock error')
vi.spyOn(axios, 'post').mockImplementation(() => { throw new Error('Mock error') })
vi.spyOn(axios, 'post').mockImplementation(() => {
throw new Error('Mock error')
})
vi.spyOn(logger, 'error').mockImplementation(() => vi.fn())
const file = new File({
@ -277,7 +283,9 @@ describe('Favorite action execute tests', () => {
test('Removing from favorites fails and show error', async () => {
const error = new Error('Mock error')
vi.spyOn(axios, 'post').mockImplementation(() => { throw error })
vi.spyOn(axios, 'post').mockImplementation(() => {
throw error
})
vi.spyOn(logger, 'error').mockImplementation(() => vi.fn())
const file = new File({
@ -307,7 +315,9 @@ describe('Favorite action execute tests', () => {
})
describe('Favorite action batch execute tests', () => {
beforeEach(() => { vi.restoreAllMocks() })
beforeEach(() => {
vi.restoreAllMocks()
})
test('Favorite action batch execute with mixed files', async () => {
vi.spyOn(favoriteAction, 'favoriteNode')
@ -337,7 +347,7 @@ describe('Favorite action batch execute tests', () => {
// Mixed states triggers favorite action
const exec = await action.execBatch!([file1, file2], view, '/')
expect(exec).toStrictEqual([true, true])
expect([file1, file2].every(file => file.attributes.favorite === 1)).toBe(true)
expect([file1, file2].every((file) => file.attributes.favorite === 1)).toBe(true)
expect(axios.post).toBeCalledTimes(2)
expect(axios.post).toHaveBeenNthCalledWith(1, '/index.php/apps/files/api/v1/files/foo.txt', { tags: ['_$!<Favorite>!$_'] })
@ -372,7 +382,7 @@ describe('Favorite action batch execute tests', () => {
// Mixed states triggers favorite action
const exec = await action.execBatch!([file1, file2], view, '/')
expect(exec).toStrictEqual([true, true])
expect([file1, file2].every(file => file.attributes.favorite === 0)).toBe(true)
expect([file1, file2].every((file) => file.attributes.favorite === 0)).toBe(true)
expect(axios.post).toBeCalledTimes(2)
expect(axios.post).toHaveBeenNthCalledWith(1, '/index.php/apps/files/api/v1/files/foo.txt', { tags: [] })

@ -4,19 +4,17 @@
*/
import type { Node, View } from '@nextcloud/files'
import StarOutlineSvg from '@mdi/svg/svg/star-outline.svg?raw'
import StarSvg from '@mdi/svg/svg/star.svg?raw'
import axios from '@nextcloud/axios'
import { emit } from '@nextcloud/event-bus'
import { Permission, FileAction } from '@nextcloud/files'
import { FileAction, Permission } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import { encodePath } from '@nextcloud/paths'
import { generateUrl } from '@nextcloud/router'
import { isPublicShare } from '@nextcloud/sharing/public'
import axios from '@nextcloud/axios'
import PQueue from 'p-queue'
import Vue from 'vue'
import StarOutlineSvg from '@mdi/svg/svg/star-outline.svg?raw'
import StarSvg from '@mdi/svg/svg/star.svg?raw'
import logger from '../logger.ts'
export const ACTION_FAVORITE = 'favorite'
@ -24,11 +22,21 @@ export const ACTION_FAVORITE = 'favorite'
const queue = new PQueue({ concurrency: 5 })
// If any of the nodes is not favorited, we display the favorite action.
const shouldFavorite = (nodes: Node[]): boolean => {
return nodes.some(node => node.attributes.favorite !== 1)
/**
*
* @param nodes
*/
function shouldFavorite(nodes: Node[]): boolean {
return nodes.some((node) => node.attributes.favorite !== 1)
}
export const favoriteNode = async (node: Node, view: View, willFavorite: boolean): Promise<boolean> => {
/**
*
* @param node
* @param view
* @param willFavorite
*/
export async function favoriteNode(node: Node, view: View, willFavorite: boolean): Promise<boolean> {
try {
// TODO: migrate to webdav tags plugin
const url = generateUrl('/apps/files/api/v1/files') + encodePath(node.path)
@ -83,9 +91,9 @@ export const action = new FileAction({
}
// We can only favorite nodes if they are located in files
return nodes.every(node => node.root?.startsWith?.('/files'))
return nodes.every((node) => node.root?.startsWith?.('/files'))
// and we have permissions
&& nodes.every(node => node.permissions !== Permission.NONE)
&& nodes.every((node) => node.permissions !== Permission.NONE)
},
async exec(node: Node, view: View) {
@ -96,9 +104,9 @@ export const action = new FileAction({
const willFavorite = shouldFavorite(nodes)
// Map each node to a promise that resolves with the result of exec(node)
const promises = nodes.map(node => {
const promises = nodes.map((node) => {
// Create a promise that resolves with the result of exec(node)
const promise = new Promise<boolean>(resolve => {
const promise = new Promise<boolean>((resolve) => {
queue.add(async () => {
try {
await favoriteNode(node, view, willFavorite)

@ -2,33 +2,33 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Folder, Node, View } from '@nextcloud/files'
import type { IFilePickerButton } from '@nextcloud/dialogs'
import type { Folder, Node, View } from '@nextcloud/files'
import type { FileStat, ResponseDataDetailed, WebDAVClientError } from 'webdav'
import type { MoveCopyResult } from './moveOrCopyActionUtils'
import type { MoveCopyResult } from './moveOrCopyActionUtils.ts'
import FolderMoveSvg from '@mdi/svg/svg/folder-move-outline.svg?raw'
import CopyIconSvg from '@mdi/svg/svg/folder-multiple-outline.svg?raw'
import { isAxiosError } from '@nextcloud/axios'
import { FilePickerClosed, getFilePickerBuilder, showError, showInfo, TOAST_PERMANENT_TIMEOUT } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import { FileAction, FileType, NodeStatus, davGetClient, davRootPath, davResultToNode, davGetDefaultPropfind, getUniqueName, Permission } from '@nextcloud/files'
import { davGetClient, davGetDefaultPropfind, davResultToNode, davRootPath, FileAction, FileType, getUniqueName, NodeStatus, Permission } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import { openConflictPicker, hasConflict } from '@nextcloud/upload'
import { hasConflict, openConflictPicker } from '@nextcloud/upload'
import { basename, join } from 'path'
import Vue from 'vue'
import CopyIconSvg from '@mdi/svg/svg/folder-multiple-outline.svg?raw'
import FolderMoveSvg from '@mdi/svg/svg/folder-move-outline.svg?raw'
import { MoveCopyAction, canCopy, canMove, getQueue } from './moveOrCopyActionUtils'
import { getContents } from '../services/Files'
import logger from '../logger'
import logger from '../logger.ts'
import { getContents } from '../services/Files.ts'
import { canCopy, canMove, getQueue, MoveCopyAction } from './moveOrCopyActionUtils.ts'
/**
* Return the action that is possible for the given nodes
* @param {Node[]} nodes The nodes to check against
* @return {MoveCopyAction} The action that is possible for the given nodes
*
* @param nodes The nodes to check against
* @return The action that is possible for the given nodes
*/
const getActionForNodes = (nodes: Node[]): MoveCopyAction => {
function getActionForNodes(nodes: Node[]): MoveCopyAction {
if (canMove(nodes)) {
if (canCopy(nodes)) {
return MoveCopyAction.MOVE_OR_COPY
@ -42,21 +42,25 @@ const getActionForNodes = (nodes: Node[]): MoveCopyAction => {
/**
* Create a loading notification toast
*
* @param mode The move or copy mode
* @param source Name of the node that is copied / moved
* @param destination Destination path
* @return {() => void} Function to hide the notification
* @return Function to hide the notification
*/
function createLoadingNotification(mode: MoveCopyAction, source: string, destination: string): () => void {
const text = mode === MoveCopyAction.MOVE ? t('files', 'Moving "{source}" to "{destination}" …', { source, destination }) : t('files', 'Copying "{source}" to "{destination}" …', { source, destination })
const text = mode === MoveCopyAction.MOVE ? t('files', 'Moving "{source}" to "{destination}" …', { source, destination }) : t('files', 'Copying "{source}" to "{destination}" …', { source, destination })
let toast: ReturnType<typeof showInfo>|undefined
let toast: ReturnType<typeof showInfo> | undefined
toast = showInfo(
`<span class="icon icon-loading-small toast-loading-icon"></span> ${text}`,
{
isHTML: true,
timeout: TOAST_PERMANENT_TIMEOUT,
onRemove: () => { toast?.hideToast(); toast = undefined },
onRemove() {
toast?.hideToast()
toast = undefined
},
},
)
return () => toast && toast.hideToast()
@ -65,13 +69,14 @@ function createLoadingNotification(mode: MoveCopyAction, source: string, destina
/**
* Handle the copy/move of a node to a destination
* This can be imported and used by other scripts/components on server
* @param {Node} node The node to copy/move
* @param {Folder} destination The destination to copy/move the node to
* @param {MoveCopyAction} method The method to use for the copy/move
* @param {boolean} overwrite Whether to overwrite the destination if it exists
* @return {Promise<void>} A promise that resolves when the copy/move is done
*
* @param node The node to copy/move
* @param destination The destination to copy/move the node to
* @param method The method to use for the copy/move
* @param overwrite Whether to overwrite the destination if it exists
* @return A promise that resolves when the copy/move is done
*/
export const handleCopyMoveNodeTo = async (node: Node, destination: Folder, method: MoveCopyAction.COPY | MoveCopyAction.MOVE, overwrite = false) => {
export async function handleCopyMoveNodeTo(node: Node, destination: Folder, method: MoveCopyAction.COPY | MoveCopyAction.MOVE, overwrite = false) {
if (!destination) {
return
}
@ -156,7 +161,7 @@ export const handleCopyMoveNodeTo = async (node: Node, destination: Folder, meth
if (!selected.length && !renamed.length) {
return
}
} catch (error) {
} catch {
// User cancelled
return
}
@ -203,6 +208,7 @@ export const handleCopyMoveNodeTo = async (node: Node, destination: Folder, meth
/**
* Open a file picker for the given action
*
* @param action The action to open the file picker for
* @param dir The directory to start the file picker in
* @param nodes The nodes to move/copy
@ -214,7 +220,7 @@ async function openFilePickerForAction(
nodes: Node[],
): Promise<MoveCopyResult | false> {
const { resolve, reject, promise } = Promise.withResolvers<MoveCopyResult | false>()
const fileIDs = nodes.map(node => node.fileid).filter(Boolean)
const fileIDs = nodes.map((node) => node.fileid).filter(Boolean)
const filePicker = getFilePickerBuilder(t('files', 'Choose destination'))
.allowDirectories(true)
.setFilter((n: Node) => {
@ -228,8 +234,8 @@ async function openFilePickerForAction(
const buttons: IFilePickerButton[] = []
const target = basename(path)
const dirnames = nodes.map(node => node.dirname)
const paths = nodes.map(node => node.path)
const dirnames = nodes.map((node) => node.dirname)
const paths = nodes.map((node) => node.path)
if (action === MoveCopyAction.COPY || action === MoveCopyAction.MOVE_OR_COPY) {
buttons.push({
@ -298,12 +304,12 @@ export const action = new FileAction({
id: ACTION_COPY_MOVE,
displayName(nodes: Node[]) {
switch (getActionForNodes(nodes)) {
case MoveCopyAction.MOVE:
return t('files', 'Move')
case MoveCopyAction.COPY:
return t('files', 'Copy')
case MoveCopyAction.MOVE_OR_COPY:
return t('files', 'Move or copy')
case MoveCopyAction.MOVE:
return t('files', 'Move')
case MoveCopyAction.COPY:
return t('files', 'Copy')
case MoveCopyAction.MOVE_OR_COPY:
return t('files', 'Move or copy')
}
},
iconSvgInline: () => FolderMoveSvg,
@ -313,7 +319,7 @@ export const action = new FileAction({
return false
}
// We only support moving/copying files within the user folder
if (!nodes.every(node => node.root?.startsWith('/files/'))) {
if (!nodes.every((node) => node.root?.startsWith('/files/'))) {
return false
}
return nodes.length > 0 && (canMove(nodes) || canCopy(nodes))
@ -353,7 +359,7 @@ export const action = new FileAction({
return nodes.map(() => null)
}
const promises = nodes.map(async node => {
const promises = nodes.map(async (node) => {
try {
await handleCopyMoveNodeTo(node, result.destination, result.action)
return true

@ -4,12 +4,12 @@
*/
import type { Folder, Node } from '@nextcloud/files'
import type { ShareAttribute } from '../../../files_sharing/src/sharing'
import type { ShareAttribute } from '../../../files_sharing/src/sharing.ts'
import { Permission } from '@nextcloud/files'
import { loadState } from '@nextcloud/initial-state'
import { isPublicShare } from '@nextcloud/sharing/public'
import PQueue from 'p-queue'
import { loadState } from '@nextcloud/initial-state'
const sharePermissions = loadState<number>('files_sharing', 'sharePermissions', Permission.NONE)
@ -22,7 +22,7 @@ const MAX_CONCURRENCY = 5
/**
* Get the processing queue
*/
export const getQueue = () => {
export function getQueue() {
if (!queue) {
queue = new PQueue({ concurrency: MAX_CONCURRENCY })
}
@ -40,20 +40,31 @@ export type MoveCopyResult = {
action: MoveCopyAction.COPY | MoveCopyAction.MOVE
}
export const canMove = (nodes: Node[]) => {
/**
*
* @param nodes
*/
export function canMove(nodes: Node[]) {
const minPermission = nodes.reduce((min, node) => Math.min(min, node.permissions), Permission.ALL)
return Boolean(minPermission & Permission.DELETE)
}
export const canDownload = (nodes: Node[]) => {
return nodes.every(node => {
/**
*
* @param nodes
*/
export function canDownload(nodes: Node[]) {
return nodes.every((node) => {
const shareAttributes = JSON.parse(node.attributes?.['share-attributes'] ?? '[]') as Array<ShareAttribute>
return !shareAttributes.some(attribute => attribute.scope === 'permissions' && attribute.value === false && attribute.key === 'download')
return !shareAttributes.some((attribute) => attribute.scope === 'permissions' && attribute.value === false && attribute.key === 'download')
})
}
export const canCopy = (nodes: Node[]) => {
/**
*
* @param nodes
*/
export function canCopy(nodes: Node[]) {
// a shared file cannot be copied if the download is disabled
if (!canDownload(nodes)) {
return false

@ -2,10 +2,12 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { File, Folder, Node, Permission, View, DefaultType, FileAction } from '@nextcloud/files'
import { describe, expect, test, vi } from 'vitest'
import { action } from './openFolderAction'
import type { Node, View } from '@nextcloud/files'
import { DefaultType, File, FileAction, Folder, Permission } from '@nextcloud/files'
import { describe, expect, test, vi } from 'vitest'
import { action } from './openFolderAction.ts'
const view = {
id: 'files',

@ -2,9 +2,11 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { Permission, Node, FileType, View, FileAction, DefaultType } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import type { Node, View } from '@nextcloud/files'
import FolderSvg from '@mdi/svg/svg/folder.svg?raw'
import { DefaultType, FileAction, FileType, Permission } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
export const action = new FileAction({
id: 'open-folder',

@ -2,9 +2,12 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { action } from './openInFilesAction'
import type { View } from '@nextcloud/files'
import { DefaultType, File, FileAction, Folder, Permission } from '@nextcloud/files'
import { describe, expect, test, vi } from 'vitest'
import { File, Folder, Permission, View, DefaultType, FileAction } from '@nextcloud/files'
import { action } from './openInFilesAction.ts'
const view = {
id: 'files',
@ -43,7 +46,6 @@ describe('Open in files action enabled tests', () => {
describe('Open in files action execute tests', () => {
test('Open in files', async () => {
const goToRouteMock = vi.fn()
// @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation
window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } }
const file = new File({
@ -65,7 +67,6 @@ describe('Open in files action execute tests', () => {
test('Open in files with folder', async () => {
const goToRouteMock = vi.fn()
// @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation
window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } }
const file = new Folder({

@ -5,9 +5,9 @@
import type { Node } from '@nextcloud/files'
import { DefaultType, FileAction, FileType } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import { FileType, FileAction, DefaultType } from '@nextcloud/files'
import { VIEW_ID as SEARCH_VIEW_ID } from '../views/search'
import { VIEW_ID as SEARCH_VIEW_ID } from '../views/search.ts'
export const action = new FileAction({
id: 'open-in-files',

@ -2,12 +2,14 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { File, Permission, View, FileAction } from '@nextcloud/files'
import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'
import type { View } from '@nextcloud/files'
import axios from '@nextcloud/axios'
import * as nextcloudDialogs from '@nextcloud/dialogs'
import { action } from './openLocallyAction'
import { File, FileAction, Permission } from '@nextcloud/files'
import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'
import { action } from './openLocallyAction.ts'
vi.mock('@nextcloud/auth')
vi.mock('@nextcloud/axios')
@ -19,9 +21,8 @@ const view = {
// Mock web root variable
beforeAll(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any)._oc_webroot = '';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).OCA = { Viewer: { open: vi.fn() } }
})

@ -2,16 +2,20 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { encodePath } from '@nextcloud/paths'
import { generateOcsUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth'
import { FileAction, Permission, type Node } from '@nextcloud/files'
import { showError, DialogBuilder } from '@nextcloud/dialogs'
import { translate as t } from '@nextcloud/l10n'
import axios from '@nextcloud/axios'
import type { Node } from '@nextcloud/files'
import LaptopSvg from '@mdi/svg/svg/laptop.svg?raw'
import IconWeb from '@mdi/svg/svg/web.svg?raw'
import { getCurrentUser } from '@nextcloud/auth'
import axios from '@nextcloud/axios'
import { DialogBuilder, showError } from '@nextcloud/dialogs'
import { FileAction, Permission } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import { encodePath } from '@nextcloud/paths'
import { generateOcsUrl } from '@nextcloud/router'
import { isPublicShare } from '@nextcloud/sharing/public'
import logger from '../logger.ts'
export const action = new FileAction({
id: 'edit-locally',
@ -79,14 +83,15 @@ async function openLocalClient(path: string): Promise<void> {
window.open(url, '_self')
} catch (error) {
showError(t('files', 'Failed to redirect to client'))
logger.error('Failed to redirect to client', { error })
}
}
/**
* Open the confirmation dialog.
*/
async function confirmLocalEditDialog(): Promise<'online'|'local'|false> {
let result: 'online'|'local'|false = false
async function confirmLocalEditDialog(): Promise<'online' | 'local' | false> {
let result: 'online' | 'local' | false = false
const dialog = (new DialogBuilder())
.setName(t('files', 'Open file locally'))
.setText(t('files', 'The file should now open on your device. If it doesn\'t, please check that you have the desktop app installed.'))

@ -2,12 +2,15 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { action } from './renameAction'
import { File, Folder, Permission, View, FileAction } from '@nextcloud/files'
import type { View } from '@nextcloud/files'
import * as eventBus from '@nextcloud/event-bus'
import { describe, expect, test, vi, beforeEach } from 'vitest'
import { useFilesStore } from '../store/files'
import { File, FileAction, Folder, Permission } from '@nextcloud/files'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { useFilesStore } from '../store/files.ts'
import { getPinia } from '../store/index.ts'
import { action } from './renameAction.ts'
const view = {
id: 'files',
@ -59,6 +62,7 @@ describe('Rename action enabled tests', () => {
})
test('Disabled if more than one node', () => {
// @ts-expect-error mocking for tests
window.OCA = { Files: { Sidebar: {} } }
const file1 = new File({

@ -2,13 +2,17 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { View } from '@nextcloud/files'
import type { Node } from '@nextcloud/files'
import PencilSvg from '@mdi/svg/svg/pencil-outline.svg?raw'
import { emit } from '@nextcloud/event-bus'
import { Permission, type Node, FileAction, View } from '@nextcloud/files'
import { FileAction, Permission } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import PencilSvg from '@mdi/svg/svg/pencil-outline.svg?raw'
import { getPinia } from '../store'
import { useFilesStore } from '../store/files'
import { dirname } from 'path'
import { useFilesStore } from '../store/files.ts'
import { getPinia } from '../store/index.ts'
export const ACTION_RENAME = 'rename'

@ -2,11 +2,13 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { File, Permission, View, FileAction, Folder } from '@nextcloud/files'
import { describe, expect, test, vi } from 'vitest'
import { action } from './sidebarAction'
import logger from '../logger'
import type { View } from '@nextcloud/files'
import { File, FileAction, Folder, Permission } from '@nextcloud/files'
import { describe, expect, test, vi } from 'vitest'
import logger from '../logger.ts'
import { action } from './sidebarAction.ts'
const view = {
id: 'files',
@ -26,6 +28,7 @@ describe('Open sidebar action conditions tests', () => {
describe('Open sidebar action enabled tests', () => {
test('Enabled for ressources within user root folder', () => {
// @ts-expect-error mocking for tests
window.OCA = { Files: { Sidebar: {} } }
const file = new File({
@ -41,6 +44,7 @@ describe('Open sidebar action enabled tests', () => {
})
test('Disabled without permissions', () => {
// @ts-expect-error mocking for tests
window.OCA = { Files: { Sidebar: {} } }
const file = new File({
@ -53,10 +57,10 @@ describe('Open sidebar action enabled tests', () => {
expect(action.enabled).toBeDefined()
expect(action.enabled!([file], view)).toBe(false)
})
test('Disabled if more than one node', () => {
// @ts-expect-error mocking for tests
window.OCA = { Files: { Sidebar: {} } }
const file1 = new File({
@ -77,6 +81,7 @@ describe('Open sidebar action enabled tests', () => {
})
test('Disabled if no Sidebar', () => {
// @ts-expect-error mocking for tests
window.OCA = {}
const file = new File({
@ -91,6 +96,7 @@ describe('Open sidebar action enabled tests', () => {
})
test('Disabled for non-dav ressources', () => {
// @ts-expect-error mocking for tests
window.OCA = { Files: { Sidebar: {} } }
const file = new File({
@ -109,10 +115,10 @@ describe('Open sidebar action exec tests', () => {
test('Open sidebar', async () => {
const openMock = vi.fn()
const defaultTabMock = vi.fn()
// @ts-expect-error mocking for tests
window.OCA = { Files: { Sidebar: { open: openMock, setActiveTab: defaultTabMock } } }
const goToRouteMock = vi.fn()
// @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation
window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } }
const file = new File({
@ -138,10 +144,10 @@ describe('Open sidebar action exec tests', () => {
test('Open sidebar for folder', async () => {
const openMock = vi.fn()
const defaultTabMock = vi.fn()
// @ts-expect-error mocking for tests
window.OCA = { Files: { Sidebar: { open: openMock, setActiveTab: defaultTabMock } } }
const goToRouteMock = vi.fn()
// @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation
window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } }
const file = new Folder({
@ -165,8 +171,11 @@ describe('Open sidebar action exec tests', () => {
})
test('Open sidebar fails', async () => {
const openMock = vi.fn(() => { throw new Error('Mock error') })
const openMock = vi.fn(() => {
throw new Error('Mock error')
})
const defaultTabMock = vi.fn()
// @ts-expect-error mocking for tests
window.OCA = { Files: { Sidebar: { open: openMock, setActiveTab: defaultTabMock } } }
vi.spyOn(logger, 'error').mockImplementation(() => vi.fn())

@ -4,12 +4,10 @@
*/
import type { Node, View } from '@nextcloud/files'
import { Permission, FileAction } from '@nextcloud/files'
import InformationSvg from '@mdi/svg/svg/information-outline.svg?raw'
import { FileAction, Permission } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import { isPublicShare } from '@nextcloud/sharing/public'
import InformationSvg from '@mdi/svg/svg/information-outline.svg?raw'
import logger from '../logger.ts'
export const ACTION_DETAILS = 'details'

@ -2,9 +2,11 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { File, Folder, Node, Permission, View, FileAction } from '@nextcloud/files'
import type { Node, View } from '@nextcloud/files'
import { File, FileAction, Folder, Permission } from '@nextcloud/files'
import { describe, expect, test, vi } from 'vitest'
import { action } from './viewInFolderAction'
import { action } from './viewInFolderAction.ts'
const view = {
id: 'trashbin',
@ -126,7 +128,6 @@ describe('View in folder action enabled tests', () => {
describe('View in folder action execute tests', () => {
test('View in folder', async () => {
const goToRouteMock = vi.fn()
// @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation
window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } }
const file = new File({
@ -146,7 +147,6 @@ describe('View in folder action execute tests', () => {
test('View in (sub) folder', async () => {
const goToRouteMock = vi.fn()
// @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation
window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } }
const file = new File({
@ -167,7 +167,6 @@ describe('View in folder action execute tests', () => {
test('View in folder fails without node', async () => {
const goToRouteMock = vi.fn()
// @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation
window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } }
const exec = await action.exec(null as unknown as Node, view, '/')
@ -177,7 +176,6 @@ describe('View in folder action execute tests', () => {
test('View in folder fails without File', async () => {
const goToRouteMock = vi.fn()
// @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation
window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } }
const folder = new Folder({

@ -4,11 +4,10 @@
*/
import type { Node, View } from '@nextcloud/files'
import { isPublicShare } from '@nextcloud/sharing/public'
import FolderMoveSvg from '@mdi/svg/svg/folder-move-outline.svg?raw'
import { FileAction, FileType, Permission } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import FolderMoveSvg from '@mdi/svg/svg/folder-move-outline.svg?raw'
import { isPublicShare } from '@nextcloud/sharing/public'
export const action = new FileAction({
id: 'view-in-folder',

@ -4,12 +4,14 @@
-->
<template>
<NcBreadcrumbs data-cy-files-content-breadcrumbs
<NcBreadcrumbs
data-cy-files-content-breadcrumbs
:aria-label="t('files', 'Current directory path')"
class="files-list__breadcrumbs"
:class="{ 'files-list__breadcrumbs--with-progress': wrapUploadProgressBar }">
<!-- Current path sections -->
<NcBreadcrumb v-for="(section, index) in sections"
<NcBreadcrumb
v-for="(section, index) in sections"
:key="section.dir"
v-bind="section"
dir="auto"
@ -21,7 +23,8 @@
@dragover.native="onDragOver($event, section.dir)"
@drop="onDrop($event, section.dir)">
<template v-if="index === 0" #icon>
<NcIconSvgWrapper :size="20"
<NcIconSvgWrapper
:size="20"
:svg="viewIcon" />
</template>
</NcBreadcrumb>
@ -37,25 +40,24 @@
import type { Node } from '@nextcloud/files'
import type { FileSource } from '../types.ts'
import { basename } from 'path'
import { defineComponent } from 'vue'
import HomeSvg from '@mdi/svg/svg/home.svg?raw'
import { showError } from '@nextcloud/dialogs'
import { Permission } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import HomeSvg from '@mdi/svg/svg/home.svg?raw'
import { basename } from 'path'
import { defineComponent } from 'vue'
import NcBreadcrumb from '@nextcloud/vue/components/NcBreadcrumb'
import NcBreadcrumbs from '@nextcloud/vue/components/NcBreadcrumbs'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import { useNavigation } from '../composables/useNavigation.ts'
import { onDropInternalFiles, dataTransferToFileTree, onDropExternalFiles } from '../services/DropService.ts'
import { useFileListWidth } from '../composables/useFileListWidth.ts'
import { showError } from '@nextcloud/dialogs'
import { useNavigation } from '../composables/useNavigation.ts'
import logger from '../logger.ts'
import { dataTransferToFileTree, onDropExternalFiles, onDropInternalFiles } from '../services/DropService.ts'
import { useDragAndDropStore } from '../store/dragging.ts'
import { useFilesStore } from '../store/files.ts'
import { usePathsStore } from '../store/paths.ts'
import { useSelectionStore } from '../store/selection.ts'
import { useUploaderStore } from '../store/uploader.ts'
import logger from '../logger'
export default defineComponent({
name: 'BreadCrumbs',
@ -148,9 +150,11 @@ export default defineComponent({
getNodeFromSource(source: FileSource): Node | undefined {
return this.filesStore.getNode(source)
},
getFileSourceFromPath(path: string): FileSource | null {
return (this.currentView && this.pathsStore.getPath(this.currentView.id, path)) ?? null
},
getDirDisplayName(path: string): string {
if (path === '/') {
return this.currentView?.name || t('files', 'Home')
@ -170,7 +174,7 @@ export default defineComponent({
}
}
if (node === undefined) {
const view = this.views.find(view => view.params?.dir === dir)
const view = this.views.find((view) => view.params?.dir === dir)
return {
...this.$route,
params: { fileid: view?.params?.fileid ?? '' },
@ -254,12 +258,12 @@ export default defineComponent({
}
// Else we're moving/copying files
const nodes = selection.map(source => this.filesStore.getNode(source)) as Node[]
const nodes = selection.map((source) => this.filesStore.getNode(source)) as Node[]
await onDropInternalFiles(nodes, folder, contents.contents, isCopy)
// Reset selection after we dropped the files
// if the dropped files are within the selection
if (selection.some(source => this.selectedFiles.includes(source))) {
if (selection.some((source) => this.selectedFiles.includes(source))) {
logger.debug('Dropped selection, resetting select store...')
this.selectionStore.reset()
}

@ -20,26 +20,32 @@ export default {
type: Object,
required: true,
},
currentView: {
type: Object,
required: true,
},
render: {
type: Function,
required: true,
},
},
watch: {
source() {
this.updateRootElement()
},
currentView() {
this.updateRootElement()
},
},
mounted() {
this.updateRootElement()
},
methods: {
async updateRootElement() {
const element = await this.render(this.source, this.currentView)

@ -3,7 +3,8 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div v-show="dragover"
<div
v-show="dragover"
data-cy-files-drag-drop-area
class="files-list__drag-drop-notice"
@drop="onDrop">
@ -27,20 +28,19 @@
<script lang="ts">
import type { Folder } from '@nextcloud/files'
import type { PropType } from 'vue'
import type { RawLocation } from 'vue-router'
import { Permission } from '@nextcloud/files'
import { showError } from '@nextcloud/dialogs'
import { Permission } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import { UploadStatus } from '@nextcloud/upload'
import { defineComponent, type PropType } from 'vue'
import debounce from 'debounce'
import { defineComponent } from 'vue'
import TrayArrowDownIcon from 'vue-material-design-icons/TrayArrowDown.vue'
import { useNavigation } from '../composables/useNavigation'
import { dataTransferToFileTree, onDropExternalFiles } from '../services/DropService'
import { useNavigation } from '../composables/useNavigation.ts'
import logger from '../logger.ts'
import type { RawLocation } from 'vue-router'
import { dataTransferToFileTree, onDropExternalFiles } from '../services/DropService.ts'
export default defineComponent({
name: 'DragAndDropNotice',
@ -77,6 +77,7 @@ export default defineComponent({
canUpload() {
return this.currentFolder && (this.currentFolder.permissions & Permission.CREATE) !== 0
},
isQuotaExceeded() {
return this.currentFolder?.attributes?.['quota-available-bytes'] === 0
},
@ -169,7 +170,7 @@ export default defineComponent({
event.stopPropagation()
// Caching the selection
const items: DataTransferItem[] = [...event.dataTransfer?.items || []]
const items: DataTransferItem[] = Array.from(event.dataTransfer?.items || [])
// We need to process the dataTransfer ASAP before the
// browser clears it. This is why we cache the items too.
@ -210,12 +211,13 @@ export default defineComponent({
...this.$route.params,
fileid: String(lastUpload.response!.headers['oc-fileid']),
},
query: {
...this.$route.query,
},
}
// Remove open file from query
delete location.query.openfile
delete location.query?.openfile
this.$router.push(location)
}

@ -14,12 +14,12 @@
</template>
<script lang="ts">
import { FileType, Node, formatFileSize } from '@nextcloud/files'
import Vue from 'vue'
import type { Node } from '@nextcloud/files'
import { FileType, formatFileSize } from '@nextcloud/files'
import Vue from 'vue'
import FileMultipleIcon from 'vue-material-design-icons/FileMultiple.vue'
import FolderIcon from 'vue-material-design-icons/Folder.vue'
import { getSummaryFor } from '../utils/fileUtils.ts'
export default Vue.extend({
@ -40,6 +40,7 @@ export default Vue.extend({
isSingleNode() {
return this.nodes.length === 1
},
isSingleFolder() {
return this.isSingleNode
&& this.nodes[0].type === FileType.Folder
@ -51,6 +52,7 @@ export default Vue.extend({
}
return `${this.summary}${this.size}`
},
size() {
const totalSize = this.nodes.reduce((total, node) => total + node.size || 0, 0)
const size = parseInt(totalSize, 10) || 0
@ -59,6 +61,7 @@ export default Vue.extend({
}
return formatFileSize(size, true)
},
summary(): string {
if (this.isSingleNode) {
const node = this.nodes[0]
@ -75,7 +78,7 @@ export default Vue.extend({
this.$refs.previewImg.replaceChildren()
// Clone icon node from the list
nodes.slice(0, 3).forEach(node => {
nodes.slice(0, 3).forEach((node) => {
const preview = document.querySelector(`[data-cy-files-list-row-fileid="${node.fileid}"] .files-list__row-icon img`)
if (preview) {
const previewElmt = this.$refs.previewImg as HTMLElement

@ -4,7 +4,8 @@
-->
<template>
<tr :class="{
<tr
:class="{
'files-list__row--dragover': dragover,
'files-list__row--loading': isLoading,
'files-list__row--active': isActive,
@ -19,7 +20,8 @@
<span v-if="isFailedSource" class="files-list__row--failed" />
<!-- Checkbox -->
<FileEntryCheckbox :fileid="fileid"
<FileEntryCheckbox
:fileid="fileid"
:is-loading="isLoading"
:nodes="nodes"
:source="source" />
@ -27,13 +29,15 @@
<!-- Link to file -->
<td class="files-list__row-name" data-cy-files-list-row-name>
<!-- Icon or preview -->
<FileEntryPreview ref="preview"
<FileEntryPreview
ref="preview"
:source="source"
:dragover="dragover"
@auxclick.native="execDefaultAction"
@click.native="execDefaultAction" />
<FileEntryName ref="name"
<FileEntryName
ref="name"
:basename="basename"
:extension="extension"
:nodes="nodes"
@ -43,14 +47,16 @@
</td>
<!-- Actions -->
<FileEntryActions v-show="!isRenamingSmallScreen"
<FileEntryActions
v-show="!isRenamingSmallScreen"
ref="actions"
:class="`files-list__row-actions-${uniqueId}`"
:opened.sync="openedMenu"
:source="source" />
<!-- Mime -->
<td v-if="isMimeAvailable"
<td
v-if="isMimeAvailable"
:title="mime"
class="files-list__row-mime"
data-cy-files-list-row-mime
@ -59,7 +65,8 @@
</td>
<!-- Size -->
<td v-if="!compact && isSizeAvailable"
<td
v-if="!compact && isSizeAvailable"
:style="sizeOpacity"
class="files-list__row-size"
data-cy-files-list-row-size
@ -68,25 +75,29 @@
</td>
<!-- Mtime -->
<td v-if="!compact && isMtimeAvailable"
<td
v-if="!compact && isMtimeAvailable"
:style="mtimeOpacity"
class="files-list__row-mtime"
data-cy-files-list-row-mtime
@click="openDetailsIfAvailable">
<NcDateTime v-if="mtime"
<NcDateTime
v-if="mtime"
ignore-seconds
:timestamp="mtime" />
<span v-else>{{ t('files', 'Unknown date') }}</span>
</td>
<!-- View columns -->
<td v-for="column in columns"
<td
v-for="column in columns"
:key="column.id"
:class="`files-list__row-${currentView.id}-${column.id}`"
class="files-list__row-column-custom"
:data-cy-files-list-row-column-custom="column.id"
@click="openDetailsIfAvailable">
<CustomElementRender :current-view="currentView"
<CustomElementRender
:current-view="currentView"
:render="column.render"
:source="source" />
</td>
@ -95,26 +106,24 @@
<script lang="ts">
import { FileType, formatFileSize } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
import { defineComponent } from 'vue'
import { t } from '@nextcloud/l10n'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import { useNavigation } from '../composables/useNavigation.ts'
import CustomElementRender from './CustomElementRender.vue'
import FileEntryActions from './FileEntry/FileEntryActions.vue'
import FileEntryCheckbox from './FileEntry/FileEntryCheckbox.vue'
import FileEntryName from './FileEntry/FileEntryName.vue'
import FileEntryPreview from './FileEntry/FileEntryPreview.vue'
import { useFileListWidth } from '../composables/useFileListWidth.ts'
import { useNavigation } from '../composables/useNavigation.ts'
import { useRouteParameters } from '../composables/useRouteParameters.ts'
import { useActionsMenuStore } from '../store/actionsmenu.ts'
import { useDragAndDropStore } from '../store/dragging.ts'
import { useFilesStore } from '../store/files.ts'
import { useRenamingStore } from '../store/renaming.ts'
import { useSelectionStore } from '../store/selection.ts'
import CustomElementRender from './CustomElementRender.vue'
import FileEntryActions from './FileEntry/FileEntryActions.vue'
import FileEntryCheckbox from './FileEntry/FileEntryCheckbox.vue'
import FileEntryMixin from './FileEntryMixin.ts'
import FileEntryName from './FileEntry/FileEntryName.vue'
import FileEntryPreview from './FileEntry/FileEntryPreview.vue'
export default defineComponent({
name: 'FileEntry',
@ -137,6 +146,7 @@ export default defineComponent({
type: Boolean,
default: false,
},
isSizeAvailable: {
type: Boolean,
default: false,
@ -180,9 +190,9 @@ export default defineComponent({
const conditionals = this.isRenaming
? {}
: {
dragstart: this.onDragStart,
dragover: this.onDragOver,
}
dragstart: this.onDragStart,
dragover: this.onDragOver,
}
return {
...conditionals,
@ -192,6 +202,7 @@ export default defineComponent({
drop: this.onDrop,
}
},
columns() {
// Hide columns if the list is too small
if (this.filesListWidth < 512 || this.compact) {
@ -230,6 +241,7 @@ export default defineComponent({
return this.source.mime
},
size() {
const size = this.source.size
if (size === undefined || isNaN(size) || size < 0) {

@ -3,13 +3,15 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<span :aria-hidden="!title"
<span
:aria-hidden="!title"
:aria-label="title"
class="material-design-icon collectives-icon"
role="img"
v-bind="$attrs"
@click="$emit('click', $event)">
<svg :fill="fillColor"
<svg
:fill="fillColor"
class="material-design-icon__svg"
:width="size"
:height="size"
@ -32,10 +34,12 @@ export default {
type: String,
default: '',
},
fillColor: {
type: String,
default: 'currentColor',
},
size: {
type: Number,
default: 24,

@ -7,10 +7,9 @@
</template>
<script lang="ts">
import StarSvg from '@mdi/svg/svg/star.svg?raw'
import { translate as t } from '@nextcloud/l10n'
import { defineComponent } from 'vue'
import StarSvg from '@mdi/svg/svg/star.svg?raw'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
/**
@ -29,17 +28,20 @@ export default defineComponent({
components: {
NcIconSvgWrapper,
},
data() {
return {
StarSvg,
}
},
async mounted() {
await this.$nextTick()
// MDI default viewBox is "0 0 24 24" but we add a stroke of 10px so we must adjust it
const el = this.$el.querySelector('svg')
el?.setAttribute?.('viewBox', '-4 -4 30 30')
},
methods: {
t,
},

@ -3,10 +3,12 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<td class="files-list__row-actions"
<td
class="files-list__row-actions"
data-cy-files-list-row-actions>
<!-- Render actions -->
<CustomElementRender v-for="action in enabledRenderActions"
<CustomElementRender
v-for="action in enabledRenderActions"
:key="action.id"
:class="'files-list__row-action-' + action.id"
:current-view="currentView"
@ -15,11 +17,12 @@
class="files-list__row-action--inline" />
<!-- Menu actions -->
<NcActions ref="actionsMenu"
<NcActions
ref="actionsMenu"
:boundaries-element="getBoundariesElement"
:container="getBoundariesElement"
:force-name="true"
type="tertiary"
variant="tertiary"
:force-menu="enabledInlineActions.length === 0 /* forceMenu only if no inline actions */"
:inline="enabledInlineActions.length"
:open="openedMenu"
@ -27,7 +30,8 @@
@closed="onMenuClosed">
<!-- Non-destructive actions list -->
<!-- Please keep this block in sync with the destructive actions block below -->
<NcActionButton v-for="action, index in renderedNonDestructiveActions"
<NcActionButton
v-for="action, index in renderedNonDestructiveActions"
:key="action.id"
:ref="`action-${action.id}`"
class="files-list__row-action"
@ -44,7 +48,8 @@
@click="onActionClick(action)">
<template #icon>
<NcLoadingIcon v-if="isLoadingAction(action)" />
<NcIconSvgWrapper v-else
<NcIconSvgWrapper
v-else
class="files-list__row-action-icon"
:svg="action.iconSvgInline([source], currentView)" />
</template>
@ -54,15 +59,15 @@
<!-- Destructive actions list -->
<template v-if="renderedDestructiveActions.length > 0">
<NcActionSeparator />
<NcActionButton v-for="action, index in renderedDestructiveActions"
<NcActionButton
v-for="action, index in renderedDestructiveActions"
:key="action.id"
:ref="`action-${action.id}`"
class="files-list__row-action"
class="files-list__row-action files-list__row-action--destructive"
:class="{
[`files-list__row-action-${action.id}`]: true,
'files-list__row-action--inline': index < enabledInlineActions.length,
'files-list__row-action--menu': isValidMenu(action),
'files-list__row-action--destructive': true,
}"
:close-after-click="!isValidMenu(action)"
:data-cy-files-list-row-action="action.id"
@ -72,7 +77,8 @@
@click="onActionClick(action)">
<template #icon>
<NcLoadingIcon v-if="isLoadingAction(action)" />
<NcIconSvgWrapper v-else
<NcIconSvgWrapper
v-else
class="files-list__row-action-icon"
:svg="action.iconSvgInline([source], currentView)" />
</template>
@ -92,7 +98,8 @@
<NcActionSeparator />
<!-- Submenu actions -->
<NcActionButton v-for="action in enabledSubmenuActions[openedSubmenu?.id]"
<NcActionButton
v-for="action in enabledSubmenuActions[openedSubmenu?.id]"
:key="action.id"
:class="`files-list__row-action-${action.id}`"
class="files-list__row-action--submenu"
@ -113,29 +120,27 @@
</template>
<script lang="ts">
import type { PropType } from 'vue'
import type { FileAction, Node } from '@nextcloud/files'
import type { PropType } from 'vue'
import { DefaultType, NodeStatus } from '@nextcloud/files'
import { defineComponent, inject } from 'vue'
import { t } from '@nextcloud/l10n'
import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
import ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue'
import CustomElementRender from '../CustomElementRender.vue'
import { defineComponent, inject } from 'vue'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import { executeAction } from '../../utils/actionUtils.ts'
import { useActiveStore } from '../../store/active.ts'
import ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue'
import CustomElementRender from '../CustomElementRender.vue'
import { useFileListWidth } from '../../composables/useFileListWidth.ts'
import { useNavigation } from '../../composables/useNavigation'
import { useNavigation } from '../../composables/useNavigation.ts'
import { useRouteParameters } from '../../composables/useRouteParameters.ts'
import actionsMixins from '../../mixins/actionsMixin.ts'
import logger from '../../logger.ts'
import actionsMixins from '../../mixins/actionsMixin.ts'
import { useActiveStore } from '../../store/active.ts'
import { executeAction } from '../../utils/actionUtils.ts'
export default defineComponent({
name: 'FileEntryActions',
@ -157,10 +162,12 @@ export default defineComponent({
type: Boolean,
default: false,
},
source: {
type: Object as PropType<Node>,
required: true,
},
gridMode: {
type: Boolean,
default: false,
@ -199,7 +206,7 @@ export default defineComponent({
if (this.filesListWidth < 768 || this.gridMode) {
return []
}
return this.enabledFileActions.filter(action => {
return this.enabledFileActions.filter((action) => {
try {
return action?.inline?.(this.source, this.currentView)
} catch (error) {
@ -214,7 +221,7 @@ export default defineComponent({
if (this.gridMode) {
return []
}
return this.enabledFileActions.filter(action => typeof action.renderInline === 'function')
return this.enabledFileActions.filter((action) => typeof action.renderInline === 'function')
},
// Actions shown in the menu
@ -229,31 +236,32 @@ export default defineComponent({
// Showing inline first for the NcActions inline prop
...this.enabledInlineActions,
// Then the rest
...this.enabledFileActions.filter(action => action.default !== DefaultType.HIDDEN && typeof action.renderInline !== 'function'),
...this.enabledFileActions.filter((action) => action.default !== DefaultType.HIDDEN && typeof action.renderInline !== 'function'),
].filter((value, index, self) => {
// Then we filter duplicates to prevent inline actions to be shown twice
return index === self.findIndex(action => action.id === value.id)
return index === self.findIndex((action) => action.id === value.id)
})
// Generate list of all top-level actions ids
const topActionsIds = actions.filter(action => !action.parent).map(action => action.id) as string[]
const topActionsIds = actions.filter((action) => !action.parent).map((action) => action.id) as string[]
// Filter actions that are not top-level AND have a valid parent
return actions.filter(action => !(action.parent && topActionsIds.includes(action.parent)))
return actions.filter((action) => !(action.parent && topActionsIds.includes(action.parent)))
},
renderedNonDestructiveActions() {
return this.enabledMenuActions.filter(action => !action.destructive)
return this.enabledMenuActions.filter((action) => !action.destructive)
},
renderedDestructiveActions() {
return this.enabledMenuActions.filter(action => action.destructive)
return this.enabledMenuActions.filter((action) => action.destructive)
},
openedMenu: {
get() {
return this.opened
},
set(value) {
this.$emit('update:opened', value)
},
@ -295,7 +303,9 @@ export default defineComponent({
// if an inline action is rendered in the menu for
// lack of space we use the title first if defined
const title = action.title([this.source], this.currentView)
if (title) return title
if (title) {
return title
}
}
return action.displayName([this.source], this.currentView)
} catch (error) {

@ -3,10 +3,12 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<td class="files-list__row-checkbox"
<td
class="files-list__row-checkbox"
@keyup.esc.exact="resetSelection">
<NcLoadingIcon v-if="isLoading" :name="loadingLabel" />
<NcCheckboxRadioSwitch v-else
<NcCheckboxRadioSwitch
v-else
:aria-label="ariaLabel"
:checked="isSelected"
data-cy-files-list-row-checkbox
@ -23,14 +25,12 @@ import { FileType } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
import { defineComponent } from 'vue'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import logger from '../../logger.ts'
import { useActiveStore } from '../../store/active.ts'
import { useKeyboardStore } from '../../store/keyboard.ts'
import { useSelectionStore } from '../../store/selection.ts'
import logger from '../../logger.ts'
export default defineComponent({
name: 'FileEntryCheckbox',
@ -45,14 +45,17 @@ export default defineComponent({
type: Number,
required: true,
},
isLoading: {
type: Boolean,
default: false,
},
nodes: {
type: Array as PropType<Node[]>,
required: true,
},
source: {
type: Object as PropType<Node>,
required: true,
@ -80,20 +83,25 @@ export default defineComponent({
selectedFiles() {
return this.selectionStore.selected
},
isSelected() {
return this.selectedFiles.includes(this.source.source)
},
index() {
return this.nodes.findIndex((node: Node) => node.source === this.source.source)
},
isFile() {
return this.source.type === FileType.File
},
ariaLabel() {
return this.isFile
? t('files', 'Toggle selection for file "{displayName}"', { displayName: this.source.basename })
: t('files', 'Toggle selection for folder "{displayName}"', { displayName: this.source.basename })
},
loadingLabel() {
return this.isFile
? t('files', 'File is loading')
@ -132,13 +140,13 @@ export default defineComponent({
const lastSelection = this.selectionStore.lastSelection
const filesToSelect = this.nodes
.map(file => file.source)
.map((file) => file.source)
.slice(start, end + 1)
.filter(Boolean) as FileSource[]
// If already selected, update the new selection _without_ the current file
const selection = [...lastSelection, ...filesToSelect]
.filter(source => !isAlreadySelected || source !== this.source.source)
.filter((source) => !isAlreadySelected || source !== this.source.source)
logger.debug('Shift key pressed, selecting all files in between', { start, end, filesToSelect, isAlreadySelected })
// Keep previous lastSelectedIndex to be use for further shift selections
@ -148,7 +156,7 @@ export default defineComponent({
const selection = selected
? [...this.selectedFiles, this.source.source]
: this.selectedFiles.filter(source => source !== this.source.source)
: this.selectedFiles.filter((source) => source !== this.source.source)
logger.debug('Updating selection', { selection })
this.selectionStore.set(selection)

@ -4,13 +4,15 @@
-->
<template>
<!-- Rename input -->
<form v-if="isRenaming"
<form
v-if="isRenaming"
ref="renameForm"
v-on-click-outside="onRename"
:aria-label="t('files', 'Rename file')"
class="files-list__row-rename"
@submit.prevent.stop="onRename">
<NcTextField ref="renameInput"
<NcTextField
ref="renameInput"
:label="renameLabel"
:autofocus="true"
:minlength="1"
@ -20,7 +22,8 @@
@keyup.esc="stopRenaming" />
</form>
<component :is="linkTo.is"
<component
:is="linkTo.is"
v-else
ref="basename"
class="files-list__row-name-link"
@ -43,16 +46,14 @@ import { showError, showSuccess } from '@nextcloud/dialogs'
import { FileType, NodeStatus } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import { defineComponent, inject } from 'vue'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import { getFilenameValidity } from '../../utils/filenameValidity.ts'
import { useFileListWidth } from '../../composables/useFileListWidth.ts'
import { useNavigation } from '../../composables/useNavigation.ts'
import { useRenamingStore } from '../../store/renaming.ts'
import { useRouteParameters } from '../../composables/useRouteParameters.ts'
import { useUserConfigStore } from '../../store/userconfig.ts'
import logger from '../../logger.ts'
import { useRenamingStore } from '../../store/renaming.ts'
import { useUserConfigStore } from '../../store/userconfig.ts'
import { getFilenameValidity } from '../../utils/filenameValidity.ts'
export default defineComponent({
name: 'FileEntryName',
@ -69,6 +70,7 @@ export default defineComponent({
type: String,
required: true,
},
/**
* The extension of the filename
*/
@ -76,14 +78,17 @@ export default defineComponent({
type: String,
required: true,
},
nodes: {
type: Array as PropType<Node[]>,
required: true,
},
source: {
type: Object as PropType<Node>,
required: true,
},
gridMode: {
type: Boolean,
default: false,
@ -115,13 +120,16 @@ export default defineComponent({
isRenaming() {
return this.renamingStore.renamingNode === this.source
},
isRenamingSmallScreen() {
return this.isRenaming && this.filesListWidth < 512
},
newName: {
get(): string {
return this.renamingStore.newNodeName
},
set(newName: string) {
this.renamingStore.newNodeName = newName
},
@ -169,6 +177,7 @@ export default defineComponent({
/**
* If renaming starts, select the filename
* in the input, without the extension.
*
* @param renaming
*/
isRenaming: {
@ -183,7 +192,7 @@ export default defineComponent({
newName() {
// Check validity of the new name
const newName = this.newName.trim?.() || ''
const input = (this.$refs.renameInput as Vue|undefined)?.$el.querySelector('input')
const input = (this.$refs.renameInput as Vue | undefined)?.$el.querySelector('input')
if (!input) {
return
}
@ -204,13 +213,13 @@ export default defineComponent({
methods: {
checkIfNodeExists(name: string) {
return this.nodes.find(node => node.basename === name && node !== this.source)
return this.nodes.find((node) => node.basename === name && node !== this.source)
},
startRenaming() {
this.$nextTick(() => {
// Using split to get the true string length
const input = (this.$refs.renameInput as Vue|undefined)?.$el.querySelector('input')
const input = (this.$refs.renameInput as Vue | undefined)?.$el.querySelector('input')
if (!input) {
logger.error('Could not find the rename input')
return
@ -251,9 +260,7 @@ export default defineComponent({
try {
const status = await this.renamingStore.rename()
if (status) {
showSuccess(
t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName: this.source.basename }),
)
showSuccess(t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName: this.source.basename }))
this.$nextTick(() => {
const nameContainer = this.$refs.basename as HTMLElement | undefined
nameContainer?.focus()

@ -8,7 +8,8 @@
<FolderOpenIcon v-if="dragover" v-once />
<template v-else>
<FolderIcon v-once />
<OverlayIcon :is="folderOverlay"
<OverlayIcon
:is="folderOverlay"
v-if="folderOverlay"
class="files-list__row-icon-overlay" />
</template>
@ -16,16 +17,18 @@
<!-- Decorative images, should not be aria documented -->
<span v-else-if="previewUrl" class="files-list__row-icon-preview-container">
<canvas v-if="hasBlurhash && (backgroundFailed === true || !backgroundLoaded)"
<canvas
v-if="hasBlurhash && (backgroundFailed === true || !backgroundLoaded)"
ref="canvas"
class="files-list__row-icon-blurhash"
aria-hidden="true" />
<img v-if="backgroundFailed !== true"
<img
v-if="backgroundFailed !== true"
:key="source.fileid"
ref="previewImg"
alt=""
class="files-list__row-icon-preview"
:class="{'files-list__row-icon-preview--loaded': backgroundFailed === false}"
:class="{ 'files-list__row-icon-preview--loaded': backgroundFailed === false }"
loading="lazy"
:src="previewUrl"
@error="onBackgroundError"
@ -39,24 +42,25 @@
<FavoriteIcon v-once />
</span>
<OverlayIcon :is="fileOverlay"
<OverlayIcon
:is="fileOverlay"
v-if="fileOverlay"
class="files-list__row-icon-overlay files-list__row-icon-overlay--file" />
</span>
</template>
<script lang="ts">
import type { Node } from '@nextcloud/files'
import type { PropType } from 'vue'
import type { UserConfig } from '../../types.ts'
import { Node, FileType } from '@nextcloud/files'
import { FileType } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
import { ShareType } from '@nextcloud/sharing'
import { getSharingToken, isPublicShare } from '@nextcloud/sharing/public'
import { decode } from 'blurhash'
import { defineComponent } from 'vue'
import AccountGroupIcon from 'vue-material-design-icons/AccountGroup.vue'
import AccountPlusIcon from 'vue-material-design-icons/AccountPlus.vue'
import FileIcon from 'vue-material-design-icons/File.vue'
@ -65,15 +69,13 @@ import FolderOpenIcon from 'vue-material-design-icons/FolderOpen.vue'
import KeyIcon from 'vue-material-design-icons/Key.vue'
import LinkIcon from 'vue-material-design-icons/Link.vue'
import NetworkIcon from 'vue-material-design-icons/NetworkOutline.vue'
import TagIcon from 'vue-material-design-icons/Tag.vue'
import PlayCircleIcon from 'vue-material-design-icons/PlayCircle.vue'
import TagIcon from 'vue-material-design-icons/Tag.vue'
import CollectivesIcon from './CollectivesIcon.vue'
import FavoriteIcon from './FavoriteIcon.vue'
import { isLivePhoto } from '../../services/LivePhotos'
import { useUserConfigStore } from '../../store/userconfig.ts'
import logger from '../../logger.ts'
import { isLivePhoto } from '../../services/LivePhotos.ts'
import { useUserConfigStore } from '../../store/userconfig.ts'
export default defineComponent({
name: 'FileEntryPreview',
@ -97,10 +99,12 @@ export default defineComponent({
type: Object as PropType<Node>,
required: true,
},
dragover: {
type: Boolean,
default: false,
},
gridMode: {
type: Boolean,
default: false,
@ -135,6 +139,7 @@ export default defineComponent({
userConfig(): UserConfig {
return this.userConfigStore.userConfig
},
cropPreviews(): boolean {
return this.userConfig.crop_image_previews === true
},
@ -163,12 +168,12 @@ export default defineComponent({
const previewUrl = this.source.attributes.previewUrl
|| (this.isPublic
? generateUrl('/apps/files_sharing/publicpreview/{token}?file={file}', {
token: this.publicSharingToken,
file: this.source.path,
})
token: this.publicSharingToken,
file: this.source.path,
})
: generateUrl('/core/preview?fileId={fileid}', {
fileid: String(this.source.fileid),
})
fileid: String(this.source.fileid),
})
)
const url = new URL(window.location.origin + previewUrl)
@ -184,7 +189,7 @@ export default defineComponent({
// Handle cropping
url.searchParams.set('a', this.cropPreviews === true ? '0' : '1')
return url.href
} catch (e) {
} catch {
return null
}
},
@ -214,7 +219,7 @@ export default defineComponent({
// Link and mail shared folders
const shareTypes = Object.values(this.source?.attributes?.['share-types'] || {}).flat() as number[]
if (shareTypes.some(type => type === ShareType.Link || type === ShareType.Email)) {
if (shareTypes.some((type) => type === ShareType.Link || type === ShareType.Email)) {
return LinkIcon
}
@ -224,15 +229,15 @@ export default defineComponent({
}
switch (this.source?.attributes?.['mount-type']) {
case 'external':
case 'external-session':
return NetworkIcon
case 'group':
return AccountGroupIcon
case 'collective':
return CollectivesIcon
case 'shared':
return AccountPlusIcon
case 'external':
case 'external-session':
return NetworkIcon
case 'group':
return AccountGroupIcon
case 'collective':
return CollectivesIcon
case 'shared':
return AccountPlusIcon
}
return null

@ -4,7 +4,8 @@
-->
<template>
<tr :class="{'files-list__row--active': isActive, 'files-list__row--dragover': dragover, 'files-list__row--loading': isLoading}"
<tr
:class="{ 'files-list__row--active': isActive, 'files-list__row--dragover': dragover, 'files-list__row--loading': isLoading }"
data-cy-files-list-row
:data-cy-files-list-row-fileid="fileid"
:data-cy-files-list-row-name="source.basename"
@ -20,7 +21,8 @@
<span v-if="isFailedSource" class="files-list__row--failed" />
<!-- Checkbox -->
<FileEntryCheckbox :fileid="fileid"
<FileEntryCheckbox
:fileid="fileid"
:is-loading="isLoading"
:nodes="nodes"
:source="source" />
@ -28,14 +30,16 @@
<!-- Link to file -->
<td class="files-list__row-name" data-cy-files-list-row-name>
<!-- Icon or preview -->
<FileEntryPreview ref="preview"
<FileEntryPreview
ref="preview"
:dragover="dragover"
:grid-mode="true"
:source="source"
@auxclick.native="execDefaultAction"
@click.native="execDefaultAction" />
<FileEntryName ref="name"
<FileEntryName
ref="name"
:basename="basename"
:extension="extension"
:grid-mode="true"
@ -46,18 +50,21 @@
</td>
<!-- Mtime -->
<td v-if="!compact && isMtimeAvailable"
<td
v-if="!compact && isMtimeAvailable"
:style="mtimeOpacity"
class="files-list__row-mtime"
data-cy-files-list-row-mtime
@click="openDetailsIfAvailable">
<NcDateTime v-if="mtime"
<NcDateTime
v-if="mtime"
ignore-seconds
:timestamp="mtime" />
</td>
<!-- Actions -->
<FileEntryActions ref="actions"
<FileEntryActions
ref="actions"
:class="`files-list__row-actions-${uniqueId}`"
:grid-mode="true"
:opened.sync="openedMenu"
@ -67,9 +74,11 @@
<script lang="ts">
import { defineComponent } from 'vue'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import FileEntryActions from './FileEntry/FileEntryActions.vue'
import FileEntryCheckbox from './FileEntry/FileEntryCheckbox.vue'
import FileEntryName from './FileEntry/FileEntryName.vue'
import FileEntryPreview from './FileEntry/FileEntryPreview.vue'
import { useNavigation } from '../composables/useNavigation.ts'
import { useRouteParameters } from '../composables/useRouteParameters.ts'
import { useActionsMenuStore } from '../store/actionsmenu.ts'
@ -78,10 +87,6 @@ import { useFilesStore } from '../store/files.ts'
import { useRenamingStore } from '../store/renaming.ts'
import { useSelectionStore } from '../store/selection.ts'
import FileEntryMixin from './FileEntryMixin.ts'
import FileEntryActions from './FileEntry/FileEntryActions.vue'
import FileEntryCheckbox from './FileEntry/FileEntryCheckbox.vue'
import FileEntryName from './FileEntry/FileEntryName.vue'
import FileEntryPreview from './FileEntry/FileEntryPreview.vue'
export default defineComponent({
name: 'FileEntryGrid',

@ -6,21 +6,20 @@
import type { PropType } from 'vue'
import type { FileSource } from '../types.ts'
import { extname } from 'path'
import { FileType, Permission, Folder, File as NcFile, NodeStatus, Node, getFileActions } from '@nextcloud/files'
import { generateUrl } from '@nextcloud/router'
import { isPublicShare } from '@nextcloud/sharing/public'
import { showError } from '@nextcloud/dialogs'
import { FileType, Folder, getFileActions, File as NcFile, Node, NodeStatus, Permission } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
import { isPublicShare } from '@nextcloud/sharing/public'
import { vOnClickOutside } from '@vueuse/components'
import { extname } from 'path'
import Vue, { computed, defineComponent } from 'vue'
import { action as sidebarAction } from '../actions/sidebarAction.ts'
import logger from '../logger.ts'
import { dataTransferToFileTree, onDropExternalFiles, onDropInternalFiles } from '../services/DropService.ts'
import { getDragAndDropPreview } from '../utils/dragUtils.ts'
import { hashCode } from '../utils/hashUtils.ts'
import { isDownloadable } from '../utils/permissions.ts'
import logger from '../logger.ts'
Vue.directive('onClickOutside', vOnClickOutside)
@ -149,7 +148,7 @@ export default defineComponent({
// If we're dragging a selection, we need to check all files
if (this.selectedFiles.length > 0) {
const nodes = this.selectedFiles.map(source => this.filesStore.getNode(source)) as Node[]
const nodes = this.selectedFiles.map((source) => this.filesStore.getNode(source)) as Node[]
return nodes.every(canDrag)
}
return canDrag(this.source)
@ -236,7 +235,7 @@ export default defineComponent({
}
return actions
.filter(action => {
.filter((action) => {
if (!action.enabled) {
return true
}
@ -262,6 +261,7 @@ export default defineComponent({
/**
* When the source changes, reset the preview
* and fetch the new one.
*
* @param newSource The new value of the source prop
* @param oldSource The previous value
*/
@ -439,7 +439,7 @@ export default defineComponent({
}
const nodes = this.draggingStore.dragging
.map(source => this.filesStore.getNode(source)) as Node[]
.map((source) => this.filesStore.getNode(source)) as Node[]
const image = await getDragAndDropPreview(nodes)
event.dataTransfer?.setDragImage(image, -10, -10)
@ -493,12 +493,12 @@ export default defineComponent({
}
// Else we're moving/copying files
const nodes = selection.map(source => this.filesStore.getNode(source)) as Node[]
const nodes = selection.map((source) => this.filesStore.getNode(source)) as Node[]
await onDropInternalFiles(nodes, folder, contents.contents, isCopy)
// Reset selection after we dropped the files
// if the dropped files are within the selection
if (selection.some(source => this.selectedFiles.includes(source))) {
if (selection.some((source) => this.selectedFiles.includes(source))) {
logger.debug('Dropped selection, resetting select store...')
this.selectionStore.reset()
}

@ -3,8 +3,9 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcActions force-menu
:type="isActive ? 'secondary' : 'tertiary'"
<NcActions
force-menu
:variant="isActive ? 'secondary' : 'tertiary'"
:menu-name="filterName">
<template #icon>
<slot name="icon" />
@ -13,7 +14,8 @@
<template v-if="isActive">
<NcActionSeparator />
<NcActionButton class="files-list-filter__clear-button"
<NcActionButton
class="files-list-filter__clear-button"
close-after-click
@click="$emit('reset-filter')">
{{ t('files', 'Clear filter') }}
@ -24,8 +26,8 @@
<script setup lang="ts">
import { t } from '@nextcloud/l10n'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator'
defineProps<{

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save