make display defaults more terse in keeping with hg; but add -A --all option to status
[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 
179                 range(0, len(pages), 25)]: # what does this '25' do? - reagle
180             data = {
181                     'action': 'query',
182                     'titles': '|'.join(these_pages),
183                     'prop': 'info|revisions',
184                     'rvprop': 'ids|flags|timestamp|user|comment|content',
185             }
186             response = self.api.call(data)['query']['pages']
187             # for every pageid, returns dict.keys() = {'lastrevid', 'pageid', 'title', 'counter', 'length', 'touched': u'2011-02-02T19:32:04Z', 'ns', 'revisions' {...}}
188             for pageid in response.keys():
189                 pagename = response[pageid]['title']
190                 
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                     pass
223                 else:
224                     print 'pulling:        "%s" : "%s" by "%s"' % (
225                         pagename, last_wiki_rev_comment, last_wiki_rev_user)
226                     self.metadir.pagedict_add(pagename, pageid, last_wiki_revid)
227                     self.metadir.pages_add_rv(int(pageid),
228                                               response[pageid]['revisions'][0])
229                     with file(full_filename, 'w') as fd:
230                         data = response[pageid]['revisions'][0]['*']
231                         data = data.encode('utf-8')
232                         fd.write(data)
233                         
234 class StatusCommand(CommandBase):
235
236     def __init__(self):
237         CommandBase.__init__(self, 'status', 'check repo status')
238         self.shortcuts.append('st')
239         self.parser.add_option('-A', '--all', dest='show_all', action='store_true',
240                                 default = False,
241                                 help="show all files' status")
242
243     def _do_command(self):
244         self._die_if_no_init()
245         status = self.metadir.working_dir_status()
246         for filename in status:
247
248             if not self.options.show_all and status[filename] == 'C':
249                 continue
250             else:
251                 print '%s %s' % (status[filename], filename)
252
253
254 class DiffCommand(CommandBase):
255
256     def __init__(self):
257         CommandBase.__init__(self, 'diff', 'diff wiki to working directory')
258
259     def _do_command(self):
260         self._die_if_no_init()
261         status = self.metadir.working_dir_status()
262         for filename in status:
263             if status[filename] == 'M':
264                 print self.metadir.diff_rv_to_working(
265                         mw.metadir.filename_to_pagename(filename[:-5])),
266
267
268 class MergeCommand(CommandBase):
269     def __init__(self):
270         usage = '[FILES]'
271         CommandBase.__init__(self, 'merge', 'merge local and wiki copies', usage)
272
273     def _do_command(self):
274         self._die_if_no_init()
275         self.merge_tool = self.metadir.config.get('merge', 'tool')
276         status = self.metadir.working_dir_status()
277         for filename in status:
278             if status[filename] == 'M':
279                 full_filename = os.path.join(self.metadir.root, filename)
280                 pagename = mw.metadir.filename_to_pagename(filename[:-5])
281                 # mv local to filename.wiki.local
282                 os.rename(full_filename, full_filename + '.local')
283                 # pull wiki copy
284                 pull_command = PullCommand()
285                 pull_command.args = [pagename.encode('utf-8')]
286                 pull_command._do_command()
287                 # mv remote to filename.wiki.remote
288                 os.rename(full_filename, full_filename + '.remote')
289                 # Open merge tool
290                 merge_command = self.merge_tool % (full_filename + '.local', 
291                     full_filename + '.remote', full_filename + '.merge')
292                 subprocess.call(merge_command.split(' '))
293                 # mv filename.merge filename and delete tmp files
294                 os.rename(full_filename + '.merge', full_filename)
295                 os.remove(full_filename + '.local')
296                 os.remove(full_filename + '.remote')
297                 # mw ci pagename
298                 commit_command = CommitCommand()
299                 commit_command.args = [pagename.encode('utf-8')]
300                 commit_command._do_command()
301
302
303 class CommitCommand(CommandBase):
304
305     def __init__(self):
306         usage = '[FILES]'
307         CommandBase.__init__(self, 'commit', 'commit changes to wiki', usage)
308         self.shortcuts.append('ci')
309         self.parser.add_option('-m', '--message', dest='edit_summary',
310                                help='don\'t prompt for edit summary and '
311                                'use this instead')
312         self.parser.add_option('-b', '--bot', dest='bot', action='store_true',
313                                help='mark actions as a bot (won\'t affect '
314                                'anything if you don\'t have the bot right',
315                                default=False)
316
317     def _do_command(self):
318         self._die_if_no_init()
319         self._api_setup()
320         files_to_commit = 0 # how many files to process
321         status = self.metadir.working_dir_status(files=self.args)
322         for filename in status:
323             print '%s %s' % (status[filename], filename)
324             if status[filename] in ['M']:
325                 files_to_commit += 1
326         if not files_to_commit:
327             print 'nothing to commit'
328             sys.exit()
329         if self.options.edit_summary == None:
330             print 'Edit summary:',
331             edit_summary = raw_input()
332         else:
333             edit_summary = self.options.edit_summary
334         for filename in status:
335             if status[filename] in ['M']:
336                 files_to_commit -= 1
337                 # get edit token
338                 data = {
339                         'action': 'query',
340                         'prop': 'info|revisions',
341                         'intoken': 'edit',
342                         'titles': mw.metadir.filename_to_pagename(filename[:-5]),
343                 }
344                 response = self.api.call(data)
345                 pages = response['query']['pages']
346                 pageid = pages.keys()[0]
347                 revid = pages[pageid]['revisions'][0]['revid']
348                 awaitedrevid = \
349                         self.metadir.pages_get_rv_list({'id': pageid})[0]
350                 if revid != awaitedrevid:
351                     print 'warning: edit conflict detected on "%s" (%s -> %s) ' \
352                             '-- skipping! (try merge)' % (filename, awaitedrevid, revid)
353                     continue
354                 edittoken = pages[pageid]['edittoken']
355                 full_filename = os.path.join(self.metadir.root, filename)
356                 text = codecs.open(full_filename, 'r', 'utf-8').read()
357                 text = text.encode('utf-8')
358                 if (len(text) != 0) and (text[-1] == '\n'):
359                     text = text[:-1]
360                 md5 = hashlib.md5()
361                 md5.update(text)
362                 textmd5 = md5.hexdigest()
363                 data = {
364                         'action': 'edit',
365                         'title': mw.metadir.filename_to_pagename(filename[:-5]),
366                         'token': edittoken,
367                         'text': text,
368                         'md5': textmd5,
369                         'summary': edit_summary,
370                 }
371                 if self.options.bot:
372                     data['bot'] = 'bot'
373                 response = self.api.call(data)
374                 if 'error' in response:
375                     if 'code' in response['error']:
376                         if response['error']['code'] == 'permissiondenied':
377                             print 'Permission denied -- try running "mw login"'
378                             return
379                 if response['edit']['result'] == 'Success':
380                     if 'nochange' in response['edit']:
381                         print 'warning: no changes detected in %s - ' \
382                                 'skipping and removing ending LF' % filename
383                         pagename = mw.metadir.filename_to_pagename(filename[:-5])
384                         self.metadir.clean_page(pagename)
385                         continue
386                     if response['edit']['oldrevid'] != revid:
387                         print 'warning: edit conflict detected on %s (%s -> %s) ' \
388                                 '-- skipping!' % (file, 
389                                 response['edit']['oldrevid'], revid)
390                         continue
391                     data = {
392                             'action': 'query',
393                             'revids': response['edit']['newrevid'],
394                             'prop': 'info|revisions',
395                             'rvprop':
396                                     'ids|flags|timestamp|user|comment|content',
397                     }
398                     response = self.api.call(data)['query']['pages']
399                     self.metadir.pages_add_rv(int(pageid),
400                                               response[pageid]['revisions'][0])
401                     # need to write latest rev to file too, as text may be changed
402                     #such as a sig, e.g., -~ =>  -[[User:Reagle|Reagle]]
403                     with file(full_filename, 'w') as fd:
404                         data = response[pageid]['revisions'][0]['*']
405                         data = data.encode('utf-8')
406                         fd.write(data)
407                     if files_to_commit :
408                         print 'waiting 3s before processing the next file'
409                         time.sleep(3)
410                 else:
411                     print 'error: committing %s failed: %s' % \
412                             (filename, response['edit']['result'])

Benjamin Mako Hill || Want to submit a patch?