unaltraweb 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (444) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +20 -0
  3. data/Makefile +412 -0
  4. data/README.md +226 -0
  5. data/_config.yml +842 -0
  6. data/_data/i18n/ca.yml +278 -0
  7. data/_data/i18n/en.yml +278 -0
  8. data/_data/i18n/es.yml +278 -0
  9. data/_includes/audio.liquid +30 -0
  10. data/_includes/bib_search.liquid +5 -0
  11. data/_includes/blog-list.liquid +66 -0
  12. data/_includes/book-citation.liquid +33 -0
  13. data/_includes/citation.liquid +26 -0
  14. data/_includes/custom_head.liquid +3 -0
  15. data/_includes/custom_scripts.liquid +5 -0
  16. data/_includes/cv/list.liquid +5 -0
  17. data/_includes/cv/list_groups.liquid +51 -0
  18. data/_includes/cv/map.liquid +29 -0
  19. data/_includes/cv/nested_list.liquid +16 -0
  20. data/_includes/cv/time_table.liquid +118 -0
  21. data/_includes/cv-download-card.liquid +26 -0
  22. data/_includes/developer-mode-switcher.liquid +24 -0
  23. data/_includes/disqus.liquid +13 -0
  24. data/_includes/distill_scripts.liquid +298 -0
  25. data/_includes/documentation-download-detail.liquid +76 -0
  26. data/_includes/documentation-downloads.liquid +71 -0
  27. data/_includes/documentation-profile-attributes.liquid +4 -0
  28. data/_includes/documentation-section-toc.liquid +9 -0
  29. data/_includes/documentation-sidebar.liquid +112 -0
  30. data/_includes/figure.liquid +87 -0
  31. data/_includes/footer.liquid +66 -0
  32. data/_includes/giscus.liquid +25 -0
  33. data/_includes/head.liquid +220 -0
  34. data/_includes/header.liquid +544 -0
  35. data/_includes/latest_posts.liquid +61 -0
  36. data/_includes/localized-date.liquid +5 -0
  37. data/_includes/manual-bibliography.liquid +2 -0
  38. data/_includes/manual-featured-readings.liquid +12 -0
  39. data/_includes/manual-other-readings.liquid +12 -0
  40. data/_includes/manual-page-toc.liquid +11 -0
  41. data/_includes/manual-sidebar.liquid +58 -0
  42. data/_includes/manual-teachers.liquid +74 -0
  43. data/_includes/metadata.liquid +278 -0
  44. data/_includes/news.liquid +48 -0
  45. data/_includes/newsletter.liquid +68 -0
  46. data/_includes/page-hero.liquid +96 -0
  47. data/_includes/page-link.liquid +8 -0
  48. data/_includes/pagination.liquid +22 -0
  49. data/_includes/post-reading-time.liquid +29 -0
  50. data/_includes/previous-next.liquid +37 -0
  51. data/_includes/profile-card.liquid +49 -0
  52. data/_includes/profile-feature-matrix.liquid +75 -0
  53. data/_includes/profile-highlights.liquid +122 -0
  54. data/_includes/project-grid.liquid +26 -0
  55. data/_includes/project-resource-link.liquid +45 -0
  56. data/_includes/project-resources.liquid +21 -0
  57. data/_includes/projects.liquid +38 -0
  58. data/_includes/projects_horizontal.liquid +37 -0
  59. data/_includes/publications-metrics-summary.liquid +69 -0
  60. data/_includes/publications-summary.liquid +127 -0
  61. data/_includes/reading-biblio-controls.liquid +65 -0
  62. data/_includes/reading-cover-card.liquid +93 -0
  63. data/_includes/reading-rating.liquid +11 -0
  64. data/_includes/recommended-readings.liquid +30 -0
  65. data/_includes/related_posts.liquid +39 -0
  66. data/_includes/repository/repo.liquid +48 -0
  67. data/_includes/repository/repo_trophies.liquid +44 -0
  68. data/_includes/repository/repo_user.liquid +35 -0
  69. data/_includes/resume/awards.liquid +19 -0
  70. data/_includes/resume/basics.liquid +28 -0
  71. data/_includes/resume/certificates.liquid +35 -0
  72. data/_includes/resume/education.liquid +54 -0
  73. data/_includes/resume/interests.liquid +34 -0
  74. data/_includes/resume/languages.liquid +31 -0
  75. data/_includes/resume/projects.liquid +32 -0
  76. data/_includes/resume/publications.liquid +28 -0
  77. data/_includes/resume/references.liquid +31 -0
  78. data/_includes/resume/skills.liquid +33 -0
  79. data/_includes/resume/volunteer.liquid +52 -0
  80. data/_includes/resume/work.liquid +52 -0
  81. data/_includes/scripts.liquid +364 -0
  82. data/_includes/search-data.js.liquid +341 -0
  83. data/_includes/selected_papers.liquid +3 -0
  84. data/_includes/t.liquid +31 -0
  85. data/_includes/team-grid.liquid +172 -0
  86. data/_includes/video.liquid +97 -0
  87. data/_layouts/about.liquid +93 -0
  88. data/_layouts/archive.liquid +45 -0
  89. data/_layouts/bib.liquid +677 -0
  90. data/_layouts/book-review.liquid +225 -0
  91. data/_layouts/book-shelf.liquid +66 -0
  92. data/_layouts/cv.liquid +127 -0
  93. data/_layouts/default.liquid +70 -0
  94. data/_layouts/distill.liquid +132 -0
  95. data/_layouts/documentation-download-detail.liquid +5 -0
  96. data/_layouts/documentation-home.liquid +100 -0
  97. data/_layouts/documentation-page.liquid +45 -0
  98. data/_layouts/manual-bib.liquid +73 -0
  99. data/_layouts/manual-chapter.liquid +99 -0
  100. data/_layouts/manual-featured-bib.liquid +100 -0
  101. data/_layouts/manual-home.liquid +120 -0
  102. data/_layouts/none.liquid +1 -0
  103. data/_layouts/outputs.liquid +29 -0
  104. data/_layouts/page.liquid +52 -0
  105. data/_layouts/post.liquid +95 -0
  106. data/_layouts/profile.liquid +36 -0
  107. data/_layouts/profiles.liquid +36 -0
  108. data/_layouts/project.liquid +7 -0
  109. data/_layouts/repositories.liquid +58 -0
  110. data/_layouts/theses.liquid +126 -0
  111. data/_plugins/bibliography_profiles.rb +164 -0
  112. data/_plugins/callouts.rb +104 -0
  113. data/_plugins/code_blocks.rb +277 -0
  114. data/_plugins/computation_figure_images.rb +266 -0
  115. data/_plugins/computation_sources.rb +42 -0
  116. data/_plugins/content_search_index.rb +210 -0
  117. data/_plugins/details.rb +23 -0
  118. data/_plugins/external-posts.rb +124 -0
  119. data/_plugins/figure_captions.rb +800 -0
  120. data/_plugins/file-exists.rb +22 -0
  121. data/_plugins/google-scholar-citations.rb +86 -0
  122. data/_plugins/hide-custom-bibtex.rb +18 -0
  123. data/_plugins/inspirehep-citations.rb +57 -0
  124. data/_plugins/localized_visual_sources.rb +145 -0
  125. data/_plugins/manual_release_metadata.rb +47 -0
  126. data/_plugins/mermaid_mmd_images.rb +233 -0
  127. data/_plugins/profile-pages.rb +28 -0
  128. data/_plugins/remove-accents.rb +32 -0
  129. data/_plugins/reproducible_build_time.rb +55 -0
  130. data/_plugins/search-data.rb +83 -0
  131. data/_plugins/theme-cache-bust.rb +56 -0
  132. data/_plugins/vega_visual_images.rb +290 -0
  133. data/_plugins/web_capture_images.rb +154 -0
  134. data/_sass/_base.scss +2133 -0
  135. data/_sass/_blog.scss +67 -0
  136. data/_sass/_callouts.scss +100 -0
  137. data/_sass/_custom.scss +437 -0
  138. data/_sass/_cv.scss +274 -0
  139. data/_sass/_developer.scss +97 -0
  140. data/_sass/_distill.scss +185 -0
  141. data/_sass/_documentation.scss +2220 -0
  142. data/_sass/_layout.scss +218 -0
  143. data/_sass/_manual.scss +2691 -0
  144. data/_sass/_profile.scss +323 -0
  145. data/_sass/_projects.scss +163 -0
  146. data/_sass/_reading.scss +465 -0
  147. data/_sass/_site-custom.scss +3 -0
  148. data/_sass/_tabs.scss +48 -0
  149. data/_sass/_team.scss +230 -0
  150. data/_sass/_themes.scss +254 -0
  151. data/_sass/_typograms.scss +132 -0
  152. data/_sass/_variables.scss +52 -0
  153. data/_sass/font-awesome/_animated.scss +152 -0
  154. data/_sass/font-awesome/_bordered-pulled.scss +20 -0
  155. data/_sass/font-awesome/_core.scss +49 -0
  156. data/_sass/font-awesome/_fixed-width.scss +7 -0
  157. data/_sass/font-awesome/_functions.scss +57 -0
  158. data/_sass/font-awesome/_icons.scss +13 -0
  159. data/_sass/font-awesome/_list.scss +18 -0
  160. data/_sass/font-awesome/_mixins.scss +65 -0
  161. data/_sass/font-awesome/_rotated-flipped.scss +31 -0
  162. data/_sass/font-awesome/_screen-reader.scss +14 -0
  163. data/_sass/font-awesome/_shims.scss +1578 -0
  164. data/_sass/font-awesome/_sizing.scss +16 -0
  165. data/_sass/font-awesome/_stacked.scss +32 -0
  166. data/_sass/font-awesome/_variables.scss +5044 -0
  167. data/_sass/font-awesome/brands.scss +30 -0
  168. data/_sass/font-awesome/fontawesome.scss +21 -0
  169. data/_sass/font-awesome/regular.scss +27 -0
  170. data/_sass/font-awesome/solid.scss +27 -0
  171. data/_sass/font-awesome/v4-shims.scss +11 -0
  172. data/_sass/tabler-icons/tabler-icons-filled.scss +1646 -0
  173. data/_sass/tabler-icons/tabler-icons-outline.scss +9859 -0
  174. data/_sass/tabler-icons/tabler-icons.scss +11463 -0
  175. data/_scripts/cronitor-analytics-setup.js +9 -0
  176. data/_scripts/giscus-setup.js +48 -0
  177. data/_scripts/google-analytics-setup.js +9 -0
  178. data/_scripts/open-panel-analytics-setup.js +14 -0
  179. data/_scripts/photoswipe-setup.js +11 -0
  180. data/assets/audio/epicaly-short-113909.mp3 +0 -0
  181. data/assets/bibliography/2018-12-22-distill.bib +7 -0
  182. data/assets/css/academicons.min.css +1 -0
  183. data/assets/css/bootstrap-toc.min.css +4 -0
  184. data/assets/css/bootstrap.min.css +7 -0
  185. data/assets/css/bootstrap.min.css.map +1 -0
  186. data/assets/css/jekyll-pygments-themes-github.css +209 -0
  187. data/assets/css/jekyll-pygments-themes-native.css +232 -0
  188. data/assets/css/jupyter-grade3.css +3758 -0
  189. data/assets/css/jupyter-monokai.css +3758 -0
  190. data/assets/css/jupyter.css +383 -0
  191. data/assets/css/main.scss +36 -0
  192. data/assets/css/mdb.min.css +48 -0
  193. data/assets/css/mdb.min.css.map +1 -0
  194. data/assets/css/scholar-icons.css +386 -0
  195. data/assets/fonts/academicons.ttf +0 -0
  196. data/assets/fonts/academicons.woff +0 -0
  197. data/assets/fonts/scholar-icons.ttf +0 -0
  198. data/assets/fonts/scholar-icons.woff +0 -0
  199. data/assets/fonts/tabler-icons-filled.ttf +0 -0
  200. data/assets/fonts/tabler-icons-filled.woff +0 -0
  201. data/assets/fonts/tabler-icons-filled.woff2 +0 -0
  202. data/assets/fonts/tabler-icons-outline.ttf +0 -0
  203. data/assets/fonts/tabler-icons-outline.woff +0 -0
  204. data/assets/fonts/tabler-icons-outline.woff2 +0 -0
  205. data/assets/fonts/tabler-icons.ttf +0 -0
  206. data/assets/fonts/tabler-icons.woff +0 -0
  207. data/assets/fonts/tabler-icons.woff2 +0 -0
  208. data/assets/html/relativity.html +15 -0
  209. data/assets/img/1.jpg +0 -0
  210. data/assets/img/10.jpg +0 -0
  211. data/assets/img/11.jpg +0 -0
  212. data/assets/img/12.jpg +0 -0
  213. data/assets/img/2.jpg +0 -0
  214. data/assets/img/3.jpg +0 -0
  215. data/assets/img/4.jpg +0 -0
  216. data/assets/img/5.jpg +0 -0
  217. data/assets/img/6.jpg +0 -0
  218. data/assets/img/7.jpg +0 -0
  219. data/assets/img/8.jpg +0 -0
  220. data/assets/img/9.jpg +0 -0
  221. data/assets/img/book_covers/the_godfather.jpg +0 -0
  222. data/assets/img/brand/dosquartsdedocs-logo-cafe.svg +31 -0
  223. data/assets/img/brand/dosquartsdedocs-logo-footer-cafe.svg +31 -0
  224. data/assets/img/brand/dosquartsdedocs-logo-white.svg +31 -0
  225. data/assets/img/brand/dosquartsdedocs-logo.svg +31 -0
  226. data/assets/img/prof_pic.jpg +0 -0
  227. data/assets/img/prof_pic_color.png +0 -0
  228. data/assets/img/profile-placeholder.svg +8 -0
  229. data/assets/img/publication_preview/brownian-motion.gif +0 -0
  230. data/assets/img/publication_preview/goodchild1992-gis-cover.jpg +0 -0
  231. data/assets/img/publication_preview/gutierrez2020-jtrg-preview.jpg +0 -0
  232. data/assets/img/publication_preview/harvey1989-postmodernity-cover.jpg +0 -0
  233. data/assets/img/publication_preview/tobler1970-economic-geography-cover.jpg +0 -0
  234. data/assets/img/publication_preview/tuan1977-space-place-cover.jpg +0 -0
  235. data/assets/img/publication_preview/wave-mechanics.gif +0 -0
  236. data/assets/img/publication_preview/zaragozi2025-rooftop-pv-preview.jpg +0 -0
  237. data/assets/img/rhino.png +0 -0
  238. data/assets/img/team/aaron-gutierrez.png +0 -0
  239. data/assets/img/team/alba-font.png +0 -0
  240. data/assets/img/team/alba-viana.png +0 -0
  241. data/assets/img/team/alejandro-gonzalez.png +0 -0
  242. data/assets/img/team/ana-pastor.png +0 -0
  243. data/assets/img/team/antoni-domenech.png +0 -0
  244. data/assets/img/team/benito-zaragozi.png +0 -0
  245. data/assets/img/team/cinta-sanz.png +0 -0
  246. data/assets/img/team/daniel-miravet.png +0 -0
  247. data/assets/img/team/eugenia-altamirano.png +0 -0
  248. data/assets/img/team/fiammetta-brandajs.png +0 -0
  249. data/assets/img/team/francesc-gonzalez.png +0 -0
  250. data/assets/img/team/isabel-paulino.png +0 -0
  251. data/assets/img/team/joan-borras.png +0 -0
  252. data/assets/img/team/julie-wilson.png +0 -0
  253. data/assets/img/team/liliana-sole.png +0 -0
  254. data/assets/img/team/maria-casado.png +0 -0
  255. data/assets/img/team/maria-medina.png +0 -0
  256. data/assets/img/team/marta-nello.png +0 -0
  257. data/assets/img/team/mogrify +1 -0
  258. data/assets/img/team/oscar-saladie.png +0 -0
  259. data/assets/img/team/paolo-russo.png +0 -0
  260. data/assets/img/team/raquel-santos.png +0 -0
  261. data/assets/img/team/salvador-anton.png +0 -0
  262. data/assets/img/team/sunsi-huertas.png +0 -0
  263. data/assets/img/team/user-placeholder.png +0 -0
  264. data/assets/img/team/wilbert-den-hoed.png +0 -0
  265. data/assets/img/team/xavier-delclos.png +0 -0
  266. data/assets/img/template_error.png +0 -0
  267. data/assets/js/bibsearch.js +70 -0
  268. data/assets/js/bootstrap-toc.min.js +5 -0
  269. data/assets/js/bootstrap.bundle.min.js +7 -0
  270. data/assets/js/bootstrap.bundle.min.js.map +1 -0
  271. data/assets/js/callouts.js +115 -0
  272. data/assets/js/chartjs-setup.js +14 -0
  273. data/assets/js/common.js +117 -0
  274. data/assets/js/content-search-match.js +159 -0
  275. data/assets/js/content-search.js +441 -0
  276. data/assets/js/copy_code.js +60 -0
  277. data/assets/js/custom.js +6 -0
  278. data/assets/js/developer-mode.js +103 -0
  279. data/assets/js/diff2html-setup.js +20 -0
  280. data/assets/js/distillpub/overrides.js +27 -0
  281. data/assets/js/distillpub/template.v2.js +9616 -0
  282. data/assets/js/distillpub/template.v2.js.map +1 -0
  283. data/assets/js/distillpub/transforms.v2.js +14557 -0
  284. data/assets/js/distillpub/transforms.v2.js.map +1 -0
  285. data/assets/js/documentation.js +435 -0
  286. data/assets/js/echarts-setup.js +29 -0
  287. data/assets/js/highlight-search-term.js +110 -0
  288. data/assets/js/jupyter_new_tab.js +18 -0
  289. data/assets/js/leaflet-setup.js +22 -0
  290. data/assets/js/manual.js +308 -0
  291. data/assets/js/masonry.js +12 -0
  292. data/assets/js/mathjax-setup.js +46 -0
  293. data/assets/js/mermaid-setup.js +37 -0
  294. data/assets/js/newsletter.js +105 -0
  295. data/assets/js/no_defer.js +24 -0
  296. data/assets/js/plotly-setup.js +52 -0
  297. data/assets/js/profile-nav.js +92 -0
  298. data/assets/js/progress-bar.js +73 -0
  299. data/assets/js/pseudocode-setup.js +18 -0
  300. data/assets/js/search/base-styles.min.js +286 -0
  301. data/assets/js/search/command-score.min.js +7 -0
  302. data/assets/js/search/hotkeys-js/hotkeys.esm.min.js +18 -0
  303. data/assets/js/search/lit/decorators.min.js +7 -0
  304. data/assets/js/search/lit/directives/class-map.min.js +7 -0
  305. data/assets/js/search/lit/directives/join.min.js +7 -0
  306. data/assets/js/search/lit/directives/live.min.js +7 -0
  307. data/assets/js/search/lit/directives/ref.min.js +7 -0
  308. data/assets/js/search/lit/directives/repeat.min.js +7 -0
  309. data/assets/js/search/lit/directives/unsafe-html.min.js +7 -0
  310. data/assets/js/search/lit/index.min.js +7 -0
  311. data/assets/js/search/lit-element/lit-element.min.js +12 -0
  312. data/assets/js/search/lit-html/directives/class-map.min.js +22 -0
  313. data/assets/js/search/lit-html/directives/join.min.js +12 -0
  314. data/assets/js/search/lit-html/directives/live.min.js +28 -0
  315. data/assets/js/search/lit-html/directives/ref.min.js +32 -0
  316. data/assets/js/search/lit-html/directives/repeat.min.js +27 -0
  317. data/assets/js/search/lit-html/directives/unsafe-html.min.js +22 -0
  318. data/assets/js/search/lit-html/is-server.min.js +12 -0
  319. data/assets/js/search/lit-html/lit-html.min.js +12 -0
  320. data/assets/js/search/ninja-action.min.js +101 -0
  321. data/assets/js/search/ninja-footer.min.js +62 -0
  322. data/assets/js/search/ninja-header.min.js +78 -0
  323. data/assets/js/search/ninja-keys.min.js +39 -0
  324. data/assets/js/search/reactive-element/decorators/custom-element.min.js +12 -0
  325. data/assets/js/search/reactive-element/decorators/event-options.min.js +12 -0
  326. data/assets/js/search/reactive-element/decorators/property.min.js +22 -0
  327. data/assets/js/search/reactive-element/decorators/query-all.min.js +17 -0
  328. data/assets/js/search/reactive-element/decorators/query-assigned-elements.min.js +17 -0
  329. data/assets/js/search/reactive-element/decorators/query-assigned-nodes.min.js +17 -0
  330. data/assets/js/search/reactive-element/decorators/query-async.min.js +17 -0
  331. data/assets/js/search/reactive-element/decorators/query.min.js +17 -0
  332. data/assets/js/search/reactive-element/decorators/state.min.js +27 -0
  333. data/assets/js/search/reactive-element/reactive-element.min.js +17 -0
  334. data/assets/js/search-setup.js +17 -0
  335. data/assets/js/shortcut-key.js +11 -0
  336. data/assets/js/tabs.min.js +5 -0
  337. data/assets/js/theme.js +314 -0
  338. data/assets/js/tooltips-setup.js +3 -0
  339. data/assets/js/typograms.js +1341 -0
  340. data/assets/js/vanilla-back-to-top.min.js +1 -0
  341. data/assets/js/vega-setup.js +24 -0
  342. data/assets/js/venobox-setup.js +5 -0
  343. data/assets/js/wechat.js +14 -0
  344. data/assets/js/zoom.js +15 -0
  345. data/assets/json/resume.json +98 -0
  346. data/assets/json/table_data.json +128 -0
  347. data/assets/jupyter/blog.ipynb +48 -0
  348. data/assets/pdf/example_pdf.pdf +0 -0
  349. data/assets/plotly/demo.html +72 -0
  350. data/assets/video/pexels-engin-akyurt-6069112-960x540-30fps.mp4 +0 -0
  351. data/assets/webfonts/fa-brands-400.ttf +0 -0
  352. data/assets/webfonts/fa-brands-400.woff2 +0 -0
  353. data/assets/webfonts/fa-regular-400.ttf +0 -0
  354. data/assets/webfonts/fa-regular-400.woff2 +0 -0
  355. data/assets/webfonts/fa-solid-900.ttf +0 -0
  356. data/assets/webfonts/fa-solid-900.woff2 +0 -0
  357. data/assets/webfonts/fa-v4compatibility.ttf +0 -0
  358. data/assets/webfonts/fa-v4compatibility.woff2 +0 -0
  359. data/docs/Gemfile +5 -0
  360. data/docs/_config.yml +83 -0
  361. data/docs/_documentation/en/01-quick-start.md +115 -0
  362. data/docs/_documentation/en/02-tools.md +146 -0
  363. data/docs/_documentation/en/03-usage.md +107 -0
  364. data/docs/_documentation/en/04-jekyll-basics.md +51 -0
  365. data/docs/_documentation/en/05-pages-collections.md +68 -0
  366. data/docs/_documentation/en/06-github-web-editing.md +92 -0
  367. data/docs/_documentation/en/10-profiles.md +125 -0
  368. data/docs/_documentation/en/11-unaltreselfie.md +39 -0
  369. data/docs/_documentation/en/12-unaltreprojecte.md +38 -0
  370. data/docs/_documentation/en/13-unaltremanual.md +237 -0
  371. data/docs/_documentation/en/14-unaltredocs.md +92 -0
  372. data/docs/_documentation/en/15-profile-checklist.md +39 -0
  373. data/docs/_documentation/en/20-syntax.md +353 -0
  374. data/docs/_documentation/en/21-themes.md +103 -0
  375. data/docs/_documentation/en/22-feature-reference.md +56 -0
  376. data/docs/_documentation/en/23-front-matter.md +51 -0
  377. data/docs/_documentation/en/24-data-files.md +28 -0
  378. data/docs/_documentation/en/30-customization.md +448 -0
  379. data/docs/_documentation/en/31-template.md +55 -0
  380. data/docs/_documentation/en/32-development.md +108 -0
  381. data/docs/_documentation/en/33-reusable-workflows.md +136 -0
  382. data/docs/_documentation/en/40-distribution.md +164 -0
  383. data/docs/_documentation/en/41-bibliometrics.md +137 -0
  384. data/docs/_documentation/en/42-docker-image.md +185 -0
  385. data/docs/_documentation/en/50-faq.md +33 -0
  386. data/docs/_pages/ca/index.md +35 -0
  387. data/docs/_pages/en/index.md +35 -0
  388. data/docs/_pages/es/index.md +35 -0
  389. data/docs/agents/action-prompts/00-start-site-session.txt +10 -0
  390. data/docs/agents/action-prompts/05-create-new-web.txt +5 -0
  391. data/docs/agents/action-prompts/10-content-update.txt +9 -0
  392. data/docs/agents/action-prompts/15-edit-default-content.txt +8 -0
  393. data/docs/agents/action-prompts/20-manual-teaching-materials.txt +18 -0
  394. data/docs/agents/action-prompts/22-manual-style-audit.txt +14 -0
  395. data/docs/agents/action-prompts/23-manual-structure-audit.txt +17 -0
  396. data/docs/agents/action-prompts/25-translation-prepublish.txt +8 -0
  397. data/docs/agents/action-prompts/30-project-site-update.txt +7 -0
  398. data/docs/agents/action-prompts/40-documentation-update.txt +7 -0
  399. data/docs/agents/action-prompts/50-bibliography-entry.txt +7 -0
  400. data/docs/agents/action-prompts/60-bibliometrics-refresh.txt +7 -0
  401. data/docs/agents/action-prompts/70-build-and-review.txt +12 -0
  402. data/docs/agents/manual-authoring-components.md +255 -0
  403. data/docs/agents/mcp-contract.md +162 -0
  404. data/docs/agents/quarto-computation-adoption.md +102 -0
  405. data/docs/assets/img/unaltraweb-docs-hero.svg +36 -0
  406. data/lib/unaltraweb/version.rb +8 -0
  407. data/lib/unaltraweb.rb +31 -0
  408. data/requirements.txt +5 -0
  409. data/scripts/biblio/fetch_scimago_csv.sh +126 -0
  410. data/scripts/biblio/metrics_common.py +154 -0
  411. data/scripts/biblio/metrics_merge_meta.py +73 -0
  412. data/scripts/biblio/metrics_update.py +826 -0
  413. data/scripts/computations/python/Dockerfile +37 -0
  414. data/scripts/computations/r/Dockerfile +31 -0
  415. data/scripts/computations/render.py +1267 -0
  416. data/scripts/cv/render_pdf_preview.sh +36 -0
  417. data/scripts/deploy/publish_branch.sh +80 -0
  418. data/scripts/generate_logo_variants.py +47 -0
  419. data/scripts/manual/Dockerfile +31 -0
  420. data/scripts/manual/build_pdf.py +1780 -0
  421. data/scripts/manual/filters/bibliography.lua +164 -0
  422. data/scripts/manual/filters/code-blocks.lua +102 -0
  423. data/scripts/manual/filters/figure-captions.lua +35 -0
  424. data/scripts/manual/publish_release.sh +281 -0
  425. data/scripts/manual/templates/manual.tex +494 -0
  426. data/scripts/manual/verify_release_assets.py +650 -0
  427. data/scripts/test_gem_build.py +128 -0
  428. data/scripts/test_reproducible_jekyll_build.py +191 -0
  429. data/scripts/test_wheel_install.py +282 -0
  430. data/scripts/unaltraweb-docker-mount.sh +36 -0
  431. data/scripts/unaltraweb-mcp-bootstrap.sh +105 -0
  432. data/scripts/unaltraweb-mcp-cleanup.sh +73 -0
  433. data/scripts/unaltraweb-mcp-project-id.sh +34 -0
  434. data/scripts/validate_distribution.py +563 -0
  435. data/scripts/validate_workflows.py +1285 -0
  436. data/scripts/web_captures/Dockerfile +31 -0
  437. data/scripts/web_captures/capture.mjs +157 -0
  438. data/scripts/web_captures/package-lock.json +56 -0
  439. data/scripts/web_captures/package.json +9 -0
  440. data/scripts/web_captures/render.py +867 -0
  441. data/src/unaltraweb_mcp/component-contract.json +153 -0
  442. data/src/unaltraweb_mcp/component-contract.schema.json +87 -0
  443. data/src/unaltraweb_mcp/docker_mount.py +19 -0
  444. metadata +861 -0
@@ -0,0 +1,826 @@
1
+ #!/usr/bin/env python3
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import csv
7
+ import json
8
+ import re
9
+ import sys
10
+ from collections import defaultdict
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import requests
16
+ import yaml
17
+
18
+ try:
19
+ import bibtexparser
20
+ from bibtexparser.bparser import BibTexParser
21
+ from bibtexparser.bwriter import BibTexWriter
22
+ from bibtexparser.customization import convert_to_unicode
23
+ except Exception as exc: # pragma: no cover - runtime guard
24
+ print("Missing dependency 'bibtexparser'. Install dependencies first:", file=sys.stderr)
25
+ print(" python3 -m venv .venv && .venv/bin/pip install -r requirements.txt", file=sys.stderr)
26
+ print(f"Details: {exc}", file=sys.stderr)
27
+ sys.exit(1)
28
+
29
+ from metrics_common import SummaryContext, as_bool, as_float, as_int, compact_categories, compute_summary, fmt_float
30
+
31
+
32
+ X_FIELDS = [
33
+ "x_openalex_id",
34
+ "x_openalex_cited_by",
35
+ "x_crossref_cited_by",
36
+ "x_gs_id",
37
+ "x_gs_cited_by",
38
+ "x_openalex_fwci",
39
+ "x_openalex_citation_normalized_percentile",
40
+ "x_openalex_topic",
41
+ "x_openalex_subfield",
42
+ "x_openalex_field",
43
+ "x_openalex_domain",
44
+ "x_openalex_funders",
45
+ "x_openalex_source_display_name",
46
+ "x_openalex_source_works_count",
47
+ "x_openalex_source_cited_by_count",
48
+ "x_openalex_source_is_oa",
49
+ "x_openalex_source_is_core",
50
+ "x_openalex_source_is_in_doaj",
51
+ "x_openalex_source_h_index",
52
+ "x_openalex_source_i10_index",
53
+ "x_openalex_source_2yr_mean_citedness",
54
+ "x_openalex_cited_by_2y",
55
+ "x_openalex_cited_by_5y",
56
+ "x_scimago_sjr",
57
+ "x_scimago_quartile",
58
+ "x_scimago_year",
59
+ "x_scimago_categories",
60
+ "x_scimago_h_index",
61
+ "x_scimago_cites_per_doc_2y",
62
+ "x_scimago_total_docs_3y",
63
+ "x_scimago_total_cites_3y",
64
+ "x_scimago_country",
65
+ "x_scimago_type",
66
+ "x_scimago_coverage",
67
+ "x_metrics_updated",
68
+ "note",
69
+ ]
70
+
71
+
72
+ def parse_args() -> argparse.Namespace:
73
+ parser = argparse.ArgumentParser(description="Update bibliography with bibliometric metrics.")
74
+ parser.add_argument(
75
+ "--bib",
76
+ action="append",
77
+ default=[],
78
+ help="BibTeX file path (can be repeated). Defaults to all _bibliography/*.bib",
79
+ )
80
+ parser.add_argument("--overrides", default="_data/metrics-overrides.yml", help="Overrides YAML path")
81
+ parser.add_argument("--citations-data", default="_data/citations.yml", help="Google Scholar citations YAML path")
82
+ parser.add_argument("--scimago-csv", default=".cache/scimago/scimagojr.csv", help="Local Scimago CSV path")
83
+ parser.add_argument("--metrics-out", default="_data/metrics.yml", help="Aggregated metrics YAML output")
84
+ parser.add_argument("--report-json", default="tmp/metrics-report.json", help="Diagnostics JSON output")
85
+ parser.add_argument("--unmatched-tsv", default="tmp/metrics-unmatched.tsv", help="Diagnostics TSV output")
86
+ parser.add_argument("--max-year-gap", type=int, default=2, help="Max gap between publication year and Scimago year")
87
+ parser.add_argument("--offline", action="store_true", help="Do not call OpenAlex/Crossref")
88
+ parser.add_argument("--dry-run", action="store_true", help="Do not rewrite BibTeX files")
89
+ parser.add_argument("--strict-external", action="store_true", help="Exit non-zero if OpenAlex/Crossref requests fail")
90
+ parser.add_argument("--require-scimago", action="store_true", help="Exit non-zero when the Scimago CSV is unavailable")
91
+ parser.add_argument(
92
+ "--validate-scimago",
93
+ default="",
94
+ help="Only validate a Scimago CSV file and exit (path argument)",
95
+ )
96
+ return parser.parse_args()
97
+
98
+
99
+ def normalize_doi(value: str | None) -> str | None:
100
+ if not value:
101
+ return None
102
+ doi = value.strip()
103
+ doi = doi.replace("https://doi.org/", "").replace("http://doi.org/", "")
104
+ doi = doi.replace("doi:", "").strip()
105
+ return doi.lower() if doi else None
106
+
107
+
108
+ def normalize_issn_token(value: str) -> str | None:
109
+ token = re.sub(r"[^0-9xX]", "", value or "")
110
+ if len(token) != 8:
111
+ return None
112
+ return f"{token[:4]}-{token[4:].upper()}"
113
+
114
+
115
+ def normalize_issn_list(raw_value: str | None) -> list[str]:
116
+ if not raw_value:
117
+ return []
118
+ items = re.split(r"[;,|\s]+", raw_value)
119
+ normalized = []
120
+ seen = set()
121
+ for item in items:
122
+ issn = normalize_issn_token(item)
123
+ if issn and issn not in seen:
124
+ seen.add(issn)
125
+ normalized.append(issn)
126
+ return normalized
127
+
128
+
129
+ def load_yaml(path: Path) -> dict[str, Any]:
130
+ if not path.exists():
131
+ return {}
132
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
133
+ return data if isinstance(data, dict) else {}
134
+
135
+
136
+ def find_bib_files(cli_bib_args: list[str]) -> list[Path]:
137
+ if cli_bib_args:
138
+ return [Path(p) for p in cli_bib_args]
139
+ return sorted(Path("_bibliography").glob("*.bib"))
140
+
141
+
142
+ def split_bib_front_matter(content: str) -> tuple[str, str]:
143
+ match = re.search(r"@\w+\s*\{", content)
144
+ if not match:
145
+ return content, ""
146
+ return content[: match.start()], content[match.start() :]
147
+
148
+
149
+ def parse_bib_database(path: Path) -> tuple[str, Any]:
150
+ content = path.read_text(encoding="utf-8")
151
+ prefix, bib_body = split_bib_front_matter(content)
152
+
153
+ parser = BibTexParser(common_strings=True)
154
+ parser.ignore_nonstandard_types = False
155
+ parser.homogenize_fields = False
156
+ parser.customization = convert_to_unicode
157
+ db = bibtexparser.loads(bib_body, parser=parser) if bib_body.strip() else bibtexparser.bibdatabase.BibDatabase()
158
+ return prefix, db
159
+
160
+
161
+ def write_bib_database(path: Path, prefix: str, db: Any) -> None:
162
+ writer = BibTexWriter()
163
+ writer.indent = " "
164
+ writer.comma_first = False
165
+ writer.display_order = []
166
+ writer.order_entries_by = ("ID",)
167
+ rendered = bibtexparser.dumps(db, writer)
168
+ path.write_text(f"{prefix}{rendered}", encoding="utf-8")
169
+
170
+
171
+ def parse_float_text(value: str | None) -> float | None:
172
+ if value is None:
173
+ return None
174
+ text = str(value).strip()
175
+ if not text:
176
+ return None
177
+ text = text.replace(" ", "")
178
+ if text.count(",") == 1 and text.count(".") == 0:
179
+ text = text.replace(",", ".")
180
+ elif text.count(",") > 1 and text.count(".") == 0:
181
+ text = text.replace(".", "").replace(",", ".")
182
+ else:
183
+ text = text.replace(",", "")
184
+ try:
185
+ return float(text)
186
+ except ValueError:
187
+ return None
188
+
189
+
190
+ def parse_int_text(value: str | None) -> int | None:
191
+ if value is None:
192
+ return None
193
+ text = str(value).strip().replace(" ", "")
194
+ if not text:
195
+ return None
196
+ if re.fullmatch(r"-?\d+(?:\.\d+)?", text):
197
+ try:
198
+ return int(float(text))
199
+ except ValueError:
200
+ return None
201
+ token = re.sub(r"[^0-9-]", "", text)
202
+ if not token:
203
+ return None
204
+ try:
205
+ return int(token)
206
+ except ValueError:
207
+ return None
208
+
209
+
210
+ def normalize_column_name(name: str) -> str:
211
+ return re.sub(r"[^a-z0-9]", "", (name or "").lower())
212
+
213
+
214
+ def detect_scimago_columns(fieldnames: list[str]) -> dict[str, str]:
215
+ normalized = {normalize_column_name(name): name for name in fieldnames if name}
216
+
217
+ def pick(*candidates: str) -> str | None:
218
+ for candidate in candidates:
219
+ if candidate in normalized:
220
+ return normalized[candidate]
221
+ return None
222
+
223
+ columns = {
224
+ "title": pick("title", "sourcetitle", "journaltitle"),
225
+ "issn": pick("issn", "issns", "issncode", "issnprint"),
226
+ "year": pick("year", "rankyear"),
227
+ "sjr": pick("sjr", "sjrindex"),
228
+ "quartile": pick("sjrbestquartile", "bestquartile", "quartile"),
229
+ "categories": pick("categories", "category", "subjectareaandcategory"),
230
+ "h_index": pick("hindex", "hindexscore"),
231
+ "cites_per_doc_2y": pick("citesdoc2years", "citesdoc2year", "citesdoc2y", "citesperdoc2years", "citesperdoc2year"),
232
+ "total_docs_3y": pick("totaldocs3years", "totaldocs3year"),
233
+ "total_cites_3y": pick("totalcites3years", "totalcites3year"),
234
+ "country": pick("country", "sourcecountry"),
235
+ "type": pick("type", "sourcetype"),
236
+ "coverage": pick("coverage"),
237
+ }
238
+ return columns
239
+
240
+
241
+ def load_scimago(path: Path) -> tuple[dict[str, list[dict[str, Any]]], dict[str, Any]]:
242
+ if not path.exists():
243
+ return {}, {"available": False, "message": f"missing: {path}"}
244
+
245
+ sample = path.read_text(encoding="utf-8", errors="ignore")[:4096]
246
+ try:
247
+ dialect = csv.Sniffer().sniff(sample, delimiters=";,\t,")
248
+ delimiter = dialect.delimiter
249
+ except csv.Error:
250
+ delimiter = ";"
251
+
252
+ with path.open("r", encoding="utf-8", errors="ignore", newline="") as handle:
253
+ reader = csv.DictReader(handle, delimiter=delimiter)
254
+ fieldnames = reader.fieldnames or []
255
+ columns = detect_scimago_columns(fieldnames)
256
+
257
+ if not columns["issn"] or not columns["year"] or not columns["sjr"]:
258
+ raise ValueError(
259
+ "Scimago CSV missing required columns. Required: ISSN, Year, SJR. "
260
+ f"Detected headers: {fieldnames}"
261
+ )
262
+
263
+ index: dict[str, list[dict[str, Any]]] = defaultdict(list)
264
+ for row in reader:
265
+ issns = normalize_issn_list(row.get(columns["issn"], ""))
266
+ if not issns:
267
+ continue
268
+
269
+ year = parse_int_text(row.get(columns["year"]))
270
+ sjr = parse_float_text(row.get(columns["sjr"]))
271
+ quartile = (row.get(columns["quartile"]) or "").strip().upper() if columns["quartile"] else ""
272
+ categories = (row.get(columns["categories"]) or "").strip() if columns["categories"] else ""
273
+ title = (row.get(columns["title"]) or "").strip() if columns["title"] else ""
274
+ h_index = parse_int_text(row.get(columns["h_index"])) if columns["h_index"] else None
275
+ cites_per_doc_2y = parse_float_text(row.get(columns["cites_per_doc_2y"])) if columns["cites_per_doc_2y"] else None
276
+ total_docs_3y = parse_int_text(row.get(columns["total_docs_3y"])) if columns["total_docs_3y"] else None
277
+ total_cites_3y = parse_int_text(row.get(columns["total_cites_3y"])) if columns["total_cites_3y"] else None
278
+ country = (row.get(columns["country"]) or "").strip() if columns["country"] else ""
279
+ source_type = (row.get(columns["type"]) or "").strip() if columns["type"] else ""
280
+ coverage = (row.get(columns["coverage"]) or "").strip() if columns["coverage"] else ""
281
+
282
+ payload = {
283
+ "title": title,
284
+ "year": year,
285
+ "sjr": sjr,
286
+ "quartile": quartile if quartile in {"Q1", "Q2", "Q3", "Q4"} else "",
287
+ "categories": categories,
288
+ "h_index": h_index,
289
+ "cites_per_doc_2y": cites_per_doc_2y,
290
+ "total_docs_3y": total_docs_3y,
291
+ "total_cites_3y": total_cites_3y,
292
+ "country": country,
293
+ "type": source_type,
294
+ "coverage": coverage,
295
+ }
296
+ for issn in issns:
297
+ index[issn].append(payload)
298
+
299
+ return index, {"available": True, "path": str(path), "records": sum(len(v) for v in index.values())}
300
+
301
+
302
+ def validate_scimago_file(path: Path) -> None:
303
+ try:
304
+ _, meta = load_scimago(path)
305
+ if not meta.get("available"):
306
+ raise ValueError(meta.get("message", "Scimago file unavailable"))
307
+ print(f"Scimago CSV validated: {path}")
308
+ except Exception as exc:
309
+ print(f"Scimago CSV validation failed: {exc}", file=sys.stderr)
310
+ sys.exit(1)
311
+
312
+
313
+ class MetricsClient:
314
+ def __init__(self, offline: bool, email: str = "") -> None:
315
+ self.offline = offline
316
+ ua = "unaltraweb-biblio-metrics/1.0"
317
+ if email:
318
+ ua += f" (mailto:{email})"
319
+ self.session = requests.Session()
320
+ self.session.headers.update({"User-Agent": ua})
321
+
322
+ self.oa_work_cache: dict[str, dict[str, Any] | None] = {}
323
+ self.oa_source_cache: dict[str, dict[str, Any] | None] = {}
324
+ self.crossref_cache: dict[str, dict[str, Any] | None] = {}
325
+ self.api_errors: list[dict[str, str]] = []
326
+ self.last_error_by_service: dict[str, bool] = {}
327
+
328
+ def _record_api_error(self, service: str, url: str, exc: Exception) -> None:
329
+ message = f"{type(exc).__name__}: {exc}"
330
+ self.api_errors.append({"service": service, "url": url, "error": message})
331
+ print(f"{service} request failed: {url} ({message})", file=sys.stderr)
332
+
333
+ def last_service_error(self, service: str) -> bool:
334
+ return self.last_error_by_service.get(service, False)
335
+
336
+ def api_error_summary(self) -> dict[str, int]:
337
+ summary: dict[str, int] = {}
338
+ for item in self.api_errors:
339
+ service = item.get("service", "unknown")
340
+ summary[service] = summary.get(service, 0) + 1
341
+ return summary
342
+
343
+ def _get_json(self, url: str, service: str) -> dict[str, Any] | None:
344
+ if self.offline:
345
+ return None
346
+ self.last_error_by_service[service] = False
347
+ try:
348
+ response = self.session.get(url, timeout=30)
349
+ if response.status_code == 404:
350
+ return None
351
+ response.raise_for_status()
352
+ return response.json()
353
+ except (requests.RequestException, ValueError) as exc:
354
+ self.last_error_by_service[service] = True
355
+ self._record_api_error(service, url, exc)
356
+ return None
357
+
358
+ def get_openalex_work(self, doi: str | None = None, openalex_id: str | None = None) -> dict[str, Any] | None:
359
+ if openalex_id:
360
+ key = f"id:{openalex_id}"
361
+ if key not in self.oa_work_cache:
362
+ api_id = openalex_id.split("/")[-1] if openalex_id.startswith("http") else openalex_id
363
+ self.oa_work_cache[key] = self._get_json(f"https://api.openalex.org/works/{api_id}", "openalex")
364
+ return self.oa_work_cache[key]
365
+
366
+ if doi:
367
+ key = f"doi:{doi}"
368
+ if key not in self.oa_work_cache:
369
+ self.oa_work_cache[key] = self._get_json(f"https://api.openalex.org/works/https://doi.org/{doi}", "openalex")
370
+ return self.oa_work_cache[key]
371
+ return None
372
+
373
+ def get_openalex_source(self, source_id: str | None) -> dict[str, Any] | None:
374
+ if not source_id:
375
+ return None
376
+ sid = source_id.split("/")[-1]
377
+ if sid not in self.oa_source_cache:
378
+ self.oa_source_cache[sid] = self._get_json(f"https://api.openalex.org/sources/{sid}", "openalex")
379
+ return self.oa_source_cache[sid]
380
+
381
+ def get_crossref_work(self, doi: str | None) -> dict[str, Any] | None:
382
+ if not doi:
383
+ return None
384
+ if doi not in self.crossref_cache:
385
+ payload = self._get_json(f"https://api.crossref.org/works/{doi}", "crossref")
386
+ self.crossref_cache[doi] = payload.get("message") if payload and "message" in payload else payload
387
+ return self.crossref_cache[doi]
388
+
389
+
390
+ def choose_scimago_record(candidates: list[dict[str, Any]], publication_year: int | None, max_year_gap: int) -> tuple[dict[str, Any] | None, str]:
391
+ if not candidates:
392
+ return None, "no-issn-match"
393
+
394
+ if publication_year is None:
395
+ sorted_by_year = sorted(
396
+ [c for c in candidates if c.get("year") is not None], key=lambda item: item["year"], reverse=True
397
+ )
398
+ return (sorted_by_year[0], "matched") if sorted_by_year else (candidates[0], "matched")
399
+
400
+ ranked: list[tuple[Any, dict[str, Any]]] = []
401
+ for candidate in candidates:
402
+ year = candidate.get("year")
403
+ if year is None:
404
+ continue
405
+ gap = abs(publication_year - year)
406
+ bias = 0 if year <= publication_year else 1
407
+ tie = -year if year <= publication_year else year
408
+ ranked.append(((gap, bias, tie), candidate))
409
+
410
+ if not ranked:
411
+ return None, "no-year-in-scimago"
412
+
413
+ ranked.sort(key=lambda item: item[0])
414
+ best = ranked[0][1]
415
+ best_gap = abs(publication_year - best["year"])
416
+
417
+ if best_gap > max_year_gap:
418
+ return None, "stale-rejected"
419
+ return best, "matched"
420
+
421
+
422
+ def parse_counts_by_year(work: dict[str, Any]) -> tuple[int | None, int | None]:
423
+ counts = work.get("counts_by_year") or []
424
+ now = datetime.now(timezone.utc).year
425
+ total_2y = 0
426
+ total_5y = 0
427
+ found_any = False
428
+
429
+ for row in counts:
430
+ year = as_int(row.get("year"))
431
+ cited = as_int(row.get("cited_by_count")) or 0
432
+ if year is None:
433
+ continue
434
+ found_any = True
435
+ if year >= now - 1:
436
+ total_2y += cited
437
+ if year >= now - 4:
438
+ total_5y += cited
439
+
440
+ if not found_any:
441
+ return None, None
442
+ return total_2y, total_5y
443
+
444
+
445
+ def set_field(entry: dict[str, Any], field: str, value: Any) -> None:
446
+ if value is None:
447
+ return
448
+ if isinstance(value, bool):
449
+ entry[field] = "true" if value else "false"
450
+ elif isinstance(value, float):
451
+ entry[field] = fmt_float(value, digits=4)
452
+ else:
453
+ entry[field] = str(value)
454
+
455
+
456
+ def build_metrics_note(entry: dict[str, Any], original_note: str) -> str | None:
457
+ blocks: list[str] = []
458
+
459
+ oa = as_int(entry.get("x_openalex_cited_by"))
460
+ cr = as_int(entry.get("x_crossref_cited_by"))
461
+ gs = as_int(entry.get("x_gs_cited_by"))
462
+ cites = []
463
+ if oa is not None and oa > 0:
464
+ cites.append(f"OA: {oa}")
465
+ if cr is not None and cr > 0:
466
+ cites.append(f"CR: {cr}")
467
+ if gs is not None and gs > 0:
468
+ cites.append(f"GS: {gs}")
469
+ if cites:
470
+ blocks.append("Cites: " + " / ".join(cites))
471
+
472
+ fwci = as_float(entry.get("x_openalex_fwci"))
473
+ if fwci is not None:
474
+ blocks.append(f"FWCI: {fmt_float(fwci, 2)}")
475
+
476
+ cnp = as_float(entry.get("x_openalex_citation_normalized_percentile"))
477
+ if cnp is not None:
478
+ blocks.append(f"CNP: {fmt_float(cnp, 2)}")
479
+
480
+ core = as_bool(entry.get("x_openalex_source_is_core"))
481
+ if core is not None:
482
+ blocks.append(f"Core {'yes' if core else 'no'}")
483
+
484
+ doaj = as_bool(entry.get("x_openalex_source_is_in_doaj"))
485
+ if doaj is not None:
486
+ blocks.append(f"DOAJ {'yes' if doaj else 'no'}")
487
+
488
+ q = str(entry.get("x_scimago_quartile", "")).strip().upper()
489
+ sjr = as_float(entry.get("x_scimago_sjr"))
490
+ sc_year = as_int(entry.get("x_scimago_year"))
491
+ categories = compact_categories(entry.get("x_scimago_categories"))
492
+ if q or sjr is not None or sc_year is not None or categories:
493
+ sc_parts = []
494
+ if q:
495
+ sc_parts.append(q)
496
+ if sjr is not None:
497
+ sc_parts.append(f"SJR: {fmt_float(sjr, 3)}")
498
+ if sc_year is not None:
499
+ sc_parts.append(str(sc_year))
500
+ if categories:
501
+ sc_parts.append(categories)
502
+ blocks.append("Scimago: " + ", ".join(sc_parts))
503
+
504
+ if not blocks and not original_note:
505
+ return None
506
+
507
+ note = "Metrics: " + " | ".join(blocks) if blocks else ""
508
+ if original_note and not original_note.startswith("Metrics:"):
509
+ note = f"{note} | {original_note}" if note else original_note
510
+ return note
511
+
512
+
513
+ def main() -> None:
514
+ args = parse_args()
515
+
516
+ if args.validate_scimago:
517
+ validate_scimago_file(Path(args.validate_scimago))
518
+ return
519
+
520
+ bib_files = find_bib_files(args.bib)
521
+ if not bib_files:
522
+ print("No BibTeX files found.", file=sys.stderr)
523
+ sys.exit(1)
524
+
525
+ overrides_data = load_yaml(Path(args.overrides))
526
+ overrides_entries = overrides_data.get("entries", overrides_data)
527
+ overrides_defaults = overrides_data.get("defaults", {}) if isinstance(overrides_data, dict) else {}
528
+
529
+ max_year_gap = int(overrides_defaults.get("max_year_gap", args.max_year_gap))
530
+ contact_email = str(overrides_defaults.get("mailto", "")).strip()
531
+
532
+ citations_data = load_yaml(Path(args.citations_data))
533
+ scholar_map = citations_data.get("papers", {}) if isinstance(citations_data, dict) else {}
534
+
535
+ scimago_index, scimago_meta = load_scimago(Path(args.scimago_csv))
536
+ if not scimago_meta.get("available"):
537
+ message = scimago_meta.get("message", f"missing: {args.scimago_csv}")
538
+ print(f"Scimago data unavailable: {message}", file=sys.stderr)
539
+ if args.require_scimago:
540
+ print("Use fetch_scimago/metrics-scimago-fetch or pass --scimago-csv/--input with a local file.", file=sys.stderr)
541
+ sys.exit(1)
542
+
543
+ client = MetricsClient(offline=args.offline, email=contact_email)
544
+
545
+ updated_on = datetime.now(timezone.utc).strftime("%Y-%m-%d")
546
+ stale_rejected_count = 0
547
+ report_entries: list[dict[str, Any]] = []
548
+ unmatched_rows: list[dict[str, Any]] = []
549
+
550
+ for bib_path in bib_files:
551
+ if not bib_path.exists():
552
+ print(f"Skipping missing BibTeX file: {bib_path}", file=sys.stderr)
553
+ continue
554
+
555
+ prefix, db = parse_bib_database(bib_path)
556
+
557
+ for entry in db.entries:
558
+ key = entry.get("ID", "")
559
+ entry_type = entry.get("ENTRYTYPE", "")
560
+ override = overrides_entries.get(key, {}) if isinstance(overrides_entries, dict) else {}
561
+
562
+ doi = normalize_doi(entry.get("doi"))
563
+ year = as_int(entry.get("year"))
564
+ original_note = str(entry.get("note", "")).strip()
565
+
566
+ status = {
567
+ "openalex": "skipped",
568
+ "crossref": "skipped",
569
+ "scimago": "skipped",
570
+ "google_scholar": "skipped",
571
+ }
572
+
573
+ # Google Scholar compatibility bridge
574
+ gs_id = override.get("x_gs_id") or override.get("gs_id") or entry.get("x_gs_id") or entry.get("google_scholar_id")
575
+ gs_cited = (
576
+ override.get("x_gs_cited_by")
577
+ or override.get("gs_cited_by")
578
+ or entry.get("x_gs_cited_by")
579
+ or entry.get("google_scholar_citations")
580
+ )
581
+
582
+ if gs_id:
583
+ set_field(entry, "x_gs_id", gs_id)
584
+ status["google_scholar"] = "matched"
585
+ if gs_cited is None and gs_id:
586
+ scholar_key = str(gs_id).replace("cites:", "")
587
+ scholar_hit = None
588
+ for k, v in scholar_map.items():
589
+ if scholar_key and scholar_key in str(k):
590
+ scholar_hit = v
591
+ break
592
+ if isinstance(scholar_hit, dict):
593
+ gs_cited = scholar_hit.get("citations")
594
+ if gs_cited is not None:
595
+ set_field(entry, "x_gs_cited_by", as_int(gs_cited))
596
+ status["google_scholar"] = "matched"
597
+
598
+ # OpenAlex
599
+ oa_override_id = override.get("x_openalex_id") or override.get("openalex_id")
600
+ work = client.get_openalex_work(doi=doi, openalex_id=oa_override_id)
601
+ if work:
602
+ status["openalex"] = "matched"
603
+ set_field(entry, "x_openalex_id", work.get("id"))
604
+ set_field(entry, "x_openalex_cited_by", as_int(work.get("cited_by_count")))
605
+ set_field(entry, "x_openalex_fwci", as_float(work.get("fwci")))
606
+
607
+ cnp_raw = (work.get("citation_normalized_percentile") or {}).get("value")
608
+ cnp = as_float(cnp_raw)
609
+ if cnp is not None:
610
+ set_field(entry, "x_openalex_citation_normalized_percentile", cnp * 100)
611
+
612
+ primary_topic = work.get("primary_topic") or {}
613
+ if isinstance(primary_topic, dict):
614
+ subfield = primary_topic.get("subfield")
615
+ field = primary_topic.get("field")
616
+ domain = primary_topic.get("domain")
617
+ subfield_name = subfield.get("display_name") if isinstance(subfield, dict) else ""
618
+ field_name = field.get("display_name") if isinstance(field, dict) else ""
619
+ domain_name = domain.get("display_name") if isinstance(domain, dict) else ""
620
+ set_field(entry, "x_openalex_topic", str(primary_topic.get("display_name") or "").strip())
621
+ set_field(entry, "x_openalex_subfield", str(subfield_name or "").strip())
622
+ set_field(entry, "x_openalex_field", str(field_name or "").strip())
623
+ set_field(entry, "x_openalex_domain", str(domain_name or "").strip())
624
+ else:
625
+ set_field(entry, "x_openalex_topic", "")
626
+ set_field(entry, "x_openalex_subfield", "")
627
+ set_field(entry, "x_openalex_field", "")
628
+ set_field(entry, "x_openalex_domain", "")
629
+
630
+ funders: list[str] = []
631
+ seen_funders: set[str] = set()
632
+ for grant in (work.get("grants") or []):
633
+ if not isinstance(grant, dict):
634
+ continue
635
+ funder_name = str(grant.get("funder_display_name") or "").strip()
636
+ if not funder_name:
637
+ funder = grant.get("funder")
638
+ if isinstance(funder, dict):
639
+ funder_name = str(funder.get("display_name") or "").strip()
640
+ if not funder_name:
641
+ continue
642
+ canonical = funder_name.lower()
643
+ if canonical in seen_funders:
644
+ continue
645
+ seen_funders.add(canonical)
646
+ funders.append(funder_name)
647
+ set_field(entry, "x_openalex_funders", "; ".join(funders))
648
+
649
+ cited_2y, cited_5y = parse_counts_by_year(work)
650
+ set_field(entry, "x_openalex_cited_by_2y", cited_2y)
651
+ set_field(entry, "x_openalex_cited_by_5y", cited_5y)
652
+
653
+ source = (work.get("primary_location") or {}).get("source") or {}
654
+ source_id = source.get("id")
655
+ if source_id:
656
+ set_field(entry, "x_openalex_source_id", source_id)
657
+ source_full = client.get_openalex_source(source_id)
658
+ source_payload = source_full or source
659
+ set_field(entry, "x_openalex_source_display_name", str(source_payload.get("display_name") or "").strip())
660
+ set_field(entry, "x_openalex_source_works_count", as_int(source_payload.get("works_count")))
661
+ set_field(entry, "x_openalex_source_cited_by_count", as_int(source_payload.get("cited_by_count")))
662
+ set_field(entry, "x_openalex_source_is_oa", as_bool(source_payload.get("is_oa")))
663
+ set_field(entry, "x_openalex_source_is_core", as_bool(source_payload.get("is_core")))
664
+ set_field(entry, "x_openalex_source_is_in_doaj", as_bool(source_payload.get("is_in_doaj")))
665
+ summary_stats = source_payload.get("summary_stats") or {}
666
+ set_field(entry, "x_openalex_source_h_index", as_int(summary_stats.get("h_index")))
667
+ set_field(entry, "x_openalex_source_i10_index", as_int(summary_stats.get("i10_index")))
668
+ set_field(entry, "x_openalex_source_2yr_mean_citedness", as_float(summary_stats.get("2yr_mean_citedness")))
669
+ else:
670
+ if doi or oa_override_id:
671
+ status["openalex"] = "api-error" if client.last_service_error("openalex") else "not-found"
672
+ else:
673
+ status["openalex"] = "no-doi"
674
+
675
+ # Crossref
676
+ cr_override = override.get("x_crossref_cited_by") or override.get("crossref_cited_by")
677
+ if cr_override is not None:
678
+ set_field(entry, "x_crossref_cited_by", as_int(cr_override))
679
+ status["crossref"] = "matched"
680
+ else:
681
+ crossref_work = client.get_crossref_work(doi)
682
+ if crossref_work:
683
+ set_field(entry, "x_crossref_cited_by", as_int(crossref_work.get("is-referenced-by-count")))
684
+ status["crossref"] = "matched"
685
+ else:
686
+ if doi:
687
+ status["crossref"] = "api-error" if client.last_service_error("crossref") else "not-found"
688
+ else:
689
+ status["crossref"] = "no-doi"
690
+
691
+ # Scimago (articles only)
692
+ if str(entry_type).lower() == "article":
693
+ issns = []
694
+ for field_name in ("issn", "eissn"):
695
+ issns.extend(normalize_issn_list(entry.get(field_name)))
696
+ unique_issns = sorted(set(issns))
697
+
698
+ candidates: list[dict[str, Any]] = []
699
+ for issn in unique_issns:
700
+ candidates.extend(scimago_index.get(issn, []))
701
+
702
+ chosen, sc_status = choose_scimago_record(candidates, year, max_year_gap)
703
+ status["scimago"] = sc_status if scimago_meta.get("available") else "scimago-missing"
704
+
705
+ if sc_status == "stale-rejected":
706
+ stale_rejected_count += 1
707
+
708
+ if chosen:
709
+ set_field(entry, "x_scimago_sjr", chosen.get("sjr"))
710
+ set_field(entry, "x_scimago_quartile", chosen.get("quartile"))
711
+ set_field(entry, "x_scimago_year", chosen.get("year"))
712
+ set_field(entry, "x_scimago_categories", chosen.get("categories"))
713
+ set_field(entry, "x_scimago_h_index", chosen.get("h_index"))
714
+ set_field(entry, "x_scimago_cites_per_doc_2y", chosen.get("cites_per_doc_2y"))
715
+ set_field(entry, "x_scimago_total_docs_3y", chosen.get("total_docs_3y"))
716
+ set_field(entry, "x_scimago_total_cites_3y", chosen.get("total_cites_3y"))
717
+ set_field(entry, "x_scimago_country", chosen.get("country"))
718
+ set_field(entry, "x_scimago_type", chosen.get("type"))
719
+ set_field(entry, "x_scimago_coverage", chosen.get("coverage"))
720
+ else:
721
+ status["scimago"] = "not-article"
722
+
723
+ set_field(entry, "x_metrics_updated", updated_on)
724
+
725
+ note = build_metrics_note(entry, original_note)
726
+ if note:
727
+ entry["note"] = note
728
+
729
+ snapshot = {
730
+ "key": key,
731
+ "type": entry_type,
732
+ "title": entry.get("title", ""),
733
+ "doi": doi,
734
+ "status": status,
735
+ }
736
+ for field in X_FIELDS:
737
+ if field in entry:
738
+ snapshot[field] = entry[field]
739
+ report_entries.append(snapshot)
740
+
741
+ if any(value not in {"matched", "not-article", "skipped"} for value in status.values()):
742
+ unmatched_rows.append(
743
+ {
744
+ "key": key,
745
+ "type": entry_type,
746
+ "title": entry.get("title", ""),
747
+ "doi": doi or "",
748
+ "openalex_status": status["openalex"],
749
+ "crossref_status": status["crossref"],
750
+ "scimago_status": status["scimago"],
751
+ "google_scholar_status": status["google_scholar"],
752
+ }
753
+ )
754
+
755
+ if not args.dry_run:
756
+ write_bib_database(bib_path, prefix, db)
757
+
758
+ summary = compute_summary(report_entries, SummaryContext(max_year_gap=max_year_gap, stale_rejected=stale_rejected_count))
759
+
760
+ metrics_payload = {
761
+ "metrics": {
762
+ "updated_on": updated_on,
763
+ "sources": {
764
+ "openalex": "api" if not args.offline else "offline",
765
+ "crossref": "api" if not args.offline else "offline",
766
+ "google_scholar": "bibtex + _data/citations.yml",
767
+ "scimago": str(scimago_meta.get("path", args.scimago_csv)) if scimago_meta.get("available") else "missing",
768
+ },
769
+ "api_errors": client.api_error_summary(),
770
+ "summary": summary,
771
+ }
772
+ }
773
+
774
+ metrics_out = Path(args.metrics_out)
775
+ metrics_out.parent.mkdir(parents=True, exist_ok=True)
776
+ metrics_out.write_text(yaml.safe_dump(metrics_payload, sort_keys=False, allow_unicode=True), encoding="utf-8")
777
+
778
+ report_out = Path(args.report_json)
779
+ report_out.parent.mkdir(parents=True, exist_ok=True)
780
+ report_out.write_text(
781
+ json.dumps(
782
+ {
783
+ "generated_on": updated_on,
784
+ "scimago": scimago_meta,
785
+ "api_errors": client.api_errors,
786
+ "api_error_summary": client.api_error_summary(),
787
+ "max_year_gap": max_year_gap,
788
+ "entries": report_entries,
789
+ "summary": summary,
790
+ },
791
+ indent=2,
792
+ ensure_ascii=False,
793
+ ),
794
+ encoding="utf-8",
795
+ )
796
+
797
+ unmatched_out = Path(args.unmatched_tsv)
798
+ unmatched_out.parent.mkdir(parents=True, exist_ok=True)
799
+ with unmatched_out.open("w", encoding="utf-8", newline="") as handle:
800
+ fieldnames = [
801
+ "key",
802
+ "type",
803
+ "title",
804
+ "doi",
805
+ "openalex_status",
806
+ "crossref_status",
807
+ "scimago_status",
808
+ "google_scholar_status",
809
+ ]
810
+ writer = csv.DictWriter(handle, fieldnames=fieldnames, delimiter="\t")
811
+ writer.writeheader()
812
+ writer.writerows(unmatched_rows)
813
+
814
+ print(f"Metrics updated on {updated_on}")
815
+ print(f"- Metrics summary: {metrics_out}")
816
+ print(f"- Diagnostics JSON: {report_out}")
817
+ print(f"- Unmatched TSV: {unmatched_out}")
818
+ if client.api_errors:
819
+ print(f"External API request errors: {client.api_error_summary()}", file=sys.stderr)
820
+ if args.strict_external:
821
+ print("Failing because --strict-external is enabled.", file=sys.stderr)
822
+ sys.exit(2)
823
+
824
+
825
+ if __name__ == "__main__":
826
+ main()