Skip to content

Commit 9c9d970

Browse files
mfloreaclaude
andcommitted
XWIKI-24372: Implement auto-save for BlockNote realtime collaboration sessions
* Separate the generic XWiki document API from the real-time specific one, by splitting the XWikiDocument class in two: XWikiDocument, which holds the generic API, and RealtimeXWikiDocument, which adds the Netflux channels API on top of it. Both stay in the same module for now. * Decouple XWikiDocument from the current document, so that it can target any document: the constructor became a copy constructor, update() merges only the data it is given, and the current document is now obtained through the static currentDocument() factory. The page state side effect (the meta version and the hidden fields of the edit form) moved to syncCurrentDocumentState(). * Limit the jQuery usage to event handling, which is the only place where it is required, since XWiki triggers its events with jQuery: $.extend became Object.assign, $.param became URLSearchParams, $.getJSON and $.post became fetch, and $('#id').val() became plain DOM access. Note that fetch keeps the form token and the custom XWiki response headers working because xwiki.js wraps it for that purpose. * Remove XWikiDocument#save(), which never had a caller since it was introduced. * Request the REST API of the edited document translation rather than the one of the original translation, which is exposed through a different URL. The Netflux channels are still requested from the page, because they are not exposed per translation: the translations are separated by the channel path instead. * Stop overwriting the language field received from the REST API. The raw locale is kept in 'language' while the real locale is exposed by the new realLocale accessor, computed from the raw locale and the default locale. * Align the XWikiDocument implementation with the one from XWiki.InplaceEditing, in order to ease a future de-duplication: reuse its getOldAPI() and removeNullProperties() helpers, its real locale computation and its translation aware REST URL. The fallback on document.documentElement.lang is deliberately left out, because two users having different UI locales would then compute different real locales for a technical document (the one having the root locale as default locale), and thus end up using different Netflux channels. Verified with RealtimeWYSIWYGEditorIT (28 tests) after each step. Two cases are not covered by the automated tests and were checked by reading the code only: * a technical document, having the root locale as default locale, edited by two users having different UI locales; * the toolbar version entries, whose author and date come from the document translation history. Both deserve a follow-up, together with these two findings: * Saver#_notifyInitialVersion() swallows every error with a console.debug(), which is why the REST URL targeting the wrong document translation produced no signal at all. * The in-place editor and lock.js compute the real locale of a technical document from the UI locale, so two users having different UI locales use different lock keys for the same document translation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 879250a commit 9c9d970

5 files changed

Lines changed: 192 additions & 101 deletions

File tree

xwiki-platform-core/xwiki-platform-realtime/xwiki-platform-realtime-ui/src/main/resources/XWiki/Realtime/Configuration.xml

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -540,18 +540,19 @@ ul.realtime-versions &gt; li.divider:last-child {
540540
{{html clean="false"}}
541541
#getUserAvatarURL($xcontext.userReference $avatarURL 120)
542542
## Note that we have to pass some document information that is missing from the meta:
543-
## * meta includes only the raw document locale value which is the same for documents with translations (e.g. having
544-
## English as default locale) and for technical documents that don't have translations (e.g. having ROOT as default
545-
## locale)
546-
## * meta includes the document version but not its timestamp which is needed to be able to properly merge on save.
543+
## * 'realLocale' because meta includes only the raw document locale value which is the same for documents with
544+
## translations (e.g. having English as default locale) and for technical documents that don't have translations
545+
## (e.g. having ROOT as default locale)
546+
## * 'modified' because meta includes the document version but not its timestamp which is needed to be able to
547+
## properly merge on save.
547548
#set ($config = {
548549
'webSocketURL': $services.websocket.url('netflux'),
549550
'user': {
550551
'name': $xwiki.getUserName($xcontext.user, false),
551552
'avatarURL': $avatarURL.url
552553
},
553554
'document': {
554-
'language': $tdoc.realLocale,
555+
'realLocale': $tdoc.realLocale,
555556
'modified': $tdoc.date.time
556557
},
557558
'dateFormat': $xwiki.getXWikiPreference('dateformat', 'yyyy/MM/dd HH:mm')

xwiki-platform-core/xwiki-platform-realtime/xwiki-platform-realtime-webjar/src/main/webjar/document.js

Lines changed: 171 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -24,97 +24,196 @@ define('xwiki-realtime-document', [
2424
], function($, meta, realtimeConfig) {
2525
'use strict';
2626

27-
const channelListAPI = {
28-
getByPath: function(path) {
29-
return this.find(channel => JSON.stringify(channel.path) === JSON.stringify(path));
30-
},
31-
getByPathPrefix: function(pathPrefix) {
32-
return this.filter(channel => channel.path.length >= pathPrefix.length &&
33-
JSON.stringify(channel.path.slice(0, pathPrefix.length)) === JSON.stringify(pathPrefix));
27+
// Serialize the given parameters as a query string, supporting multi-value parameters (e.g. {path: ['a', 'b']}) and
28+
// ignoring the parameters that have no value. Parameters that are already serialized are returned as they are.
29+
function toQueryString(params) {
30+
if (typeof params !== 'object' || params === null) {
31+
return params;
3432
}
35-
};
33+
const queryString = new URLSearchParams();
34+
for (const [name, value] of Object.entries(params)) {
35+
for (const item of Array.isArray(value) ? value : [value]) {
36+
if (item !== undefined && item !== null) {
37+
queryString.append(name, item);
38+
}
39+
}
40+
}
41+
return queryString.toString();
42+
}
43+
44+
// Fetch JSON from the given URL. The error thrown when the request fails carries the response status.
45+
async function getJSON(url) {
46+
const response = await fetch(url, {
47+
// The XWiki REST API doesn't specify how its responses should be cached, so we ask for a fresh one.
48+
cache: 'no-store',
49+
headers: {
50+
// Without this the XWiki REST API answers with XML.
51+
'Accept': 'application/json',
52+
// Some server side code answers differently when the request is made from JavaScript.
53+
'X-Requested-With': 'XMLHttpRequest'
54+
}
55+
});
56+
if (!response.ok) {
57+
const error = new Error(`Failed to fetch [${url}]. Response status: ${response.status}`);
58+
error.status = response.status;
59+
throw error;
60+
}
61+
return response.json();
62+
}
63+
64+
function getOldAPI(xwikiDocument) {
65+
return (xwikiDocument.documentReference && new XWiki.Document(xwikiDocument.documentReference)) ||
66+
XWiki.currentDocument;
67+
}
68+
69+
function removeNullProperties(object) {
70+
return Object.fromEntries(Object.entries(object).filter(([key, value]) => value != null));
71+
}
3672

73+
// The value of a hidden field of the edit form, when that field is present.
74+
function getFieldValue(id) {
75+
return document.getElementById(id)?.value;
76+
}
77+
78+
// Update the value of a hidden field of the edit form, when that field is present.
79+
function setFieldValue(id, value) {
80+
const field = document.getElementById(id);
81+
if (field) {
82+
field.value = value;
83+
}
84+
}
85+
86+
// Generic client-side API for an XWiki document. Nothing real-time specific should go here.
3787
class XWikiDocument {
38-
constructor() {
39-
// Initialize with document fields coming from the real-time configuration.
40-
$.extend(this, realtimeConfig.document);
41-
this.update();
42-
}
43-
44-
reload() {
45-
return $.getJSON(meta.restURL, {
46-
// Make sure the response is not retrieved from cache (IE11 doesn't obey the caching HTTP headers).
47-
timestamp: Date.now()
48-
}).then(updatedDocument => {
49-
// Reload succeeded.
50-
// We were able to load the document so it's not new.
51-
this.isNew = false;
52-
return $.extend(this, updatedDocument, {
53-
// We need the real locale.
54-
language: updatedDocument.language || updatedDocument.translations['default']
88+
// The document currently displayed by the web page, with the fields exposed by the meta information.
89+
static currentDocument() {
90+
return new this({
91+
documentReference: meta.documentReference,
92+
language: meta.locale,
93+
version: meta.version,
94+
isNew: meta.isNew
95+
});
96+
}
97+
98+
constructor(data) {
99+
Object.assign(this, data);
100+
}
101+
102+
async reload() {
103+
try {
104+
const updatedDocument = await getJSON(this.getRestURL());
105+
return this.update({
106+
// The REST API response includes some properties with null values, that would otherwise overwrite the
107+
// properties of this document that have a value set.
108+
...removeNullProperties(updatedDocument),
109+
// We were able to load the document so it's not new.
110+
isNew: false
55111
});
56-
}, error => {
112+
} catch (error) {
57113
if (error.status === 404) {
58114
// The document doesn't exist anymore. Maybe it was deleted?
59-
return $.extend(this, {
115+
return this.update({
60116
version: '1.1',
61117
modified: 0,
62118
content: '',
63119
isNew: true
64120
});
65-
} else {
66-
// Reload failed. Continue using the current data.
67-
return this;
68121
}
69-
}).then(this.update.bind(this));
122+
// Otherwise the reload failed and we continue using the current data.
123+
return this.update();
124+
}
70125
}
71126

72127
update(data) {
73-
data = data || {
74-
documentReference: meta.documentReference,
75-
// We need the real locale.
76-
language: meta.locale || realtimeConfig.document.language,
77-
version: meta.version,
78-
// The timestamp of the last modification is needed to be able to properly merge on save.
79-
modified: meta.modified || realtimeConfig.document.modified,
80-
isNew: meta.isNew
81-
};
82-
$.extend(this, data);
83-
if (this.documentReference === meta.documentReference && this.version !== meta.version) {
84-
// Update the meta and the hidden fields used by the edit form in order to ensure proper merge on save.
128+
Object.assign(this, data);
129+
this.syncCurrentDocumentState();
130+
return this;
131+
}
132+
133+
// Whether this document is the one currently displayed by the web page.
134+
isCurrentDocument() {
135+
return !!this.documentReference?.equals(meta.documentReference);
136+
}
137+
138+
// Keep the meta and the hidden fields used by the edit form in sync, in order to ensure a proper merge on save.
139+
syncCurrentDocumentState() {
140+
if (this.isCurrentDocument() && this.version !== meta.version) {
85141
meta.setVersion(this.version);
86-
$('#editingVersionDate').val(this.modified);
87-
$('#isNew').val(this.isNew);
142+
setFieldValue('editingVersionDate', this.modified);
143+
setFieldValue('isNew', this.isNew);
88144
}
89-
return this;
90145
}
91146

92-
save(data) {
93-
return $.post(globalThis.docsaveurl, $.param($.extend({
94-
/* jshint camelcase:false */
95-
form_token: meta.form_token,
96-
xredirect: '',
97-
language: this.language,
98-
xaction: ['save', 'saveandcontinue', 'preview', 'cancel'],
99-
action_saveandcontinue: 'Save',
100-
xeditaction: 'edit',
101-
previousVersion: this.version,
102-
isNew: this.isNew,
103-
editingVersionDate: this.modified,
104-
minorEdit: 1,
105-
ajax: true
106-
}, data), true)).then(this.reload.bind(this));
147+
// This document's real locale. It differs from its (raw) locale only for the original translation, whose raw
148+
// locale is empty.
149+
get realLocale() {
150+
const locale = this.language;
151+
if (typeof locale !== 'string' || locale === '') {
152+
return this.defaultLocale;
153+
}
154+
return locale;
155+
}
156+
157+
// The locale of this document's original translation. Note that it is the empty string for a technical document,
158+
// whose default locale is the root locale.
159+
get defaultLocale() {
160+
return this.translations?.['default'];
161+
}
162+
163+
getURL(action, params, fragment) {
164+
return getOldAPI(this).getURL(action, toQueryString(params), fragment);
165+
}
166+
167+
// The REST URL of the wiki page this document belongs to, without taking its translation into account.
168+
getPageRestURL(entity, params) {
169+
return getOldAPI(this).getRestURL(entity, toQueryString(params));
170+
}
171+
172+
// The REST URL of this document. Note that a document translation is exposed through a different REST URL than
173+
// the original translation.
174+
getRestURL(entity, params) {
175+
const translationEntity = this.language && ('translations/' + encodeURIComponent(this.language));
176+
return this.getPageRestURL([translationEntity, entity].filter(segment => segment).join('/'), params);
177+
}
178+
179+
getRevision(version) {
180+
return getJSON(this.getRestURL('history/' + encodeURIComponent(version), {
181+
prettyNames: true
182+
}));
183+
}
184+
}
185+
186+
const channelListAPI = {
187+
getByPath: function(path) {
188+
return this.find(channel => JSON.stringify(channel.path) === JSON.stringify(path));
189+
},
190+
getByPathPrefix: function(pathPrefix) {
191+
return this.filter(channel => channel.path.length >= pathPrefix.length &&
192+
JSON.stringify(channel.path.slice(0, pathPrefix.length)) === JSON.stringify(pathPrefix));
193+
}
194+
};
195+
196+
// Adds the real-time channels API on top of the generic XWiki document API.
197+
class RealtimeXWikiDocument extends XWikiDocument {
198+
static currentDocument() {
199+
const currentDocument = super.currentDocument();
200+
const config = realtimeConfig.document || {};
201+
if (!currentDocument.language) {
202+
// We know this is the original document translation, but the meta information doesn't expose its actual
203+
// (real) locale, so we take it from the real-time configuration.
204+
currentDocument.translations = {'default': config.realLocale};
205+
}
206+
// The meta information doesn't expose the date of the last modification, which is needed to properly merge on
207+
// save. We keep it up to date on the edit form ourselves, see syncCurrentDocumentState().
208+
currentDocument.modified = Number(getFieldValue('editingVersionDate')) || config.modified;
209+
return currentDocument;
107210
}
108211

109212
getChannels(params) {
110-
const url = new XWiki.Document(this.documentReference).getRestURL('channels');
111-
params = $.extend({
112-
// Make sure the response is not retrieved from cache (IE11 doesn't obey the caching HTTP headers).
113-
timestamp: Date.now()
114-
}, params);
115-
return $.getJSON(url, $.param(params, true)).then(function(data) {
213+
const url = this.getPageRestURL('channels', params);
214+
return getJSON(url).then(function(data) {
116215
if (Array.isArray(data)) {
117-
return $.extend(data, channelListAPI);
216+
return Object.assign(data, channelListAPI);
118217
} else {
119218
throw new TypeError('Invalid response from the server when requesting the list of document channels.',
120219
{cause: data});
@@ -123,24 +222,15 @@ define('xwiki-realtime-document', [
123222
throw new Error('Failed to retrieve the list of document channels.', {cause: error});
124223
});
125224
}
126-
127-
getURL(...args) {
128-
return new XWiki.Document(this.documentReference).getURL(...args);
129-
}
130-
131-
getRevision(version) {
132-
return $.getJSON(meta.restURL + '/history/' + encodeURIComponent(version), $.param({
133-
prettyNames: true
134-
}, true));
135-
}
136225
}
137226

138-
// Initialize the document fields based on the meta information available on page load.
139-
const xwikiDocument = new XWikiDocument();
227+
// The document currently displayed by the web page.
228+
const xwikiDocument = RealtimeXWikiDocument.currentDocument();
140229

141230
// Update the document fields before and after the document is edited inplace (without reloading the web page).
231+
// We need jQuery here because these events are triggered with jQuery.
142232
$(document).on('xwiki:actions:edit xwiki:actions:view', function(event, data) {
143-
xwikiDocument.update();
233+
xwikiDocument.update(RealtimeXWikiDocument.currentDocument());
144234
});
145235

146236
return xwikiDocument;

xwiki-platform-core/xwiki-platform-realtime/xwiki-platform-realtime-webjar/src/main/webjar/loader.js

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,11 @@ define('xwiki-realtime-loader', [
6969
async updateChannels() {
7070
const channels = await doc.getChannels({
7171
path: [
72-
`translations/${doc.language}/saver`,
73-
`translations/${doc.language}/userData`,
74-
`translations/${doc.language}/fields/${this.info.field}/editors/${this.info.type}`,
72+
`translations/${doc.realLocale}/saver`,
73+
`translations/${doc.realLocale}/userData`,
74+
`translations/${doc.realLocale}/fields/${this.info.field}/editors/${this.info.type}`,
7575
// Check also if the field is edited in real-time with other editors at the same time.
76-
`translations/${doc.language}/fields/${this.info.field}/editors/`,
76+
`translations/${doc.realLocale}/fields/${this.info.field}/editors/`,
7777
],
7878
create: true
7979
});
@@ -83,9 +83,9 @@ define('xwiki-realtime-loader', [
8383

8484
_parseChannels(channels) {
8585
let keys = {};
86-
const saverChannel = channels.getByPath(['translations', doc.language, 'saver']);
87-
const userDataChannel = channels.getByPath(['translations', doc.language, 'userData']);
88-
const editorChannel = channels.getByPath(['translations', doc.language, 'fields', this.info.field, 'editors',
86+
const saverChannel = channels.getByPath(['translations', doc.realLocale, 'saver']);
87+
const userDataChannel = channels.getByPath(['translations', doc.realLocale, 'userData']);
88+
const editorChannel = channels.getByPath(['translations', doc.realLocale, 'fields', this.info.field, 'editors',
8989
this.info.type]);
9090
if (!saverChannel || !userDataChannel || !editorChannel) {
9191
console.error('Missing document channels.');
@@ -100,7 +100,7 @@ define('xwiki-realtime-loader', [
100100
// Collect the other active real-time editing session (for the specified document field) that are using a
101101
// different editor (e.g. the WYSIWYG editor).
102102
channels.getByPathPrefix([
103-
'translations', doc.language, 'fields', this.info.field, 'editors'
103+
'translations', doc.realLocale, 'fields', this.info.field, 'editors'
104104
]).forEach(channel => {
105105
if (channel.userCount > 0 && JSON.stringify(channel.path) !== JSON.stringify(editorChannel.path)) {
106106
keys.active[channel.path.slice(5).join('/')] = channel;
@@ -482,7 +482,7 @@ define('xwiki-realtime-loader', [
482482

483483
getAllUsersChannel = async function() {
484484
const channels = await doc.getChannels({
485-
path: `translations/${doc.language}/loader`,
485+
path: `translations/${doc.realLocale}/loader`,
486486
create: true
487487
});
488488
if (channels.length) {

xwiki-platform-core/xwiki-platform-realtime/xwiki-platform-realtime-webjar/src/main/webjar/toolbar.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -320,10 +320,10 @@ define('xwiki-realtime-toolbar', [
320320
const template = document.querySelector('template#realtime-version');
321321
const versionElement = template.content.querySelector('.realtime-version').cloneNode(true);
322322
versionElement.dataset.version = JSON.stringify(version);
323-
versionElement.href = xwikiDocument.getURL('view', $.param({
323+
versionElement.href = xwikiDocument.getURL('view', {
324324
'rev': version.number,
325-
'language': xwikiDocument.language
326-
}));
325+
'language': xwikiDocument.realLocale
326+
});
327327
versionElement.querySelector('.realtime-version-number').textContent = version.number;
328328
versionElement.querySelector('.realtime-version-date').textContent = moment(version.date)
329329
.format(this._dateFormat);

xwiki-platform-core/xwiki-platform-realtime/xwiki-platform-realtime-wysiwyg/xwiki-platform-realtime-wysiwyg-webjar/src/main/webjar/wysiwygEditor.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -422,13 +422,13 @@ define('xwiki-realtime-wysiwyg', [
422422
}
423423

424424
async _resetContent() {
425-
const html = await $.get(xwikiDocument.getURL('get', $.param({
425+
const html = await $.get(xwikiDocument.getURL('get', {
426426
xpage:'get',
427427
outputSyntax:'annotatedhtml',
428428
outputSyntaxVersion:'5.0',
429429
transformations:'macro',
430-
language: xwikiDocument.language
431-
})));
430+
language: xwikiDocument.realLocale
431+
}));
432432
this._hideChangesFromSaver(this._patchedEditor.setHTML(html, true));
433433
}
434434

0 commit comments

Comments
 (0)