Skip to content

Commit

Permalink
refactor: Scope package into modules
Browse files Browse the repository at this point in the history
This splits the package into two modules:
1. The default one (`@nextcloud/files`) which provides general utils to work with files or the files app
2. DAV utils (`@nextcloud/files/dav`) which provides WebDAV related utils

For legacy reasons to not make this a breaking release the DAV utils are exported with their prefixed names in the default module,
but this will be then removed with the next major version.

Signed-off-by: Ferdinand Thiessen <[email protected]>
  • Loading branch information
susnux committed Nov 13, 2024
1 parent 7a25265 commit 781517b
Show file tree
Hide file tree
Showing 12 changed files with 263 additions and 104 deletions.
89 changes: 63 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,70 +7,107 @@

Nextcloud Files helpers for Nextcloud apps and libraries.

The `davGetClient` exported function returns a webDAV client that's a wrapper around [webdav's webDAV client](https://www.npmjs.com/package/webdav); All its methods are available here.
This library provides three kinds of utils:
1. WebDAV helper functions to work with the Nextcloud WebDAV interface.
Those functions are available in `@nextcloud/files/dav`
2. Geneal purpose function related to files or folders, like filename validation.
3. Functions and classes to interact with the Nextcloud **files** app, like registering a new view or a file action.

## Usage example
## Usage examples

### Using WebDAV to query favorite nodes
### Files app

#### Register a "New"-menu entry

The "New"-menu allows to create new entries or upload files, it is also possible for other apps to register their own actions here.

```ts
import type { Entry } from '@nextcloud/files'
import { addNewFileMenuEntry } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'

const myEntry: Entry = {
// unique ID of the entry
id: 'my-app',
// The display name in the menu
displayName: t('my-app', 'New something'),
// optionally pass an SVG (string) to be used as the menu entry icon
iconSvgInline: importedSVGFile,
handler(context: Folder, content: Node[]): void {
// `context` is the current active folder
// `content` is the content of the currently active folder
// You can add new files here e.g. use the WebDAV functions to create files.
// If new content is added, ensure to emit the event-bus signals so the files app can update the list.
}
}

addNewFileMenuEntry(myEntry)
```

### WebDAV
The `getClient` exported function returns a webDAV client that's a wrapper around [webdav's webDAV client](https://www.npmjs.com/package/webdav).
All its methods are available here.

#### Using WebDAV to query favorite nodes

```ts
import { davGetClient, davRootPath, getFavoriteNodes } from '@nextcloud/files'
import { getClient, defaultRootPath, getFavoriteNodes } from '@nextcloud/files/dav'

const client = davGetClient()
const client = getClient()
// query favorites for the root folder (meaning all favorites)
const favorites = await getFavoriteNodes(client)
// which is the same as writing:
const favorites = await getFavoriteNodes(client, '/', davRootPath)
const favorites = await getFavoriteNodes(client, '/', defaultRootPath)
```

### Using WebDAV to list all nodes in directory
#### Using WebDAV to list all nodes in directory

```ts
import {
davGetClient,
davGetDefaultPropfind,
davResultToNode,
davRootPath,
davRemoteURL
} from '@nextcloud/files'
getClient,
getDefaultPropfind,
resultToNode,
defaultRootPath,
defaultRemoteURL
} from '@nextcloud/files/dav'

// Get the DAV client for the default remote
const client = davGetClient()
const client = getClient()
// which is the same as writing
const client = davGetClient(davRemoteURL)
const client = getClient(defaultRemoteURL)
// of cause you can also configure another WebDAV remote
const client = davGetClient('https://example.com/dav')
const client = getClient('https://example.com/dav')

const path = '/my-folder/' // the directory you want to list

// Query the directory content using the webdav library
// `davRootPath` is the files root, for Nextcloud this is '/files/USERID', by default the current user is used
const results = client.getDirectoryContents(`${davRootPath}${path}`, {
const results = client.getDirectoryContents(`${defaultRootPath}${path}`, {
details: true,
// Query all required properties for a Node
data: davGetDefaultPropfind()
data: getDefaultPropfind()
})

// Convert the result to an array of Node
const nodes = results.data.map((result) => davResultToNode(r))
// If you specified a different root in the `getDirectoryContents` you must add this also on the `davResultToNode` call:
const nodes = results.data.map((result) => davResultToNode(r, myRoot))
const nodes = results.data.map((result) => resultToNode(r))
// If you specified a different root in the `getDirectoryContents` you must add this also on the `resultToNode` call:
const nodes = results.data.map((result) => resultToNode(r, myRoot))
// Same if you used a different remote URL:
const nodes = results.data.map((result) => davResultToNode(r, myRoot, myRemoteURL))
const nodes = results.data.map((result) => resultToNode(r, myRoot, myRemoteURL))

```

### Using WebDAV to get a Node from a file's name
#### Using WebDAV to get a Node from a file's name

```ts
import { davGetClient, davGetDefaultPropfind, davResultToNode, davRootPath } from '@nextcloud/files'
import { getClient, davGetDefaultPropfind, resultToNode, davRootPath } from '@nextcloud/files'
import { emit } from '@nextcloud/event-bus'
const client = davGetClient()
const client = getClient()
client.stat(`${davRootPath}${filename}`, {
details: true,
data: davGetDefaultPropfind(),
}).then((result) => {
const node = davResultToNode(result.data)
const node = resultToNode(result.data)
emit('files:node:updated', node)
})
```
49 changes: 28 additions & 21 deletions __tests__/dav/dav.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { readFile } from 'node:fs/promises'

import { File, Folder, davRemoteURL, davGetFavoritesReport, davRootPath, getFavoriteNodes, davResultToNode, NodeStatus } from '../../lib'
import {
defaultRemoteURL,
defaultRootPath,
getFavoritesReport,
getFavoriteNodes,
resultToNode,
} from '../../lib/dav/index'
import { File, Folder, NodeStatus } from '../../lib'
import { FileStat } from 'webdav'
import * as auth from '@nextcloud/auth'

Expand All @@ -17,21 +24,21 @@ vi.mock('@nextcloud/router')

describe('DAV functions', () => {
test('root path is correct', () => {
expect(davRootPath).toBe('/files/test')
expect(defaultRootPath).toBe('/files/test')
})

test('remote url is correct', () => {
expect(davRemoteURL).toBe('https://localhost/dav')
expect(defaultRemoteURL).toBe('https://localhost/dav')
})
})

describe('davResultToNode', () => {
describe('resultToNode', () => {
afterEach(() => {
vi.resetAllMocks()
})

/* Result of:
davGetClient().getDirectoryContents(`${davRootPath}${path}`, { details: true })
getClient().getDirectoryContents(`${defaultRootPath}${path}`, { details: true })
*/
const result: FileStat = {
filename: '/files/test/New folder/Neue Textdatei.md',
Expand All @@ -52,12 +59,12 @@ describe('davResultToNode', () => {
}

test('path does not contain root', () => {
const node = davResultToNode(result)
const node = resultToNode(result)
expect(node.basename).toBe(result.basename)
expect(node.displayname).toBe(result.props!.displayname)
expect(node.extension).toBe('.md')
expect(node.source).toBe('https://localhost/dav/files/test/New folder/Neue Textdatei.md')
expect(node.root).toBe(davRootPath)
expect(node.root).toBe(defaultRootPath)
expect(node.path).toBe('/New folder/Neue Textdatei.md')
expect(node.dirname).toBe('/New folder')
expect(node.size).toBe(123)
Expand All @@ -67,7 +74,7 @@ describe('davResultToNode', () => {

test('has correct root set', () => {
const remoteResult = { ...result, filename: '/root/New folder/Neue Textdatei.md' }
const node = davResultToNode(remoteResult, '/root')
const node = resultToNode(remoteResult, '/root')
expect(node.basename).toBe(remoteResult.basename)
expect(node.extension).toBe('.md')
expect(node.root).toBe('/root')
Expand All @@ -78,7 +85,7 @@ describe('davResultToNode', () => {

test('has correct remote path set', () => {
const remoteResult = { ...result, filename: '/root/New folder/Neue Textdatei.md' }
const node = davResultToNode(remoteResult, '/root', 'http://example.com/dav')
const node = resultToNode(remoteResult, '/root', 'http://example.com/dav')
expect(node.basename).toBe(remoteResult.basename)
expect(node.extension).toBe('.md')
expect(node.source).toBe('http://example.com/dav/root/New folder/Neue Textdatei.md')
Expand All @@ -88,7 +95,7 @@ describe('davResultToNode', () => {

test('has correct displayname set', () => {
const remoteResult = { ...result, filename: '/root/New folder/Neue Textdatei.md' }
const node = davResultToNode(remoteResult, '/root', 'http://example.com/dav')
const node = resultToNode(remoteResult, '/root', 'http://example.com/dav')
expect(node.basename).toBe(remoteResult.basename)
expect(node.displayname).toBe(remoteResult.props!.displayname)
})
Expand All @@ -99,7 +106,7 @@ describe('davResultToNode', () => {

const remoteResult = { ...result, filename: '/root/New folder/Neue Textdatei.md' }
remoteResult.props = { ...remoteResult.props, ...{ 'owner-id': 'user1' } } as FileStat['props']
const node = davResultToNode(remoteResult, '/root', 'http://example.com/remote.php/dav')
const node = resultToNode(remoteResult, '/root', 'http://example.com/remote.php/dav')

expect(node.isDavRessource).toBe(true)
expect(node.owner).toBe('user1')
Expand All @@ -110,7 +117,7 @@ describe('davResultToNode', () => {

const remoteResult = { ...result, filename: '/root/New folder/Neue Textdatei.md' }
remoteResult.props = { ...remoteResult.props, ...{ 'owner-id': 123456789 } } as FileStat['props']
const node = davResultToNode(remoteResult, '/root', 'http://example.com/remote.php/dav')
const node = resultToNode(remoteResult, '/root', 'http://example.com/remote.php/dav')

expect(node.isDavRessource).toBe(true)
expect(node.owner).toBe('123456789')
Expand All @@ -120,7 +127,7 @@ describe('davResultToNode', () => {
vi.spyOn(auth, 'getCurrentUser').mockReturnValue({ uid: 'user1', displayName: 'User 1', isAdmin: false })

const remoteResult = { ...result, filename: '/root/New folder/Neue Textdatei.md' }
const node = davResultToNode(remoteResult, '/root', 'http://example.com/remote.php/dav')
const node = resultToNode(remoteResult, '/root', 'http://example.com/remote.php/dav')

expect(node.isDavRessource).toBe(true)
expect(node.owner).toBe('user1')
Expand All @@ -131,7 +138,7 @@ describe('davResultToNode', () => {

const remoteResult = { ...result }
remoteResult.props!.fileid = 1
const node = davResultToNode(remoteResult)
const node = resultToNode(remoteResult)
expect(node.status).toBeUndefined()
})

Expand All @@ -140,7 +147,7 @@ describe('davResultToNode', () => {

const remoteResult = { ...result }
remoteResult.props!.fileid = -1
const node = davResultToNode(remoteResult)
const node = resultToNode(remoteResult)
expect(node.status).toBe(NodeStatus.FAILED)
})

Expand All @@ -151,14 +158,14 @@ describe('davResultToNode', () => {
const remoteResult = { ...result }
remoteResult.lastmod = 'invalid'
remoteResult.props!.creationdate = 'invalid'
const node = davResultToNode(remoteResult)
const node = resultToNode(remoteResult)
expect(node.mtime).toBeUndefined()
expect(node.crtime).toBeUndefined()

// Zero dates
remoteResult.lastmod = 'Thu, 01 Jan 1970 00:00:00 GMT'
remoteResult.props!.creationdate = 'Thu, 01 Jan 1970 00:00:00 GMT'
const node2 = davResultToNode(remoteResult)
const node2 = resultToNode(remoteResult)
expect(node2.mtime).toBeUndefined()
expect(node2.crtime).toBeUndefined()
})
Expand Down Expand Up @@ -194,8 +201,8 @@ describe('DAV requests', () => {

// Check client was called correctly
expect(client.getDirectoryContents).toBeCalled()
expect(client.getDirectoryContents.mock.lastCall?.at(0)).toBe(`${davRootPath}/`)
expect(client.getDirectoryContents.mock.lastCall?.at(1)?.data).toBe(davGetFavoritesReport())
expect(client.getDirectoryContents.mock.lastCall?.at(0)).toBe(`${defaultRootPath}/`)
expect(client.getDirectoryContents.mock.lastCall?.at(1)?.data).toBe(getFavoritesReport())
expect(client.getDirectoryContents.mock.lastCall?.at(1)?.headers?.method).toBe('REPORT')

// Check for correct output
Expand Down Expand Up @@ -227,8 +234,8 @@ describe('DAV requests', () => {

// Check client was called correctly
expect(client.getDirectoryContents).toBeCalled()
expect(client.getDirectoryContents.mock.lastCall?.at(0)).toBe(`${davRootPath}/Neuer Ordner`)
expect(client.getDirectoryContents.mock.lastCall?.at(1)?.data).toBe(davGetFavoritesReport())
expect(client.getDirectoryContents.mock.lastCall?.at(0)).toBe(`${defaultRootPath}/Neuer Ordner`)
expect(client.getDirectoryContents.mock.lastCall?.at(1)?.data).toBe(getFavoritesReport())
expect(client.getDirectoryContents.mock.lastCall?.at(1)?.headers?.method).toBe('REPORT')

// There are no inner nodes
Expand Down
4 changes: 2 additions & 2 deletions __tests__/dav/davPermissions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/
import { describe, it, expect } from 'vitest'

import { davParsePermissions } from '../../lib/dav/davPermissions'
import { parsePermissions } from '../../lib/dav/davPermissions'
import { Permission } from '../../lib/permissions'

const dataSet = [
Expand All @@ -30,7 +30,7 @@ describe('davParsePermissions', () => {
dataSet.forEach(({ input, permissions }) => {
it(`expect ${input} to be ${permissions}`, () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(davParsePermissions(input as any as string)).toBe(permissions)
expect(parsePermissions(input as any as string)).toBe(permissions)
})
})
})
37 changes: 22 additions & 15 deletions __tests__/dav/davProperties.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,25 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'
import { XMLValidator } from 'fast-xml-parser'

import {
davGetDefaultPropfind,
davGetFavoritesReport,
defaultDavNamespaces,
defaultDavProperties,
getDavNameSpaces,
getDavProperties,
getDefaultPropfind,
getFavoritesReport,
getRecentSearch,
registerDavProperty,
defaultDavNamespaces,
defaultDavProperties,
davGetRecentSearch,
} from '../../lib/dav/davProperties'

import logger from '../../lib/utils/logger'

declare global {
interface Window {
_nc_dav_namespaces?: string[]
_nc_dav_properties?: string[]
}
}

describe('DAV Properties', () => {

beforeEach(() => {
Expand All @@ -42,19 +49,19 @@ describe('DAV Properties', () => {
defaultDavProperties.forEach(p => expect(props.includes(p)).toBe(true))
})

test('davGetDefaultPropfind', () => {
expect(typeof davGetDefaultPropfind()).toBe('string')
expect(XMLValidator.validate(davGetDefaultPropfind())).toBe(true)
test('getDefaultPropfind', () => {
expect(typeof getDefaultPropfind()).toBe('string')
expect(XMLValidator.validate(getDefaultPropfind())).toBe(true)
})

test('davGetFavoritesReport', () => {
expect(typeof davGetFavoritesReport()).toBe('string')
expect(XMLValidator.validate(davGetFavoritesReport())).toBe(true)
test('getFavoritesReport', () => {
expect(typeof getFavoritesReport()).toBe('string')
expect(XMLValidator.validate(getFavoritesReport())).toBe(true)
})

test('davGetFavoritesReport', () => {
expect(typeof davGetRecentSearch(1337)).toBe('string')
expect(XMLValidator.validate(davGetRecentSearch(1337))).toBe(true)
test('getFavoritesReport', () => {
expect(typeof getRecentSearch(1337)).toBe('string')
expect(XMLValidator.validate(getRecentSearch(1337))).toBe(true)
})

test('registerDavProperty registers successfully', () => {
Expand Down Expand Up @@ -116,7 +123,7 @@ describe('DAV Properties', () => {
'd:getetag',
'd:getlastmodified',
'd:resourcetype',
// Nextcloud autmatically includes:
// Nextcloud automatically includes:
// 'd:source'
// Only valid for GET requests
// 'd:getcontentlanguage',
Expand Down
Loading

0 comments on commit 781517b

Please sign in to comment.