comparison frontends/src/jp/cmd_blog.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 ed28798fd76c
children 07caa12be945
comparison
equal deleted inserted replaced
2268:a29d1351bc83 2269:606ff34d30f2
19 19
20 20
21 import base 21 import base
22 from sat.core.i18n import _ 22 from sat.core.i18n import _
23 from sat_frontends.jp.constants import Const as C 23 from sat_frontends.jp.constants import Const as C
24 from sat_frontends.jp import common
24 from sat.tools.common.ansi import ANSI as A 25 from sat.tools.common.ansi import ANSI as A
25 from sat.tools.common import data_objects 26 from sat.tools.common import data_objects
26 from sat.tools import config 27 from sat.tools import config
27 from ConfigParser import NoSectionError, NoOptionError 28 from ConfigParser import NoSectionError, NoOptionError
28 import json 29 import json
31 import os 32 import os
32 import time 33 import time
33 import tempfile 34 import tempfile
34 import subprocess 35 import subprocess
35 import shlex 36 import shlex
36 import glob
37 from sat.tools.common import data_format 37 from sat.tools.common import data_format
38 from sat.tools.common import regex
39 38
40 __commands__ = ["Blog"] 39 __commands__ = ["Blog"]
41 40
42 # extensions to use with known syntaxes 41 # extensions to use with known syntaxes
43 SYNTAX_EXT = { 42 SYNTAX_EXT = {
44 '': 'txt', # used when the syntax is not found 43 '': 'txt', # used when the syntax is not found
45 "XHTML": "xhtml", 44 "XHTML": "xhtml",
46 "markdown": "md" 45 "markdown": "md"
47 } 46 }
48 47
49 # defaut arguments used for some known editors
50 VIM_SPLIT_ARGS = "-c 'vsplit|wincmd w|next|wincmd w'"
51 EMACS_SPLIT_ARGS = '--eval "(split-window-horizontally)"'
52 EDITOR_ARGS_MAGIC = {
53 'vim': VIM_SPLIT_ARGS + ' {content_file} {metadata_file}',
54 'gvim': VIM_SPLIT_ARGS + ' --nofork {content_file} {metadata_file}',
55 'emacs': EMACS_SPLIT_ARGS + ' {content_file} {metadata_file}',
56 'xemacs': EMACS_SPLIT_ARGS + ' {content_file} {metadata_file}',
57 'nano': ' -F {content_file} {metadata_file}',
58 }
59 48
60 CONF_SYNTAX_EXT = 'syntax_ext_dict' 49 CONF_SYNTAX_EXT = 'syntax_ext_dict'
61 BLOG_TMP_DIR="blog" 50 BLOG_TMP_DIR=u"blog"
62 METADATA_SUFF = '_metadata.json'
63 # key to remove from metadata tmp file if they exist 51 # key to remove from metadata tmp file if they exist
64 KEY_TO_REMOVE_METADATA = ('id','content', 'content_xhtml', 'comments_node', 'comments_service', 'updated') 52 KEY_TO_REMOVE_METADATA = ('id','content', 'content_xhtml', 'comments_node', 'comments_service', 'updated')
65 53
66 URL_REDIRECT_PREFIX = 'url_redirect_' 54 URL_REDIRECT_PREFIX = 'url_redirect_'
67 INOTIFY_INSTALL = '"pip install inotify"' 55 INOTIFY_INSTALL = '"pip install inotify"'
68 SECURE_UNLINK_MAX = 10 * 2 # we double value as there are 2 files per draft (content and metadata)
69 SECURE_UNLINK_DIR = ".backup"
70 MB_KEYS = (u"id", 56 MB_KEYS = (u"id",
71 u"atom_id", 57 u"atom_id",
72 u"updated", 58 u"updated",
73 u"published", 59 u"published",
74 u"language", 60 u"language",
91 self.host = host 77 self.host = host
92 78
93 def addServiceNodeArgs(self): 79 def addServiceNodeArgs(self):
94 self.parser.add_argument("-n", "--node", type=base.unicode_decoder, default=u'', help=_(u"PubSub node to request (default: microblog namespace)")) 80 self.parser.add_argument("-n", "--node", type=base.unicode_decoder, default=u'', help=_(u"PubSub node to request (default: microblog namespace)"))
95 self.parser.add_argument("-s", "--service", type=base.unicode_decoder, default=u'', help=_(u"JID of the PubSub service (default: request profile own blog)")) 81 self.parser.add_argument("-s", "--service", type=base.unicode_decoder, default=u'', help=_(u"JID of the PubSub service (default: request profile own blog)"))
96
97 def getTmpDir(self, sat_conf, sub_dir=None):
98 """Return directory used to store temporary files
99
100 @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration
101 @param sub_dir(str): sub directory where data need to be put
102 profile can be used here, or special directory name
103 sub_dir will be escaped to be usable in path (use regex.pathUnescape to find
104 initial str)
105 @return (str): path to the dir
106 """
107 local_dir = config.getConfig(sat_conf, '', 'local_dir', Exception)
108 path = [local_dir, BLOG_TMP_DIR]
109 if sub_dir is not None:
110 path.append(regex.pathEscape(sub_dir))
111 return os.path.join(*path)
112
113 def getCurrentFile(self, sat_conf, profile):
114 """Get most recently edited file
115
116 @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration
117 @param profile(unicode): profile linked to the blog draft
118 @return(str): full path of current file
119 """
120 # we guess the blog item currently edited by choosing
121 # the most recent file corresponding to temp file pattern
122 # in tmp_dir, excluding metadata files
123 tmp_dir = self.getTmpDir(sat_conf, profile.encode('utf-8'))
124 available = [path for path in glob.glob(os.path.join(tmp_dir, 'blog_*')) if not path.endswith(METADATA_SUFF)]
125 if not available:
126 self.disp(u"Counldn't find any content draft in {path}".format(path=tmp_dir), error=True)
127 self.host.quit(1)
128 return max(available, key=lambda path: os.stat(path).st_mtime)
129
130 def secureUnlink(self, sat_conf, path):
131 """Unlink given path after keeping it for a while
132
133 This method is used to prevent accidental deletion of a blog draft
134 If there are more file in SECURE_UNLINK_DIR than SECURE_UNLINK_MAX,
135 older file are deleted
136 @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration
137 @param path(str): file to unlink
138 """
139 if not os.path.isfile(path):
140 raise OSError(u"path must link to a regular file")
141 if not path.startswith(self.getTmpDir(sat_conf)):
142 self.disp(u"File {} is not in blog temporary hierarchy, we do not remove it".format(path.decode('utf-8')), 2)
143 return
144 backup_dir = self.getTmpDir(sat_conf, SECURE_UNLINK_DIR)
145 if not os.path.exists(backup_dir):
146 os.makedirs(backup_dir)
147 filename = os.path.basename(path)
148 backup_path = os.path.join(backup_dir, filename)
149 # we move file to backup dir
150 self.host.disp(u"Backuping file {src} to {dst}".format(
151 src=path.decode('utf-8'), dst=backup_path.decode('utf-8')), 1)
152 os.rename(path, backup_path)
153 # and if we exceeded the limit, we remove older file
154 backup_files = [os.path.join(backup_dir, f) for f in os.listdir(backup_dir)]
155 if len(backup_files) > SECURE_UNLINK_MAX:
156 backup_files.sort(key=lambda path: os.stat(path).st_mtime)
157 for path in backup_files[:len(backup_files) - SECURE_UNLINK_MAX]:
158 self.host.disp(u"Purging backup file {}".format(path.decode('utf-8')), 2)
159 os.unlink(path)
160 82
161 def guessSyntaxFromPath(self, sat_conf, path): 83 def guessSyntaxFromPath(self, sat_conf, path):
162 """Return syntax guessed according to filename extension 84 """Return syntax guessed according to filename extension
163 85
164 @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration 86 @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration
363 self.profile, 285 self.profile,
364 callback=self.mbGetCb, 286 callback=self.mbGetCb,
365 errback=self.mbGetEb) 287 errback=self.mbGetEb)
366 288
367 289
368 class Edit(base.CommandBase, BlogCommon): 290 class Edit(base.CommandBase, BlogCommon, common.BaseEdit):
369 291
370 def __init__(self, host): 292 def __init__(self, host):
371 base.CommandBase.__init__(self, host, 'edit', use_verbose=True, help=_(u'edit an existing or new blog post')) 293 base.CommandBase.__init__(self, host, 'edit', use_verbose=True, help=_(u'edit an existing or new blog post'))
372 BlogCommon.__init__(self, self.host) 294 BlogCommon.__init__(self, self.host)
295 common.BaseEdit.__init__(self, self.host, BLOG_TMP_DIR, use_metadata=True)
373 296
374 def add_parser_options(self): 297 def add_parser_options(self):
375 self.addServiceNodeArgs() 298 self.addServiceNodeArgs()
376 self.parser.add_argument("item", type=base.unicode_decoder, nargs='?', default=u'new', help=_(u"URL of the item to edit, or keyword")) 299 self.parser.add_argument("item", type=base.unicode_decoder, nargs='?', default=u'new', help=_(u"URL of the item to edit, or keyword"))
377 self.parser.add_argument("-P", "--preview", action="store_true", help=_(u"launch a blog preview in parallel")) 300 self.parser.add_argument("-P", "--preview", action="store_true", help=_(u"launch a blog preview in parallel"))
378 self.parser.add_argument("-T", '--title', type=base.unicode_decoder, help=_(u"title of the item")) 301 self.parser.add_argument("-T", '--title', type=base.unicode_decoder, help=_(u"title of the item"))
379 self.parser.add_argument("-t", '--tag', type=base.unicode_decoder, action='append', help=_(u"tag (category) of your item")) 302 self.parser.add_argument("-t", '--tag', type=base.unicode_decoder, action='append', help=_(u"tag (category) of your item"))
380 self.parser.add_argument("--no-comment", action='store_true', help=_(u"disable comments")) 303 self.parser.add_argument("--no-comment", action='store_true', help=_(u"disable comments"))
381
382 def getTmpFile(self, sat_conf, tmp_suff):
383 """Create a temporary file to received blog item body
384
385 @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration
386 @param tmp_suff (str): suffix to use for the filename
387 @return (tuple(file, str)): opened (w+b) file object and file path
388 """
389 tmp_dir = self.getTmpDir(sat_conf, self.profile.encode('utf-8'))
390 if not os.path.exists(tmp_dir):
391 try:
392 os.makedirs(tmp_dir)
393 except OSError as e:
394 self.disp(u"Can't create {path} directory: {reason}".format(
395 path=tmp_dir, reason=e), error=True)
396 self.host.quit(1)
397 try:
398 fd, path = tempfile.mkstemp(suffix=tmp_suff,
399 prefix=time.strftime('blog_%Y-%m-%d_%H:%M:%S_'),
400 dir=tmp_dir, text=True)
401 return os.fdopen(fd, 'w+b'), path
402 except OSError as e:
403 self.disp(u"Can't create temporary file: {reason}".format(reason=e), error=True)
404 self.host.quit(1)
405 304
406 def buildMetadataFile(self, content_file_path, mb_data=None): 305 def buildMetadataFile(self, content_file_path, mb_data=None):
407 """Build a metadata file using json 306 """Build a metadata file using json
408 307
409 The file is named after content_file_path, with extension replaced by _metadata.json 308 The file is named after content_file_path, with extension replaced by _metadata.json
412 @return (tuple[dict, str]): merged metadata put originaly in metadata file 311 @return (tuple[dict, str]): merged metadata put originaly in metadata file
413 and path to temporary metadata file 312 and path to temporary metadata file
414 """ 313 """
415 # we first construct metadata from edited item ones and CLI argumments 314 # we first construct metadata from edited item ones and CLI argumments
416 # or re-use the existing one if it exists 315 # or re-use the existing one if it exists
417 meta_file_path = os.path.splitext(content_file_path)[0] + METADATA_SUFF 316 meta_file_path = os.path.splitext(content_file_path)[0] + common.METADATA_SUFF
418 if os.path.exists(meta_file_path): 317 if os.path.exists(meta_file_path):
419 self.disp(u"Metadata file already exists, we re-use it") 318 self.disp(u"Metadata file already exists, we re-use it")
420 try: 319 try:
421 with open(meta_file_path, 'rb') as f: 320 with open(meta_file_path, 'rb') as f:
422 mb_data = json.load(f) 321 mb_data = json.load(f)
447 unicode_dump = json.dumps(mb_data, ensure_ascii=False, indent=4, separators=(',', ': '), sort_keys=True) 346 unicode_dump = json.dumps(mb_data, ensure_ascii=False, indent=4, separators=(',', ': '), sort_keys=True)
448 f.write(unicode_dump.encode('utf-8')) 347 f.write(unicode_dump.encode('utf-8'))
449 348
450 return mb_data, meta_file_path 349 return mb_data, meta_file_path
451 350
452 def edit(self, sat_conf, content_file_path, content_file_obj, 351 def edit(self, content_file_path, content_file_obj,
453 pubsub_service, pubsub_node, mb_data=None): 352 mb_data=None):
454 """Edit the file contening the content using editor, and publish it""" 353 """Edit the file contening the content using editor, and publish it"""
455 item_ori_mb_data = mb_data 354 self.item_ori_mb_data = mb_data
456 # we first create metadata file 355 # we first create metadata file
457 meta_ori, meta_file_path = self.buildMetadataFile(content_file_path, item_ori_mb_data) 356 meta_ori, meta_file_path = self.buildMetadataFile(content_file_path, self.item_ori_mb_data)
458
459 # then we calculate hashes to check for modifications
460 import hashlib
461 content_file_obj.seek(0)
462 tmp_ori_hash = hashlib.sha1(content_file_obj.read()).digest()
463 content_file_obj.close()
464 357
465 # do we need a preview ? 358 # do we need a preview ?
466 if self.args.preview: 359 if self.args.preview:
467 self.disp(u"Preview requested, launching it", 1) 360 self.disp(u"Preview requested, launching it", 1)
468 # we redirect outputs to /dev/null to avoid console pollution in editor 361 # we redirect outputs to /dev/null to avoid console pollution in editor
469 # if user wants to see messages, (s)he can call "blog preview" directly 362 # if user wants to see messages, (s)he can call "blog preview" directly
470 DEVNULL = open(os.devnull, 'wb') 363 DEVNULL = open(os.devnull, 'wb')
471 subprocess.Popen([sys.argv[0], "blog", "preview", "--inotify", "true", "-p", self.profile, content_file_path], stdout=DEVNULL, stderr=subprocess.STDOUT) 364 subprocess.Popen([sys.argv[0], "blog", "preview", "--inotify", "true", "-p", self.profile, content_file_path], stdout=DEVNULL, stderr=subprocess.STDOUT)
472 365
473 # then we launch editor 366 # we launch editor
474 editor = config.getConfig(sat_conf, 'jp', 'editor') or os.getenv('EDITOR', 'vi') 367 self.runEditor("blog_editor_args", content_file_path, content_file_obj, meta_file_path=meta_file_path, meta_ori=meta_ori)
368
369 def publish(self, content, mb_data):
370 mb_data['content_rich'] = content
371
372 if self.item_ori_mb_data is not None:
373 mb_data['id'] = self.item_ori_mb_data['id']
374
375 self.host.bridge.mbSend(self.pubsub_service, self.pubsub_node, mb_data, self.profile)
376 self.disp(u"Blog item published")
377
378 def getTmpSuff(self):
379 # we get current syntax to determine file extension
380 self.current_syntax = self.host.bridge.getParamA("Syntax", "Composition", "value", self.profile)
381 return SYNTAX_EXT.get(self.current_syntax, SYNTAX_EXT[''])
382
383 def getItemData(self, service, node, item):
384 items = [item] if item is not None else []
385 mb_data = self.host.bridge.mbGet(service, node, 1, items, {}, self.profile)[0][0]
475 try: 386 try:
476 # is there custom arguments in sat.conf ? 387 content = mb_data['content_xhtml']
477 editor_args = config.getConfig(sat_conf, 'jp', 'blog_editor_args', Exception) 388 except KeyError:
478 except (NoOptionError, NoSectionError): 389 content = mb_data['content']
479 # no, we check if we know the editor and have special arguments 390 if content:
480 editor_args = EDITOR_ARGS_MAGIC.get(os.path.basename(editor), '') 391 content = self.host.bridge.syntaxConvert(content, 'text', 'XHTML', False, self.profile)
481 args = self.parse_args(editor_args, content_file=content_file_path, metadata_file=meta_file_path) 392 if content and self.current_syntax != 'XHTML':
482 if not args: 393 content = self.host.bridge.syntaxConvert(content, 'XHTML', self.current_syntax, False, self.profile)
483 args = [content_file_path] 394 return content, mb_data
484 editor_exit = subprocess.call([editor] + args)
485
486 # we send the file if edition was a success
487 if editor_exit != 0:
488 self.disp(u"Editor exited with an error code, so temporary file has not be deleted, and blog item is not published.\nTou can find temporary file at {path}".format(
489 path=content_file_path), error=True)
490 else:
491 try:
492 with open(content_file_path, 'rb') as f:
493 content = f.read()
494 with open(meta_file_path, 'rb') as f:
495 mb_data = json.load(f)
496 except (OSError, IOError):
497 self.disp(u"Can read files at {content_path} and/or {meta_path}, have they been deleted?\nCancelling edition".format(
498 content_path=content_file_path, meta_path=meta_file_path), error=True)
499 self.host.quit(1)
500 except ValueError:
501 self.disp(u"Can't parse metadata, please check it is correct JSON format. Cancelling edition.\n" +
502 "You can find tmp file at {content_path} and temporary meta file at {meta_path}.".format(
503 content_path=content_file_path, meta_path=meta_file_path), error=True)
504 self.host.quit(1)
505
506 if not C.bool(mb_data.get('publish', "true")):
507 self.disp(u'Publication blocked by "publish" key in metadata, cancelling edition.\n\n' +
508 "temporary file path:\t{content_path}\nmetadata file path:\t{meta_path}".format(
509 content_path=content_file_path, meta_path=meta_file_path), error=True)
510 self.host.quit(0)
511
512 if len(content) == 0:
513 self.disp(u"Content is empty, cancelling the blog edition")
514 if not content_file_path.startswith(self.getTmpDir(sat_conf)):
515 self.disp(u"File are not in blog temporary hierarchy, we do not remove it", 2)
516 self.host.quit()
517 self.disp(u"Deletion of {}".format(content_file_path.decode('utf-8')), 2)
518 os.unlink(content_file_path)
519 self.disp(u"Deletion of {}".format(meta_file_path.decode('utf-8')), 2)
520 os.unlink(meta_file_path)
521 self.host.quit()
522
523 # time to re-check the hash
524 elif (tmp_ori_hash == hashlib.sha1(content).digest() and
525 meta_ori == mb_data):
526 self.disp(u"The content has not been modified, cancelling the blog edition")
527
528 else:
529 # we can now send the blog
530 mb_data['content_rich'] = content.decode('utf-8-sig') # we use utf-8-sig to avoid BOM
531
532 if item_ori_mb_data is not None:
533 mb_data['id'] = item_ori_mb_data['id']
534
535 try:
536 self.host.bridge.mbSend(pubsub_service, pubsub_node, mb_data, self.profile)
537 except Exception as e:
538 self.disp(u"Error while sending your blog, the temporary files have been kept at {content_path} and {meta_path}: {reason}".format(
539 content_path=content_file_path, meta_path=meta_file_path, reason=e), error=True)
540 self.host.quit(1)
541 else:
542 self.disp(u"Blog item published")
543
544 self.secureUnlink(sat_conf, content_file_path)
545 self.secureUnlink(sat_conf, meta_file_path)
546 395
547 def start(self): 396 def start(self):
548 command = self.args.item.lower()
549 sat_conf = config.parseMainConf()
550 # if there are user defined extension, we use them 397 # if there are user defined extension, we use them
551 SYNTAX_EXT.update(config.getConfig(sat_conf, 'jp', CONF_SYNTAX_EXT, {})) 398 SYNTAX_EXT.update(config.getConfig(self.sat_conf, 'jp', CONF_SYNTAX_EXT, {}))
552 current_syntax = None 399 self.current_syntax = None
553 pubsub_service = self.args.service 400
554 pubsub_node = self.args.node 401 self.pubsub_service, self.pubsub_node, content_file_path, content_file_obj, mb_data = self.getItemPath(self.args.item)
555 pubsub_item = None 402
556 403 self.edit(content_file_path, content_file_obj, mb_data=mb_data)
557 if command not in ('new', 'last', 'current'):
558 # we have probably an URL, we try to parse it
559 import urlparse
560 url = self.args.item
561 parsed_url = urlparse.urlsplit(url)
562 if parsed_url.scheme.startswith('http'):
563 self.disp(u"{} URL found, trying to find associated xmpp: URI".format(parsed_url.scheme.upper()),1)
564 # HTTP URL, we try to find xmpp: links
565 try:
566 from lxml import etree
567 except ImportError:
568 self.disp(u"lxml module must be installed to use http(s) scheme, please install it with \"pip install lxml\"", error=True)
569 self.host.quit(1)
570 parser = etree.HTMLParser()
571 root = etree.parse(url, parser)
572 links = root.xpath("//link[@rel='alternate' and starts-with(@href, 'xmpp:')]")
573 if not links:
574 self.disp(u'Could not find alternate "xmpp:" URI, can\'t find associated XMPP PubSub node/item', error=True)
575 self.host.quit(1)
576 url = links[0].get('href')
577 parsed_url = urlparse.urlsplit(url)
578
579 if parsed_url.scheme == 'xmpp':
580 if self.args.service or self.args.node:
581 self.parser.error(_(u"You can't use URI and --service or --node at the same time"))
582
583 self.disp(u"XMPP URI used: {}".format(url),2)
584 # XXX: if we have not xmpp: URI here, we'll take the data as a file path
585 pubsub_service = parsed_url.path
586 pubsub_data = urlparse.parse_qs(parsed_url.query)
587 try:
588 pubsub_node = pubsub_data['node'][0]
589 except KeyError:
590 self.disp(u'No node found in xmpp: URI, can\'t retrieve item', error=True)
591 self.host.quit(1)
592 pubsub_item = pubsub_data.get('item',[None])[0]
593 if pubsub_item is not None:
594 command = 'edit' # XXX: edit command is only used internaly, it similar to last, but with the item given in the URL
595 else:
596 command = 'new'
597
598 if command in ('new', 'last', 'edit'):
599 # we get current syntax to determine file extension
600 current_syntax = self.host.bridge.getParamA("Syntax", "Composition", "value", self.profile)
601 # we now create a temporary file
602 tmp_suff = '.' + SYNTAX_EXT.get(current_syntax, SYNTAX_EXT[''])
603 content_file_obj, content_file_path = self.getTmpFile(sat_conf, tmp_suff)
604 if command == 'new':
605 self.disp(u'Editing a new blog item', 2)
606 mb_data = None
607 elif command in ('last', 'edit'):
608 self.disp(u'Editing requested published item', 2)
609 try:
610 items_ids = [pubsub_item] if pubsub_item is not None else []
611 mb_data = self.host.bridge.mbGet(pubsub_service, pubsub_node, 1, items_ids, {}, self.profile)[0][0]
612 except Exception as e:
613 self.disp(u"Error while retrieving last item: {}".format(e))
614 self.host.quit(1)
615
616 content = mb_data['content_xhtml']
617 if content and current_syntax != 'XHTML':
618 content = self.host.bridge.syntaxConvert(content, 'XHTML', current_syntax, False, self.profile)
619 content_file_obj.write(content.encode('utf-8'))
620 content_file_obj.seek(0)
621 else:
622 mb_data = None
623 if command == 'current':
624 # use wants to continue current draft
625 content_file_path = self.getCurrentFile(sat_conf, self.profile)
626 self.disp(u'Continuing edition of current draft', 2)
627 else:
628 # we consider the item as a file path
629 content_file_path = os.path.expanduser(self.args.item)
630 content_file_obj = open(content_file_path, 'r+b')
631 current_syntax = self.guessSyntaxFromPath(sat_conf, content_file_path)
632
633 self.disp(u"Syntax used: {}".format(current_syntax), 1)
634 self.edit(sat_conf, content_file_path, content_file_obj, pubsub_service, pubsub_node, mb_data=mb_data)
635 404
636 405
637 class Preview(base.CommandBase, BlogCommon): 406 class Preview(base.CommandBase, BlogCommon):
638 407
639 def __init__(self, host): 408 def __init__(self, host):