scriptorium 0.0.3 → 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 (353) hide show
  1. checksums.yaml +4 -4
  2. data/README.lt3 +324 -0
  3. data/README.md +3155 -1
  4. data/assets/.DS_Store +0 -0
  5. data/assets/README.md +44 -0
  6. data/assets/icons/social/reddit.png +0 -0
  7. data/assets/icons/social/x-logo.png +0 -0
  8. data/assets/icons/ui/.DS_Store +0 -0
  9. data/assets/icons/ui/back.png +0 -0
  10. data/assets/icons/ui/copy.png +0 -0
  11. data/assets/icons/ui/down.png +0 -0
  12. data/assets/icons/ui/end.png +0 -0
  13. data/assets/icons/ui/exit.png +0 -0
  14. data/assets/icons/ui/foo +10 -0
  15. data/assets/icons/ui/home.png +0 -0
  16. data/assets/icons/ui/left.png +0 -0
  17. data/assets/icons/ui/next.png +0 -0
  18. data/assets/icons/ui/right.png +0 -0
  19. data/assets/icons/ui/start.png +0 -0
  20. data/assets/icons/ui/up.png +0 -0
  21. data/assets/imagenotfound.jpg +0 -0
  22. data/assets/samples/placeholder.svg +9 -0
  23. data/assets/themes/standard/favicon.svg +6 -0
  24. data/bin/sblog +84 -5
  25. data/bin/scriptorium +1 -0
  26. data/doc/README.txt +6 -0
  27. data/doc/anti-amnesia/20250727-054000-scriptorium-overview.md +94 -0
  28. data/doc/anti-amnesia/20250727-123000-anti-amnesia-conventions.md +2 -0
  29. data/doc/anti-amnesia/20250727-172600-cursor-rbenv-ruby-version-mystery.md +45 -0
  30. data/doc/anti-amnesia/20250727-172900-ai-cognitive-assessment-capabilities.md +40 -0
  31. data/doc/anti-amnesia/20250728-124243-aaa-syntax-clarification.md +46 -0
  32. data/doc/anti-amnesia/20250729-210000-reddit-autopost-integration-complete.md +158 -0
  33. data/doc/anti-amnesia/20250804-190500-cognitive-loop-bug.md +35 -0
  34. data/doc/anti-amnesia/20250804-190700-anti-amnesia-timestamping-fix.md +27 -0
  35. data/doc/anti-amnesia/20250807-213025.md +116 -0
  36. data/doc/anti-amnesia/20250901-211714-codemirror-integration-and-web-tests.md +172 -0
  37. data/doc/anti-amnesia/20250902-002402-backup-restore-system.md +126 -0
  38. data/doc/anti-amnesia/20250907-203339-backup-metadata-implementation.md +66 -0
  39. data/doc/banner_svg_config.md +114 -0
  40. data/doc/contrib.lt3 +8 -0
  41. data/doc/dependencies.md +281 -0
  42. data/doc/hacker.lt3 +5 -0
  43. data/doc/imported/0001-elixir-conf-2014/metadata.txt +7 -0
  44. data/doc/imported/0001-elixir-conf-2014/post.html +37 -0
  45. data/doc/imported/0001-elixir-conf-2014/source.lt3 +22 -0
  46. data/doc/imported/0002-programmers-and-word-processing/metadata.txt +7 -0
  47. data/doc/imported/0002-programmers-and-word-processing/post.html +192 -0
  48. data/doc/imported/0002-programmers-and-word-processing/source.lt3 +146 -0
  49. data/doc/imported/0003-how-to-turn-your-brain-sideways/metadata.txt +7 -0
  50. data/doc/imported/0003-how-to-turn-your-brain-sideways/post.html +60 -0
  51. data/doc/imported/0003-how-to-turn-your-brain-sideways/source.lt3 +40 -0
  52. data/doc/imported/0004-upcoming-lone-star-ruby-conference/metadata.txt +7 -0
  53. data/doc/imported/0004-upcoming-lone-star-ruby-conference/post.html +42 -0
  54. data/doc/imported/0004-upcoming-lone-star-ruby-conference/source.lt3 +24 -0
  55. data/doc/imported/0005-elixir-conf-2015-announced/metadata.txt +7 -0
  56. data/doc/imported/0005-elixir-conf-2015-announced/post.html +30 -0
  57. data/doc/imported/0005-elixir-conf-2015-announced/source.lt3 +16 -0
  58. data/doc/imported/0006-ruby-for-dinosaurs/metadata.txt +7 -0
  59. data/doc/imported/0006-ruby-for-dinosaurs/post.html +43 -0
  60. data/doc/imported/0006-ruby-for-dinosaurs/source.lt3 +27 -0
  61. data/doc/imported/0007-phoenix-isnt-rails/metadata.txt +7 -0
  62. data/doc/imported/0007-phoenix-isnt-rails/post.html +116 -0
  63. data/doc/imported/0007-phoenix-isnt-rails/source.lt3 +87 -0
  64. data/doc/imported/0008-concerning-the-term-monkeypatching/metadata.txt +7 -0
  65. data/doc/imported/0008-concerning-the-term-monkeypatching/post.html +129 -0
  66. data/doc/imported/0008-concerning-the-term-monkeypatching/source.lt3 +92 -0
  67. data/doc/imported/0009-announcement-coming-soon/metadata.txt +7 -0
  68. data/doc/imported/0009-announcement-coming-soon/post.html +33 -0
  69. data/doc/imported/0009-announcement-coming-soon/source.lt3 +19 -0
  70. data/doc/imported/0010-immutable-data-ditching-the-wax-tablet/metadata.txt +7 -0
  71. data/doc/imported/0010-immutable-data-ditching-the-wax-tablet/post.html +175 -0
  72. data/doc/imported/0010-immutable-data-ditching-the-wax-tablet/source.lt3 +139 -0
  73. data/doc/imported/0011-computer-science-as-a-lost-art/metadata.txt +7 -0
  74. data/doc/imported/0011-computer-science-as-a-lost-art/post.html +139 -0
  75. data/doc/imported/0011-computer-science-as-a-lost-art/source.lt3 +104 -0
  76. data/doc/imported/0012-ruby-day-in-turin-italy/metadata.txt +7 -0
  77. data/doc/imported/0012-ruby-day-in-turin-italy/post.html +42 -0
  78. data/doc/imported/0012-ruby-day-in-turin-italy/source.lt3 +24 -0
  79. data/doc/imported/0013-rubyday-was-a-success/metadata.txt +7 -0
  80. data/doc/imported/0013-rubyday-was-a-success/post.html +44 -0
  81. data/doc/imported/0013-rubyday-was-a-success/source.lt3 +27 -0
  82. data/doc/imported/0014-working-on-the-blogging-software/metadata.txt +7 -0
  83. data/doc/imported/0014-working-on-the-blogging-software/post.html +63 -0
  84. data/doc/imported/0014-working-on-the-blogging-software/source.lt3 +41 -0
  85. data/doc/imported/0015-ok-its-not-really-a-lost-art/metadata.txt +7 -0
  86. data/doc/imported/0015-ok-its-not-really-a-lost-art/post.html +172 -0
  87. data/doc/imported/0015-ok-its-not-really-a-lost-art/source.lt3 +134 -0
  88. data/doc/imported/0016-an-in-operator-for-ruby/metadata.txt +7 -0
  89. data/doc/imported/0016-an-in-operator-for-ruby/post.html +155 -0
  90. data/doc/imported/0016-an-in-operator-for-ruby/source.lt3 +106 -0
  91. data/doc/imported/0017-the-forgotten-mathematician/metadata.txt +7 -0
  92. data/doc/imported/0017-the-forgotten-mathematician/post.html +161 -0
  93. data/doc/imported/0017-the-forgotten-mathematician/source.lt3 +119 -0
  94. data/doc/imported/0018-ruby-puns/metadata.txt +7 -0
  95. data/doc/imported/0018-ruby-puns/post.html +46 -0
  96. data/doc/imported/0018-ruby-puns/source.lt3 +28 -0
  97. data/doc/imported/0019-custom-exceptions-via-metaprogramming/metadata.txt +7 -0
  98. data/doc/imported/0019-custom-exceptions-via-metaprogramming/post.html +138 -0
  99. data/doc/imported/0019-custom-exceptions-via-metaprogramming/source.lt3 +101 -0
  100. data/doc/imported/0020-fffff/metadata.txt +7 -0
  101. data/doc/imported/0020-fffff/post.html +24 -0
  102. data/doc/imported/0020-fffff/source.lt3 +12 -0
  103. data/doc/imported/0021-trying-ror-yet-again/metadata.txt +7 -0
  104. data/doc/imported/0021-trying-ror-yet-again/post.html +26 -0
  105. data/doc/imported/0021-trying-ror-yet-again/source.lt3 +12 -0
  106. data/doc/imported/0023-doctor-sleep/metadata.txt +7 -0
  107. data/doc/imported/0023-doctor-sleep/post.html +63 -0
  108. data/doc/imported/0023-doctor-sleep/source.lt3 +44 -0
  109. data/doc/imported/0024-just-a-test/metadata.txt +7 -0
  110. data/doc/imported/0024-just-a-test/post.html +24 -0
  111. data/doc/imported/0024-just-a-test/source.lt3 +12 -0
  112. data/doc/imported/import_summary.txt +98 -0
  113. data/doc/livetext-informal-spec.txt +65 -0
  114. data/doc/myuserdoc/ch-0.lt3 +31 -0
  115. data/doc/myuserdoc/ch-1.lt3 +37 -0
  116. data/doc/myuserdoc/ch-10.lt3 +22 -0
  117. data/doc/myuserdoc/ch-2.lt3 +37 -0
  118. data/doc/myuserdoc/ch-3.lt3 +19 -0
  119. data/doc/myuserdoc/ch-4.lt3 +43 -0
  120. data/doc/myuserdoc/ch-5.lt3 +22 -0
  121. data/doc/myuserdoc/ch-6.lt3 +19 -0
  122. data/doc/myuserdoc/ch-7.lt3 +16 -0
  123. data/doc/myuserdoc/ch-8.lt3 +13 -0
  124. data/doc/myuserdoc/ch-9.lt3 +19 -0
  125. data/doc/myuserdoc/tweak.rb +18 -0
  126. data/doc/myuserdoc/userdoc-toc.txt +88 -0
  127. data/doc/old-posts/0001-elixir-conf-2014.lt3 +24 -0
  128. data/doc/old-posts/0002-programmers-and-word-processing.lt3 +150 -0
  129. data/doc/old-posts/0003-how-to-turn-your-brain-sideways.lt3 +43 -0
  130. data/doc/old-posts/0004-upcoming-lone-star-ruby-conference.lt3 +26 -0
  131. data/doc/old-posts/0005-elixir-conf-2015-announced.lt3 +17 -0
  132. data/doc/old-posts/0006-ruby-for-dinosaurs.lt3 +30 -0
  133. data/doc/old-posts/0007-phoenix-isnt-rails.lt3 +90 -0
  134. data/doc/old-posts/0008-concerning-the-term-monkeypatching.lt3 +105 -0
  135. data/doc/old-posts/0009-announcement-coming-soon.lt3 +20 -0
  136. data/doc/old-posts/0010-immutable-data-ditching-the-wax-tablet.lt3 +142 -0
  137. data/doc/old-posts/0011-computer-science-as-a-lost-art.lt3 +117 -0
  138. data/doc/old-posts/0012-ruby-day-in-turin-italy.lt3 +26 -0
  139. data/doc/old-posts/0013-rubyday-was-a-success.lt3 +28 -0
  140. data/doc/old-posts/0014-working-on-the-blogging-software.lt3 +42 -0
  141. data/doc/old-posts/0015-ok-its-not-really-a-lost-art.lt3 +137 -0
  142. data/doc/old-posts/0016-an-in-operator-for-ruby.lt3 +142 -0
  143. data/doc/old-posts/0017-the-forgotten-mathematician.lt3 +129 -0
  144. data/doc/old-posts/0018-ruby-puns.lt3 +31 -0
  145. data/doc/old-posts/0019-custom-exceptions-via-metaprogramming.lt3 +116 -0
  146. data/doc/old-posts/0021-trying-ror-yet-again.lt3 +35 -0
  147. data/doc/old-posts/0023-doctor-sleep.lt3 +43 -0
  148. data/doc/old-posts/0024-just-a-test.lt3 +12 -0
  149. data/doc/old-posts/0025-trying-another-post.lt3 +12 -0
  150. data/doc/old-repo +1 -0
  151. data/doc/reddit_credentials_template.json +8 -0
  152. data/doc/reddit_integration.md +207 -0
  153. data/doc/user.lt3 +35 -0
  154. data/doc/user_guide_section_1.md +137 -0
  155. data/doc/user_guide_section_10.md +515 -0
  156. data/doc/user_guide_section_11.md +708 -0
  157. data/doc/user_guide_section_2.md +233 -0
  158. data/doc/user_guide_section_3.md +5 -0
  159. data/doc/user_guide_section_4.md +221 -0
  160. data/doc/user_guide_section_5.md +243 -0
  161. data/doc/user_guide_section_6.md +147 -0
  162. data/doc/user_guide_section_7.md +311 -0
  163. data/doc/user_guide_section_8.md +224 -0
  164. data/doc/user_guide_section_9.md +375 -0
  165. data/lib/rouge/lexers/livetext.rb +74 -0
  166. data/lib/scriptorium/api.rb +2373 -0
  167. data/lib/scriptorium/banner_svg.rb +729 -0
  168. data/lib/scriptorium/contract.rb +34 -0
  169. data/lib/scriptorium/exceptions.rb +201 -1
  170. data/lib/scriptorium/helpers.rb +675 -0
  171. data/lib/scriptorium/post.rb +259 -0
  172. data/lib/scriptorium/reddit.rb +83 -0
  173. data/lib/scriptorium/repo.rb +938 -0
  174. data/lib/scriptorium/standard_files.rb +149 -0
  175. data/lib/scriptorium/support/bootstrap/css.txt +5 -0
  176. data/lib/scriptorium/support/bootstrap/js.txt +4 -0
  177. data/lib/scriptorium/support/common_js/clipboard.js +35 -0
  178. data/lib/scriptorium/support/common_js/content-loader.js +187 -0
  179. data/lib/scriptorium/support/common_js/navigation.js +52 -0
  180. data/lib/scriptorium/support/common_js/syntax-highlighting.js +27 -0
  181. data/lib/scriptorium/support/config/reddit.txt +10 -0
  182. data/lib/scriptorium/support/config/reddit_template.txt +17 -0
  183. data/lib/scriptorium/support/config/social.txt +8 -0
  184. data/lib/scriptorium/support/highlight/css.txt +2 -0
  185. data/lib/scriptorium/support/highlight/custom.css +119 -0
  186. data/lib/scriptorium/support/highlight/js.txt +1 -0
  187. data/lib/scriptorium/support/post_index/config.txt +15 -0
  188. data/lib/scriptorium/support/post_index/style.css +55 -0
  189. data/lib/scriptorium/support/templates/index_entry.lt3 +16 -0
  190. data/lib/scriptorium/support/templates/initial_post.lt3 +12 -0
  191. data/lib/scriptorium/support/templates/layout.txt +5 -0
  192. data/lib/scriptorium/support/templates/post.lt3 +104 -0
  193. data/lib/scriptorium/support/theme/footer.lt3 +2 -0
  194. data/lib/scriptorium/support/theme/header.lt3 +4 -0
  195. data/lib/scriptorium/support/theme/left.lt3 +3 -0
  196. data/lib/scriptorium/support/theme/main.lt3 +5 -0
  197. data/lib/scriptorium/support/theme/right.lt3 +3 -0
  198. data/lib/scriptorium/theme.rb +192 -0
  199. data/lib/scriptorium/version.rb +1 -1
  200. data/lib/scriptorium/view.rb +1021 -0
  201. data/lib/scriptorium/widgets/featured_posts.rb +149 -0
  202. data/lib/scriptorium/widgets/links.rb +112 -0
  203. data/lib/scriptorium/widgets/pages.rb +133 -0
  204. data/lib/scriptorium/widgets/widget.rb +133 -0
  205. data/lib/scriptorium.rb +38 -34
  206. data/lib/skeleton.rb +10 -1
  207. data/scriptorium.gemspec +17 -5
  208. data/test/README.md +69 -0
  209. data/test/WEB_INTEGRATION_README.md +196 -0
  210. data/test/all +83 -0
  211. data/test/api_demo.rb +99 -0
  212. data/test/assets/imagenotfound.jpg +0 -0
  213. data/test/assets/images/.DS_Store +0 -0
  214. data/test/assets/images/README.md +27 -0
  215. data/test/assets/images/odd_aspect.png +0 -0
  216. data/test/assets/images/perfect.png +0 -0
  217. data/test/assets/images/small.png +0 -0
  218. data/test/assets/images/tall.png +0 -0
  219. data/test/assets/images/very_tall.png +0 -0
  220. data/test/assets/images/very_wide.png +0 -0
  221. data/test/assets/images/wide.png +0 -0
  222. data/test/assets/testbanner.jpg +0 -0
  223. data/test/banner_svg/simple_helpers.rb +13 -0
  224. data/test/banner_svg/unit.rb +1000 -0
  225. data/test/config/deployment.txt +5 -0
  226. data/test/ed_test.rb +204 -0
  227. data/test/integration/cursor_banner_combinations.rb +193 -0
  228. data/test/integration/cursor_banner_features.rb +374 -0
  229. data/test/integration/integration_test.rb +326 -0
  230. data/test/integration/preview_flow_test.rb +94 -0
  231. data/test/livetext_plugin_test.rb +500 -0
  232. data/test/manual/asset_mgmt.rb +67 -0
  233. data/test/manual/banner-tests/index.html +45 -0
  234. data/test/manual/banner-tests/svg.txt +3 -0
  235. data/test/manual/banner-tests/test01.html +122 -0
  236. data/test/manual/banner-tests/test02.html +122 -0
  237. data/test/manual/banner-tests/test03.html +122 -0
  238. data/test/manual/banner-tests/test04.html +129 -0
  239. data/test/manual/banner-tests/test05.html +129 -0
  240. data/test/manual/banner-tests/test06.html +129 -0
  241. data/test/manual/banner-tests/test07.html +129 -0
  242. data/test/manual/banner-tests/test08.html +123 -0
  243. data/test/manual/banner-tests/test09.html +123 -0
  244. data/test/manual/banner-tests/test10.html +123 -0
  245. data/test/manual/banner-tests/test11.html +123 -0
  246. data/test/manual/banner-tests/test12.html +123 -0
  247. data/test/manual/banner-tests/test13.html +123 -0
  248. data/test/manual/banner-tests/test14.html +123 -0
  249. data/test/manual/banner-tests/test15.html +122 -0
  250. data/test/manual/banner-tests/test16.html +122 -0
  251. data/test/manual/banner-tests/test17.html +122 -0
  252. data/test/manual/banner-tests/test18.html +132 -0
  253. data/test/manual/banner-tests/test19.html +132 -0
  254. data/test/manual/banner-tests/test20.html +132 -0
  255. data/test/manual/banner-tests/test21.html +132 -0
  256. data/test/manual/banner-tests/test22.html +132 -0
  257. data/test/manual/banner-tests/test23.html +132 -0
  258. data/test/manual/banner-tests/test24.html +132 -0
  259. data/test/manual/banner-tests/test25.html +131 -0
  260. data/test/manual/banner_environment.rb +205 -0
  261. data/test/manual/codemirror_demo.html +773 -0
  262. data/test/manual/create_posts_for_web.rb +114 -0
  263. data/test/manual/environment.rb +67 -0
  264. data/test/manual/make_banner.rb +153 -0
  265. data/test/manual/preview_manual_test.rb +129 -0
  266. data/test/manual/sample_banner_config.txt +12 -0
  267. data/test/manual/test_advanced_widgets.rb +73 -0
  268. data/test/manual/test_banner_combinations.rb +120 -0
  269. data/test/manual/test_banner_features.rb +306 -0
  270. data/test/manual/test_banner_integration.rb +115 -0
  271. data/test/manual/test_banner_radial.rb +87 -0
  272. data/test/manual/test_basic_posts.rb +47 -0
  273. data/test/manual/test_layout_widgets.rb +40 -0
  274. data/test/manual/test_pagination.rb +24 -0
  275. data/test/manual/test_random_posts.rb +38 -0
  276. data/test/manual/test_syntax_highlighting.rb +167 -0
  277. data/test/rubytext/rubytext_comprehensive_test.rb +307 -0
  278. data/test/rubytext/rubytext_demo_test.rb +42 -0
  279. data/test/rubytext/rubytext_testing_guide.md +277 -0
  280. data/test/run_automated_tests.rb +45 -0
  281. data/test/staging/.DS_Store +0 -0
  282. data/test/support/preview_utils.rb +88 -0
  283. data/test/syntax_highlighting_test.lt3 +124 -0
  284. data/test/test_gem_assets.rb +48 -0
  285. data/test/test_helpers.rb +240 -0
  286. data/test/tui_editor_integration_test.rb +296 -0
  287. data/test/tui_integration_test.rb +883 -0
  288. data/test/unit/api.rb +1776 -0
  289. data/test/unit/asset_management.rb +219 -0
  290. data/test/unit/backup_test.rb +451 -0
  291. data/test/unit/clipboard_test.rb +60 -0
  292. data/test/unit/contract_test.rb +69 -0
  293. data/test/unit/core.rb +1211 -0
  294. data/test/unit/deploy_config_test.rb +248 -0
  295. data/test/unit/deploy_test.rb +478 -0
  296. data/test/unit/edit_post_test.rb +168 -0
  297. data/test/unit/gem_asset_management.rb +183 -0
  298. data/test/unit/livetext_basic.rb +57 -0
  299. data/test/unit/livetext_compatibility.rb +82 -0
  300. data/test/unit/parse_cmd_test.rb +260 -0
  301. data/test/unit/permalink_copy_test.rb +211 -0
  302. data/test/unit/post.rb +309 -0
  303. data/test/unit/post_index_config_test.rb +258 -0
  304. data/test/unit/post_state_helpers_test.rb +137 -0
  305. data/test/unit/read_commented_file_test.rb +278 -0
  306. data/test/unit/reddit_test.rb +235 -0
  307. data/test/unit/repo.rb +569 -0
  308. data/test/unit/social_test.rb +366 -0
  309. data/test/unit/syntax_highlighting.rb +70 -0
  310. data/test/unit/theme_management_test.rb +91 -0
  311. data/test/unit/view.rb +498 -0
  312. data/test/unit/widgets.rb +669 -0
  313. data/test/web_integration_test.rb +231 -0
  314. data/test/web_test_helper.rb +218 -0
  315. data/test/web_workflow_test.rb +527 -0
  316. data/test/wizard_test.rb +123 -0
  317. data/ui/README.md +67 -0
  318. data/ui/common/lib/ui_common.rb +8 -0
  319. data/ui/rubytext/README.md +191 -0
  320. data/ui/rubytext/bin/scriptorium-rubytext +402 -0
  321. data/ui/rubytext/lib/rubytext_ui.rb +300 -0
  322. data/ui/tui/bin/scriptorium +1890 -0
  323. data/ui/tui/test/tui_test.rb +23 -0
  324. data/ui/web/app/app.rb +2600 -0
  325. data/ui/web/app/assets/livetext_mode.js +244 -0
  326. data/ui/web/app/error_helpers.rb +150 -0
  327. data/ui/web/app/views/advanced_config.erb +196 -0
  328. data/ui/web/app/views/asset_management.erb +645 -0
  329. data/ui/web/app/views/backup_management.erb +238 -0
  330. data/ui/web/app/views/banner_config.erb +200 -0
  331. data/ui/web/app/views/config_widget.erb +232 -0
  332. data/ui/web/app/views/configure_view.erb +401 -0
  333. data/ui/web/app/views/dashboard.erb +154 -0
  334. data/ui/web/app/views/deploy_config.erb +149 -0
  335. data/ui/web/app/views/edit_pages.erb +363 -0
  336. data/ui/web/app/views/edit_post.erb +175 -0
  337. data/ui/web/app/views/edit_theme.erb +73 -0
  338. data/ui/web/app/views/edit_theme_file.erb +74 -0
  339. data/ui/web/app/views/error_page.erb +29 -0
  340. data/ui/web/app/views/header_config.erb +155 -0
  341. data/ui/web/app/views/layout_config.erb +147 -0
  342. data/ui/web/app/views/navbar_config.erb +411 -0
  343. data/ui/web/app/views/theme_management.erb +130 -0
  344. data/ui/web/app/views/view_dashboard.erb +779 -0
  345. data/ui/web/app/views/widgets.erb +249 -0
  346. data/ui/web/bin/scriptorium-web +164 -0
  347. data/ui/web/test/web_basic_test.rb +38 -0
  348. data/ui/web/test_navbar.txt +7 -0
  349. data/ui/web/tmp/timing.log +17 -0
  350. data/ui/web/tmp/web_server.log +0 -0
  351. metadata +434 -8
  352. data/lib/scriptorium/engine.rb +0 -22
  353. data/test/engine/unit.rb +0 -44
@@ -0,0 +1,938 @@
1
+
2
+
3
+ class Scriptorium::Repo
4
+ include Scriptorium::Exceptions
5
+ extend Scriptorium::Exceptions
6
+ include Scriptorium::Helpers
7
+ extend Scriptorium::Helpers
8
+ include Scriptorium::Contract
9
+ extend Scriptorium::Contract
10
+
11
+ class << self
12
+ attr_accessor :testing
13
+ attr_reader :root, :repo # class level
14
+ end
15
+
16
+ # instance attrs
17
+
18
+ attr_reader :root, :views, :current_view
19
+
20
+ def self.exist?
21
+ dir = Scriptorium::Repo.root
22
+ return false if dir.nil?
23
+ Dir.exist?(dir)
24
+ end
25
+
26
+ def self.create(path = nil, testmode: false)
27
+ msg = "path must be nil or String, got #{path.class}"
28
+ assume(msg) { path.nil? || path.is_a?(String) }
29
+ # Handle backward compatibility: boolean true means testing mode
30
+ if testmode == true
31
+ Scriptorium::Repo.testing = path
32
+ else
33
+ Scriptorium::Repo.testing = nil
34
+ end
35
+ home = ENV['HOME']
36
+ @predef = Scriptorium::StandardFiles.new
37
+ @root = path || "#{home}/.scriptorium"
38
+ raise self.RepoDirAlreadyExists(@root) if Dir.exist?(@root)
39
+ make_tree(@root, <<~EOS)
40
+ .
41
+ ├── config/ # Global config files
42
+ ├── views/ # Views
43
+ ├── drafts/ # Draft posts (global)
44
+ ├── posts/ # Global generated posts (slug.html)
45
+ ├── assets/ # Images, etc.
46
+ │ └── library/ # Common images, icons, etc.
47
+ └── themes/ # Themes
48
+ EOS
49
+
50
+ postnum_file = "#@root/config/last_post_num.txt"
51
+ write_file(postnum_file, "0")
52
+ write_file(@root/:config/"global-head.txt", @predef.html_head_content)
53
+ copy_support_file('bootstrap/js.txt', @root/:config/"bootstrap_js.txt")
54
+ copy_support_file('bootstrap/css.txt', @root/:config/"bootstrap_css.txt")
55
+ write_file(@root/:config/"common.js", @predef.common_js)
56
+ write_file(@root/:config/"widgets.txt", @predef.available_widgets)
57
+ copy_support_file('post_index/config.txt', @root/:config/"post_index_defaults.txt")
58
+
59
+ # Create global credentials directory and template
60
+ FileUtils.mkdir_p(@root/:credentials)
61
+ copy_support_file('config/reddit_template.txt', @root/:credentials/"reddit.txt")
62
+
63
+
64
+
65
+ Scriptorium::Theme.create_standard(@root) # Theme: templates, etc.
66
+
67
+ # Copy application-wide gem assets to library
68
+ Scriptorium::Theme.copy_gem_assets_to_library(@root)
69
+
70
+ # Generate OS-specific helper code
71
+ generate_os_helpers(@root)
72
+
73
+ @repo = self.open(@root)
74
+ Scriptorium::View.create_sample_view(@repo)
75
+ verify { @repo.is_a?(Scriptorium::Repo) }
76
+ return @repo
77
+ end
78
+
79
+ def self.open(root)
80
+ msg = "root must be a non-empty String, got #{root.class} (#{root.inspect})"
81
+ assume(msg) { root.is_a?(String) && !root.empty? }
82
+ repo = Scriptorium::Repo.new(root)
83
+ verify { repo.is_a?(Scriptorium::Repo) }
84
+ repo
85
+ end
86
+
87
+ def self.destroy
88
+ msg = "Repo.testing must be true in test mode"
89
+ assume(msg) { Scriptorium::Repo.testing }
90
+ raise self.TestModeOnly unless Scriptorium::Repo.testing
91
+ system!("rm -rf #@root", "destroying repository")
92
+ verify { !Dir.exist?(@root) }
93
+ end
94
+
95
+ def postnum_file
96
+ "#@root/config/last_post_num.txt"
97
+ end
98
+
99
+ # Invariants
100
+ def define_invariants
101
+ invariant { @root.is_a?(String) && !@root.empty? }
102
+ invariant { @views.is_a?(Array) }
103
+ invariant { @current_view.nil? || @current_view.is_a?(Scriptorium::View) }
104
+ end
105
+
106
+ def initialize(root) # repo
107
+ msg = "root must be a non-empty String, got #{root.class} (#{root.inspect})"
108
+ assume(msg) { root.is_a?(String) && !root.empty? }
109
+ @root = root
110
+ @predef = Scriptorium::StandardFiles.new
111
+ # Scriptorium::Repo.class_eval { @root, @repo = root, self }
112
+ self.class.instance_variable_set(:@root, root)
113
+ self.class.instance_variable_set(:@repo, self)
114
+ load_views
115
+ @reddit = nil # Lazy load Reddit integration
116
+ define_invariants
117
+ verify { @root == root }
118
+ check_invariants
119
+ end
120
+
121
+ private def load_views
122
+ @views = []
123
+ list = Dir.entries(@root/:views) - %w[. .. config.txt]
124
+ list.each {|dir| open_view(dir) }
125
+ cview_file = @root/:config/"currentview.txt"
126
+ @current_view = nil
127
+ if File.exist?(cview_file)
128
+ view_name = read_file(cview_file).chomp
129
+ begin
130
+ @current_view = lookup_view(view_name)
131
+ rescue
132
+ # If the saved view doesn't exist, just leave current_view as nil
133
+ # It will be set when a view is created or selected
134
+ end
135
+ end
136
+ end
137
+
138
+ ### View methods...
139
+
140
+ def lookup_view(target)
141
+ return target if target.is_a?(Scriptorium::View)
142
+
143
+ validate_view_target(target)
144
+
145
+ list = @views.select {|v| v.name == target }
146
+ raise CannotLookupView(target) if list.empty?
147
+ raise MoreThanOneResult(target) if list.size > 1
148
+ return list[0]
149
+ end
150
+
151
+ private def validate_view_target(target)
152
+ raise ViewTargetNil if target.nil?
153
+
154
+ raise ViewTargetEmpty if target.to_s.strip.empty?
155
+
156
+ # Validate that target is a valid view name (alphanumeric, hyphen, underscore)
157
+ unless target.match?(/^[a-zA-Z0-9_-]+$/)
158
+ raise ViewTargetInvalid(target)
159
+ end
160
+ end
161
+
162
+ def view(change = nil) # get/set current view
163
+ return @current_view if change.nil?
164
+ vnew = change.is_a?(Scriptorium::View) ? change : lookup_view(change)
165
+ write_file(@root/:config/"currentview.txt", vnew.name)
166
+ @current_view = vnew
167
+ @current_view
168
+ end
169
+
170
+ def current_view
171
+ @current_view
172
+ end
173
+
174
+ def view_exist?(name)
175
+ Dir.exist?("#@root/views/#{name}")
176
+ end
177
+
178
+ def create_view(name, title, subtitle = "", theme: "standard")
179
+ msg = "name must be a String, got #{name.class}"
180
+ assume(msg) { name.is_a?(String) }
181
+ msg = "title must be a String, got #{title.class}"
182
+ assume(msg) { title.is_a?(String) }
183
+ validate_view_name(name)
184
+ validate_view_title(title)
185
+
186
+ # Validate name format (only allow alphanumeric, hyphen, underscore)
187
+ unless name.match?(/^[a-zA-Z0-9_-]+$/)
188
+ raise ViewNameInvalid(name)
189
+ end
190
+
191
+ raise ViewDirAlreadyExists(name) if view_exist?(name)
192
+ make_tree(@root/:views/name, <<~EOS)
193
+ .
194
+ ├── config/ # View-specific config files
195
+ │ ├── layout.txt # Overall layout for front page
196
+ │ ├── footer.txt # Content for footer.html
197
+ │ ├── header.txt # Content for header.html
198
+ │ ├── left.txt # Content for left.html
199
+ │ ├── main.txt # Content for main.html
200
+ │ └── right.txt # Content for right.html
201
+ ├── config.txt # View-specific config file # maybe call settings.txt?
202
+ ├── layout/ # Unused?
203
+ ├── pages/ # Static pages for view
204
+ ├── assets/ # Images, etc. (view-specific)
205
+ │ └── missing/ # Missing assets (SVG placeholder files)
206
+ ├── output/ # Output files (generated HTML)
207
+ │ ├── panes/ # Containers from layout.txt
208
+ │ │ ├── footer.html # Generated from footer.txt
209
+ │ │ ├── header.html # Generated from header.txt
210
+ │ │ ├── left.html # Generated from left.txt
211
+ │ │ ├── main.html # Generated from main.txt
212
+ │ │ └── right.html # Generated from right.txt
213
+ │ └── posts/ # Generated posts for view (slug.html)
214
+ ├── posts/ # Post state tracking
215
+ │ ├── unpublished.txt # Posts NOT published in this view (empty = all published)
216
+ │ └── undeployed.txt # Posts NOT published in this view (empty = all deployed)
217
+ ├── widgets/ # Widgets for view
218
+ └── staging/ # Staging area prior to deployment
219
+ EOS
220
+
221
+ ###
222
+
223
+ dir = "#@root/views/#{name}"
224
+
225
+ begin
226
+ write_file!(dir/"config.txt",
227
+ "title #{title}",
228
+ "subtitle #{subtitle}",
229
+ "theme #{theme}")
230
+
231
+ write_file(dir/:config/"global-head.txt", @predef.html_head_content(true)) # true = view-specific
232
+ copy_support_file('bootstrap/js.txt', dir/:config/"bootstrap_js.txt")
233
+ copy_support_file('bootstrap/css.txt', dir/:config/"bootstrap_css.txt")
234
+ # Highlight.js config files (renamed from prism_* for clarity)
235
+ copy_support_file('highlight/js.txt', dir/:config/"highlight_js.txt")
236
+ write_file(dir/:config/"highlight_ruby_js.txt", @predef.highlight_ruby_js)
237
+ copy_support_file('highlight/css.txt', dir/:config/"highlight_css.txt")
238
+ write_file(dir/:config/"common.js", @predef.common_js)
239
+ copy_support_file('config/social.txt', dir/:config/"social.txt")
240
+ copy_support_file('config/reddit.txt', dir/:config/"reddit.txt")
241
+ write_file(dir/:config/"deploy.txt", @predef.deploy_text % {view: name, domain: "example.com"})
242
+ write_file(dir/:config/"status.txt", @predef.status_txt)
243
+ copy_support_file('post_index/config.txt', dir/:config/"post_index.txt")
244
+
245
+ # Create view credentials directory and template
246
+ FileUtils.mkdir_p(dir/:credentials)
247
+ copy_support_file('config/reddit_template.txt', dir/:credentials/"reddit.txt")
248
+
249
+ # Copy essential icons to view assets directory
250
+ view_assets_dir = dir/:assets
251
+ FileUtils.mkdir_p(view_assets_dir)
252
+ FileUtils.mkdir_p(view_assets_dir/"icons"/"ui")
253
+ FileUtils.mkdir_p(view_assets_dir/"icons"/"social")
254
+
255
+ # Copy UI icons
256
+ if File.exist?(@root/:assets/"icons"/"ui"/"back.png")
257
+ FileUtils.cp(@root/:assets/"icons"/"ui"/"back.png", view_assets_dir/"icons"/"ui"/"back.png")
258
+ end
259
+
260
+ # Copy social icons
261
+ if File.exist?(@root/:assets/"icons"/"social"/"reddit.png")
262
+ FileUtils.cp(@root/:assets/"icons"/"social"/"reddit.png", view_assets_dir/"icons"/"social"/"reddit.png")
263
+ end
264
+
265
+ # Copy missing image placeholder
266
+ if File.exist?(@root/:assets/"imagenotfound.jpg")
267
+ FileUtils.cp(@root/:assets/"imagenotfound.jpg", view_assets_dir/"imagenotfound.jpg")
268
+ end
269
+
270
+ # Create post state tracking files
271
+ write_file(dir/:posts/"unpublished.txt", "") # Empty = all posts published
272
+ write_file(dir/:posts/"undeployed.txt", "") # Empty = all posts deployed
273
+
274
+ view = open_view(name)
275
+ rescue => e
276
+ # Clean up partial view directory if creation fails
277
+ FileUtils.rm_rf(dir) if Dir.exist?(dir)
278
+ raise CannotCreateView("Failed to create view '#{name}': #{e.message}")
279
+ end
280
+ @views -= [view]
281
+ @views << view
282
+ @current_view = view
283
+ write_file(@root/:config/"currentview.txt", view.name)
284
+ cfg = dir/:config # Should these be copied from theme??
285
+ theme_config = @root/:themes/theme/:layout/:config
286
+ containers = %w[header.txt footer.txt left.txt right.txt main.txt]
287
+ containers.each { |container| FileUtils.cp(theme_config/container, cfg/container) } # from theme to view
288
+
289
+ # Create default SVG configuration using standard files
290
+ write_file(cfg/"svg.txt", @predef.svg_txt)
291
+
292
+ view.apply_theme(theme)
293
+ verify { view.is_a?(Scriptorium::View) }
294
+ return view
295
+ end
296
+
297
+ def open_view(name)
298
+ config_file = view_dir(name)/"config.txt"
299
+ vhash = getvars(config_file)
300
+ title, subtitle, theme = vhash.values_at(:title, :subtitle, :theme)
301
+ view = Scriptorium::View.new(name, title, subtitle, theme)
302
+ @views -= [view]
303
+ @views << view
304
+ # Remove this line - current view should only be set from currentview.txt
305
+ # @current_view = view
306
+ # write_file(@root/:config/"currentview.txt", view.name)
307
+ view
308
+ end
309
+
310
+ def create_draft(title: nil, blurb: nil, views: nil, tags: nil, body: nil)
311
+ ts = Time.now.strftime("%Y%m%d-%H%M%S")
312
+ content_name = "#@root/drafts/#{ts}-draft.lt3"
313
+ metadata_name = "#@root/drafts/#{ts}-draft.meta"
314
+
315
+ # Whoa - what if different views have different themes??? FIXME
316
+ # Maybe solution is as simple as: Initial post is not theme-dependent
317
+ views ||= @current_view.name # initial_post wants a String!
318
+ views, tags = Array(views), Array(tags)
319
+
320
+ # Create content file (no ID, no created date)
321
+ content = @predef.initial_post(:filled, title: title, blurb: blurb,
322
+ views: views, tags: tags, body: body)
323
+ write_file(content_name, content)
324
+
325
+ # Create metadata file (no ID for drafts)
326
+ metadata = @predef.initial_post_metadata(title: title, blurb: blurb,
327
+ views: views, tags: tags)
328
+ write_file(metadata_name, metadata)
329
+
330
+ # Return the content file name (for backward compatibility)
331
+ content_name
332
+ end
333
+
334
+ def last_post_num
335
+ read_file(postnum_file).to_i
336
+ end
337
+
338
+ def incr_post_num
339
+ num = last_post_num + 1
340
+ write_file(postnum_file, num.to_s)
341
+ num
342
+ end
343
+
344
+ def finish_draft(name)
345
+ id = incr_post_num
346
+ id4 = d4(id)
347
+ posts = @root/:posts
348
+ make_dir(posts/id4)
349
+ make_dir(posts/id4/:assets)
350
+
351
+ # Move content file
352
+ FileUtils.mv(name, posts/id4/"source.lt3")
353
+
354
+ # Move metadata file (same timestamp, different extension)
355
+ metadata_name = name.sub('.lt3', '.meta')
356
+ FileUtils.mv(metadata_name, posts/id4/"meta.txt") if File.exist?(metadata_name)
357
+ id
358
+ end
359
+
360
+ def tree(file = nil)
361
+ cmd = "tree #@root"
362
+ cmd << " >#{file}" if file
363
+ system!(cmd, "generating tree structure")
364
+ end
365
+
366
+
367
+ private def write_post_metadata(data, view)
368
+ num, title = data.values_at(:"post.id", :"post.title")
369
+ metadata_file = @root/:posts/d4(num)/"meta.txt"
370
+
371
+ # Read existing metadata to preserve fields like post.published
372
+ existing_metadata = {}
373
+ existing_metadata = getvars(metadata_file) if File.exist?(metadata_file)
374
+
375
+ # Prepare new metadata from data
376
+ new_metadata = data.select {|k,v| k.to_s.start_with?("post.") }
377
+ new_metadata.delete(:"post.body")
378
+ new_metadata[:"post.slug"] = slugify(num, title) + ".html"
379
+
380
+ # Merge existing metadata over new metadata to preserve important fields
381
+ # Only preserve fields that should not be overwritten by source file changes
382
+ fields_to_preserve = [:"post.published", :"post.deployed", :"post.created"]
383
+ existing_metadata.each { |key, value| new_metadata[key] = value if fields_to_preserve.include?(key) }
384
+
385
+ lines = new_metadata.map { |k, v| sprintf("%-18s %s", k, v) }
386
+ write_file(metadata_file, lines.join("\n"))
387
+ end
388
+
389
+ private def write_generated_post(data, view, final)
390
+ num, title = data.values_at(:"post.id", :"post.title")
391
+ id4 = d4(num)
392
+ slug = slugify(num, title) + ".html"
393
+ # Write to:
394
+ # root/posts/0123/body.html meta.txt (assets/ draft.lt3)
395
+ top = @root/:posts/id4/"body.html"
396
+ write_file(top, final)
397
+ write_post_metadata(data, view)
398
+ # view/.../output/posts/0123-this-is-me.html
399
+ path = view.dir/:output/:posts/slug
400
+ write_file(path, final)
401
+ # view/.../output/permalink/0123-this-is-me.html (for direct access)
402
+ permalink_path = view.dir/:output/:permalink/slug
403
+ make_dir(File.dirname(permalink_path))
404
+ # Remove the Back-to-index block from permalink content
405
+ cleaned_final = final.gsub(/<div align='right'>.*?Back to index<\/a>\s*<\/div>\s*/m, "")
406
+ # Write the permalink version with "Visit Blog" link and "Copy link" button
407
+ permalink_content = cleaned_final + "\n<div style=\"text-align: center; margin-top: 20px;\">\n<a href=\"../index.html\">Visit Blog</a>\n</div>\n<div style=\"text-align: center; margin-top: 10px;\">\n<button onclick=\"copyPermalinkToClipboard()\" style=\"padding: 8px 16px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer;\">Copy link</button>\n</div>\n<script>\nfunction copyPermalinkToClipboard() {\n navigator.clipboard.writeText(window.location.href).then(function() {\n const button = event.target;\n const originalText = button.textContent;\n button.textContent = 'Copied!';\n button.style.background = '#28a745';\n setTimeout(function() {\n button.textContent = originalText;\n button.style.background = '#007bff';\n }, 2000);\n }).catch(function(err) {\n console.error('Failed to copy: ', err);\n alert('Failed to copy link to clipboard');\n });\n}\n</script>"
408
+ write_file(permalink_path, permalink_content)
409
+
410
+ # Create copy for clean URL (without numeric prefix)
411
+ clean_slug = clean_slugify(title) + ".html"
412
+ clean_copy_path = view.dir/:output/:permalink/clean_slug
413
+
414
+ # Remove existing file if it exists
415
+ File.delete(clean_copy_path) if File.exist?(clean_copy_path)
416
+
417
+ # Copy the permalink file to create clean URL
418
+ FileUtils.cp(permalink_path, clean_copy_path)
419
+ end
420
+
421
+ def create_post(title: nil, views: nil, tags: nil, body: nil, blurb: nil)
422
+ msg = "title must be nil or String, got #{title.class}"
423
+ assume(msg) { title.nil? || title.is_a?(String) }
424
+ msg = "views must be nil, Array, or String, got #{views.class}"
425
+ assume(msg) { views.nil? || views.is_a?(Array) || views.is_a?(String) }
426
+ msg = "tags must be nil, Array, or String, got #{tags.class}"
427
+ assume(msg) { tags.nil? || tags.is_a?(Array) || tags.is_a?(String) }
428
+ msg = "body must be nil or String, got #{body.class}"
429
+ assume(msg) { body.nil? || body.is_a?(String) }
430
+ msg = "blurb must be nil or String, got #{blurb.class}"
431
+ assume(msg) { blurb.nil? || blurb.is_a?(String) }
432
+ name = create_draft(title: title, views: views, tags: tags, body: body, blurb: blurb)
433
+ num = finish_draft(name)
434
+
435
+ # Add post to unpublished and undeployed lists for all views it belongs to
436
+ metadata_file = @root/:posts/d4(num)/"meta.txt"
437
+ if File.exist?(metadata_file)
438
+ metadata = getvars(metadata_file)
439
+ views = metadata[:"post.views"]&.strip&.split(/\s+/) || ["sample"]
440
+ views.each do |view_name|
441
+ view_obj = lookup_view(view_name)
442
+ unpublished_file = view_obj.dir/:posts/"unpublished.txt"
443
+ undeployed_file = view_obj.dir/:posts/"undeployed.txt"
444
+ add_post_to_state_file(unpublished_file, num)
445
+ add_post_to_state_file(undeployed_file, num)
446
+ end
447
+ end
448
+
449
+ generate_post(num)
450
+ post = self.post(num) # Return the Post object
451
+ verify { post.is_a?(Scriptorium::Post) }
452
+ post
453
+ end
454
+
455
+ def publish_post(num, view = nil)
456
+ validate_post_id(num)
457
+
458
+ # Check if post exists in normal location first
459
+ metadata_file = @root/:posts/d4(num)/"meta.txt"
460
+ unless File.exist?(metadata_file)
461
+ # Check if post is deleted
462
+ if post_deleted?(num)
463
+ raise PostDeleted(num)
464
+ else
465
+ raise CannotGetPost("Post #{num} not found")
466
+ end
467
+ end
468
+
469
+ # Read current metadata
470
+ metadata = getvars(metadata_file)
471
+
472
+ if view.nil?
473
+ # Use current view if no view specified
474
+ view = @current_view&.name || "sample"
475
+ end
476
+
477
+ # Check if already published in this view
478
+ view_obj = lookup_view(view)
479
+ unpublished_file = view_obj.dir/:posts/"unpublished.txt"
480
+ if !post_in_state_file?(unpublished_file, num)
481
+ raise PostAlreadyPublished(num)
482
+ end
483
+
484
+ # View-specific publish - only update the specified view's state
485
+ remove_post_from_state_file(unpublished_file, num)
486
+
487
+ # If this is the first time publishing this post, update global metadata
488
+ if metadata[:"post.published"] == "no" || metadata[:"post.published"].nil?
489
+ metadata[:"post.published"] = ymdhms
490
+
491
+ # Write updated metadata
492
+ lines = metadata.map { |k, v| sprintf("%-18s %s", k, v) }
493
+ write_file(metadata_file, lines.join("\n"))
494
+ end
495
+
496
+ self.post(num)
497
+ end
498
+
499
+ def mark_post_deployed(num, view = nil)
500
+ validate_post_id(num)
501
+
502
+ # Check if post exists in normal location first
503
+ metadata_file = @root/:posts/d4(num)/"meta.txt"
504
+ unless File.exist?(metadata_file)
505
+ # Check if post is deleted
506
+ if post_deleted?(num)
507
+ raise PostDeleted(num)
508
+ else
509
+ raise CannotGetPost("Post #{num} not found")
510
+ end
511
+ end
512
+
513
+ if view.nil?
514
+ # Use current view if no view specified
515
+ view = @current_view&.name || "sample"
516
+ end
517
+
518
+ # Check if already deployed in this view
519
+ view_obj = lookup_view(view)
520
+ undeployed_file = view_obj.dir/:posts/"undeployed.txt"
521
+ if !post_in_state_file?(undeployed_file, num)
522
+ raise PostAlreadyDeployed(num)
523
+ end
524
+
525
+ # Validate that only published posts can be deployed
526
+ unless post_published?(num, view)
527
+ raise PostNotPublished(num)
528
+ end
529
+
530
+ # Remove from undeployed list for this view
531
+ remove_post_from_state_file(undeployed_file, num)
532
+
533
+ # Update global metadata if this is the first deployment
534
+ metadata_file = @root/:posts/d4(num)/"meta.txt"
535
+ if File.exist?(metadata_file)
536
+ metadata = getvars(metadata_file)
537
+ if metadata[:"post.deployed"] == "no" || metadata[:"post.deployed"].nil?
538
+ metadata[:"post.deployed"] = ymdhms
539
+ lines = metadata.map { |k, v| sprintf("%-18s %s", k, v) }
540
+ write_file(metadata_file, lines.join("\n"))
541
+ end
542
+ else
543
+ raise CannotGetPost("Post #{num} metadata not found")
544
+ end
545
+ end
546
+
547
+ def mark_post_undeployed(num, view = nil)
548
+ validate_post_id(num)
549
+
550
+ if view.nil?
551
+ # Use current view if no view specified
552
+ view = @current_view&.name || "sample"
553
+ end
554
+
555
+ # Add to undeployed list for this view
556
+ view_obj = lookup_view(view)
557
+ undeployed_file = view_obj.dir/:posts/"undeployed.txt"
558
+ add_post_to_state_file(undeployed_file, num)
559
+
560
+ # Update global metadata
561
+ metadata_file = @root/:posts/d4(num)/"meta.txt"
562
+ if File.exist?(metadata_file)
563
+ metadata = getvars(metadata_file)
564
+ metadata[:"post.deployed"] = "no"
565
+ lines = metadata.map { |k, v| sprintf("%-18s %s", k, v) }
566
+ write_file(metadata_file, lines.join("\n"))
567
+ end
568
+ end
569
+
570
+ def post_deployed?(num, view = nil)
571
+ validate_post_id(num)
572
+
573
+ # If no specific view, check global metadata (for backward compatibility)
574
+ if view.nil?
575
+ metadata_file = @root/:posts/d4(num)/"meta.txt"
576
+ return false unless File.exist?(metadata_file)
577
+
578
+ metadata = getvars(metadata_file)
579
+ return metadata[:"post.deployed"] != "no"
580
+ end
581
+
582
+ # Check view-specific deployed status
583
+ view = lookup_view(view)
584
+ undeployed_file = view.dir/:posts/"undeployed.txt"
585
+ !post_in_state_file?(undeployed_file, num)
586
+ end
587
+
588
+ def get_deployed_posts(view = nil)
589
+ all_posts = all_posts(view)
590
+
591
+ if view.nil?
592
+ # If no specific view, use global metadata (for backward compatibility)
593
+ all_posts.select { |post| post_deployed?(post.id) }
594
+ else
595
+ # Use view-specific deployed status
596
+ view = lookup_view(view)
597
+ undeployed_file = view.dir/:posts/"undeployed.txt"
598
+ all_posts.reject { |post| post_in_state_file?(undeployed_file, post.id) }
599
+ end
600
+ end
601
+
602
+ def unpublish_post(num, view = nil)
603
+ validate_post_id(num)
604
+
605
+ # Check if post is deployed in any view (including current view if none specified)
606
+ if view.nil?
607
+ view = @current_view&.name || "sample"
608
+ end
609
+
610
+ # Check if post is deployed in this view
611
+ view_obj = lookup_view(view)
612
+ if post_deployed?(num, view)
613
+ raise PostAlreadyDeployed(num)
614
+ end
615
+
616
+ # Always use view-specific logic when a specific view is provided
617
+ # Add to view-specific unpublished list
618
+ unpublished_file = view_obj.dir/:posts/"unpublished.txt"
619
+ add_post_to_state_file(unpublished_file, num)
620
+
621
+ # Also update global metadata to "no" if this was the first published view
622
+ metadata_file = @root/:posts/d4(num)/"meta.txt"
623
+ if File.exist?(metadata_file)
624
+ metadata = getvars(metadata_file)
625
+ metadata[:"post.published"] = "no"
626
+ lines = metadata.map { |k, v| sprintf("%-18s %s", k, v) }
627
+ write_file(metadata_file, lines.join("\n"))
628
+ end
629
+
630
+ # Regenerate the post
631
+ generate_post(num)
632
+ end
633
+
634
+ def post_published?(num, view = nil)
635
+ validate_post_id(num)
636
+
637
+ # If no specific view, check global metadata (for backward compatibility)
638
+ if view.nil?
639
+ metadata_file = @root/:posts/d4(num)/"meta.txt"
640
+ return false unless File.exist?(metadata_file)
641
+
642
+ metadata = getvars(metadata_file)
643
+ return metadata[:"post.published"] != "no"
644
+ end
645
+
646
+ # Check view-specific published status
647
+ view = lookup_view(view)
648
+ unpublished_file = view.dir/:posts/"unpublished.txt"
649
+ !post_in_state_file?(unpublished_file, num)
650
+ end
651
+
652
+ def post_deleted?(num)
653
+ validate_post_id(num)
654
+
655
+ # Check deleted location first (with underscore prefix)
656
+ deleted_metadata_file = @root/:posts/"_#{d4(num)}"/"meta.txt"
657
+ if File.exist?(deleted_metadata_file)
658
+ return true
659
+ end
660
+
661
+ # Check normal location
662
+ metadata_file = @root/:posts/d4(num)/"meta.txt"
663
+ return false unless File.exist?(metadata_file)
664
+
665
+ metadata = getvars(metadata_file)
666
+ metadata[:"post.deleted"] == "yes"
667
+ end
668
+
669
+ def generate_post(num)
670
+ content_file = @root/:posts/d4(num)/"source.lt3"
671
+ metadata_file = @root/:posts/d4(num)/"meta.txt"
672
+
673
+ need(:file, content_file)
674
+
675
+ # Read content file
676
+ vars = { View: @current_view.name, :"post.id" => num }
677
+ # Mark transform as post-context
678
+ vars[:"post.context"] = "post"
679
+
680
+ # Merge metadata into vars if metadata file exists
681
+ if File.exist?(metadata_file)
682
+ metadata = getvars(metadata_file)
683
+ vars.merge!(metadata)
684
+ end
685
+
686
+ live = Livetext.customize(mix: "lt3scriptor", call: ".nopara", vars: vars)
687
+ body, vars = live.process(file: content_file)
688
+
689
+ # Debug: Write vars to file
690
+ File.open("/tmp/debug_vars_#{num}.txt", "w") do |f|
691
+ f.puts "Vars after LiveText processing:"
692
+ vars.each { |k, v| f.puts "#{k} = #{v.inspect}" }
693
+ end
694
+
695
+ # Use metadata from LiveText processing
696
+ # Filter vars to only include post.* fields for metadata
697
+ metadata_vars = vars.select {|k,v| k.to_s.start_with?("post.") }
698
+ metadata_vars.delete(:"post.body")
699
+ metadata_vars[:"post.slug"] = slugify(num, vars[:"post.title"]) + ".html"
700
+ metadata_vars[:"post.published"] = "no"
701
+ metadata_vars[:"post.deployed"] = "no"
702
+
703
+ if vars[:"post.created"]
704
+ time = Time.parse(vars[:"post.created"])
705
+ metadata_vars["post.pubdate"] = time.strftime("%Y-%m-%d %H:%M:%S")
706
+ metadata_vars["post.pubdate.month"] = time.strftime("%B")
707
+ metadata_vars["post.pubdate.day"] = time.strftime("%e")
708
+ metadata_vars["post.pubdate.year"] = time.strftime("%Y")
709
+ end
710
+
711
+ # Write metadata file
712
+ lines = metadata_vars.map { |k, v| sprintf("%-18s %s", k, v) }
713
+ write_file(metadata_file, lines.join("\n"))
714
+
715
+ views = (vars[:"post.views"] || "").strip.split(/\s+/)
716
+ vars[:"post.views"] = views.join(" ") # Ensure post.views is set in vars
717
+ views.each do |view|
718
+ view = lookup_view(view)
719
+ vars[:"post.id"] = num.to_s # Always use the post number as ID
720
+ vars[:"post.body"] = body
721
+ vars[:"post.date"] = self.post(num).date # Set post.date for templates
722
+
723
+
724
+ template = support_data('templates/post.lt3')
725
+ # Add Reddit button if enabled
726
+ vars[:"reddit_button"] = view.generate_reddit_button(vars)
727
+ final = substitute(vars, template)
728
+ write_generated_post(vars, view, final)
729
+ end
730
+ end
731
+
732
+
733
+
734
+ def all_posts(view = nil)
735
+ posts = []
736
+ dirs = Dir.children(@root/:posts)
737
+ dirs.each do |id4|
738
+ # Skip deleted posts (directories starting with underscore)
739
+ next if id4.start_with?('_')
740
+ posts << Scriptorium::Post.read(self, id4)
741
+ end
742
+ return posts if view.nil?
743
+ view = lookup_view(view)
744
+ posts.select {|x| x.views.include?(view.name) }
745
+ end
746
+
747
+ def all_posts_including_deleted(view = nil)
748
+ posts = []
749
+ dirs = Dir.children(@root/:posts)
750
+ dirs.each do |id4|
751
+ # Include both normal and deleted posts
752
+ if id4.start_with?('_')
753
+ # Deleted post - remove underscore prefix and pass deleted: true
754
+ original_id = id4[1..-1]
755
+ posts << Scriptorium::Post.read(self, original_id, deleted: true)
756
+ else
757
+ posts << Scriptorium::Post.read(self, id4)
758
+ end
759
+ end
760
+ return posts if view.nil?
761
+ view = lookup_view(view)
762
+ posts.select {|x|
763
+ views_str = x.views
764
+ if views_str.nil? || views_str.strip.empty?
765
+ false
766
+ else
767
+ views_str.strip.split(/\s+/).include?(view.name)
768
+ end
769
+ }
770
+ end
771
+
772
+ def generate_post_index(view)
773
+ view = lookup_view(view)
774
+ view.generate_post_index
775
+ end
776
+
777
+ def post(id)
778
+ validate_post_id(id)
779
+
780
+ # Check normal directory first
781
+ meta = @root/:posts/d4(id)/"meta.txt"
782
+ return Scriptorium::Post.new(self, id) if File.exist?(meta)
783
+
784
+ # Check deleted directory (with underscore prefix)
785
+ deleted_meta = @root/:posts/"_#{d4(id)}"/"meta.txt"
786
+ return Scriptorium::Post.new(self, id) if File.exist?(deleted_meta)
787
+
788
+ # Post not found in either location
789
+ raise CannotGetPost("Post with ID #{id} not found")
790
+ end
791
+
792
+ def delete_post(num)
793
+ validate_post_id(num)
794
+
795
+ # Check if post exists
796
+ metadata_file = @root/:posts/d4(num)/"meta.txt"
797
+ unless File.exist?(metadata_file)
798
+ raise CannotGetPost("Post #{num} not found")
799
+ end
800
+
801
+ # Check if already deleted
802
+ if post_deleted?(num)
803
+ raise PostAlreadyDeleted(num)
804
+ end
805
+
806
+ # Mark as deleted in metadata
807
+ metadata = getvars(metadata_file)
808
+ metadata[:"post.deleted"] = "yes"
809
+ metadata[:"post.deleted_at"] = ymdhms
810
+
811
+ # Write updated metadata
812
+ lines = metadata.map { |k, v| sprintf("%-18s %s", k, v) }
813
+ write_file(metadata_file, lines.join("\n"))
814
+
815
+ # Move post directory to deleted location (with underscore prefix)
816
+ post_dir = @root/:posts/d4(num)
817
+ deleted_dir = @root/:posts/"_#{d4(num)}"
818
+
819
+ if Dir.exist?(post_dir)
820
+ FileUtils.mv(post_dir, deleted_dir)
821
+ end
822
+ end
823
+
824
+ def undelete_post(num)
825
+ validate_post_id(num)
826
+
827
+ # Check if post exists in deleted location
828
+ deleted_dir = @root/:posts/"_#{d4(num)}"
829
+ unless Dir.exist?(deleted_dir)
830
+ raise CannotGetPost("Deleted post #{num} not found")
831
+ end
832
+
833
+ # Check if already undeleted
834
+ unless post_deleted?(num)
835
+ raise PostNotDeleted(num)
836
+ end
837
+
838
+ # Move post directory back to normal location
839
+ post_dir = @root/:posts/d4(num)
840
+ FileUtils.mv(deleted_dir, post_dir)
841
+
842
+ # Remove deleted flag from metadata
843
+ metadata_file = @root/:posts/d4(num)/"meta.txt"
844
+ if File.exist?(metadata_file)
845
+ metadata = getvars(metadata_file)
846
+ metadata.delete(:"post.deleted")
847
+ metadata.delete(:"post.deleted_at")
848
+
849
+ # Write updated metadata
850
+ lines = metadata.map { |k, v| sprintf("%-18s %s", k, v) }
851
+ write_file(metadata_file, lines.join("\n"))
852
+ end
853
+ end
854
+
855
+
856
+
857
+ private def validate_post_id(id)
858
+ raise PostIdNil if id.nil?
859
+
860
+ raise PostIdEmpty if id.to_s.strip.empty?
861
+
862
+ unless id.to_s.match?(/^\d+$/)
863
+ raise PostIdInvalid(id)
864
+ end
865
+ end
866
+
867
+ def generate_front_page(view)
868
+ view = lookup_view(view)
869
+ view.generate_front_page
870
+ end
871
+
872
+ # Reddit integration
873
+ def reddit
874
+ @reddit ||= Scriptorium::Reddit.new(self)
875
+ end
876
+
877
+ def autopost_to_reddit(post_data, subreddit = nil)
878
+ reddit.autopost(post_data, subreddit)
879
+ end
880
+
881
+ def reddit_configured?
882
+ reddit.configured?
883
+ end
884
+
885
+
886
+
887
+ private def validate_view_name(name)
888
+ raise ViewNameNil if name.nil?
889
+
890
+ raise ViewNameEmpty if name.to_s.strip.empty?
891
+ end
892
+
893
+
894
+
895
+ private def validate_view_title(title)
896
+ raise ViewTitleNil if title.nil?
897
+
898
+ raise ViewTitleEmpty if title.to_s.strip.empty?
899
+ end
900
+
901
+
902
+
903
+ def self.generate_os_helpers(root)
904
+ os_code = case RbConfig::CONFIG['host_os']
905
+ when /darwin/ # macOS
906
+ <<~RUBY
907
+ # Generated at repo creation for macOS
908
+ def open_file(file_path)
909
+ system("open", file_path)
910
+ end
911
+ RUBY
912
+ when /linux/ # Linux
913
+ <<~RUBY
914
+ # Generated at repo creation for Linux
915
+ def open_file(file_path)
916
+ system("xdg-open", file_path)
917
+ end
918
+ RUBY
919
+ when /mswin|mingw|cygwin/ # Windows
920
+ <<~RUBY
921
+ # Generated at repo creation for Windows
922
+ def open_file(file_path)
923
+ system("start", file_path)
924
+ end
925
+ RUBY
926
+ else
927
+ <<~RUBY
928
+ # Generated at repo creation for unknown OS
929
+ def open_file(file_path)
930
+ puts " Unable to open file on this OS"
931
+ end
932
+ RUBY
933
+ end
934
+
935
+ write_file(root/:config/"os_helpers.rb", os_code)
936
+ end
937
+
938
+ end