Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
736fbc9
fix(playwright): use server master branch for unreleased version
max-nextcloud Jan 6, 2026
2a8c5ce
wip: try y-indexeddb
max-nextcloud Jun 13, 2025
71c8a29
chore(split) useIndexedDbProvider from Editor.vue
max-nextcloud Sep 4, 2025
7f8ee3d
fix(cron): do not reset document
max-nextcloud Sep 4, 2025
28a8f53
enh(yjs): store baseVersionEtag alongside doc
max-nextcloud Sep 4, 2025
a1426b8
fix(offline): persist dirty state in indexed db
max-nextcloud Oct 14, 2025
84439a5
chore(test): explore empty changesets
max-nextcloud Oct 22, 2025
4007a94
chore(rename): use privateMethods for emitError and emitDocumentState…
max-nextcloud Oct 26, 2025
8b8f2b5
chore(cleanup): _getContent alias for serialize
max-nextcloud Oct 26, 2025
c0f8e0a
chore(refactor): handle open data in websocket polyfill
max-nextcloud Oct 26, 2025
e249165
fix(sync): only accept sync protocol and return sync step 2
max-nextcloud Oct 26, 2025
1b8c8d8
enh(sync): recover automatically from outdated / renamed doc
max-nextcloud Oct 27, 2025
9e562d7
fix(sync): ensure dirty is updated when saving in onDestroy
max-nextcloud Nov 4, 2025
59650d5
chore(logging): some optional debug logging
max-nextcloud Nov 5, 2025
73daead
fix(sync): actually disable browser broadcast
max-nextcloud Nov 5, 2025
360843c
chore(test): conflict and sync with autoreload
max-nextcloud Nov 5, 2025
911346d
chore(type) a few more files
max-nextcloud Nov 6, 2025
a1a4e3e
chore(test): add initial test for indexed db
max-nextcloud Nov 7, 2025
4bb4b22
fix(sync): Cleanup sessions even with unsaved changes
max-nextcloud Nov 7, 2025
7ab596b
chore(type): EditorFactory and setInitialYjsState
max-nextcloud Nov 10, 2025
b355517
enh(sync): updateFromContent()
max-nextcloud Nov 10, 2025
ac68248
fix(indexedDB): handle conflict with local change
max-nextcloud Jan 7, 2026
0c1c9fc
chore(log): debug log when attempting to open sync service twice
max-nextcloud Jan 7, 2026
9e50301
fix(conflict): handle conflict when opening initial session
max-nextcloud Jan 7, 2026
5051e03
fix(conflict): label buttons properly for unsaved local changes
max-nextcloud Jan 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 21 additions & 30 deletions cypress/e2e/api/SessionApi.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,34 +73,33 @@ describe('The session Api', function () {
cy.closeConnection(connection)
})

// Echoes all message types but queries
Object.entries(messages)
.filter(([key, _value]) => key !== 'query')
.forEach(([type, sample]) => {
it(`echos ${type} messages`, function () {
const steps = [sample]
const version = 0
cy.pushSteps({ connection, steps, version })
.its('version')
.should('eql', 0)
cy.syncSteps(connection)
.its('steps[0].data')
.should('eql', steps)
})
// Echoes updates and responses
;['update', 'response'].forEach((type) => {
it(`echos ${type} messages`, function () {
const steps = [messages[type]]
const version = 0
cy.pushSteps({ connection, steps, version })
.its('version')
.should('eql', 0)
cy.syncSteps(connection).its('steps[0].data').should('eql', steps)
})
})

it('responds to queries', function () {
it('responds to queries with updates and responses', function () {
const version = 0
Object.entries(messages).forEach(([type, sample]) => {
cy.pushSteps({ connection, steps: [sample], version })
})
cy.pushSteps({ connection, steps: [messages.query], version }).then(
(response) => {
cy.wrap(response).its('version').should('eql', 0)
cy.wrap(response).its('steps.length').should('eql', 1)
cy.wrap(response).its('steps.length').should('eql', 2)
cy.wrap(response)
.its('steps[0].data')
.should('eql', [messages.update])
cy.wrap(response)
.its('steps[1].data')
.should('eql', [messages.response])
},
)
})
Expand All @@ -111,7 +110,6 @@ describe('The session Api', function () {
let connection
let fileId
let filePath
let joining

beforeEach(function () {
cy.testName().then((name) => {
Expand Down Expand Up @@ -156,13 +154,10 @@ describe('The session Api', function () {
manualSave: true,
})
cy.openConnection({ fileId, filePath })
.then(({ connection: con, data }) => {
joining = con
return data
})
.its('documentState')
.as('joining')
.its('data.documentState')
.should('eql', documentState)
cy.closeConnection(joining)
cy.get('@joining').its('connection').then(cy.closeConnection)
})

afterEach(function () {
Expand All @@ -175,7 +170,6 @@ describe('The session Api', function () {
let connection
let filePath
let shareToken
let joining

beforeEach(function () {
cy.testName().then((name) => {
Expand Down Expand Up @@ -232,13 +226,10 @@ describe('The session Api', function () {
manualSave: true,
})
cy.openConnection({ filePath: '', token: shareToken })
.then(({ connection: con, data }) => {
joining = con
return data
})
.its('documentState')
.as('joining')
.its('data.documentState')
.should('eql', documentState)
cy.closeConnection(joining)
cy.get('@joining').its('connection').then(cy.closeConnection)
})
})

Expand Down
21 changes: 13 additions & 8 deletions cypress/e2e/api/SyncServiceProvider.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,20 @@ describe('Sync service provider', function () {
*/
function createProvider(ydoc) {
const relativePath = '.'
const { connection, openConnection, baseVersionEtag } = provideConnection({
fileId,
relativePath,
})
const { syncService } = provideSyncService(
connection,
openConnection,
baseVersionEtag,
let baseVersionEtag
const setBaseVersionEtag = (val) => {
baseVersionEtag = val
}
const getBaseVersionEtag = () => baseVersionEtag
const { connection, openConnection } = provideConnection(
{
fileId,
relativePath,
},
getBaseVersionEtag,
setBaseVersionEtag,
)
const { syncService } = provideSyncService(connection, openConnection)
const queue = []
syncService.bus.on('opened', () => syncService.startSync())
return createSyncServiceProvider({
Expand Down
21 changes: 8 additions & 13 deletions cypress/e2e/conflict.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,9 @@ variants.forEach(function ({ fixture, mime }) {
cy.getContent().should('contain', 'Heading')

cy.uploadFile(fileName, mime, testName + '/' + fileName)
cy.get('#editor-container .document-status', {
timeout: 40000,
}).should('contain', 'session has expired')

// Reload button works
cy.get('#editor-container .document-status a.button')
.contains('Reload')
.click()
cy.intercept({ method: 'POST', url: '**/session/*/push' }).as('push')
cy.wait('@push', { timeout: 20_000 })
// Autoreload works
getWrapper().should('not.exist')
cy.getContent().should('contain', 'Hello world')
cy.getContent().should('not.contain', 'Heading')
Expand Down Expand Up @@ -78,10 +73,10 @@ variants.forEach(function ({ fixture, mime }) {
cy.openFile(fileName)
cy.intercept({ method: 'POST', url: '**/session/*/push' }).as('push')
cy.wait('@push')
cy.get('[data-cy="resolveThisVersion"]').click()
cy.get('[data-cy="useEditorVersion"]').click()

getWrapper().should('not.exist')
cy.get('[data-cy="resolveThisVersion"]').should('not.exist')
cy.get('[data-cy="useEditorVersion"]').should('not.exist')
cy.getContent().should('contain', 'cruel conflicting')
},
)
Expand All @@ -90,11 +85,11 @@ variants.forEach(function ({ fixture, mime }) {
createConflict(fileName, 'edited-' + fileName, mime)

cy.openFile(fileName)
cy.get('[data-cy="resolveServerVersion"]').click()
cy.get('[data-cy="useReaderVersion"]').click()

getWrapper().should('not.exist')
cy.get('[data-cy="resolveThisVersion"]').should('not.exist')
cy.get('[data-cy="resolveServerVersion"]').should('not.exist')
cy.get('[data-cy="useEditorVersion"]').should('not.exist')
cy.get('[data-cy="useReaderVersion"]').should('not.exist')
cy.getContent().should('contain', 'Hello world')
cy.getContent().should('not.contain', 'cruel conflicting')
})
Expand Down
22 changes: 8 additions & 14 deletions cypress/e2e/sync.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,10 @@ describe('Sync', () => {
'contain',
'The document could not be loaded.',
)
cy.intercept('**/apps/text/session/*/create').as('create')
cy.get('#editor-container .document-status').find('.button.primary').click()
// let first attempt fail
cy.wait('@create', { timeout: 10000 })
cy.get('#editor-container .document-status', { timeout: 30000 }).should(
'contain',
'The document could not be loaded.',
Expand All @@ -117,13 +120,13 @@ describe('Sync', () => {
cy.intercept('**/apps/text/session/*/*', (req) => {
req.continue()
}).as('alive')
cy.intercept('**/apps/text/session/*/create').as('create')
cy.get('#editor-container .document-status').find('.button.primary').click()
// this is the create request... - now with the alive alias
cy.wait('@alive', { timeout: 30000 })
cy.wait('@create', { timeout: 10000 })
.its('request.body')
.should('have.property', 'baseVersionEtag')
.should('not.be.empty')
cy.getContent().should('contain', 'Hello world')
})

it('recovers from a lost and closed connection', () => {
Expand Down Expand Up @@ -176,18 +179,9 @@ describe('Sync', () => {
cy.wait('@save')
cy.uploadTestFile('test.md')

cy.get('#editor-container .document-status', { timeout: 30000 }).should(
'contain',
'Editing session has expired.',
)

// Reload button works
cy.get('#editor-container .document-status a.button')
.contains('Reload')
.click()

cy.getContent()
cy.get('#editor-container .document-status .notecard').should('not.exist')
cy.getContent().should('not.exist')
cy.getContent().find('h2').should('contain', 'Hello world')
cy.getContent().find('li').should('not.exist') // was overwritten after the save
})

it('passes the doc content from one session to the next', () => {
Expand Down
6 changes: 0 additions & 6 deletions lib/Cron/Cleanup.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@

namespace OCA\Text\Cron;

use OCA\Text\Exception\DocumentHasUnsavedChangesException;
use OCA\Text\Service\AttachmentService;
use OCA\Text\Service\DocumentService;
use OCA\Text\Service\SessionService;
Expand Down Expand Up @@ -42,11 +41,6 @@ protected function run($argument): void {
// Inactive sessions will get removed further down and will trigger a reset next time
continue;
}

try {
$this->documentService->resetDocument($document->getId());
} catch (DocumentHasUnsavedChangesException) {
}
$this->attachmentService->cleanupAttachments($document->getId());
}

Expand Down
4 changes: 2 additions & 2 deletions lib/Listeners/BeforeNodeWrittenListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,12 @@ public function handle(Event $event): void {
}
// Reset document session to avoid manual conflict resolution if there's no unsaved steps
try {
$this->documentService->resetDocument($node->getId());
$this->documentService->resetDocument($node->getId(), true);
} catch (DocumentHasUnsavedChangesException|NotFoundException $e) {
// Do not throw during event handling in this is expected to happen
// DocumentHasUnsavedChangesException: A document editing session is likely ongoing, someone can resolve the conflict
// NotFoundException: The event was called oin a file that was just created so a NonExistingFile object is used that has no id yet
$this->logger->debug('Reset document skipped in BeforeNodeWrittenEvent', ['exception' => $e]);
$this->logger->warning('Reset document skipped in BeforeNodeWrittenEvent', ['exception' => $e]);
}
}
}
8 changes: 6 additions & 2 deletions lib/Service/DocumentService.php
Original file line number Diff line number Diff line change
Expand Up @@ -208,8 +208,12 @@ public function addStep(Document $document, Session $session, array $steps, int
if ($readOnly && $message->isUpdate()) {
continue;
}
// Only accept sync protocol
if ($message->getYjsMessageType() !== YjsMessage::YJS_MESSAGE_SYNC) {
continue;
}
// Filter out query steps as they would just trigger clients to send their steps again
if ($message->getYjsMessageType() === YjsMessage::YJS_MESSAGE_SYNC && $message->getYjsSyncType() === YjsMessage::YJS_MESSAGE_SYNC_STEP1) {
if ($message->getYjsSyncType() === YjsMessage::YJS_MESSAGE_SYNC_STEP1) {
$stepsIncludeQuery = true;
} else {
$stepsToInsert[] = $step;
Expand Down Expand Up @@ -249,7 +253,7 @@ public function addStep(Document $document, Session $session, array $steps, int
$stepsToReturn = [];
foreach ($allSteps as $step) {
$message = YjsMessage::fromBase64($step->getData());
if ($message->getYjsMessageType() === YjsMessage::YJS_MESSAGE_SYNC && $message->getYjsSyncType() === YjsMessage::YJS_MESSAGE_SYNC_UPDATE) {
if ($message->isUpdate()) {
$stepsToReturn[] = $step;
}
}
Expand Down
21 changes: 21 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
"vue-click-outside": "^1.1.0",
"vue-material-design-icons": "^5.3.1",
"webdav": "^5.8.0",
"y-indexeddb": "^9.0.12",
"y-prosemirror": "^1.3.7",
"y-protocols": "^1.0.7",
"yjs": "^13.6.28"
Expand Down
19 changes: 11 additions & 8 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,28 +31,31 @@ export default defineConfig({
},

projects: [
// Our global setup to configure the Nextcloud docker container
{
name: 'setup',
testMatch: /setup\.ts$/,
},

{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
},
dependencies: ['setup'],
},
],

webServer: {
// Starts the Nextcloud docker container
command: 'npm run start:nextcloud',
// we use sigterm to notify the script to stop the container
// if it does not respond, we force kill it after 10 seconds
gracefulShutdown: {
signal: 'SIGTERM',
timeout: 10000,
},
reuseExistingServer: !process.env.CI,
url: 'http://127.0.0.1:8089',
stderr: 'pipe',
stdout: 'pipe',
url: 'http://127.0.0.1:8089',
timeout: 5 * 60 * 1000, // max. 5 minutes for creating the container
wait: {
// we wait for this line to appear in the output of the webserver until consider it done
stdout: /Nextcloud is now ready to use/,
},
},
})
Loading
Loading