Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions __tests__/component/Viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,17 @@ describe('Viewer.open()', () => {
expect(modalProps().show).toBe(true)
})

it('titles the modal with the display name when the server gives one', async () => {
const { vm, wrapper, modalName } = mountViewer([imageHandler()])
// A version of a file is served under its version id but reads as a date
const version = makeFile({ basename: '1737542400', mime: 'image/jpeg', displayname: '22 January 2025, 11:20:00' })

await vm.open([version], version)
await wrapper.vm.$nextTick()

expect(modalName()).toBe('22 January 2025, 11:20:00')
})

it('filters currentFileList to files of the same handler group', async () => {
const pdfHandler = makeHandler({
id: 'pdf',
Expand Down Expand Up @@ -305,6 +316,30 @@ describe('Viewer action submenu', () => {
})
})

describe('Viewer sidebar', () => {
const sidebarButton = (wrapper: ReturnType<typeof mountViewer>['wrapper']) => wrapper.findAll('.nc-action-button-stub').find((button) => button.text().includes('Open sidebar'))

it('offers the sidebar for an ordinary file', async () => {
const { vm, wrapper } = mountViewer([imageHandler()])
const f1 = makeFile({ mime: 'image/jpeg' })

await vm.open([f1], f1)
await wrapper.vm.$nextTick()

expect(sidebarButton(wrapper)).toBeTruthy()
})

it('does not offer it for a file the sidebar cannot resolve', async () => {
const { vm, wrapper } = mountViewer([imageHandler()])
const version = makeFile({ mime: 'image/jpeg' })

await vm.open([version], version, { enableSidebar: false })
await wrapper.vm.$nextTick()

expect(sidebarButton(wrapper)).toBeUndefined()
})
})

describe('Viewer loadMore', () => {
it('appends files returned by loadMore when reaching the last item', async () => {
const f1 = makeFile({ basename: 'f1.jpg', mime: 'image/jpeg' })
Expand Down
12 changes: 12 additions & 0 deletions __tests__/defaults.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@ describe('default handlers', () => {
expect([...scope.handlers!.keys()].sort()).toEqual(['audios', 'images', 'videos'])
})

it('can be reached by importing one of the handler modules first', async () => {
// Entering the graph anywhere but the entry used to hit the entry
// mid-evaluation, and the handler it was about to register was not
// initialised yet
vi.resetModules()
const { registerImageHandler } = await import('../lib/models/images.ts')

registerImageHandler()

expect(scope.handlers!.has('images')).toBe(true)
})

it('do not complain about themselves when asked for explicitly', async () => {
const { registerDefaultHandlers } = await importPackage()
const { logger } = await import('../lib/services/logger.ts')
Expand Down
3 changes: 3 additions & 0 deletions __tests__/factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ interface MakeFileOptions {
mtime?: Date
size?: number
root?: string
/** What the server calls the file, when that differs from its name */
displayname?: string
}

/**
Expand All @@ -41,6 +43,7 @@ export function makeFile(options: MakeFileOptions = {}): File {
// A dav node reports whatever it was given, and a file nobody can
// read is not what these tests are about unless they say so
permissions: options.permissions ?? Permission.ALL,
displayname: options.displayname,
})
}

Expand Down
2 changes: 1 addition & 1 deletion lib/components/ImageEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<template>
<NcImageEditor
:src="file.source"
:label="file.basename"
:label="file.displayname"
:exportOptions="exportOptions"
:saving="saving"
class="viewer__image-editor"
Expand Down
2 changes: 1 addition & 1 deletion lib/components/Images.vue
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ const previewPath = computed(() => getPreviewIfAny(props.file))

const zoomHeight = computed(() => Math.round(height.value * zoomRatio.value))
const zoomWidth = computed(() => Math.round(width.value * zoomRatio.value))
const alt = computed(() => props.file.basename)
const alt = computed(() => props.file.displayname)

const imgStyle = computed(() => {
if (zoomRatio.value === 1) {
Expand Down
2 changes: 1 addition & 1 deletion lib/composables/useViewerProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { computed, ref, watch } from 'vue'
* @param props The viewer props
*/
export function useViewerProps(props: ViewerProps) {
const filename = computed(() => props.file.basename)
const filename = computed(() => props.file.displayname)

// Src is not a computed as we want to be able to change it on error.
// Use the encoded source so special characters in the name don't break the
Expand Down
287 changes: 287 additions & 0 deletions lib/handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
/*!
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { IFile, IFileAction, INode } from '@nextcloud/files'

import FileSvg from '@mdi/svg/svg/file.svg?raw'
import OpenInAppSvg from '@mdi/svg/svg/open-in-app.svg?raw'
import { DefaultType, FileType, getFileActions, Permission, registerFileAction } from '@nextcloud/files'
import { scope } from './scope.ts'
import { logger } from './services/logger.ts'
import { openWithHistory } from './utils/history.ts'
import { t } from './utils/l10n.ts'

/** Default click-to-open action id */
const ACTION_VIEWER = 'viewer-open'
/** Parent "Open with …" selector menu id */
const ACTION_VIEWER_MENU = 'viewer-open-with'

export interface IHandler {
/**
* Unique identifier for the handler
*/
id: string

/**
* The handler translated name
*/
displayName: string

/**
* Optional icon for the handler
*/
iconSvgInline?: string

/**
* The custom element tag name to use for this handler.
*/
tagname: string

/**
* Identifier to group handlers by.
* When opening a folder we'll check
* against all handlers that are enabled
* for the given group AND matches the
* group property.
*/
group?: string

/**
* Is this enabled for the given mimes ?
*/
enabled: (nodes: IFile[]) => boolean

/**
* Optional function to preload data for the given node.
* This will be called for the previous and next nodes on
* opening a file to allow the handler to be faster when navigating.
*
* @param node - The node to preload data for
* @return A promise that resolves when the data is preloaded
*/
preload?: (node: IFile) => Promise<void>

/**
* Viewer modal theme (one of 'dark', 'light', 'default')
*/
theme?: 'dark' | 'light' | 'default'

/**
* Whether this handler supports editing the current file in place.
* When true the viewer shows an "Edit" action that toggles the handler's
* `editing` prop (e.g. the image editor).
*/
canEdit?: boolean
}

/**
* Whether the viewer can open the given nodes.
*
* Answers what clicking them would do without opening anything, for a
* caller that has to decide whether to offer the viewer at all — a
* "View" button in a sidebar, say. Only files are supported, folders
* never match, and a set of nodes matches when one handler takes all
* of them.
*
* @param nodes - The node, or nodes, to test the handlers against
*/
export function canView(nodes: INode | INode[]): boolean {
return countEnabledHandlers(Array.isArray(nodes) ? nodes : [nodes], 1)
}

/**
* Whether at least `min` registered handlers can open the given nodes.
* Only files are supported, folders never match.
*
* @param nodes - The nodes to test the handlers against
* @param min - The minimum number of matching handlers required
*/
function countEnabledHandlers(nodes: INode[], min: number): boolean {
if (nodes.length === 0 || nodes.some((node) => node.type !== FileType.File)) {
return false
}

// Nothing to show for a file this user cannot read. Deleted files pass
// this: the trashbin reports them as readable, and previewing them is
// the point. A node that is not dav-backed always reports readable.
if (nodes.some((node) => (node.permissions & Permission.READ) === 0)) {
return false
}

let count = 0
for (const handler of getHandlers().values()) {
if (handler.enabled(nodes as IFile[])) {
count++
}
if (count >= min) {
return true
}
}
return false
}

/**
* Default action, triggered on file click. Opens the viewer with the first
* matching handler. Hidden from the actions menu to avoid cluttering it, but
* it is what makes any viewable file open on a single click, regardless of how
* many handlers are registered.
*/
const defaultViewerAction: IFileAction = {
id: ACTION_VIEWER,
displayName: () => t('View'),
iconSvgInline: () => OpenInAppSvg,
order: -1000,
default: DefaultType.DEFAULT,

enabled: ({ nodes }) => countEnabledHandlers(nodes, 1),
async exec({ nodes, contents, view, folder }) {
if (nodes[0]?.type !== FileType.File) {
return null
}

openWithHistory(contents as IFile[], nodes[0] as IFile, view, folder)
return null
},
}

/**
* Parent "Open with …" menu. Only shown when more than one handler can open
* the given nodes, so the user is offered a real choice between them.
*/
const openWithViewerAction: IFileAction = {
id: ACTION_VIEWER_MENU,
displayName: () => t('Open with …'),
iconSvgInline: () => OpenInAppSvg,
order: -999,

enabled: ({ nodes }) => countEnabledHandlers(nodes, 2),
exec() {
return Promise.resolve(null)
},
}

/**
* Register a new handler for the viewer.
* This needs to be called before the viewer is initialized to ensure the handler is available.
* So this should be called from an initialization script (`OCP\Util::addInitScript`).
*
* @param handler - The handler to register
* @throws {Error} if the handler is invalid
*/
export function registerHandler(handler: IHandler): void {
validateHandler(handler)

scope.handlers ??= new Map<string, IHandler>()
if (scope.handlers.has(handler.id)) {
logger.warn(`Handler with id ${handler.id} is already registered.`)
return
}

scope.handlers.set(handler.id, handler)

// Selector entry shown under the "Open with …" menu. Opening forces this
// specific handler regardless of registration order.
registerFileAction({
id: `${ACTION_VIEWER_MENU}-${handler.id}`,
// TRANSLATORS: handler is the translated name of the handler.
displayName: () => t('Open with {handler}', { handler: handler.displayName }),

iconSvgInline: () => handler.iconSvgInline ?? FileSvg,
parent: ACTION_VIEWER_MENU,
order: -999,

enabled: ({ nodes }) => {
if (nodes.length === 0 || nodes.some((node) => node.type !== FileType.File)) {
return false
}

return handler.enabled(nodes as IFile[])
},
async exec({ nodes, contents, view, folder }) {
if (nodes[0]?.type !== FileType.File) {
return null
}

openWithHistory(contents as IFile[], nodes[0] as IFile, view, folder, handler.id)
return null
},
})

// Register the shared actions only once.
const actions = getFileActions()
if (!actions.find((action) => action.id === ACTION_VIEWER)) {
registerFileAction(defaultViewerAction)
registerFileAction(openWithViewerAction)

logger.info('Registered viewer file actions', { id: ACTION_VIEWER, menu: ACTION_VIEWER_MENU })
}
}

/**
* Get all registered handlers.
*/
export function getHandlers(): Map<string, IHandler> {
return scope.handlers ??= new Map<string, IHandler>()
}

/**
* Validate the handler object.
*
* @param handler - The handler to validate
*/
function validateHandler(handler: IHandler): void {
const { id, displayName, group, enabled } = handler
if (typeof id !== 'string' || id.trim() === '') {
throw new Error('Handler id must be a non-empty string')
}

if (typeof displayName !== 'string' || displayName.trim() === '') {
throw new Error('Handler displayName must be a non-empty string')
}

if (typeof handler.tagname !== 'string' || handler.tagname.trim() === '') {
throw new Error('Handler tagname must be a non-empty string')
}

if (group && (typeof group !== 'string' || group.trim() === '')) {
throw new Error('Handler group must be a non-empty string if provided')
}

if (typeof enabled !== 'function') {
throw new Error('Handler enabled must be a function')
}

if (handler.preload && typeof handler.preload !== 'function') {
throw new Error('Handler preload must be a function if provided')
}

if (handler.theme && !['dark', 'light', 'default'].includes(handler.theme)) {
throw new Error("Handler theme must be one of 'dark', 'light', 'default' if provided")
}

validateCustomElementName(handler.tagname)
}

/**
* Validate that the given tag name is a valid custom element name.
*
* @param tagname - The custom element tag name to validate
*/
function validateCustomElementName(tagname: string): void {
if (!tagname.includes('-')) {
throw new Error('Handler tagname must contain a hyphen (-)')
}
if (/^[A-Z]/.test(tagname)) {
throw new Error('Handler tagname must not start with an uppercase letter')
}
if (/--/.test(tagname)) {
throw new Error('Handler tagname must not contain consecutive hyphens (--)')
}
if (tagname.startsWith('-') || tagname.endsWith('-')) {
throw new Error('Handler tagname must not start or end with a hyphen (-)')
}
if (!/^[a-z][a-z0-9-]*$/.test(tagname)) {
throw new Error('Handler tagname must only contain lowercase letters, numbers, and hyphens (-)')
}
}
Loading
Loading