433e607839a841b1f219ba5e3d1274cff4af6923
[mw] / src / mw / clicommands.py
1 ###
2 # mw - VCS-like nonsense for MediaWiki websites
3 # Copyright (C) 2011  Ian Weller <ian@ianweller.org> and others
4 #
5 # This program is free software; you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation; either version 2 of the License, or
8 # (at your option) any later version.
9 #
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License along
16 # with this program.  If not, see <http://www.gnu.org/licenses/>.
17 ###
18
19 import codecs
20 import cookielib
21 import getpass
22 import hashlib
23 import mw.metadir
24 from optparse import OptionParser, OptionGroup
25 import os
26 import simplemediawiki
27 import subprocess
28 import sys
29 import time
30
31
32 class CommandBase(object):
33
34     def __init__(self, name, description, usage=None):
35         self.me = os.path.basename(sys.argv[0])
36         self.description = description
37         if usage is None:
38             usage = '%prog ' + name
39         else:
40             usage = '%%prog %s %s' % (name, usage)
41         self.parser = OptionParser(usage=usage, description=description)
42         self.name = name
43         self.metadir = mw.metadir.Metadir()
44         self.shortcuts = []
45
46     def main(self):
47         (self.options, self.args) = self.parser.parse_args()
48         self.args = self.args[1:]  # don't need the first thing
49         self._do_command()
50
51     def _do_command(self):
52         pass
53
54     def _login(self):
55         user = raw_input('Username: ')
56         passwd = getpass.getpass()
57         result = self.api.call({'action': 'login',
58                                 'lgname': user,
59                                 'lgpassword': passwd})
60         if result['login']['result'] == 'Success':
61             # cookies are saved to a file
62             print 'Login successful! (yay)'
63         elif result['login']['result'] == 'NeedToken':
64             print 'Login with token'
65             result = self.api.call({'action': 'login',
66                                     'lgname': user,
67                                     'lgpassword': passwd,
68                                     'lgtoken': result['login']['token']})
69             if result['login']['result'] == 'Success':
70                 print 'Login successful! (yay)'
71             else:
72                 print 'Login failed: %s' % result['login']['result']
73         else:
74             print 'Login failed: %s' % result['login']['result']
75
76     def _die_if_no_init(self):
77         if self.metadir.config is None:
78             print '%s: not a mw repo' % self.me
79             sys.exit(1)
80
81     def _api_setup(self):
82         cookie_filename = os.path.join(self.metadir.location, 'cookies')
83         self.api_url = self.metadir.config.get('remote', 'api_url')
84         self.api = simplemediawiki.MediaWiki(self.api_url,
85                                              cookie_file=cookie_filename)
86
87
88 class InitCommand(CommandBase):
89
90     def __init__(self):
91         usage = 'API_URL'
92         CommandBase.__init__(self, 'init', 'start a mw repo', usage)
93
94     def _do_command(self):
95         if len(self.args) < 1:
96             self.parser.error('must have URL to remote api.php')
97         elif len(self.args) > 1:
98             self.parser.error('too many arguments')
99         self.metadir.create(self.args[0])
100
101
102 class LoginCommand(CommandBase):
103
104     def __init__(self):
105         CommandBase.__init__(self, 'login', 'authenticate with wiki')
106
107     def _do_command(self):
108         self._die_if_no_init()
109         self._api_setup()
110         self._login()
111
112
113 class LogoutCommand(CommandBase):
114
115     def __init__(self):
116         CommandBase.__init__(self, 'logout', 'forget authentication')
117
118     def _do_command(self):
119         self._die_if_no_init()
120         try:
121             os.unlink(os.path.join(self.metadir.location, 'cookies'))
122         except OSError:
123             pass
124
125
126 class PullCategoryMembersCommand(CommandBase):
127
128     def __init__(self):
129         usage = '[options] PAGENAME ...'
130         CommandBase.__init__(self, 'pull_commandat', 'add remote pages to repo '
131                              'belonging to the given category', usage)
132
133     def _do_command(self):
134         self._die_if_no_init()
135         self._api_setup()
136         pages = []
137         pages += self.args
138         for these_pages in [pages[i:i + 25] for i in range(0, len(pages), 25)]:
139             data = {
140                 'action': 'query',
141                 'gcmtitle': '|'.join(these_pages),
142                 'generator': 'categorymembers',
143                 'gcmlimit': 500
144             }
145         response = self.api.call(data)['query']['pages']
146         for pageid in response.keys():
147             pagename = response[pageid]['title']
148             print pagename
149             pull_command = PullCommand()
150             pull_command.args = [pagename.encode('utf-8')]
151             pull_command._do_command()
152
153
154 class PullCommand(CommandBase):
155     
156     def __init__(self):
157         usage = '[options] PAGENAME ...'
158         CommandBase.__init__(self, 'pull', 'add remote pages to repo', usage)
159
160     def _do_command(self):
161         self._die_if_no_init()
162         self._api_setup()
163         pages = []
164         pages += self.args
165
166         # Pull should work with pagename, filename, or working directory
167         converted_pages = []
168         if pages == []:
169             pages = self.metadir.working_dir_status().keys()
170         for pagename in pages:
171             if '.wiki' in pagename:
172                 converted_pages.append(
173                     mw.metadir.filename_to_pagename(pagename[:-5]))
174             else:
175                 converted_pages.append(pagename)
176         pages = converted_pages
177
178         for these_pages in [pages[i:i + 25] for i in range(0, len(pages), 25)]: # XXX ?
179             data = {
180                     'action': 'query',
181                     'titles': '|'.join(these_pages),
182                     'prop': 'info|revisions',
183                     'rvprop': 'ids|flags|timestamp|user|comment|content',
184             }
185             response = self.api.call(data)['query']['pages']
186             # for every pageid, returns dict.keys() = {'lastrevid', 'pageid', 'title', 'counter', 'length', 'touched': u'2011-02-02T19:32:04Z', 'ns', 'revisions' {...}}
187             for pageid in response.keys():
188                 pagename = response[pageid]['title']
189                 
190                 # If no revisions, then error, perhaps page deleted
191                 if 'revisions' not in response[pageid]:
192                     print 'skipping:       "%s" -- cannot find page, perhaps deleted' % (pagename)
193                     continue
194                 
195                 # Is the revisions list a sorted one, should I use [0] or [-1]? -- reagle
196                 if 'comment' in response[pageid]['revisions'][0]:
197                     last_wiki_rev_comment = response[pageid]['revisions'][0]['comment']
198                 else:
199                     last_wiki_rev_comment = ''
200                 last_wiki_rev_user = response[pageid]['revisions'][0]['user']
201                 
202                 # check if working file is modified or if wiki page doesn't exists
203                 status = self.metadir.working_dir_status()
204                 filename = mw.metadir.pagename_to_filename(pagename)
205                 full_filename = os.path.join(self.metadir.root, filename + '.wiki')
206                 if filename + '.wiki' in status and \
207                     status[filename + '.wiki' ] in ['M']:
208                     print 'skipping:       "%s" -- uncommitted modifications ' % (pagename)
209                     continue
210                 if 'missing' in response[pageid].keys():
211                     print 'error:          "%s": -- page does not exist, file not created' % \
212                             (self.me, pagename)
213                     continue
214
215                 wiki_revids = sorted([x['revid'] for x in response[pageid]['revisions']])
216                 last_wiki_revid = wiki_revids[-1]
217                 working_revids = sorted(self.metadir.pages_get_rv_list({'id' : pageid}))
218                 last_working_revid = working_revids[-1]
219                 if ( os.path.exists(full_filename) and 
220                         last_wiki_revid == last_working_revid):
221                     print 'wiki unchanged: "%s"' % (pagename)
222                 else:
223                     print 'pulling:        "%s" : "%s" by "%s"' % (
224                         pagename, last_wiki_rev_comment, last_wiki_rev_user)
225                     self.metadir.pagedict_add(pagename, pageid, last_wiki_revid)
226                     self.metadir.pages_add_rv(int(pageid),
227                                               response[pageid]['revisions'][0])
228                     with file(full_filename, 'w') as fd:
229                         data = response[pageid]['revisions'][0]['*']
230                         data = data.encode('utf-8')
231                         fd.write(data)
232                         
233 class StatusCommand(CommandBase):
234
235     def __init__(self):
236         CommandBase.__init__(self, 'status', 'check repo status')
237         self.shortcuts.append('st')
238
239     def _do_command(self):
240         self._die_if_no_init()
241         status = self.metadir.working_dir_status()
242         for filename in status:
243             print '%s %s' % (status[filename], filename)
244
245
246 class DiffCommand(CommandBase):
247
248     def __init__(self):
249         CommandBase.__init__(self, 'diff', 'diff wiki to working directory')
250
251     def _do_command(self):
252         self._die_if_no_init()
253         status = self.metadir.working_dir_status()
254         for filename in status:
255             if status[filename] == 'M':
256                 print self.metadir.diff_rv_to_working(
257                         mw.metadir.filename_to_pagename(filename[:-5])),
258
259
260 class MergeCommand(CommandBase):
261     def __init__(self):
262         usage = '[FILES]'
263         CommandBase.__init__(self, 'merge', 'merge local and wiki copies', usage)
264
265     def _do_command(self):
266         self._die_if_no_init()
267         self.merge_tool = self.metadir.config.get('merge', 'tool')
268         status = self.metadir.working_dir_status()
269         for filename in status:
270             if status[filename] == 'M':
271                 full_filename = os.path.join(self.metadir.root, filename)
272                 pagename = mw.metadir.filename_to_pagename(filename[:-5])
273                 # mv local to filename.wiki.local
274                 os.rename(full_filename, full_filename + '.local')
275                 # pull wiki copy
276                 pull_command = PullCommand()
277                 pull_command.args = [pagename.encode('utf-8')]
278                 pull_command._do_command()
279                 # mv remote to filename.wiki.remote
280                 os.rename(full_filename, full_filename + '.remote')
281                 # Open merge tool
282                 merge_command = self.merge_tool % (full_filename + '.local', 
283                     full_filename + '.remote', full_filename + '.merge')
284                 subprocess.call(merge_command.split(' '))
285                 # mv filename.merge filename and delete tmp files
286                 os.rename(full_filename + '.merge', full_filename)
287                 os.remove(full_filename + '.local')
288                 os.remove(full_filename + '.remote')
289                 # mw ci pagename
290                 commit_command = CommitCommand()
291                 commit_command.args = [pagename.encode('utf-8')]
292                 commit_command._do_command()
293
294
295 class CommitCommand(CommandBase):
296
297     def __init__(self):
298         usage = '[FILES]'
299         CommandBase.__init__(self, 'commit', 'commit changes to wiki', usage)
300         self.shortcuts.append('ci')
301         self.parser.add_option('-m', '--message', dest='edit_summary',
302                                help='don\'t prompt for edit summary and '
303                                'use this instead')
304         self.parser.add_option('-b', '--bot', dest='bot', action='store_true',
305                                help='mark actions as a bot (won\'t affect '
306                                'anything if you don\'t have the bot right',
307                                default=False)
308
309     def _do_command(self):
310         self._die_if_no_init()
311         self._api_setup()
312         files_to_commit = 0 # how many files to process
313         status = self.metadir.working_dir_status(files=self.args)
314         for filename in status:
315             print '%s %s' % (status[filename], filename)
316             if status[filename] in ['M']:
317                 files_to_commit += 1
318         if not files_to_commit:
319             print 'nothing to commit'
320             sys.exit()
321         if self.options.edit_summary == None:
322             print 'Edit summary:',
323             edit_summary = raw_input()
324         else:
325             edit_summary = self.options.edit_summary
326         for filename in status:
327             if status[filename] in ['M']:
328                 files_to_commit -= 1
329                 # get edit token
330                 data = {
331                         'action': 'query',
332                         'prop': 'info|revisions',
333                         'intoken': 'edit',
334                         'titles': mw.metadir.filename_to_pagename(filename[:-5]),
335                 }
336                 response = self.api.call(data)
337                 pages = response['query']['pages']
338                 pageid = pages.keys()[0]
339                 revid = pages[pageid]['revisions'][0]['revid']
340                 awaitedrevid = \
341                         self.metadir.pages_get_rv_list({'id': pageid})[0]
342                 if revid != awaitedrevid:
343                     print 'warning: edit conflict detected on "%s" (%s -> %s) ' \
344                             '-- skipping! (try merge)' % (filename, awaitedrevid, revid)
345                     continue
346                 edittoken = pages[pageid]['edittoken']
347                 full_filename = os.path.join(self.metadir.root, filename)
348                 text = codecs.open(full_filename, 'r', 'utf-8').read()
349                 text = text.encode('utf-8')
350                 if (len(text) != 0) and (text[-1] == '\n'):
351                     text = text[:-1]
352                 md5 = hashlib.md5()
353                 md5.update(text)
354                 textmd5 = md5.hexdigest()
355                 data = {
356                         'action': 'edit',
357                         'title': mw.metadir.filename_to_pagename(filename[:-5]),
358                         'token': edittoken,
359                         'text': text,
360                         'md5': textmd5,
361                         'summary': edit_summary,
362                 }
363                 if self.options.bot:
364                     data['bot'] = 'bot'
365                 response = self.api.call(data)
366                 if 'error' in response:
367                     if 'code' in response['error']:
368                         if response['error']['code'] == 'permissiondenied':
369                             print 'Permission denied -- try running "mw login"'
370                             return
371                 if response['edit']['result'] == 'Success':
372                     if 'nochange' in response['edit']:
373                         print 'warning: no changes detected in %s - ' \
374                                 'skipping and removing ending LF' % filename
375                         pagename = mw.metadir.filename_to_pagename(filename[:-5])
376                         self.metadir.clean_page(pagename)
377                         continue
378                     if response['edit']['oldrevid'] != revid:
379                         print 'warning: edit conflict detected on %s (%s -> %s) ' \
380                                 '-- skipping!' % (file, 
381                                 response['edit']['oldrevid'], revid)
382                         continue
383                     data = {
384                             'action': 'query',
385                             'revids': response['edit']['newrevid'],
386                             'prop': 'info|revisions',
387                             'rvprop':
388                                     'ids|flags|timestamp|user|comment|content',
389                     }
390                     response = self.api.call(data)['query']['pages']
391                     self.metadir.pages_add_rv(int(pageid),
392                                               response[pageid]['revisions'][0])
393                     # need to write latest rev to file too, as text may be changed
394                     #such as a sig, e.g., -~ =>  -[[User:Reagle|Reagle]]
395                     with file(full_filename, 'w') as fd:
396                         data = response[pageid]['revisions'][0]['*']
397                         data = data.encode('utf-8')
398                         fd.write(data)
399                     if files_to_commit :
400                         print 'waiting 3s before processing the next file'
401                         time.sleep(3)
402                 else:
403                     print 'error: committing %s failed: %s' % \
404                             (filename, response['edit']['result'])

Benjamin Mako Hill || Want to submit a patch?