diff --git a/apps/files/lib/Controller/ViewController.php b/apps/files/lib/Controller/ViewController.php index 054bb106e150f..d826f6e8745aa 100644 --- a/apps/files/lib/Controller/ViewController.php +++ b/apps/files/lib/Controller/ViewController.php @@ -17,7 +17,6 @@ use OCA\Files\Event\LoadSidebar; use OCA\Files\Service\UserConfig; use OCA\Files\Service\ViewConfig; -use OCA\Viewer\Event\LoadViewer; use OCP\App\IAppManager; use OCP\AppFramework\Controller; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -197,10 +196,6 @@ public function index($dir = '', $view = '', $fileid = null) { $this->eventDispatcher->dispatchTyped(new ResourcesLoadAdditionalScriptsEvent()); $this->eventDispatcher->dispatchTyped(new LoadSidebar()); $this->eventDispatcher->dispatchTyped(new LoadSearchPlugins()); - // Load Viewer scripts - if (class_exists(LoadViewer::class)) { - $this->eventDispatcher->dispatchTyped(new LoadViewer()); - } $this->initialState->provideInitialState('templates_enabled', true); $this->initialState->provideInitialState('templates_path', $this->templateManager->hasTemplateDirectory() ? $this->templateManager->getTemplatePath() : false); diff --git a/apps/files/src/actions/openLocallyAction.ts b/apps/files/src/actions/openLocallyAction.ts index 963df49e44442..d93f06ed4cf34 100644 --- a/apps/files/src/actions/openLocallyAction.ts +++ b/apps/files/src/actions/openLocallyAction.ts @@ -3,18 +3,20 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { IFileAction } from '@nextcloud/files' +import type { ActionContextSingle, IFile, IFileAction, INode } 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 { FileType } from '@nextcloud/files' import { loadState } from '@nextcloud/initial-state' import { translate as t } from '@nextcloud/l10n' import { encodePath } from '@nextcloud/paths' import { generateOcsUrl } from '@nextcloud/router' import { isPublicShare } from '@nextcloud/sharing/public' +import { getViewer } from '@nextcloud/viewer' import { logger } from '../utils/logger.ts' import { isSyncable } from '../utils/permissions.ts' @@ -44,8 +46,8 @@ export const action: IFileAction = { return isSyncable(nodes[0]!) }, - async exec({ nodes }) { - await attemptOpenLocalClient(nodes[0].path) + async exec({ nodes, contents }) { + await attemptOpenLocalClient(nodes[0], contents) return null }, @@ -60,15 +62,19 @@ export const action: IFileAction = { * 2. Open online: The viewer is used to open the file. * 3. Close the dialog and nothing happens (abort). * - * @param path - The path to open + * @param node - The file to open + * @param contents - The folder the file is in, so the viewer can navigate */ -async function attemptOpenLocalClient(path: string) { - await openLocalClient(path) +async function attemptOpenLocalClient(node: INode, contents: ActionContextSingle['contents']) { + await openLocalClient(node.path) const result = await confirmLocalEditDialog() if (result === 'local') { - await openLocalClient(path) + await openLocalClient(node.path) } else if (result === 'online') { - window.OCA.Viewer.open({ path }) + // The listing carries the folders too, and the viewer pages through + // files alone + const files = contents.filter((entry): entry is IFile => entry.type === FileType.File) + await getViewer().open(files, node as IFile) } } diff --git a/apps/files_sharing/lib/DefaultPublicShareTemplateProvider.php b/apps/files_sharing/lib/DefaultPublicShareTemplateProvider.php index af755fc0eb444..7e2091d23cba4 100644 --- a/apps/files_sharing/lib/DefaultPublicShareTemplateProvider.php +++ b/apps/files_sharing/lib/DefaultPublicShareTemplateProvider.php @@ -11,7 +11,6 @@ use OCA\FederatedFileSharing\FederatedShareProvider; use OCA\Files_Sharing\AppInfo\Application; use OCA\Files_Sharing\Event\BeforeTemplateRenderedEvent; -use OCA\Viewer\Event\LoadViewer; use OCP\Accounts\IAccountManager; use OCP\AppFramework\Http\ContentSecurityPolicy; use OCP\AppFramework\Http\Template\ExternalShareMenuAction; @@ -118,11 +117,6 @@ public function renderPage(IShare $share, string $token, string $path): Template $isFileRequest = $attributes?->getAttribute('fileRequest', 'enabled') === true; $this->initialState->provideInitialState('isFileRequest', $isFileRequest); - // Load Viewer scripts - if (class_exists(LoadViewer::class)) { - $this->eventDispatcher->dispatchTyped(new LoadViewer()); - } - $this->initialState->provideInitialState('templates', $this->templateManager->listCreators()); // Allow external apps to register their scripts diff --git a/apps/files_versions/src/utils/versions.spec.ts b/apps/files_versions/src/utils/versions.spec.ts new file mode 100644 index 0000000000000..b80f9579d2554 --- /dev/null +++ b/apps/files_versions/src/utils/versions.spec.ts @@ -0,0 +1,99 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { INode } from '@nextcloud/files' +import type { Version } from './versions.ts' + +import { File, Permission } from '@nextcloud/files' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@nextcloud/auth', async (orig) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- vitest importOriginal idiom + const actual = await orig() + return { + ...actual, + getCurrentUser: () => ({ uid: 'alice', displayName: 'Alice', isAdmin: false }), + } +}) + +const { versionToNode } = await import('./versions.ts') + +/** + * Build the file a version belongs to. + */ +function makeNode(): INode { + return new File({ + id: 42, + source: 'https://cloud.example.com/remote.php/dav/files/alice/holiday.jpg', + root: '/files/alice', + owner: 'alice', + mime: 'image/jpeg', + mtime: new Date('2025-01-22T11:20:00Z'), + }) +} + +/** + * Build a version of that file, as fetchVersions returns them. + * + * @param overrides - Fields to override on the version + */ +function makeVersion(overrides: Partial = {}): Version { + return { + fileId: '42', + label: '', + author: 'alice', + authorName: 'Alice', + filename: '/versions/alice/versions/42/1737542400', + basename: '22 January 2025, 11:20:00', + mime: 'image/jpeg', + etag: 'abc123', + size: 1024, + type: 'file', + mtime: 1737542400000, + permissions: 'R', + previewUrl: 'https://cloud.example.com/apps/files_versions/preview?file=/holiday.jpg&version=1737542400', + url: '/remote.php/dav/versions/alice/versions/42/1737542400', + source: 'https://cloud.example.com/remote.php/dav/versions/alice/versions/42/1737542400', + fileVersion: '1737542400', + ...overrides, + } as Version +} + +describe('versionToNode', () => { + let node: INode + + beforeEach(() => { + node = makeNode() + }) + + it('points at the version content rather than at its thumbnail', () => { + const version = versionToNode(makeVersion(), node) + + expect(version.source).toBe('https://cloud.example.com/remote.php/dav/versions/alice/versions/42/1737542400') + // The version preview is a 250px thumbnail meant for the list, so the + // viewer has to load the version itself + expect(version.attributes.hasPreview).toBe(false) + }) + + it('reads as the date it was taken, not as its id on the server', () => { + const version = versionToNode(makeVersion(), node) + + expect(version.basename).toBe('1737542400') + expect(version.displayname).toBe('22 January 2025, 11:20:00') + }) + + it('is read-only, whatever the file it belongs to allows', () => { + const version = versionToNode(makeVersion(), node) + + expect(version.permissions).toBe(Permission.READ) + }) + + it('takes the mime of the file when the version reports none', () => { + // The current version comes back with an empty mime + const version = versionToNode(makeVersion({ mime: '' }), node) + + expect(version.mime).toBe('image/jpeg') + }) +}) diff --git a/apps/files_versions/src/utils/versions.ts b/apps/files_versions/src/utils/versions.ts index 77995e85d7677..451de38eada99 100644 --- a/apps/files_versions/src/utils/versions.ts +++ b/apps/files_versions/src/utils/versions.ts @@ -8,6 +8,7 @@ import type { FileStat, ResponseDataDetailed } from 'webdav' import { getCurrentUser } from '@nextcloud/auth' import axios from '@nextcloud/axios' +import { File, Permission } from '@nextcloud/files' import { getClient } from '@nextcloud/files/dav' import { getCanonicalLocale } from '@nextcloud/l10n' import { encodePath, join } from '@nextcloud/paths' @@ -36,6 +37,39 @@ export interface Version { const client = getClient() +/** + * Build a node for a version, so that it can be handed to the viewer. + * + * The version content lives at its own dav endpoint, which is what the + * node points at: the preview a version has is a 250px thumbnail meant + * for this list, too small to show as the file itself. A version is + * named after its id on the server and reads as the date it was taken, + * so that is its display name. + * + * @param version - The version to build a node for + * @param node - The file the version belongs to + */ +export function versionToNode(version: Version, node: INode): File { + const owner = getCurrentUser()?.uid ?? null + + return new File({ + id: Number(version.fileId), + source: version.source, + root: `/versions/${owner}/versions/${version.fileId}`, + displayname: version.basename, + // The current version reports no mime of its own + mime: version.mime || node.mime || 'application/octet-stream', + size: version.size, + mtime: new Date(version.mtime), + owner, + permissions: Permission.READ, + attributes: { + etag: version.etag, + hasPreview: false, + }, + }) +} + /** * Get file versions for a given node * diff --git a/apps/files_versions/src/views/FilesVersionsSidebarTab.vue b/apps/files_versions/src/views/FilesVersionsSidebarTab.vue index e215c0eb784c7..839fb82d31936 100644 --- a/apps/files_versions/src/views/FilesVersionsSidebarTab.vue +++ b/apps/files_versions/src/views/FilesVersionsSidebarTab.vue @@ -41,12 +41,13 @@ diff --git a/apps/settings/lib/Settings/Personal/ServerDevNotice.php b/apps/settings/lib/Settings/Personal/ServerDevNotice.php index f4c4f04a33ba4..b0b6c7cf22047 100644 --- a/apps/settings/lib/Settings/Personal/ServerDevNotice.php +++ b/apps/settings/lib/Settings/Personal/ServerDevNotice.php @@ -7,10 +7,8 @@ namespace OCA\Settings\Settings\Personal; -use OCA\Viewer\Event\LoadViewer; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; -use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\IRootFolder; use OCP\IURLGenerator; use OCP\IUserSession; @@ -22,7 +20,6 @@ class ServerDevNotice implements ISettings { public function __construct( private IRegistry $registry, - private IEventDispatcher $eventDispatcher, private IRootFolder $rootFolder, private IUserSession $userSession, private IInitialState $initialState, @@ -39,12 +36,9 @@ public function getForm(): TemplateResponse { $hasInitialState = false; - // If the Reasons to use Nextcloud.pdf file is here, let's init Viewer, also check that Viewer is there - if (class_exists(LoadViewer::class) && $userFolder->nodeExists('Reasons to use Nextcloud.pdf')) { - /** - * @psalm-suppress UndefinedClass, InvalidArgument - */ - $this->eventDispatcher->dispatch(LoadViewer::class, new LoadViewer()); + // The viewer loads itself; this only decides whether the notice has + // a file to point at + if ($userFolder->nodeExists('Reasons to use Nextcloud.pdf')) { $hasInitialState = true; } diff --git a/apps/settings/src/main-nextcloud-pdf.js b/apps/settings/src/main-nextcloud-pdf.js index 55d689c0f4a0e..339faa2da7d82 100644 --- a/apps/settings/src/main-nextcloud-pdf.js +++ b/apps/settings/src/main-nextcloud-pdf.js @@ -3,18 +3,31 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { getClient, getDefaultPropfind, getRootPath, resultToNode } from '@nextcloud/files/dav' import { loadState } from '@nextcloud/initial-state' +import { getViewer } from '@nextcloud/viewer' const hasPdf = loadState('settings', 'has-reasons-use-nextcloud-pdf') === true +const path = '/Reasons to use Nextcloud.pdf' + +/** + * Look the file up so the viewer can be handed a node. + */ +async function openInViewer() { + const { data } = await getClient().stat(getRootPath() + path, { + details: true, + data: getDefaultPropfind(), + }) + const node = resultToNode(data) + await getViewer().open([node], node) +} window.addEventListener('DOMContentLoaded', function() { const link = document.getElementById('open-reasons-use-nextcloud-pdf') if (link && hasPdf) { link.addEventListener('click', function(event) { event.preventDefault() - OCA.Viewer.open({ - path: '/Reasons to use Nextcloud.pdf', - }) + openInViewer() }) } }) diff --git a/build/frontend/vite.config.ts b/build/frontend/vite.config.ts index c292c3f371202..7cb4e616dbfda 100644 --- a/build/frontend/vite.config.ts +++ b/build/frontend/vite.config.ts @@ -10,6 +10,9 @@ const modules = { appstore: { main: resolve(import.meta.dirname, 'apps/appstore/src', 'main.ts'), }, + core: { + 'viewer-init': resolve(import.meta.dirname, '../../core/src', 'viewer-init.ts'), + }, comments: { 'comments-app': resolve(import.meta.dirname, 'apps/comments/src', 'comments-app.ts'), 'comments-tab': resolve(import.meta.dirname, 'apps/comments/src', 'files-sidebar.ts'), diff --git a/build/integration/features/provisioning-v1.feature b/build/integration/features/provisioning-v1.feature index cb9006474ed32..2730581534e74 100644 --- a/build/integration/features/provisioning-v1.feature +++ b/build/integration/features/provisioning-v1.feature @@ -658,7 +658,6 @@ Feature: provisioning | updatenotification | | user_ldap | | user_status | - | viewer | | workflowengine | | webhook_listeners | | weather_status | diff --git a/build/psalm-baseline.xml b/build/psalm-baseline.xml index b6da12c8b93f5..13779bc972e0b 100644 --- a/build/psalm-baseline.xml +++ b/build/psalm-baseline.xml @@ -2296,11 +2296,6 @@ - - - - - diff --git a/core/openapi-administration.json b/core/openapi-administration.json index fdb5aa9e523cd..050a817249faf 100644 --- a/core/openapi-administration.json +++ b/core/openapi-administration.json @@ -33,7 +33,8 @@ "webdav-root", "reference-api", "reference-regex", - "mod-rewrite-working" + "mod-rewrite-working", + "previews" ], "properties": { "pollinterval": { @@ -52,6 +53,20 @@ "mod-rewrite-working": { "type": "boolean" }, + "previews": { + "type": "object", + "required": [ + "enabled_providers" + ], + "properties": { + "enabled_providers": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "user": { "type": "object", "required": [ diff --git a/core/openapi-ex_app.json b/core/openapi-ex_app.json index 17dbd07fe1ae5..fc5b382108dd7 100644 --- a/core/openapi-ex_app.json +++ b/core/openapi-ex_app.json @@ -33,7 +33,8 @@ "webdav-root", "reference-api", "reference-regex", - "mod-rewrite-working" + "mod-rewrite-working", + "previews" ], "properties": { "pollinterval": { @@ -52,6 +53,20 @@ "mod-rewrite-working": { "type": "boolean" }, + "previews": { + "type": "object", + "required": [ + "enabled_providers" + ], + "properties": { + "enabled_providers": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "user": { "type": "object", "required": [ diff --git a/core/openapi-full.json b/core/openapi-full.json index a68b14c3420a0..90c2b853eedf5 100644 --- a/core/openapi-full.json +++ b/core/openapi-full.json @@ -99,7 +99,8 @@ "webdav-root", "reference-api", "reference-regex", - "mod-rewrite-working" + "mod-rewrite-working", + "previews" ], "properties": { "pollinterval": { @@ -118,6 +119,20 @@ "mod-rewrite-working": { "type": "boolean" }, + "previews": { + "type": "object", + "required": [ + "enabled_providers" + ], + "properties": { + "enabled_providers": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "user": { "type": "object", "required": [ diff --git a/core/openapi.json b/core/openapi.json index 259376f32ad7c..cb65b81252ef0 100644 --- a/core/openapi.json +++ b/core/openapi.json @@ -99,7 +99,8 @@ "webdav-root", "reference-api", "reference-regex", - "mod-rewrite-working" + "mod-rewrite-working", + "previews" ], "properties": { "pollinterval": { @@ -118,6 +119,20 @@ "mod-rewrite-working": { "type": "boolean" }, + "previews": { + "type": "object", + "required": [ + "enabled_providers" + ], + "properties": { + "enabled_providers": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "user": { "type": "object", "required": [ diff --git a/core/shipped.json b/core/shipped.json index 81f734cfeb2cd..a068fa8f61569 100644 --- a/core/shipped.json +++ b/core/shipped.json @@ -53,7 +53,6 @@ "updatenotification", "user_ldap", "user_status", - "viewer", "weather_status", "webhook_listeners", "workflowengine" @@ -102,7 +101,6 @@ "twofactor_totp", "updatenotification", "user_status", - "viewer", "weather_status", "webhook_listeners" ], @@ -120,7 +118,6 @@ "sharing", "theming", "twofactor_backupcodes", - "viewer", "workflowengine" ] } diff --git a/core/src/viewer-init.ts b/core/src/viewer-init.ts new file mode 100644 index 0000000000000..cfc199ad0288e --- /dev/null +++ b/core/src/viewer-init.ts @@ -0,0 +1,20 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { registerDefaultHandlers } from '@nextcloud/viewer' +import { installLegacyViewerApi } from './viewer-legacy.ts' + +// Registers the handlers for images, video and audio, and offers this copy of +// the viewer to the page. Nothing of the viewer itself is loaded until a file +// is opened. +// +// This runs from an init script so those handlers, and the file actions they +// register, are in place before the Files list takes its first snapshot of the +// available actions. +registerDefaultHandlers() + +// Nothing here calls OCA.Viewer any more. It stays on the page for apps that +// have not moved over, warns whenever it is touched, and goes in Nextcloud 40. +installLegacyViewerApi() diff --git a/core/src/viewer-legacy.spec.ts b/core/src/viewer-legacy.spec.ts new file mode 100644 index 0000000000000..49155fe7983a7 --- /dev/null +++ b/core/src/viewer-legacy.spec.ts @@ -0,0 +1,172 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { LegacyViewerApi } from './viewer-legacy.ts' + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const warn = vi.fn() +const viewerOpen = vi.fn() +const viewerClose = vi.fn() +const viewerCompare = vi.fn() +const canViewMock = vi.fn<(node?: unknown) => boolean>(() => true) +const stat = vi.fn() + +vi.mock('@nextcloud/logger', () => ({ + getLoggerBuilder: () => ({ + setApp: () => ({ detectUser: () => ({ build: () => ({ warn, debug: vi.fn() }) }) }), + }), +})) + +vi.mock('@nextcloud/auth', () => ({ + getCurrentUser: () => ({ uid: 'emma' }), +})) + +vi.mock('@nextcloud/viewer', () => ({ + getViewer: () => ({ open: viewerOpen, close: viewerClose, compare: viewerCompare }), + canView: (node: unknown) => canViewMock(node), +})) + +vi.mock('@nextcloud/files/dav', async (orig) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- vitest importOriginal idiom + const actual = await orig() + return { + ...actual, + getRootPath: () => '/files/emma', + getRemoteURL: () => 'https://cloud.example/remote.php/dav', + getClient: () => ({ stat }), + } +}) + +const { installLegacyViewerApi } = await import('./viewer-legacy.ts') + +/** The global the shim installs */ +function viewer(): LegacyViewerApi { + return window.OCA.Viewer as LegacyViewerApi +} + +describe('OCA.Viewer compatibility layer', () => { + beforeEach(() => { + vi.clearAllMocks() + canViewMock.mockReturnValue(true) + installLegacyViewerApi() + }) + + it('warns on every use, not just the first', () => { + viewer().close() + viewer().close() + expect(warn).toHaveBeenCalledTimes(2) + expect(warn.mock.calls[0][0]).toContain('removed in Nextcloud 40') + }) + + it('warns when a property is merely read', () => { + expect(viewer().file).toBe('') + expect(warn).toHaveBeenCalledOnce() + }) + + it('opens a file given by path, looking it up first', async () => { + stat.mockResolvedValue({ data: { filename: '/files/emma/x.jpg', basename: 'x.jpg', mime: 'image/jpeg', type: 'file', props: { fileid: 7 } } }) + + await viewer().open({ path: '/x.jpg' }) + + expect(stat).toHaveBeenCalledWith('/files/emma/x.jpg', expect.objectContaining({ details: true })) + const [nodes, target] = viewerOpen.mock.calls[0] + expect(nodes).toHaveLength(1) + expect(target.basename).toBe('x.jpg') + }) + + it('opens the node from the list rather than a second copy of it', async () => { + const info = { fileid: 1, filename: '/a.jpg', basename: 'a.jpg', mime: 'image/jpeg' } + const other = { fileid: 2, filename: '/b.jpg', basename: 'b.jpg', mime: 'image/jpeg' } + + await viewer().open({ fileInfo: info, list: [info, other] }) + + const [nodes, target] = viewerOpen.mock.calls[0] + expect(nodes).toHaveLength(2) + expect(nodes).toContain(target) + expect(target.fileid).toBe(1) + }) + + it('keeps a file that the list does not hold', async () => { + const info = { fileid: 1, filename: '/a.jpg', basename: 'a.jpg', mime: 'image/jpeg' } + const other = { fileid: 2, filename: '/b.jpg', basename: 'b.jpg', mime: 'image/jpeg' } + + await viewer().open({ fileInfo: info, list: [other] }) + + const [nodes, target] = viewerOpen.mock.calls[0] + expect(nodes).toHaveLength(2) + expect(nodes).toContain(target) + }) + + it('addresses a file served from outside dav by its own source', async () => { + await viewer().open({ + fileInfo: { source: 'https://cloud.example/apps/pizza/topping/pineapple.jpg', basename: 'pineapple.jpg', mime: 'image/jpeg' }, + }) + + const [, target] = viewerOpen.mock.calls[0] + expect(target.source).toBe('https://cloud.example/apps/pizza/topping/pineapple.jpg') + expect(target.basename).toBe('pineapple.jpg') + }) + + it('passes the handler on to openWith', async () => { + await viewer().openWith('richdocuments', { fileInfo: { fileid: 1, filename: '/a.pdf', mime: 'application/pdf' } }) + expect(viewerOpen.mock.calls[0][3]).toBe('richdocuments') + }) + + it('answers the getters with what the last open was given', async () => { + const loadMore = () => [] + await viewer().open({ + fileInfo: { fileid: 1, filename: '/a.jpg', mime: 'image/jpeg' }, + list: [{ fileid: 1, filename: '/a.jpg', mime: 'image/jpeg' }], + enableSidebar: false, + canLoop: false, + loadMore, + }) + + expect(viewer().file).toBe('/a.jpg') + expect(viewer().enableSidebar).toBe(false) + expect(viewer().canLoop).toBe(false) + expect(viewer().loadMore).toBe(loadMore) + // list was never a real getter on the viewer app, so it always read as + // undefined; it answers with the files now rather than with nothing + expect(viewer().list).toHaveLength(1) + }) + + it('forgets the file once closed', async () => { + await viewer().open({ fileInfo: { fileid: 1, filename: '/a.jpg', mime: 'image/jpeg' } }) + expect(viewer().file).toBe('/a.jpg') + + viewer().close() + + expect(viewerClose).toHaveBeenCalledOnce() + expect(viewer().file).toBe('') + }) + + it('forgets the file when the viewer closes itself', async () => { + const onClose = vi.fn() + await viewer().open({ fileInfo: { fileid: 1, filename: '/a.jpg', mime: 'image/jpeg' }, onClose }) + + // the viewer calls back rather than being told + viewerOpen.mock.calls[0][2].onClose() + + expect(onClose).toHaveBeenCalledOnce() + expect(viewer().file).toBe('') + }) + + it('answers mimetypes by asking the handlers', () => { + canViewMock.mockReturnValue(true) + expect(viewer().mimetypes.includes('image/jpeg')).toBe(true) + expect(viewer().mimetypes.indexOf('image/jpeg')).not.toBe(-1) + + canViewMock.mockReturnValue(false) + expect(viewer().mimetypes.includes('application/x-nothing')).toBe(false) + expect(viewer().mimetypes.indexOf('application/x-nothing')).toBe(-1) + }) + + it('still refuses what the viewer app refused', async () => { + await expect(viewer().open({})).rejects.toThrow('needs either an URL or path') + await expect(viewer().open({ path: 'relative.jpg' })).rejects.toThrow('absolute path') + }) +}) diff --git a/core/src/viewer-legacy.ts b/core/src/viewer-legacy.ts new file mode 100644 index 0000000000000..193fdd64263c4 --- /dev/null +++ b/core/src/viewer-legacy.ts @@ -0,0 +1,445 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { IFile, NodeData } from '@nextcloud/files' +import type { FileStat, ResponseDataDetailed } from 'webdav' + +import { getCurrentUser } from '@nextcloud/auth' +import { File, Permission } from '@nextcloud/files' +import { getClient, getDefaultPropfind, getRemoteURL, getRootPath, resultToNode } from '@nextcloud/files/dav' +import { getLoggerBuilder } from '@nextcloud/logger' +import { canView, getViewer } from '@nextcloud/viewer' + +/** + * The release this shim goes away in. + * + * Named in every warning so that an app author reading a log knows how long + * they have rather than only that something is deprecated. + */ +const REMOVED_IN = 40 + +const logger = getLoggerBuilder() + .setApp('viewer') + .detectUser() + .build() + +/** + * The shape apps passed around before the viewer took nodes. + * + * Loose on purpose: this came from callers rather than from us, and most of + * them filled in only the few fields they had. + */ +interface LegacyFileInfo { + fileid?: number | string + filename?: string + basename?: string + source?: string + mime?: string + etag?: string + hasPreview?: boolean + size?: number + mtime?: number + permissions?: number +} + +interface LegacyOpenOptions { + path?: string + fileInfo?: LegacyFileInfo + list?: LegacyFileInfo[] + enableSidebar?: boolean + loadMore?: () => LegacyFileInfo[] | Promise + canLoop?: boolean + startSlideshow?: boolean + onPrev?: (file: IFile) => void + onNext?: (file: IFile) => void + onClose?: () => void +} + +/** What the getters answer with, kept from the last call to open */ +interface LegacyState { + file: string + files: LegacyFileInfo[] + enableSidebar: boolean + canLoop: boolean + startSlideshow: boolean + loadMore: () => LegacyFileInfo[] | Promise + onPrev: (file: IFile) => void + onNext: (file: IFile) => void + onClose: () => void + overrideHandlerId: string | null +} + +/** The state of a viewer with nothing open */ +function emptyState(): LegacyState { + return { + file: '', + files: [], + enableSidebar: true, + canLoop: true, + startSlideshow: false, + loadMore: () => [], + onPrev: () => {}, + onNext: () => {}, + onClose: () => {}, + overrideHandlerId: null, + } +} + +let state: LegacyState = emptyState() + +/** + * Say that something reached for the old API, and what to reach for instead. + * + * Warned on every access rather than once per caller: these end up in the + * logs an administrator sends with a bug report, and one line at the top of + * a session is the one that scrolls away. + * + * @param member - the property or method that was used + * @param replacement - what to call instead + */ +function deprecated(member: string, replacement: string): void { + logger.warn(`OCA.Viewer.${member} is deprecated and will be removed in Nextcloud ${REMOVED_IN}. Use ${replacement} from the @nextcloud/viewer package instead.`) +} + +/** + * Look a path up on the dav endpoint so the viewer can be handed a node. + * + * @param path - an absolute path in the user's files + */ +async function nodeFromPath(path: string): Promise { + const result = await getClient().stat(getRootPath() + path, { + details: true, + data: getDefaultPropfind(), + }) as ResponseDataDetailed + return resultToNode(result.data) as IFile +} + +/** + * The last part of a path, for a caller that did not say what the file is + * called. + * + * @param source - the address the file is served from + */ +function basenameOf(source: string): string { + const name = source.split('/').pop() ?? '' + try { + return decodeURIComponent(name) + } catch { + return name + } +} + +/** + * The folder part of an address, as a path. + * + * @param source - the address the file is served from + */ +function directoryOf(source: string): string { + try { + const { pathname } = new URL(source, window.location.origin) + return pathname.slice(0, pathname.lastIndexOf('/')) || '/' + } catch { + return '/' + } +} + +/** + * Build a node out of what an app handed over. + * + * Callers filled in what they had, so most of this is defaulting. A file + * with a `source` of its own is served from somewhere that is not dav, and + * keeps it; everything else is addressed under the user's files. + * + * @param info - the file info an app passed to open + */ +function nodeFromFileInfo(info: LegacyFileInfo): IFile { + const owner = getCurrentUser()?.uid ?? null + // A node is addressed by URL. What apps passed here was a path inside the + // user's files, so it needs the dav endpoint in front of it. + const davSource = info.filename + ? getRemoteURL() + getRootPath() + info.filename + : undefined + const source = info.source ?? davSource + + if (source === undefined) { + throw new Error('Viewer file info needs either a source or a filename') + } + + const data: NodeData = { + source, + // A file under the user's files is relative to their dav home. One + // served from somewhere else has no home, so the folder it sits in + // stands in for it and the node still knows its own name. + root: info.source === undefined ? getRootPath() : directoryOf(info.source), + displayname: info.basename ?? basenameOf(source), + mime: info.mime ?? 'application/octet-stream', + owner, + permissions: info.permissions ?? Permission.READ, + attributes: { + etag: info.etag, + hasPreview: info.hasPreview, + }, + } + + // Set apart rather than defaulted: a node is not allowed to carry these + // as undefined, and a caller that did not know them leaves them out + if (info.fileid !== undefined) { + data.id = Number(info.fileid) + } + if (info.size !== undefined) { + data.size = info.size + } + if (info.mtime !== undefined) { + data.mtime = new Date(info.mtime) + } + + return new File(data) +} + +/** + * Whether any handler would take a file of this type. + * + * The answer is worked out by asking the handlers about a file that has + * nothing but the type, because a handler is asked about nodes rather than + * about types. One that looks at more than the type, at whether a preview + * exists say, can answer differently here than it would for a real file. + * + * @param mime - the type to ask about + */ +function canOpenMime(mime: string): boolean { + try { + return canView(new File({ + source: `${getRemoteURL()}${getRootPath()}/.viewer-mime-probe`, + root: getRootPath(), + mime, + owner: getCurrentUser()?.uid ?? null, + permissions: Permission.READ, + attributes: { hasPreview: true }, + })) + } catch (error) { + logger.debug('Could not work out whether the viewer handles this type', { mime, error }) + return false + } +} + +/** + * A stand-in for the list of types the viewer used to publish. + * + * Handlers no longer carry a list of types, they answer about a file, so + * there is nothing left to enumerate. Every caller we know of asks whether + * one type is in the list, so that is what this answers; anything that reads + * it as a list sees an empty one rather than a wrong one. + */ +const mimetypes = new Proxy([] as string[], { + get(target, property, receiver) { + if (property === 'includes') { + return (mime: string) => canOpenMime(mime) + } + if (property === 'indexOf') { + return (mime: string) => (canOpenMime(mime) ? 0 : -1) + } + return Reflect.get(target, property, receiver) + }, +}) + +/** + * Open a file, the way the viewer app used to be asked to. + * + * @param options - what to open, and how + */ +async function open(options: LegacyOpenOptions = {}): Promise { + deprecated('open()', 'getViewer().open()') + + const { + path, + fileInfo, + list = [], + enableSidebar = true, + loadMore = () => [], + canLoop = true, + startSlideshow = false, + onPrev = () => {}, + onNext = () => {}, + onClose = () => {}, + } = options + + // Kept from the old implementation: these were thrown rather than logged, + // and an app relying on the throw should keep getting it + if (!path && !fileInfo) { + throw new Error('Viewer needs either an URL or path to open. None given') + } + if (path && !path.startsWith('/')) { + throw new Error('Please use an absolute path') + } + if (!Array.isArray(list)) { + throw new Error('The files list must be an array') + } + if (typeof loadMore !== 'function') { + throw new Error('The loadMore method must be a function') + } + + const handlerId = state.overrideHandlerId ?? undefined + state = { + ...emptyState(), + file: path ?? fileInfo?.filename ?? '', + files: list, + enableSidebar, + canLoop, + startSlideshow, + loadMore, + onPrev, + onNext, + onClose, + } + + let target = path ? await nodeFromPath(path) : nodeFromFileInfo(fileInfo!) + const nodes = list.map(nodeFromFileInfo) + + // The file to show has to be one of the nodes handed over, and the list + // the caller gave usually already holds it under a different object + const known = nodes.find((node) => ( + (target.fileid !== undefined && node.fileid === target.fileid) + || node.source === target.source + )) + if (known) { + target = known + } else { + nodes.push(target) + } + + await getViewer().open(nodes, target, { + enableSidebar, + canLoop, + startSlideshow, + onPrev, + onNext, + // The viewer being closed from the inside has to clear what the + // getters answer with, or the file stays open as far as they know + onClose: () => { + state = emptyState() + onClose() + }, + loadMore: async () => (await loadMore()).map(nodeFromFileInfo), + }, handlerId) +} + +/** + * What the viewer app used to put on the page. + * + * @deprecated since 36, removed in 40. Use the `@nextcloud/viewer` package. + */ +export interface LegacyViewerApi { + open(options?: LegacyOpenOptions): Promise + openWith(handlerId: string, options?: LegacyOpenOptions): Promise + compare(fileInfo: LegacyFileInfo, compareFileInfo: LegacyFileInfo): Promise + close(): void + readonly file: string + readonly files: LegacyFileInfo[] + readonly list: LegacyFileInfo[] + readonly mimetypes: string[] + readonly enableSidebar: boolean + readonly canLoop: boolean + readonly startSlideshow: boolean + readonly loadMore: () => LegacyFileInfo[] | Promise + readonly onPrev: (file: IFile) => void + readonly onNext: (file: IFile) => void + readonly onClose: () => void + readonly overrideHandlerId: string | null +} + +/** + * Install the old global on the page. + * + * Nothing in the server calls it any more. It is here for apps that have not + * moved over yet, and goes away in Nextcloud {@link REMOVED_IN}. + */ +export function installLegacyViewerApi(): void { + window.OCA ??= {} as typeof window.OCA + + const api: LegacyViewerApi = { + open, + + openWith(handlerId: string, options: LegacyOpenOptions = {}): Promise { + deprecated('openWith()', 'the handler argument of getViewer().open()') + state.overrideHandlerId = handlerId + return open(options) + }, + + compare(fileInfo: LegacyFileInfo, compareFileInfo: LegacyFileInfo): Promise { + deprecated('compare()', 'getViewer().compare()') + return getViewer().compare(nodeFromFileInfo(fileInfo), nodeFromFileInfo(compareFileInfo)) + }, + + close(): void { + deprecated('close()', 'getViewer().close()') + state = emptyState() + getViewer().close() + }, + + get file(): string { + deprecated('file', 'the node you passed to getViewer().open()') + return state.file + }, + + get files(): LegacyFileInfo[] { + deprecated('files', 'the nodes you passed to getViewer().open()') + return state.files + }, + + // Never a getter on the viewer app, so this always read as undefined. + // Kept as the list it was meant to be rather than as the mistake. + get list(): LegacyFileInfo[] { + deprecated('list', 'the nodes you passed to getViewer().open()') + return state.files + }, + + get mimetypes(): string[] { + deprecated('mimetypes', 'canView()') + return mimetypes + }, + + get enableSidebar(): boolean { + deprecated('enableSidebar', 'the options you passed to getViewer().open()') + return state.enableSidebar + }, + + get canLoop(): boolean { + deprecated('canLoop', 'the options you passed to getViewer().open()') + return state.canLoop + }, + + get startSlideshow(): boolean { + deprecated('startSlideshow', 'the options you passed to getViewer().open()') + return state.startSlideshow + }, + + get loadMore() { + deprecated('loadMore', 'the options you passed to getViewer().open()') + return state.loadMore + }, + + get onPrev() { + deprecated('onPrev', 'the options you passed to getViewer().open()') + return state.onPrev + }, + + get onNext() { + deprecated('onNext', 'the options you passed to getViewer().open()') + return state.onNext + }, + + get onClose() { + deprecated('onClose', 'the options you passed to getViewer().open()') + return state.onClose + }, + + get overrideHandlerId(): string | null { + deprecated('overrideHandlerId', 'the handler argument of getViewer().open()') + return state.overrideHandlerId + }, + } + + window.OCA.Viewer = api +} diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 70ebb77e1aaa6..27aacb855fb0e 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -2408,6 +2408,7 @@ 'OC\\Template\\JSCombiner' => $baseDir . '/lib/private/Template/JSCombiner.php', 'OC\\Template\\JSConfigHelper' => $baseDir . '/lib/private/Template/JSConfigHelper.php', 'OC\\Template\\JSResourceLocator' => $baseDir . '/lib/private/Template/JSResourceLocator.php', + 'OC\\Template\\LoadViewerListener' => $baseDir . '/lib/private/Template/LoadViewerListener.php', 'OC\\Template\\ResourceLocator' => $baseDir . '/lib/private/Template/ResourceLocator.php', 'OC\\Template\\ResourceNotFoundException' => $baseDir . '/lib/private/Template/ResourceNotFoundException.php', 'OC\\Template\\Template' => $baseDir . '/lib/private/Template/Template.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 75746428ed294..e9beade5ed478 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -2449,6 +2449,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Template\\JSCombiner' => __DIR__ . '/../../..' . '/lib/private/Template/JSCombiner.php', 'OC\\Template\\JSConfigHelper' => __DIR__ . '/../../..' . '/lib/private/Template/JSConfigHelper.php', 'OC\\Template\\JSResourceLocator' => __DIR__ . '/../../..' . '/lib/private/Template/JSResourceLocator.php', + 'OC\\Template\\LoadViewerListener' => __DIR__ . '/../../..' . '/lib/private/Template/LoadViewerListener.php', 'OC\\Template\\ResourceLocator' => __DIR__ . '/../../..' . '/lib/private/Template/ResourceLocator.php', 'OC\\Template\\ResourceNotFoundException' => __DIR__ . '/../../..' . '/lib/private/Template/ResourceNotFoundException.php', 'OC\\Template\\Template' => __DIR__ . '/../../..' . '/lib/private/Template/Template.php', diff --git a/lib/private/OCS/CoreCapabilities.php b/lib/private/OCS/CoreCapabilities.php index 2bab5d96677a5..bb4315ef4a3ec 100644 --- a/lib/private/OCS/CoreCapabilities.php +++ b/lib/private/OCS/CoreCapabilities.php @@ -12,6 +12,7 @@ use OCP\Capabilities\ICapability; use OCP\IConfig; +use OCP\IPreview; use OCP\IURLGenerator; /** @@ -22,9 +23,11 @@ class CoreCapabilities implements ICapability { /** * @param IConfig $config + * @param IPreview $preview */ public function __construct( private IConfig $config, + private IPreview $preview, ) { } @@ -38,6 +41,9 @@ public function __construct( * reference-api: boolean, * reference-regex: string, * mod-rewrite-working: boolean, + * previews: array{ + * enabled_providers: list, + * }, * }, * } */ @@ -50,6 +56,13 @@ public function getCapabilities(): array { 'reference-api' => true, 'reference-regex' => IURLGenerator::URL_REGEX_NO_MODIFIERS, 'mod-rewrite-working' => $this->config->getSystemValueBool('htaccess.IgnoreFrontController') || getenv('front_controller_active') === 'true', + 'previews' => [ + // The mime patterns previews can be generated for. Empty + // when previews are off entirely. Clients use it to tell + // the formats a browser cannot show on its own but the + // server can render from the ones it can do neither of. + 'enabled_providers' => array_keys($this->preview->getProviders()), + ], ], ]; } diff --git a/lib/private/Repair/CleanUpAbandonedApps.php b/lib/private/Repair/CleanUpAbandonedApps.php index be08b210e5691..7eeac3e313008 100644 --- a/lib/private/Repair/CleanUpAbandonedApps.php +++ b/lib/private/Repair/CleanUpAbandonedApps.php @@ -13,7 +13,7 @@ use OCP\Migration\IRepairStep; class CleanUpAbandonedApps implements IRepairStep { - protected const ABANDONED_APPS = ['accessibility', 'files_videoplayer']; + protected const ABANDONED_APPS = ['accessibility', 'files_videoplayer', 'viewer']; public function __construct( private readonly IConfig $config, diff --git a/lib/private/Server.php b/lib/private/Server.php index ed0ba5c04f741..f2f366a5cac16 100644 --- a/lib/private/Server.php +++ b/lib/private/Server.php @@ -149,6 +149,7 @@ use OC\Talk\Broker; use OC\Teams\TeamManager; use OC\Template\JSCombiner; +use OC\Template\LoadViewerListener; use OC\Translation\TranslationManager; use OC\User\AvailabilityCoordinator; use OC\User\DisplayNameCache; @@ -163,6 +164,7 @@ use OCP\Accounts\IAccountManager; use OCP\Activity\IEventMerger; use OCP\App\IAppManager; +use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent; use OCP\AppFramework\Utility\IControllerMethodReflector; use OCP\AppFramework\Utility\ITimeFactory; use OCP\Authentication\LoginCredentials\IStore; @@ -851,7 +853,7 @@ public function __construct( $this->registerService(CapabilitiesManager::class, static function (ContainerInterface $c) { $manager = new CapabilitiesManager($c->get(LoggerInterface::class)); $manager->registerCapability(static function () use ($c) { - return new CoreCapabilities($c->get(IConfig::class)); + return new CoreCapabilities($c->get(IConfig::class), $c->get(IPreview::class)); }); $manager->registerCapability(static function () use ($c) { return $c->get(Capabilities::class); @@ -1135,6 +1137,7 @@ private function connectDispatcher(): void { $eventDispatcher->addServiceListener(BeforeUserDeletedEvent::class, BeforeUserDeletedListener::class); $eventDispatcher->addServiceListener(UserDeletedEvent::class, SubAdmin::class); $eventDispatcher->addServiceListener(GroupDeletedEvent::class, SubAdmin::class); + $eventDispatcher->addServiceListener(BeforeTemplateRenderedEvent::class, LoadViewerListener::class); FilesMetadataManager::loadListeners($eventDispatcher); GenerateBlurhashMetadata::loadListeners($eventDispatcher); diff --git a/lib/private/Template/LoadViewerListener.php b/lib/private/Template/LoadViewerListener.php new file mode 100644 index 0000000000000..bf9897c4f60fc --- /dev/null +++ b/lib/private/Template/LoadViewerListener.php @@ -0,0 +1,40 @@ + + */ +class LoadViewerListener implements IEventListener { + #[\Override] + public function handle(Event $event): void { + if (!($event instanceof BeforeTemplateRenderedEvent)) { + return; + } + + // The themed error page renders through here too, and nothing on it + // opens a file + if ($event->getResponse()->getRenderAs() === TemplateResponse::RENDER_AS_ERROR) { + return; + } + + Util::addInitScript('core', 'viewer-init'); + } +} diff --git a/openapi.json b/openapi.json index eea7f6a7906ad..2fcf95ba7309b 100644 --- a/openapi.json +++ b/openapi.json @@ -149,7 +149,8 @@ "webdav-root", "reference-api", "reference-regex", - "mod-rewrite-working" + "mod-rewrite-working", + "previews" ], "properties": { "pollinterval": { @@ -168,6 +169,20 @@ "mod-rewrite-working": { "type": "boolean" }, + "previews": { + "type": "object", + "required": [ + "enabled_providers" + ], + "properties": { + "enabled_providers": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "user": { "type": "object", "required": [ diff --git a/package-lock.json b/package-lock.json index d6b294a990374..237d04c0aef0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "@nextcloud/paths": "^3.1.0", "@nextcloud/router": "^3.1.0", "@nextcloud/sharing": "^1.0.0-beta.4", + "@nextcloud/viewer": "^2.0.0-beta.11", "@nextcloud/vue": "^9.10.0", "@vueuse/core": "^14.1.0", "@vueuse/integrations": "^14.1.0", @@ -1488,6 +1489,19 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2003,6 +2017,24 @@ "@nextcloud/files": "^3.12.2 || ^4.0.0" } }, + "node_modules/@nextcloud/image-editor": { + "version": "1.0.0-beta.6", + "resolved": "https://registry.npmjs.org/@nextcloud/image-editor/-/image-editor-1.0.0-beta.6.tgz", + "integrity": "sha512-o8wFdR+tejZKEqEDW395ru7R/KfGJTTT0UOcc6yAE3PXyXFDsz/DS1X+FGvTNZjdfqEstamBgPJSB/zU6TdL5A==", + "license": "AGPL-3.0-or-later", + "dependencies": { + "@nextcloud/l10n": "^3.4.0", + "konva": "~10.3.2" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || ^24.0.0" + }, + "peerDependencies": { + "@nextcloud/dialogs": "^7.0.0", + "@nextcloud/vue": "^9.0.0", + "vue": "^3.5.0" + } + }, "node_modules/@nextcloud/initial-state": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@nextcloud/initial-state/-/initial-state-3.0.0.tgz", @@ -2068,9 +2100,9 @@ } }, "node_modules/@nextcloud/router": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@nextcloud/router/-/router-3.1.0.tgz", - "integrity": "sha512-e4dkIaxRSwdZJlZFpn9x03QgBn/Sa2hN1hp/BA7+AbzykmSAlKuWfdmX8j/8ewrLpQwYmZR23IZO9XwpJXq2Uw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@nextcloud/router/-/router-3.2.0.tgz", + "integrity": "sha512-an4gJ7mlDZGtYq8zTxp4Sg+iLlKKGvQ9ZH3Dp6ga/IlDNAP7VeMVGphsY798ORjncAp9+sEf370L5zpsnuxgng==", "license": "GPL-3.0-or-later", "dependencies": { "@nextcloud/typings": "^1.10.0" @@ -2147,6 +2179,39 @@ "node": "^20.0.0 || ^22.0.0 || ^24.0.0" } }, + "node_modules/@nextcloud/viewer": { + "version": "2.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@nextcloud/viewer/-/viewer-2.0.0-beta.11.tgz", + "integrity": "sha512-44eLgNL4/GdGH5zV8wcz3VE4lTpVsvkPursXhtWZBZc4qqsYPesfHZ3ordTqpA2FPvTqZYCdLHqGxJHXULiqFQ==", + "license": "AGPL-3.0-or-later", + "dependencies": { + "@mdi/svg": "^7.4.47", + "@nextcloud/image-editor": "^1.0.0-beta.6", + "@nextcloud/logger": "^3.0.3", + "@skjnldsv/vue-plyr": "^7.5.0", + "debounce": "^3.0.0", + "dompurify": "^3.4.14", + "opensheetmusicdisplay": "^2.1.3", + "vue-material-design-icons": "^5.3.1", + "webdav": "^5.10.0" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || ^24.0.0" + }, + "peerDependencies": { + "@nextcloud/axios": "^2.5.2", + "@nextcloud/capabilities": "^1.2.1", + "@nextcloud/dialogs": "^7.5.0", + "@nextcloud/event-bus": "^3.3.3", + "@nextcloud/files": "^4.0.0", + "@nextcloud/l10n": "^3.4.1", + "@nextcloud/paths": "^3.1.0", + "@nextcloud/router": "^3.1.0", + "@nextcloud/sharing": "^0.4.0 || ^1.0.0-beta.2", + "@nextcloud/vue": "^9.13.0", + "vue": "^3.5.0" + } + }, "node_modules/@nextcloud/vite-config": { "version": "2.5.4", "resolved": "https://registry.npmjs.org/@nextcloud/vite-config/-/vite-config-2.5.4.tgz", @@ -2177,9 +2242,9 @@ } }, "node_modules/@nextcloud/vue": { - "version": "9.11.0", - "resolved": "https://registry.npmjs.org/@nextcloud/vue/-/vue-9.11.0.tgz", - "integrity": "sha512-LRsyU9Mxs0b2xWqbxygps8O7/i1z4QhNQD9/QDni+VqFY3V0N95mAgmKJm6vfoFuxTAMXERp9bjqFn3UV542gw==", + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/@nextcloud/vue/-/vue-9.13.0.tgz", + "integrity": "sha512-XiG34Od/hMlt4VgDmHlcUXhAj2KD/4LeorsVD5sc9Vix0wqa6e3aSHjPWghll/ZD+8JnKkId6zLoexM4VqQngQ==", "license": "AGPL-3.0-or-later", "dependencies": { "@ckpack/vue-color": "^1.6.0", @@ -2192,7 +2257,7 @@ "@nextcloud/initial-state": "^3.0.0", "@nextcloud/l10n": "^3.4.1", "@nextcloud/logger": "^3.0.3", - "@nextcloud/router": "^3.1.0", + "@nextcloud/router": "^3.2.0", "@nextcloud/sharing": "^0.4.0", "@nextcloud/vue-select": "^4.1.0", "@vuepic/vue-datepicker": "^11.0.3", @@ -2201,7 +2266,7 @@ "blurhash": "^2.0.5", "clone": "^2.1.2", "debounce": "^3.0.0", - "dompurify": "^3.4.14", + "dompurify": "^3.4.15", "emoji-mart-vue-fast": "^15.0.5", "escape-html": "^1.0.3", "floating-vue": "^5.2.2", @@ -2225,10 +2290,22 @@ "unist-builder": "^4.0.0", "unist-util-visit-parents": "^6.0.2", "vue": "^3.5.18", - "vue-router": "^5.2.0" + "vue-router": "^5.3.1" }, "engines": { "node": "^20.11.0 || ^22 || ^24" + }, + "peerDependencies": { + "@nextcloud/dialogs": "^7.4.0", + "@nextcloud/files": "^4.1.0" + }, + "peerDependenciesMeta": { + "@nextcloud/dialogs": { + "optional": true + }, + "@nextcloud/files": { + "optional": true + } } }, "node_modules/@nextcloud/vue-select": { @@ -3387,6 +3464,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@skjnldsv/vue-plyr": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@skjnldsv/vue-plyr/-/vue-plyr-7.5.0.tgz", + "integrity": "sha512-JmbKPe8Eh0yTP+7w4VYeqGhrUqDKM4qSsCn37CbADAvvwMvceBwYC0IBXil0RBtyzStxwXPtOj1oI//lQ5DeTw==", + "license": "MIT", + "dependencies": { + "plyr": "^3.7.8", + "vue": "^3.3.4" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0", + "npm": "^9.0.0 || ^10.0.0" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -3642,12 +3733,6 @@ "@types/sizzle": "*" } }, - "node_modules/@types/jsesc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", - "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", - "license": "MIT" - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -3749,6 +3834,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/vexflow": { + "version": "1.2.42", + "resolved": "https://registry.npmjs.org/@types/vexflow/-/vexflow-1.2.42.tgz", + "integrity": "sha512-bj3If1sm1FYJN0tJMV2BJNrwoeQ6xfrTt+rbmFvUytyxUhklLlW79MR4uwQ+upzrNJLVy012TnC7oZqSpEglSw==", + "license": "MIT" + }, "node_modules/@types/web-bluetooth": { "version": "0.0.21", "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", @@ -5117,7 +5208,7 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -5174,6 +5265,16 @@ "require-from-string": "^2.0.2" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, "node_modules/birpc": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", @@ -5183,11 +5284,18 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/bit-twiddle": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bit-twiddle/-/bit-twiddle-1.0.2.tgz", + "integrity": "sha512-B9UhK0DKFZhoTFcfvAzhqsjStvGJp9vYWf3+6SNTtdSQnvIgfkHbgHrg/e4+TH71N2GDu8tpmCVoyfrL1d7ntA==", + "license": "MIT", + "optional": true + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "buffer": "^5.5.0", @@ -5441,7 +5549,7 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -5704,7 +5812,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/cipher-base": { @@ -6080,13 +6188,6 @@ "proto-list": "~1.2.1" } }, - "node_modules/config-chain/node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true, - "license": "ISC" - }, "node_modules/console-browserify": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.2.0.tgz", @@ -6143,7 +6244,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", - "dev": true, "license": "MIT" }, "node_modules/cosmiconfig": { @@ -6350,6 +6450,12 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/custom-event-polyfill": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/custom-event-polyfill/-/custom-event-polyfill-1.0.7.tgz", + "integrity": "sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w==", + "license": "MIT" + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -6439,6 +6545,22 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/deep-equal": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", @@ -6472,6 +6594,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -6741,9 +6873,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.14", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", - "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz", + "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -6895,7 +7027,7 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -6917,7 +7049,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -7499,6 +7631,16 @@ "safe-buffer": "^5.1.1" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -7509,6 +7651,13 @@ "node": ">=12.0.0" } }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "license": "Apache-2.0", + "optional": true + }, "node_modules/exsolve": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz", @@ -7734,6 +7883,13 @@ "flat-cache": "^6.1.23" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT", + "optional": true + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -7909,7 +8065,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/fsevents": { @@ -8025,6 +8181,32 @@ "url": "https://github.com/fisker/git-hooks-list?sponsor=1" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT", + "optional": true + }, + "node_modules/gl": { + "version": "9.0.0-rc.10", + "resolved": "https://registry.npmjs.org/gl/-/gl-9.0.0-rc.10.tgz", + "integrity": "sha512-G6lYaWoan0d2d8UO0UmaSS8zqyyZwYt6q2dFVJ2hD62sRWUwkMIH+Jp7gEoQesLlo28Nzelh+GIYz1qOUa5WmQ==", + "hasInstallScript": true, + "license": "BSD-2-Clause", + "optional": true, + "dependencies": { + "bindings": "^1.5.0", + "bit-twiddle": "^1.0.2", + "glsl-tokenizer": "^2.1.5", + "nan": "^2.26.2", + "node-gyp": "^12.2.0", + "prebuild-install": "^7.1.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -8114,13 +8296,6 @@ "node": ">=6" } }, - "node_modules/global-prefix/node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true, - "license": "ISC" - }, "node_modules/global-prefix/node_modules/which": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", @@ -8186,6 +8361,16 @@ "dev": true, "license": "MIT" }, + "node_modules/glsl-tokenizer": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/glsl-tokenizer/-/glsl-tokenizer-2.1.5.tgz", + "integrity": "sha512-XSZEJ/i4dmz3Pmbnpsy3cKh7cotvFlBiZnDOwnj/05EwNp2XrhQ4XKJxT7/pDt4kp4YcpRSKz8eTV7S+mwV6MA==", + "license": "MIT", + "optional": true, + "dependencies": { + "through2": "^0.6.3" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -8202,7 +8387,7 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/graphql": { @@ -8572,7 +8757,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -8599,6 +8784,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/immutable": { "version": "5.1.9", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", @@ -8668,7 +8859,13 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "devOptional": true, "license": "ISC" }, "node_modules/inline-style-parser": { @@ -9411,18 +9608,6 @@ "node": "^22.14.0 || >=24.0.0" } }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -9444,18 +9629,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", @@ -9469,6 +9642,45 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/keyv": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", @@ -9504,6 +9716,38 @@ "dev": true, "license": "MIT" }, + "node_modules/konva": { + "version": "10.3.3", + "resolved": "https://registry.npmjs.org/konva/-/konva-10.3.3.tgz", + "integrity": "sha512-j3Rrn504bYFwJ0zV0ZbSOQRsM9KMBLOpEq6xZuyIjbaKeFbBXwgiM5SA4IDq4nwM1l49GVKs66Mo+sl7+4G7PQ==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/lavrton" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/konva" + }, + { + "type": "github", + "url": "https://github.com/sponsors/lavrton" + } + ], + "license": "MIT", + "peerDependencies": { + "canvas": "^3.0.0", + "skia-canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + }, + "skia-canvas": { + "optional": true + } + } + }, "node_modules/layerr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/layerr/-/layerr-3.0.0.tgz", @@ -9524,6 +9768,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -9537,6 +9790,12 @@ "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", "license": "MIT" }, + "node_modules/loadjs": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/loadjs/-/loadjs-4.3.0.tgz", + "integrity": "sha512-vNX4ZZLJBeDEOBvdr2v/F+0aN5oMuPu7JTqrMwp+DtgK+AryOlpy6Xtm2/HpNr+azEa828oQjOtWsB6iDtSfSQ==", + "license": "MIT" + }, "node_modules/local-pkg": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz", @@ -9591,6 +9850,19 @@ "dev": true, "license": "MIT" }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -10489,11 +10761,24 @@ "node": ">= 0.6" } }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -10533,7 +10818,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -10543,17 +10828,30 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, + "devOptional": true, "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" } }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/mlly": { @@ -10682,7 +10980,6 @@ "version": "2.28.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==", - "dev": true, "license": "MIT", "optional": true }, @@ -10704,6 +11001,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT", + "optional": true + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -10727,6 +11031,19 @@ "integrity": "sha512-yFehXNWRs4cM0+dz7QxCd06hTbWbSkV0ISsqBfkntU6TOY4Qm3Q88fRRLOddkGh2Qq6dZvnKVAahfhjcUvLnyA==", "license": "MIT" }, + "node_modules/node-abi": { + "version": "3.96.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz", + "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==", + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", @@ -10772,6 +11089,93 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/node-gyp": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", + "license": "MIT", + "optional": true, + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "license": "ISC", + "optional": true, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "license": "BlueOak-1.0.0", + "optional": true, + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "license": "ISC", + "optional": true, + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/undici": { + "version": "6.28.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.1.tgz", + "integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18.17" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "license": "ISC", + "optional": true, + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/node-releases": { "version": "2.0.54", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", @@ -10959,12 +11363,28 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "wrappy": "1" } }, + "node_modules/opensheetmusicdisplay": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/opensheetmusicdisplay/-/opensheetmusicdisplay-2.1.3.tgz", + "integrity": "sha512-DwHaGM0GgXanmmrSi++0eYWORAH+77yiO+nwFuf/66lq4UYrFPcyrwZuLXwQQQ+kUCnsbnON3rEsurgKdWesCA==", + "license": "BSD-3-Clause", + "dependencies": { + "@types/vexflow": "^1.2.38", + "jszip": "3.10.1", + "loglevel": "^1.8.0", + "typescript-collections": "^1.3.3", + "vexflow": "1.2.93" + }, + "optionalDependencies": { + "gl": "^9.0.0-rc.10" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -11081,7 +11501,6 @@ "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "dev": true, "license": "(MIT AND Zlib)" }, "node_modules/parent-module": { @@ -11413,6 +11832,19 @@ "node": ">=20" } }, + "node_modules/plyr": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/plyr/-/plyr-3.8.4.tgz", + "integrity": "sha512-DrzLbK9Wol3zeiuZCleD9aUOl0KAaBHR9H6WVVVYPZ4Ya+LYxUFTgSF1jooHcMQCv96Ws96wCaZzIoP3bES8pQ==", + "license": "MIT", + "dependencies": { + "core-js": "^3.45.1", + "custom-event-polyfill": "^1.0.7", + "loadjs": "^4.3.0", + "rangetouch": "^2.0.1", + "url-polyfill": "^1.1.13" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -11559,6 +11991,44 @@ "dev": true, "license": "MIT" }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prebuild-install/node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -11597,6 +12067,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -11611,7 +12091,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, "license": "MIT" }, "node_modules/property-information": { @@ -11690,7 +12169,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -11826,6 +12305,38 @@ "safe-buffer": "^5.1.0" } }, + "node_modules/rangetouch": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/rangetouch/-/rangetouch-2.0.1.tgz", + "integrity": "sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==", + "license": "MIT" + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -11837,7 +12348,7 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -12327,7 +12838,7 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -12472,7 +12983,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", - "dev": true, "license": "MIT" }, "node_modules/sha.js": { @@ -12615,6 +13125,53 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/slash": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", @@ -12920,7 +13477,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" @@ -12930,7 +13486,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, "license": "MIT" }, "node_modules/string-argv": { @@ -13592,11 +14147,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "license": "BlueOak-1.0.0", + "optional": true, + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tar-fs": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "chownr": "^1.1.1", @@ -13609,7 +14181,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "bl": "^4.0.3", @@ -13635,6 +14207,16 @@ "streamx": "^2.15.0" } }, + "node_modules/tar/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "optional": true, + "engines": { + "node": ">=18" + } + }, "node_modules/teex": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", @@ -13655,6 +14237,44 @@ "b4a": "^1.6.4" } }, + "node_modules/through2": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz", + "integrity": "sha512-RkK/CCESdTKQZHdmKICijdKKsCRVHs5KsLZ6pACAmF/1GPUQhonHSXWNERctxEp7RmvjdNbZTL5z9V7nSCXKcg==", + "license": "MIT", + "optional": true, + "dependencies": { + "readable-stream": ">=1.0.33-1 <1.1.0-0", + "xtend": ">=4.0.0 <4.1.0-0" + } + }, + "node_modules/through2/node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "license": "MIT", + "optional": true + }, + "node_modules/through2/node_modules/readable-stream": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", + "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", + "license": "MIT", + "optional": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "node_modules/through2/node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", + "license": "MIT", + "optional": true + }, "node_modules/timers-browserify": { "version": "2.0.12", "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.12.tgz", @@ -13876,6 +14496,19 @@ "dev": true, "license": "MIT" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", @@ -13925,6 +14558,12 @@ "node": ">=14.17" } }, + "node_modules/typescript-collections": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/typescript-collections/-/typescript-collections-1.3.3.tgz", + "integrity": "sha512-7sI4e/bZijOzyURng88oOFZCISQPTHozfE2sUu5AviFYk5QV7fYGb6YiDl+vKjF/pICA354JImBImL9XJWUvdQ==", + "license": "MIT" + }, "node_modules/typescript-eslint": { "version": "8.68.0", "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.68.0.tgz", @@ -14269,6 +14908,12 @@ "requires-port": "^1.0.0" } }, + "node_modules/url-polyfill": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.14.tgz", + "integrity": "sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==", + "license": "MIT" + }, "node_modules/url/node_modules/punycode": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", @@ -14294,7 +14939,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/uuid": { @@ -14310,6 +14954,12 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/vexflow": { + "version": "1.2.93", + "resolved": "https://registry.npmjs.org/vexflow/-/vexflow-1.2.93.tgz", + "integrity": "sha512-LwHQDCc257Lwju35BhyZuPYcVWu0hIUqEdM7j9+B+bq91bSelssnAG5JR8odTUtgGuwwvGwLhXw37wtmHNCS6Q==", + "license": "MIT" + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -15101,17 +15751,16 @@ } }, "node_modules/vue-router": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.2.0.tgz", - "integrity": "sha512-QAC5i0LEb1GLG0LXDQmHu8L7FX12j0KwU/JTKmLQUJMrn04gQdKP6Du+p0QwpHb3iy71vBlqnHQ8WAfOSAWhqw==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.3.1.tgz", + "integrity": "sha512-GDBZzgmILxA/kFnkFbJjQZdZ2QQbngnIMMuoUcjhZIfH1RGMaPjPwX5ASnV38qamuA9uhO0RDjSBHTDNG2uXyQ==", "license": "MIT", "dependencies": { - "@babel/generator": "^8.0.0", "@vue-macros/common": "^3.1.3", "@vue/devtools-api": "^8.1.5", "ast-walker-scope": "^0.9.0", "chokidar": "^5.0.0", - "json5": "^2.2.3", + "confbox": "^0.2.4", "local-pkg": "^1.2.1", "magic-string": "^0.30.21", "mlly": "^1.8.2", @@ -15122,8 +15771,7 @@ "scule": "^1.3.0", "tinyglobby": "^0.2.17", "unplugin": "^3.3.0", - "unplugin-utils": "^0.3.2", - "yaml": "^2.9.0" + "unplugin-utils": "^0.3.2" }, "funding": { "url": "https://github.com/sponsors/posva" @@ -15150,69 +15798,6 @@ } } }, - "node_modules/vue-router/node_modules/@babel/generator": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz", - "integrity": "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^8.0.0", - "@babel/types": "^8.0.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "@types/jsesc": "^2.5.0", - "jsesc": "^3.0.2" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/vue-router/node_modules/@babel/helper-string-parser": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", - "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/vue-router/node_modules/@babel/helper-validator-identifier": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", - "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/vue-router/node_modules/@babel/parser": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.4.tgz", - "integrity": "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==", - "license": "MIT", - "dependencies": { - "@babel/types": "^8.0.4" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/vue-router/node_modules/@babel/types": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", - "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^8.0.0", - "@babel/helper-validator-identifier": "^8.0.4" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, "node_modules/vuedraggable": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz", @@ -15526,7 +16111,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/write-file-atomic": { @@ -15591,7 +16176,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.4" @@ -15607,19 +16192,14 @@ "node": ">=10" } }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "optional": true, "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" + "node": ">=18" } }, "node_modules/yargs": { diff --git a/package.json b/package.json index fe7f25021baf1..7076310331ce2 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@nextcloud/paths": "^3.1.0", "@nextcloud/router": "^3.1.0", "@nextcloud/sharing": "^1.0.0-beta.4", + "@nextcloud/viewer": "^2.0.0-beta.11", "@nextcloud/vue": "^9.10.0", "@vueuse/core": "^14.1.0", "@vueuse/integrations": "^14.1.0", diff --git a/playwright.config.ts b/playwright.config.ts index 4a7ccea10900b..47e9aa01f034b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -14,6 +14,10 @@ const BROWSWER_CONFIG_CHROME: DeviceDescriptor & { channel: string } = { : 'chromium', // locally use the default playwright chromium browser } +// Port of the Nextcloud container under test. Override it to run several +// checkouts in parallel, as each one needs its own server. +const NEXTCLOUD_PORT = process.env.NEXTCLOUD_PORT ?? '8042' + export default defineConfig({ testDir: './tests/playwright/e2e', fullyParallel: true, @@ -23,7 +27,7 @@ export default defineConfig({ timeout: process.env.CI ? 45_000 : undefined, // on CI allow 1.5x the default timeout to compensate for shared server resources reporter: process.env.CI ? [['blob'], ['dot'], ['github']] : 'html', use: { - baseURL: 'http://localhost:8042/index.php/', + baseURL: `http://localhost:${NEXTCLOUD_PORT}/index.php/`, trace: 'on-first-retry', }, projects: [ @@ -79,7 +83,7 @@ export default defineConfig({ webServer: { command: 'node tests/playwright/start-nextcloud-server.js', env: { - NEXTCLOUD_PORT: '8042', + NEXTCLOUD_PORT, }, stderr: 'pipe', stdout: 'pipe', diff --git a/tests/data/REUSE.toml b/tests/data/REUSE.toml index 255b35698189f..0b6124646cac6 100644 --- a/tests/data/REUSE.toml +++ b/tests/data/REUSE.toml @@ -122,3 +122,51 @@ path = "images/image.jpg" precedence = "aggregate" SPDX-FileCopyrightText = "2019 Tom Gainor " SPDX-License-Identifier = "LicenseRef-Unsplash" + +[[annotations]] +path = ["media/image.gif", "media/image.ico", "media/image.svg", "media/image-small.png", "media/image-apng.png"] +precedence = "aggregate" +SPDX-FileCopyrightText = "2019 Nextcloud GmbH" +SPDX-License-Identifier = "LicenseRef-NextcloudTrademarks" + +[[annotations]] +path = ["media/image1.jpg"] +precedence = "aggregate" +SPDX-FileCopyrightText = "2019 Stephen Leonardi " +SPDX-License-Identifier = "LicenseRef-Unsplash" + +[[annotations]] +path = ["media/image2.jpg"] +precedence = "aggregate" +SPDX-FileCopyrightText = "2019 Tom Gainor " +SPDX-License-Identifier = "LicenseRef-Unsplash" + +[[annotations]] +path = ["media/image4.jpg"] +precedence = "aggregate" +SPDX-FileCopyrightText = "2019 Vincent Riszdorfer " +SPDX-License-Identifier = "LicenseRef-Unsplash" + +[[annotations]] +path = ["media/video.mkv", "media/video.ogv", "media/video.webm", "media/video1.mp4"] +precedence = "aggregate" +SPDX-FileCopyrightText = "2012 AnRo0002 " +SPDX-License-Identifier = "CC0-1.0" + +[[annotations]] +path = ["media/audio.mp3", "media/audio.ogg"] +precedence = "aggregate" +SPDX-FileCopyrightText = "2022 AC Krah " +SPDX-License-Identifier = "CC0-1.0" + +[[annotations]] +path = ["media/image3.jpg"] +precedence = "aggregate" +SPDX-FileCopyrightText = "2013 Wilfredor " +SPDX-License-Identifier = "CC0-1.0" + +[[annotations]] +path = ["media/image.png", "media/image.webp"] +precedence = "aggregate" +SPDX-FileCopyrightText = "2024 Master Unknown " +SPDX-License-Identifier = "LicenseRef-Unsplash" diff --git a/tests/data/media/audio.mp3 b/tests/data/media/audio.mp3 new file mode 100644 index 0000000000000..d71cb7e4f56ee Binary files /dev/null and b/tests/data/media/audio.mp3 differ diff --git a/tests/data/media/audio.ogg b/tests/data/media/audio.ogg new file mode 100644 index 0000000000000..661ba57eb726b Binary files /dev/null and b/tests/data/media/audio.ogg differ diff --git a/tests/data/media/image-apng.png b/tests/data/media/image-apng.png new file mode 100644 index 0000000000000..73d2f6b962a3e Binary files /dev/null and b/tests/data/media/image-apng.png differ diff --git a/tests/data/media/image-small.png b/tests/data/media/image-small.png new file mode 100644 index 0000000000000..a9128aa6afea5 Binary files /dev/null and b/tests/data/media/image-small.png differ diff --git a/tests/data/media/image.gif b/tests/data/media/image.gif new file mode 100644 index 0000000000000..4cb22be07b6e4 Binary files /dev/null and b/tests/data/media/image.gif differ diff --git a/tests/data/media/image.ico b/tests/data/media/image.ico new file mode 100644 index 0000000000000..b02c657782738 Binary files /dev/null and b/tests/data/media/image.ico differ diff --git a/tests/data/media/image.png b/tests/data/media/image.png new file mode 100644 index 0000000000000..7f41d50f7d7e8 Binary files /dev/null and b/tests/data/media/image.png differ diff --git a/tests/data/media/image.svg b/tests/data/media/image.svg new file mode 100644 index 0000000000000..7008be01a195f --- /dev/null +++ b/tests/data/media/image.svg @@ -0,0 +1 @@ + diff --git a/tests/data/media/image.webp b/tests/data/media/image.webp new file mode 100644 index 0000000000000..7e4f899eb32c2 Binary files /dev/null and b/tests/data/media/image.webp differ diff --git a/tests/data/media/image1.jpg b/tests/data/media/image1.jpg new file mode 100644 index 0000000000000..4f0063b33ec2b Binary files /dev/null and b/tests/data/media/image1.jpg differ diff --git a/tests/data/media/image2.jpg b/tests/data/media/image2.jpg new file mode 100644 index 0000000000000..f11756eb0ada9 Binary files /dev/null and b/tests/data/media/image2.jpg differ diff --git a/tests/data/media/image3.jpg b/tests/data/media/image3.jpg new file mode 100644 index 0000000000000..c33f74ee309fd Binary files /dev/null and b/tests/data/media/image3.jpg differ diff --git a/tests/data/media/image4.jpg b/tests/data/media/image4.jpg new file mode 100644 index 0000000000000..592b7ea1c086b Binary files /dev/null and b/tests/data/media/image4.jpg differ diff --git a/tests/data/media/video.mkv b/tests/data/media/video.mkv new file mode 100644 index 0000000000000..11d6323919995 Binary files /dev/null and b/tests/data/media/video.mkv differ diff --git a/tests/data/media/video.ogv b/tests/data/media/video.ogv new file mode 100644 index 0000000000000..d86f0ccba0056 Binary files /dev/null and b/tests/data/media/video.ogv differ diff --git a/tests/data/media/video.webm b/tests/data/media/video.webm new file mode 100644 index 0000000000000..244b522743c87 Binary files /dev/null and b/tests/data/media/video.webm differ diff --git a/tests/data/media/video1.mp4 b/tests/data/media/video1.mp4 new file mode 100644 index 0000000000000..c94d7ab0715c7 Binary files /dev/null and b/tests/data/media/video1.mp4 differ diff --git a/tests/lib/App/AppManagerTest.php b/tests/lib/App/AppManagerTest.php index 26c5f9d60165c..9f6577cdb030f 100644 --- a/tests/lib/App/AppManagerTest.php +++ b/tests/lib/App/AppManagerTest.php @@ -564,7 +564,6 @@ public function testGetEnabledApps(): void { 'test3', 'theming', 'twofactor_backupcodes', - 'viewer', 'workflowengine', ]; $this->assertEquals($apps, $this->manager->getEnabledApps()); @@ -597,7 +596,6 @@ public function testGetAppsForUser(): void { 'test3', 'theming', 'twofactor_backupcodes', - 'viewer', 'workflowengine', ]; $this->assertEquals($enabled, $this->manager->getEnabledAppsForUser($user)); @@ -638,7 +636,6 @@ public function testGetAppsNeedingUpgrade(): void { 'sharing' => ['id' => 'sharing'], 'theming' => ['id' => 'theming'], 'twofactor_backupcodes' => ['id' => 'twofactor_backupcodes'], - 'viewer' => ['id' => 'viewer'], 'workflowengine' => ['id' => 'workflowengine'], 'oauth2' => ['id' => 'oauth2'], ]; @@ -703,7 +700,6 @@ public function testGetIncompatibleApps(): void { 'twofactor_backupcodes' => ['id' => 'twofactor_backupcodes'], 'workflowengine' => ['id' => 'workflowengine'], 'oauth2' => ['id' => 'oauth2'], - 'viewer' => ['id' => 'viewer'], ]; $manager->expects($this->any()) @@ -751,7 +747,6 @@ public function testGetEnabledAppsForGroup(): void { 'test3', 'theming', 'twofactor_backupcodes', - 'viewer', 'workflowengine', ]; $this->assertEquals($enabled, $this->manager->getEnabledAppsForGroup($group)); diff --git a/tests/lib/AppTest.php b/tests/lib/AppTest.php index 23cb10f0477e9..20349093c8c3e 100644 --- a/tests/lib/AppTest.php +++ b/tests/lib/AppTest.php @@ -109,7 +109,6 @@ public static function appConfigValuesProvider(): array { 'sharing', 'theming', 'twofactor_backupcodes', - 'viewer', 'workflowengine', ], false @@ -135,7 +134,6 @@ public static function appConfigValuesProvider(): array { 'sharing', 'theming', 'twofactor_backupcodes', - 'viewer', 'workflowengine', ], false @@ -162,7 +160,6 @@ public static function appConfigValuesProvider(): array { 'sharing', 'theming', 'twofactor_backupcodes', - 'viewer', 'workflowengine', ], false @@ -189,7 +186,6 @@ public static function appConfigValuesProvider(): array { 'sharing', 'theming', 'twofactor_backupcodes', - 'viewer', 'workflowengine', ], false, @@ -216,7 +212,6 @@ public static function appConfigValuesProvider(): array { 'sharing', 'theming', 'twofactor_backupcodes', - 'viewer', 'workflowengine', ], true, @@ -279,11 +274,11 @@ public function testEnabledAppsCache(): void { ); $apps = \OC_App::getEnabledApps(); - $this->assertEquals(['files', 'app3', 'appstore', 'cloud_federation_api', 'dav', 'federatedfilesharing', 'lookup_server_connector', 'oauth2', 'profile', 'provisioning_api', 'settings', 'sharing', 'theming', 'twofactor_backupcodes', 'viewer', 'workflowengine'], $apps); + $this->assertEquals(['files', 'app3', 'appstore', 'cloud_federation_api', 'dav', 'federatedfilesharing', 'lookup_server_connector', 'oauth2', 'profile', 'provisioning_api', 'settings', 'sharing', 'theming', 'twofactor_backupcodes', 'workflowengine'], $apps); // mock should not be called again here $apps = \OC_App::getEnabledApps(); - $this->assertEquals(['files', 'app3', 'appstore', 'cloud_federation_api', 'dav', 'federatedfilesharing', 'lookup_server_connector', 'oauth2', 'profile', 'provisioning_api', 'settings', 'sharing', 'theming', 'twofactor_backupcodes', 'viewer', 'workflowengine'], $apps); + $this->assertEquals(['files', 'app3', 'appstore', 'cloud_federation_api', 'dav', 'federatedfilesharing', 'lookup_server_connector', 'oauth2', 'profile', 'provisioning_api', 'settings', 'sharing', 'theming', 'twofactor_backupcodes', 'workflowengine'], $apps); $this->restoreAppConfig(); $userSession->setUser(null); diff --git a/tests/lib/OCS/CoreCapabilitiesTest.php b/tests/lib/OCS/CoreCapabilitiesTest.php new file mode 100644 index 0000000000000..75ed32bc93438 --- /dev/null +++ b/tests/lib/OCS/CoreCapabilitiesTest.php @@ -0,0 +1,54 @@ +config = $this->createMock(IConfig::class); + $this->preview = $this->createMock(IPreview::class); + $this->capabilities = new CoreCapabilities($this->config, $this->preview); + } + + public function testEnabledPreviewProvidersAreReported(): void { + $this->preview->method('getProviders') + ->willReturn([ + '/image\/jpeg/' => [], + '/image\/hei(f|c)/' => [], + ]); + + $capabilities = $this->capabilities->getCapabilities(); + + $this->assertSame( + ['/image\/jpeg/', '/image\/hei(f|c)/'], + $capabilities['core']['previews']['enabled_providers'], + ); + } + + public function testNoPreviewProvidersAreReportedWhenPreviewsAreOff(): void { + // The manager returns nothing at all when `enable_previews` is off + $this->preview->method('getProviders')->willReturn([]); + + $capabilities = $this->capabilities->getCapabilities(); + + $this->assertSame([], $capabilities['core']['previews']['enabled_providers']); + } +} diff --git a/tests/playwright/e2e/viewer/a11y.spec.ts b/tests/playwright/e2e/viewer/a11y.spec.ts new file mode 100644 index 0000000000000..e79691c3209f5 --- /dev/null +++ b/tests/playwright/e2e/viewer/a11y.spec.ts @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/viewer-page.ts' + +test.describe('Viewer accessibility', () => { + test.beforeEach(async ({ filesListPage, uploadMedia }) => { + await uploadMedia('image1.jpg', 'image1.jpg', 'image/jpeg') + await uploadMedia('image2.jpg', 'image2.jpg', 'image/jpeg') + await uploadMedia('video1.mp4', 'video1.mp4', 'video/mp4') + await filesListPage.open() + await expect(filesListPage.getRowForFile('image1.jpg')).toBeVisible() + }) + + test('exposes the viewer as a labelled dialog with accessible controls', async ({ page, openFile, viewerPage }) => { + await openFile('image2.jpg') + await viewerPage.waitForOpen() + + // The modal is a dialog labelled by the current file name. + const dialog = page.locator('.viewer__modal[role="dialog"]') + await expect(dialog).toBeVisible() + expect(await viewerPage.currentName()).toBe('image2.jpg') + + // Navigation and close controls have accessible names. + await expect(viewerPage.nextButton).toBeVisible() + await expect(viewerPage.previousButton).toBeVisible() + await expect(viewerPage.closeButton).toBeVisible() + }) +}) diff --git a/tests/playwright/e2e/viewer/actions/close.spec.ts b/tests/playwright/e2e/viewer/actions/close.spec.ts new file mode 100644 index 0000000000000..cd903ac58d5a5 --- /dev/null +++ b/tests/playwright/e2e/viewer/actions/close.spec.ts @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test } from '../../../support/fixtures/viewer-page.ts' + +test.describe('Viewer close on click outside', () => { + test.beforeEach(async ({ filesListPage, uploadMedia }) => { + await uploadMedia('image1.jpg', 'image1.jpg', 'image/jpeg') + await filesListPage.open() + }) + + // Regression for nextcloud/viewer#2166: clicking outside the image closes it. + test('closes when clicking outside the media', async ({ openFile, viewerPage }) => { + await openFile('image1.jpg') + await viewerPage.waitForOpen() + + await viewerPage.clickOutside() + + await viewerPage.waitForClosed() + }) +}) diff --git a/tests/playwright/e2e/viewer/actions/delete.spec.ts b/tests/playwright/e2e/viewer/actions/delete.spec.ts new file mode 100644 index 0000000000000..b5a8fc63991cd --- /dev/null +++ b/tests/playwright/e2e/viewer/actions/delete.spec.ts @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../../support/fixtures/viewer-page.ts' + +test.describe('Viewer delete action', () => { + // Deleting the shown file advances the viewer to the next file, then the + // previous one, then closes when nothing is left. + test('advances to the next file, then closes when the list is empty', async ({ filesListPage, uploadMedia, openFile, viewerPage }) => { + await uploadMedia('image1.jpg', 'image1.jpg', 'image/jpeg') + await uploadMedia('image2.jpg', 'image2.jpg', 'image/jpeg') + await filesListPage.open() + await openFile('image1.jpg') + await viewerPage.waitForOpen() + + // Delete the first file → viewer moves to the second, staying open. + await viewerPage.runAction('Delete file') + await viewerPage.waitForOpen() + await expect(async () => { + expect(await viewerPage.currentName()).toBe('image2.jpg') + }).toPass() + + // Delete the only remaining file → viewer closes. + await viewerPage.runAction('Delete file') + await viewerPage.waitForClosed() + }) +}) diff --git a/tests/playwright/e2e/viewer/actions/download.spec.ts b/tests/playwright/e2e/viewer/actions/download.spec.ts new file mode 100644 index 0000000000000..e5e4ab487d0de --- /dev/null +++ b/tests/playwright/e2e/viewer/actions/download.spec.ts @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, sharingTest as test, uploadMediaFile } from '../../../support/fixtures/viewer-page.ts' +import { mkdir } from '../../../support/utils/dav.ts' +import { createShare, DOWNLOAD_DISABLED_ATTRIBUTE, waitForShare } from '../../../support/utils/sharing.ts' + +test.describe('Viewer download restrictions', () => { + // The owner shares a folder with the current user, forbidding download. The + // recipient must then get no download control in the viewer. + test('does not expose a download control when download is forbidden', async ({ page, user, owner, ownerRequest, filesListPage, viewerPage }) => { + await mkdir(ownerRequest, owner, '/Photos') + await uploadMediaFile(ownerRequest, owner, 'image1.jpg', '/Photos/image1.jpg', 'image/jpeg') + await createShare(ownerRequest, '/Photos', user.userId, { attributes: DOWNLOAD_DISABLED_ATTRIBUTE }) + await waitForShare(page.request, user, '', 'Photos') + + await filesListPage.open() + await filesListPage.getRowNameLinkForFile('Photos').click() + await expect(filesListPage.getRowForFile('image1.jpg')).toBeVisible() + + await filesListPage.getRowNameLinkForFile('image1.jpg').click() + await viewerPage.isVisible() + expect(await viewerPage.currentName()).toBe('image1.jpg') + + // No download link nor download action is offered in the viewer. + await expect(viewerPage.modal.locator('a[download]')).toHaveCount(0) + await expect(viewerPage.modal.getByRole('button', { name: /download/i })).toHaveCount(0) + }) +}) diff --git a/tests/playwright/e2e/viewer/actions/edit.spec.ts b/tests/playwright/e2e/viewer/actions/edit.spec.ts new file mode 100644 index 0000000000000..5f955c70af2cb --- /dev/null +++ b/tests/playwright/e2e/viewer/actions/edit.spec.ts @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../../support/fixtures/viewer-page.ts' + +test.describe('Viewer image editor action', () => { + test.beforeEach(async ({ filesListPage, uploadMedia }) => { + await uploadMedia('image1.jpg', 'image1.jpg', 'image/jpeg') + await filesListPage.open() + await expect(filesListPage.getRowForFile('image1.jpg')).toBeVisible() + }) + + // The Edit action opens @nextcloud/image-editor; its Cancel closes it. + test('opens the image editor and closes it on cancel', async ({ page, openFile, viewerPage }) => { + await openFile('image1.jpg') + await viewerPage.waitForOpen() + + await viewerPage.runAction('Edit') + const editor = page.locator('.viewer__image-editor.image-editor') + await expect(editor).toBeVisible() + expect(page.url()).toContain('editing=true') + + await page.locator('[data-test="cancel"], [data-test="cancel-icon"]').first().click() + await expect(editor).toBeHidden() + expect(page.url()).not.toContain('editing=true') + }) + + // Saving overwrites the file and shows the edited image from its local blob, + // without a server refetch. + test('saves and shows the edited image without a refetch', async ({ page, openFile, viewerPage }) => { + await openFile('image1.jpg') + await viewerPage.waitForOpen() + await viewerPage.runAction('Edit') + await expect(page.locator('.viewer__image-editor.image-editor')).toBeVisible() + + await page.locator('[data-test="save"]').click() + + await expect(page.locator('.viewer__image-editor.image-editor')).toBeHidden() + await expect(page.locator('oca-viewer-image img')).toHaveAttribute('src', /^blob:/) + }) + + // The `editing=true` URL param reopens the editor on refresh. + test('reopens the editor from an editing=true URL', async ({ page, openFile, viewerPage }) => { + await openFile('image1.jpg') + await viewerPage.waitForOpen() + await viewerPage.runAction('Edit') + await expect(page.locator('.viewer__image-editor.image-editor')).toBeVisible() + + await page.reload() + await expect(page.locator('.viewer__image-editor.image-editor')).toBeVisible() + }) +}) diff --git a/tests/playwright/e2e/viewer/actions/menu.spec.ts b/tests/playwright/e2e/viewer/actions/menu.spec.ts new file mode 100644 index 0000000000000..198fa58c62efb --- /dev/null +++ b/tests/playwright/e2e/viewer/actions/menu.spec.ts @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../../support/fixtures/viewer-page.ts' + +test.describe('Viewer actions menu', () => { + // The viewer links its header menu to the Files actions. Actions rendered by + // a custom component (e.g. the sharing status) have no static label/icon and + // must be filtered out, otherwise they show up as blank menu entries. + test('does not render blank entries for custom-rendered file actions', async ({ page, filesListPage, uploadMedia, openFile, viewerPage }) => { + await uploadMedia('image1.jpg', 'image1.jpg', 'image/jpeg') + await filesListPage.open() + await openFile('image1.jpg') + await viewerPage.waitForOpen() + + const toggle = viewerPage.actionsToggle() + await toggle.click() + + const menuItems = page.getByRole('menuitem') + await expect(menuItems.first()).toBeVisible() + + // Every menu entry must have a non-empty accessible label. + const labels = await menuItems.allInnerTexts() + expect(labels.length).toBeGreaterThan(0) + for (const label of labels) { + expect(label.trim()).not.toBe('') + } + + // The sharing-status action is custom-rendered and must be absent. + await expect(page.getByRole('menuitem', { name: /sharing/i })).toHaveCount(0) + }) +}) diff --git a/tests/playwright/e2e/viewer/actions/rename.spec.ts b/tests/playwright/e2e/viewer/actions/rename.spec.ts new file mode 100644 index 0000000000000..751769294c721 --- /dev/null +++ b/tests/playwright/e2e/viewer/actions/rename.spec.ts @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../../support/fixtures/viewer-page.ts' + +test.describe('Viewer rename action', () => { + // The Files rename action edits the file-list row, which the viewer cannot + // host, so the viewer renames through its own dialog instead. + test('renames the current file from the viewer', async ({ page, filesListPage, uploadMedia, openFile, viewerPage }) => { + await uploadMedia('image1.jpg', 'image1.jpg', 'image/jpeg') + await filesListPage.open() + await openFile('image1.jpg') + await viewerPage.waitForOpen() + + await viewerPage.runAction('Rename') + + const dialog = page.getByRole('dialog', { name: 'Rename file' }) + await expect(dialog).toBeVisible() + const input = dialog.getByRole('textbox') + await input.fill('renamed.jpg') + await dialog.getByRole('button', { name: 'Rename' }).click() + + await expect(dialog).toBeHidden() + // The header reflects the new name. + await expect(async () => { + expect(await viewerPage.currentName()).toBe('renamed.jpg') + }).toPass() + }) +}) diff --git a/tests/playwright/e2e/viewer/actions/sidebar.spec.ts b/tests/playwright/e2e/viewer/actions/sidebar.spec.ts new file mode 100644 index 0000000000000..6834f57e897bc --- /dev/null +++ b/tests/playwright/e2e/viewer/actions/sidebar.spec.ts @@ -0,0 +1,72 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../../support/fixtures/viewer-page.ts' + +test.describe('Viewer sidebar action', () => { + test.beforeEach(async ({ filesListPage, uploadMedia }) => { + await uploadMedia('image1.jpg', 'image1.jpg', 'image/jpeg') + await filesListPage.open() + await expect(filesListPage.getRowForFile('image1.jpg')).toBeVisible() + }) + + test('opens the Files sidebar for the current file', async ({ page, openFile, viewerPage }) => { + await openFile('image1.jpg') + await viewerPage.waitForOpen() + + await viewerPage.openSidebar() + + const sidebar = page.locator('aside.app-sidebar') + await expect(sidebar).toBeVisible() + await expect(sidebar.locator('.app-sidebar-header__mainname')).toContainText('image1.jpg') + }) + + // The sidebar next to the viewer must fill the full height (the app header is + // hidden), like the pre-7.0.0 viewer did. + test('shows the sidebar full height next to the viewer', async ({ page, openFile, viewerPage }) => { + await openFile('image1.jpg') + await viewerPage.waitForOpen() + await viewerPage.openSidebar() + + const sidebar = page.locator('aside.app-sidebar') + await expect(sidebar).toBeVisible() + + await expect(async () => { + const full = await page.evaluate(() => { + const el = document.querySelector('aside.app-sidebar')! + const header = document.querySelector('#header') + return document.body.classList.contains('viewer--sidebar-fullscreen') + && getComputedStyle(el).position === 'fixed' + && (!header || getComputedStyle(header).visibility === 'hidden') + && Math.round(el.getBoundingClientRect().height) === window.innerHeight + }) + expect(full).toBe(true) + }).toPass() + }) + + // Regression for nextcloud/viewer#658: opening the sidebar while the image is + // still loading must still show the sidebar (the header actions are available + // during loading). + test('opens the sidebar right after opening a file', async ({ page, openFile, viewerPage }) => { + // The header actions have to work from the moment the viewer is up, + // whether or not the pixels have arrived. The loading state itself is + // not what is asserted: the file list fetches the preview for its own + // row and the viewer reuses that response, so holding the viewer's + // request back delays nothing, and the spinner measured here lives + // about 600ms. Waiting for it is a race in both directions, which is + // how this test failed on a fast machine and passed on a slow one. + await openFile('image1.jpg') + await expect(viewerPage.container).toBeVisible() + + await viewerPage.openSidebar() + + const sidebar = page.locator('aside.app-sidebar') + await expect(sidebar).toBeVisible() + await expect(sidebar).toContainText('image1.jpg') + + // And the picture still arrives behind it + await viewerPage.waitForOpen() + }) +}) diff --git a/tests/playwright/e2e/viewer/audios.spec.ts b/tests/playwright/e2e/viewer/audios.spec.ts new file mode 100644 index 0000000000000..9f6980f150e2e --- /dev/null +++ b/tests/playwright/e2e/viewer/audios.spec.ts @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { AUDIO_MEDIA, createMediaViewerTests } from './media.ts' + +createMediaViewerTests(AUDIO_MEDIA) diff --git a/tests/playwright/e2e/viewer/compare.spec.ts b/tests/playwright/e2e/viewer/compare.spec.ts new file mode 100644 index 0000000000000..f820caab609e7 --- /dev/null +++ b/tests/playwright/e2e/viewer/compare.spec.ts @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/viewer-page.ts' + +test.describe('Viewer compare API', () => { + // The compare() API renders two files side by side. It has no Files-app UI + // (it is used programmatically, e.g. by files_versions), so we capture two + // real File nodes by opening them, then call the public viewer service. + test('renders two files side by side', async ({ page, filesListPage, uploadMedia, openFile, viewerPage }) => { + await uploadMedia('image1.jpg', 'image1.jpg', 'image/jpeg') + await uploadMedia('image2.jpg', 'image2.jpg', 'image/jpeg') + await filesListPage.open() + + // Capture the File node passed to the viewer for each opened file. + await page.evaluate(() => { + const win = window as unknown as { + __capturedNodes: unknown[] + _nc_viewer_scope: Record unknown } }> + } + win.__capturedNodes = [] + const service = win._nc_viewer_scope.handlers_v1!.service + const original = service.open.bind(service) + service.open = (nodes: unknown, file: unknown, ...rest: unknown[]) => { + win.__capturedNodes.push(file) + return original(nodes, file, ...rest) + } + }) + + await openFile('image1.jpg') + await viewerPage.waitForOpen() + await viewerPage.close() + + await openFile('image2.jpg') + await viewerPage.waitForOpen() + await viewerPage.close() + + // Programmatically compare the two captured nodes. + await page.evaluate(async () => { + const win = window as unknown as { + __capturedNodes: unknown[] + _nc_viewer_scope: Record Promise } }> + } + await win._nc_viewer_scope.handlers_v1!.service.compare(win.__capturedNodes[0], win.__capturedNodes[1]) + }) + + const comparison = viewerPage.modal.locator('.viewer__comparison') + await expect(comparison).toBeVisible() + await expect(comparison.locator('oca-viewer-image')).toHaveCount(2) + }) +}) diff --git a/tests/playwright/e2e/viewer/custom-list.spec.ts b/tests/playwright/e2e/viewer/custom-list.spec.ts new file mode 100644 index 0000000000000..0c662fe71316a --- /dev/null +++ b/tests/playwright/e2e/viewer/custom-list.spec.ts @@ -0,0 +1,137 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Page } from '@playwright/test' + +import { expect, test } from '../../support/fixtures/viewer-page.ts' + +/** + * The viewer can be handed a list of its own rather than the folder it was + * opened from: Photos and the versions sidebar both do it. These are the + * Playwright port of the old app's `images-custom-list` specs. + */ + +/** Minimum a file needs to be handed back to the viewer */ +interface CapturedNode { basename: string } + +/** + * Open one file so the viewer is handed the folder listing, keep the nodes it + * was given, then close it again. Those nodes are real, so a list built from + * them exercises the same path a caller would. + * + * @param page - The test page + */ +async function captureFolderNodes(page: Page): Promise { + await page.evaluate(() => { + const win = window as unknown as { + __capturedList: unknown[] + _nc_viewer_scope: Record unknown } }> + } + win.__capturedList = [] + const service = win._nc_viewer_scope.handlers_v1!.service + const original = service.open.bind(service) + // Only the first open is the folder listing; every later one is a list + // this test handed over, so the wrapper steps aside once it has it + service.open = (nodes: unknown, file: unknown, ...rest: unknown[]) => { + win.__capturedList = nodes as unknown[] + service.open = original + return original(nodes, file, ...rest) + } + }) +} + +/** + * Hand the viewer a list of its own, named by basename. + * + * @param page - The test page + * @param names - The files to put in the list, in order + * @param loadMore - Files to append once navigation reaches the end + */ +async function openList(page: Page, names: string[], loadMore: string[] = []): Promise { + await page.evaluate(({ wanted, more }) => { + const win = window as unknown as { + __capturedList: CapturedNode[] + _nc_viewer_scope: Record Promise } }> + } + const pick = (names: string[]) => names + .map((name) => win.__capturedList.find((node) => node.basename === name)) + .filter((node) => node !== undefined) + const list = pick(wanted) + let appended = false + const marker = window as unknown as { __loadedMore?: boolean } + marker.__loadedMore = false + const options = more.length === 0 + ? {} + : { + loadMore: async () => { + if (appended) { + return [] + } + appended = true + // The viewer appends what this resolves with, so the test + // waits for the flag before asking for the next file + marker.__loadedMore = true + return pick(more) + }, + } + return win._nc_viewer_scope.handlers_v1!.service.open(list, list[0], options) + }, { wanted: names, more: loadMore }) +} + +test.describe('Viewer custom list', () => { + test.beforeEach(async ({ filesListPage, uploadMedia, openFile, viewerPage, page }) => { + for (const name of ['image1.jpg', 'image2.jpg', 'image3.jpg', 'image4.jpg']) { + await uploadMedia(name, name, 'image/jpeg') + } + await filesListPage.open() + + await captureFolderNodes(page) + await openFile('image1.jpg') + await viewerPage.waitForOpen() + await viewerPage.close() + }) + + test('pages through the list it was handed, not the folder', async ({ page, viewerPage }) => { + // The folder holds four images; the caller offers two of them + await openList(page, ['image1.jpg', 'image3.jpg']) + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe('image1.jpg') + + await viewerPage.next() + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe('image3.jpg') + + // image2 and image4 are in the folder but not in the list, so the end of + // the list wraps straight back to its first entry + await viewerPage.next() + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe('image1.jpg') + }) + + test('asks for more files when it runs out', async ({ page, viewerPage }) => { + await openList(page, ['image1.jpg', 'image2.jpg'], ['image3.jpg', 'image4.jpg']) + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe('image1.jpg') + + await viewerPage.next() + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe('image2.jpg') + + // Reaching the last entry is what asks for more, and the viewer appends + // them when that resolves: pressing next before it has is what a person + // could never do and a test does every time + await page.waitForFunction(() => (window as unknown as { __loadedMore?: boolean }).__loadedMore === true) + + // The end of the given list: what loadMore returned continues it rather + // than wrapping round to the start + await viewerPage.next() + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe('image3.jpg') + + await viewerPage.next() + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe('image4.jpg') + }) +}) diff --git a/tests/playwright/e2e/viewer/error.spec.ts b/tests/playwright/e2e/viewer/error.spec.ts new file mode 100644 index 0000000000000..46343d7f38ea6 --- /dev/null +++ b/tests/playwright/e2e/viewer/error.spec.ts @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test } from '../../support/fixtures/viewer-page.ts' + +test.describe('Viewer error handling', () => { + // A media file that fails to load must show an error, not stay stuck on the + // loading spinner (regression guard for the loading-gate / onError path). + test('shows an error when the media fails to load', async ({ page, filesListPage, uploadMedia, openFile, viewerPage }) => { + await uploadMedia('video1.mp4', 'video1.mp4', 'video/mp4') + + // Make every request for the video fail: the direct source and the E2EE + // fallback fetch both hit the dav endpoint for this file. + await page.route('**/video1.mp4', (route) => route.abort()) + await page.route('**/video1.mp4?**', (route) => route.abort()) + + await filesListPage.open() + await openFile('video1.mp4') + + await viewerPage.expectError() + }) +}) diff --git a/tests/playwright/e2e/viewer/history.spec.ts b/tests/playwright/e2e/viewer/history.spec.ts new file mode 100644 index 0000000000000..8a94c409aba89 --- /dev/null +++ b/tests/playwright/e2e/viewer/history.spec.ts @@ -0,0 +1,76 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/viewer-page.ts' + +test.describe('Viewer browser history', () => { + test.beforeEach(async ({ uploadMedia }) => { + await uploadMedia('image1.jpg', 'image1.jpg', 'image/jpeg') + await uploadMedia('image2.jpg', 'image2.jpg', 'image/jpeg') + }) + + test('reflects the open file in the URL and re-opens on refresh', async ({ page, filesListPage, openFile, viewerPage }) => { + await filesListPage.open() + await openFile('image1.jpg') + await viewerPage.waitForOpen() + + // Opening pushes an openfile URL so a refresh re-triggers the viewer. + expect(page.url()).toContain('openfile=true') + + await page.reload() + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe('image1.jpg') + }) + + test('back and forward move between the shown files', async ({ page, filesListPage, openFile, viewerPage }) => { + await filesListPage.open() + await openFile('image1.jpg') + await viewerPage.waitForOpen() + const openUrl = page.url() + + await viewerPage.next() + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe('image2.jpg') + // Navigating pushed a new history entry. + expect(page.url()).not.toBe(openUrl) + + // Browser back returns to the first file, still inside the viewer. + await page.goBack() + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe('image1.jpg') + + // Browser forward returns to the second file. + await page.goForward() + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe('image2.jpg') + }) + + test('closing resets history so back does not re-open a file', async ({ page, filesListPage, openFile, viewerPage }) => { + await filesListPage.open() + await openFile('image1.jpg') + await viewerPage.waitForOpen() + + await viewerPage.next() + await viewerPage.waitForOpen() + + await viewerPage.close() + // The openfile flag is gone from the URL… + expect(page.url()).not.toContain('openfile=true') + + // …and pressing back does not step back into the images. + await page.goBack() + await viewerPage.waitForClosed() + }) + + test('navigating out of the openfile range closes the viewer', async ({ page, filesListPage, openFile, viewerPage }) => { + await filesListPage.open() + await openFile('image1.jpg') + await viewerPage.waitForOpen() + + // A single back from the first file leaves the openfile range → close. + await page.goBack() + await viewerPage.waitForClosed() + }) +}) diff --git a/tests/playwright/e2e/viewer/images.spec.ts b/tests/playwright/e2e/viewer/images.spec.ts new file mode 100644 index 0000000000000..1e52424d7bb7c --- /dev/null +++ b/tests/playwright/e2e/viewer/images.spec.ts @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { createMediaViewerTests, IMAGE_MEDIA } from './media.ts' + +createMediaViewerTests(IMAGE_MEDIA) diff --git a/tests/playwright/e2e/viewer/large-folder.spec.ts b/tests/playwright/e2e/viewer/large-folder.spec.ts new file mode 100644 index 0000000000000..a637e0469d90b --- /dev/null +++ b/tests/playwright/e2e/viewer/large-folder.spec.ts @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/viewer-page.ts' + +// Zero-padded so the natural file-list order matches the numeric order. +const COUNT = 30 +const name = (index: number): string => `image-${String(index).padStart(3, '0')}.jpg` + +test.describe('Viewer in a large folder', () => { + // Regression for nextcloud/viewer#3015: opening a file in a directory with + // many files used to hang (O(n^2) sort). The viewer now trusts the caller's + // order, so opening and navigating stays responsive. + test('opens quickly and navigates in a folder with many files', async ({ filesListPage, uploadMedia, openFile, viewerPage }) => { + await Promise.all(Array.from({ length: COUNT }, (_, index) => uploadMedia('image1.jpg', name(index), 'image/jpeg'))) + await filesListPage.open() + await expect(filesListPage.getRowForFile(name(0))).toBeVisible() + + const start = Date.now() + await openFile(name(0)) + await viewerPage.waitForOpen() + // The prev/next controls become available well within a sane budget. + expect(Date.now() - start).toBeLessThan(15000) + + expect(await viewerPage.currentName()).toBe(name(0)) + + await viewerPage.next() + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe(name(1)) + }) +}) diff --git a/tests/playwright/e2e/viewer/media.ts b/tests/playwright/e2e/viewer/media.ts new file mode 100644 index 0000000000000..8e410ca53c3ad --- /dev/null +++ b/tests/playwright/e2e/viewer/media.ts @@ -0,0 +1,88 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { MediaKind } from '../../support/sections/ViewerPage.ts' + +import { expect, test } from '../../support/fixtures/viewer-page.ts' + +export interface MediaItem { + /** Fixture file name in `tests/data/media`. */ + fixture: string + /** Content type used when uploading the fixture. */ + mime: string + /** Media kind handled by the viewer. */ + kind: MediaKind + /** The handler custom element tag name. */ + handlerTag: string + /** + * If set, the standard tests are skipped with this reason. + * Used for codecs unsupported by Playwright's bundled Chromium. + */ + skip?: string +} + +/** Images ported from the Cypress image specs and the image mixin. */ +export const IMAGE_MEDIA: MediaItem[] = [ + { fixture: 'image1.jpg', mime: 'image/jpeg', kind: 'image', handlerTag: 'oca-viewer-image' }, + { fixture: 'image.png', mime: 'image/png', kind: 'image', handlerTag: 'oca-viewer-image' }, + { fixture: 'image.gif', mime: 'image/gif', kind: 'image', handlerTag: 'oca-viewer-image' }, + { fixture: 'image.webp', mime: 'image/webp', kind: 'image', handlerTag: 'oca-viewer-image' }, + { fixture: 'image.svg', mime: 'image/svg+xml', kind: 'image', handlerTag: 'oca-viewer-image' }, + { fixture: 'image.ico', mime: 'image/x-icon', kind: 'image', handlerTag: 'oca-viewer-image' }, + { fixture: 'image-apng.png', mime: 'image/png', kind: 'image', handlerTag: 'oca-viewer-image' }, + { fixture: 'image-small.png', mime: 'image/png', kind: 'image', handlerTag: 'oca-viewer-image' }, +] + +/** Videos ported from the Cypress video specs and the video mixin. */ +export const VIDEO_MEDIA: MediaItem[] = [ + { fixture: 'video1.mp4', mime: 'video/mp4', kind: 'video', handlerTag: 'oca-viewer-video' }, + { fixture: 'video.webm', mime: 'video/webm', kind: 'video', handlerTag: 'oca-viewer-video' }, + { fixture: 'video.mkv', mime: 'video/x-matroska', kind: 'video', handlerTag: 'oca-viewer-video' }, + { + fixture: 'video.ogv', + mime: 'video/ogg', + kind: 'video', + handlerTag: 'oca-viewer-video', + // Ogg/Theora is not supported by Playwright's bundled Chromium. + skip: 'Ogg/Theora codec is unsupported by Playwright bundled Chromium', + }, +] + +/** Audios ported from the Cypress audio specs and the audio mixin. */ +export const AUDIO_MEDIA: MediaItem[] = [ + { fixture: 'audio.mp3', mime: 'audio/mpeg', kind: 'audio', handlerTag: 'oca-viewer-audio' }, + { fixture: 'audio.ogg', mime: 'audio/ogg', kind: 'audio', handlerTag: 'oca-viewer-audio' }, +] + +/** + * Register the standard open → load → assert → close test for a media item. + * + * This is the Playwright port of the Cypress `image`, `video` and `audio` + * mixins: it uploads a single fixture, opens it from the files list, waits for + * the viewer to finish loading, asserts the correct handler is rendered and + * that a single file shows no navigation arrows, then closes the viewer. + * + * @param media - The media items to generate tests for + */ +export function createMediaViewerTests(media: MediaItem[]): void { + for (const item of media) { + test(`opens ${item.fixture} in the viewer`, async ({ filesListPage, viewerPage, uploadMedia, openFile }) => { + test.skip(Boolean(item.skip), item.skip ?? '') + + await uploadMedia(item.fixture, item.fixture, item.mime) + await filesListPage.open() + + await expect(filesListPage.getRowForFile(item.fixture)).toBeVisible() + + await openFile(item.fixture) + await viewerPage.waitForOpen() + + expect(await viewerPage.currentName()).toBe(item.fixture) + await viewerPage.expectHandler(item.kind) + + await viewerPage.close() + }) + } +} diff --git a/tests/playwright/e2e/viewer/navigation.spec.ts b/tests/playwright/e2e/viewer/navigation.spec.ts new file mode 100644 index 0000000000000..6771d94d715c0 --- /dev/null +++ b/tests/playwright/e2e/viewer/navigation.spec.ts @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/viewer-page.ts' + +const IMAGES = ['image1.jpg', 'image2.jpg', 'image3.jpg', 'image4.jpg'] + +test.describe('Viewer navigation', () => { + test.beforeEach(async ({ filesListPage, uploadMedia }) => { + for (const image of IMAGES) { + await uploadMedia(image, image, 'image/jpeg') + } + await filesListPage.open() + await expect(filesListPage.getRowForFile('image1.jpg')).toBeVisible() + }) + + test('navigates forward through the list and loops back to the first image', async ({ openFile, viewerPage }) => { + await openFile('image1.jpg') + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe('image1.jpg') + + // The list has several images: navigation arrows are shown. + await expect(viewerPage.nextButton).toBeVisible() + await expect(viewerPage.previousButton).toBeVisible() + + for (const image of ['image2.jpg', 'image3.jpg', 'image4.jpg']) { + await viewerPage.next() + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe(image) + await viewerPage.expectHandler('image') + } + + // Looping from the last image back to the first. + await viewerPage.next() + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe('image1.jpg') + }) + + test('navigates backward and loops from the first image to the last', async ({ openFile, viewerPage }) => { + await openFile('image1.jpg') + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe('image1.jpg') + + await viewerPage.previous() + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe('image4.jpg') + }) + + test('closes the viewer when navigating back in the browser', async ({ page, openFile, viewerPage }) => { + await openFile('image1.jpg') + await viewerPage.isVisible() + + await page.goBack() + await viewerPage.waitForClosed() + }) +}) diff --git a/tests/playwright/e2e/viewer/oddname.spec.ts b/tests/playwright/e2e/viewer/oddname.spec.ts new file mode 100644 index 0000000000000..bd49c543a9c94 --- /dev/null +++ b/tests/playwright/e2e/viewer/oddname.spec.ts @@ -0,0 +1,138 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { MediaKind } from '../../support/sections/ViewerPage.ts' + +import { expect, test } from '../../support/fixtures/viewer-page.ts' +import { FilesListPage } from '../../support/sections/FilesListPage.ts' +import { ViewerPage } from '../../support/sections/ViewerPage.ts' +import { mkdir } from '../../support/utils/dav.ts' +import { createLinkShare } from '../../support/utils/sharing.ts' + +/** + * Build a name aimed at breaking the viewer in case of escaping errors. + * + * @param realName - The original file name + */ +function naughtyFileName(realName: string): string { + const ext = realName.split('.').pop() ?? '' + return ( + '~⛰️ shot of a $[big} mountain`, ' + + "realy #1's " + + '" #_+="%2520%27%22%60%25%21%23 was this called ' + + realName + + 'in the' + + '☁️' + + '👩‍💻' + + '? :* .' + + ext.toUpperCase() + ) +} + +/** + * Build a folder name with special characters around a file name. + * + * @param realName - The original file name + */ +function naughtyFolderName(realName: string): string { + return 'Nextcloud "%27%22%60%25%21%23" >`⛰️<' + realName + "><` e*'rocks!#?#%~" +} + +interface OddCase { + fixture: string + mime: string + kind: MediaKind +} + +const CASES: OddCase[] = [ + { fixture: 'image.png', mime: 'image/png', kind: 'image' }, + { fixture: 'video1.mp4', mime: 'video/mp4', kind: 'video' }, + { fixture: 'audio.mp3', mime: 'audio/mpeg', kind: 'audio' }, +] + +for (const testCase of CASES) { + const placedName = naughtyFileName(testCase.fixture) + const folderName = naughtyFolderName(testCase.fixture) + + test.describe(`Odd file name (${testCase.fixture})`, () => { + test.beforeEach(async ({ page, user, filesListPage, uploadMedia }) => { + await mkdir(page.request, user, `/${folderName}`) + await uploadMedia(testCase.fixture, `/${folderName}/${placedName}`, testCase.mime) + await filesListPage.open() + }) + + test('opens a file with an odd name in the viewer', async ({ filesListPage, openFile, viewerPage }) => { + await openFile(folderName) + await expect(filesListPage.getRowForFile(placedName)).toBeVisible() + + await openFile(placedName) + await viewerPage.waitForOpen() + + expect(await viewerPage.currentName()).toBe(placedName) + await viewerPage.expectHandler(testCase.kind) + + await viewerPage.close() + }) + }) +} + +test.describe('Odd file name (image, sidebar and public share)', () => { + const fixture = 'image.png' + const placedName = naughtyFileName(fixture) + const folderName = naughtyFolderName(fixture) + + test.beforeEach(async ({ page, user, filesListPage, uploadMedia }) => { + await mkdir(page.request, user, `/${folderName}`) + await uploadMedia(fixture, `/${folderName}/${placedName}`, 'image/png') + await filesListPage.open() + }) + + test('opens the sidebar for an odd file name', async ({ page, openFile, viewerPage }) => { + await openFile(folderName) + await openFile(placedName) + await viewerPage.waitForOpen() + + await viewerPage.openSidebar() + + const sidebar = page.locator('aside.app-sidebar') + await expect(sidebar).toBeVisible() + await expect(sidebar.locator('.app-sidebar-header__mainname')).toContainText(placedName) + }) + + test('opens an odd file name from a public link share', async ({ browser, baseURL, playwright, user }) => { + // Seed and share as the owner through a clean basic-auth request context — + // browser session cookies would otherwise win over basic auth. + const ownerRequest = await playwright.request.newContext({ + baseURL, + httpCredentials: { username: user.userId, password: user.password, send: 'always' }, + }) + let share + try { + share = await createLinkShare(ownerRequest, `/${folderName}`) + } finally { + await ownerRequest.dispose() + } + + // Visit the share as an anonymous guest, in a fresh unauthenticated context. + const context = await browser.newContext({ storageState: undefined, baseURL }) + try { + const guestPage = await context.newPage() + await guestPage.goto(share.url) + + const filesListPage = new FilesListPage(guestPage) + const viewerPage = new ViewerPage(guestPage) + + await expect(filesListPage.getRowForFile(placedName)).toBeVisible() + + await filesListPage.getRowNameLinkForFile(placedName).click() + await viewerPage.waitForOpen() + + expect(await viewerPage.currentName()).toBe(placedName) + await viewerPage.expectHandler('image') + } finally { + await context.close() + } + }) +}) diff --git a/tests/playwright/e2e/viewer/sharing.spec.ts b/tests/playwright/e2e/viewer/sharing.spec.ts new file mode 100644 index 0000000000000..8fd9edf08f4d8 --- /dev/null +++ b/tests/playwright/e2e/viewer/sharing.spec.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, publicShareTest as test, uploadMediaFile } from '../../support/fixtures/viewer-page.ts' +import { mkdir } from '../../support/utils/dav.ts' +import { createLinkShare } from '../../support/utils/sharing.ts' + +test.describe('Viewer on public link shares', () => { + test('opens a single shared image in the viewer', async ({ user, ownerRequest, publicShare, viewerPage }) => { + await uploadMediaFile(ownerRequest, user, 'image1.jpg', '/image1.jpg', 'image/jpeg') + const share = await createLinkShare(ownerRequest, '/image1.jpg') + + // A single-file public share opens the viewer automatically. On a public + // share the node is exposed under the share token, so we only assert the + // correct handler renders, not the (token) file name. + await publicShare.open(share.url) + await viewerPage.waitForOpen() + + await viewerPage.expectHandler('image') + }) + + test('opens a single shared video in the viewer', async ({ user, ownerRequest, publicShare, viewerPage }) => { + await uploadMediaFile(ownerRequest, user, 'video1.mp4', '/video1.mp4', 'video/mp4') + const share = await createLinkShare(ownerRequest, '/video1.mp4') + + // A single-file public share opens the viewer automatically. + await publicShare.open(share.url) + await viewerPage.waitForOpen() + + await viewerPage.expectHandler('video') + // The public share serves the video from the public WebDAV endpoint. + await expect(viewerPage.mediaElement('video')) + .toHaveAttribute('src', new RegExp(`/public\\.php/dav/files/${share.token}`)) + }) + + test('navigates through a shared folder of images', async ({ user, ownerRequest, publicShare, filesListPage, viewerPage }) => { + await mkdir(ownerRequest, user, '/Photos') + const images = ['image1.jpg', 'image2.jpg', 'image3.jpg', 'image4.jpg'] + for (const image of images) { + await uploadMediaFile(ownerRequest, user, image, `/Photos/${image}`, 'image/jpeg') + } + const share = await createLinkShare(ownerRequest, '/Photos') + + await publicShare.open(share.url) + await expect(filesListPage.getRowForFile('image1.jpg')).toBeVisible() + + await filesListPage.getRowNameLinkForFile('image1.jpg').click() + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe('image1.jpg') + + await expect(viewerPage.nextButton).toBeVisible() + await expect(viewerPage.previousButton).toBeVisible() + + for (const image of ['image2.jpg', 'image3.jpg', 'image4.jpg']) { + await viewerPage.next() + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe(image) + await viewerPage.expectHandler('image') + } + }) +}) diff --git a/tests/playwright/e2e/viewer/sort.spec.ts b/tests/playwright/e2e/viewer/sort.spec.ts new file mode 100644 index 0000000000000..87a8e361593a4 --- /dev/null +++ b/tests/playwright/e2e/viewer/sort.spec.ts @@ -0,0 +1,92 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/viewer-page.ts' + +// The viewer no longer sorts the list itself; it follows the order the Files +// app provides. These guard nextcloud/viewer#2596 (underscore ordering) and +// nextcloud/viewer#3027 (respect the active sort order). +test.describe('Viewer follows the files list order', () => { + test('steps through underscore-suffixed names in list order', async ({ filesListPage, uploadMedia, openFile, viewerPage }) => { + // Files app natural order is name.jpg, name_1.jpg, name_2.jpg, name_3.jpg. + const names = ['name.jpg', 'name_1.jpg', 'name_2.jpg', 'name_3.jpg'] + for (const name of names) { + await uploadMedia('image1.jpg', name, 'image/jpeg') + } + await filesListPage.open() + + await openFile('name.jpg') + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe('name.jpg') + + for (const name of names.slice(1)) { + await viewerPage.next() + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe(name) + } + }) + + test('steps through a folder sorted by modification time, not by name', async ({ filesListPage, uploadMedia, openFile, viewerPage }) => { + // Names ascend a, b, c while the modification times do not, so a + // viewer ordering the list itself by name shows a different file + const day = 24 * 60 * 60 + const now = Date.now() / 1000 + await uploadMedia('image1.jpg', 'a.jpg', 'image/jpeg', now - day) + await uploadMedia('image1.jpg', 'b.jpg', 'image/jpeg', now - day * 3) + await uploadMedia('image1.jpg', 'c.jpg', 'image/jpeg', now - day * 2) + await filesListPage.open() + + await filesListPage.sortByColumn('Modified') + await expect(filesListPage.getColumnHeader('Modified')).toHaveAttribute('aria-sort', 'ascending') + + // Whatever the list shows is what the viewer has to follow, so read + // the order rather than assuming which end welcome.txt lands on + const listed = (await filesListPage.getRowNames()).filter((name) => name.endsWith('.jpg')) + // The sort has to disagree with the name order, or this cannot tell + // a viewer following the list from one sorting by name itself + expect(listed).not.toEqual([...listed].sort()) + + await openFile(listed[0]!) + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe(listed[0]) + + for (const name of listed.slice(1)) { + await viewerPage.next() + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe(name) + } + }) + + test('steps backwards through a descending sort', async ({ filesListPage, uploadMedia, openFile, viewerPage }) => { + for (const name of ['a.jpg', 'b.jpg', 'c.jpg']) { + await uploadMedia('image1.jpg', name, 'image/jpeg') + } + await filesListPage.open() + + // Reversing the name order is the same list read the other way, so + // a viewer sorting by name on its own walks it backwards + await filesListPage.sortByColumn('Name') + await expect(filesListPage.getColumnHeader('Name')).toHaveAttribute('aria-sort', 'descending') + + const listed = (await filesListPage.getRowNames()).filter((name) => name.endsWith('.jpg')) + expect(listed).toEqual(['c.jpg', 'b.jpg', 'a.jpg']) + + await openFile(listed[0]!) + await viewerPage.waitForOpen() + + for (const name of listed.slice(1)) { + await viewerPage.next() + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe(name) + } + + // And back up the same way + for (const name of [...listed].reverse().slice(1)) { + await viewerPage.previous() + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe(name) + } + }) +}) diff --git a/tests/playwright/e2e/viewer/videos.spec.ts b/tests/playwright/e2e/viewer/videos.spec.ts new file mode 100644 index 0000000000000..b7f3c6fb93e7a --- /dev/null +++ b/tests/playwright/e2e/viewer/videos.spec.ts @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { createMediaViewerTests, VIDEO_MEDIA } from './media.ts' + +createMediaViewerTests(VIDEO_MEDIA) diff --git a/tests/playwright/start-nextcloud-server.js b/tests/playwright/start-nextcloud-server.js index e3ec044d9a57e..cb11f51ac6226 100644 --- a/tests/playwright/start-nextcloud-server.js +++ b/tests/playwright/start-nextcloud-server.js @@ -50,7 +50,9 @@ async function start() { } await waitOnNextcloud(ip) - await configureNextcloud(process.env.PLAYWRIGHT_SETUP ? [] : ['viewer']) + // The viewer used to be fetched from its own repository; it now ships with + // the server and is enabled on install, so no app has to be provisioned. + await configureNextcloud([]) if (process.env.PLAYWRIGHT_SETUP) { // When the apps folder is mounted, configureNextcloud writes an diff --git a/tests/playwright/support/fixtures/viewer-page.ts b/tests/playwright/support/fixtures/viewer-page.ts new file mode 100644 index 0000000000000..621038601954f --- /dev/null +++ b/tests/playwright/support/fixtures/viewer-page.ts @@ -0,0 +1,97 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/e2e-test-server' +import type { APIRequestContext } from '@playwright/test' + +import { mergeTests } from '@playwright/test' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { ViewerPage } from '../sections/ViewerPage.ts' +import { uploadContent } from '../utils/dav.ts' +import { test as filesTest } from './files-page.ts' +import { test as sharingTestBase } from './files-sharing-page.ts' +import { test as publicShareTestBase } from './public-share-page.ts' + +/** + * The absolute path of a media fixture, from the shared `tests/data/media`. + * + * @param name - The fixture file name (e.g. `image1.jpg`) + */ +export function mediaFixturePath(name: string): string { + return fileURLToPath(new URL(`../../../data/media/${name}`, import.meta.url)) +} + +/** + * Upload a bundled media fixture to the user's WebDAV root. + * + * @param request - The request context to upload with (authenticated as `user`) + * @param user - The user owning the file + * @param fixture - The fixture file name to read from `tests/data/media` + * @param remoteName - The remote name/path relative to the user root (defaults to the fixture name) + * @param mimeType - The content type sent with the upload + * @param mtime - Modification time in seconds, to control the sort order + */ +export async function uploadMediaFile( + request: APIRequestContext, + user: User, + fixture: string, + remoteName: string = fixture, + mimeType: string = 'application/octet-stream', + mtime?: number, +): Promise { + const path = remoteName.startsWith('/') ? remoteName : `/${remoteName}` + await uploadContent(request, user, readFileSync(mediaFixturePath(fixture)), mimeType, path, mtime) +} + +interface ViewerFixtures { + /** Page object for the viewer modal. */ + viewerPage: ViewerPage + /** Upload a bundled media fixture to the logged-in user's root. */ + uploadMedia: (fixture: string, remoteName?: string, mimeType?: string, mtime?: number) => Promise + /** Open a file (or folder) by clicking its name link, triggering its default action. */ + openFile: (name: string) => Promise +} + +/** + * The Files fixtures (logged-in random `user`, `filesListPage`) plus the viewer + * page object and helpers to upload media and open files into the viewer. + */ +export const test = mergeTests(filesTest).extend({ + viewerPage: async ({ page }, use) => { + await use(new ViewerPage(page)) + }, + + uploadMedia: async ({ page, user }, use) => { + await use((fixture, remoteName, mimeType, mtime) => uploadMediaFile(page.request, user, fixture, remoteName, mimeType, mtime)) + }, + + openFile: async ({ filesListPage }, use) => { + await use((name) => filesListPage.getRowNameLinkForFile(name).click()) + }, +}) + +/** + * The public-share fixtures (guest `page`, share `owner` request) plus the + * viewer page object — for viewer behaviour on public link shares. + */ +export const publicShareTest = mergeTests(publicShareTestBase).extend<{ viewerPage: ViewerPage }>({ + viewerPage: async ({ page }, use) => { + await use(new ViewerPage(page)) + }, +}) + +/** + * The user-share fixtures (`page` logged in as the recipient `user`, `owner` + * request context to seed and share) plus the viewer page object — for viewer + * behaviour on files shared with the current user. + */ +export const sharingTest = mergeTests(sharingTestBase).extend<{ viewerPage: ViewerPage }>({ + viewerPage: async ({ page }, use) => { + await use(new ViewerPage(page)) + }, +}) + +export { expect } from '../matchers.ts' diff --git a/tests/playwright/support/sections/ViewerPage.ts b/tests/playwright/support/sections/ViewerPage.ts new file mode 100644 index 0000000000000..3238c453f2072 --- /dev/null +++ b/tests/playwright/support/sections/ViewerPage.ts @@ -0,0 +1,218 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Locator, Page } from '@playwright/test' + +import { expect } from '@playwright/test' + +/** Media kinds handled by the viewer and their custom element tag / inner media tag. */ +export type MediaKind = 'image' | 'video' | 'audio' + +const HANDLER_TAG: Record = { + image: 'oca-viewer-image', + video: 'oca-viewer-video', + audio: 'oca-viewer-audio', +} + +const MEDIA_ELEMENT: Record = { + image: 'img', + video: 'video', + audio: 'audio', +} + +/** + * Page object for the viewer modal. + * + * The viewer mounts a `
` on the body, inside which an NcModal + * renders with the `viewer__modal` class. The active handler is rendered as a + * custom element (`oca-viewer-image`, `oca-viewer-video`, `oca-viewer-audio`). + */ +export class ViewerPage { + public readonly root: Locator + public readonly modal: Locator + public readonly container: Locator + public readonly content: Locator + public readonly headerName: Locator + public readonly loading: Locator + public readonly errorMessage: Locator + public readonly closeButton: Locator + public readonly nextButton: Locator + public readonly previousButton: Locator + + constructor(public readonly page: Page) { + // NcModal teleports its content to the document body, so the modal is not + // under the #viewer mount point — match it directly by its class + role. + this.root = page.locator('.viewer__modal') + this.modal = this.root + this.container = this.modal.locator('.modal-container') + this.content = this.modal.locator('.modal-container__content') + this.headerName = this.modal.locator('.modal-header__name') + this.loading = this.modal.locator('.viewer__loading') + this.errorMessage = this.modal.locator('.empty-content__name') + this.closeButton = this.modal.getByRole('button', { name: 'Close' }) + this.nextButton = this.modal.getByRole('button', { name: 'Next' }) + this.previousButton = this.modal.getByRole('button', { name: 'Previous' }) + } + + /** + * Click the modal content background (outside the media), which closes the + * viewer. Clicks near a corner so the centered media is never hit. + */ + public async clickOutside(): Promise { + await this.content.click({ position: { x: 8, y: 8 } }) + } + + /** + * Wait for the viewer to be gone, and to stay gone. + * + * Counting rather than asking whether the first match is hidden: closing + * unwinds the history entries the viewer pushed, and while that is in + * flight the URL still asks for a file. A second viewer opening over the + * one that is closing leaves two containers in the DOM, and `toBeHidden` + * on the first of them passes while the second is still up. + */ + public async waitForClosed(): Promise { + await expect(this.container).toHaveCount(0) + } + + /** + * Assert the viewer shows an error message and is not stuck loading. + */ + public async expectError(): Promise { + await expect(this.errorMessage).toBeVisible() + await expect(this.loading).toHaveCount(0) + } + + /** + * The custom element for the given media kind. + * + * @param kind - The media kind + */ + public handlerTag(kind: MediaKind): Locator { + return this.container.locator(HANDLER_TAG[kind]) + } + + /** + * The inner media element (img/video/audio) for the given media kind. + * + * @param kind - The media kind + */ + public mediaElement(kind: MediaKind): Locator { + return this.handlerTag(kind).locator(MEDIA_ELEMENT[kind]) + } + + /** + * Assert the viewer modal is visible. + */ + public async isVisible(): Promise { + await expect(this.container).toBeVisible() + } + + /** + * Wait for the viewer to be open and done loading (spinner gone). + */ + public async waitForOpen(): Promise { + await expect(this.container).toBeVisible() + // Fetching and decoding the media is the slowest thing the viewer does, + // and the shared CI runners are slower at it than the default budget + // allows. The spinner going away is the signal, not how fast. + await expect(this.loading).toHaveCount(0, { timeout: 20_000 }) + } + + /** + * The basename of the file currently shown in the header. + */ + public async currentName(): Promise { + return (await this.headerName.textContent())?.trim() ?? '' + } + + /** + * Assert the active handler and its media element are visible for the given kind. + * + * @param kind - The media kind + */ + public async expectHandler(kind: MediaKind): Promise { + await expect(this.handlerTag(kind)).toBeVisible() + await expect(this.mediaElement(kind)).toHaveAttribute('src', /.+/) + } + + /** + * Navigate to the next file in the slideshow. + */ + public async next(): Promise { + await this.nextButton.click() + } + + /** + * Navigate to the previous file in the slideshow. + */ + public async previous(): Promise { + await this.previousButton.click() + } + + /** + * Close the viewer modal. + */ + public async close(): Promise { + await this.closeButton.click() + // The URL first: the flag is what the Files list reads to decide + // whether to open a file, so while it is still set the viewer can be + // opened again over the one that is closing. + await this.page.waitForURL((url) => !url.searchParams.has('openfile')) + await this.waitForClosed() + } + + /** + * The header actions menu toggle, if the actions are collapsed into a menu. + */ + public actionsToggle(): Locator { + return this.modal.getByRole('button', { name: /Actions|Open actions menu/i }) + } + + /** + * Run a header action by its label, opening the actions menu if needed. + * + * @param name - The action label (or a matching regexp) + */ + public async runAction(name: string | RegExp): Promise { + const direct = this.modal.getByRole('button', { name }) + const entry = this.page.getByRole('menuitem', { name }) + + // Whether the action is a button or an entry behind the menu depends on + // the width, and the header re-renders whenever the shown file changes: + // asking once, mid-render, finds neither and then waits for a menu that + // was never opened. The whole sequence is retried instead. + await expect(async () => { + if (await direct.isVisible()) { + await direct.click() + return + } + await this.actionsToggle().click({ timeout: 2_000 }) + await entry.click({ timeout: 2_000 }) + }).toPass({ timeout: 20_000 }) + } + + /** + * Open the Files sidebar from the viewer header actions. + * The action emits `viewer:sidebar:open`, which the Files app listens to. + */ + public async openSidebar(): Promise { + // The header actions collapse into a menu; the "Open sidebar" entry is a + // menuitem there. Open the menu first if the entry is not already shown. + const directButton = this.modal.getByRole('button', { name: 'Open sidebar' }) + const toggle = this.actionsToggle() + // Both checks below are instant, so wait for the header to render one of + // the two entry points first - it is still loading when opened from a + // slow preview. + await expect(directButton.or(toggle).first()).toBeVisible() + if (!(await directButton.isVisible())) { + await toggle.click() + } + await this.page.getByRole('menuitem', { name: 'Open sidebar' }) + .or(directButton) + .first() + .click() + } +} diff --git a/window.d.ts b/window.d.ts index 0d808e32a8c88..e45374a2e7b72 100644 --- a/window.d.ts +++ b/window.d.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import type { LegacyViewerApi } from './core/src/viewer-legacy.ts' import type Tab from './apps/files/src/models/Tab.js' import type RouterService from './apps/files/src/services/RouterService.ts' import type Settings from './apps/files/src/services/Settings.js' @@ -26,6 +27,11 @@ declare global { Settings: Settings Sidebar: SidebarAPI } + /** + * @deprecated since 36, removed in 40. + * Use the `@nextcloud/viewer` package instead. + */ + Viewer?: LegacyViewerApi } & Record // eslint-disable-line @typescript-eslint/no-explicit-any // Public Files namespace