Skip to content
Draft
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
2 changes: 2 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,5 +69,7 @@
['name' => 'Workspace#publicFolder', 'url' => '/public/workspace', 'verb' => 'GET'],
/** @see Controller\WorkspaceController::direct() */
['name' => 'Workspace#direct', 'url' => '/workspace/direct', 'verb' => 'POST'],
/** @see Controller\DirectEditingController::edit() */
['name' => 'DirectEditing#edit', 'url' => '/directEditing/edit', 'verb' => 'POST'],
]
];
1 change: 1 addition & 0 deletions composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
'OCA\\Text\\Context\\UnauthorizedFileContext' => $baseDir . '/../lib/Context/UnauthorizedFileContext.php',
'OCA\\Text\\Controller\\AiController' => $baseDir . '/../lib/Controller/AiController.php',
'OCA\\Text\\Controller\\AttachmentController' => $baseDir . '/../lib/Controller/AttachmentController.php',
'OCA\\Text\\Controller\\DirectEditingController' => $baseDir . '/../lib/Controller/DirectEditingController.php',
'OCA\\Text\\Controller\\ISessionAwareController' => $baseDir . '/../lib/Controller/ISessionAwareController.php',
'OCA\\Text\\Controller\\NavigationController' => $baseDir . '/../lib/Controller/NavigationController.php',
'OCA\\Text\\Controller\\PublicSessionController' => $baseDir . '/../lib/Controller/PublicSessionController.php',
Expand Down
1 change: 1 addition & 0 deletions composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class ComposerStaticInitText
'OCA\\Text\\Context\\UnauthorizedFileContext' => __DIR__ . '/..' . '/../lib/Context/UnauthorizedFileContext.php',
'OCA\\Text\\Controller\\AiController' => __DIR__ . '/..' . '/../lib/Controller/AiController.php',
'OCA\\Text\\Controller\\AttachmentController' => __DIR__ . '/..' . '/../lib/Controller/AttachmentController.php',
'OCA\\Text\\Controller\\DirectEditingController' => __DIR__ . '/..' . '/../lib/Controller/DirectEditingController.php',
'OCA\\Text\\Controller\\ISessionAwareController' => __DIR__ . '/..' . '/../lib/Controller/ISessionAwareController.php',
'OCA\\Text\\Controller\\NavigationController' => __DIR__ . '/..' . '/../lib/Controller/NavigationController.php',
'OCA\\Text\\Controller\\PublicSessionController' => __DIR__ . '/..' . '/../lib/Controller/PublicSessionController.php',
Expand Down
81 changes: 81 additions & 0 deletions lib/Controller/DirectEditingController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Text\Controller;

use OCA\Text\Context\FileContextFactory;
use OCA\Text\Service\ApiService;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\DirectEditing\IManager;
use OCP\Files\InvalidPathException;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\IRequest;
use OCP\IUserManager;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;

class DirectEditingController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
private readonly IManager $directEditingManager,
private readonly IUserSession $userSession,
private readonly ApiService $apiService,
private readonly IUserManager $userManager,
private readonly FileContextFactory $fileContextFactory,
private readonly LoggerInterface $logger,
) {
parent::__construct($appName, $request);
}

/**
* Open a direct editing token and create an editing session.
*
* This allows navigating from one direct editing file to the next without reloading the page.
*
* Returns 200 with file metadata and session data if the token is found.
*
* @param string $url Direct editing url as provided by the server
*/
#[NoAdminRequired]
#[PublicPage]
public function edit(string $token): DataResponse {
$token = $this->directEditingManager->getToken($token);
try {
$token->useTokenScope();
$user = $this->userManager->get($token->getUser());
if ($user === null) {
throw new NotFoundException();
}
$file = $token->getFile();
$context = $this->fileContextFactory->build($user, 'file', $file->getId(), $file);
$session = $this->apiService->create($context, null);
$this->logger->debug('Started direct editing session.', ['token' => $token->getToken()]);
return new DataResponse([
'fileId' => $token->getFile()->getId(),
'mimetype' => $token->getFile()->getMimeType(),
'session' => $session->getData(),
]);
} catch (InvalidPathException|NotFoundException|NotPermittedException) {
return new DataResponse([], Http::STATUS_NOT_FOUND);
} finally {
$this->revertTokenScope();
}
}

private function revertTokenScope(): void {
$this->userSession->setUser(null);
}

}
3 changes: 3 additions & 0 deletions lib/Middleware/SessionMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
use OCP\IRequest;
use OCP\IUserManager;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;
use ReflectionException;

class SessionMiddleware extends Middleware {
Expand All @@ -43,6 +44,7 @@ public function __construct(
private readonly IL10N $l10n,
private readonly IUserManager $userManager,
private readonly FileService $fileService,
private readonly LoggerInterface $logger,
) {
}

Expand Down Expand Up @@ -155,6 +157,7 @@ private function getDocument(string $documentId): ?Document {
}

public function afterException($controller, $methodName, \Exception $exception): JSONResponse|Response {
$this->logger->debug('After exception', ['exception' => $exception]);
if ($exception instanceof InvalidDocumentBaseVersionEtagException) {
return new JSONResponse(['error' => $this->l10n->t('Editing session has expired. Please reload the page.')], Http::STATUS_PRECONDITION_FAILED);
}
Expand Down
34 changes: 34 additions & 0 deletions playwright/e2e/direct-editing.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { expect, mergeTests } from '@playwright/test'
import { test as directEditFileTest } from '../support/fixtures/direct-edit-file.ts'
import { test as editorTest } from '../support/fixtures/editor.ts'
import { test as randomUserTest } from '../support/fixtures/random-user.ts'

const test = mergeTests(editorTest, randomUserTest, directEditFileTest)

test.use({
fileName: 'initial.md',
fileContent: '# Initial File',
})

test.describe('Direct editing', () => {
test('opens once', async ({ editor, open }) => {
await open()
await expect(editor.getHeading({ name: 'Initial File' })).toBeVisible()
})

test('opens next file', async ({ editor, open, owner, page }) => {
const secondFile = await owner.uploadFile({ name: 'second.md', content: '# Second file' })
const { url } = await secondFile.createDirectEditingLink()
await open()
await expect(editor.getHeading({ name: 'Initial File' })).toBeVisible()
await page.evaluate((url) => {
window.OCA.Text.openDirectEditing(url)
}, url)
await expect(editor.getHeading({ name: 'Second File' })).toBeVisible()
})
})
17 changes: 17 additions & 0 deletions playwright/support/fixtures/Node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,23 @@ export class Node {
return { token, id }
}

async createDirectEditingLink(): Promise<{ url: string }> {
const path = `/${this.name}`
const response = await this.page.request.post(
'/ocs/v2.php/apps/files/api/v1/directEditing/open?format=json',
{
data: { path },
headers: ocsHeaders,
/*
'OCS-ApiRequest': 'true',
'Content-Type': 'application/x-www-form-urlencoded',
*/
},
)
const { ocs } = await response.json() as { ocs: { data: { url: string } } }
return ocs.data
}

async getContent() {
const response = await this.page.request.get(`/remote.php/webdav/${this.name}`)
return response.text()
Expand Down
32 changes: 32 additions & 0 deletions playwright/support/fixtures/direct-edit-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { expect } from '@playwright/test'
import { test as base } from './shared-file.ts'

export interface DirectEditFileFixture {
directEditing: { url: string }
}

/**
* This test fixture uploads the file to a user's root directory and shares it.
*/
export const test = base.extend<DirectEditFileFixture>({

directEditing: async ({ file }, use) => {
const { url } = await file.createDirectEditingLink()
await use({ url })
},

open: async ({ page, directEditing }, use) => {
const open = async () => {
await page.goto(directEditing.url)
await expect(page.getByRole('toolbar', { name: 'Formatting menu bar' }))
.toBeVisible()
}
await use(open)
},

})
26 changes: 24 additions & 2 deletions src/apis/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@

import type { Connection } from '../types/Connection.ts'
import type { Document } from '../types/Document.ts'
import type { GuestSession, Session } from '../types/Session.ts'
import type { GuestSession, Session, UserSession } from '../types/Session.ts'

import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
import { generateOcsUrl, generateUrl } from '@nextcloud/router'

export interface OpenContextParams {
type: string
Expand Down Expand Up @@ -75,6 +75,28 @@ export async function openShare(params: OpenShareParams): Promise<{ connection:
return { connection, data: response.data }
}

interface DirectEditingState {
session: UserSession
fileId: number
mimeType: string
}

/**
* Open a direct editing url to start editing the file in question
*
* Opens the direct editing token without loading a new html page.
* The response can be used to load the file in the current page.
*
* @param params send with the request.
* @param params.url the direct editing url as provided by the server.
*/
export async function openDirectEditing(params: { url: string }): Promise<DirectEditingState> {
const url = generateOcsUrl('/apps/text/directEditing/edit')
const token = params.url.split('/').at(-1)
const response = await axios.post(url, { ...params, token })
return response.data.ocs.data
}

/**
* Update the guest name
*
Expand Down
10 changes: 10 additions & 0 deletions src/components/EditorReloader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import type { ComponentInstance, ShallowRef } from 'vue'

import { nextTick, ref, useTemplateRef, watch } from 'vue'
import CollaborativeEditor from './CollaborativeEditor.vue'
import { logger } from '../helpers/logger.ts'

const props = defineProps(CollaborativeEditor.props)
defineEmits(['focus', 'push:forbidden'])
const reloading = ref(false)
Expand All @@ -37,6 +39,14 @@ watch(reloading, (val) => {
}
})

watch(
() => props.initialSession,
(cur, old) => {
logger.debug('initialSession changed', { old, cur })
reloading.value = true
},
)

const editorComponent = useTemplateRef('editor-component') as Readonly<ShallowRef<ComponentInstance<typeof CollaborativeEditor | null>>>
defineExpose({
get editor() {
Expand Down
39 changes: 32 additions & 7 deletions src/views/DirectEditing.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
:context
active
autofocus
:mime="initial.mimetype"
:mime="mimetype"
isDirectEditing
@ready="loaded"
@push:forbidden="onPushForbidden">
Expand All @@ -24,8 +24,10 @@
</template>

<script>
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
import { reactive } from 'vue'
import EditorReloader from '../components/EditorReloader.vue'
import { openDirectEditing } from '../apis/connect.ts'
import { logger } from '../helpers/logger.ts'

const log = reactive({
Expand Down Expand Up @@ -84,22 +86,27 @@ window.addEventListener('message', function(message) {
logger.debug('postMessage', { message })
})

window.OCA.Text.openDirectEditing = async (url) => {
logger.debug('opening direct editing url', { url })
const directEditing = await openDirectEditing({ url })
emit('text:directEditing:open', directEditing)
}

export default {
name: 'DirectEditing',
components: { EditorReloader },
data() {
return {
initial: OCP.InitialState.loadState('text', 'file'),
mimetype: undefined,
fileId: undefined,
initialSession: undefined,
messages: log.messages,
log,
saving: false,
}
},

computed: {
initialSession() {
return JSON.parse(this.initial.session) || null
},

isMobile() {
return (
Expand All @@ -112,17 +119,24 @@ export default {

context() {
return {
id: this.initial.fileId,
id: this.fileId,
type: 'file',
}
},
},

beforeMount() {
callMobileMessage('loading')
const initialState = OCP.InitialState.loadState('text', 'file')
if (initialState) {
callMobileMessage('loading')
this.initialSession = JSON.parse(initialState.session) || null
this.mimetype = initialState.mimetype
this.fileId = initialState.fileId
}
},

mounted() {
subscribe('text:directEditing:open', this.updateDirectEditing)
document
.querySelector('meta[name="viewport"]')
.setAttribute(
Expand All @@ -131,7 +145,18 @@ export default {
)
},

unmounted() {
unsubscribe('text:directEditing:open', this.updateDirectEditing)
},

methods: {
updateDirectEditing(value) {
logger.debug('updating direct editing', value)
this.initialSession = value.session
this.mimetype = value.mimetype
this.fileId = value.fileId
},

async close() {
this.saving = true
const timeout = new Promise((resolve) => setTimeout(resolve, 2000))
Expand Down
Loading
Loading