# HG changeset patch # User Goffi # Date 1498573408 -7200 # Node ID 606ff34d30f2e1727ce1ecd05e237c2768c549b4 # Parent a29d1351bc830697d4fbf818120ba4dff7ef049d 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). diff -r a29d1351bc83 -r 606ff34d30f2 frontends/src/jp/cmd_blog.py --- 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): diff -r a29d1351bc83 -r 606ff34d30f2 frontends/src/jp/common.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/frontends/src/jp/common.py Tue Jun 27 16:23:28 2017 +0200 @@ -0,0 +1,401 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# jp: a SàT command line tool +# Copyright (C) 2009-2016 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from sat_frontends.jp.constants import Const as C +from sat.core.i18n import _ +from sat.tools.common import regex +from sat.tools import config +from ConfigParser import NoSectionError, NoOptionError +import json +import os +import os.path +import time +import tempfile +import subprocess +import glob + +# defaut arguments used for some known editors (editing with metadata) +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}', + } + +SECURE_UNLINK_MAX = 10 +SECURE_UNLINK_DIR = ".backup" +METADATA_SUFF = '_metadata.json' + + +def getTmpDir(sat_conf, cat_dir, sub_dir=None): + """Return directory used to store temporary files + + @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration + @param cat_dir(str): directory of the category (e.g. "blog") + @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, cat_dir] + if sub_dir is not None: + path.append(regex.pathEscape(sub_dir)) + return os.path.join(*path) + + +class BaseEdit(object): + u"""base class for editing commands + + This class allows to edit file for PubSub or something else. + It works with temporary files in SàT local_dir, in a "cat_dir" subdir + """ + + def __init__(self, host, cat_dir, use_metadata=True): + """ + @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration + @param cat_dir(unicode): directory to use for drafts + this will be a sub-directory of SàT's local_dir + @param use_metadata(bool): True is edition need a second file for metadata + most of signature change with use_metadata with an additional metadata argument. + This is done to raise error if a command needs metadata but forget the flag, and vice versa + """ + self.host = host + self.sat_conf = config.parseMainConf() + self.cat_dir = cat_dir.encode('utf-8') + self.use_metadata = use_metadata + + def secureUnlink(self, path): + """Unlink given path after keeping it for a while + + This method is used to prevent accidental deletion of a draft + If there are more file in SECURE_UNLINK_DIR than SECURE_UNLINK_MAX, + older file are deleted + @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(getTmpDir(self.sat_conf, self.cat_dir)): + self.disp(u"File {} is not in SàT temporary hierarchy, we do not remove it".format(path.decode('utf-8')), 2) + return + # we have 2 files per draft with use_metadata, so we double max + unlink_max = SECURE_UNLINK_MAX * 2 if self.use_metadata else SECURE_UNLINK_MAX + backup_dir = getTmpDir(self.sat_conf, self.cat_dir, 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) > unlink_max: + backup_files.sort(key=lambda path: os.stat(path).st_mtime) + for path in backup_files[:len(backup_files) - unlink_max]: + self.host.disp(u"Purging backup file {}".format(path.decode('utf-8')), 2) + os.unlink(path) + + def runEditor(self, editor_args_opt, content_file_path, + content_file_obj, meta_file_path=None, meta_ori=None): + """run editor to edit content and metadata + + @param editor_args_opt(unicode): option in [jp] section in configuration for + specific args + @param content_file_path(str): path to the content file + @param content_file_obj(file): opened file instance + @param meta_file_path(str, None): metadata file path + if None metadata will not be used + @param meta_ori(dict, None): original cotent of metadata + can't be used if use_metadata is False + """ + if not self.use_metadata: + assert meta_file_path is None + assert meta_ori is None + + # 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() + + # we prepare arguments + editor = config.getConfig(self.sat_conf, 'jp', 'editor') or os.getenv('EDITOR', 'vi') + try: + # is there custom arguments in sat.conf ? + editor_args = config.getConfig(self.sat_conf, 'jp', editor_args_opt, 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), '') + parse_kwargs = {'content_file': content_file_path} + if self.use_metadata: + parse_kwargs['metadata_file'] = meta_file_path + args = self.parse_args(editor_args, **parse_kwargs) + if not args: + args = [content_file_path] + + # actual editing + editor_exit = subprocess.call([editor] + args) + + # edition will now be checked, and data will be sent if it was a success + if editor_exit != 0: + 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( + path=content_file_path), error=True) + else: + # main content + try: + with open(content_file_path, 'rb') as f: + content = f.read() + except (OSError, IOError): + self.disp(u"Can read file at {content_path}, have it been deleted?\nCancelling edition".format( + content_path=content_file_path), error=True) + self.host.quit(C.EXIT_NOT_FOUND) + + # metadata + if self.use_metadata: + try: + with open(meta_file_path, 'rb') as f: + metadata = json.load(f) + except (OSError, IOError): + self.disp(u"Can read file at {meta_file_path}, have it been deleted?\nCancelling edition".format( + content_path=content_file_path, meta_path=meta_file_path), error=True) + self.host.quit(C.EXIT_NOT_FOUND) + 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(C.EXIT_DATA_ERROR) + + if self.use_metadata and not C.bool(metadata.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() + + if len(content) == 0: + self.disp(u"Content is empty, cancelling the blog edition") + if not content_file_path.startswith(getTmpDir(self.sat_conf, self.cat_dir)): + self.disp(u"File are not in SàT temporary hierarchy, we do not remove them", 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() + + # time to re-check the hash + elif (tmp_ori_hash == hashlib.sha1(content).digest() and + (not self.use_metadata or meta_ori == metadata)): + self.disp(u"The content has not been modified, cancelling the blog edition") + self.host.quit() + + else: + # we can now send the item + content = content.decode('utf-8-sig') # we use utf-8-sig to avoid BOM + try: + if self.use_metadata: + self.publish(content, metadata) + else: + self.publish(content) + except Exception as e: + if self.use_metadata: + self.disp(u"Error while sending your item, 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) + else: + self.disp(u"Error while sending your item, the temporary file has been kept at {content_path}: {reason}".format( + content_path=content_file_path, reason=e), error=True) + self.host.quit(1) + + self.secureUnlink(content_file_path) + self.secureUnlink(meta_file_path) + + def publish(self, content): + # if metadata is needed, publish will be called with it last argument + raise NotImplementedError + + def getTmpFile(self, suff): + """Create a temporary file + + @param suff (str): suffix to use for the filename + @return (tuple(file, str)): opened (w+b) file object and file path + """ + tmp_dir = getTmpDir(self.sat_conf, self.cat_dir, 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=suff, + prefix=time.strftime(self.cat_dir.encode('utf-8') + '_%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 getCurrentFile(self, profile): + """Get most recently edited file + + @param profile(unicode): profile linked to the 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 + cat_dir_str = self.cat_dir.encode('utf-8') + tmp_dir = getTmpDir(self.sat_conf, cat_dir_str, profile.encode('utf-8')) + available = [path for path in glob.glob(os.path.join(tmp_dir, cat_dir_str + '_*')) if not path.endswith(METADATA_SUFF)] + if not available: + self.disp(u"Could not 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 getItemData(self, service, node, item): + """return formatted content and metadata (or not if use_metadata is false)""" + raise NotImplementedError + + def getTmpSuff(self): + """return suffix used for content file""" + return 'xml' + + def getItemPath(self, item): + """retrieve item path (i.e. service and node) from item argument + + This method is obviously only useful for edition of PubSub based features + service, node and item must be named like this in args + @param item(unicode): item to get or url or magic keyword + item argument can be used to specify : + - HTTP(S) URL + - XMPP URL + - keyword, which can be: + - new: create new item + - last: retrieve last published item + - current: continue current local draft + - file path + - item id + """ + command = item.lower() + 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) + import urllib2 + parser = etree.HTMLParser() + try: + root = etree.parse(urllib2.urlopen(url), parser) + except etree.XMLSyntaxError as e: + self.disp(_(u"Can't parse HTML page : {msg}").format(msg=e)) + links = [] + else: + 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")) + + 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' + + if command in ('new', 'last', 'edit'): + # we need a temporary file + tmp_suff = '.' + self.getTmpSuff() + content_file_obj, content_file_path = self.getTmpFile(tmp_suff) + if command == 'new': + self.disp(u'Editing a new item', 2) + if self.use_metadata: + metadata = None + elif command in ('last', 'edit'): + self.disp(u'Editing requested published item', 2) + try: + if self.use_metadata: + content, metadata = self.getItemData(pubsub_service, pubsub_node, pubsub_item) + else: + content = self.getItemData(pubsub_service, pubsub_node, pubsub_item) + except Exception as e: + self.disp(u"Error while retrieving last item: {}".format(e)) + self.host.quit(1) + content_file_obj.write(content.encode('utf-8')) + content_file_obj.seek(0) + else: + if self.use_metadata: + metadata = None + if command == 'current': + # user wants to continue current draft + content_file_path = self.getCurrentFile(self.profile) + self.disp(u'Continuing edition of current draft', 2) + content_file_obj = open(content_file_path, 'r+b') + elif os.path.isfile(self.args.item): + # there is an existing draft that we use + content_file_path = os.path.expanduser(self.args.item) + content_file_obj = open(content_file_path, 'r+b') + else: + # last chance, it should be an item + tmp_suff = '.' + self.getTmpSuff() + content_file_obj, content_file_path = self.getTmpFile(tmp_suff) + + if self.use_metadata: + content, metadata = self.getItemData(pubsub_service, pubsub_node, self.args.item) + else: + content = self.getItemData(pubsub_service, pubsub_node, self.args.item) + content_file_obj.write(content.encode('utf-8')) + content_file_obj.seek(0) + + if self.use_metadata: + return pubsub_service, pubsub_node, content_file_path, content_file_obj, metadata + else: + return pubsub_service, pubsub_node, content_file_path, content_file_obj