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).
This commit is contained in:
Goffi 2017-06-27 16:23:28 +02:00
commit 5eda63d99e

View file

@ -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 time
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 @@ SYNTAX_EXT = {
"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 @@ class BlogCommon(object):
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 @@ class Get(base.CommandBase, BlogCommon):
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 @@ class Edit(base.CommandBase, BlogCommon):
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 @@ class Edit(base.CommandBase, BlogCommon):
"""
# 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 @@ class Edit(base.CommandBase, BlogCommon):
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 @@ class Edit(base.CommandBase, BlogCommon):
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')
# 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
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")
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[''])
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:
# 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 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 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()
# 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)
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
SYNTAX_EXT.update(config.getConfig(self.sat_conf, 'jp', CONF_SYNTAX_EXT, {}))
self.current_syntax = 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)
self.pubsub_service, self.pubsub_node, content_file_path, content_file_obj, mb_data = self.getItemPath(self.args.item)
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 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):