Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
14d201e
chore(cleanup): error handling
max-nextcloud Aug 18, 2026
d20af4f
chore(refactor): Introduce IContext and FileContext
max-nextcloud Aug 12, 2026
405dbcd
chore(refactor): simplify IContext API with helper classes
max-nextcloud Aug 15, 2026
63490fc
chore(refactor): very basic registry for context factories
max-nextcloud Aug 15, 2026
251a62d
chore(refactor): handle baseVersionEtag outside IContext
max-nextcloud Aug 15, 2026
7363261
chore(refactor): introduce RegisterContextEvent
max-nextcloud Aug 16, 2026
d2237ad
chore(refactor): split open api functions
max-nextcloud Aug 16, 2026
c92618e
enh(api): allow opening contexts of different types
max-nextcloud Aug 17, 2026
d0e8a86
enh(db): add context to document
max-nextcloud Aug 18, 2026
afc80ee
chore(version): bump to run migration
max-nextcloud Aug 18, 2026
e8a8abb
chore(refactor): store outside change in the DocumentSaveConflictExce…
max-nextcloud Aug 18, 2026
e822296
enh(api): handle sync requests with context
max-nextcloud Aug 18, 2026
2d01980
fix(attachments): get file via context
max-nextcloud Aug 18, 2026
9ee754d
fix(share): consider share token in ContextManager
max-nextcloud Aug 19, 2026
1c82610
fix(push): use context to check if file is readonly
max-nextcloud Aug 19, 2026
1031a58
enh(save): use context for saving
max-nextcloud Aug 19, 2026
84cb573
chore(cleanup): redundant check for lock provider
max-nextcloud Aug 19, 2026
f5a43ce
chore(tweak): return first editable file early
max-nextcloud Aug 19, 2026
7995a5a
fix(session): also consider `shareToken` parameter as used by Attachm…
max-nextcloud Aug 19, 2026
d6fa305
fix(middleware): get fileId from context rather than documentId
max-nextcloud Aug 19, 2026
946871f
fix(share): get file from context for attachments
max-nextcloud Aug 19, 2026
75bb07b
fix(migration): migrate old document rows to new format
max-nextcloud Aug 19, 2026
cb84918
fix(db): migrations adding context type and id
max-nextcloud Aug 20, 2026
00402bd
fix(api): Unlock the file via the context
max-nextcloud Aug 20, 2026
fe14a80
fix(api): use context to load document
max-nextcloud Aug 20, 2026
41ea7a5
chore(perf): add documents index for context
max-nextcloud Aug 20, 2026
907236a
chore(refactor): migrate PublicFilesEditor to script setup and ts
max-nextcloud Aug 20, 2026
f295bd1
chore(refactor): ViewerComponent to script setup and ts
max-nextcloud Aug 20, 2026
f38ad61
chore(cleanup): unused PublicFilesEditor
max-nextcloud Aug 20, 2026
cb901d7
enh(OCA.text): accept context in CreateCollaborativeEditor
max-nextcloud Aug 20, 2026
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: 1 addition & 1 deletion appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
- **💾 Open format:** Files are saved as [Markdown](https://en.wikipedia.org/wiki/Markdown), so you can edit them from any other text app too.
- **✊ Strong foundation:** We use [🐈 tiptap](https://tiptap.scrumpy.io) which is based on [🦉 ProseMirror](https://prosemirror.net) – huge thanks to them!
]]></description>
<version>9.0.0-dev.0</version>
<version>9.0.0-dev.1</version>
<licence>agpl</licence>
<author mail="jus@bitgrid.net">Julius Härtl</author>
<namespace>Text</namespace>
Expand Down
2 changes: 1 addition & 1 deletion appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
['name' => 'Attachment#getMediaFilePreview', 'url' => '/mediaPreview', 'verb' => 'GET'],

/** @see Controller\SessionController::create() */
['name' => 'Session#create', 'url' => '/session/{documentId}/create', 'verb' => 'PUT'],
['name' => 'Session#create', 'url' => '/session/{type}/{id}/create', 'verb' => 'PUT'],
/** @see Controller\SessionController::save() */
['name' => 'Session#save', 'url' => '/session/{documentId}/save', 'verb' => 'POST'],
/** @see Controller\SessionController::sync() */
Expand Down
9 changes: 9 additions & 0 deletions composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
'OCA\\Text\\AppInfo\\Application' => $baseDir . '/../lib/AppInfo/Application.php',
'OCA\\Text\\Command\\ResetDocument' => $baseDir . '/../lib/Command/ResetDocument.php',
'OCA\\Text\\ConfigLexicon' => $baseDir . '/../lib/ConfigLexicon.php',
'OCA\\Text\\Context\\ContextManager' => $baseDir . '/../lib/Context/ContextManager.php',
'OCA\\Text\\Context\\FileContext' => $baseDir . '/../lib/Context/FileContext.php',
'OCA\\Text\\Context\\FileContextFactory' => $baseDir . '/../lib/Context/FileContextFactory.php',
'OCA\\Text\\Context\\IContext' => $baseDir . '/../lib/Context/IContext.php',
'OCA\\Text\\Controller\\AiController' => $baseDir . '/../lib/Controller/AiController.php',
'OCA\\Text\\Controller\\AttachmentController' => $baseDir . '/../lib/Controller/AttachmentController.php',
'OCA\\Text\\Controller\\ISessionAwareController' => $baseDir . '/../lib/Controller/ISessionAwareController.php',
Expand All @@ -32,6 +36,7 @@
'OCA\\Text\\DirectEditing\\TextDocumentCreator' => $baseDir . '/../lib/DirectEditing/TextDocumentCreator.php',
'OCA\\Text\\Event\\LoadEditor' => $baseDir . '/../lib/Event/LoadEditor.php',
'OCA\\Text\\Event\\MentionEvent' => $baseDir . '/../lib/Event/MentionEvent.php',
'OCA\\Text\\Event\\RegisterContextEvent' => $baseDir . '/../lib/Event/RegisterContextEvent.php',
'OCA\\Text\\Exception\\AccountDisabledException' => $baseDir . '/../lib/Exception/AccountDisabledException.php',
'OCA\\Text\\Exception\\DocumentHasUnsavedChangesException' => $baseDir . '/../lib/Exception/DocumentHasUnsavedChangesException.php',
'OCA\\Text\\Exception\\DocumentSaveConflictException' => $baseDir . '/../lib/Exception/DocumentSaveConflictException.php',
Expand All @@ -49,6 +54,7 @@
'OCA\\Text\\Listeners\\LoadViewerListener' => $baseDir . '/../lib/Listeners/LoadViewerListener.php',
'OCA\\Text\\Listeners\\NodeCopiedListener' => $baseDir . '/../lib/Listeners/NodeCopiedListener.php',
'OCA\\Text\\Listeners\\NodeWrittenResetDocumentListener' => $baseDir . '/../lib/Listeners/NodeWrittenResetDocumentListener.php',
'OCA\\Text\\Listeners\\RegisterContextEventListener' => $baseDir . '/../lib/Listeners/RegisterContextEventListener.php',
'OCA\\Text\\Listeners\\RegisterDirectEditorEventListener' => $baseDir . '/../lib/Listeners/RegisterDirectEditorEventListener.php',
'OCA\\Text\\Listeners\\RegisterTemplateCreatorListener' => $baseDir . '/../lib/Listeners/RegisterTemplateCreatorListener.php',
'OCA\\Text\\Listeners\\VersionRestoredListener' => $baseDir . '/../lib/Listeners/VersionRestoredListener.php',
Expand All @@ -67,6 +73,9 @@
'OCA\\Text\\Migration\\Version040100Date20240611165300' => $baseDir . '/../lib/Migration/Version040100Date20240611165300.php',
'OCA\\Text\\Migration\\Version070000Date20250925110024' => $baseDir . '/../lib/Migration/Version070000Date20250925110024.php',
'OCA\\Text\\Migration\\Version080000Date20260331132113' => $baseDir . '/../lib/Migration/Version080000Date20260331132113.php',
'OCA\\Text\\Migration\\Version090000Date20260817110024' => $baseDir . '/../lib/Migration/Version090000Date20260817110024.php',
'OCA\\Text\\Migration\\Version090000Date20260819110024' => $baseDir . '/../lib/Migration/Version090000Date20260819110024.php',
'OCA\\Text\\Migration\\Version090000Date20260820132113' => $baseDir . '/../lib/Migration/Version090000Date20260820132113.php',
'OCA\\Text\\Notification\\Notifier' => $baseDir . '/../lib/Notification/Notifier.php',
'OCA\\Text\\Service\\AiTagService' => $baseDir . '/../lib/Service/AiTagService.php',
'OCA\\Text\\Service\\ApiService' => $baseDir . '/../lib/Service/ApiService.php',
Expand Down
9 changes: 9 additions & 0 deletions composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ class ComposerStaticInitText
'OCA\\Text\\AppInfo\\Application' => __DIR__ . '/..' . '/../lib/AppInfo/Application.php',
'OCA\\Text\\Command\\ResetDocument' => __DIR__ . '/..' . '/../lib/Command/ResetDocument.php',
'OCA\\Text\\ConfigLexicon' => __DIR__ . '/..' . '/../lib/ConfigLexicon.php',
'OCA\\Text\\Context\\ContextManager' => __DIR__ . '/..' . '/../lib/Context/ContextManager.php',
'OCA\\Text\\Context\\FileContext' => __DIR__ . '/..' . '/../lib/Context/FileContext.php',
'OCA\\Text\\Context\\FileContextFactory' => __DIR__ . '/..' . '/../lib/Context/FileContextFactory.php',
'OCA\\Text\\Context\\IContext' => __DIR__ . '/..' . '/../lib/Context/IContext.php',
'OCA\\Text\\Controller\\AiController' => __DIR__ . '/..' . '/../lib/Controller/AiController.php',
'OCA\\Text\\Controller\\AttachmentController' => __DIR__ . '/..' . '/../lib/Controller/AttachmentController.php',
'OCA\\Text\\Controller\\ISessionAwareController' => __DIR__ . '/..' . '/../lib/Controller/ISessionAwareController.php',
Expand All @@ -47,6 +51,7 @@ class ComposerStaticInitText
'OCA\\Text\\DirectEditing\\TextDocumentCreator' => __DIR__ . '/..' . '/../lib/DirectEditing/TextDocumentCreator.php',
'OCA\\Text\\Event\\LoadEditor' => __DIR__ . '/..' . '/../lib/Event/LoadEditor.php',
'OCA\\Text\\Event\\MentionEvent' => __DIR__ . '/..' . '/../lib/Event/MentionEvent.php',
'OCA\\Text\\Event\\RegisterContextEvent' => __DIR__ . '/..' . '/../lib/Event/RegisterContextEvent.php',
'OCA\\Text\\Exception\\AccountDisabledException' => __DIR__ . '/..' . '/../lib/Exception/AccountDisabledException.php',
'OCA\\Text\\Exception\\DocumentHasUnsavedChangesException' => __DIR__ . '/..' . '/../lib/Exception/DocumentHasUnsavedChangesException.php',
'OCA\\Text\\Exception\\DocumentSaveConflictException' => __DIR__ . '/..' . '/../lib/Exception/DocumentSaveConflictException.php',
Expand All @@ -64,6 +69,7 @@ class ComposerStaticInitText
'OCA\\Text\\Listeners\\LoadViewerListener' => __DIR__ . '/..' . '/../lib/Listeners/LoadViewerListener.php',
'OCA\\Text\\Listeners\\NodeCopiedListener' => __DIR__ . '/..' . '/../lib/Listeners/NodeCopiedListener.php',
'OCA\\Text\\Listeners\\NodeWrittenResetDocumentListener' => __DIR__ . '/..' . '/../lib/Listeners/NodeWrittenResetDocumentListener.php',
'OCA\\Text\\Listeners\\RegisterContextEventListener' => __DIR__ . '/..' . '/../lib/Listeners/RegisterContextEventListener.php',
'OCA\\Text\\Listeners\\RegisterDirectEditorEventListener' => __DIR__ . '/..' . '/../lib/Listeners/RegisterDirectEditorEventListener.php',
'OCA\\Text\\Listeners\\RegisterTemplateCreatorListener' => __DIR__ . '/..' . '/../lib/Listeners/RegisterTemplateCreatorListener.php',
'OCA\\Text\\Listeners\\VersionRestoredListener' => __DIR__ . '/..' . '/../lib/Listeners/VersionRestoredListener.php',
Expand All @@ -82,6 +88,9 @@ class ComposerStaticInitText
'OCA\\Text\\Migration\\Version040100Date20240611165300' => __DIR__ . '/..' . '/../lib/Migration/Version040100Date20240611165300.php',
'OCA\\Text\\Migration\\Version070000Date20250925110024' => __DIR__ . '/..' . '/../lib/Migration/Version070000Date20250925110024.php',
'OCA\\Text\\Migration\\Version080000Date20260331132113' => __DIR__ . '/..' . '/../lib/Migration/Version080000Date20260331132113.php',
'OCA\\Text\\Migration\\Version090000Date20260817110024' => __DIR__ . '/..' . '/../lib/Migration/Version090000Date20260817110024.php',
'OCA\\Text\\Migration\\Version090000Date20260819110024' => __DIR__ . '/..' . '/../lib/Migration/Version090000Date20260819110024.php',
'OCA\\Text\\Migration\\Version090000Date20260820132113' => __DIR__ . '/..' . '/../lib/Migration/Version090000Date20260820132113.php',
'OCA\\Text\\Notification\\Notifier' => __DIR__ . '/..' . '/../lib/Notification/Notifier.php',
'OCA\\Text\\Service\\AiTagService' => __DIR__ . '/..' . '/../lib/Service/AiTagService.php',
'OCA\\Text\\Service\\ApiService' => __DIR__ . '/..' . '/../lib/Service/ApiService.php',
Expand Down
26 changes: 13 additions & 13 deletions cypress/e2e/api/SessionApi.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,14 @@ describe('The session Api', function() {
})

it('returns connection', function() {
cy.openConnection({ fileId }).then(({ connection }) => {
cy.wrap(connection).its('documentId').should('equal', fileId)
cy.openFileConnection({ fileId }).then(({ connection }) => {
cy.wrap(connection).its('documentId').should('be.greaterThan', 0)
cy.closeConnection(connection)
})
})

it('provides initial content', function() {
cy.openConnection({ fileId, filePath }).then(({ connection, data }) => {
cy.openFileConnection({ fileId, filePath }).then(({ connection, data }) => {
cy.wrap(data).its('content').should('eql', '## Hello world\n')
cy.closeConnection(connection)
})
Expand All @@ -63,7 +63,7 @@ describe('The session Api', function() {

beforeEach(function() {
cy.uploadTestFile()
.then((fileId) => cy.openConnection({ fileId }))
.then((fileId) => cy.openFileConnection({ fileId }))
.then(({ connection: con }) => {
connection = con
})
Expand Down Expand Up @@ -116,7 +116,7 @@ describe('The session Api', function() {
cy.uploadTestFile()
.then((id) => {
fileId = id
return cy.openConnection({ fileId, filePath })
return cy.openFileConnection({ fileId, filePath })
})
.then(({ connection: con }) => {
connection = con
Expand Down Expand Up @@ -151,7 +151,7 @@ describe('The session Api', function() {
documentState,
manualSave: true,
})
cy.openConnection({ fileId, filePath })
cy.openFileConnection({ fileId, filePath })
.as('joining')
.its('data.documentState')
.should('eql', documentState)
Expand Down Expand Up @@ -183,7 +183,7 @@ describe('The session Api', function() {
.then(() => cy.clearCookies())
.then(() => {
return cy
.openConnection({ filePath: '', token: shareToken })
.openShareConnection({ filePath: '', token: shareToken })
.then(({ connection: con }) => {
connection = con
})
Expand Down Expand Up @@ -223,7 +223,7 @@ describe('The session Api', function() {
documentState,
manualSave: true,
})
cy.openConnection({ filePath: '', token: shareToken })
cy.openShareConnection({ filePath: '', token: shareToken })
.as('joining')
.its('data.documentState')
.should('eql', documentState)
Expand All @@ -247,15 +247,15 @@ describe('The session Api', function() {
cy.log(token)
shareToken = token
cy.clearCookies()
cy.openConnection({ filePath: '', token: shareToken }).then(({ connection: con }) => {
cy.openShareConnection({ filePath: '', token: shareToken }).then(({ connection: con }) => {
connection = con
})
})
})

it('does not send initial content if other session is alive but did not push any steps', function() {
let joining
cy.openConnection({ filePath: '', token: shareToken })
cy.openShareConnection({ filePath: '', token: shareToken })
.then(({ connection: con, data }) => {
joining = con
return data
Expand All @@ -271,7 +271,7 @@ describe('The session Api', function() {
cy.pushSteps({ connection, steps: [messages.update], version })
.its('version')
.should('eql', 0)
cy.openConnection({ filePath: '', token: shareToken })
cy.openShareConnection({ filePath: '', token: shareToken })
.then(({ connection: con, data }) => {
joining = con
return data
Expand Down Expand Up @@ -312,7 +312,7 @@ describe('The session Api', function() {
.its('version')
.should('eql', 0)
cy.log('Other user creates session')
cy.openConnection({ filePath: '', token: shareToken }).then(({ connection: con }) => {
cy.openShareConnection({ filePath: '', token: shareToken }).then(({ connection: con }) => {
joining = con
})
cy.log('Initial user closes session')
Expand All @@ -330,7 +330,7 @@ describe('The session Api', function() {
// Skipped for now since the behaviour chanced by not cleaning up the state on close/create
it.skip('ignores steps stored after close cleaned up', function() {
cy.pushAndClose({ connection, steps: [messages.update], version })
cy.openConnection({ filePath: '', token: shareToken })
cy.openShareConnection({ filePath: '', token: shareToken })
.then(({ connection: con, data }) => {
connection = con
return data
Expand Down
2 changes: 1 addition & 1 deletion cypress/e2e/api/UsersApi.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ describe('The user mention API', function() {
cy.login(user)
cy.uploadTestFile('test.md')
.as('fileId')
.then((fileId) => cy.openConnection({ fileId }))
.then((fileId) => cy.openFileConnection({ fileId }))
.its('connection')
.as('connection')
})
Expand Down
4 changes: 2 additions & 2 deletions cypress/e2e/sync.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ describe('Sync', () => {
'contain',
'The document could not be loaded.',
)
cy.intercept('**/apps/text/session/*/create').as('create')
cy.intercept('**/apps/text/session/*/*/create').as('create')
cy.get('#editor-container .document-status').find('button').click()
// let first attempt fail
cy.wait('@create', { timeout: 10000 })
Expand Down Expand Up @@ -180,7 +180,7 @@ describe('Sync', () => {

it('passes the doc content from one session to the next', () => {
cy.closeFile()
cy.intercept({ method: 'PUT', url: '**/apps/text/session/*/create' }).as('create')
cy.intercept({ method: 'PUT', url: '**/apps/text/session/*/*/create' }).as('create')
cy.openTestFile()
cy.wait('@create', { timeout: 10000 })
.its('response.body')
Expand Down
13 changes: 10 additions & 3 deletions cypress/support/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,27 @@
*/

import axios from '@nextcloud/axios'
import { close, open } from '../../src/apis/connect.ts'
import { close, openContext, openShare } from '../../src/apis/connect.ts'
import { save } from '../../src/apis/save.ts'
import { push, sync } from '../../src/apis/sync.ts'

const url = Cypress.config('baseUrl').replace(/\/index.php\/?$/g, '')

Cypress.Commands.add('openConnection', open)
Cypress.Commands.add(
'openFileConnection',
({ fileId, filePath }) => {
return openContext({ type: 'file', id: fileId, filePath })
},
)

Cypress.Commands.add('openShareConnection', openShare)

Cypress.Commands.add('closeConnection', close)

Cypress.Commands.add(
'failToCreateTextSession',
(fileId, baseVersionEtag = null, options = {}) => {
return open({ fileId, ...options, baseVersionEtag }).then(
return openContext({ type: 'file', id: fileId, ...options, baseVersionEtag }).then(
() => {
throw new Error('Expected request to fail - but it succeeded!')
},
Expand Down
4 changes: 4 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use OCA\Files_Sharing\Event\BeforeTemplateRenderedEvent;
use OCA\Files_Versions\Events\VersionRestoredEvent;
use OCA\Text\Event\LoadEditor;
use OCA\Text\Event\RegisterContextEvent;
use OCA\Text\Listeners\AddMissingIndicesListener;
use OCA\Text\Listeners\BeforeAssistantNotificationListener;
use OCA\Text\Listeners\BeforeNodeDeletedListener;
Expand All @@ -22,6 +23,7 @@
use OCA\Text\Listeners\LoadViewerListener;
use OCA\Text\Listeners\NodeCopiedListener;
use OCA\Text\Listeners\NodeWrittenResetDocumentListener;
use OCA\Text\Listeners\RegisterContextEventListener;
use OCA\Text\Listeners\RegisterDirectEditorEventListener;
use OCA\Text\Listeners\RegisterTemplateCreatorListener;
use OCA\Text\Listeners\VersionRestoredListener;
Expand Down Expand Up @@ -69,6 +71,8 @@ public function register(IRegistrationContext $context): void {

$context->registerEventListener(VersionRestoredEvent::class, VersionRestoredListener::class);

$context->registerEventListener(RegisterContextEvent::class, RegisterContextEventListener::class);

$context->registerNotifierService(Notifier::class);
$context->registerMiddleware(SessionMiddleware::class);
}
Expand Down
56 changes: 56 additions & 0 deletions lib/Context/ContextManager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

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

namespace OCA\Text\Context;

use OCA\Text\Event\RegisterContextEvent;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\NotFoundException;
use Psr\Log\LoggerInterface;

class ContextManager {
/** @var array<string, callable> */
private array $contexts = [];
public function __construct(
private readonly IEventDispatcher $eventDispatcher,
private readonly LoggerInterface $logger,
) {
}

private function getContexts(): array {
$contexts = $this->contexts;
if (!empty($contexts)) {
return $contexts;
}
$this->eventDispatcher->dispatchTyped(new RegisterContextEvent($this));
if (empty($this->contexts)) {
$this->logger->warning('Failed to register contexts.');
}
return $this->contexts;
}

public function registerContext(string $type, callable $createContext): void {
$this->logger->debug('Registering context for type "' . $type . '".');
if (array_key_exists($type, $this->contexts)) {
$this->logger->warning('Context of type "' . $type . '" was already registered!');
return;
}
$this->contexts[$type] = $createContext;
}

public function getContext(string $type, int $id, ?string $shareToken): IContext {
$createContext = $this->getContexts()[$type];
if (!is_callable($createContext)) {
throw new NotFoundException('Context of type "' . $type . '" was not registered!');
}
$context = $createContext($id, $type, $shareToken);
if (!$context instanceof IContext) {
throw new NotFoundException('Failed to create context of type ' . $type . '!');
}
return $context;
}
}
Loading
Loading