Coverage for pyquickhelper/sphinxext/blog_post_list.py: 89%
319 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-03 02:21 +0200
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-03 02:21 +0200
1# -*- coding: utf-8 -*-
2"""
3@file
4@brief Helpers to build :epkg:`RST` extra files inserted in the documentation.
5"""
6import os
7import shutil
8from .blog_post import BlogPost
9from .build_rss import build_rss
10from ..texthelper.texts_language import TITLES
11from ..texthelper.diacritic_helper import remove_diacritics
12from ..loghelper import noLOG
15class BlogPostList:
17 """
18 Defines a list of @see cl BlogPost.
19 """
21 def __init__(self, folder, encoding="utf8", language="en", extensions=None,
22 conf=None, fLOG=noLOG):
23 """
24 Creates a list of @see cl BlogPost, we assume each blog
25 post belongs to a subfolder ``YYYY``.
27 :param folder: folder when to find files
28 :param encoding: encoding
29 :param language: language
30 :param extensions: list of extension to use to parse the content
31 of the blog, if None, it will consider a default list
32 (@see cl BlogPost and @see fn get_default_extensions)
33 :param conf: existing configuration
34 :param fLOG: logging function
35 """
36 self._blogposts = []
37 sub = os.listdir(folder)
38 for s in sorted(sub):
39 full = os.path.join(folder, s)
40 if os.path.isdir(full):
41 fLOG(f"[BlogPostList] reading folder {full!r}")
42 posts = os.listdir(full)
43 for post in sorted(posts):
44 if os.path.splitext(post)[-1] in [".rst"]:
45 fpost = os.path.join(full, post)
46 fLOG(f" reading post {post!r}")
47 obj = BlogPost(fpost, encoding=encoding,
48 extensions=extensions, conf=conf)
49 self._blogposts.append((obj.date, obj))
50 fLOG(f"[BlogPostList] end reading folder {full!r}")
51 self._blogposts.sort(reverse=True)
52 self._blogposts = [_[1] for _ in self._blogposts]
53 self._encoding = encoding
54 self._language = language
56 def __getitem__(self, key):
57 """
58 usual
59 """
60 return self._blogposts[key]
62 @staticmethod
63 def category2url(cat):
64 """
65 Removes accents and spaces to get a clean url.
67 @param cat category name
68 @return cleaned category
69 """
70 return remove_diacritics(cat).replace(" ", "_")
72 @property
73 def Lang(self):
74 """
75 Returns the language.
76 """
77 return self._language
79 def __iter__(self):
80 """
81 Iterates on @see cl BlogPost.
82 """
83 for obj in self._blogposts:
84 yield obj
86 def __len__(self):
87 """
88 Returns the number of blog posts.
89 """
90 return len(self._blogposts)
92 def get_categories(self):
93 """
94 Extracts the categories.
96 @return list of sorted categories
97 """
98 cats = []
99 for post in self:
100 cats.extend(post.Categories)
101 return list(sorted(set(cats)))
103 def get_categories_group(self):
104 """
105 Extracts the categories with the posts associated to it.
107 @return dictionary (category, list of posts)
108 """
109 m = {}
110 for post in self:
111 for cat in post.Categories:
112 if cat not in m:
113 m[cat] = []
114 m[cat].append(post)
115 return m
117 def get_keywords(self):
118 """
119 Extracts the categories.
121 @return list of sorted keywords
122 """
123 keys = []
124 for post in self:
125 keys.extend(post.Keywords)
126 return list(sorted(set(keys)))
128 def get_months(self):
129 """
130 Extracts the months.
132 @return list of sorted months (more recent first)
133 """
134 m = []
135 for post in self:
136 d = "-".join(post.Date.split("-")[:2])
137 m.append(d)
138 return list(sorted(set(m), reverse=True))
140 def get_months_group(self):
141 """
142 Extracts the months with the posts associated to it.
144 @return dictionary (months, list of posts)
145 """
146 m = {}
147 for post in self:
148 d = "-".join(post.Date.split("-")[:2])
149 if d not in m:
150 m[d] = []
151 m[d].append(post)
152 return m
154 def get_files(self):
155 """
156 Extracts the files.
158 @return list of sorted months (more recent first)
159 """
160 m = []
161 for post in self:
162 m.append(post.FileName)
163 return list(sorted(set(m), reverse=True))
165 def get_rst_links_up(self):
166 """
167 Builds the :epkg:`rst` links to months or categories to displays
168 at the beginning of the aggregated pages.
170 @return list of rst_links
171 """
172 ens = self.get_categories_group()
173 if len(ens) > 5:
174 sorted_end = list(sorted((v, m) for m, v in ens.items()))
175 ens = {}
176 for v, m in sorted_end[-5:]:
177 ens[m] = v
179 links = []
180 for m, v in sorted(ens.items()):
181 if len(v) <= 2:
182 # we skip categories with less than 2 blog post
183 continue
184 link = ":ref:`{0} ({1}) <ap-cat-{0}-0>`".format(
185 BlogPostList.category2url(m), len(v))
186 links.append(link)
187 return links
189 def get_rst_links_down(self):
190 """
191 Builds the :epkg:`rst` links to months or categories to displays
192 the bottom of the aggregated pages.
194 @return list of rst_links
195 """
196 ens = self.get_months_group()
197 if len(ens) > 5:
198 sorted_end = list(sorted((m, v) for m, v in ens.items()))
199 ens = {}
200 for m, v in sorted_end[-5:]:
201 ens[m] = v
203 links = []
204 for m, v in sorted(ens.items()):
205 link = ":ref:`{0} ({1}) <ap-month-{0}-0>`".format(m, len(v))
206 links.append(link)
207 return links
209 def write_aggregated(self, folder, division=10,
210 blog_title="__BLOG_TITLE__",
211 blog_description="__BLOG_DESCRIPTION__",
212 blog_root="__BLOG_ROOT__",
213 only_html_index=True,
214 only_html_agg=False):
215 """
216 Writes posts in a aggregated manner (post, categories, months).
218 @param folder where to write them
219 @param division add a new page every *division* items
220 @param blog_title blog title
221 @param blog_description blog description
222 @param blog_root blog root (publish url)
223 @param only_html_index add item ``.. only:: html`` and indent everything
224 after the main index
225 @param only_html_agg add item ``.. only:: html`` and indent everything
226 for aggregated pages
227 @return list of produced files
228 """
229 link_up = self.get_rst_links_up()
230 link_down = self.get_rst_links_down()
232 # rss
233 rss = os.path.join(folder, "rss.xml")
234 keep = []
235 for _ in self:
236 if len(keep) >= 10:
237 break
238 keep.append(_)
239 c = build_rss(keep, blog_title=blog_title,
240 blog_description=blog_description,
241 blog_root=blog_root)
242 with open(rss, "w", encoding=self._encoding) as f:
243 f.write(c)
245 # aggregated pages
246 res = []
247 res.extend(self.write_aggregated_posts(folder, division, rst_links_up=link_up,
248 rst_links_down=link_down, only_html=only_html_agg))
249 res.extend(self.write_aggregated_categories(folder, division, rst_links_up=link_up,
250 rst_links_down=link_down, only_html=only_html_agg))
251 res.extend(self.write_aggregated_months(folder, division, rst_links_up=link_up,
252 rst_links_down=link_down, only_html=only_html_agg))
253 res.append(self.write_aggregated_index(
254 folder, hidden_files=None, hidden_files_html=res, only_html=only_html_index))
256 # final aggregator
257 res.extend(self.write_aggregated_chapters(folder))
259 return res
261 def get_image(self, img):
262 """
263 Returns the local path to an image in this folder.
265 @param img image name (see below)
266 @return local file
268 Allowed image names:
269 - rss: image for RSS stream
270 """
271 if img == "rss":
272 img = "feed-icon-16x16.png"
273 loc = os.path.abspath(os.path.dirname(__file__))
274 img = os.path.join(loc, img)
275 if not os.path.exists(img):
276 raise FileNotFoundError( # pragma: no cover
277 f"Unable to find {img!r}.")
278 return img
279 else:
280 raise FileNotFoundError( # pragma: no cover
281 f"Unable to get image name: {img!r}.")
283 def write_aggregated_index(self, folder, hidden_files=None, hidden_files_html=None, only_html=True):
284 """
285 Writes an index.
287 @param folder where to write the file
288 @param hidden_files creates an hidden *toctree* and a @see cl tocdelay_node.
289 @param only_html add item ``.. only:: html`` and indent everything
290 after the main index
291 @param hidden_files_html add item ``.. only:: html`` for these pages
292 @return filename
293 """
294 indent = " " if only_html else ""
295 name = os.path.join(folder, "blogindex.rst")
296 with open(name, "w", encoding=self._encoding) as f:
297 f.write("\n")
298 f.write(":orphan:\n")
299 f.write("\n")
300 f.write(".. _l-mainblog:\n")
301 f.write("\n")
302 f.write("\n")
303 f.write("Blog Gallery\n")
304 f.write("============\n")
305 f.write("\n")
306 f.write(".. tocdelay::\n")
307 f.write("\n")
308 for item in self:
309 name_file = os.path.splitext(
310 os.path.split(item.FileName)[-1])[0]
311 f.write(" {0} - {1} <{2}/{3}>\n".format(
312 item.Date, item.Title, item.Date[:4], name_file))
313 f.write("\n\n")
314 f.write(".. toctree::\n")
315 f.write(" :hidden:\n")
316 f.write("\n")
317 for item_id, item in enumerate(self):
318 fl = os.path.split(item.FileName)[-1]
319 fl = os.path.splitext(fl)[0]
320 f.write(
321 f" blog {item_id} <{item.Date[:4]}/{fl}>\n")
323 if hidden_files is not None:
324 f.write("\n\n")
325 f.write(".. toctree::\n")
326 f.write(" :hidden:\n")
327 f.write("\n")
328 for hid, h in enumerate(hidden_files):
329 f.write(" blog {1} <{0}>\n".format(
330 os.path.splitext(os.path.split(h)[-1])[0], hid))
331 f.write("\n\n")
333 if only_html:
334 f.write("\n\n")
335 f.write(".. only:: html\n\n")
337 if hidden_files_html is not None:
338 f.write(indent + ".. toctree::\n")
339 f.write(indent + " :hidden:\n")
340 f.write("\n")
341 for item_id, item in enumerate(self):
342 fl = os.path.split(item.FileName)[-1]
343 fl = os.path.splitext(fl)[0]
344 f.write(
345 indent + f" {item_id} <{item.Date[:4]}/{fl}>\n")
346 for hid, h in enumerate(hidden_files_html):
347 f.write(
348 indent + f" blog {hid} <{os.path.splitext(os.path.split(h)[-1])[0]}>\n")
349 f.write("\n\n")
351 f.write("\n")
352 f.write(indent + ".. image:: feed-icon-16x16.png\n\n")
353 f.write(
354 indent + f":download:`{TITLES[self.Lang]['download']} rss <rss.xml>`\n")
355 f.write("\n\n\n")
357 f.write(
358 indent + f":ref:`{TITLES[self.Lang]['main']} <hblog-blog>`, ")
359 f.write(
360 indent + f":ref:`{TITLES[self.Lang]['main2']} <ap-main-0>`")
361 f.write("\n\n\n")
363 img = self.get_image("rss")
364 shutil.copy(img, folder)
366 return name
368 def write_aggregated_posts(self, folder, division=10, rst_links_up=None,
369 rst_links_down=None, only_html=True):
370 """
371 Writes posts in a aggregated manner.
373 @param folder where to write them
374 @param division add a new page every *division* items
375 @param rst_links_up list of rst_links to add at the beginning of a page
376 @param rst_links_down list of rst_links to add at the bottom of a page
377 @param only_html add item ``.. only:: html`` and indent everything
378 @return list of produced files
379 """
380 return BlogPostList.write_aggregated_post_list(
381 folder=folder, lp=list(_ for _ in self), division=division,
382 prefix="main", encoding=self._encoding, rst_links_up=rst_links_up,
383 rst_links_down=rst_links_down, index_terms=["blog"],
384 language=self.Lang, bold_title=TITLES[self.Lang]["main_title"],
385 only_html=only_html)
387 def write_aggregated_categories(self, folder, division=10, rst_links_up=None,
388 rst_links_down=None, only_html=True):
389 """
390 Writes posts in a aggregated manner per categories.
392 @param folder where to write them
393 @param division add a new page every *division* items
394 @param rst_links_up list of rst_links to add at the beginning of a page
395 @param rst_links_down list of rst_links to add at the bottom of a page
396 @param only_html add item ``.. only:: html`` and indent everything
397 @return list of produced files
398 """
399 cats = self.get_categories()
400 res = []
401 for cat in cats:
402 posts = [_ for _ in self if cat in _.Categories]
403 url_cat = BlogPostList.category2url(cat)
404 add = BlogPostList.write_aggregated_post_list(
405 folder=folder, lp=posts, division=division, prefix="cat-" + url_cat,
406 encoding=self._encoding, rst_links_up=rst_links_up,
407 rst_links_down=rst_links_down, index_terms=[cat],
408 bold_title=cat, only_html=only_html)
409 res.extend(add)
410 return res
412 def write_aggregated_months(self, folder, division=10, rst_links_up=None,
413 rst_links_down=None, only_html=True):
414 """
415 Writes posts in a aggregated manner per months.
417 @param folder where to write them
418 @param division add a new page every *division* items
419 @param rst_links_up list of rst_links to add at the beginning of a page
420 @param rst_links_down list of rst_links to add at the bottom of a page
421 @param only_html add item ``.. only:: html`` and indent everything
422 @return list of produced files
423 """
424 mo = self.get_months()
425 res = []
426 for m in mo:
427 posts = [_ for _ in self if _.Date.startswith(m)]
428 add = BlogPostList.write_aggregated_post_list(
429 folder=folder, lp=posts, division=division,
430 prefix="month-" + m, encoding=self._encoding,
431 rst_links_up=rst_links_up, rst_links_down=rst_links_down,
432 index_terms=[m], bold_title=m, only_html=only_html)
433 res.extend(add)
434 return res
436 def write_aggregated_chapters(self, folder):
437 """
438 Writes links to post per categories and per months.
440 @param folder where to write them
441 @return list of produced files
442 """
443 cats = sorted([(k, len(v))
444 for k, v in self.get_categories_group().items()])
445 months = sorted(
446 [(k, len(v)) for k, v in self.get_months_group().items()], reverse=True)
447 res = ["", ":orphan:", "", ".. _hblog-blog:",
448 "", "", "Blog", "====", "", ""]
449 res.extend(
450 [f"* :ref:`{TITLES[self.Lang]['page1']} <ap-main-0>`", "", ""])
451 res.extend([TITLES[self.Lang]["by category:"], "", ""])
452 for cat, nb in cats:
453 res.append(
454 "* :ref:`{0} ({1}) <ap-cat-{0}-0>`".format(BlogPostList.category2url(cat), nb))
455 res.extend(["", "", ""])
456 res.extend([TITLES[self.Lang]["by month:"], "", ""])
457 res.extend(["", "", ""])
458 for mon, nb in months:
459 res.append("* :ref:`{0} ({1}) <ap-month-{0}-0>`".format(mon, nb))
461 res.extend(["", "", ""])
462 res.extend([TITLES[self.Lang]["by title:"], "", ""])
463 res.extend(
464 ["", "", f":ref:`{TITLES[self.Lang]['allblogs']} <l-mainblog>`", "", ""])
466 filename = os.path.join(folder, "index_blog.rst")
467 with open(filename, "w", encoding="utf8") as f:
468 f.write("\n".join(res))
469 return [filename]
471 #################
472 # static methods
473 #################
475 @staticmethod
476 def divide_list(ld, division):
477 """
478 Divides a list into buckets of *division* items.
480 @param ld list of to divide
481 @param division bucket size
482 @return list fo buckets
483 """
484 buckets = []
485 current = []
486 for obj in ld:
487 if len(current) < division:
488 current.append(obj)
489 else:
490 buckets.append(current)
491 current = [obj]
492 if len(current) > 0:
493 buckets.append(current)
494 return buckets
496 @staticmethod
497 def write_aggregated_post_list(folder, lp, division, prefix, encoding,
498 rst_links_up=None, rst_links_down=None, index_terms=None,
499 bold_title=None, language="en", only_html=True):
500 """
501 Writes list of posts in an aggregated manners.
503 @param folder when to write the aggregated posts
504 @param lp list of posts
505 @param division bucket size
506 @param prefix prefix name for the files
507 @param encoding encoding for the written files
508 @param rst_links_up list of rst_links to add at the beginning of a page
509 @param rst_links_down list of rst_links to add at the bottom of a page
510 @param index_terms terms to index on the first bucket
511 @param bold_title title to display at the beginning of the page
512 @param language language
513 @param only_html add item ``.. only:: html`` and indent everything
514 @return list of produced files
515 """
516 res = []
517 buckets = BlogPostList.divide_list(lp, division)
518 for i, b in enumerate(buckets):
519 if bold_title is not None:
520 title = f"{bold_title} - {i + 1}/{len(buckets)}"
521 else:
522 title = None
523 name = os.path.join(folder, "%s_%04d.rst" % (prefix, i))
524 prev = "ap-%s-%d" % (prefix, i - 1) if i > 0 else None
525 this = "ap-%s-%d" % (prefix, i)
526 next = "ap-%s-%d" % (prefix, i + 1) \
527 if i < len(buckets) - 1 else None
528 content = BlogPostList.produce_aggregated_post_page(
529 name, b, this, prev, next,
530 rst_links_up=rst_links_up,
531 rst_links_down=rst_links_down,
532 index_terms=index_terms if i == 0 else None,
533 bold_title=title, language=language)
534 if only_html:
535 lines = content.split("\n")
536 head = "\n:orphan:\n\n.. only:: html\n\n"
537 content = head + "\n".join(" " + _ for _ in lines)
538 with open(name, "w", encoding=encoding) as f:
539 f.write(content)
540 res.append(name)
541 return res
543 @staticmethod
544 def produce_aggregated_post_page(name, lp, this, prev, next, main_page="Blog",
545 rst_links_up=None, rst_links_down=None,
546 index_terms=None, bold_title=None, language="en"):
547 """
548 Writes the content of an aggregate page of blog posts.
550 @param name filename to write
551 @param lp list of posts
552 @param this reference to this page
553 @param prev reference to the previous page
554 @param next reference to the next page
555 @param main_page name of the main page
556 @param rst_links_up list of rst_links to add at the beginning of a page
557 @param rst_links_down list of rst_links to add at the bottom of a page
558 @param index_terms terms to index
559 @param bold_title title to display of the beginning of the page
560 @param language language
561 @return content of the page
562 """
563 direction = "|rss_image| "
564 if prev is not None:
565 direction += f":ref:`<== <{prev}>` "
566 if bold_title is not None:
567 if len(direction) > 0:
568 direction += " "
569 direction += f"**{bold_title}**"
570 if next is not None:
571 if len(direction) > 0:
572 direction += " "
573 direction += f":ref:`==> <{next}>`"
574 arrows = direction
575 if main_page is not None:
576 if len(direction) > 0:
577 direction += " "
578 direction += f":ref:`{main_page} <ap-main-0>`"
579 if rst_links_up is not None:
580 if len(direction) > 0:
581 direction += " "
582 direction += " ".join(rst_links_up)
584 rows = []
585 rows.append("")
586 rows.append(":orphan:")
587 rows.append("")
588 rows.append(direction)
589 rows.append("")
590 rows.append(".. |rss_image| image:: feed-icon-16x16.png")
591 rows.append(" :target: ../_downloads/rss.xml")
592 rows.append(" :alt: RSS")
593 rows.append("")
594 rows.append("----")
595 rows.append("")
597 if index_terms is not None:
598 rows.append("")
599 rows.append(".. index:: " + ",".join(index_terms))
600 rows.append("")
602 rows.append("")
603 rows.append(f".. _{this}:")
604 rows.append("")
606 if bold_title is not None:
607 rows.append(bold_title)
608 rows.append("+" * len(bold_title))
609 rows.append("")
611 for post in lp:
612 text = post.post_as_rst(language=language, cut=True)
613 rows.append(text)
614 rows.append("")
615 rows.append("")
617 rows.append("")
618 rows.append("----")
619 rows.append("")
620 if rst_links_down is not None:
621 if len(arrows) > 0:
622 arrows += " "
623 arrows += " ".join(rst_links_down)
624 rows.append(arrows)
626 return "\n".join(rows)