scriptorium 0.6.1 → 0.7.2

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 (358) hide show
  1. checksums.yaml +4 -4
  2. data/assets/icons/social/reddit.png +0 -0
  3. data/assets/icons/social/x-logo.png +0 -0
  4. data/assets/imagenotfound.jpg +0 -0
  5. data/bin/sblog +84 -5
  6. data/bin/scriptorium +1 -0
  7. data/doc/anti-amnesia/20250727-054000-scriptorium-overview.md +0 -1
  8. data/doc/anti-amnesia/20250727-123000-anti-amnesia-conventions.md +0 -29
  9. data/doc/anti-amnesia/20250727-172600-cursor-rbenv-ruby-version-mystery.md +0 -19
  10. data/doc/anti-amnesia/20250727-172900-ai-cognitive-assessment-capabilities.md +1 -1
  11. data/doc/anti-amnesia/20250728-124243-aaa-syntax-clarification.md +1 -1
  12. data/doc/anti-amnesia/20250729-210000-reddit-autopost-integration-complete.md +1 -1
  13. data/doc/anti-amnesia/20250804-190500-cognitive-loop-bug.md +0 -10
  14. data/doc/anti-amnesia/20250804-190700-anti-amnesia-timestamping-fix.md +1 -4
  15. data/doc/anti-amnesia/20250901-211714-codemirror-integration-and-web-tests.md +172 -0
  16. data/doc/anti-amnesia/20250902-002402-backup-restore-system.md +126 -0
  17. data/doc/anti-amnesia/20250907-203339-backup-metadata-implementation.md +66 -0
  18. data/doc/imported/0001-elixir-conf-2014/metadata.txt +7 -0
  19. data/doc/imported/0001-elixir-conf-2014/post.html +37 -0
  20. data/doc/imported/0001-elixir-conf-2014/source.lt3 +22 -0
  21. data/doc/imported/0002-programmers-and-word-processing/metadata.txt +7 -0
  22. data/doc/imported/0002-programmers-and-word-processing/post.html +192 -0
  23. data/doc/imported/0002-programmers-and-word-processing/source.lt3 +146 -0
  24. data/doc/imported/0003-how-to-turn-your-brain-sideways/metadata.txt +7 -0
  25. data/doc/imported/0003-how-to-turn-your-brain-sideways/post.html +60 -0
  26. data/doc/imported/0003-how-to-turn-your-brain-sideways/source.lt3 +40 -0
  27. data/doc/imported/0004-upcoming-lone-star-ruby-conference/metadata.txt +7 -0
  28. data/doc/imported/0004-upcoming-lone-star-ruby-conference/post.html +42 -0
  29. data/doc/imported/0004-upcoming-lone-star-ruby-conference/source.lt3 +24 -0
  30. data/doc/imported/0005-elixir-conf-2015-announced/metadata.txt +7 -0
  31. data/doc/imported/0005-elixir-conf-2015-announced/post.html +30 -0
  32. data/doc/imported/0005-elixir-conf-2015-announced/source.lt3 +16 -0
  33. data/doc/imported/0006-ruby-for-dinosaurs/metadata.txt +7 -0
  34. data/doc/imported/0006-ruby-for-dinosaurs/post.html +43 -0
  35. data/doc/imported/0006-ruby-for-dinosaurs/source.lt3 +27 -0
  36. data/doc/imported/0007-phoenix-isnt-rails/metadata.txt +7 -0
  37. data/doc/imported/0007-phoenix-isnt-rails/post.html +116 -0
  38. data/doc/imported/0007-phoenix-isnt-rails/source.lt3 +87 -0
  39. data/doc/imported/0008-concerning-the-term-monkeypatching/metadata.txt +7 -0
  40. data/doc/imported/0008-concerning-the-term-monkeypatching/post.html +129 -0
  41. data/doc/imported/0008-concerning-the-term-monkeypatching/source.lt3 +92 -0
  42. data/doc/imported/0009-announcement-coming-soon/metadata.txt +7 -0
  43. data/doc/imported/0009-announcement-coming-soon/post.html +33 -0
  44. data/doc/imported/0009-announcement-coming-soon/source.lt3 +19 -0
  45. data/doc/imported/0010-immutable-data-ditching-the-wax-tablet/metadata.txt +7 -0
  46. data/doc/imported/0010-immutable-data-ditching-the-wax-tablet/post.html +175 -0
  47. data/doc/imported/0010-immutable-data-ditching-the-wax-tablet/source.lt3 +139 -0
  48. data/doc/imported/0011-computer-science-as-a-lost-art/metadata.txt +7 -0
  49. data/doc/imported/0011-computer-science-as-a-lost-art/post.html +139 -0
  50. data/doc/imported/0011-computer-science-as-a-lost-art/source.lt3 +104 -0
  51. data/doc/imported/0012-ruby-day-in-turin-italy/metadata.txt +7 -0
  52. data/doc/imported/0012-ruby-day-in-turin-italy/post.html +42 -0
  53. data/doc/imported/0012-ruby-day-in-turin-italy/source.lt3 +24 -0
  54. data/doc/imported/0013-rubyday-was-a-success/metadata.txt +7 -0
  55. data/doc/imported/0013-rubyday-was-a-success/post.html +44 -0
  56. data/doc/imported/0013-rubyday-was-a-success/source.lt3 +27 -0
  57. data/doc/imported/0014-working-on-the-blogging-software/metadata.txt +7 -0
  58. data/doc/imported/0014-working-on-the-blogging-software/post.html +63 -0
  59. data/doc/imported/0014-working-on-the-blogging-software/source.lt3 +41 -0
  60. data/doc/imported/0015-ok-its-not-really-a-lost-art/metadata.txt +7 -0
  61. data/doc/imported/0015-ok-its-not-really-a-lost-art/post.html +172 -0
  62. data/doc/imported/0015-ok-its-not-really-a-lost-art/source.lt3 +134 -0
  63. data/doc/imported/0016-an-in-operator-for-ruby/metadata.txt +7 -0
  64. data/doc/imported/0016-an-in-operator-for-ruby/post.html +155 -0
  65. data/doc/imported/0016-an-in-operator-for-ruby/source.lt3 +106 -0
  66. data/doc/imported/0017-the-forgotten-mathematician/metadata.txt +7 -0
  67. data/doc/imported/0017-the-forgotten-mathematician/post.html +161 -0
  68. data/doc/imported/0017-the-forgotten-mathematician/source.lt3 +119 -0
  69. data/doc/imported/0018-ruby-puns/metadata.txt +7 -0
  70. data/doc/imported/0018-ruby-puns/post.html +46 -0
  71. data/doc/imported/0018-ruby-puns/source.lt3 +28 -0
  72. data/doc/imported/0019-custom-exceptions-via-metaprogramming/metadata.txt +7 -0
  73. data/doc/imported/0019-custom-exceptions-via-metaprogramming/post.html +138 -0
  74. data/doc/imported/0019-custom-exceptions-via-metaprogramming/source.lt3 +101 -0
  75. data/doc/imported/0020-fffff/metadata.txt +7 -0
  76. data/doc/imported/0020-fffff/post.html +24 -0
  77. data/doc/imported/0020-fffff/source.lt3 +12 -0
  78. data/doc/imported/0021-trying-ror-yet-again/metadata.txt +7 -0
  79. data/doc/imported/0021-trying-ror-yet-again/post.html +26 -0
  80. data/doc/imported/0021-trying-ror-yet-again/source.lt3 +12 -0
  81. data/doc/imported/0023-doctor-sleep/metadata.txt +7 -0
  82. data/doc/imported/0023-doctor-sleep/post.html +63 -0
  83. data/doc/imported/0023-doctor-sleep/source.lt3 +44 -0
  84. data/doc/imported/0024-just-a-test/metadata.txt +7 -0
  85. data/doc/imported/0024-just-a-test/post.html +24 -0
  86. data/doc/imported/0024-just-a-test/source.lt3 +12 -0
  87. data/doc/imported/import_summary.txt +98 -0
  88. data/doc/livetext-informal-spec.txt +65 -0
  89. data/doc/myuserdoc/ch-0.lt3 +31 -0
  90. data/doc/myuserdoc/ch-1.lt3 +37 -0
  91. data/doc/myuserdoc/ch-10.lt3 +22 -0
  92. data/doc/myuserdoc/ch-2.lt3 +37 -0
  93. data/doc/myuserdoc/ch-3.lt3 +19 -0
  94. data/doc/myuserdoc/ch-4.lt3 +43 -0
  95. data/doc/myuserdoc/ch-5.lt3 +22 -0
  96. data/doc/myuserdoc/ch-6.lt3 +19 -0
  97. data/doc/myuserdoc/ch-7.lt3 +16 -0
  98. data/doc/myuserdoc/ch-8.lt3 +13 -0
  99. data/doc/myuserdoc/ch-9.lt3 +19 -0
  100. data/doc/myuserdoc/tweak.rb +18 -0
  101. data/doc/{userdoc-toc.txt → myuserdoc/userdoc-toc.txt} +27 -27
  102. data/doc/old-posts/0001-elixir-conf-2014.lt3 +24 -0
  103. data/doc/old-posts/0002-programmers-and-word-processing.lt3 +150 -0
  104. data/doc/old-posts/0003-how-to-turn-your-brain-sideways.lt3 +43 -0
  105. data/doc/old-posts/0004-upcoming-lone-star-ruby-conference.lt3 +26 -0
  106. data/doc/old-posts/0005-elixir-conf-2015-announced.lt3 +17 -0
  107. data/doc/old-posts/0006-ruby-for-dinosaurs.lt3 +30 -0
  108. data/doc/old-posts/0007-phoenix-isnt-rails.lt3 +90 -0
  109. data/doc/old-posts/0008-concerning-the-term-monkeypatching.lt3 +105 -0
  110. data/doc/old-posts/0009-announcement-coming-soon.lt3 +20 -0
  111. data/doc/old-posts/0010-immutable-data-ditching-the-wax-tablet.lt3 +142 -0
  112. data/doc/old-posts/0011-computer-science-as-a-lost-art.lt3 +117 -0
  113. data/doc/old-posts/0012-ruby-day-in-turin-italy.lt3 +26 -0
  114. data/doc/old-posts/0013-rubyday-was-a-success.lt3 +28 -0
  115. data/doc/old-posts/0014-working-on-the-blogging-software.lt3 +42 -0
  116. data/doc/old-posts/0015-ok-its-not-really-a-lost-art.lt3 +137 -0
  117. data/doc/old-posts/0016-an-in-operator-for-ruby.lt3 +142 -0
  118. data/doc/old-posts/0017-the-forgotten-mathematician.lt3 +129 -0
  119. data/doc/old-posts/0018-ruby-puns.lt3 +31 -0
  120. data/doc/old-posts/0019-custom-exceptions-via-metaprogramming.lt3 +116 -0
  121. data/doc/old-posts/0021-trying-ror-yet-again.lt3 +35 -0
  122. data/doc/old-posts/0023-doctor-sleep.lt3 +43 -0
  123. data/doc/old-posts/0024-just-a-test.lt3 +12 -0
  124. data/doc/old-posts/0025-trying-another-post.lt3 +12 -0
  125. data/doc/old-repo +1 -0
  126. data/doc/reddit_integration.md +2 -2
  127. data/doc/user.lt3 +0 -3
  128. data/lib/scriptorium/api.rb +1811 -78
  129. data/lib/scriptorium/banner_svg.rb +55 -68
  130. data/lib/scriptorium/contract.rb +3 -2
  131. data/lib/scriptorium/exceptions.rb +133 -102
  132. data/lib/scriptorium/helpers.rb +282 -82
  133. data/lib/scriptorium/post.rb +81 -17
  134. data/lib/scriptorium/reddit.rb +1 -1
  135. data/lib/scriptorium/repo.rb +478 -164
  136. data/lib/scriptorium/standard_files.rb +30 -396
  137. data/lib/scriptorium/support/common_js/clipboard.js +35 -0
  138. data/lib/scriptorium/support/common_js/content-loader.js +187 -0
  139. data/lib/scriptorium/support/common_js/navigation.js +52 -0
  140. data/lib/scriptorium/support/common_js/syntax-highlighting.js +27 -0
  141. data/lib/scriptorium/support/config/reddit_template.txt +17 -0
  142. data/{test/scriptorium-TEST-1754622690-146/views/sample → lib/scriptorium/support}/config/social.txt +1 -0
  143. data/lib/scriptorium/support/highlight/css.txt +2 -0
  144. data/lib/scriptorium/support/highlight/custom.css +119 -0
  145. data/lib/scriptorium/support/highlight/js.txt +1 -0
  146. data/lib/scriptorium/support/post_index/config.txt +15 -0
  147. data/lib/scriptorium/support/post_index/style.css +55 -0
  148. data/lib/scriptorium/support/templates/index_entry.lt3 +16 -0
  149. data/{test/scriptorium-TEST-1754622690-146/themes/standard/initial/post.lt3 → lib/scriptorium/support/templates/initial_post.lt3} +5 -5
  150. data/lib/scriptorium/support/templates/post.lt3 +104 -0
  151. data/{test/scriptorium-TEST-1754622690-146/themes/standard/layout/config/header.txt → lib/scriptorium/support/theme/header.lt3} +1 -1
  152. data/lib/scriptorium/theme.rb +83 -70
  153. data/lib/scriptorium/version.rb +2 -2
  154. data/lib/scriptorium/view.rb +194 -149
  155. data/lib/scriptorium.rb +24 -1
  156. data/lib/skeleton.rb +4 -1
  157. data/scriptorium.gemspec +2 -1
  158. data/test/WEB_INTEGRATION_README.md +196 -0
  159. data/test/all +40 -0
  160. data/test/banner_svg/unit.rb +267 -35
  161. data/test/config/deployment.txt +5 -0
  162. data/test/integration/integration_test.rb +7 -7
  163. data/test/integration/preview_flow_test.rb +94 -0
  164. data/test/livetext_plugin_test.rb +453 -182
  165. data/test/manual/banner-tests/test01.html +82 -18
  166. data/test/manual/banner-tests/test02.html +82 -18
  167. data/test/manual/banner-tests/test03.html +82 -18
  168. data/test/manual/banner-tests/test04.html +89 -25
  169. data/test/manual/banner-tests/test05.html +89 -25
  170. data/test/manual/banner-tests/test06.html +89 -25
  171. data/test/manual/banner-tests/test07.html +89 -25
  172. data/test/manual/banner-tests/test08.html +82 -18
  173. data/test/manual/banner-tests/test09.html +82 -18
  174. data/test/manual/banner-tests/test10.html +82 -18
  175. data/test/manual/banner-tests/test11.html +82 -18
  176. data/test/manual/banner-tests/test12.html +82 -18
  177. data/test/manual/banner-tests/test13.html +82 -18
  178. data/test/manual/banner-tests/test14.html +82 -18
  179. data/test/manual/banner-tests/test15.html +82 -18
  180. data/test/manual/banner-tests/test16.html +82 -18
  181. data/test/manual/banner-tests/test17.html +82 -18
  182. data/test/manual/banner-tests/test18.html +90 -26
  183. data/test/manual/banner-tests/test19.html +90 -26
  184. data/test/manual/banner-tests/test20.html +90 -26
  185. data/test/manual/banner-tests/test21.html +90 -26
  186. data/test/manual/banner-tests/test22.html +90 -26
  187. data/test/manual/banner-tests/test23.html +90 -26
  188. data/test/manual/banner-tests/test24.html +90 -26
  189. data/test/manual/banner-tests/test25.html +89 -25
  190. data/test/manual/banner_environment.rb +15 -2
  191. data/test/manual/codemirror_demo.html +773 -0
  192. data/test/manual/create_posts_for_web.rb +114 -0
  193. data/test/manual/preview_manual_test.rb +129 -0
  194. data/test/manual/test_banner_features.rb +14 -14
  195. data/test/manual/test_banner_integration.rb +115 -0
  196. data/test/manual/test_banner_radial.rb +87 -0
  197. data/test/manual/test_syntax_highlighting.rb +60 -40
  198. data/test/support/preview_utils.rb +88 -0
  199. data/test/test_gem_assets.rb +48 -0
  200. data/test/test_helpers.rb +10 -0
  201. data/test/tui_editor_integration_test.rb +15 -15
  202. data/test/tui_integration_test.rb +687 -441
  203. data/test/unit/api.rb +757 -37
  204. data/test/unit/asset_management.rb +195 -221
  205. data/test/unit/backup_test.rb +451 -0
  206. data/test/unit/contract_test.rb +1 -23
  207. data/test/unit/core.rb +415 -61
  208. data/test/unit/deploy_config_test.rb +248 -0
  209. data/test/unit/deploy_test.rb +312 -21
  210. data/test/unit/edit_post_test.rb +168 -0
  211. data/test/unit/gem_asset_management.rb +36 -42
  212. data/test/unit/livetext_basic.rb +23 -35
  213. data/test/unit/livetext_compatibility.rb +7 -14
  214. data/test/unit/parse_cmd_test.rb +260 -0
  215. data/test/unit/{symlink_test.rb → permalink_copy_test.rb} +47 -49
  216. data/test/unit/post.rb +91 -26
  217. data/test/unit/post_index_config_test.rb +258 -0
  218. data/test/unit/post_state_helpers_test.rb +137 -0
  219. data/test/unit/read_commented_file_test.rb +8 -6
  220. data/test/unit/repo.rb +75 -54
  221. data/test/unit/social_test.rb +41 -44
  222. data/test/unit/syntax_highlighting.rb +70 -0
  223. data/test/unit/theme_management_test.rb +91 -0
  224. data/test/unit/view.rb +79 -12
  225. data/test/unit/widgets.rb +8 -8
  226. data/test/web_integration_test.rb +231 -0
  227. data/test/web_test_helper.rb +218 -0
  228. data/test/web_workflow_test.rb +527 -0
  229. data/ui/tui/bin/scriptorium +885 -415
  230. data/ui/web/app/app.rb +1398 -176
  231. data/ui/web/app/assets/livetext_mode.js +244 -0
  232. data/ui/web/app/error_helpers.rb +16 -16
  233. data/ui/web/app/views/advanced_config.erb +8 -2
  234. data/ui/web/app/views/asset_management.erb +56 -0
  235. data/ui/web/app/views/backup_management.erb +238 -0
  236. data/ui/web/app/views/config_widget.erb +232 -0
  237. data/ui/web/app/views/dashboard.erb +64 -72
  238. data/ui/web/app/views/deploy_config.erb +3 -0
  239. data/ui/web/app/views/edit_pages.erb +170 -2
  240. data/ui/web/app/views/edit_post.erb +130 -9
  241. data/ui/web/app/views/edit_theme.erb +73 -0
  242. data/ui/web/app/views/edit_theme_file.erb +74 -0
  243. data/ui/web/app/views/theme_management.erb +130 -0
  244. data/ui/web/app/views/view_dashboard.erb +666 -25
  245. data/ui/web/app/views/widgets.erb +249 -0
  246. data/ui/web/bin/scriptorium-web +35 -24
  247. data/ui/web/tmp/timing.log +17 -0
  248. data/ui/web/tmp/web_server.log +0 -5
  249. metadata +190 -116
  250. data/assets/back-icon.png +0 -0
  251. data/assets/icons/facebook.svg +0 -1
  252. data/assets/icons/github.svg +0 -1
  253. data/assets/icons/instagram.svg +0 -1
  254. data/assets/icons/reddit.svg +0 -1
  255. data/assets/icons/x.svg +0 -1
  256. data/assets/icons/youtube.svg +0 -1
  257. data/bin/scriptorium +0 -1511
  258. data/doc/anti-amnesia/20250727-060000-api-design-tui-planning.md +0 -34
  259. data/doc/anti-amnesia/20250727-061000-runeblog-tui-analysis.md +0 -50
  260. data/doc/anti-amnesia/20250727-154000-livetext-plugin-file-stats.md +0 -73
  261. data/doc/anti-amnesia/20250727-172600-unified-minitest-framework.md +0 -70
  262. data/doc/anti-amnesia/20250727-173000-widget-testing-achievement.md +0 -110
  263. data/doc/anti-amnesia/20250727-180000-post-id-num-refactoring.md +0 -73
  264. data/doc/anti-amnesia/20250728-124421-conversation-summary-concise.md +0 -124
  265. data/doc/anti-amnesia/20250729-190000-scriptorium-tui-testing-complete.md +0 -46
  266. data/doc/anti-amnesia/20250729-200000-scriptorium-tui-testing-edit-file-workflow.md +0 -97
  267. data/doc/anti-amnesia/20250729-211500-dependency-management-system.md +0 -211
  268. data/doc/anti-amnesia/20250729-213000-python-virtual-environment-setup.md +0 -141
  269. data/doc/anti-amnesia/20250729-214500-theme-management-commands.md +0 -211
  270. data/doc/anti-amnesia/20250729-215000-version-update-to-0.6.0.md +0 -134
  271. data/doc/anti-amnesia/20250729-220000-user-guide-complete.md +0 -41
  272. data/doc/anti-amnesia/20250804-213700-publishing-test-fix.md +0 -49
  273. data/doc/anti-amnesia/20250804-214400-additional-test-fixes.md +0 -46
  274. data/doc/anti-amnesia/20250804-220000-asset-function-logic-clarification.md +0 -41
  275. data/doc/anti-amnesia/20250806-202032-asset-function-logic-clarification.md +0 -41
  276. data/doc/anti-amnesia/20250813-082428-syntax-highlighting-and-navigation-improvements.md +0 -256
  277. data/lib/scriptorium/syntax_highlighter.rb +0 -234
  278. data/test/manual/deploy_symlink_demo.rb +0 -142
  279. data/test/manual/symlink_demo.rb +0 -117
  280. data/test/manual/test2.rb +0 -12
  281. data/test/manual/test_banner_from_file.rb +0 -150
  282. data/test/manual/test_banner_in_header.rb +0 -35
  283. data/test/manual/test_code_highlighting.rb +0 -68
  284. data/test/manual/test_complex_header.rb +0 -74
  285. data/test/manual/test_empty_header.rb +0 -32
  286. data/test/manual/test_radial_custom.rb +0 -58
  287. data/test/manual/test_radial_large_radius.rb +0 -52
  288. data/test/manual/test_svg_debug.rb +0 -47
  289. data/test/pages-demo/config/currentview.txt +0 -1
  290. data/test/pages-demo/views/demo/config/common.js +0 -57
  291. data/test/pages-demo/views/demo/config/footer.txt +0 -1
  292. data/test/pages-demo/views/demo/config/global-head.txt +0 -8
  293. data/test/pages-demo/views/demo/config/header.txt +0 -1
  294. data/test/pages-demo/views/demo/config/layout.txt +0 -1
  295. data/test/pages-demo/views/demo/config/left.txt +0 -1
  296. data/test/pages-demo/views/demo/config/main.txt +0 -1
  297. data/test/pages-demo/views/demo/config/right.txt +0 -1
  298. data/test/pages-demo/views/demo/config.txt +0 -3
  299. data/test/pages-demo/views/demo/output/panes/footer.html +0 -1
  300. data/test/pages-demo/views/demo/output/panes/header.html +0 -1
  301. data/test/pages-demo/views/demo/output/panes/left.html +0 -1
  302. data/test/pages-demo/views/demo/output/panes/main.html +0 -1
  303. data/test/pages-demo/views/demo/output/panes/right.html +0 -1
  304. data/test/scriptorium-TEST-1754622690-146/config/bootstrap_css.txt +0 -5
  305. data/test/scriptorium-TEST-1754622690-146/config/bootstrap_js.txt +0 -4
  306. data/test/scriptorium-TEST-1754622690-146/config/common.js +0 -57
  307. data/test/scriptorium-TEST-1754622690-146/config/currentview.txt +0 -1
  308. data/test/scriptorium-TEST-1754622690-146/config/global-head.txt +0 -9
  309. data/test/scriptorium-TEST-1754622690-146/config/last_post_num.txt +0 -1
  310. data/test/scriptorium-TEST-1754622690-146/config/os_helpers.rb +0 -4
  311. data/test/scriptorium-TEST-1754622690-146/config/widgets.txt +0 -3
  312. data/test/scriptorium-TEST-1754622690-146/posts/0001/meta.txt +0 -8
  313. data/test/scriptorium-TEST-1754622690-146/posts/0001/source.lt3 +0 -6
  314. data/test/scriptorium-TEST-1754622690-146/themes/standard/README.txt +0 -1
  315. data/test/scriptorium-TEST-1754622690-146/themes/standard/config.txt +0 -1
  316. data/test/scriptorium-TEST-1754622690-146/themes/standard/layout/gen/text.css +0 -1
  317. data/test/scriptorium-TEST-1754622690-146/themes/standard/templates/index.lt3 +0 -1
  318. data/test/scriptorium-TEST-1754622690-146/themes/standard/templates/index_entry.lt3 +0 -14
  319. data/test/scriptorium-TEST-1754622690-146/themes/standard/templates/post.lt3 +0 -13
  320. data/test/scriptorium-TEST-1754622690-146/themes/standard/templates/widget.lt3 +0 -1
  321. data/test/scriptorium-TEST-1754622690-146/views/sample/config/bootstrap_css.txt +0 -5
  322. data/test/scriptorium-TEST-1754622690-146/views/sample/config/bootstrap_js.txt +0 -4
  323. data/test/scriptorium-TEST-1754622690-146/views/sample/config/common.js +0 -57
  324. data/test/scriptorium-TEST-1754622690-146/views/sample/config/deploy.txt +0 -5
  325. data/test/scriptorium-TEST-1754622690-146/views/sample/config/footer.txt +0 -2
  326. data/test/scriptorium-TEST-1754622690-146/views/sample/config/global-head.txt +0 -9
  327. data/test/scriptorium-TEST-1754622690-146/views/sample/config/header.txt +0 -4
  328. data/test/scriptorium-TEST-1754622690-146/views/sample/config/layout.txt +0 -5
  329. data/test/scriptorium-TEST-1754622690-146/views/sample/config/left.txt +0 -3
  330. data/test/scriptorium-TEST-1754622690-146/views/sample/config/main.txt +0 -5
  331. data/test/scriptorium-TEST-1754622690-146/views/sample/config/right.txt +0 -3
  332. data/test/scriptorium-TEST-1754622690-146/views/sample/config/status.txt +0 -7
  333. data/test/scriptorium-TEST-1754622690-146/views/sample/config.txt +0 -3
  334. data/test/scriptorium-TEST-1754622690-146/views/sample/layout/footer.html +0 -3
  335. data/test/scriptorium-TEST-1754622690-146/views/sample/layout/header.html +0 -3
  336. data/test/scriptorium-TEST-1754622690-146/views/sample/layout/left.html +0 -3
  337. data/test/scriptorium-TEST-1754622690-146/views/sample/layout/main.html +0 -3
  338. data/test/scriptorium-TEST-1754622690-146/views/sample/layout/right.html +0 -3
  339. data/test/scriptorium-TEST-1754622690-146/views/sample/output/panes/footer.html +0 -1
  340. data/test/scriptorium-TEST-1754622690-146/views/sample/output/panes/header.html +0 -1
  341. data/test/scriptorium-TEST-1754622690-146/views/sample/output/panes/left.html +0 -1
  342. data/test/scriptorium-TEST-1754622690-146/views/sample/output/panes/main.html +0 -1
  343. data/test/scriptorium-TEST-1754622690-146/views/sample/output/panes/right.html +0 -1
  344. data/ui/web/tmp/web_server.pid +0 -1
  345. /data/{test/pages-demo/views/demo/config/bootstrap_css.txt → lib/scriptorium/support/bootstrap/css.txt} +0 -0
  346. /data/{test/pages-demo/views/demo/config/bootstrap_js.txt → lib/scriptorium/support/bootstrap/js.txt} +0 -0
  347. /data/{test/scriptorium-TEST-1754622690-146/views/sample → lib/scriptorium/support}/config/reddit.txt +0 -0
  348. /data/{test/scriptorium-TEST-1754622690-146/themes/standard/layout → lib/scriptorium/support/templates}/layout.txt +0 -0
  349. /data/{test/scriptorium-TEST-1754622690-146/themes/standard/layout/config/footer.txt → lib/scriptorium/support/theme/footer.lt3} +0 -0
  350. /data/{test/scriptorium-TEST-1754622690-146/themes/standard/layout/config/left.txt → lib/scriptorium/support/theme/left.lt3} +0 -0
  351. /data/{test/scriptorium-TEST-1754622690-146/themes/standard/layout/config/main.txt → lib/scriptorium/support/theme/main.lt3} +0 -0
  352. /data/{test/scriptorium-TEST-1754622690-146/themes/standard/layout/config/right.txt → lib/scriptorium/support/theme/right.lt3} +0 -0
  353. /data/test/manual/banner-tests/{config.txt → svg.txt} +0 -0
  354. /data/test/manual/{test6.rb → test_advanced_widgets.rb} +0 -0
  355. /data/test/manual/{test1.rb → test_basic_posts.rb} +0 -0
  356. /data/test/manual/{test4.rb → test_layout_widgets.rb} +0 -0
  357. /data/test/manual/{test5.rb → test_pagination.rb} +0 -0
  358. /data/test/manual/{test3.rb → test_random_posts.rb} +0 -0
@@ -1,3 +1,6 @@
1
+
2
+ require 'set'
3
+
1
4
  class Scriptorium::API
2
5
  include Scriptorium::Exceptions
3
6
  include Scriptorium::Helpers
@@ -12,7 +15,8 @@ class Scriptorium::API
12
15
  end
13
16
 
14
17
  def initialize(testmode: false)
15
- assume { [true, false].include?(testmode) }
18
+ msg = "testmode must be true or false, got #{testmode}"
19
+ assume(msg) { [true, false].include?(testmode) }
16
20
 
17
21
  @testing = testmode
18
22
  @repo = nil
@@ -28,7 +32,8 @@ class Scriptorium::API
28
32
 
29
33
  def create_repo(path)
30
34
  check_invariants
31
- assume { path.is_a?(String) && !path.empty? }
35
+ msg = "path must be a non-empty String, got #{path.class} (#{path.inspect})"
36
+ assume(msg) { path.is_a?(String) && !path.empty? }
32
37
 
33
38
  raise RepoDirAlreadyExists if repo_exists?(path)
34
39
  Scriptorium::Repo.create(path)
@@ -40,7 +45,8 @@ class Scriptorium::API
40
45
 
41
46
  def open_repo(path)
42
47
  check_invariants
43
- assume { path.is_a?(String) && !path.empty? }
48
+ msg = "path must be a non-empty String, got #{path.class} (#{path.inspect})"
49
+ assume(msg) { path.is_a?(String) && !path.empty? }
44
50
 
45
51
  @repo = Scriptorium::Repo.open(path)
46
52
 
@@ -51,11 +57,16 @@ class Scriptorium::API
51
57
  # View management
52
58
  def create_view(name, title, subtitle = "", theme: "standard")
53
59
  check_invariants
54
- assume { name.is_a?(String) }
55
- assume { title.is_a?(String) }
56
- assume { subtitle.is_a?(String) }
57
- assume { theme.is_a?(String) }
58
- assume { @repo.is_a?(Scriptorium::Repo) }
60
+ msg = "name must be a String, got #{name.class}"
61
+ assume(msg) { name.is_a?(String) }
62
+ msg = "title must be a String, got #{title.class}"
63
+ assume(msg) { title.is_a?(String) }
64
+ msg = "subtitle must be a String, got #{subtitle.class}"
65
+ assume(msg) { subtitle.is_a?(String) }
66
+ msg = "theme must be a String, got #{theme.class}"
67
+ assume(msg) { theme.is_a?(String) }
68
+ msg = "@repo must be a Scriptorium::Repo, got #{@repo.class}"
69
+ assume(msg) { @repo.is_a?(Scriptorium::Repo) }
59
70
 
60
71
  @repo.create_view(name, title, subtitle, theme: theme)
61
72
 
@@ -76,6 +87,10 @@ class Scriptorium::API
76
87
  Scriptorium::VERSION
77
88
  end
78
89
 
90
+ def testing
91
+ @testing
92
+ end
93
+
79
94
  def apply_theme(theme)
80
95
  @repo.view.apply_theme(theme)
81
96
  end
@@ -106,15 +121,21 @@ class Scriptorium::API
106
121
  # Post creation with convenience defaults
107
122
  def create_post(title, body, views: nil, tags: nil, blurb: nil)
108
123
  check_invariants
109
- assume { title.is_a?(String) }
110
- assume { body.is_a?(String) }
111
- assume { views.nil? || views.is_a?(String) || views.is_a?(Array) }
112
- assume { tags.nil? || tags.is_a?(String) || tags.is_a?(Array) }
113
- assume { blurb.nil? || blurb.is_a?(String) }
114
- assume { @repo.is_a?(Scriptorium::Repo) }
124
+ msg = "title must be a String, got #{title.class}"
125
+ assume(msg) { title.is_a?(String) }
126
+ msg = "body must be a String, got #{body.class}"
127
+ assume(msg) { body.is_a?(String) }
128
+ msg = "views must be nil, String, or Array, got #{views.class}"
129
+ assume(msg) { views.nil? || views.is_a?(String) || views.is_a?(Array) }
130
+ msg = "tags must be nil, String, or Array, got #{tags.class}"
131
+ assume(msg) { tags.nil? || tags.is_a?(String) || tags.is_a?(Array) }
132
+ msg = "blurb must be nil or String, got #{blurb.class}"
133
+ assume(msg) { blurb.nil? || blurb.is_a?(String) }
134
+ msg = "@repo must be a Scriptorium::Repo, got #{@repo.class}"
135
+ assume(msg) { @repo.is_a?(Scriptorium::Repo) }
115
136
 
116
137
  views ||= @repo.current_view&.name
117
- raise "No view specified and no current view set" if views.nil?
138
+ raise ViewTargetNil if views.nil?
118
139
 
119
140
  post = @repo.create_post(
120
141
  title: title,
@@ -132,7 +153,7 @@ class Scriptorium::API
132
153
  # Draft management
133
154
  def draft(title: nil, body: nil, views: nil, tags: nil, blurb: nil)
134
155
  views ||= @repo.current_view&.name
135
- raise "No view specified and no current view set" if views.nil?
156
+ raise ViewTargetNil if views.nil?
136
157
 
137
158
  @repo.create_draft(
138
159
  title: title,
@@ -145,7 +166,7 @@ class Scriptorium::API
145
166
 
146
167
  def create_draft(title: nil, body: nil, views: nil, tags: nil, blurb: nil)
147
168
  views ||= @repo.current_view&.name
148
- raise "No view specified and no current view set" if views.nil?
169
+ raise ViewTargetNil if views.nil?
149
170
 
150
171
  @repo.create_draft(
151
172
  title: title,
@@ -156,6 +177,22 @@ class Scriptorium::API
156
177
  )
157
178
  end
158
179
 
180
+ def create_page(view_name, page_name, title, content)
181
+ view = @repo.lookup_view(view_name)
182
+ raise ViewTargetNil if view.nil?
183
+
184
+ page_content = <<~LT3
185
+ .title #{title}
186
+
187
+ #{content}
188
+ LT3
189
+
190
+ page_file = "#{@repo.root}/views/#{view_name}/pages/#{page_name}.lt3"
191
+ write_file(page_file, page_content)
192
+
193
+ page_name
194
+ end
195
+
159
196
  def finish_draft(draft_path)
160
197
  @repo.finish_draft(draft_path)
161
198
  end
@@ -163,17 +200,27 @@ class Scriptorium::API
163
200
  # Generation
164
201
  def generate_front_page(view = nil)
165
202
  view ||= @repo.current_view&.name
166
- raise "No view specified and no current view set" if view.nil?
203
+ raise ViewTargetNil if view.nil?
167
204
 
168
205
  @repo.generate_front_page(view)
169
206
  end
170
207
 
171
208
  def generate_post_index(view = nil)
172
209
  view ||= @repo.current_view&.name
173
- raise "No view specified and no current view set" if view.nil?
210
+ raise ViewTargetNil if view.nil?
174
211
 
212
+ # Delegate to the view/repo implementation to ensure correct table layout
213
+ # and formatted dates via View#post_index_entry and format_date
175
214
  @repo.generate_post_index(view)
176
215
  end
216
+
217
+ # Note: post_index_entry handled by View#post_index_entry
218
+
219
+ private def substitute(post, template)
220
+ # Use the same substitution system as helpers - text % vars
221
+ vars = post.vars
222
+ template % vars
223
+ end
177
224
 
178
225
  def generate_post(post_id)
179
226
  # Check if the post directory exists first
@@ -184,7 +231,7 @@ class Scriptorium::API
184
231
  else
185
232
  # Try to find the post through normal means
186
233
  post = @repo.post(post_id)
187
- raise "Post not found" if post.nil?
234
+ raise CannotGetPost("Post with ID #{post_id} not found") if post.nil?
188
235
 
189
236
  @repo.generate_post(post_id)
190
237
  end
@@ -195,31 +242,171 @@ class Scriptorium::API
195
242
  end
196
243
 
197
244
  # Publication system
198
- def publish_post(num)
245
+ def publish_post(num, view = nil)
199
246
  check_invariants
200
- assume { num.is_a?(Integer) }
201
- assume { @repo.is_a?(Scriptorium::Repo) }
247
+ msg = "num must be an Integer, got #{num.class}"
248
+ assume(msg) { num.is_a?(Integer) }
249
+ msg = "@repo must be a Scriptorium::Repo, got #{@repo.class}"
250
+ assume(msg) { @repo.is_a?(Scriptorium::Repo) }
202
251
 
203
- post = @repo.publish_post(num)
252
+ post = @repo.publish_post(num, view)
204
253
 
205
254
  verify { post.is_a?(Scriptorium::Post) }
206
255
  check_invariants
207
256
  post
208
257
  end
258
+
259
+ def unpublish_post(num, view = nil)
260
+ check_invariants
261
+ msg = "num must be an Integer, got #{num.class}"
262
+ assume(msg) { num.is_a?(Integer) }
263
+ msg = "@repo must be a Scriptorium::Repo, got #{@repo.class}"
264
+ assume(msg) { @repo.is_a?(Scriptorium::Repo) }
265
+
266
+ @repo.unpublish_post(num, view)
267
+
268
+ check_invariants
269
+ end
209
270
 
210
- def post_published?(num)
211
- @repo.post_published?(num)
271
+ def post_published?(num, view = nil)
272
+ @repo.post_published?(num, view)
273
+ end
274
+
275
+ # Deployment state management
276
+ def mark_post_deployed(num, view = nil)
277
+ check_invariants
278
+ msg = "num must be an Integer, got #{num.class}"
279
+ assume(msg) { num.is_a?(Integer) }
280
+ msg = "@repo must be a Scriptorium::Repo, got #{@repo.class}"
281
+ assume(msg) { @repo.is_a?(Scriptorium::Repo) }
282
+
283
+ @repo.mark_post_deployed(num, view)
284
+
285
+ check_invariants
286
+ end
287
+
288
+ def mark_post_undeployed(num, view = nil)
289
+ check_invariants
290
+ msg = "num must be an Integer, got #{num.class}"
291
+ assume(msg) { num.is_a?(Integer) }
292
+ msg = "@repo must be a Scriptorium::Repo, got #{@repo.class}"
293
+ assume(msg) { @repo.is_a?(Scriptorium::Repo) }
294
+
295
+ @repo.mark_post_undeployed(num, view)
296
+
297
+ check_invariants
298
+ end
299
+
300
+ def post_deployed?(num, view = nil)
301
+ @repo.post_deployed?(num, view)
302
+ end
303
+
304
+ def get_deployed_posts(view = nil)
305
+ view ||= @repo.current_view&.name
306
+ @repo.get_deployed_posts(view)
307
+ end
308
+
309
+ def get_post_states(view = nil)
310
+ view ||= @repo.current_view&.name
311
+ raise ViewTargetNil if view.nil?
312
+
313
+ # Get normal posts
314
+ posts = @repo.all_posts(view)
315
+ states = {}
316
+
317
+ # Add normal posts to states
318
+ posts.each do |post|
319
+ published = post_published?(post.id, view)
320
+ deployed = post_deployed?(post.id, view)
321
+ deleted = @repo.post_deleted?(post.id)
322
+
323
+ # Create concise state representation
324
+ state = ""
325
+ state += "P" if published
326
+ state += "D" if deployed
327
+ state += "X" if deleted
328
+ state = "-" if state.empty?
329
+
330
+ states[post.id] = {
331
+ id: post.id,
332
+ title: post.title,
333
+ state: state,
334
+ published: published,
335
+ deployed: deployed,
336
+ deleted: deleted
337
+ }
338
+ end
339
+
340
+ # Add deleted posts that were in this view
341
+ deleted_posts = @repo.all_posts_including_deleted(view)
342
+ deleted_posts.each do |post|
343
+ if @repo.post_deleted?(post.id)
344
+ states[post.id] = {
345
+ id: post.id,
346
+ title: post.title,
347
+ state: "X",
348
+ published: false,
349
+ deployed: false,
350
+ deleted: true
351
+ }
352
+ end
353
+ end
354
+
355
+ states
212
356
  end
357
+
358
+ def delete_post(num)
359
+ @repo.delete_post(num)
360
+ end
361
+
362
+ def undelete_post(num)
363
+ @repo.undelete_post(num)
364
+ end
365
+
366
+ def post_deleted?(num)
367
+ @repo.post_deleted?(num)
368
+ end
369
+
213
370
 
214
- def get_published_posts(view = nil)
371
+
372
+ def undeploy_post(num, view = nil)
215
373
  view ||= @repo.current_view&.name
216
- @repo.get_published_posts(view)
374
+ raise ViewTargetNil if view.nil?
375
+
376
+ # Check if post is actually deployed
377
+ unless post_deployed?(num, view)
378
+ puts "Post #{num} is not deployed in view '#{view}'"
379
+ return false
380
+ end
381
+
382
+ # Mark as undeployed
383
+ mark_post_undeployed(num, view)
384
+
385
+ # Regenerate the post
386
+ @repo.generate_post(num)
387
+
388
+ # Redeploy to update the server
389
+ deploy(view)
390
+
391
+ puts "Post #{num} undeployed and redeployed in view '#{view}'"
392
+ true
217
393
  end
218
394
 
219
395
  # Post retrieval
220
- def posts(view = nil)
396
+ def posts(view = nil, include_deleted: false, published: false)
221
397
  view ||= @repo.current_view&.name
222
- @repo.all_posts(view)
398
+ if include_deleted
399
+ posts = @repo.all_posts_including_deleted(view)
400
+ else
401
+ posts = @repo.all_posts(view)
402
+ end
403
+
404
+ # Filter by published status if requested
405
+ if published
406
+ posts = posts.select { |post| post_published?(post.id, view) }
407
+ end
408
+
409
+ posts
223
410
  end
224
411
 
225
412
  def post_attrs(post_id, *keys)
@@ -257,10 +444,10 @@ class Scriptorium::API
257
444
  def unlink_post(id, view = nil)
258
445
  # Remove post from a specific view (or current view if none specified)
259
446
  view ||= @repo.current_view&.name
260
- raise "No view specified and no current view set" if view.nil?
447
+ raise ViewTargetNil if view.nil?
261
448
 
262
449
  post = @repo.post(id)
263
- raise "Post not found" if post.nil?
450
+ raise CannotGetPost("Post with ID #{id} not found") if post.nil?
264
451
 
265
452
  # Get current views from metadata (split string into array)
266
453
  current_views = post.views.strip.split(/\s+/)
@@ -280,10 +467,10 @@ class Scriptorium::API
280
467
  def link_post(id, view = nil)
281
468
  # Add post to a specific view (or current view if none specified)
282
469
  view ||= @repo.current_view&.name
283
- raise "No view specified and no current view set" if view.nil?
470
+ raise ViewTargetNil if view.nil?
284
471
 
285
472
  post = @repo.post(id)
286
- raise "Post not found" if post.nil?
473
+ raise CannotGetPost("Post with ID #{id} not found") if post.nil?
287
474
 
288
475
  current_views = post.views.strip.split(/\s+/)
289
476
  new_views = current_views.include?(view) ? current_views : current_views + [view]
@@ -309,7 +496,7 @@ class Scriptorium::API
309
496
  def post_add_tag(id, tag)
310
497
  # Add a tag to a post
311
498
  post = @repo.post(id)
312
- raise "Post not found" if post.nil?
499
+ raise CannotGetPost("Post with ID #{id} not found") if post.nil?
313
500
 
314
501
  # Get current tags from metadata (split comma-separated string into array)
315
502
  current_tags = post.tags.strip.split(/,\s*/)
@@ -329,7 +516,7 @@ class Scriptorium::API
329
516
  def post_remove_tag(id, tag)
330
517
  # Remove a tag from a post
331
518
  post = @repo.post(id)
332
- raise "Post not found" if post.nil?
519
+ raise CannotGetPost("Post with ID #{id} not found") if post.nil?
333
520
 
334
521
  # Get current tags from metadata (split comma-separated string into array)
335
522
  current_tags = post.tags.strip.split(/,\s*/)
@@ -348,12 +535,83 @@ class Scriptorium::API
348
535
 
349
536
  # Theme management
350
537
  def themes_available
538
+ themes = []
539
+ themes_dir = @repo.root/:themes
540
+
541
+ if Dir.exist?(themes_dir)
542
+ Dir.children(themes_dir).each do |item|
543
+ next if item == "system.txt" || item.start_with?(".")
544
+ next unless Dir.exist?(themes_dir/item)
545
+ themes << item
546
+ end
547
+ end
548
+
549
+ themes
550
+ end
551
+
552
+ def system_themes
553
+ themes = []
554
+ system_file = @repo.root/:themes/"system.txt"
555
+
556
+ if File.exist?(system_file)
557
+ themes = read_file(system_file, lines: true, chomp: true)
558
+ end
559
+
560
+ themes
561
+ end
562
+
563
+ def user_themes
564
+ themes = []
351
565
  themes_dir = @repo.root/:themes
352
- return [] unless Dir.exist?(themes_dir)
353
- Dir.children(themes_dir).select { |d| Dir.exist?(themes_dir/d) }
566
+ system_themes_list = system_themes
567
+
568
+ if Dir.exist?(themes_dir)
569
+ Dir.children(themes_dir).each do |item|
570
+ next if item == "system.txt" || item.start_with?(".")
571
+ next unless Dir.exist?(themes_dir/item)
572
+ next if system_themes_list.include?(item)
573
+ themes << item
574
+ end
575
+ end
576
+
577
+ themes
578
+ end
579
+
580
+ def theme_exists?(theme_name)
581
+ # Check if theme name exists in themes directory
582
+ themes = themes_available
583
+ themes.include?(theme_name)
584
+ end
585
+
586
+ def clone_theme(source_theme, new_name)
587
+ # Validate source theme exists
588
+ unless theme_exists?(source_theme)
589
+ raise ThemeNotFound(source_theme)
590
+ end
591
+
592
+ # Validate new name doesn't exist
593
+ if theme_exists?(new_name)
594
+ raise ThemeAlreadyExists(new_name)
595
+ end
596
+
597
+ # Validate new name format (alphanumeric, hyphen, underscore)
598
+ unless new_name.match?(/^[a-zA-Z0-9_-]+$/)
599
+ raise ThemeNameInvalid(new_name)
600
+ end
601
+
602
+ source_dir = @repo.root/:themes/source_theme
603
+ target_dir = @repo.root/:themes/new_name
604
+
605
+ # Copy theme directory
606
+ require 'fileutils'
607
+ FileUtils.cp_r(source_dir, target_dir)
608
+
609
+ # Cloned themes become user themes (not system themes)
610
+ # No need to modify system.txt
611
+
612
+ new_name
354
613
  end
355
614
 
356
- # Widget management
357
615
  def widgets_available
358
616
  widgets_file = @repo.root/:config/"widgets.txt"
359
617
  return [] unless File.exist?(widgets_file)
@@ -365,13 +623,13 @@ class Scriptorium::API
365
623
  # widget_name: string name of the widget (e.g., "links", "news")
366
624
  # Returns true on success, raises error on failure
367
625
 
368
- raise "No current view set" if @repo.current_view.nil?
369
- raise "Widget name cannot be nil" if widget_name.nil?
370
- raise "Widget name cannot be empty" if widget_name.to_s.strip.empty?
626
+ raise ViewTargetNil if @repo.current_view.nil?
627
+ raise WidgetNameNil if widget_name.nil?
628
+ raise WidgetsArgEmpty if widget_name.to_s.strip.empty?
371
629
 
372
630
  # Validate widget name format
373
631
  unless widget_name.to_s.match?(/^[a-zA-Z0-9_]+$/)
374
- raise "Invalid widget name: #{widget_name} (must be alphanumeric and underscore only)"
632
+ raise WidgetNameInvalid(widget_name)
375
633
  end
376
634
 
377
635
  # Convert to class name (capitalize first letter)
@@ -381,7 +639,7 @@ class Scriptorium::API
381
639
  begin
382
640
  widget_class = eval("Scriptorium::Widget::#{widget_class_name}")
383
641
  rescue NameError
384
- raise "Widget class not found: Scriptorium::Widget::#{widget_class_name}"
642
+ raise CannotBuildWidget("Widget class not found: Scriptorium::Widget::#{widget_class_name}")
385
643
  end
386
644
 
387
645
  # Create widget instance and generate
@@ -395,20 +653,20 @@ class Scriptorium::API
395
653
 
396
654
  def edit_layout(view = nil)
397
655
  view ||= @repo.current_view&.name
398
- raise "No view specified and no current view set" if view.nil?
656
+ raise ViewTargetNil if view.nil?
399
657
  edit_file("views/#{view}/layout.txt")
400
658
  end
401
659
 
402
660
  def edit_config(view = nil)
403
661
  view ||= @repo.current_view&.name
404
- raise "No view specified and no current view set" if view.nil?
662
+ raise ViewTargetNil if view.nil?
405
663
  edit_file("views/#{view}/config.txt")
406
664
  end
407
665
 
408
666
  def edit_widget_data(view = nil, widget)
409
667
  view ||= @repo.current_view&.name
410
- raise "No view specified and no current view set" if view.nil?
411
- raise "Widget name cannot be nil" if widget.nil?
668
+ raise ViewTargetNil if view.nil?
669
+ raise WidgetNameNil if widget.nil?
412
670
  edit_file("views/#{view}/widgets/#{widget}/list.txt")
413
671
  end
414
672
 
@@ -420,15 +678,49 @@ class Scriptorium::API
420
678
  edit_file("config/deploy.txt")
421
679
  end
422
680
 
423
- def edit_post(post_id)
681
+ def edit_post(post_id, mock: false)
682
+ # Check if post is deleted first
683
+ if post_deleted?(post_id)
684
+ raise PostDeleted, "Post #{post_id} is deleted"
685
+ end
686
+
424
687
  post = @repo.post(post_id)
425
- source_path = "posts/#{post.num}/source.lt3"
426
- body_path = "posts/#{post.num}/body.html"
688
+ source_path = @repo.root/"posts/#{post.num}/source.lt3"
689
+ body_path = @repo.root/"posts/#{post.num}/body.html"
427
690
 
691
+ # Save checksum before edit
428
692
  if File.exist?(source_path)
429
- edit_file(source_path)
693
+ before_checksum = Digest::MD5.file(source_path).hexdigest
694
+
695
+ if mock.is_a?(Array) && mock.include?(:checksum)
696
+ # Use mock checksum for testing
697
+ after_checksum = mock[mock.index(:checksum) + 1]
698
+ else
699
+ edit_file(source_path) unless mock
700
+ after_checksum = Digest::MD5.file(source_path).hexdigest
701
+ end
702
+ else
703
+ raise "Cannot edit post #{post_id}: source.lt3 file not found"
704
+ end
705
+
706
+ # Check if file was actually modified
707
+ if before_checksum != after_checksum
708
+ # Mark as unpublished and undeployed in all views
709
+ @repo.views.each do |view|
710
+ if post_deployed?(post_id, view.name)
711
+ mark_post_undeployed(post_id, view.name)
712
+ end
713
+ if post_published?(post_id, view.name)
714
+ unpublish_post(post_id, view.name)
715
+ end
716
+ end
717
+
718
+ # Regenerate the post
719
+ @repo.generate_post(post_id)
720
+
721
+ true # Changes were made
430
722
  else
431
- edit_file(body_path)
723
+ false # No changes
432
724
  end
433
725
  end
434
726
 
@@ -436,10 +728,17 @@ class Scriptorium::API
436
728
 
437
729
  def edit_file(path)
438
730
  # Input validation
439
- raise CannotEditFilePathNil if path.nil?
440
- raise CannotEditFilePathEmpty if path.to_s.strip.empty?
731
+ raise EditFilePathNil if path.nil?
732
+ raise EditFilePathEmpty if path.to_s.strip.empty?
733
+
734
+ # Try to use the TUI's editor configuration first
735
+ editor_file = @repo.root/"config/editor.txt"
736
+ editor = if File.exist?(editor_file)
737
+ read_file(editor_file).strip
738
+ else
739
+ ENV['EDITOR'] || 'vim'
740
+ end
441
741
 
442
- editor = ENV['EDITOR'] || 'vim'
443
742
  system!(editor, path)
444
743
  end
445
744
 
@@ -479,7 +778,7 @@ class Scriptorium::API
479
778
  when :blurb
480
779
  post.blurb
481
780
  else
482
- raise "Unknown search field: #{field}"
781
+ raise UnknownSearchField(field)
483
782
  end
484
783
 
485
784
  # Check if the pattern matches
@@ -501,13 +800,215 @@ class Scriptorium::API
501
800
  # Generation
502
801
  def generate_view(view = nil)
503
802
  view ||= @repo.current_view&.name
504
- raise "No view specified and no current view set" if view.nil?
803
+ raise ViewTargetNil if view.nil?
804
+
805
+ # Guard: skip regeneration if outputs are newer than inputs (simple mtime check)
806
+ begin
807
+ v = @repo.lookup_view(view)
808
+ view_dir = v.dir
809
+ output_dir = v.dir/:output
810
+ output_index = output_dir/:index.html
811
+ output_post_index = output_dir/:post_index.html
812
+
813
+ latest_input = Time.at(0)
814
+
815
+ # Posts: only count source.lt3 and post assets as inputs
816
+ posts_dir = @repo.root/:posts
817
+ if Dir.exist?(posts_dir)
818
+ Dir.glob("#{posts_dir}/[0-9][0-9][0-9][0-9]/source.lt3").each do |p|
819
+ m = File.mtime(p) rescue nil
820
+ latest_input = m if m && m > latest_input
821
+ end
822
+ Dir.glob("#{posts_dir}/[0-9][0-9][0-9][0-9]/assets/**/*").each do |p|
823
+ next unless File.file?(p)
824
+ m = File.mtime(p) rescue nil
825
+ latest_input = m if m && m > latest_input
826
+ end
827
+ end
828
+
829
+ # View inputs (exclude generated output/*)
830
+ if Dir.exist?(view_dir)
831
+ Dir.glob("#{view_dir}/**/*").each do |p|
832
+ next unless File.file?(p)
833
+ next if p.start_with?(output_dir.to_s)
834
+ m = File.mtime(p) rescue nil
835
+ latest_input = m if m && m > latest_input
836
+ end
837
+ end
838
+
839
+ # Global assets
840
+ global_assets = @repo.root/:assets
841
+ if Dir.exist?(global_assets)
842
+ Dir.glob("#{global_assets}/**/*").each do |p|
843
+ next unless File.file?(p)
844
+ m = File.mtime(p) rescue nil
845
+ latest_input = m if m && m > latest_input
846
+ end
847
+ end
848
+
849
+ # Themes (inputs that can affect templates)
850
+ themes_dir = @repo.root/:themes
851
+ if Dir.exist?(themes_dir)
852
+ Dir.glob("#{themes_dir}/**/*").each do |p|
853
+ next unless File.file?(p)
854
+ m = File.mtime(p) rescue nil
855
+ latest_input = m if m && m > latest_input
856
+ end
857
+ end
858
+
859
+ # Skip regeneration only if both primary outputs exist and are newer than inputs
860
+ if File.exist?(output_index) && File.exist?(output_post_index)
861
+ out_mtime = File.mtime(output_index) rescue nil
862
+ out2_mtime = File.mtime(output_post_index) rescue nil
863
+ if out_mtime && out2_mtime && out_mtime >= latest_input && out2_mtime >= latest_input
864
+ return true
865
+ end
866
+ end
867
+ rescue => _e
868
+ # If guard fails, fall through to full generation
869
+ end
870
+
871
+ # Copy all global assets to view assets
872
+ copy_global_assets_to_view(view)
873
+
874
+ # Copy post assets to view assets
875
+ copy_post_assets_to_view(view)
505
876
 
877
+ # Check for stale posts and regenerate them before view generation
878
+ regenerate_stale_posts(view)
879
+
880
+ # Copy view assets to output directory for web serving
881
+ copy_view_assets_to_output(view)
882
+
883
+ # Generate post index (with correct table structure and formatted dates)
884
+ @repo.generate_post_index(view)
885
+
506
886
  @repo.generate_front_page(view)
507
887
  true
508
888
  end
509
889
 
890
+ def upload_asset(file_path, target = 'global', target_id = nil, filename: nil, **kwargs)
891
+ # Handle backward compatibility with keyword arguments
892
+ if kwargs.any?
893
+ target = kwargs[:target] || target
894
+ target_id = kwargs[:view] || target_id
895
+ end
896
+ unless File.exist?(file_path)
897
+ raise FileNotFoundError(file_path)
898
+ end
899
+
900
+ filename ||= File.basename(file_path)
901
+
902
+ # Determine target directory
903
+ target_dir = case target
904
+ when 'global'
905
+ @repo.root/"assets"
906
+ when 'library'
907
+ @repo.root/"assets"/"library"
908
+ when 'view'
909
+ target_id ||= @repo.current_view&.name
910
+ raise ViewTargetNil if target_id.nil?
911
+ @repo.root/"views"/target_id/"assets"
912
+ when 'post'
913
+ raise ArgumentError, "Post ID required for post uploads" if target_id.nil?
914
+ post_id = target_id.to_i
915
+ post_num = d4(post_id)
916
+ @repo.root/"posts"/post_num/"assets"
917
+ else
918
+ raise InvalidFormatError("target", target)
919
+ end
920
+
921
+ # Create target directory if it doesn't exist
922
+ FileUtils.mkdir_p(target_dir)
923
+
924
+ # Copy the file
925
+ target_file = target_dir/filename
926
+ FileUtils.cp(file_path, target_file)
927
+
928
+ target_file
929
+ end
930
+
931
+ def copy_global_assets_to_view(view_name)
932
+ view = @repo.lookup_view(view_name)
933
+ return unless view
934
+
935
+ view_assets_dir = view.dir/:assets
936
+ global_assets_dir = @repo.root/:assets
937
+
938
+ # Ensure view assets directory exists
939
+ FileUtils.mkdir_p(view_assets_dir) unless Dir.exist?(view_assets_dir)
940
+
941
+ # Copy all global assets (recursively) to view assets, preserving structure
942
+ if Dir.exist?(global_assets_dir)
943
+ Dir.glob("#{global_assets_dir}/**/*").each do |global_path|
944
+ next unless File.file?(global_path)
945
+ rel = Pathname.new(global_path).relative_path_from(Pathname.new(global_assets_dir))
946
+ dest = view_assets_dir/rel
947
+ FileUtils.mkdir_p(File.dirname(dest))
948
+ FileUtils.cp(global_path, dest) unless File.exist?(dest)
949
+ end
950
+ end
951
+ end
952
+
953
+ def copy_post_assets_to_view(view_name)
954
+ view = @repo.lookup_view(view_name)
955
+ return unless view
956
+
957
+ view_assets_dir = view.dir/:assets
958
+
959
+ # Get all posts associated with this view
960
+ posts = @repo.all_posts.select { |post| post.views&.include?(view_name) }
961
+
962
+ posts.each do |post|
963
+ post_assets_dir = @repo.root/"posts"/post.num/"assets"
964
+ view_post_assets_dir = view_assets_dir/"posts"/post.num
965
+
966
+ # Skip if post has no assets
967
+ next unless Dir.exist?(post_assets_dir)
968
+
969
+ # Create view post assets directory
970
+ FileUtils.mkdir_p(view_post_assets_dir)
971
+
972
+ # Copy all post assets to view post assets directory
973
+ Dir.glob("#{post_assets_dir}/*").each do |post_asset|
974
+ next unless File.file?(post_asset)
975
+ filename = File.basename(post_asset)
976
+ view_asset_path = view_post_assets_dir/filename
977
+
978
+ # Copy if view asset doesn't exist (don't overwrite)
979
+ FileUtils.cp(post_asset, view_asset_path) unless File.exist?(view_asset_path)
980
+ end
981
+ end
982
+ end
983
+
984
+ def copy_view_assets_to_output(view_name)
985
+ view = @repo.lookup_view(view_name)
986
+ return unless view
987
+
988
+ view_assets_dir = view.dir/:assets
989
+ output_assets_dir = view.dir/:output/:assets
990
+
991
+ # Skip if view has no assets
992
+ return unless Dir.exist?(view_assets_dir)
993
+
994
+ # Create output assets directory
995
+ FileUtils.mkdir_p(output_assets_dir)
510
996
 
997
+ # Copy all view assets to output assets directory
998
+ Dir.glob("#{view_assets_dir}/**/*").each do |asset_path|
999
+ next unless File.file?(asset_path)
1000
+
1001
+ # Calculate relative path from view_assets_dir
1002
+ relative_path = Pathname.new(asset_path).relative_path_from(Pathname.new(view_assets_dir))
1003
+ output_asset_path = output_assets_dir/relative_path
1004
+
1005
+ # Create parent directory if needed
1006
+ FileUtils.mkdir_p(File.dirname(output_asset_path))
1007
+
1008
+ # Copy the asset
1009
+ FileUtils.cp(asset_path, output_asset_path)
1010
+ end
1011
+ end
511
1012
 
512
1013
  # Draft management
513
1014
  def drafts
@@ -527,17 +1028,17 @@ class Scriptorium::API
527
1028
  # Delete a draft file
528
1029
  # draft_path: path to the draft file (e.g., from drafts() method)
529
1030
 
530
- raise "Draft path cannot be nil" if draft_path.nil?
531
- raise "Draft path cannot be empty" if draft_path.to_s.strip.empty?
1031
+ raise DraftPathNil if draft_path.nil?
1032
+ raise DraftPathEmpty if draft_path.to_s.strip.empty?
532
1033
 
533
1034
  # Ensure it's actually a draft file
534
1035
  unless draft_path.to_s.end_with?('-draft.lt3')
535
- raise "Not a valid draft file: #{draft_path}"
1036
+ raise DraftFileInvalid(draft_path)
536
1037
  end
537
1038
 
538
1039
  # Ensure it exists
539
1040
  unless File.exist?(draft_path)
540
- raise "Draft file not found: #{draft_path}"
1041
+ raise DraftFileNotFound(draft_path)
541
1042
  end
542
1043
 
543
1044
  # Delete the file
@@ -545,6 +1046,45 @@ class Scriptorium::API
545
1046
  true
546
1047
  end
547
1048
 
1049
+ private def regenerate_stale_posts(view)
1050
+ # Get all posts for this view
1051
+ posts = @repo.all_posts(view)
1052
+
1053
+ posts.each do |post|
1054
+ source_file = post.dir/"source.lt3"
1055
+ body_file = post.dir/"body.html"
1056
+
1057
+ # Skip if source file doesn't exist
1058
+ next unless File.exist?(source_file)
1059
+
1060
+ # Skip if body file doesn't exist (post needs initial generation)
1061
+ next unless File.exist?(body_file)
1062
+
1063
+ # Compare modification times
1064
+ source_mtime = File.mtime(source_file)
1065
+ body_mtime = File.mtime(body_file)
1066
+
1067
+ # If source is newer than body, regenerate the post
1068
+ if source_mtime > body_mtime
1069
+ @repo.generate_post(post.id)
1070
+ next
1071
+ end
1072
+
1073
+ # Check if any assets are newer than body file
1074
+ assets_dir = post.dir/"assets"
1075
+ if Dir.exist?(assets_dir)
1076
+ asset_files = Dir.glob("#{assets_dir}/*")
1077
+ asset_files.each do |asset_file|
1078
+ next unless File.file?(asset_file)
1079
+ if File.mtime(asset_file) > body_mtime
1080
+ @repo.generate_post(post.id)
1081
+ break
1082
+ end
1083
+ end
1084
+ end
1085
+ end
1086
+ end
1087
+
548
1088
  private def extract_title_from_draft(draft_path)
549
1089
  # Quick scan for .title line in draft file
550
1090
  return "Untitled" unless File.exist?(draft_path)
@@ -621,20 +1161,1213 @@ class Scriptorium::API
621
1161
  # # finish_draft + generate_post combined?
622
1162
  # end
623
1163
 
624
- # Utility methods
625
-
626
- # Convenience workflow methods
627
-
628
- # # Delegate common repo methods
629
- # def method_missing(method, *args, &block)
630
- # if @repo.respond_to?(method)
631
- # @repo.send(method, *args, &block)
632
- # else
633
- # super
634
- # end
635
- # end
636
- #
637
- # def respond_to_missing?(method, include_private = false)
638
- # @repo.respond_to?(method, include_private) || super
639
- # end
1164
+ # Asset management methods
1165
+
1166
+ def list_assets(target = 'global', target_id = nil, include_gem: true, **kwargs)
1167
+ # Handle backward compatibility with keyword arguments
1168
+ if kwargs.any?
1169
+ target = kwargs[:target] || target
1170
+ target_id = kwargs[:view] || target_id
1171
+ end
1172
+ assets = []
1173
+
1174
+ case target
1175
+ when 'view'
1176
+ target_id ||= @repo.current_view&.name
1177
+ raise ViewTargetNil if target_id.nil?
1178
+ assets_dir = @repo.root/"views"/target_id/"assets"
1179
+ if Dir.exist?(assets_dir)
1180
+ Dir.glob(assets_dir/"*").each do |file|
1181
+ next unless File.file?(file)
1182
+ assets << build_asset_info(file)
1183
+ end
1184
+ end
1185
+ when 'post'
1186
+ raise ArgumentError, "Post ID required for post assets" if target_id.nil?
1187
+ post_id = target_id.to_i
1188
+ post_num = d4(post_id)
1189
+ assets_dir = @repo.root/"posts"/post_num/"assets"
1190
+ if Dir.exist?(assets_dir)
1191
+ Dir.glob(assets_dir/"*").each do |file|
1192
+ next unless File.file?(file)
1193
+ assets << build_asset_info(file)
1194
+ end
1195
+ end
1196
+ when 'global'
1197
+ assets_dir = @repo.root/"assets"
1198
+ if Dir.exist?(assets_dir)
1199
+ Dir.glob(assets_dir/"*").each do |file|
1200
+ next unless File.file?(file)
1201
+ assets << build_asset_info(file)
1202
+ end
1203
+ end
1204
+ when 'library'
1205
+ assets_dir = @repo.root/"assets"/"library"
1206
+ if Dir.exist?(assets_dir)
1207
+ Dir.glob(assets_dir/"*").each do |file|
1208
+ next unless File.file?(file)
1209
+ assets << build_asset_info(file)
1210
+ end
1211
+ end
1212
+ when 'gem'
1213
+ if include_gem
1214
+ gem_spec = Gem.loaded_specs['scriptorium']
1215
+ if gem_spec
1216
+ gem_assets_dir = "#{gem_spec.full_gem_path}/assets"
1217
+ if Dir.exist?(gem_assets_dir)
1218
+ Dir.glob("#{gem_assets_dir}/**/*").each do |file|
1219
+ next unless File.file?(file)
1220
+ relative_path = file.sub("#{gem_assets_dir}/", "")
1221
+ assets << build_asset_info(file, relative_path)
1222
+ end
1223
+ end
1224
+ end
1225
+ end
1226
+ else
1227
+ raise InvalidFormatError("target", target)
1228
+ end
1229
+
1230
+ assets.sort_by { |asset| asset[:filename] }
1231
+ end
1232
+
1233
+ def get_asset_info(filename, target: 'global', view: nil, post: nil, include_gem: true)
1234
+ view ||= @repo.current_view&.name
1235
+ raise ViewTargetNil if target == 'view' && view.nil?
1236
+ raise ArgumentError, "Post ID required for post assets" if target == 'post' && post.nil?
1237
+
1238
+ case target
1239
+ when 'view'
1240
+ asset_path = @repo.root/"views"/view/"assets"/filename
1241
+ return build_asset_info(asset_path) if File.exist?(asset_path)
1242
+ when 'post'
1243
+ post_id = post.to_i
1244
+ post_num = d4(post_id)
1245
+ asset_path = @repo.root/"posts"/post_num/"assets"/filename
1246
+ return build_asset_info(asset_path) if File.exist?(asset_path)
1247
+ when 'global'
1248
+ asset_path = @repo.root/"assets"/filename
1249
+ return build_asset_info(asset_path) if File.exist?(asset_path)
1250
+ when 'library'
1251
+ asset_path = @repo.root/"assets"/"library"/filename
1252
+ return build_asset_info(asset_path) if File.exist?(asset_path)
1253
+ when 'gem'
1254
+ if include_gem
1255
+ gem_spec = Gem.loaded_specs['scriptorium']
1256
+ if gem_spec
1257
+ gem_asset_path = "#{gem_spec.full_gem_path}/assets/#{filename}"
1258
+ return build_asset_info(gem_asset_path, filename) if File.exist?(gem_asset_path)
1259
+ end
1260
+ end
1261
+ else
1262
+ raise InvalidFormatError("target", target)
1263
+ end
1264
+
1265
+ nil
1266
+ end
1267
+
1268
+ def asset_exists?(filename, target: 'global', view: nil, include_gem: true)
1269
+ !get_asset_info(filename, target: target, view: view, include_gem: include_gem).nil?
1270
+ end
1271
+
1272
+ def copy_asset(filename, from = 'global', to = 'view', from_id = nil, to_id = nil, **kwargs)
1273
+ # Handle backward compatibility with keyword arguments
1274
+ if kwargs.any?
1275
+ from = kwargs[:from] || from
1276
+ to = kwargs[:to] || to
1277
+ from_id = kwargs[:view] || from_id
1278
+ to_id = kwargs[:view] || to_id if to == 'view'
1279
+ end
1280
+ # Determine source path
1281
+ source_path = case from
1282
+ when 'gem'
1283
+ gem_spec = Gem.loaded_specs['scriptorium']
1284
+ if gem_spec
1285
+ "#{gem_spec.full_gem_path}/assets/#{filename}"
1286
+ else
1287
+ # Development environment fallback
1288
+ File.expand_path("assets/#{filename}")
1289
+ end
1290
+ when 'global'
1291
+ @repo.root/"assets"/filename
1292
+ when 'library'
1293
+ @repo.root/"assets"/"library"/filename
1294
+ when 'view'
1295
+ from_id ||= @repo.current_view&.name
1296
+ raise ViewTargetNil if from_id.nil?
1297
+ @repo.root/"views"/from_id/"assets"/filename
1298
+ when 'post'
1299
+ raise ArgumentError, "Post ID required for post assets" if from_id.nil?
1300
+ post_id = from_id.to_i
1301
+ post_num = d4(post_id)
1302
+ @repo.root/"posts"/post_num/"assets"/filename
1303
+ else
1304
+ raise InvalidFormatError("source", from)
1305
+ end
1306
+
1307
+ # Determine target path
1308
+ target_path = case to
1309
+ when 'global'
1310
+ @repo.root/"assets"/filename
1311
+ when 'library'
1312
+ @repo.root/"assets"/"library"/filename
1313
+ when 'view'
1314
+ to_id ||= @repo.current_view&.name
1315
+ raise ViewTargetNil if to_id.nil?
1316
+ @repo.root/"views"/to_id/"assets"/filename
1317
+ when 'post'
1318
+ raise ArgumentError, "Post ID required for post assets" if to_id.nil?
1319
+ post_id = to_id.to_i
1320
+ post_num = d4(post_id)
1321
+ @repo.root/"posts"/post_num/"assets"/filename
1322
+ else
1323
+ raise InvalidFormatError("target", to)
1324
+ end
1325
+
1326
+ # Validate source exists
1327
+ unless File.exist?(source_path)
1328
+ raise FileNotFoundError(source_path)
1329
+ end
1330
+
1331
+ # Create target directory and copy
1332
+ FileUtils.mkdir_p(File.dirname(target_path))
1333
+ FileUtils.cp(source_path, target_path)
1334
+
1335
+ target_path
1336
+ end
1337
+
1338
+
1339
+ def delete_asset(filename, target = 'global', target_id = nil, **kwargs)
1340
+ # Handle backward compatibility with keyword arguments
1341
+ if kwargs.any?
1342
+ target = kwargs[:target] || target
1343
+ target_id = kwargs[:view] || target_id
1344
+ end
1345
+ # Determine target file
1346
+ target_file = case target
1347
+ when 'global'
1348
+ @repo.root/"assets"/filename
1349
+ when 'library'
1350
+ @repo.root/"assets"/"library"/filename
1351
+ when 'view'
1352
+ target_id ||= @repo.current_view&.name
1353
+ raise ViewTargetNil if target_id.nil?
1354
+ @repo.root/"views"/target_id/"assets"/filename
1355
+ when 'post'
1356
+ raise ArgumentError, "Post ID required for post assets" if target_id.nil?
1357
+ post_id = target_id.to_i
1358
+ post_num = d4(post_id)
1359
+ @repo.root/"posts"/post_num/"assets"/filename
1360
+ else
1361
+ raise InvalidFormatError("target", target)
1362
+ end
1363
+
1364
+ unless File.exist?(target_file)
1365
+ raise FileNotFoundError(target_file)
1366
+ end
1367
+
1368
+ # Delete the file
1369
+ File.delete(target_file)
1370
+ true
1371
+ end
1372
+
1373
+ def get_asset_path(filename, target: 'global', view: nil, post: nil, include_gem: true)
1374
+ view ||= @repo.current_view&.name
1375
+ raise ViewTargetNil if target == 'view' && view.nil?
1376
+ raise ArgumentError, "Post ID required for post assets" if target == 'post' && post.nil?
1377
+
1378
+ case target
1379
+ when 'view'
1380
+ asset_path = @repo.root/"views"/view/"assets"/filename
1381
+ return asset_path.to_s if File.exist?(asset_path)
1382
+ when 'post'
1383
+ post_id = post.to_i
1384
+ post_num = d4(post_id)
1385
+ asset_path = @repo.root/"posts"/post_num/"assets"/filename
1386
+ return asset_path.to_s if File.exist?(asset_path)
1387
+ when 'global'
1388
+ asset_path = @repo.root/"assets"/filename
1389
+ return asset_path.to_s if File.exist?(asset_path)
1390
+ when 'library'
1391
+ asset_path = @repo.root/"assets"/"library"/filename
1392
+ return asset_path.to_s if File.exist?(asset_path)
1393
+ when 'gem'
1394
+ if include_gem
1395
+ gem_spec = Gem.loaded_specs['scriptorium']
1396
+ if gem_spec
1397
+ gem_asset_path = "#{gem_spec.full_gem_path}/assets/#{filename}"
1398
+ return gem_asset_path if File.exist?(gem_asset_path)
1399
+ end
1400
+ end
1401
+ else
1402
+ raise InvalidFormatError("target", target)
1403
+ end
1404
+
1405
+ nil
1406
+ end
1407
+
1408
+ def get_image_dimensions(file_path)
1409
+ return nil unless File.exist?(file_path)
1410
+
1411
+ # Check if it's an image file
1412
+ image_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp', '.svg']
1413
+ return nil unless image_extensions.any? { |ext| file_path.downcase.end_with?(ext) }
1414
+
1415
+ # Check if FastImage is available
1416
+ return nil unless defined?(FastImage)
1417
+
1418
+ dimensions = FastImage.size(file_path)
1419
+ return dimensions ? "#{dimensions[0]}×#{dimensions[1]}" : nil
1420
+ rescue => e
1421
+ # If FastImage fails, return nil
1422
+ return nil
1423
+ end
1424
+
1425
+ def get_asset_dimensions(filename, target: 'global', view: nil, include_gem: true)
1426
+ asset_info = get_asset_info(filename, target: target, view: view, include_gem: include_gem)
1427
+ asset_info&.dig(:dimensions)
1428
+ end
1429
+
1430
+ def get_asset_size(filename, target: 'global', view: nil, include_gem: true)
1431
+ asset_info = get_asset_info(filename, target: target, view: view, include_gem: include_gem)
1432
+ asset_info&.dig(:size)
1433
+ end
1434
+
1435
+ def get_asset_type(filename)
1436
+ return nil if filename.nil?
1437
+
1438
+ ext = File.extname(filename).downcase
1439
+ case ext
1440
+ when '.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp', '.svg'
1441
+ 'image'
1442
+ when '.pdf', '.doc', '.docx', '.txt', '.md'
1443
+ 'document'
1444
+ when '.mp4', '.avi', '.mov', '.wmv'
1445
+ 'video'
1446
+ when '.mp3', '.wav', '.flac'
1447
+ 'audio'
1448
+ else
1449
+ 'other'
1450
+ end
1451
+ end
1452
+
1453
+ def bulk_copy_assets(filenames, from: 'global', to: 'view', view: nil)
1454
+ view ||= @repo.current_view&.name
1455
+ raise ViewTargetNil if to == 'view' && view.nil?
1456
+
1457
+ results = []
1458
+ filenames.each do |filename|
1459
+ begin
1460
+ target_path = copy_asset(filename, from: from, to: to, view: view)
1461
+ results << { filename: filename, success: true, target: target_path }
1462
+ rescue => e
1463
+ results << { filename: filename, success: false, error: e.message }
1464
+ end
1465
+ end
1466
+
1467
+ results
1468
+ end
1469
+
1470
+ private def build_asset_info(file_path, relative_path = nil)
1471
+ filename = relative_path || File.basename(file_path)
1472
+ size = File.size(file_path)
1473
+ dimensions = get_image_dimensions(file_path) if get_asset_type(filename) == 'image'
1474
+
1475
+ {
1476
+ filename: filename,
1477
+ size: size,
1478
+ path: file_path.to_s,
1479
+ dimensions: dimensions,
1480
+ type: get_asset_type(filename)
1481
+ }
1482
+ end
1483
+
1484
+ # Deployment methods
1485
+
1486
+ def can_deploy?(view = nil)
1487
+ view ||= @repo.current_view&.name
1488
+ raise ViewTargetNil if view.nil?
1489
+ # Check deployment status
1490
+ status_file = @repo.root/"views"/view/"config"/"status.txt"
1491
+ return false unless File.exist?(status_file)
1492
+ status_content = read_commented_file(status_file)
1493
+ deploy_status = status_content.any? { |line| line.start_with?('deploy ') && line.split(/\s+/, 2)[1] == 'y' }
1494
+ return false unless deploy_status
1495
+ # Check if deploy.txt exists and has valid content
1496
+ deploy_file = @repo.root/"views"/view/"config"/"deploy.txt"
1497
+ return false unless File.exist?(deploy_file)
1498
+ # Basic validation of deploy.txt content
1499
+ deploy_content = read_file(deploy_file)
1500
+ required_fields = ['user', 'server', 'docroot', 'path']
1501
+ return false unless required_fields.all? { |field| deploy_content.include?(field) }
1502
+ # Parse deploy config to get server and user for SSH test
1503
+ deploy_config = parse_commented_file(deploy_file)
1504
+ # Check SSH connectivity
1505
+ server, user = deploy_config[:server], deploy_config[:user]
1506
+ ok = ssh_keys_configured?(server, user)
1507
+ return false if !ok
1508
+ true
1509
+ end
1510
+
1511
+ private def ssh_keys_configured?(server, user)
1512
+ # Try to run a simple command via SSH
1513
+ cmd = "ssh -o ConnectTimeout=5 -o BatchMode=yes #{user}@#{server} 'echo' >/dev/null 2>&1"
1514
+ result = system(cmd)
1515
+ result && $?.exitstatus == 0
1516
+ end
1517
+
1518
+ def deploy(view = nil, dry_run: false)
1519
+ view ||= @repo.current_view&.name
1520
+ raise ViewTargetNil if view.nil?
1521
+ raise DeploymentNotReady(view) unless can_deploy?(view)
1522
+
1523
+ # Get published posts that are not yet deployed
1524
+ published_posts = posts(view, published: true)
1525
+ undeployed_posts = published_posts.select { |post| !post_deployed?(post.id, view) }
1526
+
1527
+ # Always deploy the entire output directory, regardless of post status
1528
+
1529
+ # Read deployment configuration
1530
+ deploy_file = @repo.root/"views"/view/"config"/"deploy.txt"
1531
+ deploy_config = parse_commented_file(deploy_file)
1532
+
1533
+ # Validate required fields
1534
+ required_fields = [:user, :server, :docroot, :path]
1535
+ missing_fields = required_fields - deploy_config.keys
1536
+ missing = missing_fields.join(', ')
1537
+ raise DeploymentFieldsMissing(missing) unless missing.empty?
1538
+
1539
+ # Construct paths
1540
+ output_dir = @repo.root/"views"/view/"output"
1541
+ remote_path = "#{deploy_config[:user]}@#{deploy_config[:server]}:#{deploy_config[:docroot]}/#{deploy_config[:path]}"
1542
+
1543
+ # Build rsync command
1544
+ cmd = "rsync -r -z -l #{output_dir}/ #{remote_path}/"
1545
+
1546
+ if dry_run
1547
+ puts "DRY RUN: Would execute: #{cmd}"
1548
+ puts "Output directory: #{output_dir}"
1549
+ puts "Remote path: #{remote_path}"
1550
+ puts "Deployment config: #{deploy_config}"
1551
+ puts "Posts to deploy: #{undeployed_posts.map(&:id).join(', ')}"
1552
+ return true
1553
+ end
1554
+
1555
+ # Log deployment details to /tmp
1556
+ log_file = "/tmp/deployment.log"
1557
+ File.open(log_file, 'a') do |f|
1558
+ f.puts "=== DEPLOYMENT DEBUG #{Time.now} ==="
1559
+ f.puts " Source directory: #{output_dir}"
1560
+ f.puts " Remote path: #{remote_path}"
1561
+ f.puts " Rsync command: #{cmd}"
1562
+ f.puts " Source directory exists: #{Dir.exist?(output_dir)}"
1563
+ f.puts " Source files: #{Dir.children(output_dir).join(', ')}"
1564
+ f.puts " Current working directory: #{Dir.pwd}"
1565
+ f.puts " Repo root: #{@repo.root}"
1566
+ end
1567
+
1568
+ # Execute deployment
1569
+ result = system(cmd)
1570
+
1571
+ # Log rsync result
1572
+ File.open("/tmp/deployment.log", 'a') do |f|
1573
+ f.puts " Rsync result: #{result}"
1574
+ f.puts " Exit status: #{$?.exitstatus}"
1575
+ f.puts " Exit success: #{$?.success?}"
1576
+ end
1577
+
1578
+ if result
1579
+ # Mark successfully deployed posts as deployed
1580
+ undeployed_posts.each do |post|
1581
+ mark_post_deployed(post.id, view)
1582
+ end
1583
+
1584
+ true
1585
+ else
1586
+ raise DeploymentFailed($?.exitstatus)
1587
+ end
1588
+ end
1589
+
1590
+ # Parse deployment configuration file
1591
+ def parse_deploy_config(config_content)
1592
+ lines = config_content.strip.split("\n")
1593
+ config = {}
1594
+
1595
+ # Parse space-separated key-value format
1596
+ lines.each do |line|
1597
+ line = line.strip
1598
+ next if line.empty? || line.start_with?('#')
1599
+
1600
+ if line.match(/^(\w+)\s+(.+)$/)
1601
+ key = $1.strip
1602
+ value = $2.strip
1603
+ config[key] = value
1604
+ end
1605
+ end
1606
+
1607
+ # Return the config hash (or empty hash if no valid entries)
1608
+ config
1609
+ end
1610
+
1611
+ # Build rsync destination from deployment config
1612
+ def build_rsync_destination(config)
1613
+ if config['user'] && config['server'] && config['path']
1614
+ return "#{config['user']}@#{config['server']}:#{config['path']}"
1615
+ end
1616
+ nil
1617
+ end
1618
+
1619
+ # Validate rsync destination format
1620
+ def validate_rsync_destination(destination)
1621
+ destination =~ /^[^@]+@[^:]+:.+/
1622
+ end
1623
+
1624
+ # Execute deployment rsync with validation
1625
+ def execute_deploy_rsync(source_dir, destination)
1626
+ # Validate destination format
1627
+ unless validate_rsync_destination(destination)
1628
+ puts " ❌ Invalid destination format: #{destination}"
1629
+ puts " Expected format: user@server:path"
1630
+ return false
1631
+ end
1632
+
1633
+ # Log the rsync command
1634
+ cmd = "rsync -r -z -l #{source_dir}/ #{destination}/"
1635
+ puts " Executing: #{cmd}"
1636
+
1637
+ # Execute rsync
1638
+ result = system(cmd)
1639
+ puts " rsync completed with result: #{result}"
1640
+
1641
+ result
1642
+ end
1643
+
1644
+ # Backup system methods
1645
+
1646
+ def get_backup_directory
1647
+ repo_path = Pathname.new(@repo.root)
1648
+ repo_parent = repo_path.parent
1649
+ repo_name = repo_path.basename.to_s
1650
+ if repo_name == "scriptorium-TEST"
1651
+ repo_parent/"backup-scriptorium-TEST"
1652
+ else
1653
+ repo_parent/"backup-scriptorium"
1654
+ end
1655
+ end
1656
+
1657
+ def create_backup(type: :incremental, label: nil)
1658
+ check_invariants
1659
+ msg = "type must be :full or :incremental, got #{type}"
1660
+ assume(msg) { [:full, :incremental].include?(type) }
1661
+ msg = "@repo must be a Scriptorium::Repo, got #{@repo.class}"
1662
+ assume(msg) { @repo.is_a?(Scriptorium::Repo) }
1663
+
1664
+ backup_dir = get_backup_directory
1665
+ data_dir = backup_dir/"data"
1666
+ FileUtils.mkdir_p(data_dir)
1667
+
1668
+ # Sleep 1 second to ensure backup timestamp is clearly after all existing files
1669
+ sleep(1)
1670
+
1671
+ if type == :full
1672
+ # Full backup - copy entire repository
1673
+ temp_backup_path = data_dir/"temp-full-backup"
1674
+ FileUtils.mkdir_p(temp_backup_path)
1675
+ copy_repo_to_backup(temp_backup_path)
1676
+ else
1677
+ # Incremental backup - copy only changed files since last backup
1678
+ temp_backup_path = data_dir/"temp-incr-backup"
1679
+ FileUtils.mkdir_p(temp_backup_path)
1680
+ copy_changed_files_to_backup(temp_backup_path)
1681
+ end
1682
+
1683
+ # Record timestamp AFTER backup is created
1684
+ timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
1685
+ backup_name = "#{timestamp}-#{type == :full ? 'full' : 'incr'}"
1686
+
1687
+ # Create final backup directory
1688
+ final_backup_path = data_dir/backup_name
1689
+ FileUtils.mkdir_p(final_backup_path)
1690
+
1691
+ # Create backup info file in final directory
1692
+ create_backup_info(final_backup_path, type, backup_name)
1693
+
1694
+ # Compress the backup data into data.tar.gz
1695
+ compress_backup_data(temp_backup_path, final_backup_path/"data.tar.gz")
1696
+
1697
+ # Remove temporary directory
1698
+ FileUtils.rm_rf(temp_backup_path)
1699
+
1700
+ # Update backup manifest
1701
+ update_backup_manifest(backup_name, type, label)
1702
+
1703
+ # Cleanup old backups
1704
+ cleanup_old_backups
1705
+
1706
+ verify { File.exist?(final_backup_path) }
1707
+ verify { File.exist?(final_backup_path/"data.tar.gz") }
1708
+ check_invariants
1709
+ backup_name
1710
+ end
1711
+
1712
+ def list_backups
1713
+ check_invariants
1714
+ backup_dir = get_backup_directory
1715
+ manifest_file = backup_dir/"manifest.txt"
1716
+ return [] unless File.exist?(manifest_file)
1717
+
1718
+ backups = []
1719
+ File.readlines(manifest_file).each do |line|
1720
+ line = line.strip
1721
+ next if line.empty? || line.start_with?('#')
1722
+
1723
+ parts = line.split(' ', 3)
1724
+ next if parts.length < 1
1725
+
1726
+ timestamp_type = parts[0]
1727
+ description = parts.length > 1 ? parts[1..-1].join(' ') : nil
1728
+
1729
+ # Parse timestamp-type
1730
+ if timestamp_type.match(/^(\d{8}-\d{6})-(full|incr)$/)
1731
+ timestamp_str = $1
1732
+ type = $2 == 'full' ? :full : :incremental
1733
+
1734
+ # Convert timestamp to Time object
1735
+ begin
1736
+ timestamp = Time.strptime(timestamp_str, "%Y%m%d-%H%M%S")
1737
+ backups << {
1738
+ name: timestamp_type,
1739
+ type: type,
1740
+ description: description,
1741
+ timestamp: timestamp,
1742
+ size: calculate_backup_size(timestamp_type),
1743
+ file_count: count_backup_files(timestamp_type)
1744
+ }
1745
+ rescue ArgumentError
1746
+ # Skip invalid timestamps
1747
+ next
1748
+ end
1749
+ end
1750
+ end
1751
+
1752
+ backups.sort_by { |b| b[:timestamp] }.reverse
1753
+ end
1754
+
1755
+ def restore_backup(backup_name, strategy: :safe)
1756
+ check_invariants
1757
+ backup_dir = get_backup_directory
1758
+ backup_path = backup_dir/"data"/backup_name
1759
+ raise BackupNotFound, "Backup '#{backup_name}' not found" unless File.exist?(backup_path)
1760
+
1761
+ case strategy
1762
+ when :safe
1763
+ # Always create pre-restore backup, then restore
1764
+ pre_restore = create_backup(type: :full, label: "pre-restore-#{backup_name}")
1765
+ # Small delay to ensure pre-restore backup has different timestamp
1766
+ sleep(2)
1767
+ restore_from_backup(backup_path)
1768
+ verify { File.exist?(@repo.root/"posts") }
1769
+ check_invariants
1770
+ { restored: backup_name, pre_restore: pre_restore }
1771
+
1772
+ when :merge
1773
+ # Keep existing files, only restore backup files
1774
+ restore_files_from_backup(backup_path)
1775
+ verify { File.exist?(@repo.root/"posts") }
1776
+ check_invariants
1777
+ { restored: backup_name, strategy: :merge }
1778
+
1779
+ when :destroy
1780
+ # Current behavior - clear everything and restore
1781
+ restore_from_backup(backup_path)
1782
+ verify { File.exist?(@repo.root/"posts") }
1783
+ check_invariants
1784
+ { restored: backup_name, strategy: :destroy }
1785
+
1786
+ else
1787
+ raise ArgumentError, "Invalid restore strategy: #{strategy}. Must be :safe, :merge, or :destroy"
1788
+ end
1789
+ end
1790
+
1791
+ def delete_backup(backup_name)
1792
+ check_invariants
1793
+ backup_dir = get_backup_directory
1794
+ backup_path = backup_dir/"data"/backup_name
1795
+ raise BackupNotFound, "Backup '#{backup_name}' not found" unless File.exist?(backup_path)
1796
+
1797
+ # Remove backup directory
1798
+ FileUtils.rm_rf(backup_path)
1799
+
1800
+ # Update manifest
1801
+ update_backup_manifest_remove(backup_name)
1802
+
1803
+ verify { !File.exist?(backup_path) }
1804
+ check_invariants
1805
+ true
1806
+ end
1807
+
1808
+ private def copy_repo_to_backup(backup_path)
1809
+ # Copy all repository files except backups directory
1810
+ Dir.glob(@repo.root/"**/*").each do |file_path|
1811
+ next unless File.file?(file_path)
1812
+ next if file_path.to_s.include?("/backups/")
1813
+
1814
+ file_pathname = Pathname.new(file_path)
1815
+ repo_root_pathname = Pathname.new(@repo.root)
1816
+ relative_path = file_pathname.relative_path_from(repo_root_pathname)
1817
+ dest_path = backup_path/relative_path
1818
+ FileUtils.mkdir_p(File.dirname(dest_path))
1819
+ FileUtils.cp(file_path, dest_path)
1820
+ end
1821
+ end
1822
+
1823
+ private def compress_backup_data(source_dir, tar_gz_path)
1824
+ # Ensure target directory exists
1825
+ FileUtils.mkdir_p(File.dirname(tar_gz_path))
1826
+
1827
+ # Convert to absolute paths
1828
+ source_dir = File.absolute_path(source_dir)
1829
+ tar_gz_path = File.absolute_path(tar_gz_path)
1830
+
1831
+ # Check if source directory has any files
1832
+ files = Dir.glob(source_dir/"**/*").select { |f| File.file?(f) }
1833
+ if files.empty?
1834
+ # Create empty tar.gz if no files
1835
+ system("tar -czf '#{tar_gz_path}' -T /dev/null")
1836
+ else
1837
+ # Change to source directory to create relative paths in tar
1838
+ Dir.chdir(source_dir) do
1839
+ # Create tar.gz archive with all files in source directory
1840
+ system("tar -czf '#{tar_gz_path}' .")
1841
+ end
1842
+ end
1843
+
1844
+ raise "Failed to create compressed backup" unless $?.success?
1845
+ end
1846
+
1847
+ private def create_backup_info(backup_path, type, backup_name)
1848
+ # Get version information
1849
+ scriptorium_version = Scriptorium::VERSION
1850
+ livetext_version = get_livetext_version
1851
+ ruby_version = RUBY_VERSION
1852
+ platform = "#{RUBY_PLATFORM} #{RUBY_ENGINE}"
1853
+
1854
+ # Calculate backup statistics
1855
+ file_count = count_files_in_backup(backup_path)
1856
+ total_size = calculate_directory_size(backup_path)
1857
+
1858
+ # Get git commit if available
1859
+ git_commit = get_git_commit_hash
1860
+
1861
+ # Create backup info content
1862
+ info_content = <<~INFO
1863
+ # Scriptorium Backup Information
1864
+ # Generated: #{Time.now.strftime("%Y-%m-%d %H:%M:%S")}
1865
+ scriptorium_version: #{scriptorium_version}
1866
+ livetext_version: #{livetext_version}
1867
+ ruby_version: #{ruby_version}
1868
+ backup_type: #{type}
1869
+ backup_name: #{backup_name}
1870
+ repository_path: #{@repo.root}
1871
+ file_count: #{file_count}
1872
+ total_size: #{total_size}
1873
+ platform: #{platform}
1874
+ git_commit: #{git_commit}
1875
+ INFO
1876
+
1877
+ # Write backup info file
1878
+ info_file = backup_path/"backup-info.txt"
1879
+ File.write(info_file, info_content)
1880
+ end
1881
+
1882
+ private def get_livetext_version
1883
+ # Try to get LiveText version from command line
1884
+ result = `livetext -v 2>/dev/null`.strip
1885
+ result.empty? ? "unknown" : result
1886
+ rescue
1887
+ "unknown"
1888
+ end
1889
+
1890
+ private def get_git_commit_hash
1891
+ # Try to get git commit hash if in a git repository
1892
+ result = `git rev-parse HEAD 2>/dev/null`.strip
1893
+ result.empty? ? "unknown" : result[0..7] # First 8 characters
1894
+ rescue
1895
+ "unknown"
1896
+ end
1897
+
1898
+ private def count_files_in_backup(backup_path)
1899
+ # Check if this is a compressed backup
1900
+ tar_gz_path = backup_path/"data.tar.gz"
1901
+ if File.exist?(tar_gz_path)
1902
+ # Count files in compressed archive using tar -tf
1903
+ output = `tar -tf #{tar_gz_path} 2>/dev/null`
1904
+ return 0 unless $?.success?
1905
+ output.lines.count { |line| !line.strip.empty? }
1906
+ else
1907
+ # Legacy uncompressed backup
1908
+ count = 0
1909
+ Dir.glob(backup_path/"**/*").each do |file_path|
1910
+ count += 1 if File.file?(file_path)
1911
+ end
1912
+ count
1913
+ end
1914
+ end
1915
+
1916
+ private def calculate_directory_size(backup_path)
1917
+ # Check if this is a compressed backup
1918
+ tar_gz_path = backup_path/"data.tar.gz"
1919
+ if File.exist?(tar_gz_path)
1920
+ # Get size of compressed file plus backup-info.txt
1921
+ compressed_size = File.size(tar_gz_path)
1922
+ info_size = File.exist?(backup_path/"backup-info.txt") ? File.size(backup_path/"backup-info.txt") : 0
1923
+ compressed_size + info_size
1924
+ else
1925
+ # Legacy uncompressed backup
1926
+ size = 0
1927
+ Dir.glob(backup_path/"**/*").each do |file_path|
1928
+ size += File.size(file_path) if File.file?(file_path)
1929
+ end
1930
+ size
1931
+ end
1932
+ end
1933
+
1934
+ private def copy_changed_files_to_backup(backup_path)
1935
+ last_backup_time = get_last_backup_time
1936
+ changed_files = find_changed_files_since(last_backup_time)
1937
+
1938
+ changed_files.each do |file_path|
1939
+ file_pathname = Pathname.new(file_path)
1940
+ repo_root_pathname = Pathname.new(@repo.root)
1941
+ relative_path = file_pathname.relative_path_from(repo_root_pathname)
1942
+ dest_path = backup_path/relative_path
1943
+ FileUtils.mkdir_p(File.dirname(dest_path))
1944
+ FileUtils.cp(file_path, dest_path)
1945
+ end
1946
+ end
1947
+
1948
+ private def find_changed_files_since(since_time)
1949
+ return [] unless since_time
1950
+
1951
+ # Get the most recent backup to compare against
1952
+ last_backup = get_last_backup_name
1953
+ return [] unless last_backup
1954
+
1955
+ backup_dir = get_backup_directory
1956
+ last_backup_path = backup_dir/"data"/last_backup
1957
+ last_backup_tar = last_backup_path/"data.tar.gz"
1958
+
1959
+ # If no compressed backup exists, fall back to file system comparison
1960
+ unless File.exist?(last_backup_tar)
1961
+ return find_changed_files_since_filesystem(since_time)
1962
+ end
1963
+
1964
+ # Get file timestamps from the last backup's tar TOC
1965
+ last_backup_files = get_tar_file_timestamps(last_backup_tar)
1966
+
1967
+ changed_files = []
1968
+ Dir.glob(@repo.root/"**/*").each do |file_path|
1969
+ next unless File.file?(file_path)
1970
+ next if file_path.to_s.include?("/backups/")
1971
+
1972
+ file_pathname = Pathname.new(file_path)
1973
+ repo_root_pathname = Pathname.new(@repo.root)
1974
+ relative_path = file_pathname.relative_path_from(repo_root_pathname).to_s
1975
+
1976
+ current_mtime = File.mtime(file_path)
1977
+ # Try both with and without ./ prefix
1978
+ last_mtime = last_backup_files[relative_path] || last_backup_files["./#{relative_path}"]
1979
+
1980
+ # File is changed if it's new or modified
1981
+ if last_mtime.nil? || current_mtime > last_mtime
1982
+ changed_files << file_path
1983
+ end
1984
+ end
1985
+ changed_files
1986
+ end
1987
+
1988
+ private def find_changed_files_since_filesystem(since_time)
1989
+ changed_files = []
1990
+ Dir.glob(@repo.root/"**/*").each do |file_path|
1991
+ next unless File.file?(file_path)
1992
+ next if file_path.to_s.include?("/backups/")
1993
+
1994
+ if File.mtime(file_path) > since_time
1995
+ changed_files << file_path
1996
+ end
1997
+ end
1998
+
1999
+ changed_files
2000
+ end
2001
+
2002
+ private def get_last_backup_name
2003
+ backup_dir = get_backup_directory
2004
+ manifest_file = backup_dir/"manifest.txt"
2005
+ return nil unless File.exist?(manifest_file)
2006
+
2007
+ File.readlines(manifest_file).each do |line|
2008
+ line = line.strip
2009
+ next if line.empty? || line.start_with?('#')
2010
+
2011
+ parts = line.split(' ', 3)
2012
+ next if parts.length < 1
2013
+
2014
+ backup_name = parts[0]
2015
+ if backup_name.match(/^\d{8}-\d{6}-(full|incr)$/)
2016
+ return backup_name
2017
+ end
2018
+ end
2019
+
2020
+ nil
2021
+ end
2022
+
2023
+ private def get_tar_file_timestamps(tar_gz_path)
2024
+ file_timestamps = {}
2025
+
2026
+ # Use tar -tvf to get file list with timestamps
2027
+ output = `tar -tvf #{tar_gz_path} 2>/dev/null`
2028
+ return file_timestamps unless $?.success?
2029
+
2030
+ output.lines.each do |line|
2031
+ # Parse tar -tvf output format:
2032
+ # -rw-r--r-- user/group size date time filename
2033
+ # drwxr-xr-x user/group size date time filename
2034
+ # Format: drwxr-xr-x 0 Hal staff 0 Sep 7 22:06 ./
2035
+ if line.match(/^[d-]\S+\s+\d+\s+\S+\s+\S+\s+\d+\s+(\w{3})\s+(\d{1,2})\s+(\d{2}:\d{2})\s+(.+?)\s*$/)
2036
+ month_str = $1
2037
+ day_str = $2
2038
+ time_str = $3
2039
+ filename = $4
2040
+
2041
+ begin
2042
+ # Parse abbreviated month name and day
2043
+ timestamp = Time.strptime("#{month_str} #{day_str} #{time_str}", "%b %d %H:%M")
2044
+ # Set year to current year (tar doesn't include year)
2045
+ timestamp = Time.new(Time.now.year, timestamp.month, timestamp.day, timestamp.hour, timestamp.min)
2046
+ file_timestamps[filename] = timestamp
2047
+ rescue ArgumentError
2048
+ # Skip invalid timestamps
2049
+ end
2050
+ end
2051
+ end
2052
+ file_timestamps
2053
+ end
2054
+
2055
+ private def get_last_backup_time
2056
+ backup_dir = get_backup_directory
2057
+ manifest_file = backup_dir/"manifest.txt"
2058
+ return nil unless File.exist?(manifest_file)
2059
+
2060
+ last_time = nil
2061
+ File.readlines(manifest_file).each do |line|
2062
+ line = line.strip
2063
+ next if line.empty? || line.start_with?('#')
2064
+
2065
+ parts = line.split(' ', 3)
2066
+ next if parts.length < 2
2067
+
2068
+ timestamp_type = parts[0]
2069
+ if timestamp_type.match(/^(\d{8}-\d{6})-(full|incr)$/)
2070
+ timestamp_str = $1
2071
+ begin
2072
+ timestamp = Time.strptime(timestamp_str, "%Y%m%d-%H%M%S")
2073
+ last_time = timestamp if last_time.nil? || timestamp > last_time
2074
+ rescue ArgumentError
2075
+ next
2076
+ end
2077
+ end
2078
+ end
2079
+
2080
+ last_time
2081
+ end
2082
+
2083
+ private def update_backup_manifest(backup_name, type, label)
2084
+ backup_dir = get_backup_directory
2085
+ manifest_file = backup_dir/"manifest.txt"
2086
+ FileUtils.mkdir_p(File.dirname(manifest_file))
2087
+
2088
+ # Read existing manifest
2089
+ existing_lines = []
2090
+ if File.exist?(manifest_file)
2091
+ existing_lines = File.readlines(manifest_file).map(&:strip)
2092
+ end
2093
+
2094
+ # Add new backup entry
2095
+ timestamp_type = backup_name
2096
+ description = label ? "#{label}" : ""
2097
+ new_line = "#{timestamp_type} #{description}".strip
2098
+
2099
+ # Add to beginning of file (most recent first)
2100
+ existing_lines.unshift(new_line)
2101
+
2102
+ # Write back to file
2103
+ File.write(manifest_file, existing_lines.join("\n") + "\n")
2104
+ end
2105
+
2106
+ private def update_backup_manifest_remove(backup_name)
2107
+ backup_dir = get_backup_directory
2108
+ manifest_file = backup_dir/"manifest.txt"
2109
+ return unless File.exist?(manifest_file)
2110
+
2111
+ # Read existing manifest and remove the backup entry
2112
+ lines = File.readlines(manifest_file).map(&:strip)
2113
+ lines.reject! { |line| line.start_with?("#{backup_name} ") }
2114
+
2115
+ # Write back to file
2116
+ File.write(manifest_file, lines.join("\n") + "\n")
2117
+ end
2118
+
2119
+ private def calculate_backup_size(backup_name)
2120
+ backup_dir = get_backup_directory
2121
+ backup_path = backup_dir/"data"/backup_name
2122
+ return 0 unless File.exist?(backup_path)
2123
+
2124
+ total_size = 0
2125
+ Dir.glob(backup_path/"**/*").each do |file_path|
2126
+ total_size += File.size(file_path) if File.file?(file_path)
2127
+ end
2128
+ total_size
2129
+ end
2130
+
2131
+ private def count_backup_files(backup_name)
2132
+ backup_dir = get_backup_directory
2133
+ backup_path = backup_dir/"data"/backup_name
2134
+ return 0 unless File.exist?(backup_path)
2135
+
2136
+ Dir.glob(backup_path/"**/*").count { |f| File.file?(f) }
2137
+ end
2138
+
2139
+ private def restore_from_backup(backup_path)
2140
+ # Clear existing content first (except backups)
2141
+ clear_repo_content
2142
+
2143
+ # Find the most recent full backup before this backup
2144
+ full_backup_path = find_full_backup_for_restore(backup_path)
2145
+
2146
+ if full_backup_path
2147
+ # Restore from full backup first
2148
+ restore_files_from_backup(full_backup_path)
2149
+
2150
+ # Then apply all incrementals up to the target backup
2151
+ apply_incrementals_up_to(backup_path)
2152
+ else
2153
+ # No full backup found, just restore the files directly
2154
+ restore_files_from_backup(backup_path)
2155
+ end
2156
+ end
2157
+
2158
+ private def clear_repo_content
2159
+ # Remove existing content (except backups)
2160
+ Dir.glob(@repo.root/"*").each do |item|
2161
+ next if File.basename(item) == "backups"
2162
+ FileUtils.rm_rf(item)
2163
+ end
2164
+ end
2165
+
2166
+ private def restore_files_from_backup(backup_path)
2167
+ # Check if this is a compressed backup
2168
+ tar_gz_path = backup_path/"data.tar.gz"
2169
+ if File.exist?(tar_gz_path)
2170
+ # Decompress to temporary directory and restore from there
2171
+ temp_extract_dir = backup_path/"temp_extract"
2172
+ FileUtils.mkdir_p(temp_extract_dir)
2173
+
2174
+ begin
2175
+ # Extract tar.gz to temporary directory
2176
+ system("tar -xzf #{tar_gz_path} -C #{temp_extract_dir}")
2177
+ raise "Failed to extract compressed backup" unless $?.success?
2178
+
2179
+ # Restore files from extracted directory
2180
+ restore_files_from_directory(temp_extract_dir)
2181
+ ensure
2182
+ # Clean up temporary directory
2183
+ FileUtils.rm_rf(temp_extract_dir) if Dir.exist?(temp_extract_dir)
2184
+ end
2185
+ else
2186
+ # Legacy uncompressed backup - restore directly
2187
+ restore_files_from_directory(backup_path)
2188
+ end
2189
+ end
2190
+
2191
+ private def restore_files_from_directory(source_dir)
2192
+ # Copy all files from source directory to repo
2193
+ Dir.glob(source_dir/"**/*").each do |file_path|
2194
+ next unless File.file?(file_path)
2195
+
2196
+ file_pathname = Pathname.new(file_path)
2197
+ source_pathname = Pathname.new(source_dir)
2198
+ relative_path = file_pathname.relative_path_from(source_pathname)
2199
+ dest_path = @repo.root/relative_path
2200
+ FileUtils.mkdir_p(File.dirname(dest_path))
2201
+ FileUtils.cp(file_path, dest_path)
2202
+ end
2203
+ end
2204
+
2205
+ private def find_full_backup_for_restore(target_backup_path)
2206
+ target_name = File.basename(target_backup_path)
2207
+ target_timestamp = extract_timestamp_from_backup_name(target_name)
2208
+
2209
+ # Find the most recent full backup before the target backup
2210
+ backup_dir = get_backup_directory
2211
+ manifest_file = backup_dir/"manifest.txt"
2212
+ return nil unless File.exist?(manifest_file)
2213
+
2214
+ latest_full_backup = nil
2215
+ latest_full_timestamp = nil
2216
+
2217
+ File.readlines(manifest_file).each do |line|
2218
+ line = line.strip
2219
+ next if line.empty? || line.start_with?('#')
2220
+
2221
+ parts = line.split(' ', 3)
2222
+ next if parts.length < 1
2223
+
2224
+ backup_name = parts[0]
2225
+ if backup_name.end_with?('-full')
2226
+ # Skip pre-restore backups - they shouldn't be used as base for restore
2227
+ next if backup_name.include?('pre-restore')
2228
+
2229
+ backup_timestamp = extract_timestamp_from_backup_name(backup_name)
2230
+ if backup_timestamp && backup_timestamp < target_timestamp
2231
+ if latest_full_timestamp.nil? || backup_timestamp > latest_full_timestamp
2232
+ latest_full_backup = backup_name
2233
+ latest_full_timestamp = backup_timestamp
2234
+ end
2235
+ end
2236
+ end
2237
+ end
2238
+
2239
+ return nil unless latest_full_backup
2240
+
2241
+ backup_dir = get_backup_directory
2242
+ full_backup_path = backup_dir/"data"/latest_full_backup
2243
+ File.exist?(full_backup_path) ? full_backup_path : nil
2244
+ end
2245
+
2246
+ private def apply_incrementals_up_to(target_backup_path)
2247
+ target_name = File.basename(target_backup_path)
2248
+ target_timestamp = extract_timestamp_from_backup_name(target_name)
2249
+
2250
+ backup_dir = get_backup_directory
2251
+ manifest_file = backup_dir/"manifest.txt"
2252
+ return unless File.exist?(manifest_file)
2253
+
2254
+ # Get all incrementals between the full backup and target backup
2255
+ incrementals = []
2256
+ File.readlines(manifest_file).each do |line|
2257
+ line = line.strip
2258
+ next if line.empty? || line.start_with?('#')
2259
+
2260
+ parts = line.split(' ', 3)
2261
+ next if parts.length < 1
2262
+
2263
+ backup_name = parts[0]
2264
+ if backup_name.end_with?('-incr')
2265
+ backup_timestamp = extract_timestamp_from_backup_name(backup_name)
2266
+ if backup_timestamp && backup_timestamp <= target_timestamp
2267
+ incrementals << backup_name
2268
+ end
2269
+ end
2270
+ end
2271
+
2272
+ # Sort incrementals by timestamp and apply them
2273
+ incrementals.sort_by { |name| extract_timestamp_from_backup_name(name) }.each do |backup_name|
2274
+ backup_dir = get_backup_directory
2275
+ incremental_path = backup_dir/"data"/backup_name
2276
+ if File.exist?(incremental_path)
2277
+ restore_files_from_backup(incremental_path)
2278
+ end
2279
+ end
2280
+ end
2281
+
2282
+ private def extract_timestamp_from_backup_name(backup_name)
2283
+ if backup_name.match(/^(\d{8}-\d{6})-(full|incr)$/)
2284
+ timestamp_str = $1
2285
+ begin
2286
+ Time.strptime(timestamp_str, "%Y%m%d-%H%M%S")
2287
+ rescue ArgumentError
2288
+ nil
2289
+ end
2290
+ else
2291
+ nil
2292
+ end
2293
+ end
2294
+
2295
+
2296
+
2297
+ private def cleanup_old_backups
2298
+ # Keep backups for 30 days, but always keep the most recent full backup
2299
+ cutoff_time = Time.now - (30 * 24 * 60 * 60)
2300
+
2301
+ backup_dir = get_backup_directory
2302
+ manifest_file = backup_dir/"manifest.txt"
2303
+ return unless File.exist?(manifest_file)
2304
+
2305
+ # Find the most recent full backup
2306
+ most_recent_full_backup = nil
2307
+ most_recent_full_timestamp = nil
2308
+
2309
+ File.readlines(manifest_file).each do |line|
2310
+ line = line.strip
2311
+ next if line.empty? || line.start_with?('#')
2312
+
2313
+ parts = line.split(' ', 3)
2314
+ next if parts.length < 1
2315
+
2316
+ backup_name = parts[0]
2317
+ if backup_name.end_with?('-full')
2318
+ backup_timestamp = extract_timestamp_from_backup_name(backup_name)
2319
+ if backup_timestamp && (most_recent_full_timestamp.nil? || backup_timestamp > most_recent_full_timestamp)
2320
+ most_recent_full_backup = backup_name
2321
+ most_recent_full_timestamp = backup_timestamp
2322
+ end
2323
+ end
2324
+ end
2325
+
2326
+ lines_to_keep = []
2327
+ lines_to_remove = []
2328
+
2329
+ File.readlines(manifest_file).each do |line|
2330
+ line = line.strip
2331
+ next if line.empty? || line.start_with?('#')
2332
+
2333
+ parts = line.split(' ', 3)
2334
+ next if parts.length < 1
2335
+
2336
+ backup_name = parts[0]
2337
+ if backup_name.match(/^(\d{8}-\d{6})-(full|incr)$/)
2338
+ timestamp_str = $1
2339
+ begin
2340
+ timestamp = Time.strptime(timestamp_str, "%Y%m%d-%H%M%S")
2341
+
2342
+ # Always keep the most recent full backup
2343
+ if backup_name == most_recent_full_backup
2344
+ lines_to_keep << line
2345
+ # Keep all backups newer than cutoff
2346
+ elsif timestamp >= cutoff_time
2347
+ lines_to_keep << line
2348
+ # Keep incrementals that are newer than the most recent full backup
2349
+ elsif backup_name.end_with?('-incr') && most_recent_full_timestamp && timestamp > most_recent_full_timestamp
2350
+ lines_to_keep << line
2351
+ else
2352
+ lines_to_remove << backup_name
2353
+ end
2354
+ rescue ArgumentError
2355
+ lines_to_keep << line
2356
+ end
2357
+ else
2358
+ lines_to_keep << line
2359
+ end
2360
+ end
2361
+
2362
+ # Remove old backup directories
2363
+ lines_to_remove.each do |backup_name|
2364
+ backup_dir = get_backup_directory
2365
+ backup_path = backup_dir/"data"/backup_name
2366
+ FileUtils.rm_rf(backup_path) if File.exist?(backup_path)
2367
+ end
2368
+
2369
+ # Update manifest file
2370
+ File.write(manifest_file, lines_to_keep.join("\n") + "\n")
2371
+ end
2372
+
640
2373
  end