From f4a09809c77470f8d2c1d472d7cebd73b6a2ad88 Mon Sep 17 00:00:00 2001 From: skjnldsv Date: Wed, 9 Sep 2026 11:44:02 +0200 Subject: [PATCH 1/3] fix(handlers): break the cycle between the entry and the handlers Registering the built-in handlers on import made the entry call into lib/models, which imports registerHandler back from the entry. Entering the graph through a model rather than through the entry then reached the entry mid-evaluation and called a handler module that had not run yet: Cannot access '__vite_ssr_import_2__' before initialization The registry, the file actions and IHandler move to lib/handlers.ts, which the models import instead. The entry keeps its exports and is now only what it does on import: offer this copy, hold the service, register the defaults. Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: skjnldsv --- __tests__/defaults.spec.ts | 12 ++ lib/handlers.ts | 287 +++++++++++++++++++++++++++++++++++ lib/helpers/handlerHelper.ts | 4 +- lib/index.ts | 287 +---------------------------------- lib/models/audios.ts | 2 +- lib/models/images.ts | 2 +- lib/models/videos.ts | 2 +- lib/scope.ts | 2 +- lib/views/Viewer.vue | 4 +- 9 files changed, 311 insertions(+), 291 deletions(-) create mode 100644 lib/handlers.ts diff --git a/__tests__/defaults.spec.ts b/__tests__/defaults.spec.ts index 94cbba2..542ca7e 100644 --- a/__tests__/defaults.spec.ts +++ b/__tests__/defaults.spec.ts @@ -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') diff --git a/lib/handlers.ts b/lib/handlers.ts new file mode 100644 index 0000000..489ce76 --- /dev/null +++ b/lib/handlers.ts @@ -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 + + /** + * 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() + 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 { + return scope.handlers ??= new Map() +} + +/** + * 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 (-)') + } +} diff --git a/lib/helpers/handlerHelper.ts b/lib/helpers/handlerHelper.ts index 59deb1d..7e7aae2 100644 --- a/lib/helpers/handlerHelper.ts +++ b/lib/helpers/handlerHelper.ts @@ -4,9 +4,9 @@ */ import type { IFile } from '@nextcloud/files' -import type { IHandler } from '../index.ts' +import type { IHandler } from '../handlers.ts' -import { getHandlers } from '../index.ts' +import { getHandlers } from '../handlers.ts' /** * Get a handler by its ID diff --git a/lib/index.ts b/lib/index.ts index 9d05ac8..54064ca 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -2,292 +2,11 @@ * 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 { registerDefaultHandlers } from './defaults.ts' -import { registerImplementation, scope } from './scope.ts' -import { logger } from './services/logger.ts' -import { openWithHistory } from './utils/history.ts' -import { loadTranslations, t } from './utils/l10n.ts' +import { registerImplementation } from './scope.ts' +import { loadTranslations } from './utils/l10n.ts' import { getViewer } from './viewer.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 - - /** - * 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() - 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 { - return scope.handlers ??= new Map() -} - -/** - * 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 (-)') - } -} - // Offer this copy as the page's viewer. Registering costs nothing: the // implementation chunk is only fetched by whichever copy wins, and only // once something actually opens a file. @@ -314,6 +33,8 @@ getViewer() // renders, and a handler registered after that is a file that does not open. registerDefaultHandlers() +export { canView, getHandlers, registerHandler } from './handlers.ts' +export type { IHandler } from './handlers.ts' export { getViewer, Viewer } from './viewer.ts' export type { ViewerAPI, ViewerEmits, ViewerOptions, ViewerProps } from './viewer.ts' export { registerDefaultHandlers } from './defaults.ts' diff --git a/lib/models/audios.ts b/lib/models/audios.ts index 0718099..c6d5bfc 100644 --- a/lib/models/audios.ts +++ b/lib/models/audios.ts @@ -5,7 +5,7 @@ import AudioOutlineSvg from '@mdi/svg/svg/music-note-outline.svg?raw' import { defineCustomElement } from 'vue' -import { registerHandler } from '../index.ts' +import { registerHandler } from '../handlers.ts' import { logger } from '../services/logger.ts' import { t } from '../utils/l10n.ts' diff --git a/lib/models/images.ts b/lib/models/images.ts index 57be0b5..04aefcf 100644 --- a/lib/models/images.ts +++ b/lib/models/images.ts @@ -5,7 +5,7 @@ import { getCapabilities } from '@nextcloud/capabilities' import { defineCustomElement } from 'vue' -import { registerHandler } from '../index.ts' +import { registerHandler } from '../handlers.ts' import { logger } from '../services/logger.ts' import { t } from '../utils/l10n.ts' diff --git a/lib/models/videos.ts b/lib/models/videos.ts index acf1b7f..8e28453 100644 --- a/lib/models/videos.ts +++ b/lib/models/videos.ts @@ -5,7 +5,7 @@ import MovieOutlineSvg from '@mdi/svg/svg/movie-outline.svg?raw' import { defineCustomElement } from 'vue' -import { registerHandler } from '../index.ts' +import { registerHandler } from '../handlers.ts' import { logger } from '../services/logger.ts' import { t } from '../utils/l10n.ts' diff --git a/lib/scope.ts b/lib/scope.ts index 459f0c1..709d245 100644 --- a/lib/scope.ts +++ b/lib/scope.ts @@ -2,7 +2,7 @@ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { IHandler } from './index.ts' +import type { IHandler } from './handlers.ts' import type { Viewer } from './viewer.ts' import { logger } from './services/logger.ts' diff --git a/lib/views/Viewer.vue b/lib/views/Viewer.vue index 7fc1e27..2d64d80 100644 --- a/lib/views/Viewer.vue +++ b/lib/views/Viewer.vue @@ -202,7 +202,7 @@