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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .tx/config
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,9 @@ file_filter = translationfiles/<lang>/workflowengine.po
source_file = translationfiles/templates/workflowengine.pot
source_lang = en
type = PO

[o:nextcloud:p:nextcloud:r:viewer]
file_filter = translationfiles/<lang>/viewer.po
source_file = translationfiles/templates/viewer.pot
source_lang = en
type = PO
1 change: 1 addition & 0 deletions REUSE.toml
Comment thread
skjnldsv marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -356,3 +356,4 @@ path = "core/img/libreoffice.svg"
precedence = "aggregate"
SPDX-FileCopyrightText = "2011-2026 The Document Foundation"
SPDX-License-Identifier = "LicenseRef-TheDocumentFoundationTrademarks"

4 changes: 0 additions & 4 deletions apps/files/lib/Controller/ViewController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -198,9 +197,6 @@ public function index($dir = '', $view = '', $fileid = null) {
$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);
Expand Down
18 changes: 10 additions & 8 deletions apps/files/src/actions/openLocallyAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* 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'
Expand All @@ -15,6 +15,7 @@ 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'

Expand Down Expand Up @@ -44,8 +45,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
},

Expand All @@ -60,15 +61,16 @@ 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 })
await getViewer().open(contents as IFile[], node as IFile)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
99 changes: 99 additions & 0 deletions apps/files_versions/src/utils/versions.spec.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('@nextcloud/auth')>()
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> = {}): 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')
})
})
34 changes: 34 additions & 0 deletions apps/files_versions/src/utils/versions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
*
Expand Down
53 changes: 14 additions & 39 deletions apps/files_versions/src/views/FilesVersionsSidebarTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,13 @@
</template>

<script lang="ts" setup>
import type { IFolder, INode, IView } from '@nextcloud/files'
import type { IFile, IFolder, INode, IView } from '@nextcloud/files'
import type { Version } from '../utils/versions.ts'

import { showError, showSuccess } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import { t } from '@nextcloud/l10n'
import { getViewer, canView as viewerCanView } from '@nextcloud/viewer'
import { useIsMobile } from '@nextcloud/vue/composables/useIsMobile'
import { watchDebounced } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
Expand All @@ -55,7 +56,7 @@ import VersionEntry from '../components/VersionEntry.vue'
import VersionLabelDialog from '../components/VersionLabelDialog.vue'
import VirtualScrolling from '../components/VirtualScrolling.vue'
import logger from '../utils/logger.ts'
import { deleteVersion, fetchVersions, restoreVersion, setVersionLabel } from '../utils/versions.ts'
import { deleteVersion, fetchVersions, restoreVersion, setVersionLabel, versionToNode } from '../utils/versions.ts'

const props = defineProps<{
active: boolean
Expand Down Expand Up @@ -114,18 +115,10 @@ const initialVersionMtime = computed(() => {
.reduce((a, b) => Math.min(a, b))
})

const canView = computed(() => {
if (!props.node) {
return false
}
const canView = computed(() => props.node !== null && viewerCanView(props.node))

return window.OCA.Viewer?.mimetypes?.includes(props.node?.mime)
})

const canCompare = computed(() => {
return !isMobile.value
&& window.OCA.Viewer?.mimetypesCompare?.includes(props.node?.mime)
})
// Comparison puts the two files side by side, which needs the width for it
const canCompare = computed(() => !isMobile.value && canView.value)

// When either the current node to show or its mtime changes we need to refetch the versions
// When the id changed we immediately show changes
Expand Down Expand Up @@ -247,41 +240,23 @@ function openVersion(version: Version) {
return
}

// Open current file view instead of read only
if (version.mtime === props.node?.mtime?.getTime()) {
window.OCA.Viewer.open({ path: props.node.path })
// The newest version is the file itself, which opens as it normally would
if (version.mtime === props.node.mtime?.getTime()) {
getViewer().open([props.node as IFile], props.node as IFile)
return
}

window.OCA.Viewer.open({
fileInfo: {
...version,
// Versions previews are too small for our use case, so we override previewUrl
// to either point to the original file or original version.
filename: version.filename,
previewUrl: undefined,
},
enableSidebar: false,
})
// The sidebar resolves a file by its dav source and a version is not
// one it can find there
const versionNode = versionToNode(version, props.node)
getViewer().open([versionNode], versionNode, { enableSidebar: false })
}

/**
* @param version - The version to compare
*/
function compareVersion(version: Version) {
const _versions = versions.value.map((version) => ({ ...version, previewUrl: undefined }))

window.OCA.Viewer.compare(
{
fileid: props.node!.fileid,
filename: props.node!.path,
basename: props.node!.basename,
source: props.node!.source,
mime: props.node!.mime,
hasPreview: props.node!.attributes?.['has-preview'] ?? false,
},
_versions.find((v) => v.source === version.source),
)
getViewer().compare(props.node as IFile, versionToNode(version, props.node))
}
</script>

Expand Down
12 changes: 3 additions & 9 deletions apps/settings/lib/Settings/Personal/ServerDevNotice.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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;
}

Expand Down
Loading
Loading