diff options
author | Mike Crute <mike@crute.us> | 2017-09-29 16:04:13 +0000 |
---|---|---|
committer | Mike Crute <mike@crute.us> | 2017-09-29 16:04:13 +0000 |
commit | fe7a38fcdcda907e3fe922fda6ef1595f365ba41 (patch) | |
tree | 638a09b61147a2395408306f67d8d05f83fddc52 | |
download | viwiki-master.tar.bz2 viwiki-master.tar.xz viwiki-master.zip |
-rwxr-xr-x | viwiki | 300 |
1 files changed, 300 insertions, 0 deletions
@@ -0,0 +1,300 @@ | |||
1 | #!/usr/bin/env python2.7 | ||
2 | """ | ||
3 | Wiki API and Editor | ||
4 | by Mike Crute (mcrute@gmail.com) | ||
5 | |||
6 | This module contains a full API for MoinMoin as well as a shim to download | ||
7 | pages, present them in vim and upload the changes. Great care has been taken to | ||
8 | ensure that this script relies only on the python standard library and that it | ||
9 | is compatible with both Python 2.7+ and Python 3.0+ | ||
10 | |||
11 | This module is usually never on the pythonpath for a system but can be loaded | ||
12 | into other python scripts for use by copying this function into their code. | ||
13 | |||
14 | def get_wiki(path): | ||
15 | import imp, os.path | ||
16 | return imp.load_module('wiki', open(os.path.expanduser(path)), path, | ||
17 | [s for s in imp.get_suffixes() if s[0] == '.py'][0]) | ||
18 | |||
19 | To get a programatic handle to the API create a class like this which will | ||
20 | parse ~/.wikivimrc and use the login info from the [wiki_name] key. | ||
21 | |||
22 | >>> wiki = get_wiki('~/bin/viwiki') | ||
23 | >>> api = wiki.WikiAPI(*wiki.get_credentials('wiki_name')) | ||
24 | |||
25 | The API supports attachment handling but a separate program called wikiattach | ||
26 | is used for managing attachments from the command line. | ||
27 | """ | ||
28 | |||
29 | import re | ||
30 | import os | ||
31 | import imp | ||
32 | import sys | ||
33 | import stat | ||
34 | import os.path | ||
35 | import tempfile | ||
36 | import subprocess | ||
37 | from codecs import open | ||
38 | from optparse import OptionParser | ||
39 | |||
40 | try: | ||
41 | from cStringIO import StringIO as BytesIO | ||
42 | from ConfigParser import SafeConfigParser | ||
43 | from xmlrpclib import ServerProxy, MultiCall, Fault, Binary | ||
44 | except ImportError: | ||
45 | from io import BytesIO | ||
46 | from configparser import SafeConfigParser | ||
47 | from xmlrpc.client import ServerProxy, MultiCall, Fault, Binary | ||
48 | |||
49 | |||
50 | def get_wiki_config(wiki_name=None): | ||
51 | """Parses wiki config file and provides config dictionary | ||
52 | |||
53 | Parses the ~/.wikivimrc file and returns a dictionary of config values for | ||
54 | the named wiki. If no wiki_name is passed the function will use the first | ||
55 | wiki defined in the file. | ||
56 | """ | ||
57 | config = SafeConfigParser() | ||
58 | config.read(os.path.expanduser("~/.wikivimrc")) | ||
59 | |||
60 | section = wiki_name if wiki_name else config.sections()[0] | ||
61 | |||
62 | return dict( | ||
63 | (key, config.get(section, key)) | ||
64 | for key in config.options(section)) | ||
65 | |||
66 | |||
67 | def get_wiki_api(wiki_name=None): | ||
68 | """Parses wiki config file and provides wiki API class | ||
69 | |||
70 | Parses the config file and looks for an `api` key in the wiki config. If | ||
71 | that is specified it must contain a string of wiki class name, colon, path | ||
72 | to wiki class file per the example below. That class will be imported and | ||
73 | returned, otherwise the default MoinMoin wiki API is returned. | ||
74 | """ | ||
75 | config = get_wiki_config(wiki_name) | ||
76 | api = config.get("api", "WikiAPI").split(":") | ||
77 | |||
78 | if len(api) == 2: | ||
79 | module = imp.load_module( | ||
80 | 'wiki_api', open(os.path.expanduser(api[1])), api[1], | ||
81 | [s for s in imp.get_suffixes() if s[0] == '.py'][0]) | ||
82 | return getattr(module, api[0]) | ||
83 | else: | ||
84 | return globals()[api[0]] | ||
85 | |||
86 | |||
87 | def get_credentials(wiki_name=None): | ||
88 | """Parses wiki config file and provides credentials tuple | ||
89 | |||
90 | Parses the ~/.wikivimrc file and returns a credentials tuple suitable for | ||
91 | unpacking directly into to the WikiAPI constructor. | ||
92 | |||
93 | If no wiki_name is passed the function will use the first wiki defined in | ||
94 | the file. | ||
95 | |||
96 | The format of the file is: | ||
97 | [wikiname] | ||
98 | host = https://example.com/ | ||
99 | username = foobear | ||
100 | password = secret | ||
101 | """ | ||
102 | config = get_wiki_config(wiki_name) | ||
103 | return (config["host"], config.get("username"), config.get("password")) | ||
104 | |||
105 | |||
106 | class WikiAPI(object): | ||
107 | """Low Level Wiki API | ||
108 | |||
109 | This class wraps the low level XMLRPC wiki interface and exposes higher | ||
110 | level methods that do specific tasks in a little bit more python friendly | ||
111 | way. This assumes a private wiki and requires credentials. | ||
112 | """ | ||
113 | |||
114 | DEFAULT_FORMAT = "moin" | ||
115 | |||
116 | def __init__(self, host, username, password): | ||
117 | self.wiki = ServerProxy("{0}?action=xmlrpc2".format(host)) | ||
118 | self.token = self.wiki.getAuthToken(username, password) | ||
119 | |||
120 | def __call__(self, method, *args): | ||
121 | proxy = MultiCall(self.wiki) | ||
122 | proxy.applyAuthToken(self.token) | ||
123 | getattr(proxy, method)(*args) | ||
124 | results = tuple(proxy()) | ||
125 | |||
126 | if results[0] != "SUCCESS": | ||
127 | raise Exception("Authentication failed!") | ||
128 | |||
129 | return results[1] | ||
130 | |||
131 | def page_format(self, page, contents): | ||
132 | """Determine file format of a page | ||
133 | """ | ||
134 | format = re.findall("#format (.*)", contents) | ||
135 | format = format[0] if len(format) else self.DEFAULT_FORMAT | ||
136 | return self.DEFAULT_FORMAT if format == "wiki" else format | ||
137 | |||
138 | def search(self, term): | ||
139 | # http://hg.moinmo.in/moin/1.9/file/tip/MoinMoin/xmlrpc/__init__.py#l683 | ||
140 | return [page[0] for page in | ||
141 | self("searchPagesEx", term, "text", 0, False, 0, False)] | ||
142 | |||
143 | def get_page(self, page_name): | ||
144 | try: | ||
145 | return self("getPage", page_name).decode("utf-8") | ||
146 | except Fault as error: | ||
147 | if error.faultString == "No such page was found.": | ||
148 | return "" | ||
149 | |||
150 | def put_page(self, page_name, contents): | ||
151 | try: | ||
152 | return self("putPage", page_name, contents.encode("utf-8")) | ||
153 | except Fault as error: | ||
154 | print(error) | ||
155 | return False | ||
156 | |||
157 | def list_attachments(self, page_name): | ||
158 | return self("listAttachments", page_name) | ||
159 | |||
160 | def get_attachment(self, page_name, attachment): | ||
161 | return BytesIO(self("getAttachment", page_name, attachment).data) | ||
162 | |||
163 | def put_attachment(self, page_name, name, contents): | ||
164 | data = Binary(contents.read()) | ||
165 | self("putAttachment", page_name, name, data) | ||
166 | |||
167 | def delete_attachment(self, page_name, attachment): | ||
168 | self("deleteAttachment", page_name, attachment) | ||
169 | |||
170 | |||
171 | class WikiEditor(object): | ||
172 | """Wiki Page Command Line Editor | ||
173 | |||
174 | This class handles editing and updating wiki pages based on arguments | ||
175 | passed on the command line. It requires a connected WikiAPI instance to do | ||
176 | its dirty work. | ||
177 | """ | ||
178 | |||
179 | EDITOR_OPTIONS = { | ||
180 | "vim": "vim -c ':set wrap spell ft={format}' {filename}", | ||
181 | } | ||
182 | |||
183 | def __init__(self, wiki_api, editor=None): | ||
184 | self.api = wiki_api | ||
185 | self.editor = editor if editor else os.environ.get("EDITOR", "vim") | ||
186 | self.editor = self.EDITOR_OPTIONS.get(self.editor, self.editor) | ||
187 | |||
188 | @staticmethod | ||
189 | def _get_tempfile(): | ||
190 | _, filename = tempfile.mkstemp() | ||
191 | return filename, open(filename, "w", "utf-8") | ||
192 | |||
193 | @staticmethod | ||
194 | def _file_last_updated(filename): | ||
195 | return os.stat(filename).st_mtime | ||
196 | |||
197 | def download_page(self, page): | ||
198 | page_contents = self.api.get_page(page) | ||
199 | |||
200 | filename, open_file = self._get_tempfile() | ||
201 | format = self.api.page_format(page, page_contents) | ||
202 | |||
203 | with open_file as fh: | ||
204 | fh.write(page_contents) | ||
205 | |||
206 | return filename, format | ||
207 | |||
208 | def upload_page(self, page, filename, timestamp): | ||
209 | updated_last = self._file_last_updated(filename) | ||
210 | |||
211 | if timestamp == updated_last: | ||
212 | print("Nothing changed") | ||
213 | return | ||
214 | |||
215 | with open(filename, "r", "utf-8") as fh: | ||
216 | contents = fh.read() | ||
217 | |||
218 | try: | ||
219 | if not self.api.put_page(page, contents): | ||
220 | raise Exception("failed to write page") | ||
221 | except: | ||
222 | print("Failed to save page.") | ||
223 | print("Contents exist in {!r}".format(filename)) | ||
224 | return | ||
225 | finally: | ||
226 | del contents | ||
227 | |||
228 | os.unlink(filename) | ||
229 | |||
230 | def edit_page(self, page): | ||
231 | filename, format = self.download_page(page) | ||
232 | updated_first = self._file_last_updated(filename) | ||
233 | |||
234 | subprocess.call( | ||
235 | self.editor.format(format=format, filename=filename), | ||
236 | shell=True) | ||
237 | |||
238 | self.upload_page(page, filename, updated_first) | ||
239 | |||
240 | |||
241 | class NonInteractiveEditor(WikiEditor): | ||
242 | """Non-Interactive Wiki Page Editor | ||
243 | |||
244 | This class handles editing and updating wiki pages based on arguments | ||
245 | passed on the command line. It requires a connected WikiAPI instance to do | ||
246 | its dirty work. | ||
247 | """ | ||
248 | |||
249 | @staticmethod | ||
250 | def get_input(arg=None): | ||
251 | # cat foo.txt | viwiki PageName | ||
252 | # viwiki PageName < foo.txt | ||
253 | if (stat.S_IFMT(os.fstat(sys.stdin.fileno()).st_mode) | ||
254 | in (stat.S_IFREG, stat.S_IFIFO)): | ||
255 | if hasattr(sys.stdin, 'buffer'): | ||
256 | return sys.stdin.buffer | ||
257 | else: | ||
258 | return sys.stdin | ||
259 | |||
260 | if not arg: | ||
261 | return None | ||
262 | |||
263 | # viwiki PageName <(cat foo.txt) | ||
264 | # viwiki PageName /path/to/foo.txt | ||
265 | if os.path.exists(arg): | ||
266 | return open(arg, 'rb') | ||
267 | |||
268 | def edit_page(self, args): | ||
269 | input = self.get_input(args[1] if len(args) == 2 else None) | ||
270 | |||
271 | if input: | ||
272 | self.api.put_page(args[0], input.read().decode("utf-8")) | ||
273 | else: | ||
274 | sys.stdout.write(self.api.get_page(args[0]).encode("utf-8")) | ||
275 | sys.stdout.flush() | ||
276 | |||
277 | |||
278 | def main(default_wiki=None): | ||
279 | parser = OptionParser() | ||
280 | parser.add_option("-n", "--non-interactive", dest="interactive", | ||
281 | help="Don't start vim", action="store_false", default=True) | ||
282 | parser.add_option("-w", "--wiki", dest="wiki", default=default_wiki, | ||
283 | help="Wiki config to use") | ||
284 | options, args = parser.parse_args() | ||
285 | |||
286 | api_class = get_wiki_api(options.wiki) | ||
287 | api = api_class(*get_credentials(options.wiki)) | ||
288 | |||
289 | if len(args) == 0: | ||
290 | print("usage: viwiki <page name>") | ||
291 | return 1 | ||
292 | |||
293 | if options.interactive: | ||
294 | WikiEditor(api).edit_page(args[0]) | ||
295 | else: | ||
296 | NonInteractiveEditor(api).edit_page(args) | ||
297 | |||
298 | |||
299 | if __name__ == "__main__": | ||
300 | main() | ||