From 24cb9d9a605154dbd7522523ecc9883e0622047d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?John=20Molakvo=C3=A6?= <14975046+skjnldsv@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:03:51 +0100 Subject: [PATCH] feat(files): report files opened locally as opened MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Open locally" now reports the file through the opened API once the desktop client was asked to open it, retried or not. "Open online" leaves it to the viewer, which reports what it opens itself. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: John Molakvoæ <14975046+skjnldsv@users.noreply.github.com> --- .../src/actions/openLocallyAction.spec.ts | 63 ++++++++++++++++++- apps/files/src/actions/openLocallyAction.ts | 30 +++++++-- 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/apps/files/src/actions/openLocallyAction.spec.ts b/apps/files/src/actions/openLocallyAction.spec.ts index 6202edda9fcbc..dd1e9680ae728 100644 --- a/apps/files/src/actions/openLocallyAction.spec.ts +++ b/apps/files/src/actions/openLocallyAction.spec.ts @@ -18,6 +18,7 @@ vi.mock('@nextcloud/dialogs', async (importOriginal) => ({ ...await importOriginal(), showError: vi.fn(), })) +vi.mock('@nextcloud/viewer', () => ({ getViewer: () => ({ open: vi.fn() }) })) const view = { id: 'files', @@ -155,8 +156,31 @@ describe('Open locally action enabled tests', () => { }) describe('Open locally action execute tests', () => { + const file = new File({ + id: 1, + source: 'http://nextcloud.local/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.UPDATE, + root: '/files/admin', + }) + + /** + * Answer the confirmation dialog with one of its buttons + * + * @param label - The label of the button to click + */ + function clickDialogButton(label: string) { + const setButtons = nextcloudDialogs.DialogBuilder.prototype.setButtons + vi.spyOn(nextcloudDialogs.DialogBuilder.prototype, 'setButtons').mockImplementation(function(this: nextcloudDialogs.DialogBuilder, buttons) { + buttons.find((button) => button.label === label)!.callback() + return setButtons.call(this, buttons) + }) + } + let spyShowDialog beforeEach(() => { + vi.restoreAllMocks() vi.resetAllMocks() spyShowDialog = vi.spyOn(nextcloudDialogs.Dialog.prototype, 'show') .mockImplementation(() => Promise.resolve()) @@ -189,12 +213,49 @@ describe('Open locally action execute tests', () => { // Silent action expect(exec).toBe(null) - expect(axios.post).toBeCalledTimes(1) + expect(axios.post).toBeCalledTimes(2) expect(axios.post).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files/api/v1/openlocaleditor?format=json', { path: '/foobar.txt' }) + expect(axios.post).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files/api/v1/opened', { fileId: 1 }) expect(showError).toBeCalledTimes(0) expect(windowOpenSpy).toBeCalledWith('nc://open/test@nextcloud.local/foobar.txt?token=foobar', '_self') }) + test('Open locally reports the file opened once, even after a retry', async () => { + vi.spyOn(axios, 'post').mockImplementation(async () => ({ + data: { ocs: { data: { token: 'foobar' } } }, + })) + vi.spyOn(window, 'open').mockImplementation(() => null) + clickDialogButton('Retry and close') + + await action.exec({ + nodes: [file], + view, + folder: {} as IFolder, + contents: [], + }) + + expect(axios.post).toBeCalledTimes(3) + expect(vi.mocked(axios.post).mock.calls.filter(([url]) => url.endsWith('/opened'))).toHaveLength(1) + }) + + test('Open online leaves reporting the file opened to the viewer', async () => { + vi.spyOn(axios, 'post').mockImplementation(async () => ({ + data: { ocs: { data: { token: 'foobar' } } }, + })) + vi.spyOn(window, 'open').mockImplementation(() => null) + clickDialogButton('Open online') + + await action.exec({ + nodes: [file], + view, + folder: {} as IFolder, + contents: [], + }) + + expect(axios.post).toBeCalledTimes(1) + expect(axios.post).not.toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files/api/v1/opened', expect.anything()) + }) + test('Open locally fails and shows error', async () => { vi.spyOn(axios, 'post').mockImplementation(async () => ({})) const showError = vi.spyOn(nextcloudDialogs, 'showError') diff --git a/apps/files/src/actions/openLocallyAction.ts b/apps/files/src/actions/openLocallyAction.ts index d93f06ed4cf34..2f9ecfccf0cee 100644 --- a/apps/files/src/actions/openLocallyAction.ts +++ b/apps/files/src/actions/openLocallyAction.ts @@ -66,15 +66,20 @@ export const action: IFileAction = { * @param contents - The folder the file is in, so the viewer can navigate */ async function attemptOpenLocalClient(node: INode, contents: ActionContextSingle['contents']) { - await openLocalClient(node.path) + let redirected = await openLocalClient(node.path) const result = await confirmLocalEditDialog() if (result === 'local') { - await openLocalClient(node.path) + redirected = await openLocalClient(node.path) || redirected } else if (result === 'online') { // The listing carries the folders too, and the viewer pages through - // files alone + // files alone. The viewer reports the open itself. const files = contents.filter((entry): entry is IFile => entry.type === FileType.File) await getViewer().open(files, node as IFile) + return + } + + if (redirected) { + await reportOpened(node) } } @@ -83,8 +88,9 @@ async function attemptOpenLocalClient(node: INode, contents: ActionContextSingle * There is no way to get notified if this action was successful. * * @param path - Path to open + * @return Whether the client was asked to open it */ -async function openLocalClient(path: string): Promise { +async function openLocalClient(path: string): Promise { const link = generateOcsUrl('apps/files/api/v1') + '/openlocaleditor?format=json' try { @@ -94,9 +100,25 @@ async function openLocalClient(path: string): Promise { url += '?token=' + result.data.ocs.data.token window.open(url, '_self') + return true } catch (error) { showError(t('files', 'Failed to redirect to client')) logger.error('Failed to redirect to client', { error }) + return false + } +} + +/** + * Tell the server the user opened the file, so apps know what they work with. + * We cannot know whether the client did open it, only that it was asked to. + * + * @param node - The file opened + */ +async function reportOpened(node: INode): Promise { + try { + await axios.post(generateOcsUrl('apps/files/api/v1/opened'), { fileId: node.fileid }) + } catch (error) { + logger.error('Failed to report the file as opened', { error }) } }