Fix comment order in pages
Last modified by Raphaël Jakse on 2026/08/05 17:15
| order comment by date in XWiki documents |
| Type | |
| Category | Other |
| Developed by | |
| Rating | |
| License | GNU Lesser General Public License 2.1 |
Table of contents
Description
This script allows bulk fixing comment order in XWiki documents. Comments will be reordered by date. Comments can be misordered, for example, because order versions of the Confluence migrator didn't order comments by date and used the order advertised in confluence export packages, which is wrong at times.
In a new page, copy-paste the following snippet:
{{velocity}}
#set ($spacePickerParams = {
'name': 'targetSpace',
'value': "$!{request.targetSpace}"
})
This script allows bulk fixing comment order in XWiki documents. Comments will be reordered by date. Comments can be misordered, for example, because order versions of the Confluence migrator didn't order comments by date and used the order advertised in confluence export packages, which is wrong at times.
Programming rights are required to use this script.
#set($jobId = "BulkCommentOrderFixing-$!{datetool.systemTime}")
{{html clean="false"}}
<form class="xform" action="#" method="post">
<dl>
<dt>
<label for="targetSpace">Space</label>
<span class="xHint">The script will look for documents to fix within the following space.</span>
</dt>
<dd>
#pagePicker($spacePickerParams)
</dd>
<dt>
<input type="checkbox" name="allSpaces" id="allSpaces"#if($request.allSpaces) checked="checked"#end />
<label for="allSpaces">All spaces</label>
<span class="xHint">The macro replace job will execute for every document in all spaces (excerpt the XWiki system space).</span>
</dt>
<dt>
<input id="updateInPlace" name="updateInPlace" type="checkbox"#if($request.updateInPlace) checked="checked"#end /> <label for="updateInPlace">Update in place</label>
<span class="xHint">By default, this script adds a revision. This option will save the document in place instead, without adding a revision (DANGEROUS).</span>
</dt>
<dt>
<input id="savePages" name="savePages" type="checkbox"#if($request.savePages) checked="checked"#end /> <label for="savePages">Save pages</label>
<span class="xHint">By default, this script will execute in dry-mode, and will not save pages.</span>
</dt>
<dt>
<label for="comment">Revision comment</label>
<span class="xHint">The revision comment to use when updating documents (does not apply when using the update in place option)</span>
<input id="comment" name="comment" type="text" size="30"#if($request.comment) value="$escapetool.xml($request.comment)"#end/>
</dt>
</dl>
<p>
<span class="buttonwrapper">
<input type="hidden" name="form_token" value="$!{services.csrf.token}"/>
<input type="hidden" name="confirm" value="true"/>
<input type="hidden" name="jobId" value="$jobId"/>
<input class="button" type="submit" value="Fix comment order"/>
</span>
</p>
</form>
{{/html}}
#if ($request.confirm)
#set($jobStatusURL = "$!{request.contextPath}/rest/jobstatus/$!{escapetool.url($request.jobId)}")
#set($jobLogURL = "$!{request.contextPath}/rest/joblog/$!{escapetool.url($request.jobId)}")
This migration job is running with the ID **$!{request.jobId}**.
Access information about the job execution using the following REST endpoints:
* ${escapetool.h}${escapetool.h}[[$!{jobStatusURL}>>path:$!{jobStatusURL}||target="_blank"]]${escapetool.h}${escapetool.h}
* ${escapetool.h}${escapetool.h}[[$!{jobLogURL}>>path:$!{jobLogURL}||target="_blank"]]${escapetool.h}${escapetool.h}
#else
The migration job will be triggered with the ID **$jobId**.
#end
{{/velocity}}
{{job id="{{velocity}}$request.jobId{{/velocity}}" start="{{velocity}}$!{request.confirm}{{/velocity}}"}}
{{groovy}}
import com.xpn.xwiki.XWikiContext;
import com.xpn.xwiki.api.Document;
import com.xpn.xwiki.api.Element;
import com.xpn.xwiki.api.Object;
import com.xpn.xwiki.api.Property;
import com.xpn.xwiki.objects.BaseObject;
import com.xpn.xwiki.objects.PropertyInterface;
import org.apache.commons.lang3.StringUtils;
import org.xwiki.query.Query;
import java.util.regex.Pattern;
logger = services.logging.getLogger('BulkReplaceContent');
services.logging.setLevel('BulkReplaceContent', org.xwiki.logging.LogLevel.INFO);
if (!hasProgramming || !services.csrf.isTokenValid(request.form_token)) {
logger.error('Insufficient permissions or invalid CSRF token. Aborting.');
return;
}
boolean allSpaces = request.allSpaces == 'on';
if ((!request.targetSpace || StringUtils.isBlank(request.targetSpace)) && !allSpaces) {
logger.error('Missing a target space. Aborting.');
return;
}
String spacePrefix = "${StringUtils.removeEnd(request.targetSpace, 'WebHome')}%".toString();
String revisionComment = StringUtils.isEmpty(request.comment) ? "" : request.comment;
// Get every page matching the space
boolean updateInPlace = request.updateInPlace == "on";
boolean savePages = request.savePages == "on";
Query q = (
allSpaces
? services.query.xwql("select distinct doc.fullName from Document doc, doc.object(XWiki.XWikiComments) obj where doc.fullName not like 'XWiki.%'")
: services.query.xwql('select distinct doc.fullName from Document doc, doc.object(XWiki.XWikiComments) obj where doc.fullName like :spacePrefix').bindValue('spacePrefix', spacePrefix)
);
List<String> documents = q.execute();
logger.info('Found [{}] documents to verify', documents.size());
for (String documentFullName : documents) {
try {
Document document = xwiki.getDocument(documentFullName);
logger.info('Verifying document [{}]', document.getDocumentReference());
Date lastDate = null;
Vector<Object> comments = document.getComments()
for (Object comment : comments) {
Date date = (Date) comment.getValue("date");
if (date != null) {
if (lastDate != null && date.before(lastDate)) {
if (savePages) {
reorderComments(xcontext.getContext(), comments, document);
}
maybeSave(savePages, logger, document, updateInPlace, revisionComment);
break;
}
lastDate = date;
}
}
} catch (Exception e) {
logger.error('Uncaught exception [{}]', e);
}
}
private static void maybeSave(boolean savePages, logger, Document document, boolean updateInPlace,
String revisionComment)
{
if (savePages) {
logger.info('Content has changed; saving document [{}]', document.getDocumentReference());
if (updateInPlace) {
document.getDocument().setContentDirty(false);
document.getDocument().setMetaDataDirty(false);
}
document.save(revisionComment);
} else {
logger.info('Document [{}] would be fixed (but saving is not enabled)', document.getDocumentReference());
}
}
private static void reorderComments(XWikiContext context, Vector<Object> comments, Document document)
{
comments.sort((a, b) -> {
Date dA = (Date) a.getValue("date");
Date dB = (Date) b.getValue("date");
if (dA == null) {
if (dB == null) {
return 0;
}
return 1;
}
if (dB == null) {
return -1;
}
return dA.compareTo(dB);
})
List<BaseObject> baseComments = comments.stream().map(c -> c.getXWikiObject().clone()).toList();
int i = 0;
for (BaseObject comment : baseComments) {
BaseObject commentToSet = document.getObject("XWiki.XWikiComments", i).getXWikiObject();
Collection<String> propertiesToKeep = comment.getPropertyList()
for (String name : (propertiesToKeep)) {
if (!"replyto".equals(name)) {
def p = comment.get(name);
commentToSet.put(name, p);
}
}
for (String name : commentToSet.getPropertyList()) {
if (!propertiesToKeep.contains(name)) {
commentToSet.removeField(name);
}
}
setReplyTo(comment, commentToSet, context, comments)
i++;
}
}
private static void setReplyTo(BaseObject comment, BaseObject commentToSet, XWikiContext context,
Vector<Object> comments)
{
int replyTo = comment.getIntValue("replyto", -1);
if (replyTo != -1) {
Integer newReplyTo = getNewReplyToValue(replyTo, comments);
// null should not happen
if (newReplyTo != null) {
commentToSet.set("replyto", newReplyTo, context);
return;
}
}
commentToSet.set("replyto", null, context);
}
private static Integer getNewReplyToValue(int index, Vector<Object> comments)
{
int i = 0;
for (Object c : comments) {
if (c.getNumber() == index) {
return i;
}
i++;
}
return null;
}
{{/groovy}}
{{/job}}