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

Benjamin Mako Hill || Want to submit a patch?