Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,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',
Expand Down
1 change: 1 addition & 0 deletions composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,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',
Expand Down
37 changes: 26 additions & 11 deletions cypress/e2e/conflict.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ variants.forEach(function({ fixture, mime }) {
})

it(prefix + ': displays conflicts', function() {
createConflict(fileName, mime)
createConflict(fileName, 'edited-' + fileName, mime)

cy.openFile(fileName)

Expand All @@ -61,6 +61,9 @@ variants.forEach(function({ fixture, mime }) {
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')
Expand All @@ -70,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' })
Expand All @@ -81,12 +84,11 @@ 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"]')
Expand All @@ -102,7 +104,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) => {
Expand All @@ -114,6 +116,12 @@ variants.forEach(function({ fixture, mime }) {
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')
})
})
})

Expand All @@ -129,7 +137,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)

Expand All @@ -148,22 +156,29 @@ 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')
}
9 changes: 9 additions & 0 deletions cypress/fixtures/edited-lines.txt
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions cypress/fixtures/edited-long.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions cypress/fixtures/edited-test.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
## Hello world
edited
7 changes: 6 additions & 1 deletion lib/Db/Document.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,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;
Expand All @@ -32,13 +34,15 @@ 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');
$this->addType('currentVersion', 'integer');
$this->addType('lastSavedVersion', 'integer');
$this->addType('lastSavedVersionTime', 'integer');
$this->addType('initialVersion', 'integer');
$this->addType('checksum', 'string');
}

public function jsonSerialize(): array {
Expand All @@ -47,7 +51,8 @@ public function jsonSerialize(): array {
'lastSavedVersion' => $this->lastSavedVersion,
'lastSavedVersionTime' => $this->lastSavedVersionTime,
'baseVersionEtag' => $this->baseVersionEtag,
'initialVersion' => $this->initialVersion
'initialVersion' => $this->initialVersion,
'checksum' => $this->checksum
];
}
}
37 changes: 37 additions & 0 deletions lib/Migration/Version070000Date20250925110024.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

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

namespace OCA\Text\Migration;

use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\Attributes\AddColumn;
use OCP\Migration\Attributes\ColumnType;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;

#[AddColumn(table: 'text_documents', name: 'checksum', type: ColumnType::STRING, description: 'CRC32 checksum of document content')]
class Version070000Date20250925110024 extends SimpleMigrationStep {
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();

$table = $schema->getTable('text_documents');
if (!$table->hasColumn('checksum')) {
$table->addColumn('checksum', Types::STRING, [
'notnull' => false,
'length' => 8,
]);
return $schema;
}

return null;
}
}
41 changes: 33 additions & 8 deletions lib/Service/DocumentService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -310,25 +311,48 @@ 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();
$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);
}

/**
Expand Down Expand Up @@ -414,6 +438,7 @@ public function autosave(Document $document, ?File $file, int $version, ?string
$document->setLastSavedVersion($version);
$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
Expand Down
Loading