8a7971dc2bbd71a7d99b202193c95540776a0b66
[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                 subprocess.call([self.merge_tool, full_filename + '.local', 
277                     full_filename + '.remote', '-o', full_filename + '.merge'])
278                 # mv filename.merge filename and delete tmp files
279                 os.rename(full_filename + '.merge', full_filename)
280                 os.remove(full_filename + '.local')
281                 os.remove(full_filename + '.remote')
282                 # mw ci pagename
283                 commit_command = CommitCommand()
284                 commit_command.args = [pagename.encode('utf-8')]
285                 commit_command._do_command()
286
287
288 class CommitCommand(CommandBase):
289
290     def __init__(self):
291         usage = '[FILES]'
292         CommandBase.__init__(self, 'commit', 'commit changes to wiki', usage)
293         self.shortcuts.append('ci')
294         self.parser.add_option('-m', '--message', dest='edit_summary',
295                                help='don\'t prompt for edit summary and '
296                                'use this instead')
297         self.parser.add_option('-b', '--bot', dest='bot', action='store_true',
298                                help='mark actions as a bot (won\'t affect '
299                                'anything if you don\'t have the bot right',
300                                default=False)
301
302     def _do_command(self):
303         self._die_if_no_init()
304         self._api_setup()
305         status = self.metadir.working_dir_status(files=self.args)
306         nothing_to_commit = True
307         for filename in status:
308             print '%s %s' % (status[filename], filename)
309             if status[filename] in ['M']:
310                 nothing_to_commit = False
311         if nothing_to_commit:
312             print 'nothing to commit'
313             sys.exit()
314         if self.options.edit_summary == None:
315             print 'Edit summary:',
316             edit_summary = raw_input()
317         else:
318             edit_summary = self.options.edit_summary
319         for file_num, filename in enumerate(status):
320             if status[filename] in ['M']:
321                 # get edit token
322                 data = {
323                         'action': 'query',
324                         'prop': 'info|revisions',
325                         'intoken': 'edit',
326                         'titles': mw.metadir.filename_to_pagename(filename[:-5]),
327                 }
328                 response = self.api.call(data)
329                 pages = response['query']['pages']
330                 pageid = pages.keys()[0]
331                 revid = pages[pageid]['revisions'][0]['revid']
332                 awaitedrevid = \
333                         self.metadir.pages_get_rv_list({'id': pageid})[0]
334                 if revid != awaitedrevid:
335                     print 'warning: edit conflict detected on "%s" (%s -> %s) ' \
336                             '-- skipping! (try merge)' % (filename, awaitedrevid, revid)
337                     continue
338                 edittoken = pages[pageid]['edittoken']
339                 full_filename = os.path.join(self.metadir.root, filename)
340                 text = codecs.open(full_filename, 'r', 'utf-8').read()
341                 text = text.encode('utf-8')
342                 if (len(text) != 0) and (text[-1] == '\n'):
343                     text = text[:-1]
344                 md5 = hashlib.md5()
345                 md5.update(text)
346                 textmd5 = md5.hexdigest()
347                 data = {
348                         'action': 'edit',
349                         'title': mw.metadir.filename_to_pagename(filename[:-5]),
350                         'token': edittoken,
351                         'text': text,
352                         'md5': textmd5,
353                         'summary': edit_summary,
354                 }
355                 if self.options.bot:
356                     data['bot'] = 'bot'
357                 response = self.api.call(data)
358                 if 'error' in response:
359                     if 'code' in response['error']:
360                         if response['error']['code'] == 'permissiondenied':
361                             print 'Permission denied -- try running "mw login"'
362                             return
363                 if response['edit']['result'] == 'Success':
364                     if 'nochange' in response['edit']:
365                         print 'warning: no changes detected in %s - ' \
366                                 'skipping and removing ending LF' % filename
367                         pagename = mw.metadir.filename_to_pagename(filename[:-5])
368                         self.metadir.clean_page(pagename)
369                         continue
370                     if response['edit']['oldrevid'] != revid:
371                         print 'warning: edit conflict detected on %s (%s -> %s) ' \
372                                 '-- skipping!' % (file, 
373                                 response['edit']['oldrevid'], revid)
374                         continue
375                     data = {
376                             'action': 'query',
377                             'revids': response['edit']['newrevid'],
378                             'prop': 'info|revisions',
379                             'rvprop':
380                                     'ids|flags|timestamp|user|comment|content',
381                     }
382                     response = self.api.call(data)['query']['pages']
383                     self.metadir.pages_add_rv(int(pageid),
384                                               response[pageid]['revisions'][0])
385                     # need to write latest rev to file too, as text may be changed
386                     # such as a sig, e.g., -~ =>  -[[User:Reagle|Reagle]]
387                     with file(full_filename, 'w') as fd:
388                         data = response[pageid]['revisions'][0]['*']
389                         data = data.encode('utf-8')
390                         fd.write(data)
391                     if file_num != len(status) - 1:
392                         print 'waiting 3s before processing the next file'
393                         time.sleep(3)
394                 else:
395                     print 'error: committing %s failed: %s' % \
396                             (filename, response['edit']['result'])

Benjamin Mako Hill || Want to submit a patch?