commit needs to write latest rev to file too, as text may be changed such as a sig...
[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 sys
28 import time
29
30
31 class CommandBase(object):
32
33     def __init__(self, name, description, usage=None):
34         self.me = os.path.basename(sys.argv[0])
35         self.description = description
36         if usage is None:
37             usage = '%prog ' + name
38         else:
39             usage = '%%prog %s %s' % (name, usage)
40         self.parser = OptionParser(usage=usage, description=description)
41         self.name = name
42         self.metadir = mw.metadir.Metadir()
43         self.shortcuts = []
44
45     def main(self):
46         (self.options, self.args) = self.parser.parse_args()
47         self.args = self.args[1:]  # don't need the first thing
48         self._do_command()
49
50     def _do_command(self):
51         pass
52
53     def _login(self):
54         user = raw_input('Username: ')
55         passwd = getpass.getpass()
56         result = self.api.call({'action': 'login',
57                                 'lgname': user,
58                                 'lgpassword': passwd})
59         if result['login']['result'] == 'Success':
60             # cookies are saved to a file
61             print 'Login successful! (yay)'
62         elif result['login']['result'] == 'NeedToken':
63             print 'Login with token'
64             result = self.api.call({'action': 'login',
65                                     'lgname': user,
66                                     'lgpassword': passwd,
67                                     'lgtoken': result['login']['token']})
68             if result['login']['result'] == 'Success':
69                 print 'Login successful! (yay)'
70             else:
71                 print 'Login failed: %s' % result['login']['result']
72         else:
73             print 'Login failed: %s' % result['login']['result']
74
75     def _die_if_no_init(self):
76         if self.metadir.config is None:
77             print '%s: not a mw repo' % self.me
78             sys.exit(1)
79
80     def _api_setup(self):
81         cookie_filename = os.path.join(self.metadir.location, 'cookies')
82         self.api_url = self.metadir.config.get('remote', 'api_url')
83         self.api = simplemediawiki.MediaWiki(self.api_url,
84                                              cookie_file=cookie_filename)
85
86
87 class InitCommand(CommandBase):
88
89     def __init__(self):
90         usage = 'API_URL'
91         CommandBase.__init__(self, 'init', 'start a mw repo', usage)
92
93     def _do_command(self):
94         if len(self.args) < 1:
95             self.parser.error('must have URL to remote api.php')
96         elif len(self.args) > 1:
97             self.parser.error('too many arguments')
98         self.metadir.create(self.args[0])
99
100
101 class LoginCommand(CommandBase):
102
103     def __init__(self):
104         CommandBase.__init__(self, 'login', 'authenticate with wiki')
105
106     def _do_command(self):
107         self._die_if_no_init()
108         self._api_setup()
109         self._login()
110
111
112 class LogoutCommand(CommandBase):
113
114     def __init__(self):
115         CommandBase.__init__(self, 'logout', 'forget authentication')
116
117     def _do_command(self):
118         self._die_if_no_init()
119         try:
120             os.unlink(os.path.join(self.metadir.location, 'cookies'))
121         except OSError:
122             pass
123
124
125 class PullCategoryMembersCommand(CommandBase):
126
127     def __init__(self):
128         usage = '[options] PAGENAME ...'
129         CommandBase.__init__(self, 'pullcat', 'add remote pages to repo '
130                              'belonging to the given category', usage)
131
132     def _do_command(self):
133         self._die_if_no_init()
134         self._api_setup()
135         pages = []
136         pages += self.args
137         for these_pages in [pages[i:i + 25] for i in range(0, len(pages), 25)]:
138             data = {
139                 'action': 'query',
140                 'gcmtitle': '|'.join(these_pages),
141                 'generator': 'categorymembers',
142                 'gcmlimit': 500
143             }
144         response = self.api.call(data)['query']['pages']
145         for pageid in response.keys():
146             pagename = response[pageid]['title']
147             print pagename
148             pullc = PullCommand()
149             pullc.args = [pagename.encode('utf-8')]
150             pullc._do_command()
151
152
153 class PullCommand(CommandBase):
154     
155     def __init__(self):
156         usage = '[options] PAGENAME ...'
157         CommandBase.__init__(self, 'pull', 'add remote pages to repo', usage)
158
159     def _do_command(self):
160         self._die_if_no_init()
161         self._api_setup()
162         pages = []
163         pages += self.args
164
165         # Pull should work with pagename, filename, or working directory
166         converted_pages = []
167         if pages == []:
168             pages = self.metadir.working_dir_status().keys()
169         for pagename in pages:
170             if '.wiki' in pagename:
171                 converted_pages.append(
172                     mw.metadir.filename_to_pagename(pagename[:-5]))
173             else:
174                 converted_pages.append(pagename)
175         pages = converted_pages
176
177         for these_pages in [pages[i:i + 25] for i in range(0, len(pages), 25)]: # XXX ?
178             data = {
179                     'action': 'query',
180                     'titles': '|'.join(these_pages),
181                     'prop': 'info|revisions',
182                     'rvprop': 'ids|flags|timestamp|user|comment|content',
183             }
184             response = self.api.call(data)['query']['pages']
185             # for every pageid, returns dict.keys() = {'lastrevid', 'pageid', 'title', 'counter', 'length', 'touched': u'2011-02-02T19:32:04Z', 'ns', 'revisions' {...}}
186             for pageid in response.keys():
187                 pagename = response[pageid]['title']
188                 
189                 # XXX is the revisions list a sorted one, should I use [0] or [-1]?
190                 last_wiki_rev_comment = response[pageid]['revisions'][0]['comment']
191                 last_wiki_rev_user = response[pageid]['revisions'][0]['user']
192                 
193                 # check if working file is modified or if wiki page doesn't exists
194                 status = self.metadir.working_dir_status()
195                 filename = mw.metadir.pagename_to_filename(pagename)
196                 full_filename = os.path.join(self.metadir.root, filename + '.wiki')
197                 if filename + '.wiki' in status and \
198                     status[filename + '.wiki' ] in ['M']:
199                     print 'skipping:       %s -- uncommitted modifications ' % (pagename)
200                     continue
201                 if 'missing' in response[pageid].keys():
202                     print '%s: %s: page does not exist, file not created' % \
203                             (self.me, pagename)
204                     continue
205
206                 wiki_revids = sorted([x['revid'] for x in response[pageid]['revisions']])
207                 last_wiki_revid = wiki_revids[-1]
208                 working_revids = sorted(self.metadir.pages_get_rv_list({'id' : pageid}))
209                 last_working_revid = working_revids[-1]
210                 if ( os.path.exists(full_filename) and 
211                         last_wiki_revid == last_working_revid):
212                     print 'wiki unchanged: %s' % (pagename)
213                 else:
214                     print 'pulling:        %s : %s -- %s' % (
215                         pagename, last_wiki_rev_comment, last_wiki_rev_user)
216                     self.metadir.pagedict_add(pagename, pageid, last_wiki_revid)
217                     self.metadir.pages_add_rv(int(pageid),
218                                               response[pageid]['revisions'][0])
219                     with file(full_filename, 'w') as fd:
220                         data = response[pageid]['revisions'][0]['*']
221                         data = data.encode('utf-8')
222                         fd.write(data)
223
224
225 class StatusCommand(CommandBase):
226
227     def __init__(self):
228         CommandBase.__init__(self, 'status', 'check repo status')
229         self.shortcuts.append('st')
230
231     def _do_command(self):
232         self._die_if_no_init()
233         status = self.metadir.working_dir_status()
234         for filename in status:
235             print '%s %s' % (status[filename], filename)
236
237
238 class DiffCommand(CommandBase):
239
240     def __init__(self):
241         CommandBase.__init__(self, 'diff', 'diff wiki to working directory')
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             if status[filename] == 'M':
248                 print self.metadir.diff_rv_to_working(
249                         mw.metadir.filename_to_pagename(filename[:-5])),
250
251
252 class CommitCommand(CommandBase):
253
254     def __init__(self):
255         usage = '[FILES]'
256         CommandBase.__init__(self, 'commit', 'commit changes to wiki', usage)
257         self.shortcuts.append('ci')
258         self.parser.add_option('-m', '--message', dest='edit_summary',
259                                help='don\'t prompt for edit summary and '
260                                'use this instead')
261         self.parser.add_option('-b', '--bot', dest='bot', action='store_true',
262                                help='mark actions as a bot (won\'t affect '
263                                'anything if you don\'t have the bot right',
264                                default=False)
265
266     def _do_command(self):
267         self._die_if_no_init()
268         self._api_setup()
269         status = self.metadir.working_dir_status(files=self.args)
270         nothing_to_commit = True
271         for filename in status:
272             print '%s %s' % (status[filename], filename)
273             if status[filename] in ['M']:
274                 nothing_to_commit = False
275         if nothing_to_commit:
276             print 'nothing to commit'
277             sys.exit()
278         if self.options.edit_summary == None:
279             print 'Edit summary:',
280             edit_summary = raw_input()
281         else:
282             edit_summary = self.options.edit_summary
283         for file_num, filename in enumerate(status):
284             if status[filename] in ['M']:
285                 # get edit token
286                 data = {
287                         'action': 'query',
288                         'prop': 'info|revisions',
289                         'intoken': 'edit',
290                         'titles': mw.metadir.filename_to_pagename(filename[:-5]),
291                 }
292                 response = self.api.call(data)
293                 pages = response['query']['pages']
294                 pageid = pages.keys()[0]
295                 revid = pages[pageid]['revisions'][0]['revid']
296                 awaitedrevid = \
297                         self.metadir.pages_get_rv_list({'id': pageid})[0]
298                 if revid != awaitedrevid:
299                     print 'warning: edit conflict detected on %s (%s -> %s) ' \
300                             '-- skipping!' % (file, awaitedrevid, revid)
301                     continue
302                 edittoken = pages[pageid]['edittoken']
303                 full_filename = os.path.join(self.metadir.root, filename)
304                 text = codecs.open(full_filename, 'r', 'utf-8').read()
305                 text = text.encode('utf-8')
306                 if (len(text) != 0) and (text[-1] == '\n'):
307                     text = text[:-1]
308                 md5 = hashlib.md5()
309                 md5.update(text)
310                 textmd5 = md5.hexdigest()
311                 data = {
312                         'action': 'edit',
313                         'title': mw.metadir.filename_to_pagename(filename[:-5]),
314                         'token': edittoken,
315                         'text': text,
316                         'md5': textmd5,
317                         'summary': edit_summary,
318                 }
319                 if self.options.bot:
320                     data['bot'] = 'bot'
321                 response = self.api.call(data)
322                 if 'error' in response:
323                     if 'code' in response['error']:
324                         if response['error']['code'] == 'permissiondenied':
325                             print 'Permission denied -- try running "mw login"'
326                             return
327                 if response['edit']['result'] == 'Success':
328                     if 'nochange' in response['edit']:
329                         print 'warning: no changes detected in %s - ' \
330                                 'skipping and removing ending LF' % filename
331                         pagename = mw.metadir.filename_to_pagename(filename[:-5])
332                         self.metadir.clean_page(pagename)
333                         continue
334                     if response['edit']['oldrevid'] != revid:
335                         print 'warning: edit conflict detected on %s (%s -> %s) ' \
336                                 '-- skipping!' % (file, 
337                                 response['edit']['oldrevid'], revid)
338                         continue
339                     data = {
340                             'action': 'query',
341                             'revids': response['edit']['newrevid'],
342                             'prop': 'info|revisions',
343                             'rvprop':
344                                     'ids|flags|timestamp|user|comment|content',
345                     }
346                     response = self.api.call(data)['query']['pages']
347                     self.metadir.pages_add_rv(int(pageid),
348                                               response[pageid]['revisions'][0])
349                     # need to write latest rev to file too, as text may be changed
350                     # such as a signature
351                     with file(full_filename, 'w') as fd:
352                         data = response[pageid]['revisions'][0]['*']
353                         data = data.encode('utf-8')
354                         fd.write(data)
355                     if file_num != len(status) - 1:
356                         print 'waiting 3s before processing the next file'
357                         time.sleep(3)
358                 else:
359                     print 'error: committing %s failed: %s' % \
360                             (filename, response['edit']['result'])

Benjamin Mako Hill || Want to submit a patch?