Mercurial > libervia-backend
comparison frontends/src/jp/common.py @ 2269:606ff34d30f2
jp (blog, common): moved and improved edit code from blog:
- a new "common" module is there for code commonly used in commands
- moved code for editing item with $EDITOR there
- moved code to identify item to edit there
- aforementioned fontions have been made generic
- a class BaseEdit is now available to implement edition
- HTTPS links are handled (only HTTP links were working before)
- item can be use if all previous methods fail (url, keyword, file path).
author | Goffi <goffi@goffi.org> |
---|---|
date | Tue, 27 Jun 2017 16:23:28 +0200 |
parents | |
children | 07caa12be945 |
comparison
equal
deleted
inserted
replaced
2268:a29d1351bc83 | 2269:606ff34d30f2 |
---|---|
1 #!/usr/bin/env python2 | |
2 # -*- coding: utf-8 -*- | |
3 | |
4 # jp: a SàT command line tool | |
5 # Copyright (C) 2009-2016 Jérôme Poisson (goffi@goffi.org) | |
6 | |
7 # This program is free software: you can redistribute it and/or modify | |
8 # it under the terms of the GNU Affero General Public License as published by | |
9 # the Free Software Foundation, either version 3 of the License, or | |
10 # (at your option) any later version. | |
11 | |
12 # This program is distributed in the hope that it will be useful, | |
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
15 # GNU Affero General Public License for more details. | |
16 | |
17 # You should have received a copy of the GNU Affero General Public License | |
18 # along with this program. If not, see <http://www.gnu.org/licenses/>. | |
19 | |
20 from sat_frontends.jp.constants import Const as C | |
21 from sat.core.i18n import _ | |
22 from sat.tools.common import regex | |
23 from sat.tools import config | |
24 from ConfigParser import NoSectionError, NoOptionError | |
25 import json | |
26 import os | |
27 import os.path | |
28 import time | |
29 import tempfile | |
30 import subprocess | |
31 import glob | |
32 | |
33 # defaut arguments used for some known editors (editing with metadata) | |
34 VIM_SPLIT_ARGS = "-c 'vsplit|wincmd w|next|wincmd w'" | |
35 EMACS_SPLIT_ARGS = '--eval "(split-window-horizontally)"' | |
36 EDITOR_ARGS_MAGIC = { | |
37 'vim': VIM_SPLIT_ARGS + ' {content_file} {metadata_file}', | |
38 'gvim': VIM_SPLIT_ARGS + ' --nofork {content_file} {metadata_file}', | |
39 'emacs': EMACS_SPLIT_ARGS + ' {content_file} {metadata_file}', | |
40 'xemacs': EMACS_SPLIT_ARGS + ' {content_file} {metadata_file}', | |
41 'nano': ' -F {content_file} {metadata_file}', | |
42 } | |
43 | |
44 SECURE_UNLINK_MAX = 10 | |
45 SECURE_UNLINK_DIR = ".backup" | |
46 METADATA_SUFF = '_metadata.json' | |
47 | |
48 | |
49 def getTmpDir(sat_conf, cat_dir, sub_dir=None): | |
50 """Return directory used to store temporary files | |
51 | |
52 @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration | |
53 @param cat_dir(str): directory of the category (e.g. "blog") | |
54 @param sub_dir(str): sub directory where data need to be put | |
55 profile can be used here, or special directory name | |
56 sub_dir will be escaped to be usable in path (use regex.pathUnescape to find | |
57 initial str) | |
58 @return (str): path to the dir | |
59 """ | |
60 local_dir = config.getConfig(sat_conf, '', 'local_dir', Exception) | |
61 path = [local_dir, cat_dir] | |
62 if sub_dir is not None: | |
63 path.append(regex.pathEscape(sub_dir)) | |
64 return os.path.join(*path) | |
65 | |
66 | |
67 class BaseEdit(object): | |
68 u"""base class for editing commands | |
69 | |
70 This class allows to edit file for PubSub or something else. | |
71 It works with temporary files in SàT local_dir, in a "cat_dir" subdir | |
72 """ | |
73 | |
74 def __init__(self, host, cat_dir, use_metadata=True): | |
75 """ | |
76 @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration | |
77 @param cat_dir(unicode): directory to use for drafts | |
78 this will be a sub-directory of SàT's local_dir | |
79 @param use_metadata(bool): True is edition need a second file for metadata | |
80 most of signature change with use_metadata with an additional metadata argument. | |
81 This is done to raise error if a command needs metadata but forget the flag, and vice versa | |
82 """ | |
83 self.host = host | |
84 self.sat_conf = config.parseMainConf() | |
85 self.cat_dir = cat_dir.encode('utf-8') | |
86 self.use_metadata = use_metadata | |
87 | |
88 def secureUnlink(self, path): | |
89 """Unlink given path after keeping it for a while | |
90 | |
91 This method is used to prevent accidental deletion of a draft | |
92 If there are more file in SECURE_UNLINK_DIR than SECURE_UNLINK_MAX, | |
93 older file are deleted | |
94 @param path(str): file to unlink | |
95 """ | |
96 if not os.path.isfile(path): | |
97 raise OSError(u"path must link to a regular file") | |
98 if not path.startswith(getTmpDir(self.sat_conf, self.cat_dir)): | |
99 self.disp(u"File {} is not in SàT temporary hierarchy, we do not remove it".format(path.decode('utf-8')), 2) | |
100 return | |
101 # we have 2 files per draft with use_metadata, so we double max | |
102 unlink_max = SECURE_UNLINK_MAX * 2 if self.use_metadata else SECURE_UNLINK_MAX | |
103 backup_dir = getTmpDir(self.sat_conf, self.cat_dir, SECURE_UNLINK_DIR) | |
104 if not os.path.exists(backup_dir): | |
105 os.makedirs(backup_dir) | |
106 filename = os.path.basename(path) | |
107 backup_path = os.path.join(backup_dir, filename) | |
108 # we move file to backup dir | |
109 self.host.disp(u"Backuping file {src} to {dst}".format( | |
110 src=path.decode('utf-8'), dst=backup_path.decode('utf-8')), 1) | |
111 os.rename(path, backup_path) | |
112 # and if we exceeded the limit, we remove older file | |
113 backup_files = [os.path.join(backup_dir, f) for f in os.listdir(backup_dir)] | |
114 if len(backup_files) > unlink_max: | |
115 backup_files.sort(key=lambda path: os.stat(path).st_mtime) | |
116 for path in backup_files[:len(backup_files) - unlink_max]: | |
117 self.host.disp(u"Purging backup file {}".format(path.decode('utf-8')), 2) | |
118 os.unlink(path) | |
119 | |
120 def runEditor(self, editor_args_opt, content_file_path, | |
121 content_file_obj, meta_file_path=None, meta_ori=None): | |
122 """run editor to edit content and metadata | |
123 | |
124 @param editor_args_opt(unicode): option in [jp] section in configuration for | |
125 specific args | |
126 @param content_file_path(str): path to the content file | |
127 @param content_file_obj(file): opened file instance | |
128 @param meta_file_path(str, None): metadata file path | |
129 if None metadata will not be used | |
130 @param meta_ori(dict, None): original cotent of metadata | |
131 can't be used if use_metadata is False | |
132 """ | |
133 if not self.use_metadata: | |
134 assert meta_file_path is None | |
135 assert meta_ori is None | |
136 | |
137 # we calculate hashes to check for modifications | |
138 import hashlib | |
139 content_file_obj.seek(0) | |
140 tmp_ori_hash = hashlib.sha1(content_file_obj.read()).digest() | |
141 content_file_obj.close() | |
142 | |
143 # we prepare arguments | |
144 editor = config.getConfig(self.sat_conf, 'jp', 'editor') or os.getenv('EDITOR', 'vi') | |
145 try: | |
146 # is there custom arguments in sat.conf ? | |
147 editor_args = config.getConfig(self.sat_conf, 'jp', editor_args_opt, Exception) | |
148 except (NoOptionError, NoSectionError): | |
149 # no, we check if we know the editor and have special arguments | |
150 editor_args = EDITOR_ARGS_MAGIC.get(os.path.basename(editor), '') | |
151 parse_kwargs = {'content_file': content_file_path} | |
152 if self.use_metadata: | |
153 parse_kwargs['metadata_file'] = meta_file_path | |
154 args = self.parse_args(editor_args, **parse_kwargs) | |
155 if not args: | |
156 args = [content_file_path] | |
157 | |
158 # actual editing | |
159 editor_exit = subprocess.call([editor] + args) | |
160 | |
161 # edition will now be checked, and data will be sent if it was a success | |
162 if editor_exit != 0: | |
163 self.disp(u"Editor exited with an error code, so temporary file has not be deleted, and item is not published.\nYou can find temporary file at {path}".format( | |
164 path=content_file_path), error=True) | |
165 else: | |
166 # main content | |
167 try: | |
168 with open(content_file_path, 'rb') as f: | |
169 content = f.read() | |
170 except (OSError, IOError): | |
171 self.disp(u"Can read file at {content_path}, have it been deleted?\nCancelling edition".format( | |
172 content_path=content_file_path), error=True) | |
173 self.host.quit(C.EXIT_NOT_FOUND) | |
174 | |
175 # metadata | |
176 if self.use_metadata: | |
177 try: | |
178 with open(meta_file_path, 'rb') as f: | |
179 metadata = json.load(f) | |
180 except (OSError, IOError): | |
181 self.disp(u"Can read file at {meta_file_path}, have it been deleted?\nCancelling edition".format( | |
182 content_path=content_file_path, meta_path=meta_file_path), error=True) | |
183 self.host.quit(C.EXIT_NOT_FOUND) | |
184 except ValueError: | |
185 self.disp(u"Can't parse metadata, please check it is correct JSON format. Cancelling edition.\n" + | |
186 "You can find tmp file at {content_path} and temporary meta file at {meta_path}.".format( | |
187 content_path=content_file_path, | |
188 meta_path=meta_file_path), error=True) | |
189 self.host.quit(C.EXIT_DATA_ERROR) | |
190 | |
191 if self.use_metadata and not C.bool(metadata.get('publish', "true")): | |
192 self.disp(u'Publication blocked by "publish" key in metadata, cancelling edition.\n\n' + | |
193 "temporary file path:\t{content_path}\nmetadata file path:\t{meta_path}".format( | |
194 content_path=content_file_path, meta_path=meta_file_path), error=True) | |
195 self.host.quit() | |
196 | |
197 if len(content) == 0: | |
198 self.disp(u"Content is empty, cancelling the blog edition") | |
199 if not content_file_path.startswith(getTmpDir(self.sat_conf, self.cat_dir)): | |
200 self.disp(u"File are not in SàT temporary hierarchy, we do not remove them", 2) | |
201 self.host.quit() | |
202 self.disp(u"Deletion of {}".format(content_file_path.decode('utf-8')), 2) | |
203 os.unlink(content_file_path) | |
204 self.disp(u"Deletion of {}".format(meta_file_path.decode('utf-8')), 2) | |
205 os.unlink(meta_file_path) | |
206 self.host.quit() | |
207 | |
208 # time to re-check the hash | |
209 elif (tmp_ori_hash == hashlib.sha1(content).digest() and | |
210 (not self.use_metadata or meta_ori == metadata)): | |
211 self.disp(u"The content has not been modified, cancelling the blog edition") | |
212 self.host.quit() | |
213 | |
214 else: | |
215 # we can now send the item | |
216 content = content.decode('utf-8-sig') # we use utf-8-sig to avoid BOM | |
217 try: | |
218 if self.use_metadata: | |
219 self.publish(content, metadata) | |
220 else: | |
221 self.publish(content) | |
222 except Exception as e: | |
223 if self.use_metadata: | |
224 self.disp(u"Error while sending your item, the temporary files have been kept at {content_path} and {meta_path}: {reason}".format( | |
225 content_path=content_file_path, meta_path=meta_file_path, reason=e), error=True) | |
226 else: | |
227 self.disp(u"Error while sending your item, the temporary file has been kept at {content_path}: {reason}".format( | |
228 content_path=content_file_path, reason=e), error=True) | |
229 self.host.quit(1) | |
230 | |
231 self.secureUnlink(content_file_path) | |
232 self.secureUnlink(meta_file_path) | |
233 | |
234 def publish(self, content): | |
235 # if metadata is needed, publish will be called with it last argument | |
236 raise NotImplementedError | |
237 | |
238 def getTmpFile(self, suff): | |
239 """Create a temporary file | |
240 | |
241 @param suff (str): suffix to use for the filename | |
242 @return (tuple(file, str)): opened (w+b) file object and file path | |
243 """ | |
244 tmp_dir = getTmpDir(self.sat_conf, self.cat_dir, self.profile.encode('utf-8')) | |
245 if not os.path.exists(tmp_dir): | |
246 try: | |
247 os.makedirs(tmp_dir) | |
248 except OSError as e: | |
249 self.disp(u"Can't create {path} directory: {reason}".format( | |
250 path=tmp_dir, reason=e), error=True) | |
251 self.host.quit(1) | |
252 try: | |
253 fd, path = tempfile.mkstemp(suffix=suff, | |
254 prefix=time.strftime(self.cat_dir.encode('utf-8') + '_%Y-%m-%d_%H:%M:%S_'), | |
255 dir=tmp_dir, text=True) | |
256 return os.fdopen(fd, 'w+b'), path | |
257 except OSError as e: | |
258 self.disp(u"Can't create temporary file: {reason}".format(reason=e), error=True) | |
259 self.host.quit(1) | |
260 | |
261 def getCurrentFile(self, profile): | |
262 """Get most recently edited file | |
263 | |
264 @param profile(unicode): profile linked to the draft | |
265 @return(str): full path of current file | |
266 """ | |
267 # we guess the blog item currently edited by choosing | |
268 # the most recent file corresponding to temp file pattern | |
269 # in tmp_dir, excluding metadata files | |
270 cat_dir_str = self.cat_dir.encode('utf-8') | |
271 tmp_dir = getTmpDir(self.sat_conf, cat_dir_str, profile.encode('utf-8')) | |
272 available = [path for path in glob.glob(os.path.join(tmp_dir, cat_dir_str + '_*')) if not path.endswith(METADATA_SUFF)] | |
273 if not available: | |
274 self.disp(u"Could not find any content draft in {path}".format(path=tmp_dir), error=True) | |
275 self.host.quit(1) | |
276 return max(available, key=lambda path: os.stat(path).st_mtime) | |
277 | |
278 def getItemData(self, service, node, item): | |
279 """return formatted content and metadata (or not if use_metadata is false)""" | |
280 raise NotImplementedError | |
281 | |
282 def getTmpSuff(self): | |
283 """return suffix used for content file""" | |
284 return 'xml' | |
285 | |
286 def getItemPath(self, item): | |
287 """retrieve item path (i.e. service and node) from item argument | |
288 | |
289 This method is obviously only useful for edition of PubSub based features | |
290 service, node and item must be named like this in args | |
291 @param item(unicode): item to get or url or magic keyword | |
292 item argument can be used to specify : | |
293 - HTTP(S) URL | |
294 - XMPP URL | |
295 - keyword, which can be: | |
296 - new: create new item | |
297 - last: retrieve last published item | |
298 - current: continue current local draft | |
299 - file path | |
300 - item id | |
301 """ | |
302 command = item.lower() | |
303 pubsub_service = self.args.service | |
304 pubsub_node = self.args.node | |
305 pubsub_item = None | |
306 | |
307 if command not in ('new', 'last', 'current'): | |
308 # we have probably an URL, we try to parse it | |
309 import urlparse | |
310 url = self.args.item | |
311 parsed_url = urlparse.urlsplit(url) | |
312 if parsed_url.scheme.startswith('http'): | |
313 self.disp(u"{} URL found, trying to find associated xmpp: URI".format(parsed_url.scheme.upper()),1) | |
314 # HTTP URL, we try to find xmpp: links | |
315 try: | |
316 from lxml import etree | |
317 except ImportError: | |
318 self.disp(u"lxml module must be installed to use http(s) scheme, please install it with \"pip install lxml\"", error=True) | |
319 self.host.quit(1) | |
320 import urllib2 | |
321 parser = etree.HTMLParser() | |
322 try: | |
323 root = etree.parse(urllib2.urlopen(url), parser) | |
324 except etree.XMLSyntaxError as e: | |
325 self.disp(_(u"Can't parse HTML page : {msg}").format(msg=e)) | |
326 links = [] | |
327 else: | |
328 links = root.xpath("//link[@rel='alternate' and starts-with(@href, 'xmpp:')]") | |
329 if not links: | |
330 self.disp(u'Could not find alternate "xmpp:" URI, can\'t find associated XMPP PubSub node/item', error=True) | |
331 self.host.quit(1) | |
332 url = links[0].get('href') | |
333 parsed_url = urlparse.urlsplit(url) | |
334 | |
335 if parsed_url.scheme == 'xmpp': | |
336 if self.args.service or self.args.node: | |
337 self.parser.error(_(u"You can't use URI and --service or --node at the same time")) | |
338 | |
339 self.disp(u"XMPP URI used: {}".format(url),2) | |
340 # XXX: if we have not xmpp: URI here, we'll take the data as a file path | |
341 pubsub_service = parsed_url.path | |
342 pubsub_data = urlparse.parse_qs(parsed_url.query) | |
343 try: | |
344 pubsub_node = pubsub_data['node'][0] | |
345 except KeyError: | |
346 self.disp(u'No node found in xmpp: URI, can\'t retrieve item', error=True) | |
347 self.host.quit(1) | |
348 pubsub_item = pubsub_data.get('item',[None])[0] | |
349 if pubsub_item is not None: | |
350 command = 'edit' # XXX: edit command is only used internaly, it similar to last, but with the item given in the URL | |
351 else: | |
352 command = 'new' | |
353 | |
354 if command in ('new', 'last', 'edit'): | |
355 # we need a temporary file | |
356 tmp_suff = '.' + self.getTmpSuff() | |
357 content_file_obj, content_file_path = self.getTmpFile(tmp_suff) | |
358 if command == 'new': | |
359 self.disp(u'Editing a new item', 2) | |
360 if self.use_metadata: | |
361 metadata = None | |
362 elif command in ('last', 'edit'): | |
363 self.disp(u'Editing requested published item', 2) | |
364 try: | |
365 if self.use_metadata: | |
366 content, metadata = self.getItemData(pubsub_service, pubsub_node, pubsub_item) | |
367 else: | |
368 content = self.getItemData(pubsub_service, pubsub_node, pubsub_item) | |
369 except Exception as e: | |
370 self.disp(u"Error while retrieving last item: {}".format(e)) | |
371 self.host.quit(1) | |
372 content_file_obj.write(content.encode('utf-8')) | |
373 content_file_obj.seek(0) | |
374 else: | |
375 if self.use_metadata: | |
376 metadata = None | |
377 if command == 'current': | |
378 # user wants to continue current draft | |
379 content_file_path = self.getCurrentFile(self.profile) | |
380 self.disp(u'Continuing edition of current draft', 2) | |
381 content_file_obj = open(content_file_path, 'r+b') | |
382 elif os.path.isfile(self.args.item): | |
383 # there is an existing draft that we use | |
384 content_file_path = os.path.expanduser(self.args.item) | |
385 content_file_obj = open(content_file_path, 'r+b') | |
386 else: | |
387 # last chance, it should be an item | |
388 tmp_suff = '.' + self.getTmpSuff() | |
389 content_file_obj, content_file_path = self.getTmpFile(tmp_suff) | |
390 | |
391 if self.use_metadata: | |
392 content, metadata = self.getItemData(pubsub_service, pubsub_node, self.args.item) | |
393 else: | |
394 content = self.getItemData(pubsub_service, pubsub_node, self.args.item) | |
395 content_file_obj.write(content.encode('utf-8')) | |
396 content_file_obj.seek(0) | |
397 | |
398 if self.use_metadata: | |
399 return pubsub_service, pubsub_node, content_file_path, content_file_obj, metadata | |
400 else: | |
401 return pubsub_service, pubsub_node, content_file_path, content_file_obj |