fix 3s pause between commits
[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                 # Is the revisions list a sorted one, should I use [0] or [-1]? -- reagle
191                 if 'comment' in response[pageid]['revisions'][0]:
192                     last_wiki_rev_comment = response[pageid]['revisions'][0]['comment']
193                 else:
194                     last_wiki_rev_comment = ''
195                 last_wiki_rev_user = response[pageid]['revisions'][0]['user']
196                 
197                 # check if working file is modified or if wiki page doesn't exists
198                 status = self.metadir.working_dir_status()
199                 filename = mw.metadir.pagename_to_filename(pagename)
200                 full_filename = os.path.join(self.metadir.root, filename + '.wiki')
201                 if filename + '.wiki' in status and \
202                     status[filename + '.wiki' ] in ['M']:
203                     print 'skipping:       "%s" -- uncommitted modifications ' % (pagename)
204                     continue
205                 if 'missing' in response[pageid].keys():
206                     print 'error:          "%s": -- page does not exist, file not created' % \
207                             (self.me, pagename)
208                     continue
209
210                 wiki_revids = sorted([x['revid'] for x in response[pageid]['revisions']])
211                 last_wiki_revid = wiki_revids[-1]
212                 working_revids = sorted(self.metadir.pages_get_rv_list({'id' : pageid}))
213                 last_working_revid = working_revids[-1]
214                 if ( os.path.exists(full_filename) and 
215                         last_wiki_revid == last_working_revid):
216                     print 'wiki unchanged: "%s"' % (pagename)
217                 else:
218                     print 'pulling:        "%s" : "%s" by "%s"' % (
219                         pagename, last_wiki_rev_comment, last_wiki_rev_user)
220                     self.metadir.pagedict_add(pagename, pageid, last_wiki_revid)
221                     self.metadir.pages_add_rv(int(pageid),
222                                               response[pageid]['revisions'][0])
223                     with file(full_filename, 'w') as fd:
224                         data = response[pageid]['revisions'][0]['*']
225                         data = data.encode('utf-8')
226                         fd.write(data)
227                         
228 class StatusCommand(CommandBase):
229
230     def __init__(self):
231         CommandBase.__init__(self, 'status', 'check repo status')
232         self.shortcuts.append('st')
233
234     def _do_command(self):
235         self._die_if_no_init()
236         status = self.metadir.working_dir_status()
237         for filename in status:
238             print '%s %s' % (status[filename], filename)
239
240
241 class DiffCommand(CommandBase):
242
243     def __init__(self):
244         CommandBase.__init__(self, 'diff', 'diff wiki to working directory')
245
246     def _do_command(self):
247         self._die_if_no_init()
248         status = self.metadir.working_dir_status()
249         for filename in status:
250             if status[filename] == 'M':
251                 print self.metadir.diff_rv_to_working(
252                         mw.metadir.filename_to_pagename(filename[:-5])),
253
254
255 class MergeCommand(CommandBase):
256     def __init__(self):
257         CommandBase.__init__(self, 'merge', 'merge local and wiki copies')
258         self.merge_tool = self.metadir.config.get('merge', 'tool')
259
260     def _do_command(self):
261         self._die_if_no_init()
262         status = self.metadir.working_dir_status()
263         for filename in status:
264             if status[filename] == 'M':
265                 full_filename = os.path.join(self.metadir.root, filename)
266                 pagename = mw.metadir.filename_to_pagename(filename[:-5])
267                 # mv local to filename.wiki.local
268                 os.rename(full_filename, full_filename + '.local')
269                 # pull wiki copy
270                 pull_command = PullCommand()
271                 pull_command.args = [pagename.encode('utf-8')]
272                 pull_command._do_command()
273                 # mv remote to filename.wiki.remote
274                 os.rename(full_filename, full_filename + '.remote')
275                 # Open merge tool
276                 merge_command = self.merge_tool % (full_filename + '.local', 
277                     full_filename + '.remote', full_filename + '.merge')
278                 subprocess.call(merge_command.split(' '))
279                 # mv filename.merge filename and delete tmp files
280                 os.rename(full_filename + '.merge', full_filename)
281                 os.remove(full_filename + '.local')
282                 os.remove(full_filename + '.remote')
283                 # mw ci pagename
284                 commit_command = CommitCommand()
285                 commit_command.args = [pagename.encode('utf-8')]
286                 commit_command._do_command()
287
288
289 class CommitCommand(CommandBase):
290
291     def __init__(self):
292         usage = '[FILES]'
293         CommandBase.__init__(self, 'commit', 'commit changes to wiki', usage)
294         self.shortcuts.append('ci')
295         self.parser.add_option('-m', '--message', dest='edit_summary',
296                                help='don\'t prompt for edit summary and '
297                                'use this instead')
298         self.parser.add_option('-b', '--bot', dest='bot', action='store_true',
299                                help='mark actions as a bot (won\'t affect '
300                                'anything if you don\'t have the bot right',
301                                default=False)
302
303     def _do_command(self):
304         self._die_if_no_init()
305         self._api_setup()
306         files_to_commit = 0 # how many files to process
307         status = self.metadir.working_dir_status(files=self.args)
308         for filename in status:
309             print '%s %s' % (status[filename], filename)
310             if status[filename] in ['M']:
311                 files_to_commit += 1
312         if not files_to_commit:
313             print 'nothing to commit'
314             sys.exit()
315         if self.options.edit_summary == None:
316             print 'Edit summary:',
317             edit_summary = raw_input()
318         else:
319             edit_summary = self.options.edit_summary
320         for filename in status:
321             if status[filename] in ['M']:
322                 files_to_commit -= 1
323                 # get edit token
324                 data = {
325                         'action': 'query',
326                         'prop': 'info|revisions',
327                         'intoken': 'edit',
328                         'titles': mw.metadir.filename_to_pagename(filename[:-5]),
329                 }
330                 response = self.api.call(data)
331                 pages = response['query']['pages']
332                 pageid = pages.keys()[0]
333                 revid = pages[pageid]['revisions'][0]['revid']
334                 awaitedrevid = \
335                         self.metadir.pages_get_rv_list({'id': pageid})[0]
336                 if revid != awaitedrevid:
337                     print 'warning: edit conflict detected on "%s" (%s -> %s) ' \
338                             '-- skipping! (try merge)' % (filename, awaitedrevid, revid)
339                     continue
340                 edittoken = pages[pageid]['edittoken']
341                 full_filename = os.path.join(self.metadir.root, filename)
342                 text = codecs.open(full_filename, 'r', 'utf-8').read()
343                 text = text.encode('utf-8')
344                 if (len(text) != 0) and (text[-1] == '\n'):
345                     text = text[:-1]
346                 md5 = hashlib.md5()
347                 md5.update(text)
348                 textmd5 = md5.hexdigest()
349                 data = {
350                         'action': 'edit',
351                         'title': mw.metadir.filename_to_pagename(filename[:-5]),
352                         'token': edittoken,
353                         'text': text,
354                         'md5': textmd5,
355                         'summary': edit_summary,
356                 }
357                 if self.options.bot:
358                     data['bot'] = 'bot'
359                 response = self.api.call(data)
360                 if 'error' in response:
361                     if 'code' in response['error']:
362                         if response['error']['code'] == 'permissiondenied':
363                             print 'Permission denied -- try running "mw login"'
364                             return
365                 if response['edit']['result'] == 'Success':
366                     if 'nochange' in response['edit']:
367                         print 'warning: no changes detected in %s - ' \
368                                 'skipping and removing ending LF' % filename
369                         pagename = mw.metadir.filename_to_pagename(filename[:-5])
370                         self.metadir.clean_page(pagename)
371                         continue
372                     if response['edit']['oldrevid'] != revid:
373                         print 'warning: edit conflict detected on %s (%s -> %s) ' \
374                                 '-- skipping!' % (file, 
375                                 response['edit']['oldrevid'], revid)
376                         continue
377                     data = {
378                             'action': 'query',
379                             'revids': response['edit']['newrevid'],
380                             'prop': 'info|revisions',
381                             'rvprop':
382                                     'ids|flags|timestamp|user|comment|content',
383                     }
384                     response = self.api.call(data)['query']['pages']
385                     self.metadir.pages_add_rv(int(pageid),
386                                               response[pageid]['revisions'][0])
387                     # need to write latest rev to file too, as text may be changed
388                     #such as a sig, e.g., -~ =>  -[[User:Reagle|Reagle]]
389                     with file(full_filename, 'w') as fd:
390                         data = response[pageid]['revisions'][0]['*']
391                         data = data.encode('utf-8')
392                         fd.write(data)
393                     if files_to_commit :
394                         print 'waiting 3s before processing the next file'
395                         time.sleep(3)
396                 else:
397                     print 'error: committing %s failed: %s' % \
398                             (filename, response['edit']['result'])

Benjamin Mako Hill || Want to submit a patch?