Mercurial > libervia-backend
comparison sat_frontends/jp/cmd_blog.py @ 2562:26edcf3a30eb
core, setup: huge cleaning:
- moved directories from src and frontends/src to sat and sat_frontends, which is the recommanded naming convention
- move twisted directory to root
- removed all hacks from setup.py, and added missing dependencies, it is now clean
- use https URL for website in setup.py
- removed "Environment :: X11 Applications :: GTK", as wix is deprecated and removed
- renamed sat.sh to sat and fixed its installation
- added python_requires to specify Python version needed
- replaced glib2reactor which use deprecated code by gtk3reactor
sat can now be installed directly from virtualenv without using --system-site-packages anymore \o/
author | Goffi <goffi@goffi.org> |
---|---|
date | Mon, 02 Apr 2018 19:44:50 +0200 |
parents | frontends/src/jp/cmd_blog.py@dcc77f23e370 |
children | 0b6adc2672d9 |
comparison
equal
deleted
inserted
replaced
2561:bd30dc3ffe5a | 2562:26edcf3a30eb |
---|---|
1 #!/usr/bin/env python2 | |
2 # -*- coding: utf-8 -*- | |
3 | |
4 # jp: a SàT command line tool | |
5 # Copyright (C) 2009-2018 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 | |
21 import base | |
22 from sat.core.i18n import _ | |
23 from sat_frontends.jp.constants import Const as C | |
24 from sat_frontends.jp import common | |
25 from sat.tools.common.ansi import ANSI as A | |
26 from sat.tools.common import data_objects | |
27 from sat.tools.common import uri | |
28 from sat.tools import config | |
29 from ConfigParser import NoSectionError, NoOptionError | |
30 from functools import partial | |
31 import json | |
32 import sys | |
33 import os.path | |
34 import os | |
35 import time | |
36 import tempfile | |
37 import subprocess | |
38 import codecs | |
39 from sat.tools.common import data_format | |
40 | |
41 __commands__ = ["Blog"] | |
42 | |
43 SYNTAX_XHTML = u'xhtml' | |
44 # extensions to use with known syntaxes | |
45 SYNTAX_EXT = { | |
46 '': 'txt', # used when the syntax is not found | |
47 SYNTAX_XHTML: "xhtml", | |
48 "markdown": "md" | |
49 } | |
50 | |
51 | |
52 CONF_SYNTAX_EXT = u'syntax_ext_dict' | |
53 BLOG_TMP_DIR = u"blog" | |
54 # key to remove from metadata tmp file if they exist | |
55 KEY_TO_REMOVE_METADATA = ('id','content', 'content_xhtml', 'comments_node', 'comments_service', 'updated') | |
56 | |
57 URL_REDIRECT_PREFIX = 'url_redirect_' | |
58 INOTIFY_INSTALL = '"pip install inotify"' | |
59 MB_KEYS = (u"id", | |
60 u"url", | |
61 u"atom_id", | |
62 u"updated", | |
63 u"published", | |
64 u"language", | |
65 u"comments", # this key is used for all comments* keys | |
66 u"tags", # this key is used for all tag* keys | |
67 u"author", | |
68 u"author_jid", | |
69 u"author_email", | |
70 u"author_jid_verified", | |
71 u"content", | |
72 u"content_xhtml", | |
73 u"title", | |
74 u"title_xhtml", | |
75 ) | |
76 OUTPUT_OPT_NO_HEADER = u'no-header' | |
77 | |
78 | |
79 def guessSyntaxFromPath(host, sat_conf, path): | |
80 """Return syntax guessed according to filename extension | |
81 | |
82 @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration | |
83 @param path(str): path to the content file | |
84 @return(unicode): syntax to use | |
85 """ | |
86 # we first try to guess syntax with extension | |
87 ext = os.path.splitext(path)[1][1:] # we get extension without the '.' | |
88 if ext: | |
89 for k,v in SYNTAX_EXT.iteritems(): | |
90 if k and ext == v: | |
91 return k | |
92 | |
93 # if not found, we use current syntax | |
94 return host.bridge.getParamA("Syntax", "Composition", "value", host.profile) | |
95 | |
96 | |
97 class BlogPublishCommon(object): | |
98 """handle common option for publising commands (Set and Edit)""" | |
99 | |
100 def add_parser_options(self): | |
101 self.parser.add_argument("-T", '--title', type=base.unicode_decoder, help=_(u"title of the item")) | |
102 self.parser.add_argument("-t", '--tag', type=base.unicode_decoder, action='append', help=_(u"tag (category) of your item")) | |
103 self.parser.add_argument("-C", "--comments", action='store_true', help=_(u"enable comments")) | |
104 self.parser.add_argument("-S", '--syntax', type=base.unicode_decoder, help=_(u"syntax to use (default: get profile's default syntax)")) | |
105 | |
106 def setMbDataContent(self, content, mb_data): | |
107 if self.args.syntax is None: | |
108 # default syntax has been used | |
109 mb_data['content_rich'] = content | |
110 elif self.current_syntax == SYNTAX_XHTML: | |
111 mb_data['content_xhtml'] = content | |
112 else: | |
113 mb_data['content_xhtml'] = self.host.bridge.syntaxConvert(content, self.current_syntax, SYNTAX_XHTML, False, self.profile) | |
114 | |
115 def setMbDataFromArgs(self, mb_data): | |
116 """set microblog metadata according to command line options | |
117 | |
118 if metadata already exist, it will be overwritten | |
119 """ | |
120 mb_data['allow_comments'] = C.boolConst(self.args.comments) | |
121 if self.args.tag: | |
122 data_format.iter2dict('tag', self.args.tag, mb_data, check_conflict=False) | |
123 if self.args.title is not None: | |
124 mb_data['title'] = self.args.title | |
125 | |
126 | |
127 class Set(base.CommandBase, BlogPublishCommon): | |
128 | |
129 def __init__(self, host): | |
130 base.CommandBase.__init__(self, host, 'set', use_pubsub=True, pubsub_flags={C.SINGLE_ITEM}, | |
131 help=_(u'publish a new blog item or update an existing one')) | |
132 BlogPublishCommon.__init__(self) | |
133 self.need_loop=True | |
134 | |
135 def add_parser_options(self): | |
136 BlogPublishCommon.add_parser_options(self) | |
137 | |
138 def mbSendCb(self): | |
139 self.disp(u"Item published") | |
140 self.host.quit(C.EXIT_OK) | |
141 | |
142 def start(self): | |
143 self.pubsub_item = self.args.item | |
144 mb_data = {} | |
145 self.setMbDataFromArgs(mb_data) | |
146 content = codecs.getreader('utf-8')(sys.stdin).read() | |
147 self.setMbDataContent(content, mb_data) | |
148 | |
149 self.host.bridge.mbSend( | |
150 self.args.service, | |
151 self.args.node, | |
152 mb_data, | |
153 self.profile, | |
154 callback=self.exitCb, | |
155 errback=partial(self.errback, | |
156 msg=_(u"can't send item: {}"), | |
157 exit_code=C.EXIT_BRIDGE_ERRBACK)) | |
158 | |
159 | |
160 class Get(base.CommandBase): | |
161 TEMPLATE = u"blog/articles.html" | |
162 | |
163 def __init__(self, host): | |
164 extra_outputs = {'default': self.default_output, | |
165 'fancy': self.fancy_output} | |
166 base.CommandBase.__init__(self, host, 'get', use_verbose=True, use_pubsub=True, pubsub_flags={C.MULTI_ITEMS}, | |
167 use_output=C.OUTPUT_COMPLEX, extra_outputs=extra_outputs, help=_(u'get blog item(s)')) | |
168 self.need_loop=True | |
169 | |
170 def add_parser_options(self): | |
171 # TODO: a key(s) argument to select keys to display | |
172 self.parser.add_argument("-k", "--key", type=base.unicode_decoder, action='append', dest='keys', | |
173 help=_(u"microblog data key(s) to display (default: depend of verbosity)")) | |
174 # TODO: add MAM filters | |
175 | |
176 def template_data_mapping(self, data): | |
177 return {u'items': data_objects.BlogItems(data)} | |
178 | |
179 def format_comments(self, item, keys): | |
180 comments_data = data_format.dict2iterdict(u'comments', item, (u'node', u'service'), pop=True) | |
181 lines = [] | |
182 for data in comments_data: | |
183 lines.append(data[u'comments']) | |
184 for k in (u'node', u'service'): | |
185 if OUTPUT_OPT_NO_HEADER in self.args.output_opts: | |
186 header = u'' | |
187 else: | |
188 header = C.A_HEADER + k + u': ' + A.RESET | |
189 lines.append(header + data[k]) | |
190 return u'\n'.join(lines) | |
191 | |
192 def format_tags(self, item, keys): | |
193 tags = data_format.dict2iter('tag', item, pop=True) | |
194 return u', '.join(tags) | |
195 | |
196 def format_updated(self, item, keys): | |
197 return self.format_time(item['updated']) | |
198 | |
199 def format_published(self, item, keys): | |
200 return self.format_time(item['published']) | |
201 | |
202 def format_url(self, item, keys): | |
203 return uri.buildXMPPUri(u'pubsub', | |
204 subtype=u'microblog', | |
205 path=self.metadata[u'service'], | |
206 node=self.metadata[u'node'], | |
207 item=item[u'id']) | |
208 | |
209 def get_keys(self): | |
210 """return keys to display according to verbosity or explicit key request""" | |
211 verbosity = self.args.verbose | |
212 if self.args.keys: | |
213 if not set(MB_KEYS).issuperset(self.args.keys): | |
214 self.disp(u"following keys are invalid: {invalid}.\n" | |
215 u"Valid keys are: {valid}.".format( | |
216 invalid = u', '.join(set(self.args.keys).difference(MB_KEYS)), | |
217 valid = u', '.join(sorted(MB_KEYS))), | |
218 error=True) | |
219 self.host.quit(C.EXIT_BAD_ARG) | |
220 return self.args.keys | |
221 else: | |
222 if verbosity == 0: | |
223 return (u'title', u'content') | |
224 elif verbosity == 1: | |
225 return (u"title", u"tags", u"author", u"author_jid", u"author_email", u"author_jid_verified", u"published", u"updated", u"content") | |
226 else: | |
227 return MB_KEYS | |
228 | |
229 def default_output(self, data): | |
230 """simple key/value output""" | |
231 items, self.metadata = data | |
232 keys = self.get_keys() | |
233 | |
234 # k_cb use format_[key] methods for complex formattings | |
235 k_cb = {} | |
236 for k in keys: | |
237 try: | |
238 callback = getattr(self, "format_" + k) | |
239 except AttributeError: | |
240 pass | |
241 else: | |
242 k_cb[k] = callback | |
243 for idx, item in enumerate(items): | |
244 for k in keys: | |
245 if k not in item and k not in k_cb: | |
246 continue | |
247 if OUTPUT_OPT_NO_HEADER in self.args.output_opts: | |
248 header = '' | |
249 else: | |
250 header = u"{k_fmt}{key}:{k_fmt_e} {sep}".format( | |
251 k_fmt = C.A_HEADER, | |
252 key = k, | |
253 k_fmt_e = A.RESET, | |
254 sep = u'\n' if 'content' in k else u'') | |
255 value = k_cb[k](item, keys) if k in k_cb else item[k] | |
256 self.disp(header + value) | |
257 # we want a separation line after each item but the last one | |
258 if idx < len(items)-1: | |
259 print(u'') | |
260 | |
261 def format_time(self, timestamp): | |
262 """return formatted date for timestamp | |
263 | |
264 @param timestamp(str,int,float): unix timestamp | |
265 @return (unicode): formatted date | |
266 """ | |
267 fmt = u"%d/%m/%Y %H:%M:%S" | |
268 return time.strftime(fmt, time.localtime(float(timestamp))) | |
269 | |
270 def fancy_output(self, data): | |
271 """display blog is a nice to read way | |
272 | |
273 this output doesn't use keys filter | |
274 """ | |
275 # thanks to http://stackoverflow.com/a/943921 | |
276 rows, columns = map(int, os.popen('stty size', 'r').read().split()) | |
277 items, metadata = data | |
278 verbosity = self.args.verbose | |
279 sep = A.color(A.FG_BLUE, columns * u'▬') | |
280 if items: | |
281 print(u'\n' + sep + '\n') | |
282 | |
283 for idx, item in enumerate(items): | |
284 title = item.get(u'title') | |
285 if verbosity > 0: | |
286 author = item[u'author'] | |
287 published, updated = item[u'published'], item.get('updated') | |
288 else: | |
289 author = published = updated = None | |
290 if verbosity > 1: | |
291 tags = list(data_format.dict2iter('tag', item, pop=True)) | |
292 else: | |
293 tags = None | |
294 content = item.get(u'content') | |
295 | |
296 if title: | |
297 print(A.color(A.BOLD, A.FG_CYAN, item[u'title'])) | |
298 meta = [] | |
299 if author: | |
300 meta.append(A.color(A.FG_YELLOW, author)) | |
301 if published: | |
302 meta.append(A.color(A.FG_YELLOW, u'on ', self.format_time(published))) | |
303 if updated != published: | |
304 meta.append(A.color(A.FG_YELLOW, u'(updated on ', self.format_time(updated), u')')) | |
305 print(u' '.join(meta)) | |
306 if tags: | |
307 print(A.color(A.FG_MAGENTA, u', '.join(tags))) | |
308 if (title or tags) and content: | |
309 print("") | |
310 if content: | |
311 self.disp(content) | |
312 | |
313 print(u'\n' + sep + '\n') | |
314 | |
315 | |
316 def mbGetCb(self, mb_result): | |
317 self.output(mb_result) | |
318 self.host.quit(C.EXIT_OK) | |
319 | |
320 def mbGetEb(self, failure_): | |
321 self.disp(u"can't get blog items: {reason}".format( | |
322 reason=failure_), error=True) | |
323 self.host.quit(C.EXIT_BRIDGE_ERRBACK) | |
324 | |
325 def start(self): | |
326 self.host.bridge.mbGet( | |
327 self.args.service, | |
328 self.args.node, | |
329 self.args.max, | |
330 self.args.items, | |
331 {}, | |
332 self.profile, | |
333 callback=self.mbGetCb, | |
334 errback=self.mbGetEb) | |
335 | |
336 | |
337 class Edit(base.CommandBase, BlogPublishCommon, common.BaseEdit): | |
338 | |
339 def __init__(self, host): | |
340 base.CommandBase.__init__(self, host, 'edit', use_pubsub=True, pubsub_flags={C.SINGLE_ITEM}, | |
341 use_draft=True, use_verbose=True, help=_(u'edit an existing or new blog post')) | |
342 BlogPublishCommon.__init__(self) | |
343 common.BaseEdit.__init__(self, self.host, BLOG_TMP_DIR, use_metadata=True) | |
344 | |
345 @property | |
346 def current_syntax(self): | |
347 if self._current_syntax is None: | |
348 self._current_syntax = self.host.bridge.getParamA("Syntax", "Composition", "value", self.profile) | |
349 return self._current_syntax | |
350 | |
351 def add_parser_options(self): | |
352 BlogPublishCommon.add_parser_options(self) | |
353 self.parser.add_argument("-P", "--preview", action="store_true", help=_(u"launch a blog preview in parallel")) | |
354 | |
355 def buildMetadataFile(self, content_file_path, mb_data=None): | |
356 """Build a metadata file using json | |
357 | |
358 The file is named after content_file_path, with extension replaced by _metadata.json | |
359 @param content_file_path(str): path to the temporary file which will contain the body | |
360 @param mb_data(dict, None): microblog metadata (for existing items) | |
361 @return (tuple[dict, str]): merged metadata put originaly in metadata file | |
362 and path to temporary metadata file | |
363 """ | |
364 # we first construct metadata from edited item ones and CLI argumments | |
365 # or re-use the existing one if it exists | |
366 meta_file_path = os.path.splitext(content_file_path)[0] + common.METADATA_SUFF | |
367 if os.path.exists(meta_file_path): | |
368 self.disp(u"Metadata file already exists, we re-use it") | |
369 try: | |
370 with open(meta_file_path, 'rb') as f: | |
371 mb_data = json.load(f) | |
372 except (OSError, IOError, ValueError) as e: | |
373 self.disp(u"Can't read existing metadata file at {path}, aborting: {reason}".format( | |
374 path=meta_file_path, reason=e), error=True) | |
375 self.host.quit(1) | |
376 else: | |
377 mb_data = {} if mb_data is None else mb_data.copy() | |
378 | |
379 # in all cases, we want to remove unwanted keys | |
380 for key in KEY_TO_REMOVE_METADATA: | |
381 try: | |
382 del mb_data[key] | |
383 except KeyError: | |
384 pass | |
385 # and override metadata with command-line arguments | |
386 self.setMbDataFromArgs(mb_data) | |
387 | |
388 # then we create the file and write metadata there, as JSON dict | |
389 # XXX: if we port jp one day on Windows, O_BINARY may need to be added here | |
390 with os.fdopen(os.open(meta_file_path, os.O_RDWR | os.O_CREAT | os.O_TRUNC,0o600), 'w+b') as f: | |
391 # we need to use an intermediate unicode buffer to write to the file unicode without escaping characters | |
392 unicode_dump = json.dumps(mb_data, ensure_ascii=False, indent=4, separators=(',', ': '), sort_keys=True) | |
393 f.write(unicode_dump.encode('utf-8')) | |
394 | |
395 return mb_data, meta_file_path | |
396 | |
397 def edit(self, content_file_path, content_file_obj, | |
398 mb_data=None): | |
399 """Edit the file contening the content using editor, and publish it""" | |
400 # we first create metadata file | |
401 meta_ori, meta_file_path = self.buildMetadataFile(content_file_path, mb_data) | |
402 | |
403 # do we need a preview ? | |
404 if self.args.preview: | |
405 self.disp(u"Preview requested, launching it", 1) | |
406 # we redirect outputs to /dev/null to avoid console pollution in editor | |
407 # if user wants to see messages, (s)he can call "blog preview" directly | |
408 DEVNULL = open(os.devnull, 'wb') | |
409 subprocess.Popen([sys.argv[0], "blog", "preview", "--inotify", "true", "-p", self.profile, content_file_path], stdout=DEVNULL, stderr=subprocess.STDOUT) | |
410 | |
411 # we launch editor | |
412 self.runEditor("blog_editor_args", content_file_path, content_file_obj, meta_file_path=meta_file_path, meta_ori=meta_ori) | |
413 | |
414 def publish(self, content, mb_data): | |
415 self.setMbDataContent(content, mb_data) | |
416 | |
417 if self.pubsub_item is not None: | |
418 mb_data['id'] = self.pubsub_item | |
419 | |
420 self.host.bridge.mbSend(self.pubsub_service, self.pubsub_node, mb_data, self.profile) | |
421 self.disp(u"Blog item published") | |
422 | |
423 | |
424 def getTmpSuff(self): | |
425 # we get current syntax to determine file extension | |
426 return SYNTAX_EXT.get(self.current_syntax, SYNTAX_EXT['']) | |
427 | |
428 def getItemData(self, service, node, item): | |
429 items = [item] if item is not None else [] | |
430 mb_data = self.host.bridge.mbGet(service, node, 1, items, {}, self.profile)[0][0] | |
431 try: | |
432 content = mb_data['content_xhtml'] | |
433 except KeyError: | |
434 content = mb_data['content'] | |
435 if content: | |
436 content = self.host.bridge.syntaxConvert(content, 'text', SYNTAX_XHTML, False, self.profile) | |
437 if content and self.current_syntax != SYNTAX_XHTML: | |
438 content = self.host.bridge.syntaxConvert(content, SYNTAX_XHTML, self.current_syntax, False, self.profile) | |
439 if content and self.current_syntax == SYNTAX_XHTML: | |
440 try: | |
441 from lxml import etree | |
442 except ImportError: | |
443 self.disp(_(u"You need lxml to edit pretty XHTML")) | |
444 else: | |
445 parser = etree.XMLParser(remove_blank_text=True) | |
446 root = etree.fromstring(content, parser) | |
447 content = etree.tostring(root, encoding=unicode, pretty_print=True) | |
448 | |
449 return content, mb_data, mb_data['id'] | |
450 | |
451 def start(self): | |
452 # if there are user defined extension, we use them | |
453 SYNTAX_EXT.update(config.getConfig(self.sat_conf, 'jp', CONF_SYNTAX_EXT, {})) | |
454 self._current_syntax = self.args.syntax | |
455 if self._current_syntax is not None: | |
456 try: | |
457 self._current_syntax = self.args.syntax = self.host.bridge.syntaxGet(self.current_syntax) | |
458 except Exception as e: | |
459 if "NotFound" in unicode(e): # FIXME: there is not good way to check bridge errors | |
460 self.parser.error(_(u"unknown syntax requested ({syntax})").format(syntax=self.args.syntax)) | |
461 else: | |
462 raise e | |
463 | |
464 self.pubsub_service, self.pubsub_node, self.pubsub_item, content_file_path, content_file_obj, mb_data = self.getItemPath() | |
465 | |
466 self.edit(content_file_path, content_file_obj, mb_data=mb_data) | |
467 | |
468 | |
469 class Preview(base.CommandBase): | |
470 # TODO: need to be rewritten with template output | |
471 | |
472 def __init__(self, host): | |
473 base.CommandBase.__init__(self, host, 'preview', use_verbose=True, help=_(u'preview a blog content')) | |
474 | |
475 def add_parser_options(self): | |
476 self.parser.add_argument("--inotify", type=str, choices=('auto', 'true', 'false'), default=u'auto', help=_(u"use inotify to handle preview")) | |
477 self.parser.add_argument("file", type=base.unicode_decoder, nargs='?', default=u'current', help=_(u"path to the content file")) | |
478 | |
479 def showPreview(self): | |
480 # we implement showPreview here so we don't have to import webbrowser and urllib | |
481 # when preview is not used | |
482 url = 'file:{}'.format(self.urllib.quote(self.preview_file_path)) | |
483 self.webbrowser.open_new_tab(url) | |
484 | |
485 def _launchPreviewExt(self, cmd_line, opt_name): | |
486 url = 'file:{}'.format(self.urllib.quote(self.preview_file_path)) | |
487 args = common.parse_args(self.host, cmd_line, url=url, preview_file=self.preview_file_path) | |
488 if not args: | |
489 self.disp(u"Couln't find command in \"{name}\", abording".format(name=opt_name), error=True) | |
490 self.host.quit(1) | |
491 subprocess.Popen(args) | |
492 | |
493 def openPreviewExt(self): | |
494 self._launchPreviewExt(self.open_cb_cmd, "blog_preview_open_cmd") | |
495 | |
496 def updatePreviewExt(self): | |
497 self._launchPreviewExt(self.update_cb_cmd, "blog_preview_update_cmd") | |
498 | |
499 def updateContent(self): | |
500 with open(self.content_file_path, 'rb') as f: | |
501 content = f.read().decode('utf-8-sig') | |
502 if content and self.syntax != SYNTAX_XHTML: | |
503 # we use safe=True because we want to have a preview as close as possible to what the | |
504 # people will see | |
505 content = self.host.bridge.syntaxConvert(content, self.syntax, SYNTAX_XHTML, True, self.profile) | |
506 | |
507 xhtml = (u'<html xmlns="http://www.w3.org/1999/xhtml">' + | |
508 u'<head><meta http-equiv="Content-Type" content="text/html;charset=utf-8" /></head>'+ | |
509 '<body>{}</body>' + | |
510 u'</html>').format(content) | |
511 | |
512 with open(self.preview_file_path, 'wb') as f: | |
513 f.write(xhtml.encode('utf-8')) | |
514 | |
515 def start(self): | |
516 import webbrowser | |
517 import urllib | |
518 self.webbrowser, self.urllib = webbrowser, urllib | |
519 | |
520 if self.args.inotify != 'false': | |
521 try: | |
522 import inotify.adapters | |
523 import inotify.constants | |
524 from inotify.calls import InotifyError | |
525 except ImportError: | |
526 if self.args.inotify == 'auto': | |
527 inotify = None | |
528 self.disp(u'inotify module not found, deactivating feature. You can install it with {install}'.format(install=INOTIFY_INSTALL)) | |
529 else: | |
530 self.disp(u"inotify not found, can't activate the feature! Please install it with {install}".format(install=INOTIFY_INSTALL), error=True) | |
531 self.host.quit(1) | |
532 else: | |
533 # we deactivate logging in inotify, which is quite annoying | |
534 try: | |
535 inotify.adapters._LOGGER.setLevel(40) | |
536 except AttributeError: | |
537 self.disp(u"Logger doesn't exists, inotify may have chanded", error=True) | |
538 else: | |
539 inotify=None | |
540 | |
541 sat_conf = config.parseMainConf() | |
542 SYNTAX_EXT.update(config.getConfig(sat_conf, 'jp', CONF_SYNTAX_EXT, {})) | |
543 | |
544 try: | |
545 self.open_cb_cmd = config.getConfig(sat_conf, 'jp', "blog_preview_open_cmd", Exception) | |
546 except (NoOptionError, NoSectionError): | |
547 self.open_cb_cmd = None | |
548 open_cb = self.showPreview | |
549 else: | |
550 open_cb = self.openPreviewExt | |
551 | |
552 self.update_cb_cmd = config.getConfig(sat_conf, 'jp', "blog_preview_update_cmd", self.open_cb_cmd) | |
553 if self.update_cb_cmd is None: | |
554 update_cb = self.showPreview | |
555 else: | |
556 update_cb = self.updatePreviewExt | |
557 | |
558 # which file do we need to edit? | |
559 if self.args.file == 'current': | |
560 self.content_file_path = self.getCurrentFile(sat_conf, self.profile) | |
561 else: | |
562 self.content_file_path = os.path.abspath(self.args.file) | |
563 | |
564 self.syntax = self.guessSyntaxFromPath(sat_conf, self.content_file_path) | |
565 | |
566 | |
567 # at this point the syntax is converted, we can display the preview | |
568 preview_file = tempfile.NamedTemporaryFile(suffix='.xhtml', delete=False) | |
569 self.preview_file_path = preview_file.name | |
570 preview_file.close() | |
571 self.updateContent() | |
572 | |
573 if inotify is None: | |
574 # XXX: we don't delete file automatically because browser need it (and webbrowser.open can return before it is read) | |
575 self.disp(u'temporary file created at {}\nthis file will NOT BE DELETED AUTOMATICALLY, please delete it yourself when you have finished'.format(self.preview_file_path)) | |
576 open_cb() | |
577 else: | |
578 open_cb() | |
579 i = inotify.adapters.Inotify(block_duration_s=60) # no need for 1 s duraction, inotify drive actions here | |
580 | |
581 def add_watch(): | |
582 i.add_watch(self.content_file_path, mask=inotify.constants.IN_CLOSE_WRITE | | |
583 inotify.constants.IN_DELETE_SELF | | |
584 inotify.constants.IN_MOVE_SELF) | |
585 add_watch() | |
586 | |
587 try: | |
588 for event in i.event_gen(): | |
589 if event is not None: | |
590 self.disp(u"Content updated", 1) | |
591 if {"IN_DELETE_SELF", "IN_MOVE_SELF"}.intersection(event[1]): | |
592 self.disp(u"{} event catched, changing the watch".format(", ".join(event[1])), 2) | |
593 try: | |
594 add_watch() | |
595 except InotifyError: | |
596 # if the new file is not here yet we can have an error | |
597 # as a workaround, we do a little rest | |
598 time.sleep(1) | |
599 add_watch() | |
600 self.updateContent() | |
601 update_cb() | |
602 except InotifyError: | |
603 self.disp(u"Can't catch inotify events, as the file been deleted?", error=True) | |
604 finally: | |
605 os.unlink(self.preview_file_path) | |
606 try: | |
607 i.remove_watch(self.content_file_path) | |
608 except InotifyError: | |
609 pass | |
610 | |
611 | |
612 class Import(base.CommandAnswering): | |
613 def __init__(self, host): | |
614 super(Import, self).__init__(host, 'import', use_pubsub=True, use_progress=True, help=_(u'import an external blog')) | |
615 self.need_loop=True | |
616 | |
617 def add_parser_options(self): | |
618 self.parser.add_argument("importer", type=base.unicode_decoder, nargs='?', help=_(u"importer name, nothing to display importers list")) | |
619 self.parser.add_argument('--host', type=base.unicode_decoder, help=_(u"original blog host")) | |
620 self.parser.add_argument('--no-images-upload', action='store_true', help=_(u"do *NOT* upload images (default: do upload images)")) | |
621 self.parser.add_argument('--upload-ignore-host', help=_(u"do not upload images from this host (default: upload all images)")) | |
622 self.parser.add_argument("--ignore-tls-errors", action="store_true", help=_("ignore invalide TLS certificate for uploads")) | |
623 self.parser.add_argument('-o', '--option', action='append', nargs=2, default=[], metavar=(u'NAME', u'VALUE'), | |
624 help=_(u"importer specific options (see importer description)")) | |
625 self.parser.add_argument("location", type=base.unicode_decoder, nargs='?', | |
626 help=_(u"importer data location (see importer description), nothing to show importer description")) | |
627 | |
628 def onProgressStarted(self, metadata): | |
629 self.disp(_(u'Blog upload started'),2) | |
630 | |
631 def onProgressFinished(self, metadata): | |
632 self.disp(_(u'Blog uploaded successfully'),2) | |
633 redirections = {k[len(URL_REDIRECT_PREFIX):]:v for k,v in metadata.iteritems() | |
634 if k.startswith(URL_REDIRECT_PREFIX)} | |
635 if redirections: | |
636 conf = u'\n'.join([ | |
637 u'url_redirections_profile = {}'.format(self.profile), | |
638 u"url_redirections_dict = {}".format( | |
639 # we need to add ' ' before each new line and to double each '%' for ConfigParser | |
640 u'\n '.join(json.dumps(redirections, indent=1, separators=(',',': ')).replace(u'%', u'%%').split(u'\n'))), | |
641 ]) | |
642 self.disp(_(u'\nTo redirect old URLs to new ones, put the following lines in your sat.conf file, in [libervia] section:\n\n{conf}'.format(conf=conf))) | |
643 | |
644 def onProgressError(self, error_msg): | |
645 self.disp(_(u'Error while uploading blog: {}').format(error_msg),error=True) | |
646 | |
647 def error(self, failure): | |
648 self.disp(_("Error while trying to upload a blog: {reason}").format(reason=failure), error=True) | |
649 self.host.quit(1) | |
650 | |
651 def start(self): | |
652 if self.args.location is None: | |
653 for name in ('option', 'service', 'no_images_upload'): | |
654 if getattr(self.args, name): | |
655 self.parser.error(_(u"{name} argument can't be used without location argument").format(name=name)) | |
656 if self.args.importer is None: | |
657 self.disp(u'\n'.join([u'{}: {}'.format(name, desc) for name, desc in self.host.bridge.blogImportList()])) | |
658 else: | |
659 try: | |
660 short_desc, long_desc = self.host.bridge.blogImportDesc(self.args.importer) | |
661 except Exception as e: | |
662 msg = [l for l in unicode(e).split('\n') if l][-1] # we only keep the last line | |
663 self.disp(msg) | |
664 self.host.quit(1) | |
665 else: | |
666 self.disp(u"{name}: {short_desc}\n\n{long_desc}".format(name=self.args.importer, short_desc=short_desc, long_desc=long_desc)) | |
667 self.host.quit() | |
668 else: | |
669 # we have a location, an import is requested | |
670 options = {key: value for key, value in self.args.option} | |
671 if self.args.host: | |
672 options['host'] = self.args.host | |
673 if self.args.ignore_tls_errors: | |
674 options['ignore_tls_errors'] = C.BOOL_TRUE | |
675 if self.args.no_images_upload: | |
676 options['upload_images'] = C.BOOL_FALSE | |
677 if self.args.upload_ignore_host: | |
678 self.parser.error(u"upload-ignore-host option can't be used when no-images-upload is set") | |
679 elif self.args.upload_ignore_host: | |
680 options['upload_ignore_host'] = self.args.upload_ignore_host | |
681 def gotId(id_): | |
682 self.progress_id = id_ | |
683 self.host.bridge.blogImport(self.args.importer, self.args.location, options, self.args.service, self.args.node, self.profile, | |
684 callback=gotId, errback=self.error) | |
685 | |
686 | |
687 class Blog(base.CommandBase): | |
688 subcommands = (Set, Get, Edit, Preview, Import) | |
689 | |
690 def __init__(self, host): | |
691 super(Blog, self).__init__(host, 'blog', use_profile=False, help=_('blog/microblog management')) |