Bulk replace content in pages

Last modified by Clemens Robbenhaar on 2025/03/31 13:25

cogAllows to perfrom bulk search and replace of contents in wiki pages
TypeSnippet
CategoryOther
Developed by

Clément Aubin

Rating
1 Votes
LicenseGNU Lesser General Public License 2.1

Table of contents

Description

This script allows to perform bulk search and replace of content in wiki pages.

This snippet requires the Job Macro to run.

In a new page, copy-paste the following snippet :

{{velocity}}
#set ($spacePickerParams = {
  'name': 'targetSpace',
  'value': "$!{request.targetSpace}"
})
This script allows to perform bulk search and replace of XWiki document contents.

Programming rights are required to use this script.

{{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 containing the given search text 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>
      <label for="search">Text to be searched</label>
      <span class="xHint">The script will look for the following text in documents. The text in document contents has to match exactly with the search.</span>
    </dt>
    <dd>
      <textarea name="search" id="search">#if($request.search)$escapetool.xml($request.search)#end</textarea>
    </dd>
    <dt>
      <label for="replace">Text to replace</label>
    </dt>
    <dd>
      <textarea name="replace" id="replace">#if($request.replace)$escapetool.xml($request.replace)#end</textarea>
    </dd>
    <dt>
      <input id="regex" name="regex" type="checkbox"#if($request.regex) checked="checked"#end /> <label for="regex">Use regex</label>
      <span class="xHint">By default, this script will do a raw search / replace. Checking this allows you to use a regular expression, and reference captured groups in the replacement as well (using \0 (for the whole match), \1 (for the first captured group), \2, …), using the Java regex syntax</span>
    </dt>
    <dt>
      <input id="logContent" name="logContent" type="checkbox"#if($request.logContent) checked="checked"#end /> <label for="logContent">Log content</label>
      <span class="xHint">By default, this script won't log content, but you may want these logs it you want to perform a careful review</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 class="button" type="submit" value="Replace contents"/>
    </span>
  </p>
</form>
{{/html}}
{{/velocity}}

{{job id="bulkReplace" start="{{velocity}}$!{request.confirm}{{/velocity}}"}}
{{groovy}}
import com.xpn.xwiki.api.Document;
import org.apache.commons.lang3.StringUtils;
import org.xwiki.model.reference.DocumentReference
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;
}

if (StringUtils.isBlank(request.search)) {
    logger.error('Please provide a search string.');
    return;
}

String spacePrefix = "${StringUtils.removeEnd(request.targetSpace, 'WebHome')}%".toString();

String sanitizedSearchString = request.search.replace('\r\n','\n');
String sanitizedReplaceString = request.replace ? request.replace.replace('\r\n','\n') : "";
String comment = StringUtils.isEmpty(request.comment) ? "" : request.comment;
// Get every page matching the space
Pattern regex = request.regex == "on" ? Pattern.compile(sanitizedSearchString) : null;
boolean updateInPlace = request.updateInPlace == "on";
boolean logContent = request.logContent == "on";
boolean savePages = request.savePages == "on";
String filterContent = regex ? "" : " and doc.content like :searchContent";
Query q = (
    allSpaces
        ? services.query.hql("select doc.fullName from XWikiDocument doc where doc.fullName not like 'XWiki.%'" + filterContent)
        : services.query.hql('select doc.fullName from XWikiDocument doc where doc.fullName like :spacePrefix' + filterContent).bindValue('spacePrefix', spacePrefix)
);
q.addFilter('language')
if (filterContent) {
    q = q.bindValue('searchContent').anyChars().literal(sanitizedSearchString).anyChars().query();
}
List<String[]> documents = q.execute();
logger.info('Found [{}] documents to verify', documents.size());
for (String[] docs : documents) {
    try {
        String documentFullName = docs[0]
        String language = docs[1]
        DocumentReference documentReference = services.model.resolveDocument(documentFullName, 'current')
        if (language != null) {
          Locale locale = Locale.forLanguageTag(language)
          documentReference = new DocumentReference(documentReference, locale)
        }
        Document document = xwiki.getDocument(documentReference);

        logger.info('Verifying document [{}]', document.getDocumentReferenceWithLocale());
        String oldContent = document.getContent();
        String newContent = (
            regex == null
                ? StringUtils.replace(oldContent, sanitizedSearchString, sanitizedReplaceString)
                : regex.matcher(oldContent).replaceAll(sanitizedReplaceString)
        );
        boolean hasContentChanged = !oldContent.equals(newContent);

        if (hasContentChanged) {
            if (savePages) {
                logger.info('Content has changed; saving document [{}]', document.getDocumentReferenceWithLocale());
                document.setContent(newContent);
                if (updateInPlace) {
                    document.getDocument().setContentDirty(false);
                    document.getDocument().setMetaDataDirty(false);
                }
                document.save(comment);
            } else {
                logger.info('Content for document [{}] would change (but saving is not enabled)',
                    document.getDocumentReferenceWithLocale());
            }
            if (logContent) {
                logger.info("Before:\n{}", oldContent);
                logger.info("After:\n{}", newContent);
            }
        }
    } catch (Exception e) {
        logger.error('Uncaught exception [{}]', e);
    }
}
{{/groovy}}
{{/job}}

Get Connected