comparison libervia/frontends/jp/cmd_blog.py @ 4074:26b7ed2817da

refactoring: rename `sat_frontends` to `libervia.frontends`
author Goffi <goffi@goffi.org>
date Fri, 02 Jun 2023 14:12:38 +0200
parents sat_frontends/jp/cmd_blog.py@4b842c1fb686
children
comparison
equal deleted inserted replaced
4073:7c5654c54fed 4074:26b7ed2817da
1 #!/usr/bin/env python3
2
3
4 # jp: a SàT command line tool
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.frontends.jp import common
40 from libervia.frontends.jp.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 jp one day on Windows, O_BINARY may need to be added here
604 with os.fdopen(
605 os.open(meta_file_path, os.O_RDWR | os.O_CREAT | os.O_TRUNC, 0o600), "w+b"
606 ) as f:
607 # we need to use an intermediate unicode buffer to write to the file
608 # unicode without escaping characters
609 unicode_dump = json.dumps(
610 mb_data,
611 ensure_ascii=False,
612 indent=4,
613 separators=(",", ": "),
614 sort_keys=True,
615 )
616 f.write(unicode_dump.encode("utf-8"))
617
618 return mb_data, meta_file_path
619
620 async def edit(self, content_file_path, content_file_obj, mb_data=None):
621 """Edit the file contening the content using editor, and publish it"""
622 # we first create metadata file
623 meta_ori, meta_file_path = self.build_metadata_file(content_file_path, mb_data)
624
625 coroutines = []
626
627 # do we need a preview ?
628 if self.args.preview:
629 self.disp("Preview requested, launching it", 1)
630 # we redirect outputs to /dev/null to avoid console pollution in editor
631 # if user wants to see messages, (s)he can call "blog preview" directly
632 coroutines.append(
633 asyncio.create_subprocess_exec(
634 sys.argv[0],
635 "blog",
636 "preview",
637 "--inotify",
638 "true",
639 "-p",
640 self.profile,
641 str(content_file_path),
642 stdout=DEVNULL,
643 stderr=DEVNULL,
644 )
645 )
646
647 # we launch editor
648 coroutines.append(
649 self.run_editor(
650 "blog_editor_args",
651 content_file_path,
652 content_file_obj,
653 meta_file_path=meta_file_path,
654 meta_ori=meta_ori,
655 )
656 )
657
658 await asyncio.gather(*coroutines)
659
660 async def publish(self, content, mb_data):
661 await self.set_mb_data_content(content, mb_data)
662
663 if self.pubsub_item:
664 mb_data["id"] = self.pubsub_item
665
666 mb_data = data_format.serialise(mb_data)
667
668 await self.host.bridge.mb_send(
669 self.pubsub_service, self.pubsub_node, mb_data, self.profile
670 )
671 self.disp("Blog item published")
672
673 def get_tmp_suff(self):
674 # we get current syntax to determine file extension
675 return SYNTAX_EXT.get(self.current_syntax, SYNTAX_EXT[""])
676
677 async def get_item_data(self, service, node, item):
678 items = [item] if item else []
679
680 mb_data = data_format.deserialise(
681 await self.host.bridge.mb_get(
682 service, node, 1, items, data_format.serialise({}), self.profile
683 )
684 )
685 item = mb_data["items"][0]
686
687 try:
688 content = item["content_xhtml"]
689 except KeyError:
690 content = item["content"]
691 if content:
692 content = await self.host.bridge.syntax_convert(
693 content, "text", SYNTAX_XHTML, False, self.profile
694 )
695
696 if content and self.current_syntax != SYNTAX_XHTML:
697 content = await self.host.bridge.syntax_convert(
698 content, SYNTAX_XHTML, self.current_syntax, False, self.profile
699 )
700
701 if content and self.current_syntax == SYNTAX_XHTML:
702 content = content.strip()
703 if not content.startswith("<div>"):
704 content = "<div>" + content + "</div>"
705 try:
706 from lxml import etree
707 except ImportError:
708 self.disp(_("You need lxml to edit pretty XHTML"))
709 else:
710 parser = etree.XMLParser(remove_blank_text=True)
711 root = etree.fromstring(content, parser)
712 content = etree.tostring(root, encoding=str, pretty_print=True)
713
714 return content, item, item["id"]
715
716 async def start(self):
717 # if there are user defined extension, we use them
718 SYNTAX_EXT.update(
719 config.config_get(self.sat_conf, C.CONFIG_SECTION, CONF_SYNTAX_EXT, {})
720 )
721 self.current_syntax = await self.get_current_syntax()
722
723 (
724 self.pubsub_service,
725 self.pubsub_node,
726 self.pubsub_item,
727 content_file_path,
728 content_file_obj,
729 mb_data,
730 ) = await self.get_item_path()
731
732 await self.edit(content_file_path, content_file_obj, mb_data=mb_data)
733 self.host.quit()
734
735
736 class Rename(base.CommandBase):
737 def __init__(self, host):
738 base.CommandBase.__init__(
739 self,
740 host,
741 "rename",
742 use_pubsub=True,
743 pubsub_flags={C.SINGLE_ITEM},
744 help=_("rename an blog item"),
745 )
746
747 def add_parser_options(self):
748 self.parser.add_argument("new_id", help=_("new item id to use"))
749
750 async def start(self):
751 try:
752 await self.host.bridge.mb_rename(
753 self.args.service,
754 self.args.node,
755 self.args.item,
756 self.args.new_id,
757 self.profile,
758 )
759 except Exception as e:
760 self.disp(f"can't rename item: {e}", error=True)
761 self.host.quit(C.EXIT_BRIDGE_ERRBACK)
762 else:
763 self.disp("Item renamed")
764 self.host.quit(C.EXIT_OK)
765
766
767 class Repeat(base.CommandBase):
768 def __init__(self, host):
769 super().__init__(
770 host,
771 "repeat",
772 use_pubsub=True,
773 pubsub_flags={C.SINGLE_ITEM},
774 help=_("repeat (re-publish) a blog item"),
775 )
776
777 def add_parser_options(self):
778 pass
779
780 async def start(self):
781 try:
782 repeat_id = await self.host.bridge.mb_repeat(
783 self.args.service,
784 self.args.node,
785 self.args.item,
786 "",
787 self.profile,
788 )
789 except Exception as e:
790 self.disp(f"can't repeat item: {e}", error=True)
791 self.host.quit(C.EXIT_BRIDGE_ERRBACK)
792 else:
793 if repeat_id:
794 self.disp(f"Item repeated at ID {str(repeat_id)!r}")
795 else:
796 self.disp("Item repeated")
797 self.host.quit(C.EXIT_OK)
798
799
800 class Preview(base.CommandBase, common.BaseEdit):
801 # TODO: need to be rewritten with template output
802
803 def __init__(self, host):
804 base.CommandBase.__init__(
805 self, host, "preview", use_verbose=True, help=_("preview a blog content")
806 )
807 common.BaseEdit.__init__(self, self.host, BLOG_TMP_DIR, use_metadata=True)
808
809 def add_parser_options(self):
810 self.parser.add_argument(
811 "--inotify",
812 type=str,
813 choices=("auto", "true", "false"),
814 default="auto",
815 help=_("use inotify to handle preview"),
816 )
817 self.parser.add_argument(
818 "file",
819 nargs="?",
820 default="current",
821 help=_("path to the content file"),
822 )
823
824 async def show_preview(self):
825 # we implement show_preview here so we don't have to import webbrowser and urllib
826 # when preview is not used
827 url = "file:{}".format(self.urllib.parse.quote(self.preview_file_path))
828 self.webbrowser.open_new_tab(url)
829
830 async def _launch_preview_ext(self, cmd_line, opt_name):
831 url = "file:{}".format(self.urllib.parse.quote(self.preview_file_path))
832 args = common.parse_args(
833 self.host, cmd_line, url=url, preview_file=self.preview_file_path
834 )
835 if not args:
836 self.disp(
837 'Couln\'t find command in "{name}", abording'.format(name=opt_name),
838 error=True,
839 )
840 self.host.quit(1)
841 subprocess.Popen(args)
842
843 async def open_preview_ext(self):
844 await self._launch_preview_ext(self.open_cb_cmd, "blog_preview_open_cmd")
845
846 async def update_preview_ext(self):
847 await self._launch_preview_ext(self.update_cb_cmd, "blog_preview_update_cmd")
848
849 async def update_content(self):
850 with self.content_file_path.open("rb") as f:
851 content = f.read().decode("utf-8-sig")
852 if content and self.syntax != SYNTAX_XHTML:
853 # we use safe=True because we want to have a preview as close as possible
854 # to what the people will see
855 content = await self.host.bridge.syntax_convert(
856 content, self.syntax, SYNTAX_XHTML, True, self.profile
857 )
858
859 xhtml = (
860 f'<html xmlns="http://www.w3.org/1999/xhtml">'
861 f'<head><meta http-equiv="Content-Type" content="text/html;charset=utf-8" />'
862 f"</head>"
863 f"<body>{content}</body>"
864 f"</html>"
865 )
866
867 with open(self.preview_file_path, "wb") as f:
868 f.write(xhtml.encode("utf-8"))
869
870 async def start(self):
871 import webbrowser
872 import urllib.request, urllib.parse, urllib.error
873
874 self.webbrowser, self.urllib = webbrowser, urllib
875
876 if self.args.inotify != "false":
877 try:
878 import aionotify
879
880 except ImportError:
881 if self.args.inotify == "auto":
882 aionotify = None
883 self.disp(
884 f"aionotify module not found, deactivating feature. You can "
885 f"install it with {AIONOTIFY_INSTALL}"
886 )
887 else:
888 self.disp(
889 f"aioinotify not found, can't activate the feature! Please "
890 f"install it with {AIONOTIFY_INSTALL}",
891 error=True,
892 )
893 self.host.quit(1)
894 else:
895 aionotify = None
896
897 sat_conf = self.sat_conf
898 SYNTAX_EXT.update(
899 config.config_get(sat_conf, C.CONFIG_SECTION, CONF_SYNTAX_EXT, {})
900 )
901
902 try:
903 self.open_cb_cmd = config.config_get(
904 sat_conf, C.CONFIG_SECTION, "blog_preview_open_cmd", Exception
905 )
906 except (NoOptionError, NoSectionError):
907 self.open_cb_cmd = None
908 open_cb = self.show_preview
909 else:
910 open_cb = self.open_preview_ext
911
912 self.update_cb_cmd = config.config_get(
913 sat_conf, C.CONFIG_SECTION, "blog_preview_update_cmd", self.open_cb_cmd
914 )
915 if self.update_cb_cmd is None:
916 update_cb = self.show_preview
917 else:
918 update_cb = self.update_preview_ext
919
920 # which file do we need to edit?
921 if self.args.file == "current":
922 self.content_file_path = self.get_current_file(self.profile)
923 else:
924 try:
925 self.content_file_path = Path(self.args.file).resolve(strict=True)
926 except FileNotFoundError:
927 self.disp(_('File "{file}" doesn\'t exist!').format(file=self.args.file))
928 self.host.quit(C.EXIT_NOT_FOUND)
929
930 self.syntax = await guess_syntax_from_path(
931 self.host, sat_conf, self.content_file_path
932 )
933
934 # at this point the syntax is converted, we can display the preview
935 preview_file = tempfile.NamedTemporaryFile(suffix=".xhtml", delete=False)
936 self.preview_file_path = preview_file.name
937 preview_file.close()
938 await self.update_content()
939
940 if aionotify is None:
941 # XXX: we don't delete file automatically because browser needs it
942 # (and webbrowser.open can return before it is read)
943 self.disp(
944 f"temporary file created at {self.preview_file_path}\nthis file will NOT "
945 f"BE DELETED AUTOMATICALLY, please delete it yourself when you have "
946 f"finished"
947 )
948 await open_cb()
949 else:
950 await open_cb()
951 watcher = aionotify.Watcher()
952 watcher_kwargs = {
953 # Watcher don't accept Path so we convert to string
954 "path": str(self.content_file_path),
955 "alias": "content_file",
956 "flags": aionotify.Flags.CLOSE_WRITE
957 | aionotify.Flags.DELETE_SELF
958 | aionotify.Flags.MOVE_SELF,
959 }
960 watcher.watch(**watcher_kwargs)
961
962 loop = asyncio.get_event_loop()
963 await watcher.setup(loop)
964
965 try:
966 while True:
967 event = await watcher.get_event()
968 self.disp("Content updated", 1)
969 if event.flags & (
970 aionotify.Flags.DELETE_SELF | aionotify.Flags.MOVE_SELF
971 ):
972 self.disp(
973 "DELETE/MOVE event catched, changing the watch",
974 2,
975 )
976 try:
977 watcher.unwatch("content_file")
978 except IOError as e:
979 self.disp(
980 f"Can't remove the watch: {e}",
981 2,
982 )
983 watcher = aionotify.Watcher()
984 watcher.watch(**watcher_kwargs)
985 try:
986 await watcher.setup(loop)
987 except OSError:
988 # if the new file is not here yet we can have an error
989 # as a workaround, we do a little rest and try again
990 await asyncio.sleep(1)
991 await watcher.setup(loop)
992 await self.update_content()
993 await update_cb()
994 except FileNotFoundError:
995 self.disp("The file seems to have been deleted.", error=True)
996 self.host.quit(C.EXIT_NOT_FOUND)
997 finally:
998 os.unlink(self.preview_file_path)
999 try:
1000 watcher.unwatch("content_file")
1001 except IOError as e:
1002 self.disp(
1003 f"Can't remove the watch: {e}",
1004 2,
1005 )
1006
1007
1008 class Import(base.CommandBase):
1009 def __init__(self, host):
1010 super().__init__(
1011 host,
1012 "import",
1013 use_pubsub=True,
1014 use_progress=True,
1015 help=_("import an external blog"),
1016 )
1017
1018 def add_parser_options(self):
1019 self.parser.add_argument(
1020 "importer",
1021 nargs="?",
1022 help=_("importer name, nothing to display importers list"),
1023 )
1024 self.parser.add_argument("--host", help=_("original blog host"))
1025 self.parser.add_argument(
1026 "--no-images-upload",
1027 action="store_true",
1028 help=_("do *NOT* upload images (default: do upload images)"),
1029 )
1030 self.parser.add_argument(
1031 "--upload-ignore-host",
1032 help=_("do not upload images from this host (default: upload all images)"),
1033 )
1034 self.parser.add_argument(
1035 "--ignore-tls-errors",
1036 action="store_true",
1037 help=_("ignore invalide TLS certificate for uploads"),
1038 )
1039 self.parser.add_argument(
1040 "-o",
1041 "--option",
1042 action="append",
1043 nargs=2,
1044 default=[],
1045 metavar=("NAME", "VALUE"),
1046 help=_("importer specific options (see importer description)"),
1047 )
1048 self.parser.add_argument(
1049 "location",
1050 nargs="?",
1051 help=_(
1052 "importer data location (see importer description), nothing to show "
1053 "importer description"
1054 ),
1055 )
1056
1057 async def on_progress_started(self, metadata):
1058 self.disp(_("Blog upload started"), 2)
1059
1060 async def on_progress_finished(self, metadata):
1061 self.disp(_("Blog uploaded successfully"), 2)
1062 redirections = {
1063 k[len(URL_REDIRECT_PREFIX) :]: v
1064 for k, v in metadata.items()
1065 if k.startswith(URL_REDIRECT_PREFIX)
1066 }
1067 if redirections:
1068 conf = "\n".join(
1069 [
1070 "url_redirections_dict = {}".format(
1071 # we need to add ' ' before each new line
1072 # and to double each '%' for ConfigParser
1073 "\n ".join(
1074 json.dumps(redirections, indent=1, separators=(",", ": "))
1075 .replace("%", "%%")
1076 .split("\n")
1077 )
1078 ),
1079 ]
1080 )
1081 self.disp(
1082 _(
1083 "\nTo redirect old URLs to new ones, put the following lines in your"
1084 " sat.conf file, in [libervia] section:\n\n{conf}"
1085 ).format(conf=conf)
1086 )
1087
1088 async def on_progress_error(self, error_msg):
1089 self.disp(
1090 _("Error while uploading blog: {error_msg}").format(error_msg=error_msg),
1091 error=True,
1092 )
1093
1094 async def start(self):
1095 if self.args.location is None:
1096 for name in ("option", "service", "no_images_upload"):
1097 if getattr(self.args, name):
1098 self.parser.error(
1099 _(
1100 "{name} argument can't be used without location argument"
1101 ).format(name=name)
1102 )
1103 if self.args.importer is None:
1104 self.disp(
1105 "\n".join(
1106 [
1107 f"{name}: {desc}"
1108 for name, desc in await self.host.bridge.blogImportList()
1109 ]
1110 )
1111 )
1112 else:
1113 try:
1114 short_desc, long_desc = await self.host.bridge.blogImportDesc(
1115 self.args.importer
1116 )
1117 except Exception as e:
1118 msg = [l for l in str(e).split("\n") if l][
1119 -1
1120 ] # we only keep the last line
1121 self.disp(msg)
1122 self.host.quit(1)
1123 else:
1124 self.disp(f"{self.args.importer}: {short_desc}\n\n{long_desc}")
1125 self.host.quit()
1126 else:
1127 # we have a location, an import is requested
1128 options = {key: value for key, value in self.args.option}
1129 if self.args.host:
1130 options["host"] = self.args.host
1131 if self.args.ignore_tls_errors:
1132 options["ignore_tls_errors"] = C.BOOL_TRUE
1133 if self.args.no_images_upload:
1134 options["upload_images"] = C.BOOL_FALSE
1135 if self.args.upload_ignore_host:
1136 self.parser.error(
1137 "upload-ignore-host option can't be used when no-images-upload "
1138 "is set"
1139 )
1140 elif self.args.upload_ignore_host:
1141 options["upload_ignore_host"] = self.args.upload_ignore_host
1142
1143 try:
1144 progress_id = await self.host.bridge.blogImport(
1145 self.args.importer,
1146 self.args.location,
1147 options,
1148 self.args.service,
1149 self.args.node,
1150 self.profile,
1151 )
1152 except Exception as e:
1153 self.disp(
1154 _("Error while trying to import a blog: {e}").format(e=e),
1155 error=True,
1156 )
1157 self.host.quit(1)
1158 else:
1159 await self.set_progress_id(progress_id)
1160
1161
1162 class AttachmentGet(cmd_pubsub.AttachmentGet):
1163
1164 def __init__(self, host):
1165 super().__init__(host)
1166 self.override_pubsub_flags({C.SERVICE, C.SINGLE_ITEM})
1167
1168
1169 async def start(self):
1170 if not self.args.node:
1171 namespaces = await self.host.bridge.namespaces_get()
1172 try:
1173 ns_microblog = namespaces["microblog"]
1174 except KeyError:
1175 self.disp("XEP-0277 plugin is not loaded", error=True)
1176 self.host.quit(C.EXIT_MISSING_FEATURE)
1177 else:
1178 self.args.node = ns_microblog
1179 return await super().start()
1180
1181
1182 class AttachmentSet(cmd_pubsub.AttachmentSet):
1183
1184 def __init__(self, host):
1185 super().__init__(host)
1186 self.override_pubsub_flags({C.SERVICE, C.SINGLE_ITEM})
1187
1188 async def start(self):
1189 if not self.args.node:
1190 namespaces = await self.host.bridge.namespaces_get()
1191 try:
1192 ns_microblog = namespaces["microblog"]
1193 except KeyError:
1194 self.disp("XEP-0277 plugin is not loaded", error=True)
1195 self.host.quit(C.EXIT_MISSING_FEATURE)
1196 else:
1197 self.args.node = ns_microblog
1198 return await super().start()
1199
1200
1201 class Attachments(base.CommandBase):
1202 subcommands = (AttachmentGet, AttachmentSet)
1203
1204 def __init__(self, host):
1205 super().__init__(
1206 host,
1207 "attachments",
1208 use_profile=False,
1209 help=_("set or retrieve blog attachments"),
1210 )
1211
1212
1213 class Blog(base.CommandBase):
1214 subcommands = (Set, Get, Edit, Rename, Repeat, Preview, Import, Attachments)
1215
1216 def __init__(self, host):
1217 super(Blog, self).__init__(
1218 host, "blog", use_profile=False, help=_("blog/microblog management")
1219 )