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

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 

13 

14 

15class BlogPostList: 

16 

17 """ 

18 Defines a list of @see cl BlogPost. 

19 """ 

20 

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``. 

26 

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 

55 

56 def __getitem__(self, key): 

57 """ 

58 usual 

59 """ 

60 return self._blogposts[key] 

61 

62 @staticmethod 

63 def category2url(cat): 

64 """ 

65 Removes accents and spaces to get a clean url. 

66 

67 @param cat category name 

68 @return cleaned category 

69 """ 

70 return remove_diacritics(cat).replace(" ", "_") 

71 

72 @property 

73 def Lang(self): 

74 """ 

75 Returns the language. 

76 """ 

77 return self._language 

78 

79 def __iter__(self): 

80 """ 

81 Iterates on @see cl BlogPost. 

82 """ 

83 for obj in self._blogposts: 

84 yield obj 

85 

86 def __len__(self): 

87 """ 

88 Returns the number of blog posts. 

89 """ 

90 return len(self._blogposts) 

91 

92 def get_categories(self): 

93 """ 

94 Extracts the categories. 

95 

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))) 

102 

103 def get_categories_group(self): 

104 """ 

105 Extracts the categories with the posts associated to it. 

106 

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 

116 

117 def get_keywords(self): 

118 """ 

119 Extracts the categories. 

120 

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))) 

127 

128 def get_months(self): 

129 """ 

130 Extracts the months. 

131 

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)) 

139 

140 def get_months_group(self): 

141 """ 

142 Extracts the months with the posts associated to it. 

143 

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 

153 

154 def get_files(self): 

155 """ 

156 Extracts the files. 

157 

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)) 

164 

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. 

169 

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 

178 

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 

188 

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. 

193 

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 

202 

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 

208 

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). 

217 

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() 

231 

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) 

244 

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)) 

255 

256 # final aggregator 

257 res.extend(self.write_aggregated_chapters(folder)) 

258 

259 return res 

260 

261 def get_image(self, img): 

262 """ 

263 Returns the local path to an image in this folder. 

264 

265 @param img image name (see below) 

266 @return local file 

267 

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}.") 

282 

283 def write_aggregated_index(self, folder, hidden_files=None, hidden_files_html=None, only_html=True): 

284 """ 

285 Writes an index. 

286 

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") 

322 

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") 

332 

333 if only_html: 

334 f.write("\n\n") 

335 f.write(".. only:: html\n\n") 

336 

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") 

350 

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") 

356 

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") 

362 

363 img = self.get_image("rss") 

364 shutil.copy(img, folder) 

365 

366 return name 

367 

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. 

372 

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) 

386 

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. 

391 

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 

411 

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. 

416 

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 

435 

436 def write_aggregated_chapters(self, folder): 

437 """ 

438 Writes links to post per categories and per months. 

439 

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)) 

460 

461 res.extend(["", "", ""]) 

462 res.extend([TITLES[self.Lang]["by title:"], "", ""]) 

463 res.extend( 

464 ["", "", f":ref:`{TITLES[self.Lang]['allblogs']} <l-mainblog>`", "", ""]) 

465 

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] 

470 

471 ################# 

472 # static methods 

473 ################# 

474 

475 @staticmethod 

476 def divide_list(ld, division): 

477 """ 

478 Divides a list into buckets of *division* items. 

479 

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 

495 

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. 

502 

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 

542 

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. 

549 

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) 

583 

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("") 

596 

597 if index_terms is not None: 

598 rows.append("") 

599 rows.append(".. index:: " + ",".join(index_terms)) 

600 rows.append("") 

601 

602 rows.append("") 

603 rows.append(f".. _{this}:") 

604 rows.append("") 

605 

606 if bold_title is not None: 

607 rows.append(bold_title) 

608 rows.append("+" * len(bold_title)) 

609 rows.append("") 

610 

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("") 

616 

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) 

625 

626 return "\n".join(rows)