Mercurial > libervia-backend
comparison libervia/cli/cmd_blog.py @ 4075:47401850dec6
refactoring: rename `libervia.frontends.jp` to `libervia.cli`
author | Goffi <goffi@goffi.org> |
---|---|
date | Fri, 02 Jun 2023 14:54:26 +0200 |
parents | libervia/frontends/jp/cmd_blog.py@26b7ed2817da |
children | 7df6ba11bdae |
comparison
equal
deleted
inserted
replaced
4074:26b7ed2817da | 4075:47401850dec6 |
---|---|
1 #!/usr/bin/env python3 | |
2 | |
3 | |
4 # Libervia CLI | |
5 # Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) | |
6 | |
7 # This program is free software: you can redistribute it and/or modify | |
8 # it under the terms of the GNU Affero General Public License as published by | |
9 # the Free Software Foundation, either version 3 of the License, or | |
10 # (at your option) any later version. | |
11 | |
12 # This program is distributed in the hope that it will be useful, | |
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
15 # GNU Affero General Public License for more details. | |
16 | |
17 # You should have received a copy of the GNU Affero General Public License | |
18 # along with this program. If not, see <http://www.gnu.org/licenses/>. | |
19 | |
20 | |
21 import asyncio | |
22 from asyncio.subprocess import DEVNULL | |
23 from configparser import NoOptionError, NoSectionError | |
24 import json | |
25 import os | |
26 import os.path | |
27 from pathlib import Path | |
28 import re | |
29 import subprocess | |
30 import sys | |
31 import tempfile | |
32 from urllib.parse import urlparse | |
33 | |
34 from libervia.backend.core.i18n import _ | |
35 from libervia.backend.tools import config | |
36 from libervia.backend.tools.common import uri | |
37 from libervia.backend.tools.common import data_format | |
38 from libervia.backend.tools.common.ansi import ANSI as A | |
39 from libervia.cli import common | |
40 from libervia.cli.constants import Const as C | |
41 | |
42 from . import base, cmd_pubsub | |
43 | |
44 __commands__ = ["Blog"] | |
45 | |
46 SYNTAX_XHTML = "xhtml" | |
47 # extensions to use with known syntaxes | |
48 SYNTAX_EXT = { | |
49 # FIXME: default syntax doesn't sounds needed, there should always be a syntax set | |
50 # by the plugin. | |
51 "": "txt", # used when the syntax is not found | |
52 SYNTAX_XHTML: "xhtml", | |
53 "markdown": "md", | |
54 } | |
55 | |
56 | |
57 CONF_SYNTAX_EXT = "syntax_ext_dict" | |
58 BLOG_TMP_DIR = "blog" | |
59 # key to remove from metadata tmp file if they exist | |
60 KEY_TO_REMOVE_METADATA = ( | |
61 "id", | |
62 "content", | |
63 "content_xhtml", | |
64 "comments_node", | |
65 "comments_service", | |
66 "updated", | |
67 ) | |
68 | |
69 URL_REDIRECT_PREFIX = "url_redirect_" | |
70 AIONOTIFY_INSTALL = '"pip install aionotify"' | |
71 MB_KEYS = ( | |
72 "id", | |
73 "url", | |
74 "atom_id", | |
75 "updated", | |
76 "published", | |
77 "language", | |
78 "comments", # this key is used for all comments* keys | |
79 "tags", # this key is used for all tag* keys | |
80 "author", | |
81 "author_jid", | |
82 "author_email", | |
83 "author_jid_verified", | |
84 "content", | |
85 "content_xhtml", | |
86 "title", | |
87 "title_xhtml", | |
88 "extra" | |
89 ) | |
90 OUTPUT_OPT_NO_HEADER = "no-header" | |
91 RE_ATTACHMENT_METADATA = re.compile(r"^(?P<key>[a-z_]+)=(?P<value>.*)") | |
92 ALLOWER_ATTACH_MD_KEY = ("desc", "media_type", "external") | |
93 | |
94 | |
95 async def guess_syntax_from_path(host, sat_conf, path): | |
96 """Return syntax guessed according to filename extension | |
97 | |
98 @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration | |
99 @param path(str): path to the content file | |
100 @return(unicode): syntax to use | |
101 """ | |
102 # we first try to guess syntax with extension | |
103 ext = os.path.splitext(path)[1][1:] # we get extension without the '.' | |
104 if ext: | |
105 for k, v in SYNTAX_EXT.items(): | |
106 if k and ext == v: | |
107 return k | |
108 | |
109 # if not found, we use current syntax | |
110 return await host.bridge.param_get_a("Syntax", "Composition", "value", host.profile) | |
111 | |
112 | |
113 class BlogPublishCommon: | |
114 """handle common option for publising commands (Set and Edit)""" | |
115 | |
116 async def get_current_syntax(self): | |
117 """Retrieve current_syntax | |
118 | |
119 Use default syntax if --syntax has not been used, else check given syntax. | |
120 Will set self.default_syntax_used to True if default syntax has been used | |
121 """ | |
122 if self.args.syntax is None: | |
123 self.default_syntax_used = True | |
124 return await self.host.bridge.param_get_a( | |
125 "Syntax", "Composition", "value", self.profile | |
126 ) | |
127 else: | |
128 self.default_syntax_used = False | |
129 try: | |
130 syntax = await self.host.bridge.syntax_get(self.args.syntax) | |
131 self.current_syntax = self.args.syntax = syntax | |
132 except Exception as e: | |
133 if e.classname == "NotFound": | |
134 self.parser.error( | |
135 _("unknown syntax requested ({syntax})").format( | |
136 syntax=self.args.syntax | |
137 ) | |
138 ) | |
139 else: | |
140 raise e | |
141 return self.args.syntax | |
142 | |
143 def add_parser_options(self): | |
144 self.parser.add_argument("-T", "--title", help=_("title of the item")) | |
145 self.parser.add_argument( | |
146 "-t", | |
147 "--tag", | |
148 action="append", | |
149 help=_("tag (category) of your item"), | |
150 ) | |
151 self.parser.add_argument( | |
152 "-l", | |
153 "--language", | |
154 help=_("language of the item (ISO 639 code)"), | |
155 ) | |
156 | |
157 self.parser.add_argument( | |
158 "-a", | |
159 "--attachment", | |
160 dest="attachments", | |
161 nargs="+", | |
162 help=_( | |
163 "attachment in the form URL [metadata_name=value]" | |
164 ) | |
165 ) | |
166 | |
167 comments_group = self.parser.add_mutually_exclusive_group() | |
168 comments_group.add_argument( | |
169 "-C", | |
170 "--comments", | |
171 action="store_const", | |
172 const=True, | |
173 dest="comments", | |
174 help=_( | |
175 "enable comments (default: comments not enabled except if they " | |
176 "already exist)" | |
177 ), | |
178 ) | |
179 comments_group.add_argument( | |
180 "--no-comments", | |
181 action="store_const", | |
182 const=False, | |
183 dest="comments", | |
184 help=_("disable comments (will remove comments node if it exist)"), | |
185 ) | |
186 | |
187 self.parser.add_argument( | |
188 "-S", | |
189 "--syntax", | |
190 help=_("syntax to use (default: get profile's default syntax)"), | |
191 ) | |
192 self.parser.add_argument( | |
193 "-e", | |
194 "--encrypt", | |
195 action="store_true", | |
196 help=_("end-to-end encrypt the blog post") | |
197 ) | |
198 self.parser.add_argument( | |
199 "--encrypt-for", | |
200 metavar="JID", | |
201 action="append", | |
202 help=_("encrypt a single item for") | |
203 ) | |
204 self.parser.add_argument( | |
205 "-X", | |
206 "--sign", | |
207 action="store_true", | |
208 help=_("cryptographically sign the blog post") | |
209 ) | |
210 | |
211 async def set_mb_data_content(self, content, mb_data): | |
212 if self.default_syntax_used: | |
213 # default syntax has been used | |
214 mb_data["content_rich"] = content | |
215 elif self.current_syntax == SYNTAX_XHTML: | |
216 mb_data["content_xhtml"] = content | |
217 else: | |
218 mb_data["content_xhtml"] = await self.host.bridge.syntax_convert( | |
219 content, self.current_syntax, SYNTAX_XHTML, False, self.profile | |
220 ) | |
221 | |
222 def handle_attachments(self, mb_data: dict) -> None: | |
223 """Check, validate and add attachments to mb_data""" | |
224 if self.args.attachments: | |
225 attachments = [] | |
226 attachment = {} | |
227 for arg in self.args.attachments: | |
228 m = RE_ATTACHMENT_METADATA.match(arg) | |
229 if m is None: | |
230 # we should have an URL | |
231 url_parsed = urlparse(arg) | |
232 if url_parsed.scheme not in ("http", "https"): | |
233 self.parser.error( | |
234 "invalid URL in --attachment (only http(s) scheme is " | |
235 f" accepted): {arg}" | |
236 ) | |
237 if attachment: | |
238 # if we hae a new URL, we have a new attachment | |
239 attachments.append(attachment) | |
240 attachment = {} | |
241 attachment["url"] = arg | |
242 else: | |
243 # we should have a metadata | |
244 if "url" not in attachment: | |
245 self.parser.error( | |
246 "you must to specify an URL before any metadata in " | |
247 "--attachment" | |
248 ) | |
249 key = m.group("key") | |
250 if key not in ALLOWER_ATTACH_MD_KEY: | |
251 self.parser.error( | |
252 f"invalid metadata key in --attachment: {key!r}" | |
253 ) | |
254 value = m.group("value").strip() | |
255 if key == "external": | |
256 if not value: | |
257 value=True | |
258 else: | |
259 value = C.bool(value) | |
260 attachment[key] = value | |
261 if attachment: | |
262 attachments.append(attachment) | |
263 if attachments: | |
264 mb_data.setdefault("extra", {})["attachments"] = attachments | |
265 | |
266 def set_mb_data_from_args(self, mb_data): | |
267 """set microblog metadata according to command line options | |
268 | |
269 if metadata already exist, it will be overwritten | |
270 """ | |
271 if self.args.comments is not None: | |
272 mb_data["allow_comments"] = self.args.comments | |
273 if self.args.tag: | |
274 mb_data["tags"] = self.args.tag | |
275 if self.args.title is not None: | |
276 mb_data["title"] = self.args.title | |
277 if self.args.language is not None: | |
278 mb_data["language"] = self.args.language | |
279 if self.args.encrypt: | |
280 mb_data["encrypted"] = True | |
281 if self.args.sign: | |
282 mb_data["signed"] = True | |
283 if self.args.encrypt_for: | |
284 mb_data["encrypted_for"] = {"targets": self.args.encrypt_for} | |
285 self.handle_attachments(mb_data) | |
286 | |
287 | |
288 class Set(base.CommandBase, BlogPublishCommon): | |
289 def __init__(self, host): | |
290 base.CommandBase.__init__( | |
291 self, | |
292 host, | |
293 "set", | |
294 use_pubsub=True, | |
295 pubsub_flags={C.SINGLE_ITEM}, | |
296 help=_("publish a new blog item or update an existing one"), | |
297 ) | |
298 BlogPublishCommon.__init__(self) | |
299 | |
300 def add_parser_options(self): | |
301 BlogPublishCommon.add_parser_options(self) | |
302 | |
303 async def start(self): | |
304 self.current_syntax = await self.get_current_syntax() | |
305 self.pubsub_item = self.args.item | |
306 mb_data = {} | |
307 self.set_mb_data_from_args(mb_data) | |
308 if self.pubsub_item: | |
309 mb_data["id"] = self.pubsub_item | |
310 content = sys.stdin.read() | |
311 await self.set_mb_data_content(content, mb_data) | |
312 | |
313 try: | |
314 item_id = await self.host.bridge.mb_send( | |
315 self.args.service, | |
316 self.args.node, | |
317 data_format.serialise(mb_data), | |
318 self.profile, | |
319 ) | |
320 except Exception as e: | |
321 self.disp(f"can't send item: {e}", error=True) | |
322 self.host.quit(C.EXIT_BRIDGE_ERRBACK) | |
323 else: | |
324 self.disp(f"Item published with ID {item_id}") | |
325 self.host.quit(C.EXIT_OK) | |
326 | |
327 | |
328 class Get(base.CommandBase): | |
329 TEMPLATE = "blog/articles.html" | |
330 | |
331 def __init__(self, host): | |
332 extra_outputs = {"default": self.default_output, "fancy": self.fancy_output} | |
333 base.CommandBase.__init__( | |
334 self, | |
335 host, | |
336 "get", | |
337 use_verbose=True, | |
338 use_pubsub=True, | |
339 pubsub_flags={C.MULTI_ITEMS, C.CACHE}, | |
340 use_output=C.OUTPUT_COMPLEX, | |
341 extra_outputs=extra_outputs, | |
342 help=_("get blog item(s)"), | |
343 ) | |
344 | |
345 def add_parser_options(self): | |
346 # TODO: a key(s) argument to select keys to display | |
347 self.parser.add_argument( | |
348 "-k", | |
349 "--key", | |
350 action="append", | |
351 dest="keys", | |
352 help=_("microblog data key(s) to display (default: depend of verbosity)"), | |
353 ) | |
354 # TODO: add MAM filters | |
355 | |
356 def template_data_mapping(self, data): | |
357 items, blog_items = data | |
358 blog_items["items"] = items | |
359 return {"blog_items": blog_items} | |
360 | |
361 def format_comments(self, item, keys): | |
362 lines = [] | |
363 for data in item.get("comments", []): | |
364 lines.append(data["uri"]) | |
365 for k in ("node", "service"): | |
366 if OUTPUT_OPT_NO_HEADER in self.args.output_opts: | |
367 header = "" | |
368 else: | |
369 header = f"{C.A_HEADER}comments_{k}: {A.RESET}" | |
370 lines.append(header + data[k]) | |
371 return "\n".join(lines) | |
372 | |
373 def format_tags(self, item, keys): | |
374 tags = item.pop("tags", []) | |
375 return ", ".join(tags) | |
376 | |
377 def format_updated(self, item, keys): | |
378 return common.format_time(item["updated"]) | |
379 | |
380 def format_published(self, item, keys): | |
381 return common.format_time(item["published"]) | |
382 | |
383 def format_url(self, item, keys): | |
384 return uri.build_xmpp_uri( | |
385 "pubsub", | |
386 subtype="microblog", | |
387 path=self.metadata["service"], | |
388 node=self.metadata["node"], | |
389 item=item["id"], | |
390 ) | |
391 | |
392 def get_keys(self): | |
393 """return keys to display according to verbosity or explicit key request""" | |
394 verbosity = self.args.verbose | |
395 if self.args.keys: | |
396 if not set(MB_KEYS).issuperset(self.args.keys): | |
397 self.disp( | |
398 "following keys are invalid: {invalid}.\n" | |
399 "Valid keys are: {valid}.".format( | |
400 invalid=", ".join(set(self.args.keys).difference(MB_KEYS)), | |
401 valid=", ".join(sorted(MB_KEYS)), | |
402 ), | |
403 error=True, | |
404 ) | |
405 self.host.quit(C.EXIT_BAD_ARG) | |
406 return self.args.keys | |
407 else: | |
408 if verbosity == 0: | |
409 return ("title", "content") | |
410 elif verbosity == 1: | |
411 return ( | |
412 "title", | |
413 "tags", | |
414 "author", | |
415 "author_jid", | |
416 "author_email", | |
417 "author_jid_verified", | |
418 "published", | |
419 "updated", | |
420 "content", | |
421 ) | |
422 else: | |
423 return MB_KEYS | |
424 | |
425 def default_output(self, data): | |
426 """simple key/value output""" | |
427 items, self.metadata = data | |
428 keys = self.get_keys() | |
429 | |
430 # k_cb use format_[key] methods for complex formattings | |
431 k_cb = {} | |
432 for k in keys: | |
433 try: | |
434 callback = getattr(self, "format_" + k) | |
435 except AttributeError: | |
436 pass | |
437 else: | |
438 k_cb[k] = callback | |
439 for idx, item in enumerate(items): | |
440 for k in keys: | |
441 if k not in item and k not in k_cb: | |
442 continue | |
443 if OUTPUT_OPT_NO_HEADER in self.args.output_opts: | |
444 header = "" | |
445 else: | |
446 header = "{k_fmt}{key}:{k_fmt_e} {sep}".format( | |
447 k_fmt=C.A_HEADER, | |
448 key=k, | |
449 k_fmt_e=A.RESET, | |
450 sep="\n" if "content" in k else "", | |
451 ) | |
452 value = k_cb[k](item, keys) if k in k_cb else item[k] | |
453 if isinstance(value, bool): | |
454 value = str(value).lower() | |
455 elif isinstance(value, dict): | |
456 value = repr(value) | |
457 self.disp(header + (value or "")) | |
458 # we want a separation line after each item but the last one | |
459 if idx < len(items) - 1: | |
460 print("") | |
461 | |
462 def fancy_output(self, data): | |
463 """display blog is a nice to read way | |
464 | |
465 this output doesn't use keys filter | |
466 """ | |
467 # thanks to http://stackoverflow.com/a/943921 | |
468 rows, columns = list(map(int, os.popen("stty size", "r").read().split())) | |
469 items, metadata = data | |
470 verbosity = self.args.verbose | |
471 sep = A.color(A.FG_BLUE, columns * "▬") | |
472 if items: | |
473 print(("\n" + sep + "\n")) | |
474 | |
475 for idx, item in enumerate(items): | |
476 title = item.get("title") | |
477 if verbosity > 0: | |
478 author = item["author"] | |
479 published, updated = item["published"], item.get("updated") | |
480 else: | |
481 author = published = updated = None | |
482 if verbosity > 1: | |
483 tags = item.pop("tags", []) | |
484 else: | |
485 tags = None | |
486 content = item.get("content") | |
487 | |
488 if title: | |
489 print((A.color(A.BOLD, A.FG_CYAN, item["title"]))) | |
490 meta = [] | |
491 if author: | |
492 meta.append(A.color(A.FG_YELLOW, author)) | |
493 if published: | |
494 meta.append(A.color(A.FG_YELLOW, "on ", common.format_time(published))) | |
495 if updated != published: | |
496 meta.append( | |
497 A.color(A.FG_YELLOW, "(updated on ", common.format_time(updated), ")") | |
498 ) | |
499 print((" ".join(meta))) | |
500 if tags: | |
501 print((A.color(A.FG_MAGENTA, ", ".join(tags)))) | |
502 if (title or tags) and content: | |
503 print("") | |
504 if content: | |
505 self.disp(content) | |
506 | |
507 print(("\n" + sep + "\n")) | |
508 | |
509 async def start(self): | |
510 try: | |
511 mb_data = data_format.deserialise( | |
512 await self.host.bridge.mb_get( | |
513 self.args.service, | |
514 self.args.node, | |
515 self.args.max, | |
516 self.args.items, | |
517 self.get_pubsub_extra(), | |
518 self.profile, | |
519 ) | |
520 ) | |
521 except Exception as e: | |
522 self.disp(f"can't get blog items: {e}", error=True) | |
523 self.host.quit(C.EXIT_BRIDGE_ERRBACK) | |
524 else: | |
525 items = mb_data.pop("items") | |
526 await self.output((items, mb_data)) | |
527 self.host.quit(C.EXIT_OK) | |
528 | |
529 | |
530 class Edit(base.CommandBase, BlogPublishCommon, common.BaseEdit): | |
531 def __init__(self, host): | |
532 base.CommandBase.__init__( | |
533 self, | |
534 host, | |
535 "edit", | |
536 use_pubsub=True, | |
537 pubsub_flags={C.SINGLE_ITEM}, | |
538 use_draft=True, | |
539 use_verbose=True, | |
540 help=_("edit an existing or new blog post"), | |
541 ) | |
542 BlogPublishCommon.__init__(self) | |
543 common.BaseEdit.__init__(self, self.host, BLOG_TMP_DIR, use_metadata=True) | |
544 | |
545 def add_parser_options(self): | |
546 BlogPublishCommon.add_parser_options(self) | |
547 self.parser.add_argument( | |
548 "-P", | |
549 "--preview", | |
550 action="store_true", | |
551 help=_("launch a blog preview in parallel"), | |
552 ) | |
553 self.parser.add_argument( | |
554 "--no-publish", | |
555 action="store_true", | |
556 help=_('add "publish: False" to metadata'), | |
557 ) | |
558 | |
559 def build_metadata_file(self, content_file_path, mb_data=None): | |
560 """Build a metadata file using json | |
561 | |
562 The file is named after content_file_path, with extension replaced by | |
563 _metadata.json | |
564 @param content_file_path(str): path to the temporary file which will contain the | |
565 body | |
566 @param mb_data(dict, None): microblog metadata (for existing items) | |
567 @return (tuple[dict, Path]): merged metadata put originaly in metadata file | |
568 and path to temporary metadata file | |
569 """ | |
570 # we first construct metadata from edited item ones and CLI argumments | |
571 # or re-use the existing one if it exists | |
572 meta_file_path = content_file_path.with_name( | |
573 content_file_path.stem + common.METADATA_SUFF | |
574 ) | |
575 if meta_file_path.exists(): | |
576 self.disp("Metadata file already exists, we re-use it") | |
577 try: | |
578 with meta_file_path.open("rb") as f: | |
579 mb_data = json.load(f) | |
580 except (OSError, IOError, ValueError) as e: | |
581 self.disp( | |
582 f"Can't read existing metadata file at {meta_file_path}, " | |
583 f"aborting: {e}", | |
584 error=True, | |
585 ) | |
586 self.host.quit(1) | |
587 else: | |
588 mb_data = {} if mb_data is None else mb_data.copy() | |
589 | |
590 # in all cases, we want to remove unwanted keys | |
591 for key in KEY_TO_REMOVE_METADATA: | |
592 try: | |
593 del mb_data[key] | |
594 except KeyError: | |
595 pass | |
596 # and override metadata with command-line arguments | |
597 self.set_mb_data_from_args(mb_data) | |
598 | |
599 if self.args.no_publish: | |
600 mb_data["publish"] = False | |
601 | |
602 # then we create the file and write metadata there, as JSON dict | |
603 # XXX: if we port libervia-cli one day on Windows, O_BINARY may need to be | |
604 # added here | |
605 with os.fdopen( | |
606 os.open(meta_file_path, os.O_RDWR | os.O_CREAT | os.O_TRUNC, 0o600), "w+b" | |
607 ) as f: | |
608 # we need to use an intermediate unicode buffer to write to the file | |
609 # unicode without escaping characters | |
610 unicode_dump = json.dumps( | |
611 mb_data, | |
612 ensure_ascii=False, | |
613 indent=4, | |
614 separators=(",", ": "), | |
615 sort_keys=True, | |
616 ) | |
617 f.write(unicode_dump.encode("utf-8")) | |
618 | |
619 return mb_data, meta_file_path | |
620 | |
621 async def edit(self, content_file_path, content_file_obj, mb_data=None): | |
622 """Edit the file contening the content using editor, and publish it""" | |
623 # we first create metadata file | |
624 meta_ori, meta_file_path = self.build_metadata_file(content_file_path, mb_data) | |
625 | |
626 coroutines = [] | |
627 | |
628 # do we need a preview ? | |
629 if self.args.preview: | |
630 self.disp("Preview requested, launching it", 1) | |
631 # we redirect outputs to /dev/null to avoid console pollution in editor | |
632 # if user wants to see messages, (s)he can call "blog preview" directly | |
633 coroutines.append( | |
634 asyncio.create_subprocess_exec( | |
635 sys.argv[0], | |
636 "blog", | |
637 "preview", | |
638 "--inotify", | |
639 "true", | |
640 "-p", | |
641 self.profile, | |
642 str(content_file_path), | |
643 stdout=DEVNULL, | |
644 stderr=DEVNULL, | |
645 ) | |
646 ) | |
647 | |
648 # we launch editor | |
649 coroutines.append( | |
650 self.run_editor( | |
651 "blog_editor_args", | |
652 content_file_path, | |
653 content_file_obj, | |
654 meta_file_path=meta_file_path, | |
655 meta_ori=meta_ori, | |
656 ) | |
657 ) | |
658 | |
659 await asyncio.gather(*coroutines) | |
660 | |
661 async def publish(self, content, mb_data): | |
662 await self.set_mb_data_content(content, mb_data) | |
663 | |
664 if self.pubsub_item: | |
665 mb_data["id"] = self.pubsub_item | |
666 | |
667 mb_data = data_format.serialise(mb_data) | |
668 | |
669 await self.host.bridge.mb_send( | |
670 self.pubsub_service, self.pubsub_node, mb_data, self.profile | |
671 ) | |
672 self.disp("Blog item published") | |
673 | |
674 def get_tmp_suff(self): | |
675 # we get current syntax to determine file extension | |
676 return SYNTAX_EXT.get(self.current_syntax, SYNTAX_EXT[""]) | |
677 | |
678 async def get_item_data(self, service, node, item): | |
679 items = [item] if item else [] | |
680 | |
681 mb_data = data_format.deserialise( | |
682 await self.host.bridge.mb_get( | |
683 service, node, 1, items, data_format.serialise({}), self.profile | |
684 ) | |
685 ) | |
686 item = mb_data["items"][0] | |
687 | |
688 try: | |
689 content = item["content_xhtml"] | |
690 except KeyError: | |
691 content = item["content"] | |
692 if content: | |
693 content = await self.host.bridge.syntax_convert( | |
694 content, "text", SYNTAX_XHTML, False, self.profile | |
695 ) | |
696 | |
697 if content and self.current_syntax != SYNTAX_XHTML: | |
698 content = await self.host.bridge.syntax_convert( | |
699 content, SYNTAX_XHTML, self.current_syntax, False, self.profile | |
700 ) | |
701 | |
702 if content and self.current_syntax == SYNTAX_XHTML: | |
703 content = content.strip() | |
704 if not content.startswith("<div>"): | |
705 content = "<div>" + content + "</div>" | |
706 try: | |
707 from lxml import etree | |
708 except ImportError: | |
709 self.disp(_("You need lxml to edit pretty XHTML")) | |
710 else: | |
711 parser = etree.XMLParser(remove_blank_text=True) | |
712 root = etree.fromstring(content, parser) | |
713 content = etree.tostring(root, encoding=str, pretty_print=True) | |
714 | |
715 return content, item, item["id"] | |
716 | |
717 async def start(self): | |
718 # if there are user defined extension, we use them | |
719 SYNTAX_EXT.update( | |
720 config.config_get(self.sat_conf, C.CONFIG_SECTION, CONF_SYNTAX_EXT, {}) | |
721 ) | |
722 self.current_syntax = await self.get_current_syntax() | |
723 | |
724 ( | |
725 self.pubsub_service, | |
726 self.pubsub_node, | |
727 self.pubsub_item, | |
728 content_file_path, | |
729 content_file_obj, | |
730 mb_data, | |
731 ) = await self.get_item_path() | |
732 | |
733 await self.edit(content_file_path, content_file_obj, mb_data=mb_data) | |
734 self.host.quit() | |
735 | |
736 | |
737 class Rename(base.CommandBase): | |
738 def __init__(self, host): | |
739 base.CommandBase.__init__( | |
740 self, | |
741 host, | |
742 "rename", | |
743 use_pubsub=True, | |
744 pubsub_flags={C.SINGLE_ITEM}, | |
745 help=_("rename an blog item"), | |
746 ) | |
747 | |
748 def add_parser_options(self): | |
749 self.parser.add_argument("new_id", help=_("new item id to use")) | |
750 | |
751 async def start(self): | |
752 try: | |
753 await self.host.bridge.mb_rename( | |
754 self.args.service, | |
755 self.args.node, | |
756 self.args.item, | |
757 self.args.new_id, | |
758 self.profile, | |
759 ) | |
760 except Exception as e: | |
761 self.disp(f"can't rename item: {e}", error=True) | |
762 self.host.quit(C.EXIT_BRIDGE_ERRBACK) | |
763 else: | |
764 self.disp("Item renamed") | |
765 self.host.quit(C.EXIT_OK) | |
766 | |
767 | |
768 class Repeat(base.CommandBase): | |
769 def __init__(self, host): | |
770 super().__init__( | |
771 host, | |
772 "repeat", | |
773 use_pubsub=True, | |
774 pubsub_flags={C.SINGLE_ITEM}, | |
775 help=_("repeat (re-publish) a blog item"), | |
776 ) | |
777 | |
778 def add_parser_options(self): | |
779 pass | |
780 | |
781 async def start(self): | |
782 try: | |
783 repeat_id = await self.host.bridge.mb_repeat( | |
784 self.args.service, | |
785 self.args.node, | |
786 self.args.item, | |
787 "", | |
788 self.profile, | |
789 ) | |
790 except Exception as e: | |
791 self.disp(f"can't repeat item: {e}", error=True) | |
792 self.host.quit(C.EXIT_BRIDGE_ERRBACK) | |
793 else: | |
794 if repeat_id: | |
795 self.disp(f"Item repeated at ID {str(repeat_id)!r}") | |
796 else: | |
797 self.disp("Item repeated") | |
798 self.host.quit(C.EXIT_OK) | |
799 | |
800 | |
801 class Preview(base.CommandBase, common.BaseEdit): | |
802 # TODO: need to be rewritten with template output | |
803 | |
804 def __init__(self, host): | |
805 base.CommandBase.__init__( | |
806 self, host, "preview", use_verbose=True, help=_("preview a blog content") | |
807 ) | |
808 common.BaseEdit.__init__(self, self.host, BLOG_TMP_DIR, use_metadata=True) | |
809 | |
810 def add_parser_options(self): | |
811 self.parser.add_argument( | |
812 "--inotify", | |
813 type=str, | |
814 choices=("auto", "true", "false"), | |
815 default="auto", | |
816 help=_("use inotify to handle preview"), | |
817 ) | |
818 self.parser.add_argument( | |
819 "file", | |
820 nargs="?", | |
821 default="current", | |
822 help=_("path to the content file"), | |
823 ) | |
824 | |
825 async def show_preview(self): | |
826 # we implement show_preview here so we don't have to import webbrowser and urllib | |
827 # when preview is not used | |
828 url = "file:{}".format(self.urllib.parse.quote(self.preview_file_path)) | |
829 self.webbrowser.open_new_tab(url) | |
830 | |
831 async def _launch_preview_ext(self, cmd_line, opt_name): | |
832 url = "file:{}".format(self.urllib.parse.quote(self.preview_file_path)) | |
833 args = common.parse_args( | |
834 self.host, cmd_line, url=url, preview_file=self.preview_file_path | |
835 ) | |
836 if not args: | |
837 self.disp( | |
838 'Couln\'t find command in "{name}", abording'.format(name=opt_name), | |
839 error=True, | |
840 ) | |
841 self.host.quit(1) | |
842 subprocess.Popen(args) | |
843 | |
844 async def open_preview_ext(self): | |
845 await self._launch_preview_ext(self.open_cb_cmd, "blog_preview_open_cmd") | |
846 | |
847 async def update_preview_ext(self): | |
848 await self._launch_preview_ext(self.update_cb_cmd, "blog_preview_update_cmd") | |
849 | |
850 async def update_content(self): | |
851 with self.content_file_path.open("rb") as f: | |
852 content = f.read().decode("utf-8-sig") | |
853 if content and self.syntax != SYNTAX_XHTML: | |
854 # we use safe=True because we want to have a preview as close as possible | |
855 # to what the people will see | |
856 content = await self.host.bridge.syntax_convert( | |
857 content, self.syntax, SYNTAX_XHTML, True, self.profile | |
858 ) | |
859 | |
860 xhtml = ( | |
861 f'<html xmlns="http://www.w3.org/1999/xhtml">' | |
862 f'<head><meta http-equiv="Content-Type" content="text/html;charset=utf-8" />' | |
863 f"</head>" | |
864 f"<body>{content}</body>" | |
865 f"</html>" | |
866 ) | |
867 | |
868 with open(self.preview_file_path, "wb") as f: | |
869 f.write(xhtml.encode("utf-8")) | |
870 | |
871 async def start(self): | |
872 import webbrowser | |
873 import urllib.request, urllib.parse, urllib.error | |
874 | |
875 self.webbrowser, self.urllib = webbrowser, urllib | |
876 | |
877 if self.args.inotify != "false": | |
878 try: | |
879 import aionotify | |
880 | |
881 except ImportError: | |
882 if self.args.inotify == "auto": | |
883 aionotify = None | |
884 self.disp( | |
885 f"aionotify module not found, deactivating feature. You can " | |
886 f"install it with {AIONOTIFY_INSTALL}" | |
887 ) | |
888 else: | |
889 self.disp( | |
890 f"aioinotify not found, can't activate the feature! Please " | |
891 f"install it with {AIONOTIFY_INSTALL}", | |
892 error=True, | |
893 ) | |
894 self.host.quit(1) | |
895 else: | |
896 aionotify = None | |
897 | |
898 sat_conf = self.sat_conf | |
899 SYNTAX_EXT.update( | |
900 config.config_get(sat_conf, C.CONFIG_SECTION, CONF_SYNTAX_EXT, {}) | |
901 ) | |
902 | |
903 try: | |
904 self.open_cb_cmd = config.config_get( | |
905 sat_conf, C.CONFIG_SECTION, "blog_preview_open_cmd", Exception | |
906 ) | |
907 except (NoOptionError, NoSectionError): | |
908 self.open_cb_cmd = None | |
909 open_cb = self.show_preview | |
910 else: | |
911 open_cb = self.open_preview_ext | |
912 | |
913 self.update_cb_cmd = config.config_get( | |
914 sat_conf, C.CONFIG_SECTION, "blog_preview_update_cmd", self.open_cb_cmd | |
915 ) | |
916 if self.update_cb_cmd is None: | |
917 update_cb = self.show_preview | |
918 else: | |
919 update_cb = self.update_preview_ext | |
920 | |
921 # which file do we need to edit? | |
922 if self.args.file == "current": | |
923 self.content_file_path = self.get_current_file(self.profile) | |
924 else: | |
925 try: | |
926 self.content_file_path = Path(self.args.file).resolve(strict=True) | |
927 except FileNotFoundError: | |
928 self.disp(_('File "{file}" doesn\'t exist!').format(file=self.args.file)) | |
929 self.host.quit(C.EXIT_NOT_FOUND) | |
930 | |
931 self.syntax = await guess_syntax_from_path( | |
932 self.host, sat_conf, self.content_file_path | |
933 ) | |
934 | |
935 # at this point the syntax is converted, we can display the preview | |
936 preview_file = tempfile.NamedTemporaryFile(suffix=".xhtml", delete=False) | |
937 self.preview_file_path = preview_file.name | |
938 preview_file.close() | |
939 await self.update_content() | |
940 | |
941 if aionotify is None: | |
942 # XXX: we don't delete file automatically because browser needs it | |
943 # (and webbrowser.open can return before it is read) | |
944 self.disp( | |
945 f"temporary file created at {self.preview_file_path}\nthis file will NOT " | |
946 f"BE DELETED AUTOMATICALLY, please delete it yourself when you have " | |
947 f"finished" | |
948 ) | |
949 await open_cb() | |
950 else: | |
951 await open_cb() | |
952 watcher = aionotify.Watcher() | |
953 watcher_kwargs = { | |
954 # Watcher don't accept Path so we convert to string | |
955 "path": str(self.content_file_path), | |
956 "alias": "content_file", | |
957 "flags": aionotify.Flags.CLOSE_WRITE | |
958 | aionotify.Flags.DELETE_SELF | |
959 | aionotify.Flags.MOVE_SELF, | |
960 } | |
961 watcher.watch(**watcher_kwargs) | |
962 | |
963 loop = asyncio.get_event_loop() | |
964 await watcher.setup(loop) | |
965 | |
966 try: | |
967 while True: | |
968 event = await watcher.get_event() | |
969 self.disp("Content updated", 1) | |
970 if event.flags & ( | |
971 aionotify.Flags.DELETE_SELF | aionotify.Flags.MOVE_SELF | |
972 ): | |
973 self.disp( | |
974 "DELETE/MOVE event catched, changing the watch", | |
975 2, | |
976 ) | |
977 try: | |
978 watcher.unwatch("content_file") | |
979 except IOError as e: | |
980 self.disp( | |
981 f"Can't remove the watch: {e}", | |
982 2, | |
983 ) | |
984 watcher = aionotify.Watcher() | |
985 watcher.watch(**watcher_kwargs) | |
986 try: | |
987 await watcher.setup(loop) | |
988 except OSError: | |
989 # if the new file is not here yet we can have an error | |
990 # as a workaround, we do a little rest and try again | |
991 await asyncio.sleep(1) | |
992 await watcher.setup(loop) | |
993 await self.update_content() | |
994 await update_cb() | |
995 except FileNotFoundError: | |
996 self.disp("The file seems to have been deleted.", error=True) | |
997 self.host.quit(C.EXIT_NOT_FOUND) | |
998 finally: | |
999 os.unlink(self.preview_file_path) | |
1000 try: | |
1001 watcher.unwatch("content_file") | |
1002 except IOError as e: | |
1003 self.disp( | |
1004 f"Can't remove the watch: {e}", | |
1005 2, | |
1006 ) | |
1007 | |
1008 | |
1009 class Import(base.CommandBase): | |
1010 def __init__(self, host): | |
1011 super().__init__( | |
1012 host, | |
1013 "import", | |
1014 use_pubsub=True, | |
1015 use_progress=True, | |
1016 help=_("import an external blog"), | |
1017 ) | |
1018 | |
1019 def add_parser_options(self): | |
1020 self.parser.add_argument( | |
1021 "importer", | |
1022 nargs="?", | |
1023 help=_("importer name, nothing to display importers list"), | |
1024 ) | |
1025 self.parser.add_argument("--host", help=_("original blog host")) | |
1026 self.parser.add_argument( | |
1027 "--no-images-upload", | |
1028 action="store_true", | |
1029 help=_("do *NOT* upload images (default: do upload images)"), | |
1030 ) | |
1031 self.parser.add_argument( | |
1032 "--upload-ignore-host", | |
1033 help=_("do not upload images from this host (default: upload all images)"), | |
1034 ) | |
1035 self.parser.add_argument( | |
1036 "--ignore-tls-errors", | |
1037 action="store_true", | |
1038 help=_("ignore invalide TLS certificate for uploads"), | |
1039 ) | |
1040 self.parser.add_argument( | |
1041 "-o", | |
1042 "--option", | |
1043 action="append", | |
1044 nargs=2, | |
1045 default=[], | |
1046 metavar=("NAME", "VALUE"), | |
1047 help=_("importer specific options (see importer description)"), | |
1048 ) | |
1049 self.parser.add_argument( | |
1050 "location", | |
1051 nargs="?", | |
1052 help=_( | |
1053 "importer data location (see importer description), nothing to show " | |
1054 "importer description" | |
1055 ), | |
1056 ) | |
1057 | |
1058 async def on_progress_started(self, metadata): | |
1059 self.disp(_("Blog upload started"), 2) | |
1060 | |
1061 async def on_progress_finished(self, metadata): | |
1062 self.disp(_("Blog uploaded successfully"), 2) | |
1063 redirections = { | |
1064 k[len(URL_REDIRECT_PREFIX) :]: v | |
1065 for k, v in metadata.items() | |
1066 if k.startswith(URL_REDIRECT_PREFIX) | |
1067 } | |
1068 if redirections: | |
1069 conf = "\n".join( | |
1070 [ | |
1071 "url_redirections_dict = {}".format( | |
1072 # we need to add ' ' before each new line | |
1073 # and to double each '%' for ConfigParser | |
1074 "\n ".join( | |
1075 json.dumps(redirections, indent=1, separators=(",", ": ")) | |
1076 .replace("%", "%%") | |
1077 .split("\n") | |
1078 ) | |
1079 ), | |
1080 ] | |
1081 ) | |
1082 self.disp( | |
1083 _( | |
1084 "\nTo redirect old URLs to new ones, put the following lines in your" | |
1085 " sat.conf file, in [libervia] section:\n\n{conf}" | |
1086 ).format(conf=conf) | |
1087 ) | |
1088 | |
1089 async def on_progress_error(self, error_msg): | |
1090 self.disp( | |
1091 _("Error while uploading blog: {error_msg}").format(error_msg=error_msg), | |
1092 error=True, | |
1093 ) | |
1094 | |
1095 async def start(self): | |
1096 if self.args.location is None: | |
1097 for name in ("option", "service", "no_images_upload"): | |
1098 if getattr(self.args, name): | |
1099 self.parser.error( | |
1100 _( | |
1101 "{name} argument can't be used without location argument" | |
1102 ).format(name=name) | |
1103 ) | |
1104 if self.args.importer is None: | |
1105 self.disp( | |
1106 "\n".join( | |
1107 [ | |
1108 f"{name}: {desc}" | |
1109 for name, desc in await self.host.bridge.blogImportList() | |
1110 ] | |
1111 ) | |
1112 ) | |
1113 else: | |
1114 try: | |
1115 short_desc, long_desc = await self.host.bridge.blogImportDesc( | |
1116 self.args.importer | |
1117 ) | |
1118 except Exception as e: | |
1119 msg = [l for l in str(e).split("\n") if l][ | |
1120 -1 | |
1121 ] # we only keep the last line | |
1122 self.disp(msg) | |
1123 self.host.quit(1) | |
1124 else: | |
1125 self.disp(f"{self.args.importer}: {short_desc}\n\n{long_desc}") | |
1126 self.host.quit() | |
1127 else: | |
1128 # we have a location, an import is requested | |
1129 options = {key: value for key, value in self.args.option} | |
1130 if self.args.host: | |
1131 options["host"] = self.args.host | |
1132 if self.args.ignore_tls_errors: | |
1133 options["ignore_tls_errors"] = C.BOOL_TRUE | |
1134 if self.args.no_images_upload: | |
1135 options["upload_images"] = C.BOOL_FALSE | |
1136 if self.args.upload_ignore_host: | |
1137 self.parser.error( | |
1138 "upload-ignore-host option can't be used when no-images-upload " | |
1139 "is set" | |
1140 ) | |
1141 elif self.args.upload_ignore_host: | |
1142 options["upload_ignore_host"] = self.args.upload_ignore_host | |
1143 | |
1144 try: | |
1145 progress_id = await self.host.bridge.blogImport( | |
1146 self.args.importer, | |
1147 self.args.location, | |
1148 options, | |
1149 self.args.service, | |
1150 self.args.node, | |
1151 self.profile, | |
1152 ) | |
1153 except Exception as e: | |
1154 self.disp( | |
1155 _("Error while trying to import a blog: {e}").format(e=e), | |
1156 error=True, | |
1157 ) | |
1158 self.host.quit(1) | |
1159 else: | |
1160 await self.set_progress_id(progress_id) | |
1161 | |
1162 | |
1163 class AttachmentGet(cmd_pubsub.AttachmentGet): | |
1164 | |
1165 def __init__(self, host): | |
1166 super().__init__(host) | |
1167 self.override_pubsub_flags({C.SERVICE, C.SINGLE_ITEM}) | |
1168 | |
1169 | |
1170 async def start(self): | |
1171 if not self.args.node: | |
1172 namespaces = await self.host.bridge.namespaces_get() | |
1173 try: | |
1174 ns_microblog = namespaces["microblog"] | |
1175 except KeyError: | |
1176 self.disp("XEP-0277 plugin is not loaded", error=True) | |
1177 self.host.quit(C.EXIT_MISSING_FEATURE) | |
1178 else: | |
1179 self.args.node = ns_microblog | |
1180 return await super().start() | |
1181 | |
1182 | |
1183 class AttachmentSet(cmd_pubsub.AttachmentSet): | |
1184 | |
1185 def __init__(self, host): | |
1186 super().__init__(host) | |
1187 self.override_pubsub_flags({C.SERVICE, C.SINGLE_ITEM}) | |
1188 | |
1189 async def start(self): | |
1190 if not self.args.node: | |
1191 namespaces = await self.host.bridge.namespaces_get() | |
1192 try: | |
1193 ns_microblog = namespaces["microblog"] | |
1194 except KeyError: | |
1195 self.disp("XEP-0277 plugin is not loaded", error=True) | |
1196 self.host.quit(C.EXIT_MISSING_FEATURE) | |
1197 else: | |
1198 self.args.node = ns_microblog | |
1199 return await super().start() | |
1200 | |
1201 | |
1202 class Attachments(base.CommandBase): | |
1203 subcommands = (AttachmentGet, AttachmentSet) | |
1204 | |
1205 def __init__(self, host): | |
1206 super().__init__( | |
1207 host, | |
1208 "attachments", | |
1209 use_profile=False, | |
1210 help=_("set or retrieve blog attachments"), | |
1211 ) | |
1212 | |
1213 | |
1214 class Blog(base.CommandBase): | |
1215 subcommands = (Set, Get, Edit, Rename, Repeat, Preview, Import, Attachments) | |
1216 | |
1217 def __init__(self, host): | |
1218 super(Blog, self).__init__( | |
1219 host, "blog", use_profile=False, help=_("blog/microblog management") | |
1220 ) |