changeset 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 (2017-06-27)
parents a29d1351bc83
children 07caa12be945
files frontends/src/jp/cmd_blog.py frontends/src/jp/common.py
diffstat 2 files changed, 440 insertions(+), 270 deletions(-) [+]
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):
--- /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 <http://www.gnu.org/licenses/>.
+
+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