Mercurial > libervia-backend
diff 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 |
line wrap: on
line diff
--- a/frontends/src/jp/cmd_blog.py Tue Jun 27 16:14:58 2017 +0200 +++ b/frontends/src/jp/cmd_blog.py Tue Jun 27 16:23:28 2017 +0200 @@ -21,6 +21,7 @@ import base from sat.core.i18n import _ from sat_frontends.jp.constants import Const as C +from sat_frontends.jp import common from sat.tools.common.ansi import ANSI as A from sat.tools.common import data_objects from sat.tools import config @@ -33,9 +34,7 @@ import tempfile import subprocess import shlex -import glob from sat.tools.common import data_format -from sat.tools.common import regex __commands__ = ["Blog"] @@ -46,27 +45,14 @@ "markdown": "md" } -# defaut arguments used for some known editors -VIM_SPLIT_ARGS = "-c 'vsplit|wincmd w|next|wincmd w'" -EMACS_SPLIT_ARGS = '--eval "(split-window-horizontally)"' -EDITOR_ARGS_MAGIC = { - 'vim': VIM_SPLIT_ARGS + ' {content_file} {metadata_file}', - 'gvim': VIM_SPLIT_ARGS + ' --nofork {content_file} {metadata_file}', - 'emacs': EMACS_SPLIT_ARGS + ' {content_file} {metadata_file}', - 'xemacs': EMACS_SPLIT_ARGS + ' {content_file} {metadata_file}', - 'nano': ' -F {content_file} {metadata_file}', - } CONF_SYNTAX_EXT = 'syntax_ext_dict' -BLOG_TMP_DIR="blog" -METADATA_SUFF = '_metadata.json' +BLOG_TMP_DIR=u"blog" # key to remove from metadata tmp file if they exist KEY_TO_REMOVE_METADATA = ('id','content', 'content_xhtml', 'comments_node', 'comments_service', 'updated') URL_REDIRECT_PREFIX = 'url_redirect_' INOTIFY_INSTALL = '"pip install inotify"' -SECURE_UNLINK_MAX = 10 * 2 # we double value as there are 2 files per draft (content and metadata) -SECURE_UNLINK_DIR = ".backup" MB_KEYS = (u"id", u"atom_id", u"updated", @@ -94,70 +80,6 @@ self.parser.add_argument("-n", "--node", type=base.unicode_decoder, default=u'', help=_(u"PubSub node to request (default: microblog namespace)")) self.parser.add_argument("-s", "--service", type=base.unicode_decoder, default=u'', help=_(u"JID of the PubSub service (default: request profile own blog)")) - def getTmpDir(self, sat_conf, sub_dir=None): - """Return directory used to store temporary files - - @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration - @param sub_dir(str): sub directory where data need to be put - profile can be used here, or special directory name - sub_dir will be escaped to be usable in path (use regex.pathUnescape to find - initial str) - @return (str): path to the dir - """ - local_dir = config.getConfig(sat_conf, '', 'local_dir', Exception) - path = [local_dir, BLOG_TMP_DIR] - if sub_dir is not None: - path.append(regex.pathEscape(sub_dir)) - return os.path.join(*path) - - def getCurrentFile(self, sat_conf, profile): - """Get most recently edited file - - @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration - @param profile(unicode): profile linked to the blog draft - @return(str): full path of current file - """ - # we guess the blog item currently edited by choosing - # the most recent file corresponding to temp file pattern - # in tmp_dir, excluding metadata files - tmp_dir = self.getTmpDir(sat_conf, profile.encode('utf-8')) - available = [path for path in glob.glob(os.path.join(tmp_dir, 'blog_*')) if not path.endswith(METADATA_SUFF)] - if not available: - self.disp(u"Counldn't find any content draft in {path}".format(path=tmp_dir), error=True) - self.host.quit(1) - return max(available, key=lambda path: os.stat(path).st_mtime) - - def secureUnlink(self, sat_conf, path): - """Unlink given path after keeping it for a while - - This method is used to prevent accidental deletion of a blog draft - If there are more file in SECURE_UNLINK_DIR than SECURE_UNLINK_MAX, - older file are deleted - @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration - @param path(str): file to unlink - """ - if not os.path.isfile(path): - raise OSError(u"path must link to a regular file") - if not path.startswith(self.getTmpDir(sat_conf)): - self.disp(u"File {} is not in blog temporary hierarchy, we do not remove it".format(path.decode('utf-8')), 2) - return - backup_dir = self.getTmpDir(sat_conf, SECURE_UNLINK_DIR) - if not os.path.exists(backup_dir): - os.makedirs(backup_dir) - filename = os.path.basename(path) - backup_path = os.path.join(backup_dir, filename) - # we move file to backup dir - self.host.disp(u"Backuping file {src} to {dst}".format( - src=path.decode('utf-8'), dst=backup_path.decode('utf-8')), 1) - os.rename(path, backup_path) - # and if we exceeded the limit, we remove older file - backup_files = [os.path.join(backup_dir, f) for f in os.listdir(backup_dir)] - if len(backup_files) > SECURE_UNLINK_MAX: - backup_files.sort(key=lambda path: os.stat(path).st_mtime) - for path in backup_files[:len(backup_files) - SECURE_UNLINK_MAX]: - self.host.disp(u"Purging backup file {}".format(path.decode('utf-8')), 2) - os.unlink(path) - def guessSyntaxFromPath(self, sat_conf, path): """Return syntax guessed according to filename extension @@ -365,11 +287,12 @@ errback=self.mbGetEb) -class Edit(base.CommandBase, BlogCommon): +class Edit(base.CommandBase, BlogCommon, common.BaseEdit): def __init__(self, host): base.CommandBase.__init__(self, host, 'edit', use_verbose=True, help=_(u'edit an existing or new blog post')) BlogCommon.__init__(self, self.host) + common.BaseEdit.__init__(self, self.host, BLOG_TMP_DIR, use_metadata=True) def add_parser_options(self): self.addServiceNodeArgs() @@ -379,30 +302,6 @@ self.parser.add_argument("-t", '--tag', type=base.unicode_decoder, action='append', help=_(u"tag (category) of your item")) self.parser.add_argument("--no-comment", action='store_true', help=_(u"disable comments")) - def getTmpFile(self, sat_conf, tmp_suff): - """Create a temporary file to received blog item body - - @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration - @param tmp_suff (str): suffix to use for the filename - @return (tuple(file, str)): opened (w+b) file object and file path - """ - tmp_dir = self.getTmpDir(sat_conf, self.profile.encode('utf-8')) - if not os.path.exists(tmp_dir): - try: - os.makedirs(tmp_dir) - except OSError as e: - self.disp(u"Can't create {path} directory: {reason}".format( - path=tmp_dir, reason=e), error=True) - self.host.quit(1) - try: - fd, path = tempfile.mkstemp(suffix=tmp_suff, - prefix=time.strftime('blog_%Y-%m-%d_%H:%M:%S_'), - dir=tmp_dir, text=True) - return os.fdopen(fd, 'w+b'), path - except OSError as e: - self.disp(u"Can't create temporary file: {reason}".format(reason=e), error=True) - self.host.quit(1) - def buildMetadataFile(self, content_file_path, mb_data=None): """Build a metadata file using json @@ -414,7 +313,7 @@ """ # we first construct metadata from edited item ones and CLI argumments # or re-use the existing one if it exists - meta_file_path = os.path.splitext(content_file_path)[0] + METADATA_SUFF + meta_file_path = os.path.splitext(content_file_path)[0] + common.METADATA_SUFF if os.path.exists(meta_file_path): self.disp(u"Metadata file already exists, we re-use it") try: @@ -449,18 +348,12 @@ return mb_data, meta_file_path - def edit(self, sat_conf, content_file_path, content_file_obj, - pubsub_service, pubsub_node, mb_data=None): + def edit(self, content_file_path, content_file_obj, + mb_data=None): """Edit the file contening the content using editor, and publish it""" - item_ori_mb_data = mb_data + self.item_ori_mb_data = mb_data # we first create metadata file - meta_ori, meta_file_path = self.buildMetadataFile(content_file_path, item_ori_mb_data) - - # then we calculate hashes to check for modifications - import hashlib - content_file_obj.seek(0) - tmp_ori_hash = hashlib.sha1(content_file_obj.read()).digest() - content_file_obj.close() + meta_ori, meta_file_path = self.buildMetadataFile(content_file_path, self.item_ori_mb_data) # do we need a preview ? if self.args.preview: @@ -470,168 +363,44 @@ DEVNULL = open(os.devnull, 'wb') subprocess.Popen([sys.argv[0], "blog", "preview", "--inotify", "true", "-p", self.profile, content_file_path], stdout=DEVNULL, stderr=subprocess.STDOUT) - # then we launch editor - editor = config.getConfig(sat_conf, 'jp', 'editor') or os.getenv('EDITOR', 'vi') - try: - # is there custom arguments in sat.conf ? - editor_args = config.getConfig(sat_conf, 'jp', 'blog_editor_args', Exception) - except (NoOptionError, NoSectionError): - # no, we check if we know the editor and have special arguments - editor_args = EDITOR_ARGS_MAGIC.get(os.path.basename(editor), '') - args = self.parse_args(editor_args, content_file=content_file_path, metadata_file=meta_file_path) - if not args: - args = [content_file_path] - editor_exit = subprocess.call([editor] + args) + # we launch editor + self.runEditor("blog_editor_args", content_file_path, content_file_obj, meta_file_path=meta_file_path, meta_ori=meta_ori) + + def publish(self, content, mb_data): + mb_data['content_rich'] = content - # we send the file if edition was a success - if editor_exit != 0: - 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( - path=content_file_path), error=True) - else: - try: - with open(content_file_path, 'rb') as f: - content = f.read() - with open(meta_file_path, 'rb') as f: - mb_data = json.load(f) - except (OSError, IOError): - self.disp(u"Can read files at {content_path} and/or {meta_path}, have they been deleted?\nCancelling edition".format( - content_path=content_file_path, meta_path=meta_file_path), error=True) - self.host.quit(1) - except ValueError: - self.disp(u"Can't parse metadata, please check it is correct JSON format. Cancelling edition.\n" + - "You can find tmp file at {content_path} and temporary meta file at {meta_path}.".format( - content_path=content_file_path, meta_path=meta_file_path), error=True) - self.host.quit(1) + if self.item_ori_mb_data is not None: + mb_data['id'] = self.item_ori_mb_data['id'] + + self.host.bridge.mbSend(self.pubsub_service, self.pubsub_node, mb_data, self.profile) + self.disp(u"Blog item published") - if not C.bool(mb_data.get('publish', "true")): - self.disp(u'Publication blocked by "publish" key in metadata, cancelling edition.\n\n' + - "temporary file path:\t{content_path}\nmetadata file path:\t{meta_path}".format( - content_path=content_file_path, meta_path=meta_file_path), error=True) - self.host.quit(0) - - if len(content) == 0: - self.disp(u"Content is empty, cancelling the blog edition") - if not content_file_path.startswith(self.getTmpDir(sat_conf)): - self.disp(u"File are not in blog temporary hierarchy, we do not remove it", 2) - self.host.quit() - self.disp(u"Deletion of {}".format(content_file_path.decode('utf-8')), 2) - os.unlink(content_file_path) - self.disp(u"Deletion of {}".format(meta_file_path.decode('utf-8')), 2) - os.unlink(meta_file_path) - self.host.quit() + def getTmpSuff(self): + # we get current syntax to determine file extension + self.current_syntax = self.host.bridge.getParamA("Syntax", "Composition", "value", self.profile) + return SYNTAX_EXT.get(self.current_syntax, SYNTAX_EXT['']) - # time to re-check the hash - elif (tmp_ori_hash == hashlib.sha1(content).digest() and - meta_ori == mb_data): - self.disp(u"The content has not been modified, cancelling the blog edition") - - else: - # we can now send the blog - mb_data['content_rich'] = content.decode('utf-8-sig') # we use utf-8-sig to avoid BOM - - if item_ori_mb_data is not None: - mb_data['id'] = item_ori_mb_data['id'] - - try: - self.host.bridge.mbSend(pubsub_service, pubsub_node, mb_data, self.profile) - except Exception as e: - self.disp(u"Error while sending your blog, the temporary files have been kept at {content_path} and {meta_path}: {reason}".format( - content_path=content_file_path, meta_path=meta_file_path, reason=e), error=True) - self.host.quit(1) - else: - self.disp(u"Blog item published") - - self.secureUnlink(sat_conf, content_file_path) - self.secureUnlink(sat_conf, meta_file_path) + def getItemData(self, service, node, item): + items = [item] if item is not None else [] + mb_data = self.host.bridge.mbGet(service, node, 1, items, {}, self.profile)[0][0] + try: + content = mb_data['content_xhtml'] + except KeyError: + content = mb_data['content'] + if content: + content = self.host.bridge.syntaxConvert(content, 'text', 'XHTML', False, self.profile) + if content and self.current_syntax != 'XHTML': + content = self.host.bridge.syntaxConvert(content, 'XHTML', self.current_syntax, False, self.profile) + return content, mb_data def start(self): - command = self.args.item.lower() - sat_conf = config.parseMainConf() # if there are user defined extension, we use them - SYNTAX_EXT.update(config.getConfig(sat_conf, 'jp', CONF_SYNTAX_EXT, {})) - current_syntax = None - pubsub_service = self.args.service - pubsub_node = self.args.node - pubsub_item = None - - if command not in ('new', 'last', 'current'): - # we have probably an URL, we try to parse it - import urlparse - url = self.args.item - parsed_url = urlparse.urlsplit(url) - if parsed_url.scheme.startswith('http'): - self.disp(u"{} URL found, trying to find associated xmpp: URI".format(parsed_url.scheme.upper()),1) - # HTTP URL, we try to find xmpp: links - try: - from lxml import etree - except ImportError: - self.disp(u"lxml module must be installed to use http(s) scheme, please install it with \"pip install lxml\"", error=True) - self.host.quit(1) - parser = etree.HTMLParser() - root = etree.parse(url, parser) - links = root.xpath("//link[@rel='alternate' and starts-with(@href, 'xmpp:')]") - if not links: - self.disp(u'Could not find alternate "xmpp:" URI, can\'t find associated XMPP PubSub node/item', error=True) - self.host.quit(1) - url = links[0].get('href') - parsed_url = urlparse.urlsplit(url) - - if parsed_url.scheme == 'xmpp': - if self.args.service or self.args.node: - self.parser.error(_(u"You can't use URI and --service or --node at the same time")) + SYNTAX_EXT.update(config.getConfig(self.sat_conf, 'jp', CONF_SYNTAX_EXT, {})) + self.current_syntax = None - self.disp(u"XMPP URI used: {}".format(url),2) - # XXX: if we have not xmpp: URI here, we'll take the data as a file path - pubsub_service = parsed_url.path - pubsub_data = urlparse.parse_qs(parsed_url.query) - try: - pubsub_node = pubsub_data['node'][0] - except KeyError: - self.disp(u'No node found in xmpp: URI, can\'t retrieve item', error=True) - self.host.quit(1) - pubsub_item = pubsub_data.get('item',[None])[0] - if pubsub_item is not None: - command = 'edit' # XXX: edit command is only used internaly, it similar to last, but with the item given in the URL - else: - command = 'new' + self.pubsub_service, self.pubsub_node, content_file_path, content_file_obj, mb_data = self.getItemPath(self.args.item) - if command in ('new', 'last', 'edit'): - # we get current syntax to determine file extension - current_syntax = self.host.bridge.getParamA("Syntax", "Composition", "value", self.profile) - # we now create a temporary file - tmp_suff = '.' + SYNTAX_EXT.get(current_syntax, SYNTAX_EXT['']) - content_file_obj, content_file_path = self.getTmpFile(sat_conf, tmp_suff) - if command == 'new': - self.disp(u'Editing a new blog item', 2) - mb_data = None - elif command in ('last', 'edit'): - self.disp(u'Editing requested published item', 2) - try: - items_ids = [pubsub_item] if pubsub_item is not None else [] - mb_data = self.host.bridge.mbGet(pubsub_service, pubsub_node, 1, items_ids, {}, self.profile)[0][0] - except Exception as e: - self.disp(u"Error while retrieving last item: {}".format(e)) - self.host.quit(1) - - content = mb_data['content_xhtml'] - if content and current_syntax != 'XHTML': - content = self.host.bridge.syntaxConvert(content, 'XHTML', current_syntax, False, self.profile) - content_file_obj.write(content.encode('utf-8')) - content_file_obj.seek(0) - else: - mb_data = None - if command == 'current': - # use wants to continue current draft - content_file_path = self.getCurrentFile(sat_conf, self.profile) - self.disp(u'Continuing edition of current draft', 2) - else: - # we consider the item as a file path - content_file_path = os.path.expanduser(self.args.item) - content_file_obj = open(content_file_path, 'r+b') - current_syntax = self.guessSyntaxFromPath(sat_conf, content_file_path) - - self.disp(u"Syntax used: {}".format(current_syntax), 1) - self.edit(sat_conf, content_file_path, content_file_obj, pubsub_service, pubsub_node, mb_data=mb_data) + self.edit(content_file_path, content_file_obj, mb_data=mb_data) class Preview(base.CommandBase, BlogCommon):