comparison frontends/src/jp/cmd_blog.py @ 1872:df1ca137b0cb

jp (blog/edit): editor arguments can now be specified on sat.conf, and default on are applied for known editors: - vim and gvim will open content and metadata file in a splitted window - gvim use --nofork option - installed a workaround for shlex.split not handling unicode before Python 2.7.3 - some fixes
author Goffi <goffi@goffi.org>
date Thu, 03 Mar 2016 15:57:06 +0100
parents 64a40adccba4
children 6ec54626610c
comparison
equal deleted inserted replaced
1871:64a40adccba4 1872:df1ca137b0cb
20 20
21 import base 21 import base
22 from sat.core.i18n import _ 22 from sat.core.i18n import _
23 from sat.core.constants import Const as C 23 from sat.core.constants import Const as C
24 from sat.tools import config 24 from sat.tools import config
25 from ConfigParser import NoSectionError, NoOptionError
25 import json 26 import json
26 import os.path 27 import os.path
27 import os 28 import os
28 import time 29 import time
29 import tempfile 30 import tempfile
30 import subprocess 31 import subprocess
32 import shlex
31 from sat.tools import common 33 from sat.tools import common
32 34
33 __commands__ = ["Blog"] 35 __commands__ = ["Blog"]
34 36
35 SYNTAX_EXT = { '': 'txt', # used when the syntax is not found 37 # extensions to use with known syntaxes
36 "XHTML": "xhtml", 38 SYNTAX_EXT = {
37 "markdown": "md" 39 '': 'txt', # used when the syntax is not found
38 } 40 "XHTML": "xhtml",
41 "markdown": "md"
42 }
43
44 # defaut arguments used for some known editors
45 VIM_SPLIT_ARGS="-c 'vsplit|wincmd w|next|wincmd w'"
46 EDITOR_ARGS_MAGIC = {
47 'vim': VIM_SPLIT_ARGS + ' {content_file} {metadata_file}',
48 'gvim': VIM_SPLIT_ARGS + ' --nofork {content_file} {metadata_file}',
49 }
50
39 CONF_SYNTAX_EXT = 'syntax_ext_dict' 51 CONF_SYNTAX_EXT = 'syntax_ext_dict'
40 BLOG_TMP_DIR="blog" 52 BLOG_TMP_DIR="blog"
41 # key to remove from metadata tmp file if they exist 53 # key to remove from metadata tmp file if they exist
42 KEY_TO_REMOVE_METADATA = ('id','content', 'content_xhtml', 'comments_node', 'comments_service') 54 KEY_TO_REMOVE_METADATA = ('id','content', 'content_xhtml', 'comments_node', 'comments_service')
43 55
78 return os.fdopen(fd, 'w+b'), path 90 return os.fdopen(fd, 'w+b'), path
79 except OSError as e: 91 except OSError as e:
80 self.disp(u"Can't create temporary file: {reason}".format(reason=e), error=True) 92 self.disp(u"Can't create temporary file: {reason}".format(reason=e), error=True)
81 self.host.quit(1) 93 self.host.quit(1)
82 94
83 def buildMetadataFile(self, tmp_file_path, mb_data=None): 95 def buildMetadataFile(self, content_file_path, mb_data=None):
84 """Build a metadata file using json 96 """Build a metadata file using json
85 97
86 The file is named after tmp_file_path, with extension replaced by _metadata.json 98 The file is named after content_file_path, with extension replaced by _metadata.json
87 @param tmp_file_path(str): path to the temporary file which will contain the body 99 @param content_file_path(str): path to the temporary file which will contain the body
88 @param mb_data(dict, None): microblog metadata (for existing items) 100 @param mb_data(dict, None): microblog metadata (for existing items)
89 @return (tuple[dict, str]): merged metadata put originaly in metadata file 101 @return (tuple[dict, str]): merged metadata put originaly in metadata file
90 and path to temporary metadata file 102 and path to temporary metadata file
91 """ 103 """
92 # we first construct metadata from edited item ones and CLI argumments 104 # we first construct metadata from edited item ones and CLI argumments
101 common.iter2dict('tag', self.args.tag, mb_data) 113 common.iter2dict('tag', self.args.tag, mb_data)
102 if self.args.title is not None: 114 if self.args.title is not None:
103 mb_data['title'] = self.args.title 115 mb_data['title'] = self.args.title
104 116
105 # the we create the file and write metadata there, as JSON dict 117 # the we create the file and write metadata there, as JSON dict
106 meta_file_path = os.path.splitext(tmp_file_path)[0] + '_metadata.json' 118 meta_file_path = os.path.splitext(content_file_path)[0] + '_metadata.json'
107 # XXX: if we port jp one day on Windows, O_BINARY may need to be added here 119 # XXX: if we port jp one day on Windows, O_BINARY may need to be added here
108 if os.path.exists(meta_file_path): 120 if os.path.exists(meta_file_path):
109 self.disp(u"metadata file {} already exists, this should not happen! Cancelling...", error=True) 121 self.disp(u"metadata file {} already exists, this should not happen! Cancelling...", error=True)
110 with os.fdopen(os.open(meta_file_path, os.O_RDWR | os.O_CREAT ,0o600), 'w+b') as f: 122 with os.fdopen(os.open(meta_file_path, os.O_RDWR | os.O_CREAT ,0o600), 'w+b') as f:
111 # we need to use an intermediate unicode buffer to write to the file unicode without escaping characters 123 # we need to use an intermediate unicode buffer to write to the file unicode without escaping characters
112 unicode_dump = json.dumps(mb_data, ensure_ascii=False, indent=4, separators=(',', ': '), sort_keys=True) 124 unicode_dump = json.dumps(mb_data, ensure_ascii=False, indent=4, separators=(',', ': '), sort_keys=True)
113 f.write(unicode_dump.encode('utf-8')) 125 f.write(unicode_dump.encode('utf-8'))
114 126
115 return mb_data, meta_file_path 127 return mb_data, meta_file_path
116 128
117 def edit(self, sat_conf, tmp_file_path, tmp_file_obj, mb_data=None): 129 def edit(self, sat_conf, content_file_path, content_file_obj, mb_data=None):
118 """Edit the file contening the content using editor, and publish it""" 130 """Edit the file contening the content using editor, and publish it"""
119 item_ori_mb_data = mb_data 131 item_ori_mb_data = mb_data
120 # we first create metadata file 132 # we first create metadata file
121 meta_ori, meta_file_path = self.buildMetadataFile(tmp_file_path, item_ori_mb_data) 133 meta_ori, meta_file_path = self.buildMetadataFile(content_file_path, item_ori_mb_data)
122 134
123 # then we calculate hashes to check for modifications 135 # then we calculate hashes to check for modifications
124 import hashlib 136 import hashlib
125 tmp_file_obj.seek(0) 137 content_file_obj.seek(0)
126 tmp_ori_hash = hashlib.sha1(tmp_file_obj.read()).digest() 138 tmp_ori_hash = hashlib.sha1(content_file_obj.read()).digest()
127 tmp_file_obj.close() 139 content_file_obj.close()
128 140
129 # then we launch editor 141 # then we launch editor
130 editor = config.getConfig(sat_conf, 'jp', 'editor') or os.getenv('EDITOR', 'vi') 142 editor = config.getConfig(sat_conf, 'jp', 'editor') or os.getenv('EDITOR', 'vi')
131 editor_exit = subprocess.call([editor, tmp_file_path]) 143 try:
144 # is there custom arguments in sat.conf ?
145 editor_args = config.getConfig(sat_conf, 'jp', 'blog_editor_args', Exception)
146 except (NoOptionError, NoSectionError):
147 # no, we check if we know the editor and have special arguments
148 editor_args = EDITOR_ARGS_MAGIC.get(os.path.basename(editor), '')
149 try:
150 # we split the arguments and add the known fields
151 # we split arguments first to avoid escaping issues in file names
152 args = [a.format(content_file=content_file_path, metadata_file=meta_file_path) for a in shlex.split(editor_args)]
153 except ValueError as e:
154 self.disp(u"Couldn't parse editor cmd [{cmd}]: {reason}".format(cmd=editor_args, reason=e))
155 args = []
156 if not args:
157 args = [content_file_path]
158 editor_exit = subprocess.call([editor] + args)
132 159
133 # we send the file if edition was a success 160 # we send the file if edition was a success
134 if editor_exit != 0: 161 if editor_exit != 0:
135 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( 162 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(
136 path=tmp_file_path), error=True) 163 path=content_file_path), error=True)
137 else: 164 else:
138 try: 165 try:
139 with open(tmp_file_path, 'rb') as f: 166 with open(content_file_path, 'rb') as f:
140 content = f.read() 167 content = f.read()
141 with open(meta_file_path, 'rb') as f: 168 with open(meta_file_path, 'rb') as f:
142 mb_data = json.load(f) 169 mb_data = json.load(f)
143 except OSError: 170 except (OSError, IOError):
144 self.disp(u"Can write files at {file_path} and/or {meta_path}, have they been deleted? Cancelling edition".format( 171 self.disp(u"Can read files at {content_path} and/or {meta_path}, have they been deleted?\nCancelling edition".format(
145 file_path=tmp_file_path, meta_path=meta_file_path), error=True) 172 content_path=content_file_path, meta_path=meta_file_path), error=True)
146 self.host.quit(1) 173 self.host.quit(1)
147 except ValueError: 174 except ValueError:
148 self.disp(u"Can't parse metadata, please check it is correct JSON format. Cancelling edition.\n" + 175 self.disp(u"Can't parse metadata, please check it is correct JSON format. Cancelling edition.\n" +
149 "You can find tmp file at {file_path} and temporary meta file at {meta_path}.".format( 176 "You can find tmp file at {content_path} and temporary meta file at {meta_path}.".format(
150 file_path=tmp_file_path, meta_path=meta_file_path), error=True) 177 content_path=content_file_path, meta_path=meta_file_path), error=True)
151 self.host.quit(1) 178 self.host.quit(1)
152 179
153 if not C.bool(mb_data.get('publish', "true")): 180 if not C.bool(mb_data.get('publish', "true")):
154 self.disp(u'Publication blocked by "publish" key in metadata, cancelling edition.\n\n' + 181 self.disp(u'Publication blocked by "publish" key in metadata, cancelling edition.\n\n' +
155 "temporary file path:\t{file_path}\nmetadata file path:\t{meta_path}".format( 182 "temporary file path:\t{content_path}\nmetadata file path:\t{meta_path}".format(
156 file_path=tmp_file_path, meta_path=meta_file_path), error=True) 183 content_path=content_file_path, meta_path=meta_file_path), error=True)
157 self.host.quit(0) 184 self.host.quit(0)
158 185
159 if len(content) == 0: 186 if len(content) == 0:
160 self.disp(u"Content is empty, cancelling the blog edition") 187 self.disp(u"Content is empty, cancelling the blog edition")
161 188
172 mb_data['id'] = item_ori_mb_data['id'] 199 mb_data['id'] = item_ori_mb_data['id']
173 200
174 try: 201 try:
175 self.host.bridge.mbSend('', '', mb_data, self.profile) 202 self.host.bridge.mbSend('', '', mb_data, self.profile)
176 except Exception as e: 203 except Exception as e:
177 self.disp(u"Error while sending your blog, the temporary files have been kept at {file_path} and {meta_path}: {reason}".format( 204 self.disp(u"Error while sending your blog, the temporary files have been kept at {content_path} and {meta_path}: {reason}".format(
178 file_path=tmp_file_path, meta_path=meta_file_path, reason=e), error=True) 205 content_path=content_file_path, meta_path=meta_file_path, reason=e), error=True)
179 self.host.quit(1) 206 self.host.quit(1)
180 207
181 os.unlink(tmp_file_path) 208 os.unlink(content_file_path)
182 os.unlink(meta_file_path) 209 os.unlink(meta_file_path)
183 210
184 def start(self): 211 def start(self):
185 # we get current syntax to determine file extension 212 # we get current syntax to determine file extension
186 current_syntax = self.host.bridge.getParamA("Syntax", "Composition", "value", self.profile) 213 current_syntax = self.host.bridge.getParamA("Syntax", "Composition", "value", self.profile)
189 # if there are user defined extension, we use them 216 # if there are user defined extension, we use them
190 SYNTAX_EXT.update(config.getConfig(sat_conf, 'jp', CONF_SYNTAX_EXT, {})) 217 SYNTAX_EXT.update(config.getConfig(sat_conf, 'jp', CONF_SYNTAX_EXT, {}))
191 218
192 # we now create a temporary file 219 # we now create a temporary file
193 tmp_suff = '.' + SYNTAX_EXT.get(current_syntax, SYNTAX_EXT['']) 220 tmp_suff = '.' + SYNTAX_EXT.get(current_syntax, SYNTAX_EXT[''])
194 tmp_file_obj, tmp_file_path = self.getTmpFile(sat_conf, tmp_suff) 221 content_file_obj, content_file_path = self.getTmpFile(sat_conf, tmp_suff)
195 222
196 item_lower = self.args.item.lower() 223 item_lower = self.args.item.lower()
197 if item_lower == 'new': 224 if item_lower == 'new':
198 self.disp(u'Editing a new blog item', 2) 225 self.disp(u'Editing a new blog item', 2)
199 self.edit(sat_conf, tmp_file_path, tmp_file_obj) 226 self.edit(sat_conf, content_file_path, content_file_obj)
200 elif item_lower == 'last': 227 elif item_lower == 'last':
201 self.disp(u'Editing last published item', 2) 228 self.disp(u'Editing last published item', 2)
202 try: 229 try:
203 mb_data = self.host.bridge.mbGet('', '', 1, [], {}, self.profile)[0][0] 230 mb_data = self.host.bridge.mbGet('', '', 1, [], {}, self.profile)[0][0]
204 except Exception as e: 231 except Exception as e:
206 self.host.quit(1) 233 self.host.quit(1)
207 234
208 content = mb_data['content_xhtml'] 235 content = mb_data['content_xhtml']
209 if current_syntax != 'XHTML': 236 if current_syntax != 'XHTML':
210 content = self.host.bridge.syntaxConvert(content, 'XHTML', current_syntax, False, self.profile) 237 content = self.host.bridge.syntaxConvert(content, 'XHTML', current_syntax, False, self.profile)
211 tmp_file_obj.write(content.encode('utf-8')) 238 content_file_obj.write(content.encode('utf-8'))
212 tmp_file_obj.seek(0) 239 content_file_obj.seek(0)
213 self.edit(sat_conf, tmp_file_path, tmp_file_obj, mb_data=mb_data) 240 self.edit(sat_conf, content_file_path, content_file_obj, mb_data=mb_data)
214 241
215 242
216 class Import(base.CommandAnswering): 243 class Import(base.CommandAnswering):
217 def __init__(self, host): 244 def __init__(self, host):
218 super(Import, self).__init__(host, 'import', use_progress=True, help=_(u'import an external blog')) 245 super(Import, self).__init__(host, 'import', use_progress=True, help=_(u'import an external blog'))