diff --git a/composer/composer/autoload_classmap.php b/composer/composer/autoload_classmap.php index a05cc44b183..daa77de7338 100644 --- a/composer/composer/autoload_classmap.php +++ b/composer/composer/autoload_classmap.php @@ -62,6 +62,7 @@ 'OCA\\Text\\Migration\\Version030701Date20230207131313' => $baseDir . '/../lib/Migration/Version030701Date20230207131313.php', 'OCA\\Text\\Migration\\Version030901Date20231114150437' => $baseDir . '/../lib/Migration/Version030901Date20231114150437.php', 'OCA\\Text\\Migration\\Version040100Date20240611165300' => $baseDir . '/../lib/Migration/Version040100Date20240611165300.php', + 'OCA\\Text\\Migration\\Version070000Date20250925110024' => $baseDir . '/../lib/Migration/Version070000Date20250925110024.php', 'OCA\\Text\\Notification\\Notifier' => $baseDir . '/../lib/Notification/Notifier.php', 'OCA\\Text\\Service\\ApiService' => $baseDir . '/../lib/Service/ApiService.php', 'OCA\\Text\\Service\\AttachmentService' => $baseDir . '/../lib/Service/AttachmentService.php', diff --git a/composer/composer/autoload_static.php b/composer/composer/autoload_static.php index 76acef1e6ae..a71fb7b3fbc 100644 --- a/composer/composer/autoload_static.php +++ b/composer/composer/autoload_static.php @@ -77,6 +77,7 @@ class ComposerStaticInitText 'OCA\\Text\\Migration\\Version030701Date20230207131313' => __DIR__ . '/..' . '/../lib/Migration/Version030701Date20230207131313.php', 'OCA\\Text\\Migration\\Version030901Date20231114150437' => __DIR__ . '/..' . '/../lib/Migration/Version030901Date20231114150437.php', 'OCA\\Text\\Migration\\Version040100Date20240611165300' => __DIR__ . '/..' . '/../lib/Migration/Version040100Date20240611165300.php', + 'OCA\\Text\\Migration\\Version070000Date20250925110024' => __DIR__ . '/..' . '/../lib/Migration/Version070000Date20250925110024.php', 'OCA\\Text\\Notification\\Notifier' => __DIR__ . '/..' . '/../lib/Notification/Notifier.php', 'OCA\\Text\\Service\\ApiService' => __DIR__ . '/..' . '/../lib/Service/ApiService.php', 'OCA\\Text\\Service\\AttachmentService' => __DIR__ . '/..' . '/../lib/Service/AttachmentService.php', diff --git a/cypress/e2e/conflict.spec.js b/cypress/e2e/conflict.spec.js index f6b430b5531..da4b2199d6c 100644 --- a/cypress/e2e/conflict.spec.js +++ b/cypress/e2e/conflict.spec.js @@ -51,7 +51,7 @@ variants.forEach(function ({ fixture, mime }) { }) it(prefix + ': displays conflicts', function () { - createConflict(fileName, mime) + createConflict(fileName, 'edited-' + fileName, mime) cy.openFile(fileName) @@ -60,6 +60,10 @@ variants.forEach(function ({ fixture, mime }) { 'The file was overwritten.', ) getWrapper().find('#read-only-editor').should('contain', 'Hello world') + getWrapper() + .find('#read-only-editor') + .should('not.contain', 'cruel conflicting') + getWrapper().find('.text-editor__main').should('contain', 'Hello world') getWrapper() .find('.text-editor__main') @@ -69,7 +73,7 @@ variants.forEach(function ({ fixture, mime }) { it( prefix + ': resolves conflict using current editing session', function () { - createConflict(fileName, mime) + createConflict(fileName, 'edited-' + fileName, mime) cy.openFile(fileName) cy.intercept({ method: 'POST', url: '**/session/*/push' }).as('push') @@ -78,13 +82,12 @@ variants.forEach(function ({ fixture, mime }) { getWrapper().should('not.exist') cy.get('[data-cy="resolveThisVersion"]').should('not.exist') - cy.getContent().should('contain', 'Hello world') cy.getContent().should('contain', 'cruel conflicting') }, ) it(prefix + ': resolves conflict using server version', function () { - createConflict(fileName, mime) + createConflict(fileName, 'edited-' + fileName, mime) cy.openFile(fileName) cy.get('[data-cy="resolveServerVersion"]').click() @@ -97,7 +100,7 @@ variants.forEach(function ({ fixture, mime }) { }) it(prefix + ': hides conflict in read only session', function () { - createConflict(fileName, mime) + createConflict(fileName, 'edited-' + fileName, mime) cy.testName().then((testName) => { cy.shareFile(`/${testName}/${fileName}`).then((token) => { cy.logout() @@ -107,6 +110,13 @@ variants.forEach(function ({ fixture, mime }) { cy.getContent().should('contain', 'cruel conflicting') getWrapper().should('not.exist') }) + + it(prefix + ': no conflict when uploading same file content', function () { + createConflict(fileName, fileName, mime) + cy.openFile(fileName) + cy.getContent().should('contain', 'Hello world') + getWrapper().should('not.exist') + }) }) }) @@ -122,7 +132,7 @@ describe('conflict dialog scroll behaviour', function () { cy.login(user) cy.createTestFolder() - createConflict(fileName, 'text/markdown') + createConflict(fileName, 'edited-' + fileName, 'text/markdown') cy.openFile(fileName) @@ -141,23 +151,30 @@ describe('conflict dialog scroll behaviour', function () { }) /** - * @param {string} fileName - filename + * @param {string} fileName1 - filename1 + * @param {string} fileName2 - filename2 * @param {string} mime - mimetype */ -function createConflict(fileName, mime) { +function createConflict(fileName1, fileName2, mime) { cy.testName().then((testName) => { - cy.uploadFile(fileName, mime, `${testName}/${fileName}`) + cy.uploadFile(fileName1, mime, `${testName}/${fileName1}`) }) cy.visitTestFolder() - cy.openFile(fileName) + cy.openFile(fileName1) cy.log('Inspect editor') cy.getEditor() .find('.ProseMirror') .should('have.attr', 'contenteditable', 'true') + cy.getContent().type('Hello you cruel conflicting world') + cy.testName().then((testName) => { - cy.uploadFile(fileName, mime, testName + '/' + fileName) + cy.uploadFile(fileName2, mime, testName + '/' + fileName1) }) + + cy.intercept('POST', '**/session/*/sync').as('sync') + cy.wait('@sync', { timeout: 10000 }) + cy.get('#viewer .modal-header button.header-close').click() cy.get('#viewer').should('not.exist') } diff --git a/cypress/fixtures/edited-lines.txt b/cypress/fixtures/edited-lines.txt new file mode 100644 index 00000000000..42e4ff329a7 --- /dev/null +++ b/cypress/fixtures/edited-lines.txt @@ -0,0 +1,9 @@ +This file contains multiple lines + +Hello world + +It's a text file so it should not be parsed as markdown + +But when it is these would turn into paragraphs. + +edited \ No newline at end of file diff --git a/cypress/fixtures/edited-long.md b/cypress/fixtures/edited-long.md new file mode 100644 index 00000000000..251ee450f22 --- /dev/null +++ b/cypress/fixtures/edited-long.md @@ -0,0 +1,23 @@ +# Hello world + +## First subheading + +Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos. + +Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos. + +Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos. + +## Second subheading + +Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos. + +Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos. + +Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos. + +## Third subheading + +Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos. + +edited \ No newline at end of file diff --git a/cypress/fixtures/edited-test.md b/cypress/fixtures/edited-test.md new file mode 100644 index 00000000000..a90390b9c4c --- /dev/null +++ b/cypress/fixtures/edited-test.md @@ -0,0 +1,2 @@ +## Hello world +edited diff --git a/lib/Db/Document.php b/lib/Db/Document.php index b3dff8656f5..dd188a22d37 100644 --- a/lib/Db/Document.php +++ b/lib/Db/Document.php @@ -23,6 +23,8 @@ * @method setLastSavedVersionEtag(string $etag): void * @method getBaseVersionEtag(): string * @method setBaseVersionEtag(string $etag): void + * @method getChecksum(): ?string + * @method setChecksum(?string $checksum): void */ class Document extends Entity implements \JsonSerializable { public $id = null; @@ -33,6 +35,7 @@ class Document extends Entity implements \JsonSerializable { protected int $lastSavedVersionTime = 0; protected string $lastSavedVersionEtag = ''; protected string $baseVersionEtag = ''; + protected ?string $checksum = null; public function __construct() { $this->addType('id', 'integer'); @@ -40,6 +43,7 @@ public function __construct() { $this->addType('lastSavedVersion', 'integer'); $this->addType('lastSavedVersionTime', 'integer'); $this->addType('initialVersion', 'integer'); + $this->addType('checksum', 'string'); } public function jsonSerialize(): array { @@ -48,7 +52,8 @@ public function jsonSerialize(): array { 'lastSavedVersion' => $this->lastSavedVersion, 'lastSavedVersionTime' => $this->lastSavedVersionTime, 'baseVersionEtag' => $this->baseVersionEtag, - 'initialVersion' => $this->initialVersion + 'initialVersion' => $this->initialVersion, + 'checksum' => $this->checksum ]; } } diff --git a/lib/Migration/Version070000Date20250925110024.php b/lib/Migration/Version070000Date20250925110024.php new file mode 100644 index 00000000000..c48856c9d26 --- /dev/null +++ b/lib/Migration/Version070000Date20250925110024.php @@ -0,0 +1,37 @@ +getTable('text_documents'); + if (!$table->hasColumn('checksum')) { + $table->addColumn('checksum', Types::STRING, [ + 'notnull' => false, + 'length' => 8, + ]); + return $schema; + } + + return null; + } +} diff --git a/lib/Service/DocumentService.php b/lib/Service/DocumentService.php index e57b29dc9c6..d7550e08853 100644 --- a/lib/Service/DocumentService.php +++ b/lib/Service/DocumentService.php @@ -143,6 +143,7 @@ public function createDocument(File $file): Document { $document->setLastSavedVersionTime($file->getMTime()); $document->setLastSavedVersionEtag($file->getEtag()); $document->setBaseVersionEtag(uniqid()); + $document->setChecksum($this->computeCheckSum($file->getContent())); try { /** @var Document $document */ $document = $this->documentMapper->insert($document); @@ -310,6 +311,8 @@ public function getSteps(int $documentId, int $lastVersion): array { return $this->stepMapper->find($documentId, $lastVersion); } + + /** * @throws DocumentSaveConflictException * @throws InvalidPathException @@ -317,18 +320,39 @@ public function getSteps(int $documentId, int $lastVersion): array { */ public function assertNoOutsideConflict(Document $document, File $file, bool $force = false, ?string $shareToken = null): void { $documentId = $document->getId(); - $savedEtag = $file->getEtag(); $lastMTime = $document->getLastSavedVersionTime(); + $lastEtag = $document->getLastSavedVersionEtag(); + + if ($lastMTime <= 0 || $force || $this->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 = $this->computeChecksum($fileContent); - if ($lastMTime > 0 - && $force === false - && !$this->isReadOnly($file, $shareToken) - && $savedEtag !== $document->getLastSavedVersionEtag() - && $lastMTime !== $file->getMtime() - && !$this->cache->get('document-save-lock-' . $documentId) - ) { + 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 + */ + private function computeCheckSum(string $content): string { + return hash('crc32', $content); } /** @@ -414,6 +438,7 @@ public function autosave(Document $document, ?File $file, int $version, ?string $document->setLastSavedVersion($stepsVersion); $document->setLastSavedVersionTime($file->getMTime()); $document->setLastSavedVersionEtag($file->getEtag()); + $document->setChecksum($this->computeCheckSum($autoSaveDocument)); $this->documentMapper->update($document); } catch (LockedException $e) { // Ignore lock since it might occur when multiple people save at the same time