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 )