diff --git a/appinfo/info.xml b/appinfo/info.xml
index 6dc7cdd8b44..c8335c42950 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -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!
]]>
- 9.0.0-dev.0
+ 9.0.0-dev.1
agpl
Julius Hรคrtl
Text
diff --git a/appinfo/routes.php b/appinfo/routes.php
index 0eb6c066aad..7495440142d 100644
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -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() */
diff --git a/composer/composer/autoload_classmap.php b/composer/composer/autoload_classmap.php
index 29e480fbcd4..71ef7775e67 100644
--- a/composer/composer/autoload_classmap.php
+++ b/composer/composer/autoload_classmap.php
@@ -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',
@@ -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',
@@ -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',
@@ -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',
diff --git a/composer/composer/autoload_static.php b/composer/composer/autoload_static.php
index a06a64546ec..1173f0425da 100644
--- a/composer/composer/autoload_static.php
+++ b/composer/composer/autoload_static.php
@@ -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',
@@ -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',
@@ -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',
@@ -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',
diff --git a/cypress/e2e/api/SessionApi.spec.js b/cypress/e2e/api/SessionApi.spec.js
index 50d932b3249..51291027a31 100644
--- a/cypress/e2e/api/SessionApi.spec.js
+++ b/cypress/e2e/api/SessionApi.spec.js
@@ -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)
})
@@ -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
})
@@ -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
@@ -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)
@@ -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
})
@@ -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)
@@ -247,7 +247,7 @@ 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
})
})
@@ -255,7 +255,7 @@ describe('The session Api', function() {
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
@@ -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
@@ -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')
@@ -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
diff --git a/cypress/e2e/api/UsersApi.spec.js b/cypress/e2e/api/UsersApi.spec.js
index 90319eea5e5..3f21d11200e 100644
--- a/cypress/e2e/api/UsersApi.spec.js
+++ b/cypress/e2e/api/UsersApi.spec.js
@@ -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')
})
diff --git a/cypress/e2e/sync.spec.js b/cypress/e2e/sync.spec.js
index b76414a6fe6..f850a785a16 100644
--- a/cypress/e2e/sync.spec.js
+++ b/cypress/e2e/sync.spec.js
@@ -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 })
@@ -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')
diff --git a/cypress/support/sessions.js b/cypress/support/sessions.js
index 4795df43825..bb3d8e25cc1 100644
--- a/cypress/support/sessions.js
+++ b/cypress/support/sessions.js
@@ -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!')
},
diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php
index cd1c8f2c703..8447130b01b 100644
--- a/lib/AppInfo/Application.php
+++ b/lib/AppInfo/Application.php
@@ -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;
@@ -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;
@@ -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);
}
diff --git a/lib/Context/ContextManager.php b/lib/Context/ContextManager.php
new file mode 100644
index 00000000000..893556fd145
--- /dev/null
+++ b/lib/Context/ContextManager.php
@@ -0,0 +1,56 @@
+ */
+ 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;
+ }
+}
diff --git a/lib/Context/FileContext.php b/lib/Context/FileContext.php
new file mode 100644
index 00000000000..aab0dea3a11
--- /dev/null
+++ b/lib/Context/FileContext.php
@@ -0,0 +1,191 @@
+file->getId();
+ }
+
+ #[Override]
+ public function getType(): string {
+ return 'file';
+ }
+
+ #[Override]
+ public function toString(): string {
+ return $this->getType() . ' (' . $this->getId() . ')';
+ }
+
+ #[Override]
+ public function getFile(): ?File {
+ return $this->file;
+ }
+
+ #[Override]
+ public function buildDocument(): Document|string {
+ // Block using text for disabled download internal shares
+ if ($this->fileService->isDownloadDisabled($this->file)) {
+ return $this->l10n->t('This file cannot be displayed as download is disabled by the share');
+ }
+ $document = new Document();
+ $document->setContextType('file');
+ $document->setContextId($this->getId());
+ $document->setLastSavedVersion(0);
+ $document->setLastSavedVersionTime($this->file->getMTime());
+ $document->setLastSavedVersionEtag($this->file->getEtag());
+ $document->setChecksum($this->computeChecksum());
+ // This is a new document - so it needs a fresh base version etag.
+ $document->setBaseVersionEtag(uniqid());
+ return $document;
+ }
+
+ #[Override]
+ public function prepareSession(DocumentData $documentData): SessionInfo {
+ $document = $documentData->document;
+ $documentState = $documentData->documentState;
+
+ $content = null;
+ if ($documentState === null) {
+ $this->logger->debug('Sending content for ' . $document->toString());
+ $content = $this->loadContent();
+ }
+
+ $readOnly = $this->isReadOnly();
+ $lockInfo = $this->getLockInfo();
+ if (!$readOnly) {
+ $isLocked = $this->lock();
+ if (!$isLocked) {
+ $readOnly = true;
+ }
+ }
+
+ return new SessionInfo(
+ content: $content,
+ readOnly: $readOnly,
+ lock: $lockInfo,
+ hasOwner: $this->getOwner() !== null,
+ );
+ }
+
+ public function isReadOnly(): bool {
+ return $this->fileService->isReadOnly($this->file, $this->token);
+ }
+
+ /**
+ * Update the document last saved version metadata to be in line with the data saved in the context.
+ *
+ * @throws DocumentSaveConflictException
+ * @throws GenericFileException if the file changed and reading the content fails.
+ * @throws LockedException if the file changed and a lock prevents reading the content.
+ * @throws NotPermittedException if the file changed and reading is not allowed.
+ * @return Document|null Updated document if there was an update
+ */
+ public function updateDocument(Document $document): ?Document {
+ $lastMTime = $document->getLastSavedVersionTime();
+ $lastEtag = $document->getLastSavedVersionEtag();
+
+ if ($lastMTime <= 0 || $this->isReadOnly()) {
+ return null;
+ }
+
+ $fileMtime = $this->file->getMtime();
+ $fileEtag = $this->file->getEtag();
+
+ if ($lastEtag === $fileEtag && $lastMTime === $fileMtime) {
+ return null;
+ }
+
+ $storedChecksum = $document->getChecksum();
+ $fileContent = $this->file->getContent();
+ $fileChecksum = self::computeCheckSum($fileContent);
+
+ if ($storedChecksum !== $fileChecksum) {
+ throw new DocumentSaveConflictException($fileContent);
+ }
+
+ $document->setLastSavedVersionTime($fileMtime);
+ $document->setLastSavedVersionEtag($fileEtag);
+ return $document;
+ }
+
+ public function loadContent(): ?string {
+ return $this->fileService->loadContent($this->file);
+ }
+
+ public function saveWithLock(string $content, callable $doWhileLocked): void {
+ $this->lockService->runInScope($this->file, function () use ($content, $doWhileLocked): void {
+ $this->file->putContent($content);
+ $doWhileLocked();
+ });
+ }
+
+ #[Override]
+ public function cleanup(): void {
+ $this->unlock();
+ }
+
+ private function computeCheckSum(?string $content = null): string {
+ if ($content === null) {
+ $content = $this->file->getContent();
+ }
+ return hash('crc32', $content);
+ }
+
+ private function getLockInfo(): ?ILock {
+ return $this->lockService->getLockByOthers($this->file);
+ }
+
+ private function getOwner(): ?IUser {
+ return $this->file->getOwner();
+ }
+
+ private function lock(): bool {
+ // Disable file locking for Readme.md files, because in the
+ // current setup, this makes it almost impossible to delete these files.
+ if (strcasecmp($this->file->getName(), 'Readme.md') !== 0) {
+ return $this->lockService->lock($this->file);
+ }
+ return true;
+ }
+
+ private function unlock(): void {
+ // Disable file locking for Readme.md files, because in the
+ // current setup, this makes it almost impossible to delete these files.
+ if (strcasecmp($this->file->getName(), 'Readme.md') !== 0) {
+ $this->lockService->unlock($this->file);
+ }
+ }
+
+}
diff --git a/lib/Context/FileContextFactory.php b/lib/Context/FileContextFactory.php
new file mode 100644
index 00000000000..b1b42ca19a6
--- /dev/null
+++ b/lib/Context/FileContextFactory.php
@@ -0,0 +1,101 @@
+fileService,
+ $this->l10n,
+ $this->lockService,
+ $this->logger,
+ $file,
+ $token,
+ );
+ }
+
+ /**
+ * @throws NotPermittedException if not logged in
+ * @throws NotFoundException if the file cannot be found
+ */
+ public function buildForId(
+ int $id,
+ ): FileContext {
+ $userId = $this->userSession->getUser()?->getUID();
+ if ($userId === null) {
+ throw new NotPermittedException();
+ }
+ $file = $this->fileService->getFileById($id, $userId);
+ return $this->build($file);
+ }
+
+ /**
+ * @throws NotFoundException if the file cannot be found
+ */
+ public function buildForShareWithId(
+ string $token,
+ int $id,
+ ): FileContext {
+ $file = $this->fileService->getFileByIdFromShare($id, $token);
+ /*
+ * Check if we have proper read access (files drop)
+ * If not then well 404 it is.
+ */
+ $this->fileService->checkSharePermissions($token);
+ return $this->build($file, $token);
+ }
+
+ /**
+ * @throws NotFoundException if the file cannot be found
+ * @throws \InvalidArgumentException if the share token is for a folder and path is missing
+ */
+ public function buildForShareWithPath(
+ string $token,
+ ?string $filePath,
+ ): FileContext {
+ $file = $this->fileService->getFileByShareToken($token, $filePath);
+ /*
+ * Check if we have proper read access (files drop)
+ * If not then well 404 it is.
+ */
+ $this->fileService->checkSharePermissions($token);
+ return $this->build($file, $token);
+ }
+
+ /**
+ * @throws NotFoundException if the file cannot be found
+ */
+ public function buildForDirectEditing(IToken $token): FileContext {
+ $file = $token->getFile();
+ return $this->build($file, null);
+ }
+
+}
diff --git a/lib/Context/IContext.php b/lib/Context/IContext.php
new file mode 100644
index 00000000000..cc4f9c83d58
--- /dev/null
+++ b/lib/Context/IContext.php
@@ -0,0 +1,87 @@
+ $this->document,
+ 'documentState' => $this->documentState,
+ ];
+ }
+}
+
+readonly class SessionInfo {
+ public function __construct(
+ public ?string $content,
+ public bool $readOnly,
+ public ?ILock $lock,
+ public bool $hasOwner,
+ ) {
+ }
+
+ public function jsonSerialize(): array {
+ return [
+ 'content' => $this->content,
+ 'readOnly' => $this->readOnly,
+ 'lock' => $this->lock,
+ 'hasOwner' => $this->hasOwner,
+ ];
+ }
+}
+
+readonly class NewSessionData {
+ public function __construct(
+ public DocumentData $documentData,
+ public SessionInfo $sessionInfo,
+ public Session $session,
+ public ?string $displayName,
+ ) {
+ }
+
+ public function jsonSerialize(): array {
+ return array_merge(
+ $this->documentData->jsonSerialize(),
+ $this->sessionInfo->jsonSerialize(),
+ [
+ 'session' => array_merge(
+ $this->session->jsonSerialize(),
+ ['displayName' => $this->displayName],
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/Controller/PublicSessionController.php b/lib/Controller/PublicSessionController.php
index b12fa1e5f42..d3d44f87f79 100644
--- a/lib/Controller/PublicSessionController.php
+++ b/lib/Controller/PublicSessionController.php
@@ -8,6 +8,7 @@
namespace OCA\Text\Controller;
+use OCA\Text\Context\FileContextFactory;
use OCA\Text\Middleware\Attribute\RequireDocumentBaseVersionEtag;
use OCA\Text\Middleware\Attribute\RequireDocumentSession;
use OCA\Text\Service\ApiService;
@@ -34,6 +35,7 @@ public function __construct(
string $appName,
IRequest $request,
ISession $session,
+ private FileContextFactory $fileContextFactory,
private ShareManager $shareManager,
private ApiService $apiService,
private FileService $fileService,
@@ -71,25 +73,19 @@ protected function isPasswordProtected(): bool {
#[NoAdminRequired]
#[PublicPage]
public function create(string $token, ?string $filePath = null, ?string $baseVersionEtag = null, ?string $guestName = null): DataResponse {
- $file = $this->fileService->getFileByShareToken($token, $filePath);
- /*
- * Check if we have proper read access (files drop)
- * If not then well 404 it is.
- */
try {
- $this->fileService->checkSharePermissions($token);
+ $context = $this->fileContextFactory->buildForShareWithPath($token, $filePath);
+ return $this->apiService->create($context, $baseVersionEtag, $guestName);
} catch (NotFoundException|\InvalidArgumentException) {
return new DataResponse([], Http::STATUS_NOT_FOUND);
}
- return $this->apiService->create($file, $baseVersionEtag, $token, $guestName);
}
#[NoAdminRequired]
#[PublicPage]
public function close(int $documentId, int $sessionId, string $sessionToken, string $token): DataResponse {
- $file = $this->fileService->getFileByIdFromShare($documentId, $token);
- return $this->apiService->close($documentId, $sessionId, $sessionToken, $file);
+ return $this->apiService->close($documentId, $sessionId, $sessionToken, $token);
}
#[NoAdminRequired]
diff --git a/lib/Controller/SessionController.php b/lib/Controller/SessionController.php
index e58c20f6b0e..c875c7055af 100644
--- a/lib/Controller/SessionController.php
+++ b/lib/Controller/SessionController.php
@@ -8,6 +8,7 @@
namespace OCA\Text\Controller;
+use OCA\Text\Context\ContextManager;
use OCA\Text\Exception\InvalidSessionException;
use OCA\Text\Middleware\Attribute\RequireDocumentBaseVersionEtag;
use OCA\Text\Middleware\Attribute\RequireDocumentSession;
@@ -40,6 +41,7 @@ public function __construct(
string $appName,
IRequest $request,
private ApiService $apiService,
+ private ContextManager $contextManager,
private FileService $fileService,
private SessionService $sessionService,
private NotificationService $notificationService,
@@ -52,22 +54,17 @@ public function __construct(
}
#[NoAdminRequired]
- public function create(?int $fileId = null, ?string $baseVersionEtag = null): DataResponse {
- $userId = $this->userSession->getUser()?->getUID();
- if ($fileId === null || $userId === null) {
- return new DataResponse(['error' => 'No valid file argument provided'], Http::STATUS_PRECONDITION_FAILED);
- }
-
+ public function create(string $type, int $id, ?string $baseVersionEtag = null): DataResponse {
try {
- $file = $this->fileService->getFileById($fileId, $userId);
+ $context = $this->contextManager->getContext($type, $id, null);
} catch (NotFoundException|NotPermittedException $e) {
- $this->logger->error('No permission to access this file', [ 'exception' => $e ]);
+ $this->logger->error('No context for ' . $type . ' (' . $id . ') ', [ 'exception' => $e ]);
return new DataResponse([
'error' => $this->l10n->t('File not found')
], Http::STATUS_NOT_FOUND);
}
- return $this->apiService->create($file, $baseVersionEtag);
+ return $this->apiService->create($context, $baseVersionEtag);
}
#[NoAdminRequired]
@@ -77,8 +74,7 @@ public function close(int $documentId, int $sessionId, string $sessionToken): Da
if ($userId === null) {
throw new InvalidSessionException();
}
- $file = $this->fileService->getFileById($documentId, $userId);
- return $this->apiService->close($documentId, $sessionId, $sessionToken, $file);
+ return $this->apiService->close($documentId, $sessionId, $sessionToken, null);
}
#[NoAdminRequired]
diff --git a/lib/Db/Document.php b/lib/Db/Document.php
index 5fdcdffa212..58c0f227265 100644
--- a/lib/Db/Document.php
+++ b/lib/Db/Document.php
@@ -25,6 +25,10 @@
* @method setBaseVersionEtag(string $etag): void
* @method getChecksum(): ?string
* @method setChecksum(?string $checksum): void
+ * @method getContextType(): string
+ * @method setContextType(string $contextType): void
+ * @method getContextId(): int
+ * @method setContextId(int $contextId): void
*/
class Document extends Entity implements \JsonSerializable {
public $id = null;
@@ -36,6 +40,8 @@ class Document extends Entity implements \JsonSerializable {
protected string $lastSavedVersionEtag = '';
protected string $baseVersionEtag = '';
protected ?string $checksum = null;
+ protected string $contextType = '';
+ protected int $contextId = 0;
public function __construct() {
$this->addType('currentVersion', 'integer');
@@ -43,6 +49,8 @@ public function __construct() {
$this->addType('lastSavedVersionTime', 'integer');
$this->addType('initialVersion', 'integer');
$this->addType('checksum', 'string');
+ $this->addType('contextType', 'string');
+ $this->addType('contextId', 'integer');
}
public function jsonSerialize(): array {
@@ -52,7 +60,16 @@ public function jsonSerialize(): array {
'lastSavedVersionTime' => $this->lastSavedVersionTime,
'baseVersionEtag' => $this->baseVersionEtag,
'initialVersion' => $this->initialVersion,
- 'checksum' => $this->checksum
+ 'checksum' => $this->checksum,
+ 'contextType' => $this->contextType,
+ 'contextId' => $this->contextId,
];
}
+
+ /**
+ * Short identifier - mostly for logging
+ */
+ public function toString(): string {
+ return $this->contextType . ' (' . $this->contextId . ')';
+ }
}
diff --git a/lib/Db/DocumentMapper.php b/lib/Db/DocumentMapper.php
index aedf04ad5d0..f94c0564faf 100644
--- a/lib/Db/DocumentMapper.php
+++ b/lib/Db/DocumentMapper.php
@@ -25,6 +25,7 @@ public function __construct(IDBConnection $db) {
* @throws DoesNotExistException
*/
public function find(int $documentId): Document {
+
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$result = $qb->select('*')
@@ -40,6 +41,23 @@ public function find(int $documentId): Document {
return Document::fromRow($data);
}
+ public function load(string $type, int $id): ?Document {
+ /* @var $qb IQueryBuilder */
+ $qb = $this->db->getQueryBuilder();
+ $result = $qb->select('*')
+ ->from($this->getTableName())
+ ->where($qb->expr()->eq('context_type', $qb->createNamedParameter($type)))
+ ->andWhere($qb->expr()->eq('context_id', $qb->createNamedParameter($id)))
+ ->executeQuery();
+
+ $data = $result->fetchAssociative();
+ $result->closeCursor();
+ if ($data === false) {
+ return null;
+ }
+ return Document::fromRow($data);
+ }
+
public function findAll(): Generator {
$qb = $this->db->getQueryBuilder();
$result = $qb->select('*')
diff --git a/lib/DirectEditing/TextDirectEditor.php b/lib/DirectEditing/TextDirectEditor.php
index 9725755cc0b..0d086640ad8 100644
--- a/lib/DirectEditing/TextDirectEditor.php
+++ b/lib/DirectEditing/TextDirectEditor.php
@@ -8,6 +8,7 @@
namespace OCA\Text\DirectEditing;
use OCA\Text\AppInfo\Application;
+use OCA\Text\Context\FileContextFactory;
use OCA\Text\Service\ApiService;
use OCA\Text\Service\InitialStateProvider;
use OCP\AppFramework\Http\NotFoundResponse;
@@ -29,6 +30,7 @@ public function __construct(
private readonly InitialStateProvider $initialStateProvider,
private readonly ApiService $apiService,
private readonly IAppConfig $appConfig,
+ private readonly FileContextFactory $fileContextFactory,
) {
}
@@ -131,7 +133,8 @@ public function isSecure(): bool {
public function open(IToken $token): Response {
$token->useTokenScope();
try {
- $session = $this->apiService->create($token->getFile());
+ $context = $this->fileContextFactory->buildForDirectEditing($token);
+ $session = $this->apiService->create($context, null);
$this->initialStateProvider->provideFile([
'fileId' => $token->getFile()->getId(),
'mimetype' => $token->getFile()->getMimeType(),
diff --git a/lib/Event/RegisterContextEvent.php b/lib/Event/RegisterContextEvent.php
new file mode 100644
index 00000000000..8cdab36d5db
--- /dev/null
+++ b/lib/Event/RegisterContextEvent.php
@@ -0,0 +1,33 @@
+contextManager;
+ }
+}
diff --git a/lib/Exception/DocumentSaveConflictException.php b/lib/Exception/DocumentSaveConflictException.php
index 6f7a73139dd..a6556124c33 100644
--- a/lib/Exception/DocumentSaveConflictException.php
+++ b/lib/Exception/DocumentSaveConflictException.php
@@ -9,5 +9,20 @@
namespace OCA\Text\Exception;
+use Throwable;
+
class DocumentSaveConflictException extends \Exception {
+
+ public function __construct(
+ private readonly string $content,
+ int $code = 0,
+ ?Throwable $previous = null,
+ ) {
+ $message = 'File changed in the meantime from outside';
+ parent::__construct($message, $code, $previous);
+ }
+
+ public function getContent(): string {
+ return $this->content;
+ }
}
diff --git a/lib/Listeners/RegisterContextEventListener.php b/lib/Listeners/RegisterContextEventListener.php
new file mode 100644
index 00000000000..82e53532817
--- /dev/null
+++ b/lib/Listeners/RegisterContextEventListener.php
@@ -0,0 +1,42 @@
+ */
+class RegisterContextEventListener implements IEventListener {
+
+ public function __construct(
+ private readonly FileContextFactory $fileContextFactory,
+ ) {
+ }
+
+ #[Override]
+ public function handle(Event $event): void {
+ if (!$event instanceof RegisterContextEvent) {
+ return;
+ }
+
+ $event->getContextManager()->registerContext(
+ 'file',
+ function (int $id, string $type, ?string $shareToken) {
+ if ($shareToken === null) {
+ return $this->fileContextFactory->buildForId($id);
+ } else {
+ return $this->fileContextFactory->buildForShareWithId($shareToken, $id);
+ }
+ }
+ );
+ }
+}
diff --git a/lib/Middleware/SessionMiddleware.php b/lib/Middleware/SessionMiddleware.php
index aa6a0644504..c7a08847f17 100644
--- a/lib/Middleware/SessionMiddleware.php
+++ b/lib/Middleware/SessionMiddleware.php
@@ -94,7 +94,8 @@ private function assertDocumentSession(ISessionAwareController $controller): voi
$documentId = (int)$this->request->getParam('documentId');
$sessionId = (int)$this->request->getParam('sessionId');
$token = (string)$this->request->getParam('sessionToken');
- $shareToken = (string)$this->request->getParam('token');
+ $shareToken = (string)$this->request->getParam('token')
+ || (string)$this->request->getParam('shareToken');
$session = $this->sessionService->getValidSession($documentId, $sessionId, $token);
if (!$session) {
@@ -127,18 +128,24 @@ private function assertDocumentSession(ISessionAwareController $controller): voi
* @throws InvalidSessionException
*/
private function assertUserOrShareToken(ISessionAwareController $controller): void {
- $fileId = (int)$this->request->getParam('documentId');
+ $documentId = (int)$this->request->getParam('documentId');
$shareToken = (string)$this->request->getParam('shareToken');
$userId = $this->userSession->getUser()?->getUID();
+ $document = $this->documentService->getDocument($documentId);
+ if (!$document || $document->getContextType() !== 'file') {
+ throw new InvalidSessionException();
+ }
+ $fileId = $document->getContextId();
+
if ($shareToken !== '') {
- $documentId = $this->fileService->getDocumentIdFromShare($fileId, $shareToken);
+ $this->fileService->checkFileAccessFromShare($fileId, $shareToken);
$controller->setDocumentId($documentId);
return;
}
if ($userId !== null) {
- $documentId = $this->fileService->getDocumentIdForUser($fileId, $userId);
+ $this->fileService->checkFileAccessForUser($fileId, $userId);
$controller->setUserId($userId);
$controller->setDocumentId($documentId);
return;
diff --git a/lib/Migration/Version090000Date20260817110024.php b/lib/Migration/Version090000Date20260817110024.php
new file mode 100644
index 00000000000..627d673b141
--- /dev/null
+++ b/lib/Migration/Version090000Date20260817110024.php
@@ -0,0 +1,51 @@
+getTable('text_documents');
+
+ if (!$table->hasColumn('context_type')) {
+ $table->addColumn('context_type', Types::STRING, [
+ 'notnull' => false,
+ 'length' => 64,
+ ]);
+ }
+
+ if (!$table->hasColumn('context_id')) {
+ $table->addColumn('context_id', Types::BIGINT, [
+ 'notnull' => false,
+ 'unsigned' => true,
+ ]);
+ }
+
+ $column = $table->getColumn('id');
+ if (!$column->getAutoincrement()) {
+ $table->modifyColumn('id', [
+ 'autoincrement' => true,
+ ]);
+ }
+ return $schema;
+ }
+}
diff --git a/lib/Migration/Version090000Date20260819110024.php b/lib/Migration/Version090000Date20260819110024.php
new file mode 100644
index 00000000000..9c65cbcd5ee
--- /dev/null
+++ b/lib/Migration/Version090000Date20260819110024.php
@@ -0,0 +1,69 @@
+connection->getQueryBuilder();
+ $qb->update('text_documents', 'd')
+ ->set('context_type', $qb->createNamedParameter('file'))
+ ->where($qb->expr()->isNull('context_type'))
+ ->executeStatement();
+ $qb->update('text_documents', 'd')
+ ->set('context_id', 'd.id')
+ ->where($qb->expr()->isNull('context_id'))
+ ->executeStatement();
+ }
+
+ public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) {
+ /** @var ISchemaWrapper $schema */
+ $schema = $schemaClosure();
+
+ $table = $schema->getTable('text_documents');
+
+ $column = $table->getColumn('context_type');
+ if (!$column->getNotnull()) {
+ $table->modifyColumn('context_type', [
+ 'notnull' => true,
+ ]);
+ }
+
+ $column = $table->getColumn('context_id');
+ if (!$column->getNotnull()) {
+ $table->modifyColumn('context_id', [
+ 'notnull' => true,
+ ]);
+ }
+ return $schema;
+ }
+}
diff --git a/lib/Migration/Version090000Date20260820132113.php b/lib/Migration/Version090000Date20260820132113.php
new file mode 100644
index 00000000000..e19ba349ad4
--- /dev/null
+++ b/lib/Migration/Version090000Date20260820132113.php
@@ -0,0 +1,40 @@
+getTable('text_documents');
+ if (!$table->hasIndex('text_documents_context_index')) {
+ $table->addIndex(['context_type', 'context_id'], 'text_documents_context_index');
+ }
+
+ return $schema;
+ }
+}
diff --git a/lib/Service/ApiService.php b/lib/Service/ApiService.php
index 015bcccfd1e..32aaa71f51e 100644
--- a/lib/Service/ApiService.php
+++ b/lib/Service/ApiService.php
@@ -12,6 +12,9 @@
use Exception;
use InvalidArgumentException;
use OCA\NotifyPush\Queue\IQueue;
+use OCA\Text\Context\ContextManager;
+use OCA\Text\Context\IContext;
+use OCA\Text\Context\NewSessionData;
use OCA\Text\Db\Document;
use OCA\Text\Db\Session;
use OCA\Text\Exception\DocumentSaveConflictException;
@@ -23,16 +26,14 @@
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\IL10N;
-use OCP\Lock\LockedException;
use Psr\Log\LoggerInterface;
class ApiService {
public function __construct(
private readonly ConfigService $configService,
+ private readonly ContextManager $contextManager,
private readonly SessionService $sessionService,
private readonly DocumentService $documentService,
- private readonly FileService $fileService,
- private readonly EncodingService $encodingService,
private readonly LoggerInterface $logger,
private readonly LockService $lockService,
private readonly IL10N $l10n,
@@ -40,79 +41,53 @@ public function __construct(
) {
}
- public function create(File $file, ?string $baseVersionEtag = null, ?string $token = null, ?string $guestName = null): DataResponse {
- try {
- // Block using text for disabled download internal shares
- if ($this->fileService->isDownloadDisabled($file)) {
- return new DataResponse(['error' => $this->l10n->t('This file cannot be displayed as download is disabled by the share')], Http::STATUS_FORBIDDEN);
- }
-
- $readOnly = $this->fileService->isReadOnly($file, $token);
-
- $this->sessionService->removeInactiveSessionsWithoutSteps($file->getId());
- $document = $this->documentService->getOrCreateDocument($file);
- if ($baseVersionEtag !== null && $baseVersionEtag !== $document->getBaseVersionEtag()) {
- return new DataResponse(['error' => $this->l10n->t('Editing session has expired. Please reload the page.')], Http::STATUS_PRECONDITION_FAILED);
- }
+ public function create(IContext $context, ?string $baseVersionEtag, ?string $guestName = null): DataResponse {
+ $document = $context->buildDocument();
+ if (!$document instanceof Document) {
+ return new DataResponse(['error' => $document], Http::STATUS_FORBIDDEN);
+ }
+ try {
+ $document = $this->documentService->getOrCreateDocument($document);
} catch (Exception $e) {
$this->logger->error($e->getMessage(), ['exception' => $e]);
return new DataResponse(['error' => 'Failed to create the document session'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
+ $documentData = $this->documentService->getDocumentData($document);
- /** @var Document $document */
-
- $session = $this->sessionService->initSession($document->getId(), $guestName);
-
- $documentState = null;
- $content = null;
- if ($document->getLastSavedVersion() === 0) {
- $this->logger->debug('Sending content for unsaved file ' . $file->getId());
- $content = $this->loadContent($file);
- } else {
- $this->logger->debug('Loading saved document state for ' . $file->getId());
- try {
- $stateFile = $this->documentService->getStateFile($document->getId());
- $documentState = $stateFile->getContent();
- } catch (NotFoundException) {
- $this->logger->warning('State file not found for saved document' . $file->getId());
-
- // If we have no state file we need to load the content from the file
- // On the client side we use this to initialize a idempotent initial y.js document
- $content = $this->loadContent($file);
- }
+ if ($baseVersionEtag !== null && $baseVersionEtag !== $document->getBaseVersionEtag()) {
+ $error = $this->l10n->t('Editing session has expired. Please reload the page.');
+ return new DataResponse(['error' => $error], Http::STATUS_PRECONDITION_FAILED);
}
- $lockInfo = $this->lockService->getLockByOthers($file);
+ $sessionInfo = $context->prepareSession($documentData);
+ $session = $this->sessionService->initSession($document->id, $guestName);
+ $displayName = $this->sessionService->getNameForSession($session);
- $hasOwner = $file->getOwner() !== null;
+ $newSession = new NewSessionData(
+ documentData: $documentData,
+ sessionInfo: $sessionInfo,
+ session: $session,
+ displayName: $displayName,
+ );
- // Disable file locking for Readme.md files, because in the
- // current setup, this makes it almost impossible to delete these files.
- if (!$readOnly && strcasecmp($file->getName(), 'Readme.md') !== 0) {
- $isLocked = $this->lockService->lock($file);
- if (!$isLocked) {
- $readOnly = true;
- }
- }
-
- return new DataResponse([
- 'document' => $document,
- 'session' => array_merge($session->jsonSerialize(), ['displayName' => $this->sessionService->getNameForSession($session)]),
- 'readOnly' => $readOnly,
- 'content' => $content,
- 'documentState' => $documentState,
- 'lock' => $lockInfo,
- 'hasOwner' => $hasOwner,
- ]);
+ return new DataResponse(
+ $newSession->jsonSerialize()
+ );
}
- public function close(int $documentId, int $sessionId, string $sessionToken, File $file): DataResponse {
+ public function close(int $documentId, int $sessionId, string $sessionToken, ?string $shareToken): DataResponse {
$this->sessionService->closeSession($documentId, $sessionId, $sessionToken);
$this->sessionService->removeInactiveSessionsWithoutSteps($documentId);
$activeSessions = $this->sessionService->getActiveSessions($documentId);
if (count($activeSessions) === 0) {
- $this->lockService->unlock($file);
+ $document = $this->documentService->getDocument($documentId);
+ if ($document !== null) {
+ $type = $document->getContextType();
+ $id = $document->getContextId();
+ $context = $this->contextManager->getContext($type, $id, $shareToken);
+ $context->cleanup();
+ }
}
return new DataResponse([]);
}
@@ -146,7 +121,7 @@ private function addToPushQueue(Document $document, array $steps): void {
return;
}
- $sessions = $this->sessionService->getActiveSessions($document->getId());
+ $sessions = $this->sessionService->getActiveSessions($document->id);
$userIds = array_values(array_filter(array_unique(
array_map(fn ($session): ?string => $session['userId'], $sessions)
)));
@@ -173,9 +148,14 @@ public function sync(Session $session, Document $document, int $version = 0, ?st
];
// ensure file is still present and accessible
- $file = $this->fileService->getFileForSession($session, $shareToken);
- $result['readOnly'] = $this->fileService->isReadOnly($file, $shareToken);
- $this->documentService->assertNoOutsideConflict($document, $file);
+ $type = $document->getContextType();
+ $id = $document->getContextId();
+ $context = $this->contextManager->getContext($type, $id, $shareToken);
+ $result['readOnly'] = $context->isReadOnly();
+ $document = $context->updateDocument($document);
+ if ($document) {
+ $this->documentService->updateDocument($document);
+ }
} catch (NotPermittedException|NotFoundException|InvalidPathException $e) {
$this->logger->info($e->getMessage(), ['exception' => $e]);
return new DataResponse([
@@ -186,13 +166,8 @@ public function sync(Session $session, Document $document, int $version = 0, ?st
return new DataResponse([
'message' => 'Document no longer exists'
], Http::STATUS_NOT_FOUND);
- } catch (DocumentSaveConflictException) {
- try {
- /** @psalm-suppress PossiblyUndefinedVariable */
- $result['outsideChange'] = $file->getContent();
- } catch (LockedException) {
- // Ignore locked exception since it might happen due to an autosave action happening at the same time
- }
+ } catch (DocumentSaveConflictException $e) {
+ $result['outsideChange'] = $e->getContent();
}
return new DataResponse($result, isset($result['outsideChange']) ? Http::STATUS_CONFLICT : Http::STATUS_OK);
@@ -200,28 +175,21 @@ public function sync(Session $session, Document $document, int $version = 0, ?st
public function save(Session $session, Document $document, int $version, string $autosaveContent, string $documentState, bool $force = false, bool $manualSave = false, ?string $shareToken = null): DataResponse {
try {
- $file = $this->fileService->getFileForSession($session, $shareToken);
- } catch (NotPermittedException|NotFoundException $e) {
+ $type = $document->getContextType();
+ $id = $document->getContextId();
+ $context = $this->contextManager->getContext($type, $id, $shareToken);
+ } catch (NotFoundException $e) {
$this->logger->info($e->getMessage(), ['exception' => $e]);
return new DataResponse([
'message' => 'File not found'
], Http::STATUS_NOT_FOUND);
- } catch (DoesNotExistException $e) {
- $this->logger->info($e->getMessage(), ['exception' => $e]);
- return new DataResponse([
- 'message' => 'Document no longer exists'
- ], Http::STATUS_NOT_FOUND);
}
$result = [];
try {
- $result['document'] = $this->documentService->autosave($document, $file, $version, $autosaveContent, $documentState, $force, $manualSave, $shareToken);
- } catch (DocumentSaveConflictException) {
- try {
- $result['outsideChange'] = $file->getContent();
- } catch (LockedException) {
- // Ignore locked exception since it might happen due to an autosave action happening at the same time
- }
+ $result['document'] = $this->documentService->autosave($document, $context, $version, $autosaveContent, $documentState, $force, $manualSave, $shareToken);
+ } catch (DocumentSaveConflictException $e) {
+ $result['outsideChange'] = $e->getContent();
} catch (NotPermittedException) {
return new DataResponse([
'error' => $this->l10n->t('Read-only permission cannot save document changes. Please reload the page.')
@@ -242,17 +210,4 @@ public function updateSession(Session $session, string $guestName): DataResponse
return new DataResponse($this->sessionService->updateSession($session, $guestName));
}
- private function loadContent(\OCP\Files\File $file): ?string {
- try {
- $content = $file->getContent();
- $content = $this->encodingService->encodeToUtf8($content);
- if ($content === null) {
- $this->logger->warning('Failed to encode file to UTF8. File ID: ' . $file->getId());
- }
- } catch (NotFoundException $e) {
- $this->logger->warning($e->getMessage(), ['exception' => $e]);
- $content = null;
- }
- return $content;
- }
}
diff --git a/lib/Service/AttachmentService.php b/lib/Service/AttachmentService.php
index 7147630afc7..194ed405750 100755
--- a/lib/Service/AttachmentService.php
+++ b/lib/Service/AttachmentService.php
@@ -12,7 +12,9 @@
use OC\User\NoUserException;
use OCA\DAV\Connector\Sabre\PublicAuth;
use OCA\Files_Sharing\SharedStorage;
+use OCA\Text\Context\ContextManager;
use OCA\Text\Controller\AttachmentController;
+use OCA\Text\Db\DocumentMapper;
use OCA\Text\Db\Session;
use OCP\Constants;
use OCP\Files\File;
@@ -44,6 +46,8 @@ public function __construct(
private IFilenameValidator $filenameValidator,
private IFilesMetadataManager $filesMetadataManager,
private ISession $session,
+ private DocumentMapper $documentMapper,
+ private ContextManager $contextManager,
) {
}
@@ -305,7 +309,7 @@ public function uploadAttachment(int $documentId, string $newFileName, $newFileR
* @throws InvalidPathException
* @throws NoUserException
*/
- public function uploadAttachmentPublic(?int $documentId, string $newFileName, $newFileResource, string $shareToken): array {
+ public function uploadAttachmentPublic(int $documentId, string $newFileName, $newFileResource, string $shareToken): array {
try {
$share = $this->shareManager->getShareByToken($shareToken);
} catch (ShareNotFound) {
@@ -515,12 +519,21 @@ private function isDownloadDisabled(File $file): bool {
* @throws NotPermittedException
*/
private function getTextFile(int $documentId, string $userId): File {
- $userFolder = $this->rootFolder->getUserFolder($userId);
- $file = $userFolder->getFirstNodeById($documentId);
+ $document = $this->documentMapper->find($documentId);
+ $type = $document->getContextType();
+ $id = $document->getContextId();
+ $context = $this->contextManager->getContext($type, $id, null);
+ $file = $context->getFile();
if ($file instanceof File && !$this->isDownloadDisabled($file)) {
return $file;
}
- throw new NotFoundException('Text file with id=' . $documentId . ' was not found in storage of ' . $userId);
+ throw new NotFoundException('Text file for document'
+ . $documentId
+ . ' ('
+ . $context->toString()
+ . ') was not found in storage of '
+ . $userId
+ );
}
/**
@@ -528,32 +541,23 @@ private function getTextFile(int $documentId, string $userId): File {
*
* @throws NotFoundException
*/
- private function getTextFilePublic(?int $documentId, string $shareToken): File {
- // is the file shared with this token?
+ private function getTextFilePublic(int $documentId, string $shareToken): File {
+ // TODO: Lazy load the context and enable these additional checks inside the context
try {
$share = $this->shareManager->getShareByToken($shareToken);
- if (in_array($share->getShareType(), [IShare::TYPE_LINK, IShare::TYPE_EMAIL])) {
- // shared file or folder?
- if ($share->getNodeType() === 'file') {
- $textFile = $share->getNode();
- if ($textFile instanceof File
- && !$this->isDownloadDisabled($textFile)
- && $textFile->getId() === $documentId
- ) {
- return $textFile;
- }
- } elseif ($documentId !== null && $share->getNodeType() === 'folder') {
- $folder = $share->getNode();
- if ($folder instanceof Folder) {
- $textFile = $folder->getFirstNodeById($documentId);
- if ($textFile instanceof File && !$this->isDownloadDisabled($textFile)) {
- return $textFile;
- }
- }
- }
- }
} catch (ShareNotFound) {
- // same as below
+ throw new NotFoundException();
+ }
+ if (!in_array($share->getShareType(), [IShare::TYPE_LINK, IShare::TYPE_EMAIL])) {
+ throw new NotFoundException();
+ }
+ $document = $this->documentMapper->find($documentId);
+ $type = $document->getContextType();
+ $id = $document->getContextId();
+ $context = $this->contextManager->getContext($type, $id, $shareToken);
+ $file = $context->getFile();
+ if ($file instanceof File && !$this->isDownloadDisabled($file)) {
+ return $file;
}
throw new NotFoundException('Text file with id=' . (string)$documentId . ' and shareToken ' . $shareToken . ' was not found.');
}
diff --git a/lib/Service/DocumentService.php b/lib/Service/DocumentService.php
index d4bcdf01747..fe0e8087979 100644
--- a/lib/Service/DocumentService.php
+++ b/lib/Service/DocumentService.php
@@ -10,6 +10,9 @@
namespace OCA\Text\Service;
use InvalidArgumentException;
+use OCA\Text\Context\ContextManager;
+use OCA\Text\Context\DocumentData;
+use OCA\Text\Context\IContext;
use OCA\Text\Db\Document;
use OCA\Text\Db\DocumentMapper;
use OCA\Text\Db\Session;
@@ -50,6 +53,7 @@ class DocumentService {
private readonly ICache $cache;
public function __construct(
+ private readonly ContextManager $contextManager,
private readonly DocumentMapper $documentMapper,
private readonly FileService $fileService,
private readonly StepMapper $stepMapper,
@@ -96,35 +100,28 @@ public function isSaveFromText(): bool {
* @throws NotPermittedException
* @throws Exception
*/
- public function getOrCreateDocument(File $file): Document {
- $document = $this->getDocument($file->getId());
- if ($document !== null) {
- $this->logger->info('Keep previous document of ' . $file->getId());
- return $document;
+ public function getOrCreateDocument(Document $document): Document {
+ $loaded = $this->documentMapper->load($document->getContextType(), $document->getContextId());
+ if ($loaded !== null) {
+ $this->logger->info('Keep previous document of ' . $document->toString());
+ return $loaded;
}
if (!$this->ensureDocumentsFolder()) {
throw new NotFoundException('No app data folder present for text documents');
}
- $this->logger->info('Create new document of ' . $file->getId());
- $document = new Document();
- $document->setId($file->getId());
- $document->setLastSavedVersion(0);
- $document->setLastSavedVersionTime($file->getMTime());
- $document->setLastSavedVersionEtag($file->getEtag());
- $document->setBaseVersionEtag(uniqid());
- $document->setChecksum(self::computeCheckSum($file->getContent()));
+ $this->logger->info('Create new document of ' . $document->toString());
try {
/** @var Document $document */
$document = $this->documentMapper->insert($document);
- $this->cache->set('document-version-' . $document->getId(), 0);
+ $this->cache->set('document-version-' . $document->id, 0);
} catch (Exception $e) {
if ($e->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
throw $e;
}
// Document might have been created in the meantime
- $document = $this->getDocument($file->getId());
+ $document = $this->getDocument($document->id);
if ($document === null) {
throw $e;
}
@@ -132,6 +129,28 @@ public function getOrCreateDocument(File $file): Document {
return $document;
}
+ public function getDocumentData(Document $document): DocumentData {
+ $documentState = null;
+ if ($document->getLastSavedVersion() > 0) {
+ $this->logger->debug('Loading saved document state for ' . $document->toString());
+ try {
+ $stateFile = $this->getStateFile($document->id);
+ $documentState = $stateFile->getContent();
+ } catch (NotFoundException) {
+ // If we have no state file we need to load the content from the file
+ // On the client side we use this to initialize a idempotent initial y.js document
+ $this->logger->warning('State file not found for saved document' . $document->toString());
+ }
+ }
+
+ $documentData = new DocumentData(
+ document: $document,
+ documentState: $documentState,
+ );
+
+ return $documentData;
+ }
+
/**
* @param int $documentId
* @return ISimpleFile
@@ -164,6 +183,14 @@ public function updateDocumentVersionInfo(File $file): void {
$this->documentMapper->update($document);
}
+ /**
+ * @throws Exception
+ * @throws InvalidPathException
+ */
+ public function updateDocument(Document $document): void {
+ $this->documentMapper->update($document);
+ }
+
/**
* @param int $documentId
*
@@ -216,8 +243,10 @@ public function addStep(Document $document, Session $session, array $steps, int
}
}
if (count($stepsToInsert) > 0) {
- $file = $this->fileService->getFileForSession($session, $shareToken);
- if (!$this->fileService->isReadOnly($file, $shareToken)) {
+ $type = $document->getContextType();
+ $id = $document->getContextId();
+ $context = $this->contextManager->getContext($type, $id, $shareToken);
+ if (!$context->isReadOnly()) {
$this->insertSteps($document, $session, $stepsToInsert);
}
}
@@ -305,40 +334,6 @@ public function getSteps(int $documentId, int $lastVersion): array {
return $this->stepMapper->find($documentId, $lastVersion);
}
- /**
- * @throws DocumentSaveConflictException
- * @throws InvalidPathException
- * @throws NotFoundException
- */
- public function assertNoOutsideConflict(Document $document, File $file, bool $force = false, ?string $shareToken = null): void {
- $documentId = $document->getId();
- $lastMTime = $document->getLastSavedVersionTime();
- $lastEtag = $document->getLastSavedVersionEtag();
-
- if ($lastMTime <= 0 || $force || $this->fileService->isReadOnly($file, $shareToken) || $this->cache->get('document-save-lock-' . $documentId)) {
- return;
- }
-
- $fileMtime = $file->getMtime();
- $fileEtag = $file->getEtag();
-
- if ($lastEtag === $fileEtag && $lastMTime === $fileMtime) {
- return;
- }
-
- $storedChecksum = $document->getChecksum();
- $fileContent = $file->getContent();
- $fileChecksum = self::computeCheckSum($fileContent);
-
- if ($storedChecksum !== $fileChecksum) {
- throw new DocumentSaveConflictException('File changed in the meantime from outside');
- }
-
- $document->setLastSavedVersionTime($fileMtime);
- $document->setLastSavedVersionEtag($fileEtag);
- $this->documentMapper->update($document);
- }
-
/**
* @param string $content
* @return string
@@ -355,19 +350,25 @@ public static function computeCheckSum(string $content): string {
* @throws NotPermittedException
* @throws Exception
*/
- public function autosave(Document $document, File $file, int $version, string $autoSaveDocument, string $documentState, bool $force = false, bool $manualSave = false, ?string $shareToken = null): Document {
- $documentId = $document->getId();
-
- if ($this->fileService->isReadOnly($file, $shareToken)) {
+ public function autosave(Document $document, IContext $context, int $version, string $autoSaveDocument, string $documentState, bool $force = false, bool $manualSave = false, ?string $shareToken = null): Document {
+ if ($context->isReadOnly()) {
throw new NotPermittedException('Read-only permission cannot save document changes. Please reload the page.');
}
- $this->assertNoOutsideConflict($document, $file, $force);
+ $lastMTime = $document->getLastSavedVersionTime();
+ if ($lastMTime > 0 && !$force && !$this->cache->get('document-save-lock-' . $document->id)) {
+ $updatedDocument = $context->updateDocument($document);
+ if ($updatedDocument !== null) {
+ $document = $updatedDocument;
+ $lastMTime = $document->getLastSavedVersionTime();
+ $this->documentMapper->update($document);
+ }
+ }
// Do not save if newer version already saved
// Note that $version is the version of the steps the client has fetched.
// It may have added steps on top of that - so if the versions match we still save.
- $stepsVersion = $this->stepMapper->getLatestVersion($documentId) ?? 0;
+ $stepsVersion = $this->stepMapper->getLatestVersion($document->id) ?? 0;
$savedVersion = $document->getLastSavedVersion();
$outdated = $savedVersion > 0 && $savedVersion > $version;
if (!$force && ($outdated || $version > (string)$stepsVersion)) {
@@ -375,50 +376,45 @@ public function autosave(Document $document, File $file, int $version, string $a
}
// Only save once every AUTOSAVE_MINIMUM_DELAY seconds
- $lastMTime = $document->getLastSavedVersionTime();
- if ($file->getMTime() === $lastMTime && $lastMTime > time() - self::AUTOSAVE_MINIMUM_DELAY && $manualSave === false) {
+ if ($lastMTime > time() - self::AUTOSAVE_MINIMUM_DELAY && $manualSave === false) {
return $document;
}
if (empty($autoSaveDocument)) {
+ $file = $context->getFile();
$this->logger->warning('Saving empty document', [
'requestVersion' => $version,
'requestAutosaveDocument' => $autoSaveDocument,
'requestDocumentState' => $documentState,
'document' => $document->jsonSerialize(),
- 'fileSizeBeforeSave' => $file->getSize(),
- 'steps' => array_map(static fn (Step $step) => $step->jsonSerialize(), $this->stepMapper->find($documentId, 0)),
- 'sessions' => array_map(static fn (Session $session) => $session->jsonSerialize(), $this->sessionMapper->findAll($documentId))
+ 'fileSizeBeforeSave' => $file ? $file->getSize() : $context->getType() . ' is not stored in a file',
+ 'steps' => array_map(static fn (Step $step) => $step->jsonSerialize(), $this->stepMapper->find($document->id, 0)),
+ 'sessions' => array_map(static fn (Session $session) => $session->jsonSerialize(), $this->sessionMapper->findAll($document->id))
]);
}
// Version changed but the content remains the same
- if ($autoSaveDocument === $file->getContent()) {
- $this->writeDocumentState($file->getId(), $documentState);
+ if ($autoSaveDocument === $context->loadContent()) {
+ $this->writeDocumentState($document->id, $documentState);
$document->setLastSavedVersion($version);
- $document->setLastSavedVersionTime($file->getMTime());
- $document->setLastSavedVersionEtag($file->getEtag());
$this->documentMapper->update($document);
return $document;
}
- $this->cache->set('document-save-lock-' . $documentId, true, 10);
+ $this->cache->set('document-save-lock-' . $document->id, true, 10);
+ $this->saveFromText = true;
try {
- $this->lockService->runInScope($file, function () use ($file, $autoSaveDocument, $documentState): void {
- $this->saveFromText = true;
- $file->putContent($autoSaveDocument);
- $this->writeDocumentState($file->getId(), $documentState);
+ $context->saveWithLock($autoSaveDocument, function () use ($document, $documentState): void {
+ $this->writeDocumentState($document->id, $documentState);
});
$document->setLastSavedVersion($version);
- $document->setLastSavedVersionTime($file->getMTime());
- $document->setLastSavedVersionEtag($file->getEtag());
$document->setChecksum(self::computeCheckSum($autoSaveDocument));
$this->documentMapper->update($document);
} catch (LockedException) {
// Ignore lock since it might occur when multiple people save at the same time
return $document;
} finally {
- $this->cache->remove('document-save-lock-' . $documentId);
+ $this->cache->remove('document-save-lock-' . $document->id);
}
return $document;
}
diff --git a/lib/Service/FileService.php b/lib/Service/FileService.php
index 471a6770783..b83a30114be 100644
--- a/lib/Service/FileService.php
+++ b/lib/Service/FileService.php
@@ -8,7 +8,6 @@
namespace OCA\Text\Service;
use OCA\Files_Sharing\SharedStorage;
-use OCA\Text\Db\Session;
use OCA\Text\Exception\InvalidSessionException;
use OCP\Constants;
use OCP\Files\File;
@@ -21,39 +20,20 @@
use OCP\Share\Exceptions\ShareNotFound;
use OCP\Share\IManager as ShareManager;
use OCP\Share\IShare;
+use Psr\Log\LoggerInterface;
class FileService {
public function __construct(
+ private readonly EncodingService $encodingService,
private readonly ISession $session,
private readonly IRootFolder $rootFolder,
private readonly LockService $lockService,
+ private readonly LoggerInterface $logger,
private readonly ShareManager $shareManager,
) {
}
- /**
- * @throws NotPermittedException
- * @throws NotFoundException
- */
- public function getFileForSession(Session $session, ?string $shareToken = null): File {
- if (!$session->isGuest()) {
- try {
- return $this->getFileById($session->getDocumentId(), $session->getUserId());
- } catch (NotFoundException $e) {
- if ($shareToken === null) {
- throw $e;
- }
- // We may still have a user session but on a public share link so move on
- }
- }
-
- if ($shareToken === null) {
- throw new \InvalidArgumentException('No proper share data');
- }
- return $this->getFileByIdFromShare($session->getDocumentId(), $shareToken);
- }
-
/**
* @throws NotFoundException
*/
@@ -68,7 +48,7 @@ public function getFileByIdFromShare(int $fileId, string $shareToken): File {
if ($node instanceof Folder) {
$node = $node->getFirstNodeById($fileId);
}
- if ($node instanceof File) {
+ if ($node instanceof File && $node->getId() === $fileId) {
return $node;
}
throw new NotFoundException();
@@ -94,27 +74,29 @@ public function getFileById(int $fileId, string $userId): File {
return $file;
}
- // Ideally we'd optimize this part in the future by storing the path and getting the acutal target directly
- $files = $userFolder->getById($fileId);
+ // Ideally we'd optimize this part in the future by storing the path and getting the actual target directly
+ $files = array_filter($userFolder->getById($fileId), fn (Node $f) => $f instanceof File);
if (count($files) === 0) {
throw new NotFoundException();
}
// Workaround to always open files with edit permissions if multiple occurrences of
// the same file id are in the user home, ideally we should also track the path of the file when opening
- usort($files, static fn (Node $a, Node $b) => ($b->getPermissions() & Constants::PERMISSION_UPDATE) <=> ($a->getPermissions() & Constants::PERMISSION_UPDATE));
-
- $file = array_shift($files);
-
- if (!$file instanceof File) {
- throw new NotFoundException();
+ $readableFile = null;
+ foreach ($files as $file) {
+ $permissions = $file->getPermissions();
+ if ($permissions & Constants::PERMISSION_READ && $permissions & Constants::PERMISSION_UPDATE) {
+ return $file;
+ }
+ if ($permissions & Constants::PERMISSION_READ) {
+ $readableFile = $file;
+ }
}
-
- if (($file->getPermissions() & Constants::PERMISSION_READ) !== Constants::PERMISSION_READ) {
- throw new NotPermittedException();
+ if ($readableFile !== null) {
+ return $readableFile;
}
- return $file;
+ throw new NotPermittedException();
}
/**
@@ -186,7 +168,7 @@ public function checkSharePermissions(string $shareToken, int $permission = Cons
}
}
- public function getDocumentIdFromShare(int $fileId, string $shareToken): int {
+ public function checkFileAccessFromShare(int $fileId, string $shareToken): void {
try {
$share = $this->shareManager->getShareByToken($shareToken);
} catch (ShareNotFound) {
@@ -226,15 +208,26 @@ public function getDocumentIdFromShare(int $fileId, string $shareToken): int {
if ($attributes !== null && $attributes->getAttribute('permissions', 'download') === false) {
throw new InvalidSessionException();
}
+ }
- return $fileId;
+ public function checkFileAccessForUser(int $fileId, string $userId): void {
+ if ($this->rootFolder->getUserFolder($userId)->getFirstNodeById($fileId) === null) {
+ throw new InvalidSessionException();
+ }
}
- public function getDocumentIdForUser(int $fileId, string $userId): int {
- if ($this->rootFolder->getUserFolder($userId)->getFirstNodeById($fileId) !== null) {
- return $fileId;
+ public function loadContent(File $file): ?string {
+ try {
+ $content = $file->getContent();
+ $content = $this->encodingService->encodeToUtf8($content);
+ if ($content === null) {
+ $this->logger->warning('Failed to encode file to UTF8. File ID: ' . $file->getId());
+ }
+ } catch (NotFoundException $e) {
+ $this->logger->warning($e->getMessage(), ['exception' => $e]);
+ $content = null;
}
- throw new InvalidSessionException();
+ return $content;
}
}
diff --git a/lib/Service/LockService.php b/lib/Service/LockService.php
index 9bce9254824..a64fd133b60 100644
--- a/lib/Service/LockService.php
+++ b/lib/Service/LockService.php
@@ -23,6 +23,9 @@ public function __construct(
) {
}
+ /**
+ * @throws PreConditionNotMetException if another lock scope is already active
+ */
public function runInScope(File $file, callable $callback): void {
$this->lockManager->runInScope(
new LockContext(
@@ -49,10 +52,6 @@ public function getLockByOthers(File $file): ?ILock {
}
public function lock(File $file): bool {
- if (!$this->lockManager->isLockProviderAvailable()) {
- return true;
- }
-
try {
$this->lockManager->lock(new LockContext(
$file,
@@ -67,10 +66,6 @@ public function lock(File $file): bool {
}
public function unlock(File $file): void {
- if (!$this->lockManager->isLockProviderAvailable()) {
- return;
- }
-
try {
$this->lockManager->unlock(new LockContext(
$file,
diff --git a/src/apis/connect.ts b/src/apis/connect.ts
index 33e8ebdbb00..e22e41c0669 100644
--- a/src/apis/connect.ts
+++ b/src/apis/connect.ts
@@ -9,11 +9,18 @@ import type { Document, GuestSession, Session } from '../services/SyncService.ts
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
-export interface OpenParams {
- fileId?: number
+export interface OpenContextParams {
+ type: string
+ id: number
+ filePath: string // not send to the api but included in the connection
baseVersionEtag?: string
+}
+
+export interface OpenShareParams {
+ token: string
+ fileId: number
filePath: string
- token?: string
+ baseVersionEtag?: string
guestName?: string
}
@@ -27,16 +34,33 @@ export interface OpenData {
hasOwner: boolean
}
+/**
+ * Open editing connection to a file when logged in
+ *
+ * @param params Parameters identifying the document
+ */
+export async function openContext(params: OpenContextParams): Promise<{ connection: Connection, data: OpenData }> {
+ const { type, id, baseVersionEtag } = params
+ const url = generateUrl(`/apps/text/session/${type}/${id}/create`)
+ const response = await axios.put(url, { baseVersionEtag })
+ const { document, session } = response.data
+ const connection = {
+ documentId: document.id,
+ sessionId: session.id,
+ sessionToken: session.token,
+ baseVersionEtag: document.baseVersionEtag,
+ filePath: params.filePath,
+ }
+ return { connection, data: response.data }
+}
+
/**
* Open editing connection to the document
*
* @param params Parameters identifying the document
*/
-export async function open(params: OpenParams): Promise<{ connection: Connection, data: OpenData }> {
- const _baseUrl = params.token
- ? generateUrl('/apps/text/public')
- : generateUrl('/apps/text')
- const url = `${_baseUrl}/session/${params.fileId}/create`
+export async function openShare(params: OpenShareParams): Promise<{ connection: Connection, data: OpenData }> {
+ const url = generateUrl('/apps/text/public/session/123/create')
const response = await axios.put(url, params)
const { document, session } = response.data
const connection = {
diff --git a/src/components/CollaborativeEditor.vue b/src/components/CollaborativeEditor.vue
index da0b2f86d22..f1ab97546ca 100644
--- a/src/components/CollaborativeEditor.vue
+++ b/src/components/CollaborativeEditor.vue
@@ -188,9 +188,9 @@ export default defineComponent({
default: '',
},
- fileId: {
- type: Number,
- default: null,
+ context: {
+ type: Object,
+ required: true,
},
active: {
@@ -287,7 +287,7 @@ export default defineComponent({
)
const { syncService } = provideSyncService(connection, openConnection)
const extensions = [
- Autofocus.configure({ fileId: props.fileId }),
+ Autofocus.configure({ id: props.context.type + '-' + props.context.id }),
Collaboration.configure({ document: ydoc }),
CollaborationCaret.configure({ provider: { awareness } }),
]
@@ -406,7 +406,7 @@ export default defineComponent({
},
hasDocumentParameters() {
- return this.fileId || this.shareToken || this.initialSession
+ return this.context || this.shareToken || this.initialSession
},
hasOutdatedDocument() {
@@ -449,7 +449,7 @@ export default defineComponent({
},
indexedDbConflictKey() {
- return `text-indexeddb-conflict-${this.fileId}`
+ return `text-indexeddb-conflict-${this.context.type}-${this.context.id}`
},
},
@@ -491,9 +491,7 @@ export default defineComponent({
this.indexedDbConflictKey,
JSON.stringify(conflictData),
)
- logger.debug('Stored conflict to localStorage', {
- fileId: this.fileId,
- })
+ logger.debug('Stored conflict to localStorage', this.context)
}
logger.debug('Clearing the outdated cache and connecting without it.')
@@ -559,7 +557,7 @@ export default defineComponent({
this.syncProvider = createSyncServiceProvider({
ydoc: this.ydoc,
syncService: this.syncService,
- fileId: this.fileId,
+ context: this.context,
initialSession: this.initialSession,
disableBc: true,
awareness: this.awareness,
@@ -628,9 +626,9 @@ export default defineComponent({
shareToken: this.shareToken,
currentDirectory: this.currentDirectory,
})
- if (session.userId && this.relativePath?.length) {
+ if (session.userId && this.relativePath?.length && this.context.type === 'file') {
const node = new File({
- id: this.fileId,
+ id: this.context.id,
root: `/files/${session.userId}`,
source: generateRemoteUrl(`dav/files/${session.userId}${this.relativePath}`),
@@ -875,7 +873,7 @@ export default defineComponent({
*/
debugData() {
const yjsData = {
- fileId: this.fileId,
+ context: this.context,
filePath: this.relativePath,
clientId: this.ydoc.clientID,
pendingStructs: this.ydoc.store.pendingStructs,
diff --git a/src/components/PublicFilesEditor.vue b/src/components/PublicFilesEditor.vue
deleted file mode 100644
index b255c9378f1..00000000000
--- a/src/components/PublicFilesEditor.vue
+++ /dev/null
@@ -1,70 +0,0 @@
-
-
-
-
-
-
-
-
-
diff --git a/src/components/ViewerComponent.vue b/src/components/ViewerComponent.vue
index 5d53d81f68f..2a1cff26eca 100644
--- a/src/components/ViewerComponent.vue
+++ b/src/components/ViewerComponent.vue
@@ -6,11 +6,11 @@
-