Attachment 'GlobalSearchAndReplace.py'
Download 1 # -*- coding: utf-8 -*-
2 """
3 MoinMoin - GlobalSearchAndReplace action
4
5 This action allows you to select a set of pages by a search pattern
6 and do a search and replace on each page from the set in one go.
7 It offers a preview feature that lets you review what would edited
8 before doing the actual replace.
9
10 To hide misleading links in preview add the following to common.css of your
11 theme:
12
13 .global-replace-diff .diff-title {
14 visibility: collapse;
15 }
16
17 Adapted to moin-1.9.8 and extended by David Linke.
18 Based on version 1.0 of "SearchAndReplaceMultiplePages.py" downloaded
19 from http://www.egenix.com/library/moinmoin/
20
21 Written by Marc-Andre Lemburg <mal@egenix.com>,
22 Based on the RenamePage action by Jürgen Hermann <jh@web.de>
23
24 @copyright: 2015-2018, David Linke, MoinMoin:DavidLinke, https://github.com/dalito
25 @copyright: 2008, eGenix.com Software, Skills and Services GmbH <info@egenix.com>
26 @license: GNU GPL 2.0, see COPYING for details.
27 """
28
29 import re
30
31 from MoinMoin import wikiutil
32 from MoinMoin.PageEditor import PageEditor
33 from MoinMoin.action import ActionBase
34 from MoinMoin import log
35 from MoinMoin.util import diff_html
36
37 __version__ = '2.0'
38
39 # Should only the superuser be allowed to use this action ?
40 SUPERUSER_ONLY = False
41
42 logging = log.getLogger(__name__)
43
44
45 class GlobalSearchAndReplace(ActionBase):
46 """Search and replace on multiple pages action
47
48 Note: the action name is the class name
49 """
50
51 def __init__(self, pagename, request):
52 ActionBase.__init__(self, pagename, request)
53 self.page = PageEditor(self.request, pagename)
54 self.orig_page = pagename
55 self.feedback = u''
56 self.no_refused_access = 0
57
58 def is_allowed(self, pagename=None):
59 """Check if user is allowed to access page"""
60 if pagename is None:
61 pagename = self.pagename
62 may = self.request.user.may
63 return may.write(pagename)
64
65 def render(self):
66 """
67 Render action
68
69 This action returns a wiki page with optional message, or
70 redirect to original page.
71 """
72 _ = self.request.getText
73 form = self.request.form
74
75 if 'cancel' in form:
76 # User cancelled
77 return self.page.send_page()
78
79 # Validate user rights and page state. If we get error here, we
80 # return an error message, without the rename form.
81 error = None
82 if self.is_excluded():
83 error = _('Action %(actionname)s is excluded in this wiki!') % {
84 'actionname': self.actionname}
85 elif SUPERUSER_ONLY and not self.request.user.isSuperUser():
86 error = _(u'Only superusers are allowed to use this action.')
87 elif not self.page.exists():
88 error = _(u'This page is already deleted or was never created!')
89 if error:
90 # Send page with an error message
91 self.request.theme.add_msg(msg=error)
92 return self.page.send_page()
93
94 if not form.get('searchtext'):
95 self.request.theme.add_msg(_(u'Please fill in a search text and '
96 '(optional) a replacement text!'),
97 'info')
98 # Run search & replace on the pages
99 elif ('replace' in form or 'preview' in form) and 'ticket' in form:
100 self.replace()
101
102 # logging.info('form in search and replace: %s' % repr(form))
103
104 # Show the form (with error or feedback information)
105 self.request.theme.add_msg(self.makeform(), 'info')
106 return self.page.send_page()
107
108 def replace(self):
109 """Replace text on pages matching a regexp"""
110 _ = self.request.getText
111 form = self.request.form
112 formatter = self.request.formatter
113 self.feedback = u''
114 self.no_refused_access = 0
115
116 # Require a valid ticket. Make outside attacks harder by
117 # requiring two full HTTP transactions
118 if not wikiutil.checkTicket(self.request, form['ticket']):
119 self.error = _(u'Please use the interactive user interface to '
120 'do search & replace on multiple pages!')
121 return
122
123 # Get new name from form and normalize.
124 comment = form.get('comment', [u''])
125 comment = wikiutil.clean_input(comment)
126
127 pagename = form.get('pagename')
128 pagename = wikiutil.normalize_pagename(pagename, self.cfg)
129
130 searchtext = form.get('searchtext')
131 replacetext = form.get('replacetext', '')
132
133 # test if regular expressions are OK
134 try:
135 re.compile(pagename)
136 except Exception as err:
137 self.error = _(u'Error in page name pattern: %s' % err)
138 return
139 try:
140 re.compile(searchtext)
141 except Exception as err:
142 self.error = _(u'Error in search text pattern: %s' % err)
143 return
144
145 # Get list of all pages
146 pages = self.request.rootpage.getPageList(user='', exists='')
147 # Check which page names match
148 pages_to_update = [p for p in pages if re.search(pagename, p)]
149
150 self.feedback += formatter.rule()
151 self.feedback += formatter.paragraph(1)
152 self.feedback += _('Found %i matching pagename(s).' %
153 len(pages_to_update))
154 self.feedback += formatter.paragraph(0)
155
156 if not pages_to_update:
157 return
158
159 self.feedback += formatter.rule()
160 if 'preview' not in self.request.form:
161 self.feedback += formatter.paragraph(1)
162 self.feedback += _('Updating pages...')
163 self.feedback += formatter.paragraph(0)
164 self.feedback += formatter.bullet_list(1)
165
166 # replace on all matching pages one by one
167 for page in pages_to_update:
168 self.replace_on_page(page, searchtext, replacetext, comment)
169
170 if 'preview' not in self.request.form:
171 self.feedback += formatter.bullet_list(0)
172
173 if self.no_refused_access:
174 self.feedback += formatter.paragraph(1)
175 self.feedback += formatter.smiley('<!>')
176 self.feedback += _(u' %i pages skipped because of access '
177 'restrictions.' % self.no_refused_access)
178 self.feedback += formatter.paragraph(0)
179
180 def replace_on_page(self, pagename, searchtext, replacetext, comment):
181 """Open page for editing and do search and replace in raw code
182 """
183 _ = self.request.getText
184 formatter = self.request.formatter
185
186 # Check permissions. Don't reveal the page name, just count.
187 if not self.is_allowed(pagename):
188 self.no_refused_access += 1
189 return
190
191 # Open page
192 page = PageEditor(self.request, pagename)
193
194 # Get page text
195 oldtext = page.get_raw_body()
196
197 if (self.request.form.get('ignore_pi', False)
198 and oldtext.startswith('#')):
199 # do not replace in processing instruction (pi) / header
200 header, content = [], []
201 in_header = True
202 for line in oldtext.split('\n'):
203 if line.startswith('#') and in_header:
204 header.append(line)
205 else:
206 content.append(line)
207 in_header = False
208 # Apply replacements
209 try:
210 newcontent = re.sub(searchtext, replacetext, u'\n'.join(content))
211 except Exception as err:
212 self.error = _(u'Error in replace text pattern: %s' % err)
213 return
214 newtext = u'%s\n%s' % (u'\n'.join(header), newcontent)
215 else:
216 # Apply replacements
217 try:
218 newtext = re.sub(searchtext, replacetext, oldtext)
219 except Exception as err:
220 self.error = _(u'Error in replace text pattern: %s' % err)
221 return
222
223 # Either save page text or show preview of edit, if the text changed.
224 if newtext.strip() != oldtext.strip(): # avoid hit on changed tail
225 if 'preview' in self.request.form:
226 self.feedback += formatter.paragraph(1)
227 self.feedback += _('Would edit page <b>%s</b> as follows:' %
228 wikiutil.escape(pagename))
229 self.feedback += formatter.paragraph(0)
230 self.feedback += u'<div class="global-replace-diff">'
231 self.feedback += formatter.rawHTML(
232 diff_html.diff(self.request, oldtext, newtext)
233 )
234 self.feedback += u'</div>\n'
235 else:
236 try:
237 page.saveText(newtext, 0, comment=comment)
238 self.feedback += formatter.listitem(1)
239 self.feedback += formatter.smiley('(./)')
240 self.feedback += _('%s' % wikiutil.escape(pagename))
241 self.feedback += formatter.listitem(0)
242 except PageEditor.SaveError, reason:
243 self.error += _(u'Cannot save page "%s": %s ! <br>' %
244 (wikiutil.escape(pagename), reason))
245 return
246
247 def makeform(self):
248 """Display a search&replace page form
249
250 The form also provides error feedback in case there was an
251 error during the replace.
252 """
253 from MoinMoin.widget.dialog import Dialog
254 _ = self.request.getText
255
256 error = ''
257 if self.error:
258 error = u'<p class="error">%s</p>\n' % self.error
259
260 form = self.request.form
261 namespace = {
262 'error': error,
263 'feedback': self.feedback,
264 'action': self.__class__.__name__,
265 'ticket': wikiutil.createTicket(self.request),
266 'pagename': form.get('pagename', self.orig_page),
267 'ignore_pi': 'checked' if form.get('ignore_pi', False) else '',
268 'searchtext': form.get('searchtext', ''),
269 'replacetext': form.get('replacetext', ''),
270 'comment': form.get('comment', ''),
271 'replace': _(u'Search & Replace'),
272 'preview': _(u'Preview'),
273 'cancel': _(u'Cancel'),
274 'pagename_label': _(u"Page name pattern"),
275 'ignore_pi_label': _(u"Skip processing instructions in header"),
276 'searchtext_label': _(u"Search text pattern"),
277 'replacetext_label': _(u"Replace text pattern"),
278 'comment_label': _(u"Optional reason for the replacement"),
279 'note': _(u"Note: Page name and search pattern may use regular "
280 "expression syntax (Python re-module syntax). "
281 "Groups can be referenced in the replace pattern "
282 "using \\1, \\2, etc."),
283 }
284 form = """
285 %(error)s
286 <form method="post" action="">
287 <input type="hidden" name="action" value="%(action)s">
288 <input type="hidden" name="ticket" value="%(ticket)s">
289 <table>
290 <tr>
291 <td class="label"><label>%(pagename_label)s</label></td>
292 <td class="content">
293 <input type="text" name="pagename" size="60" value="%(pagename)s">
294 </td>
295 </tr>
296 <tr>
297 <td class="label"><label>%(ignore_pi_label)s</label></td>
298 <td class="content">
299 <input type="checkbox" name="ignore_pi" value="1" %(ignore_pi)s>
300 </td>
301 </tr>
302 <tr>
303 <td class="label"><label>%(searchtext_label)s</label></td>
304 <td class="content">
305 <input type="text" name="searchtext" size="60" value="%(searchtext)s">
306 </td>
307 </tr>
308 <tr>
309 <td class="label"><label>%(replacetext_label)s</label></td>
310 <td class="content">
311 <input type="text" name="replacetext" size="60" value="%(replacetext)s">
312 </td>
313 </tr>
314 <tr>
315 <td class="label"><label>%(comment_label)s</label></td>
316 <td class="content">
317 <input type="text" name="comment" size="60" maxlength="80" value="%(comment)s">
318 </td>
319 </tr>
320 <tr>
321 <td></td>
322 <td class="buttons">
323 <input type="submit" name="preview" value="%(preview)s">
324 <input type="submit" name="replace" value="%(replace)s">
325 <input type="submit" name="cancel" value="%(cancel)s">
326 </td>
327 </tr>
328 </table>
329 <p style="font-weight: normal">%(note)s</p>
330 <p>%(feedback)s</p>
331 </form>
332 """ % namespace
333
334 return Dialog(self.request, content=form)
335
336
337 def execute(pagename, request):
338 """Glue code for actions"""
339 GlobalSearchAndReplace(pagename, request).render()
Attached Files
To refer to attachments on a page, use attachment:filename, as shown below in the list of files. Do NOT use the URL of the [get] link, since this is subject to change and can break easily.You are not allowed to attach a file to this page.