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
data/ui/web/app/app.rb ADDED
@@ -0,0 +1,2600 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'optparse'
4
+
5
+ # Parse command line arguments for test mode BEFORE requiring Sinatra
6
+ # Starting web app, ARGV: #{ARGV.inspect}
7
+ TEST_MODE = false
8
+ OptionParser.new do |opts|
9
+ opts.on("--test", "Use test repository (scriptorium-TEST)") do
10
+ TEST_MODE = true
11
+ # --test flag detected
12
+ end
13
+ end.parse!
14
+
15
+ # Command line parsing complete, test_mode: #{TEST_MODE}
16
+ # ARGV remaining: #{ARGV.inspect}
17
+
18
+ require 'sinatra'
19
+ require 'sinatra/reloader' if development?
20
+ require 'fileutils'
21
+ require 'pathname'
22
+ begin
23
+ require 'fastimage'
24
+ rescue LoadError
25
+ # FastImage not available, will handle gracefully
26
+ end
27
+ require_relative '../../../lib/scriptorium'
28
+ require_relative 'error_helpers'
29
+
30
+ class ScriptoriumWeb < Sinatra::Base
31
+ include ErrorHelpers
32
+ include Scriptorium::Helpers
33
+
34
+ set :port, 4567
35
+ set :bind, '0.0.0.0'
36
+ set :views, File.join(__dir__, 'views')
37
+ set :show_exceptions, false # Disable Sinatra's default error display
38
+
39
+ # Configure static file serving for assets
40
+ configure do
41
+ # Set public folder to serve static files from the current view's output directory
42
+ # This will be updated dynamically based on the current view
43
+ set :public_folder, File.join(__dir__, '..', 'scriptorium-TEST', 'views', 'computing', 'output')
44
+ end
45
+
46
+ # Update static file serving based on current view
47
+ before do
48
+ if @api&.current_view
49
+ public_path = @api.root/"views"/@api.current_view.name/"output"
50
+ if Dir.exist?(public_path)
51
+ settings.public_folder = public_path.to_s
52
+ end
53
+ end
54
+ end
55
+
56
+ # Helper method to render dashboard with error/message
57
+ def render_dashboard(error: nil, message: nil)
58
+ @error = error
59
+ @message = message
60
+ @current_view = @api&.current_view
61
+ @views = @api&.views || []
62
+ @posts = []
63
+ erb :dashboard
64
+ end
65
+
66
+ # Helper method to add file/line info to error messages
67
+ def error_with_location(error, message)
68
+ error_location = "#{error.backtrace.first}" if error.backtrace
69
+ result = message
70
+ result += " (#{error_location})" if error_location
71
+ result
72
+ end
73
+
74
+ # Set test mode
75
+ def self.test_mode=(value)
76
+ @@test_mode = value
77
+ end
78
+
79
+ def self.test_mode
80
+ @@test_mode
81
+ end
82
+
83
+ # Enable reloading in development
84
+ configure :development do
85
+ register Sinatra::Reloader
86
+ end
87
+
88
+ # Global error handler
89
+ error do
90
+ error_info = friendly_error_message(env['sinatra.error'])
91
+ @error = error_info[:message]
92
+ @suggestion = error_info[:suggestion]
93
+ erb :error_page
94
+ end
95
+
96
+ # Initialize API instance
97
+ before do
98
+ begin
99
+ # Use the TEST_MODE constant that was set before OptionParser consumed ARGV
100
+ # Before block - test_mode: #{TEST_MODE}
101
+ @api = Scriptorium::API.new(testmode: TEST_MODE)
102
+
103
+ if TEST_MODE
104
+ # Use test repository in the ui/web/ directory
105
+ test_repo_path = File.join(__dir__, "..", "scriptorium-TEST")
106
+ # Opening test repo: #{test_repo_path}
107
+ @api.open_repo(test_repo_path) if Dir.exist?(test_repo_path)
108
+ else
109
+ # Use production repository
110
+ home = ENV['HOME']
111
+ production_path = "#{home}/.scriptorium"
112
+ # Opening production repo: #{production_path}
113
+ @api.open_repo(production_path) if Dir.exist?(production_path)
114
+ end
115
+ rescue => e
116
+ # Error in before block: #{e.message}
117
+ @api = nil
118
+ end
119
+ end
120
+
121
+ # Main dashboard
122
+ get '/' do
123
+ @current_view = @api&.current_view
124
+ @views = @api&.views || []
125
+ begin
126
+ if @api&.instance_variable_get(:@repo)
127
+
128
+ # Only try to load posts if we have a current view
129
+ if @current_view
130
+ File.write("/tmp/debug.log", "DEBUG: Route reached, current_view: #{@current_view.name}\n", mode: 'a')
131
+ @posts = @api.posts(@current_view.name, include_deleted: true) || []
132
+ File.write("/tmp/debug.log", "DEBUG: Posts loaded: #{@posts.length}\n", mode: 'a')
133
+ if @posts.length > 0
134
+ end
135
+ else
136
+ File.write("/tmp/debug.log", "DEBUG: No current view\n", mode: 'a')
137
+ @posts = []
138
+ end
139
+ else
140
+ @posts = []
141
+ end
142
+ rescue => e
143
+ @posts = []
144
+ end
145
+ @error = @error || params[:error]
146
+ @message = params[:message]
147
+
148
+ erb :dashboard
149
+ end
150
+
151
+ # Change view
152
+ post '/change_view' do
153
+ view_name = params[:view_name]
154
+
155
+ if view_name.nil? || view_name.strip.empty?
156
+ render_dashboard(error: "No view selected")
157
+ return
158
+ end
159
+
160
+ begin
161
+ view = @api.lookup_view(view_name)
162
+ @api.view(view_name)
163
+ render_dashboard(message: "View changed successfully")
164
+ rescue => e
165
+ render_dashboard(error: error_with_location(e, "Failed to change view: #{e.message}"))
166
+ end
167
+ end
168
+
169
+ # Create new repository
170
+ post '/create_repo' do
171
+ begin
172
+ @api.create_repo("scriptorium-TEST")
173
+ # After creating, open the repo
174
+ @api.open_repo("scriptorium-TEST")
175
+ redirect '/?message=Repository created successfully'
176
+ rescue => e
177
+ redirect "/?error=Failed to create repository: #{e.message}"
178
+ end
179
+ end
180
+
181
+ # Create new view
182
+ post '/create_view' do
183
+ begin
184
+ validate_required_params(params, :name, :title)
185
+
186
+ name = params[:name].strip
187
+ title = params[:title].strip
188
+ subtitle = params[:subtitle]&.strip || ""
189
+
190
+ @api.create_view(name, title, subtitle, theme: "standard")
191
+ redirect "/?message=View '#{name}' created successfully"
192
+ rescue => e
193
+ error_info = friendly_error_message(e)
194
+ redirect "/?error=#{error_info[:message]}&suggestion=#{error_info[:suggestion]}"
195
+ end
196
+ end
197
+
198
+ # Create new post
199
+ post '/create_post' do
200
+ begin
201
+ validate_required_params(params, :title)
202
+
203
+ current_view = @api&.current_view
204
+ if current_view.nil?
205
+ redirect "/?error=No view selected. Please select a view first."
206
+ return
207
+ end
208
+
209
+ # Get selected views from checkboxes
210
+ selected_views = params[:views] || [current_view.name]
211
+ selected_views = [current_view.name] if selected_views.empty?
212
+
213
+ # Process tags
214
+ tags = params[:tags]&.strip
215
+ tags = tags&.split(',')&.map(&:strip) if tags && !tags.empty?
216
+
217
+ # Create a draft first
218
+ draft_path = @api.create_draft(
219
+ title: params[:title].strip,
220
+ body: "", # Empty body to start
221
+ views: selected_views,
222
+ tags: tags,
223
+ blurb: params[:blurb]&.strip
224
+ )
225
+
226
+ # Convert draft to post immediately
227
+ post_num = @api.finish_draft(draft_path)
228
+ # Generate the post to create meta.txt and other files
229
+ begin
230
+ @api.generate_post(post_num)
231
+ # Check if meta.txt was created
232
+ meta_file = @api.root/"posts"/"#{post_num.to_s.rjust(4, '0')}"/"meta.txt"
233
+ # Redirect back to dashboard with modal parameter to open CodeMirror editor
234
+ redirect "/view/#{current_view.name}?edit_post=#{post_num}"
235
+ rescue => e
236
+ # Log the actual error for debugging
237
+ STDERR.puts "ERROR in generate_post: #{e.class}: #{e.message}"
238
+ STDERR.puts e.backtrace.join("\n")
239
+ error_info = friendly_error_message(e)
240
+ redirect "/?error=#{error_info[:message]}&suggestion=#{error_info[:suggestion]}"
241
+ end
242
+ rescue => e
243
+ error_info = friendly_error_message(e)
244
+ redirect "/?error=#{error_info[:message]}&suggestion=#{error_info[:suggestion]}"
245
+ end
246
+ end
247
+
248
+ # Edit post (redirects to file editing)
249
+ post '/edit_post' do
250
+ begin
251
+ validate_required_params(params, :post_id)
252
+
253
+ unless validate_post_id(params[:post_id])
254
+ redirect "/?error=Invalid post ID&suggestion=Please provide a valid post number."
255
+ return
256
+ end
257
+
258
+ post = @api.post(params[:post_id].to_i)
259
+ if post.nil?
260
+ redirect "/?error=Post not found&suggestion=The post may have been deleted or moved."
261
+ return
262
+ end
263
+
264
+ # Redirect back to the view dashboard
265
+ current_view = @api&.current_view
266
+ if current_view
267
+ redirect "/view/#{current_view.name}?message=Post saved successfully"
268
+ else
269
+ redirect "/?message=Post saved successfully"
270
+ end
271
+ rescue => e
272
+ error_info = friendly_error_message(e)
273
+ redirect "/?error=#{error_info[:message]}&suggestion=#{error_info[:suggestion]}"
274
+ end
275
+ end
276
+
277
+ # API endpoint to get post content for modal
278
+ get '/api/post_content/:id' do
279
+ begin
280
+ post_id = params[:id].to_i
281
+ post = @api.post(post_id)
282
+
283
+ if post.nil?
284
+ status 404
285
+ return "Post not found"
286
+ end
287
+
288
+ # Read the source file
289
+ source_file = @api.root/"posts"/"#{post.num.to_s.rjust(4, '0')}"/"source.lt3"
290
+ if File.exist?(source_file)
291
+ content = File.read(source_file)
292
+ content_type :text
293
+ content
294
+ else
295
+ status 404
296
+ "Source file not found"
297
+ end
298
+ rescue => e
299
+ status 500
300
+ "Error loading post content: #{e.message}"
301
+ end
302
+ end
303
+
304
+ # Show edit post page
305
+ get '/edit_post/:id' do
306
+ post_id = params[:id]&.to_i
307
+
308
+ if post_id.nil? || post_id <= 0
309
+ redirect "/?error=Invalid post ID"
310
+ return
311
+ end
312
+
313
+ begin
314
+ @post = @api.post(post_id)
315
+ if @post.nil?
316
+ redirect "/?error=Post not found"
317
+ return
318
+ end
319
+
320
+ # Read the source file content
321
+ source_file = @api.root/"posts"/@post.num/"source.lt3"
322
+ if File.exist?(source_file)
323
+ @content = read_file(source_file)
324
+ else
325
+ @content = "# #{@post.title}\n\n"
326
+ end
327
+
328
+ # Set current view for template
329
+ @current_view = @api&.current_view
330
+
331
+ erb :edit_post
332
+ rescue => e
333
+ redirect "/?error=Failed to load post: #{e.message}"
334
+ end
335
+ end
336
+
337
+ # Save edited post
338
+ post '/save_post/:id' do
339
+ begin
340
+ File.write('/tmp/save_post_debug.log', "=== SAVE POST ATTEMPT ===\n", mode: 'a')
341
+ File.write('/tmp/save_post_debug.log', "Time: #{Time.now}\n", mode: 'a')
342
+ File.write('/tmp/save_post_debug.log', "Post ID: #{params[:id]}\n", mode: 'a')
343
+ File.write('/tmp/save_post_debug.log', "Content length: #{params[:content]&.length || 0}\n", mode: 'a')
344
+
345
+ File.write('/tmp/save_post_debug.log', "API instance: #{@api.inspect}\n", mode: 'a')
346
+
347
+ post_id = params[:id]&.to_i
348
+ content = params[:content]
349
+
350
+ if post_id.nil? || post_id <= 0
351
+ File.write('/tmp/save_post_debug.log', "ERROR: Invalid post ID\n", mode: 'a')
352
+ redirect "/?error=Invalid post ID"
353
+ return
354
+ end
355
+
356
+ if content.nil?
357
+ File.write('/tmp/save_post_debug.log', "ERROR: No content provided\n", mode: 'a')
358
+ redirect "/edit_post/#{post_id}?error=No content provided"
359
+ return
360
+ end
361
+
362
+ File.write('/tmp/save_post_debug.log', "Looking up post #{post_id}\n", mode: 'a')
363
+ post = @api.post(post_id)
364
+ if post.nil?
365
+ File.write('/tmp/save_post_debug.log', "ERROR: Post not found\n", mode: 'a')
366
+ redirect "/?error=Post not found"
367
+ return
368
+ end
369
+
370
+ File.write('/tmp/save_post_debug.log', "Post found: #{post.inspect}\n", mode: 'a')
371
+ File.write('/tmp/save_post_debug.log', "Post num: #{post.num}\n", mode: 'a')
372
+
373
+ # Write the content to the source file
374
+ source_file = @api.root/"posts"/post.num/"source.lt3"
375
+ File.write('/tmp/save_post_debug.log', "Source file: #{source_file}\n", mode: 'a')
376
+ write_file(source_file, content)
377
+ File.write('/tmp/save_post_debug.log', "File written successfully\n", mode: 'a')
378
+
379
+ # Generate the post after saving
380
+ File.write('/tmp/save_post_debug.log', "Generating post...\n", mode: 'a')
381
+ begin
382
+ @api.generate_post(post_id)
383
+ File.write('/tmp/save_post_debug.log', "Post generated successfully\n", mode: 'a')
384
+ rescue => e
385
+ File.write('/tmp/save_post_debug.log', "Generate post failed: #{e.class}: #{e.message}\n", mode: 'a')
386
+ File.write('/tmp/save_post_debug.log', "Backtrace: #{e.backtrace.first(3).join("\n")}\n", mode: 'a')
387
+ raise e
388
+ end
389
+
390
+ # Regenerate the view index to include the updated post
391
+ File.write('/tmp/save_post_debug.log', "Regenerating view index...\n", mode: 'a')
392
+ begin
393
+ current_view = @api&.current_view
394
+ if current_view
395
+ @api.generate_view(current_view.name)
396
+ File.write('/tmp/save_post_debug.log', "View index regenerated successfully\n", mode: 'a')
397
+ end
398
+ rescue => e
399
+ File.write('/tmp/save_post_debug.log', "View regeneration failed: #{e.class}: #{e.message}\n", mode: 'a')
400
+ # Don't fail the save if view regeneration fails
401
+ end
402
+
403
+ File.write('/tmp/save_post_debug.log', "SUCCESS: Redirecting to view dashboard\n", mode: 'a')
404
+ current_view = @api&.current_view
405
+ if current_view
406
+ redirect "/view/#{current_view.name}?message=Post saved successfully"
407
+ else
408
+ redirect "/?message=Post ##{post_id} saved and generated successfully"
409
+ end
410
+ rescue => e
411
+ File.write('/tmp/save_post_debug.log', "EXCEPTION: #{e.class}: #{e.message}\n", mode: 'a')
412
+ File.write('/tmp/save_post_debug.log', "Backtrace: #{e.backtrace.first(5).join("\n")}\n", mode: 'a')
413
+ error_location = e.backtrace&.first || "unknown location"
414
+ redirect "/edit_post/#{post_id}?error=Failed to save post: #{e.message} at #{error_location}"
415
+ end
416
+ end
417
+
418
+ # Generate post
419
+ post '/generate_post' do
420
+ post_id = params[:post_id]&.to_i
421
+
422
+ if post_id.nil? || post_id <= 0
423
+ redirect "/?error=Invalid post ID"
424
+ return
425
+ end
426
+
427
+ begin
428
+ post = @api.post(post_id)
429
+ if post.nil?
430
+ redirect "/?error=Post not found"
431
+ return
432
+ end
433
+
434
+ # Generate the post
435
+ @api.generate_post(post_id)
436
+ redirect "/?message=Post ##{post_id} generated successfully"
437
+ rescue => e
438
+ redirect "/?error=Failed to generate post: #{e.message}"
439
+ end
440
+ end
441
+
442
+ # Generate view
443
+ post '/generate_view' do
444
+ view_name = params[:view_name]
445
+
446
+ begin
447
+ if view_name.nil? || view_name.strip.empty?
448
+ render_dashboard(error: "No view specified")
449
+ return
450
+ end
451
+
452
+ # Generate the view
453
+ @api.generate_view(view_name)
454
+ render_dashboard(message: "View '#{view_name}' generated successfully")
455
+ rescue => e
456
+ render_dashboard(error: error_with_location(e, "Failed to generate view: #{e.message}"))
457
+ end
458
+ end
459
+
460
+ # Preview view
461
+ get '/preview' do
462
+ @current_view = @api&.current_view
463
+ if @current_view.nil?
464
+ render_dashboard(error: "No view selected. Please select a view first.")
465
+ return
466
+ end
467
+
468
+ begin
469
+ # Generate the view first to ensure it's up to date
470
+ @api.generate_view(@current_view.name)
471
+
472
+ # Redirect to the index route under /preview/:view_name so relative links resolve
473
+ redirect "/preview/#{@current_view.name}/index.html"
474
+ rescue => e
475
+ render_dashboard(error: error_with_location(e, "Failed to preview view: #{e.message}"))
476
+ end
477
+ end
478
+
479
+ # Preview specific view index
480
+ get '/preview/:view_name/index.html' do
481
+ view_name = params[:view_name]
482
+
483
+ begin
484
+ if view_name.nil? || view_name.strip.empty?
485
+ status 400
486
+ return "Bad request: missing view name"
487
+ end
488
+
489
+ # Generate the view to ensure it's up to date
490
+ @api.generate_view(view_name)
491
+
492
+ # Serve the generated index.html file
493
+ index_file = @api.root/"views"/view_name/"output"/"index.html"
494
+ if File.exist?(index_file)
495
+ content_type :html
496
+ read_file(index_file)
497
+ else
498
+ status 404
499
+ "Index file not found for view: #{view_name}"
500
+ end
501
+ rescue => e
502
+ status 500
503
+ "Error loading view: #{e.message}"
504
+ end
505
+ end
506
+
507
+ # Serve post_index.html fragment for SPA back navigation
508
+ get '/preview/:view_name/post_index.html' do
509
+ view_name = params[:view_name]
510
+ begin
511
+ if view_name.nil? || view_name.strip.empty?
512
+ status 400
513
+ return "Bad request: missing view name"
514
+ end
515
+ # Ensure view is generated
516
+ @api.generate_view(view_name)
517
+ fragment = @api.root/"views"/view_name/"output"/"post_index.html"
518
+ if File.exist?(fragment)
519
+ content_type :html
520
+ read_file(fragment)
521
+ else
522
+ status 404
523
+ "Not found"
524
+ end
525
+ rescue => e
526
+ status 500
527
+ "Error loading post_index: #{e.message}"
528
+ end
529
+ end
530
+
531
+ # Serve post files for preview
532
+ get '/preview/:view_name/posts/:filename' do
533
+ view_name = params[:view_name]
534
+ filename = params[:filename]
535
+
536
+ begin
537
+ if view_name.nil? || view_name.strip.empty? || filename.nil? || filename.strip.empty?
538
+ status 404
539
+ return "File not found"
540
+ end
541
+
542
+ # Check if view has been generated (index.html exists)
543
+ index_file = @api.root/"views"/view_name/"output"/"index.html"
544
+ unless File.exist?(index_file)
545
+ status 404
546
+ return "View '#{view_name}' has not been generated. Please generate the view first."
547
+ end
548
+
549
+ # Construct the file path
550
+ post_file = @api.root/"views"/view_name/"output"/"posts"/filename
551
+
552
+ if File.exist?(post_file)
553
+ content_type :html
554
+ read_file(post_file)
555
+ else
556
+ status 404
557
+ "File not found: #{filename}"
558
+ end
559
+ rescue => e
560
+ status 500
561
+ "Error loading file: #{e.message}"
562
+ end
563
+ end
564
+
565
+ # Serve post content for iframe (with syntax highlighting)
566
+ get '/preview/:view_name/posts/:filename/content' do
567
+ view_name = params[:view_name]
568
+ filename = params[:filename]
569
+
570
+ begin
571
+ if view_name.nil? || view_name.strip.empty? || filename.nil? || filename.strip.empty?
572
+ status 404
573
+ return "File not found"
574
+ end
575
+
576
+ # Construct the file path
577
+ post_file = @api.root/"views"/view_name/"output"/"posts"/filename
578
+
579
+ if File.exist?(post_file)
580
+ content_type :html
581
+
582
+ # Read the post content
583
+ post_content = read_file(post_file)
584
+
585
+ # Wrap in HTML document with syntax highlighting (Highlight.js)
586
+ html = <<~HTML
587
+ <!DOCTYPE html>
588
+ <html>
589
+ <head>
590
+ <meta charset="utf-8">
591
+ <title>Post Content</title>
592
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/default.min.css">
593
+ <style>
594
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 20px; line-height: 1.6; }
595
+ pre { background: #f5f5f5; padding: 15px; border-radius: 5px; overflow-x: auto; }
596
+ code { background: #f5f5f5; padding: 2px 4px; border-radius: 3px; }
597
+ </style>
598
+ </head>
599
+ <body>
600
+ #{post_content}
601
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
602
+ <script>
603
+ document.addEventListener('DOMContentLoaded', function() {
604
+ if (typeof hljs !== 'undefined') { hljs.highlightAll(); }
605
+ });
606
+ </script>
607
+ </body>
608
+ </html>
609
+ HTML
610
+
611
+ html
612
+ else
613
+ status 404
614
+ "File not found: #{filename}"
615
+ end
616
+ rescue => e
617
+ status 500
618
+ "Error loading file: #{e.message}"
619
+ end
620
+ end
621
+
622
+ # Serve permalink files for preview
623
+ get '/preview/:view_name/permalink/:filename' do
624
+ view_name = params[:view_name]
625
+ filename = params[:filename]
626
+
627
+ begin
628
+ if view_name.nil? || view_name.strip.empty? || filename.nil? || filename.strip.empty?
629
+ status 404
630
+ return "File not found"
631
+ end
632
+
633
+ # Check if view has been generated (index.html exists)
634
+ index_file = @api.root/"views"/view_name/"output"/"index.html"
635
+ unless File.exist?(index_file)
636
+ status 404
637
+ return "View '#{view_name}' has not been generated. Please generate the view first."
638
+ end
639
+
640
+ # Construct the file path
641
+ permalink_file = @api.root/"views"/view_name/"output"/"permalink"/filename
642
+
643
+ if File.exist?(permalink_file)
644
+ content_type :html
645
+ read_file(permalink_file)
646
+ else
647
+ status 404
648
+ "File not found: #{filename}"
649
+ end
650
+ rescue => e
651
+ status 500
652
+ "Error loading file: #{e.message}"
653
+ end
654
+ end
655
+
656
+ # Serve assets for preview
657
+ get '/preview/:view_name/assets/*' do
658
+ view_name = params[:view_name]
659
+ asset_path = params[:splat].first
660
+
661
+ begin
662
+ if view_name.nil? || view_name.strip.empty? || asset_path.nil? || asset_path.strip.empty?
663
+ status 404
664
+ return "Asset not found"
665
+ end
666
+
667
+ # Check if view has been generated (index.html exists)
668
+ index_file = @api.root/"views"/view_name/"output"/"index.html"
669
+ unless File.exist?(index_file)
670
+ status 404
671
+ return "View '#{view_name}' has not been generated. Please generate the view first."
672
+ end
673
+
674
+ # Construct the asset file path (serve from generated output assets)
675
+ asset_file = @api.root/"views"/view_name/"output"/"assets"/asset_path
676
+ # Fallback: if not present in output assets, try view assets directly
677
+ unless File.exist?(asset_file)
678
+ fallback_asset = @api.root/"views"/view_name/"assets"/asset_path
679
+ asset_file = fallback_asset if File.exist?(fallback_asset)
680
+ end
681
+
682
+ if File.exist?(asset_file)
683
+ # Set appropriate content type based on file extension
684
+ case File.extname(asset_file).downcase
685
+ when '.png'
686
+ content_type 'image/png'
687
+ when '.jpg', '.jpeg'
688
+ content_type 'image/jpeg'
689
+ when '.gif'
690
+ content_type 'image/gif'
691
+ when '.svg'
692
+ content_type 'image/svg+xml'
693
+ when '.css'
694
+ content_type 'text/css'
695
+ when '.js'
696
+ content_type 'application/javascript'
697
+ else
698
+ content_type 'application/octet-stream'
699
+ end
700
+
701
+ read_file(asset_file)
702
+ else
703
+ status 404
704
+ "Asset not found: #{asset_path}"
705
+ end
706
+ rescue => e
707
+ status 500
708
+ "Error loading asset: #{e.message}"
709
+ end
710
+ end
711
+
712
+ # (timing route removed)
713
+
714
+ # Serve post files relative to preview route
715
+ get '/posts/:filename' do
716
+ filename = params[:filename]
717
+ @current_view = @api&.current_view
718
+
719
+ begin
720
+ if filename.nil? || filename.strip.empty? || @current_view.nil?
721
+ status 404
722
+ return "File not found"
723
+ end
724
+
725
+ # Construct the file path
726
+ post_file = @api.root/"views"/@current_view.name/"output"/"posts"/filename
727
+
728
+ if File.exist?(post_file)
729
+ content_type :html
730
+ read_file(post_file)
731
+ else
732
+ status 404
733
+ "File not found: #{filename}"
734
+ end
735
+ rescue => e
736
+ status 500
737
+ "Error loading file: #{e.message}"
738
+ end
739
+ end
740
+
741
+ # Handle direct access to posts via index.html?post=filename
742
+ get '/index.html' do
743
+ post_param = params[:post]
744
+ @current_view = @api&.current_view
745
+
746
+ begin
747
+ if @current_view.nil?
748
+ status 404
749
+ return "View not found"
750
+ end
751
+
752
+ # Always return the full index.html page
753
+ # The JavaScript will handle loading the specific post if post_param is provided
754
+ index_file = @api.root/"views"/@current_view.name/"output"/"index.html"
755
+
756
+ if File.exist?(index_file)
757
+ content_type :html
758
+ read_file(index_file)
759
+ else
760
+ status 404
761
+ "Index page not found"
762
+ end
763
+ rescue => e
764
+ status 500
765
+ "Error loading page: #{e.message}"
766
+ end
767
+ end
768
+
769
+ # Handle permalink access to posts
770
+ get '/permalink/:filename' do
771
+ filename = params[:filename]
772
+ @current_view = @api&.current_view
773
+
774
+ begin
775
+ if filename.nil? || filename.strip.empty? || @current_view.nil?
776
+ status 404
777
+ return "Post not found"
778
+ end
779
+
780
+ # Construct the file path
781
+ post_file = @api.root/"views"/@current_view.name/"output"/"posts"/filename
782
+
783
+ if File.exist?(post_file)
784
+ # Redirect to the index page with the post parameter
785
+ # This allows the JavaScript to handle the post loading properly
786
+ redirect "/index.html?post=#{filename}"
787
+ else
788
+ status 404
789
+ "Post not found: #{filename}"
790
+ end
791
+ rescue => e
792
+ status 500
793
+ "Error loading post: #{e.message}"
794
+ end
795
+ end
796
+
797
+ # Show view configuration page
798
+ get '/configure_view/:name' do
799
+ begin
800
+ validate_required_params(params, :name)
801
+
802
+ unless validate_view_name(params[:name])
803
+ redirect "/?error=Invalid view name&suggestion=View names must contain only letters, numbers, hyphens, and underscores."
804
+ return
805
+ end
806
+
807
+ view = @api.lookup_view(params[:name])
808
+ if view.nil?
809
+ redirect "/?error=View not found&suggestion=The view '#{params[:name]}' does not exist. Check the view name or create it first."
810
+ return
811
+ end
812
+
813
+ @view = view
814
+
815
+ # Load view configuration safely
816
+ config_file = @api.root/"views"/params[:name]/"config.txt"
817
+ @config_content = safe_read_file(config_file, "# View configuration for #{params[:name]}\n")
818
+
819
+ # Load layout file safely
820
+ layout_file = @api.root/"views"/params[:name]/"config"/"layout.txt"
821
+ @layout_content = safe_read_file(layout_file, "# Layout configuration for #{params[:name]}\n")
822
+
823
+ erb :configure_view
824
+ rescue => e
825
+ error_info = friendly_error_message(e)
826
+ redirect "/?error=#{error_info[:message]}&suggestion=#{error_info[:suggestion]}"
827
+ end
828
+ end
829
+
830
+ # Save view configuration
831
+ post '/save_view_config/:name' do
832
+ view_name = params[:name]
833
+
834
+ begin
835
+ view = @api.lookup_view(view_name)
836
+ if view.nil?
837
+ redirect "/?error=View not found"
838
+ return
839
+ end
840
+
841
+ # Step 1: Save basic view information
842
+ if params[:view_title] && params[:view_subtitle] && params[:view_theme]
843
+ config_content = "title #{params[:view_title]}\n"
844
+ config_content += "subtitle #{params[:view_subtitle]}\n"
845
+ config_content += "theme #{params[:view_theme]}\n"
846
+
847
+ config_file = @api.root/"views"/view_name/"config.txt"
848
+ write_file(config_file, config_content)
849
+ end
850
+
851
+ # Step 2: Save layout configuration
852
+ if params[:containers]
853
+ layout_content = ""
854
+ containers = Array(params[:containers])
855
+
856
+ containers.each do |container|
857
+ case container
858
+ when 'header'
859
+ layout_content += "header # Top (banner? title? navbar? etc.)\n"
860
+ when 'left'
861
+ width = params[:left_width] || "15%"
862
+ layout_content += "left #{width} # Left sidebar, #{width} width\n"
863
+ when 'main'
864
+ layout_content += "main # Main (center) container - posts/etc.\n"
865
+ when 'right'
866
+ width = params[:right_width] || "15%"
867
+ layout_content += "right #{width} # Right sidebar, #{width} width\n"
868
+ when 'footer'
869
+ layout_content += "footer # Footer (copyright? mail? social media? etc.)\n"
870
+ end
871
+ end
872
+
873
+ layout_file = @api.root/"views"/view_name/"config"/"layout.txt"
874
+ FileUtils.mkdir_p(File.dirname(layout_file))
875
+ write_file(layout_file, layout_content)
876
+ end
877
+
878
+ # Step 3: Save container content files
879
+ containers = Array(params[:containers])
880
+
881
+ containers.each do |container|
882
+ content_param = "#{container}_content"
883
+ if params[content_param]
884
+ content_file = @api.root/"views"/view_name/"config"/"#{container}.txt"
885
+ FileUtils.mkdir_p(File.dirname(content_file))
886
+ write_file(content_file, params[content_param])
887
+
888
+ # If this is header with "banner svg", create default svg.txt
889
+ if container == 'header' && params[content_param].strip == 'banner svg'
890
+ svg_file = @api.root/"views"/view_name/"config"/"svg.txt"
891
+ unless File.exist?(svg_file)
892
+ # Create default SVG configuration
893
+ default_svg_content = "# SVG Banner Configuration\n"
894
+ default_svg_content += "# Light gradient background with dark text\n"
895
+ default_svg_content += "back.linear #f8f9fa #e9ecef lr\n"
896
+ default_svg_content += "text.color #374151\n"
897
+ default_svg_content += "title.style bold\n"
898
+ write_file(svg_file, default_svg_content)
899
+ end
900
+ end
901
+ end
902
+ end
903
+
904
+ redirect "/?message=View '#{view_name}' configuration saved successfully"
905
+ rescue => e
906
+ redirect "/configure_view/#{view_name}?error=Failed to save configuration: #{e.message}"
907
+ end
908
+ end
909
+
910
+ # Banner configuration page
911
+ get '/banner_config' do
912
+ @current_view = @api&.current_view
913
+ if @current_view.nil?
914
+ redirect "/?error=No view selected. Please select a view first."
915
+ return
916
+ end
917
+
918
+ # Get current SVG config
919
+ svg_file = @api.root/"views"/@current_view.name/"config"/"svg.txt"
920
+ @svg_config = File.exist?(svg_file) ? read_file(svg_file) : ""
921
+
922
+ # Generate current banner for display
923
+ begin
924
+ banner = Scriptorium::BannerSVG.new(@current_view.title, @current_view.subtitle)
925
+
926
+ # Use the same approach as View class
927
+ if @svg_config.strip.length > 0
928
+ svg_file = @api.root/"views"/@current_view.name/"config"/"svg.txt"
929
+ banner.parse_header_svg(svg_file)
930
+ else
931
+ # No config, use defaults
932
+ banner.parse_header_svg
933
+ end
934
+
935
+ @banner_svg = banner.get_svg
936
+ rescue => e
937
+ @banner_svg = "<p>Error generating banner: #{e.message}</p>"
938
+ end
939
+
940
+ erb :banner_config
941
+ end
942
+
943
+ # Update banner configuration
944
+ post '/banner_config' do
945
+ @current_view = @api&.current_view
946
+ if @current_view.nil?
947
+ redirect "/?error=No view selected. Please select a view first."
948
+ return
949
+ end
950
+
951
+ begin
952
+ svg_config = params[:svg_config] || ""
953
+
954
+ # Save the SVG configuration
955
+ svg_file = @api.root/"views"/@current_view.name/"config"/"svg.txt"
956
+ FileUtils.mkdir_p(File.dirname(svg_file))
957
+ write_file(svg_file, svg_config)
958
+
959
+ # Update status
960
+ update_config_status(@current_view.name, "banner", true)
961
+
962
+ redirect "/banner_config?message=Banner configuration updated successfully"
963
+ rescue => e
964
+ redirect "/banner_config?error=Failed to save banner configuration: #{e.message}"
965
+ end
966
+ end
967
+
968
+ # Navbar configuration page
969
+ get '/navbar_config' do
970
+ @current_view = @api&.current_view
971
+ if @current_view.nil?
972
+ redirect "/?error=No view selected. Please select a view first."
973
+ return
974
+ end
975
+
976
+ # Get current navbar config
977
+ navbar_file = @api.root/"views"/@current_view.name/"config"/"navbar.txt"
978
+ @navbar_config = File.exist?(navbar_file) ? read_file(navbar_file).strip : ""
979
+
980
+ # Generate current navbar preview
981
+ begin
982
+ view = @api.lookup_view(@current_view.name)
983
+ @navbar_preview = view.build_nav(nil) # nil = use default navbar.txt
984
+ rescue => e
985
+ @navbar_preview = "<p>Error generating navbar: #{e.message}</p>"
986
+ end
987
+
988
+ erb :navbar_config
989
+ end
990
+
991
+ # Add item (top-level link or parent)
992
+ post '/navbar_config/add_item' do
993
+ @current_view = @api&.current_view
994
+ if @current_view.nil?
995
+ redirect "/?error=No view selected. Please select a view first."
996
+ return
997
+ end
998
+
999
+ begin
1000
+ label = params[:label]&.strip
1001
+ filename = params[:filename]&.strip
1002
+ action = params[:action]
1003
+
1004
+ if label.nil? || label.empty?
1005
+ redirect "/navbar_config?error=Label is required"
1006
+ return
1007
+ end
1008
+
1009
+ # Read current navbar config
1010
+ navbar_file = @api.root/"views"/@current_view.name/"config"/"navbar.txt"
1011
+ current_config = File.exist?(navbar_file) ? read_file(navbar_file).strip : ""
1012
+
1013
+ # Add new item based on action
1014
+ if action == "link"
1015
+ if filename.nil? || filename.empty?
1016
+ redirect "/navbar_config?error=Filename is required for top-level links"
1017
+ return
1018
+ end
1019
+ new_line = "-#{label} #{filename}"
1020
+ message = "Added #{label} as top-level link"
1021
+ else
1022
+ new_line = "=#{label}"
1023
+ message = "Added #{label} as parent"
1024
+ end
1025
+
1026
+ # Append to config
1027
+ updated_config = current_config.empty? ? new_line : "#{current_config.rstrip}\n#{new_line}"
1028
+
1029
+ # Save the updated configuration
1030
+ FileUtils.mkdir_p(File.dirname(navbar_file))
1031
+ write_file(navbar_file, updated_config.rstrip + "\n")
1032
+
1033
+ redirect "/navbar_config?message=#{message}"
1034
+ rescue => e
1035
+ redirect "/navbar_config?error=Failed to add item: #{e.message}"
1036
+ end
1037
+ end
1038
+
1039
+ # Add child to parent
1040
+ post '/navbar_config/add_child' do
1041
+ @current_view = @api&.current_view
1042
+ if @current_view.nil?
1043
+ redirect "/?error=No view selected. Please select a view first."
1044
+ return
1045
+ end
1046
+
1047
+ begin
1048
+ parent = params[:parent]&.strip
1049
+ label = params[:label]&.strip
1050
+ filename = params[:filename]&.strip
1051
+
1052
+ if parent.nil? || parent.empty?
1053
+ redirect "/navbar_config?error=Parent is required"
1054
+ return
1055
+ end
1056
+
1057
+ if label.nil? || label.empty?
1058
+ redirect "/navbar_config?error=Label is required"
1059
+ return
1060
+ end
1061
+
1062
+ if filename.nil? || filename.empty?
1063
+ redirect "/navbar_config?error=Filename is required"
1064
+ return
1065
+ end
1066
+
1067
+ # Read current navbar config
1068
+ navbar_file = @api.root/"views"/@current_view.name/"config"/"navbar.txt"
1069
+ current_config = File.exist?(navbar_file) ? read_file(navbar_file).strip : ""
1070
+
1071
+ # Find the parent and add child after it
1072
+ lines = current_config.lines
1073
+ new_lines = []
1074
+ parent_found = false
1075
+
1076
+ lines.each do |line|
1077
+ new_lines << line
1078
+ if line.strip == "=#{parent}"
1079
+ parent_found = true
1080
+ # Add child on next line
1081
+ new_lines << " #{label} #{filename}\n"
1082
+ end
1083
+ end
1084
+
1085
+ if !parent_found
1086
+ redirect "/navbar_config?error=Parent '#{parent}' not found"
1087
+ return
1088
+ end
1089
+
1090
+ # Save the updated configuration
1091
+ FileUtils.mkdir_p(File.dirname(navbar_file))
1092
+ write_file(navbar_file, new_lines.join.rstrip + "\n")
1093
+
1094
+ redirect "/navbar_config?message=Added #{label} as child of #{parent}"
1095
+ rescue => e
1096
+ redirect "/navbar_config?error=Failed to add child: #{e.message}"
1097
+ end
1098
+ end
1099
+
1100
+ # Save direct edit of navbar config
1101
+ post '/navbar_config/save_direct' do
1102
+ @current_view = @api&.current_view
1103
+ if @current_view.nil?
1104
+ redirect "/?error=No view selected. Please select a view first."
1105
+ return
1106
+ end
1107
+
1108
+ begin
1109
+ config = params[:config]&.strip
1110
+ if config.nil?
1111
+ redirect "/navbar_config?error=Configuration is required"
1112
+ return
1113
+ end
1114
+
1115
+ # Save the configuration
1116
+ navbar_file = @api.root/"views"/@current_view.name/"config"/"navbar.txt"
1117
+ FileUtils.mkdir_p(File.dirname(navbar_file))
1118
+ write_file(navbar_file, config.rstrip + "\n")
1119
+
1120
+ # Check for missing pages and create them
1121
+ pages_created = []
1122
+ pages_dir = @api.root/"views"/@current_view.name/"pages"
1123
+ FileUtils.mkdir_p(pages_dir) unless Dir.exist?(pages_dir)
1124
+
1125
+ # Parse navbar config to find page filenames
1126
+ config.lines.each do |line|
1127
+ line = line.rstrip
1128
+ next if line.empty? || line.start_with?('#')
1129
+
1130
+ # Check for top-level links (start with -)
1131
+ if line.start_with?('-')
1132
+ if line.include?(' ')
1133
+ parts = line.split(/\s{2,}/, 2)
1134
+ if parts.length >= 2
1135
+ title = parts[0].strip
1136
+ filename = parts[1].strip
1137
+ next if filename.empty?
1138
+
1139
+ # Add .lt3 extension if no extension
1140
+ filename += '.lt3' unless filename.include?('.')
1141
+
1142
+ # Check if page exists
1143
+ page_file = pages_dir/filename
1144
+ unless File.exist?(page_file)
1145
+ content = ".page_title #{title}\n\n"
1146
+ write_file(page_file, content)
1147
+ pages_created << filename
1148
+ end
1149
+ end
1150
+ end
1151
+ # Check for child links (start with space)
1152
+ elsif line.start_with?(' ')
1153
+ if line.include?(' ')
1154
+ parts = line.split(/\s{2,}/, 2)
1155
+ if parts.length >= 2
1156
+ title = parts[0].strip
1157
+ filename = parts[1].strip
1158
+ next if filename.empty?
1159
+
1160
+ # Add .lt3 extension if no extension
1161
+ filename += '.lt3' unless filename.include?('.')
1162
+
1163
+ # Check if page exists
1164
+ page_file = pages_dir/filename
1165
+ unless File.exist?(page_file)
1166
+ content = ".page_title #{title}\n\n"
1167
+ write_file(page_file, content)
1168
+ pages_created << filename
1169
+ end
1170
+ end
1171
+ end
1172
+ end
1173
+ end
1174
+
1175
+ # Build success message
1176
+ message = "Configuration saved successfully"
1177
+ if pages_created.any?
1178
+ message += ". Created missing pages: #{pages_created.join(', ')}"
1179
+ end
1180
+
1181
+ redirect "/navbar_config?message=#{message}"
1182
+ rescue => e
1183
+ redirect "/navbar_config?error=Failed to save configuration: #{e.message}"
1184
+ end
1185
+ end
1186
+
1187
+ # Edit pages page
1188
+ get '/edit_pages' do
1189
+ @current_view = @api&.current_view
1190
+ if @current_view.nil?
1191
+ redirect "/?error=No view selected. Please select a view first."
1192
+ return
1193
+ end
1194
+
1195
+ # Get all pages in the current view
1196
+ pages_dir = @api.root/"views"/@current_view.name/"pages"
1197
+ @pages = []
1198
+
1199
+ if Dir.exist?(pages_dir)
1200
+ Dir.glob(pages_dir/"*").each do |file|
1201
+ next unless File.file?(file)
1202
+ filename = File.basename(file)
1203
+ content = read_file(file)
1204
+
1205
+ # Extract page title from .page_title directive
1206
+ title = nil
1207
+ if content.lines.first&.strip&.start_with?('.page_title')
1208
+ title = content.lines.first.strip.sub('.page_title', '').strip
1209
+ end
1210
+
1211
+ @pages << {
1212
+ filename: filename,
1213
+ title: title,
1214
+ content: content,
1215
+ empty: content.strip.empty?
1216
+ }
1217
+ end
1218
+ end
1219
+
1220
+ # Sort pages alphabetically
1221
+ @pages.sort_by! { |page| page[:filename] }
1222
+
1223
+ erb :edit_pages
1224
+ end
1225
+
1226
+ # Save page content
1227
+ post '/edit_pages/save' do
1228
+ File.write('/tmp/edit_pages_debug.log', "=== SAVE ATTEMPT ===\n", mode: 'a')
1229
+ File.write('/tmp/edit_pages_debug.log', "Time: #{Time.now}\n", mode: 'a')
1230
+ File.write('/tmp/edit_pages_debug.log', "API instance: #{@api.inspect}\n", mode: 'a')
1231
+ File.write('/tmp/edit_pages_debug.log', "Current view: #{@api&.current_view&.inspect}\n", mode: 'a')
1232
+ File.write('/tmp/edit_pages_debug.log', "Params: #{params.inspect}\n", mode: 'a')
1233
+
1234
+ @current_view = @api&.current_view
1235
+ if @current_view.nil?
1236
+ File.write('/tmp/edit_pages_debug.log', "ERROR: No current view\n", mode: 'a')
1237
+ redirect "/?error=No view selected. Please select a view first."
1238
+ return
1239
+ end
1240
+
1241
+ begin
1242
+ filename = params[:filename]&.strip
1243
+ content = params[:content]&.strip || ""
1244
+
1245
+ File.write('/tmp/edit_pages_debug.log', "Filename: #{filename.inspect}\n", mode: 'a')
1246
+ File.write('/tmp/edit_pages_debug.log', "Content length: #{content.length}\n", mode: 'a')
1247
+
1248
+ if filename.nil? || filename.empty?
1249
+ File.write('/tmp/edit_pages_debug.log', "ERROR: Filename is empty\n", mode: 'a')
1250
+ redirect "/edit_pages?error=Filename is required"
1251
+ return
1252
+ end
1253
+
1254
+ # Save the page
1255
+ pages_dir = @api.root/"views"/@current_view.name/"pages"
1256
+ File.write('/tmp/edit_pages_debug.log', "Pages dir: #{pages_dir}\n", mode: 'a')
1257
+ FileUtils.mkdir_p(pages_dir)
1258
+ page_file = pages_dir/filename
1259
+ File.write('/tmp/edit_pages_debug.log', "Page file: #{page_file}\n", mode: 'a')
1260
+ File.write(page_file, content)
1261
+ File.write('/tmp/edit_pages_debug.log', "SUCCESS: File written\n", mode: 'a')
1262
+
1263
+ redirect "/edit_pages?message=Page '#{filename}' saved successfully"
1264
+ rescue => e
1265
+ File.write('/tmp/edit_pages_debug.log', "EXCEPTION: #{e.class}: #{e.message}\n", mode: 'a')
1266
+ File.write('/tmp/edit_pages_debug.log', "Backtrace: #{e.backtrace.first(5).join("\n")}\n", mode: 'a')
1267
+ redirect "/edit_pages?error=Failed to save page: #{e.message}"
1268
+ end
1269
+ end
1270
+
1271
+ # Per-view dashboard
1272
+ get '/view/:name' do
1273
+ view_name = params[:name]
1274
+
1275
+ # Debug logging
1276
+ File.write('/tmp/dashboard_debug.log', "Dashboard accessed for view: #{view_name} at #{Time.now}\n", mode: 'a')
1277
+
1278
+ begin
1279
+ # Look up the view
1280
+ @current_view = @api.lookup_view(view_name)
1281
+ if @current_view.nil?
1282
+ redirect "/?error=View '#{view_name}' not found"
1283
+ return
1284
+ end
1285
+
1286
+ # Get all views for the checkbox list
1287
+ @views = @api.views || []
1288
+
1289
+ # Set as current view
1290
+ @api.view(view_name)
1291
+ # @current_view = @api.current_view # This line is now redundant as @current_view is set above
1292
+
1293
+ # Auto-generate view if not already generated
1294
+ index_file = @api.root/"views"/view_name/"output"/"index.html"
1295
+ unless File.exist?(index_file)
1296
+ begin
1297
+ @api.generate_view(view_name)
1298
+ rescue => e
1299
+ # Log the error but don't fail the dashboard load
1300
+ File.write('/tmp/dashboard_debug.log', "Auto-generation failed: #{e.message}\n", mode: 'a')
1301
+ end
1302
+ end
1303
+
1304
+ # Generate banner for display
1305
+ begin
1306
+ bsvg = Scriptorium::BannerSVG.new(@current_view.title, @current_view.subtitle)
1307
+ svg_config_file = @api.root/"views"/view_name/"config"/"svg.txt"
1308
+ if File.exist?(svg_config_file)
1309
+ bsvg.parse_header_svg(svg_config_file)
1310
+ else
1311
+ bsvg.parse_header_svg
1312
+ end
1313
+ # Generate responsive SVG for web display
1314
+ svg_html = bsvg.get_svg
1315
+ File.write('/tmp/dashboard_debug.log', "get_svg returned: #{svg_html[0..200]}...\n", mode: 'a')
1316
+ @banner_svg = svg_html
1317
+ rescue => e
1318
+ @banner_svg = "<p>Error generating banner: #{e.message}</p>"
1319
+ end
1320
+
1321
+ # Get posts for pagination
1322
+ begin
1323
+ posts = @api.posts(view_name, include_deleted: true) || []
1324
+
1325
+ # Debug: check if include_deleted is working
1326
+ File.write('/tmp/dashboard_debug.log', "Found #{posts.length} posts (including deleted)\n", mode: 'a')
1327
+ deleted_count = posts.count(&:deleted)
1328
+ File.write('/tmp/dashboard_debug.log', "Deleted posts: #{deleted_count}\n", mode: 'a')
1329
+
1330
+ # Debug: log first few posts and their dates for ordering analysis
1331
+ posts.first(5).each_with_index do |post, i|
1332
+ File.write('/tmp/dashboard_debug.log', "Post #{i}: #{post.num} - #{post.title} - date: #{post.date}\n", mode: 'a')
1333
+ end
1334
+
1335
+ posts.sort! { |a, b| post_compare(a, b) } # Sort by date, newest first
1336
+
1337
+ # Get posts per page from config, default to 10
1338
+ config_file = @api.root/"views"/view_name/"config"/"post_index.txt"
1339
+ posts_per_page = 10
1340
+ if File.exist?(config_file)
1341
+ config_content = read_file(config_file)
1342
+ if config_content.strip.length > 0
1343
+ posts_per_page = config_content.lines.first.strip.split.last.to_i
1344
+ end
1345
+ end
1346
+
1347
+ # Pagination logic
1348
+ page = (params[:page] || 1).to_i
1349
+ total_posts = posts.length
1350
+ total_pages = (total_posts.to_f / posts_per_page).ceil
1351
+
1352
+ # Debug pagination
1353
+ File.write('/tmp/dashboard_debug.log', "Page requested: #{params[:page]}, calculated: #{page}, total_pages: #{total_pages}\n", mode: 'a')
1354
+
1355
+ # Preserve current page if possible, otherwise reset to 1
1356
+ if page > total_pages && total_pages > 0
1357
+ page = total_pages
1358
+ File.write('/tmp/dashboard_debug.log', "Page adjusted to total_pages: #{page}\n", mode: 'a')
1359
+ elsif page < 1 || total_pages == 0
1360
+ page = 1
1361
+ File.write('/tmp/dashboard_debug.log', "Page reset to 1\n", mode: 'a')
1362
+ end
1363
+
1364
+ start_index = (page - 1) * posts_per_page
1365
+ end_index = [start_index + posts_per_page - 1, total_posts - 1].min
1366
+
1367
+ @posts = posts[start_index..end_index] || []
1368
+ @current_page = page
1369
+ @total_pages = total_pages
1370
+ @total_posts = total_posts
1371
+ @posts_per_page = posts_per_page
1372
+ rescue => e
1373
+ @posts = []
1374
+ @current_page = 1
1375
+ @total_pages = 1
1376
+ @total_posts = 0
1377
+ @posts_per_page = 10
1378
+ end
1379
+
1380
+ erb :view_dashboard
1381
+ rescue => e
1382
+ redirect "/?error=Failed to load view dashboard: #{e.message}"
1383
+ end
1384
+ end
1385
+
1386
+ # Advanced configuration page
1387
+ get '/advanced_config' do
1388
+ @current_view = @api&.current_view
1389
+ if @current_view.nil?
1390
+ redirect "/?error=No view selected. Please select a view first."
1391
+ return
1392
+ end
1393
+
1394
+ # Read status from status.txt file
1395
+ config_dir = @api.root/"views"/@current_view.name/"config"
1396
+ status_file = config_dir/"status.txt"
1397
+ @configs = {}
1398
+
1399
+ if File.exist?(status_file)
1400
+ status_config = @api.parse_commented_file(status_file)
1401
+ status_config.each do |key, value|
1402
+ @configs[key.to_sym] = value == 'y'
1403
+ end
1404
+ else
1405
+ # Default to all 'n' if status file doesn't exist
1406
+ @configs = {
1407
+ header: false,
1408
+ banner: false,
1409
+ navbar: false,
1410
+ left: false,
1411
+ right: false,
1412
+ pages: false,
1413
+ deploy: false
1414
+ }
1415
+ end
1416
+
1417
+ # Read layout to determine which containers exist
1418
+ layout_file = config_dir/"layout.txt"
1419
+ @layout_containers = []
1420
+ if File.exist?(layout_file)
1421
+ layout_config = @api.parse_commented_file(layout_file)
1422
+ layout_config.each do |container, _|
1423
+ @layout_containers << container
1424
+ end
1425
+ end
1426
+
1427
+ erb :advanced_config
1428
+ end
1429
+
1430
+ # Header configuration page
1431
+ get '/header_config' do
1432
+ @current_view = @api&.current_view
1433
+ if @current_view.nil?
1434
+ redirect "/?error=No view selected. Please select a view first."
1435
+ return
1436
+ end
1437
+
1438
+ # Read current header config
1439
+ header_file = @api.root/"views"/@current_view.name/"config"/"header.txt"
1440
+ @current_config = ""
1441
+ if File.exist?(header_file)
1442
+ @current_config = read_file(header_file).strip
1443
+ end
1444
+
1445
+ # Parse current settings
1446
+ @banner_type = @current_config.include?("banner svg") ? "svg" : "image"
1447
+ @navbar_enabled = @current_config.include?("navbar")
1448
+
1449
+ erb :header_config
1450
+ end
1451
+
1452
+ # Update header configuration
1453
+ post '/header_config' do
1454
+ @current_view = @api&.current_view
1455
+ if @current_view.nil?
1456
+ redirect "/?error=No view selected. Please select a view first."
1457
+ return
1458
+ end
1459
+
1460
+ begin
1461
+ banner_type = params[:banner_type] || "svg"
1462
+ navbar_enabled = params[:navbar_enabled] == "1"
1463
+
1464
+ # Build header.txt content
1465
+ header_content = []
1466
+ header_content << "banner #{banner_type}"
1467
+ header_content << "navbar" if navbar_enabled
1468
+
1469
+ # Save the header configuration
1470
+ header_file = @api.root/"views"/@current_view.name/"config"/"header.txt"
1471
+ FileUtils.mkdir_p(File.dirname(header_file))
1472
+ write_file(header_file, header_content.join("\n") + "\n")
1473
+
1474
+ # Update status
1475
+ update_config_status(@current_view.name, "header", true)
1476
+
1477
+ redirect "/advanced_config?message=Header configuration updated successfully"
1478
+ rescue => e
1479
+ redirect "/header_config?error=Failed to save header configuration: #{e.message}"
1480
+ end
1481
+ end
1482
+
1483
+ # Deployment configuration page
1484
+ get '/deploy_config' do
1485
+ @current_view = @api&.current_view
1486
+ if @current_view.nil?
1487
+ redirect "/?error=No view selected. Please select a view first."
1488
+ return
1489
+ end
1490
+
1491
+ # Read current deployment config
1492
+ deploy_file = @api.root/"views"/@current_view.name/"config"/"deploy.txt"
1493
+ @deploy_config = ""
1494
+ if File.exist?(deploy_file)
1495
+ @deploy_config = read_file(deploy_file).strip
1496
+ end
1497
+
1498
+ erb :deploy_config
1499
+ end
1500
+
1501
+ # Update deployment configuration
1502
+ post '/deploy_config' do
1503
+ @current_view = @api&.current_view
1504
+ if @current_view.nil?
1505
+ redirect "/?error=No view selected. Please select a view first."
1506
+ return
1507
+ end
1508
+
1509
+ begin
1510
+ deploy_config = params[:deploy_config] || ""
1511
+
1512
+ # Save the deployment configuration
1513
+ deploy_file = @api.root/"views"/@current_view.name/"config"/"deploy.txt"
1514
+ FileUtils.mkdir_p(File.dirname(deploy_file))
1515
+ write_file(deploy_file, deploy_config + "\n")
1516
+
1517
+ # Update status
1518
+ update_config_status(@current_view.name, "deploy", true)
1519
+
1520
+ # Check if user came from deploy button - if so, auto-deploy
1521
+ if params[:from_deploy] == "1"
1522
+ # User came from deploy button, perform deployment automatically
1523
+ begin
1524
+ # Log deployment attempt
1525
+ File.open("/tmp/web_deploy.log", "a") do |f|
1526
+ f.puts "=== AUTO-DEPLOYMENT AFTER CONFIG #{Time.now} ==="
1527
+ f.puts " View name: #{@current_view.name}"
1528
+ f.puts " API object: #{@api.class}"
1529
+ f.puts " Repo root: #{@api.root}"
1530
+ end
1531
+
1532
+ # Perform deployment
1533
+ result = @api.deploy(@current_view.name)
1534
+
1535
+ # Log deployment result
1536
+ File.open("/tmp/web_deploy.log", "a") do |f|
1537
+ f.puts " Auto-deployment result: #{result}"
1538
+ f.puts " Auto-deployment completed successfully"
1539
+ end
1540
+
1541
+ if result
1542
+ redirect "/view/#{@current_view.name}?deploy_success=Deployment completed successfully&hide_uploading=1"
1543
+ else
1544
+ redirect "/view/#{@current_view.name}?error=Deployment failed&hide_uploading=1"
1545
+ end
1546
+ rescue => e
1547
+ # Log deployment error
1548
+ File.open("/tmp/web_deploy.log", "a") do |f|
1549
+ f.puts " Auto-deployment error: #{e.message}"
1550
+ f.puts " Backtrace: #{e.backtrace.first(5).join("\n ")}"
1551
+ end
1552
+ redirect "/view/#{@current_view.name}?error=Deployment configuration updated but deployment failed: #{e.message}&hide_uploading=1"
1553
+ end
1554
+ else
1555
+ # Normal case - user went to deploy config directly, just return to advanced config
1556
+ redirect "/advanced_config?message=Deployment configuration updated successfully"
1557
+ end
1558
+ rescue => e
1559
+ redirect "/deploy_config?error=Failed to save deployment configuration: #{e.message}"
1560
+ end
1561
+ end
1562
+
1563
+ # Deploy current view
1564
+ get '/deploy' do
1565
+ @current_view = @api&.current_view
1566
+ if @current_view.nil?
1567
+ redirect "/?error=No view selected. Please select a view first."
1568
+ return
1569
+ end
1570
+
1571
+ # Check if deployment is ready
1572
+ unless @api.can_deploy?(@current_view.name)
1573
+ redirect "/deploy_config?error=View is not ready for deployment. Please configure deployment first.&from_deploy=1"
1574
+ return
1575
+ end
1576
+
1577
+ # Perform deployment directly
1578
+ begin
1579
+ # Log deployment attempt
1580
+ File.open("/tmp/web_deploy.log", "a") do |f|
1581
+ f.puts "=== WEB DEPLOYMENT ATTEMPT #{Time.now} ==="
1582
+ f.puts " View name: #{@current_view.name}"
1583
+ f.puts " API object: #{@api.class}"
1584
+ f.puts " Repo root: #{@api.root}"
1585
+ end
1586
+
1587
+ # Perform deployment
1588
+ result = @api.deploy(@current_view.name)
1589
+
1590
+ # Log deployment result
1591
+ File.open("/tmp/web_deploy.log", "a") do |f|
1592
+ f.puts " Deployment result: #{result}"
1593
+ f.puts " Deployment completed successfully"
1594
+ end
1595
+
1596
+ if result
1597
+ redirect "/view/#{@current_view.name}?deploy_success=Deployment completed successfully&hide_uploading=1"
1598
+ else
1599
+ redirect "/view/#{@current_view.name}?error=Deployment failed&hide_uploading=1"
1600
+ end
1601
+ rescue => e
1602
+ # Log deployment error
1603
+ File.open("/tmp/web_deploy.log", "a") do |f|
1604
+ f.puts " Deployment error: #{e.message}"
1605
+ f.puts " Backtrace: #{e.backtrace.first(5).join("\n ")}"
1606
+ end
1607
+ redirect "/view/#{@current_view.name}?error=Deployment failed: #{e.message}"
1608
+ end
1609
+ end
1610
+
1611
+
1612
+
1613
+ # Browse deployed view
1614
+ get '/browse' do
1615
+ @current_view = @api&.current_view
1616
+ if @current_view.nil?
1617
+ redirect "/?error=No view selected. Please select a view first."
1618
+ return
1619
+ end
1620
+
1621
+ # Check if deployment configuration exists
1622
+ deploy_file = @api.root/"views"/@current_view.name/"config"/"deploy.txt"
1623
+ unless File.exist?(deploy_file)
1624
+ redirect "/deploy_config?error=No deployment configuration found. Please configure deployment first."
1625
+ return
1626
+ end
1627
+
1628
+ # Read deployment configuration and extract domain
1629
+ deploy_config = read_file(deploy_file).strip
1630
+ if deploy_config.empty?
1631
+ redirect "/deploy_config?error=Deployment configuration is empty."
1632
+ return
1633
+ end
1634
+
1635
+ # Extract domain and path from deploy config (simple parsing)
1636
+ lines = deploy_config.split("\n")
1637
+ domain = nil
1638
+ path = nil
1639
+ lines.each do |line|
1640
+ line = line.strip
1641
+ next if line.empty? || line.start_with?('#')
1642
+ if line.match(/^(\w+)\s+(.+)$/)
1643
+ key = $1.strip
1644
+ value = $2.strip
1645
+ if key == 'proto' && value.start_with?('http')
1646
+ # Look for server and path fields to construct URL
1647
+ lines.each do |config_line|
1648
+ config_line = config_line.strip
1649
+ next if config_line.empty? || config_line.start_with?('#')
1650
+ if config_line.match(/^(\w+)\s+(.+)$/)
1651
+ config_key = $1.strip
1652
+ config_value = $2.strip
1653
+ if config_key == 'server'
1654
+ domain = "#{value}://#{config_value}"
1655
+ elsif config_key == 'path'
1656
+ path = config_value
1657
+ end
1658
+ end
1659
+ end
1660
+ break
1661
+ end
1662
+ end
1663
+ end
1664
+
1665
+ if domain
1666
+ # Append path if it exists
1667
+ if path && !path.empty?
1668
+ redirect "#{domain}/#{path}"
1669
+ else
1670
+ redirect domain
1671
+ end
1672
+ else
1673
+ redirect "/deploy_config?error=Could not extract domain from deployment configuration."
1674
+ end
1675
+ end
1676
+
1677
+ # Layout configuration page
1678
+ get '/layout_config' do
1679
+ @current_view = @api&.current_view
1680
+ if @current_view.nil?
1681
+ redirect "/?error=No view selected. Please select a view first."
1682
+ return
1683
+ end
1684
+
1685
+ # Read current layout config
1686
+ layout_file = @api.root/"views"/@current_view.name/"config"/"layout.txt"
1687
+ @layout_config = ""
1688
+ if File.exist?(layout_file)
1689
+ @layout_config = read_file(layout_file).strip
1690
+ end
1691
+
1692
+ erb :layout_config
1693
+ end
1694
+
1695
+ # Update layout configuration
1696
+ post '/layout_config' do
1697
+ @current_view = @api&.current_view
1698
+ if @current_view.nil?
1699
+ redirect "/?error=No view selected. Please select a view first."
1700
+ return
1701
+ end
1702
+
1703
+ begin
1704
+ layout_config = params[:layout_config] || ""
1705
+
1706
+ # Save the layout configuration
1707
+ layout_file = @api.root/"views"/@current_view.name/"config"/"layout.txt"
1708
+ FileUtils.mkdir_p(File.dirname(layout_file))
1709
+ write_file(layout_file, layout_config + "\n")
1710
+
1711
+ redirect "/advanced_config?message=Layout configuration updated successfully"
1712
+ rescue => e
1713
+ redirect "/layout_config?error=Failed to save layout configuration: #{e.message}"
1714
+ end
1715
+ end
1716
+
1717
+ # Serve web app's own assets (like livetext_mode.js)
1718
+ get '/web_assets/*' do
1719
+ begin
1720
+ asset_path = params[:splat].first
1721
+ asset_file = File.join(Dir.pwd, 'ui', 'web', 'app', 'assets', asset_path)
1722
+
1723
+ puts "DEBUG: Asset path: #{asset_path}"
1724
+ puts "DEBUG: Asset file: #{asset_file}"
1725
+ puts "DEBUG: File exists: #{File.exist?(asset_file)}"
1726
+ puts "DEBUG: Is file: #{File.file?(asset_file)}"
1727
+
1728
+ if File.exist?(asset_file) && File.file?(asset_file)
1729
+ send_file asset_file
1730
+ else
1731
+ status 404
1732
+ "Web asset not found"
1733
+ end
1734
+ rescue => e
1735
+ puts "DEBUG: Exception in web_assets: #{e.class}: #{e.message}"
1736
+ puts "DEBUG: Backtrace: #{e.backtrace.first(3).join("\n")}"
1737
+ status 500
1738
+ "Internal server error: #{e.message}"
1739
+ end
1740
+ end
1741
+
1742
+ # Static files are now served directly by Sinatra from the public_folder
1743
+ # No custom routes needed for assets
1744
+
1745
+
1746
+ # Server status endpoint
1747
+ get '/status' do
1748
+ content_type :json
1749
+ {
1750
+ status: 'running',
1751
+ port: settings.port,
1752
+ current_view: @api.current_view&.name,
1753
+ repo_loaded: !@api.instance_variable_get(:@repo).nil?
1754
+ }.to_json
1755
+ end
1756
+
1757
+ # Asset management page
1758
+ get '/asset_management' do
1759
+ @current_view = @api&.current_view
1760
+ if @current_view.nil?
1761
+ redirect "/?error=No view selected. Please select a view first."
1762
+ return
1763
+ end
1764
+
1765
+ # Get global assets
1766
+ global_assets_dir = @api.root/"assets"
1767
+ @global_assets = []
1768
+ @library_assets = []
1769
+
1770
+ if Dir.exist?(global_assets_dir)
1771
+ Dir.glob(global_assets_dir/"*").each do |file|
1772
+ next unless File.file?(file)
1773
+ filename = File.basename(file)
1774
+ size = File.size(file)
1775
+ dimensions = get_image_dimensions(file)
1776
+ @global_assets << {
1777
+ filename: filename,
1778
+ size: size,
1779
+ path: file,
1780
+ dimensions: dimensions
1781
+ }
1782
+ end
1783
+
1784
+ # Get library assets
1785
+ library_dir = global_assets_dir/"library"
1786
+ if Dir.exist?(library_dir)
1787
+ Dir.glob(library_dir/"*").each do |file|
1788
+ next unless File.file?(file)
1789
+ filename = File.basename(file)
1790
+ size = File.size(file)
1791
+ dimensions = get_image_dimensions(file)
1792
+ @library_assets << {
1793
+ filename: filename,
1794
+ size: size,
1795
+ path: file,
1796
+ dimensions: dimensions
1797
+ }
1798
+ end
1799
+ end
1800
+ end
1801
+
1802
+ # Get view-specific assets
1803
+ view_assets_dir = @api.root/"views"/@current_view.name/"assets"
1804
+ @view_assets = []
1805
+
1806
+ if Dir.exist?(view_assets_dir)
1807
+ Dir.glob(view_assets_dir/"*").each do |file|
1808
+ next unless File.file?(file)
1809
+ filename = File.basename(file)
1810
+ size = File.size(file)
1811
+ dimensions = get_image_dimensions(file)
1812
+ @view_assets << {
1813
+ filename: filename,
1814
+ size: size,
1815
+ path: file,
1816
+ dimensions: dimensions
1817
+ }
1818
+ end
1819
+ end
1820
+
1821
+ # Get post-specific assets
1822
+ @post_assets = []
1823
+ posts_dir = @api.root/:posts
1824
+ if Dir.exist?(posts_dir)
1825
+ Dir.glob(posts_dir/"*").each do |post_dir|
1826
+ next unless Dir.exist?(post_dir)
1827
+ post_num = File.basename(post_dir)
1828
+ next unless post_num.match?(/^\d{4}$/) # Only process 4-digit post numbers
1829
+
1830
+ post_id = post_num.to_i
1831
+ assets = @api.list_assets('post', post_id)
1832
+ assets.each do |asset|
1833
+ @post_assets << asset.merge({
1834
+ post_id: post_id,
1835
+ post_title: get_post_title(post_id)
1836
+ })
1837
+ end
1838
+ end
1839
+ end
1840
+
1841
+ # Sort all asset lists
1842
+ @global_assets.sort_by! { |asset| asset[:filename] }
1843
+ @library_assets.sort_by! { |asset| asset[:filename] }
1844
+ @view_assets.sort_by! { |asset| asset[:filename] }
1845
+ @post_assets.sort_by! { |asset| [asset[:post_id], asset[:filename]] }
1846
+
1847
+ erb :asset_management
1848
+ end
1849
+
1850
+ # Upload post-specific asset
1851
+ post '/upload_post_asset' do
1852
+ @current_view = @api&.current_view
1853
+ if @current_view.nil?
1854
+ redirect "/?error=No view selected. Please select a view first."
1855
+ return
1856
+ end
1857
+
1858
+ begin
1859
+ post_id = params[:post_id]&.to_i
1860
+ file = params[:file]
1861
+
1862
+ if post_id.nil? || post_id <= 0
1863
+ redirect "/view/#{@current_view.name}?error=Invalid post ID"
1864
+ return
1865
+ end
1866
+
1867
+ if file.nil? || file[:tempfile].nil?
1868
+ redirect "/view/#{@current_view.name}?error=No file selected"
1869
+ return
1870
+ end
1871
+
1872
+ # Check if post exists
1873
+ post = @api.post(post_id)
1874
+ if post.nil?
1875
+ redirect "/view/#{@current_view.name}?error=Post #{post_id} not found"
1876
+ return
1877
+ end
1878
+
1879
+ filename = file[:filename]
1880
+ tempfile = file[:tempfile]
1881
+
1882
+ # Use the API to upload the asset with original filename
1883
+ target_file = @api.upload_asset(tempfile.path, 'post', post_id, filename: filename)
1884
+
1885
+ redirect "/view/#{@current_view.name}?message=Asset '#{filename}' uploaded successfully to post ##{post_id}"
1886
+ rescue => e
1887
+ redirect "/view/#{@current_view.name}?error=Failed to upload asset: #{e.message}"
1888
+ end
1889
+ end
1890
+
1891
+ # Upload asset
1892
+ post '/asset_management/upload' do
1893
+ @current_view = @api&.current_view
1894
+ if @current_view.nil?
1895
+ redirect "/?error=No view selected. Please select a view first."
1896
+ return
1897
+ end
1898
+
1899
+ begin
1900
+ target = params[:target] # 'global', 'library', or 'view'
1901
+ file = params[:file]
1902
+
1903
+ if file.nil? || file[:tempfile].nil?
1904
+ redirect "/asset_management?error=No file selected"
1905
+ return
1906
+ end
1907
+
1908
+ filename = file[:filename]
1909
+ tempfile = file[:tempfile]
1910
+
1911
+ # Determine target directory
1912
+ case target
1913
+ when 'global'
1914
+ target_dir = @api.root/"assets"
1915
+ when 'library'
1916
+ target_dir = @api.root/"assets"/"library"
1917
+ when 'view'
1918
+ target_dir = @api.root/"views"/@current_view.name/"assets"
1919
+ else
1920
+ redirect "/asset_management?error=Invalid target"
1921
+ return
1922
+ end
1923
+
1924
+ # Create directory if it doesn't exist
1925
+ FileUtils.mkdir_p(target_dir)
1926
+
1927
+ # Save the file
1928
+ target_file = target_dir/filename
1929
+ FileUtils.cp(tempfile.path, target_file)
1930
+
1931
+ redirect "/asset_management?message=Asset '#{filename}' uploaded successfully to #{target}"
1932
+ rescue => e
1933
+ redirect "/asset_management?error=Failed to upload asset: #{e.message}"
1934
+ end
1935
+ end
1936
+
1937
+ # Copy asset from global to view
1938
+ post '/asset_management/copy' do
1939
+ @current_view = @api&.current_view
1940
+ if @current_view.nil?
1941
+ redirect "/?error=No view selected. Please select a view first."
1942
+ return
1943
+ end
1944
+
1945
+ begin
1946
+ source = params[:source] # 'global' or 'library'
1947
+ filename = params[:filename]
1948
+
1949
+ if filename.nil? || filename.empty?
1950
+ redirect "/asset_management?error=No filename specified"
1951
+ return
1952
+ end
1953
+
1954
+ # Determine source file
1955
+ case source
1956
+ when 'global'
1957
+ source_file = @api.root/"assets"/filename
1958
+ when 'library'
1959
+ source_file = @api.root/"assets"/"library"/filename
1960
+ else
1961
+ redirect "/asset_management?error=Invalid source"
1962
+ return
1963
+ end
1964
+
1965
+ unless File.exist?(source_file)
1966
+ redirect "/asset_management?error=Source file not found"
1967
+ return
1968
+ end
1969
+
1970
+ # Copy to view assets
1971
+ target_dir = @api.root/"views"/@current_view.name/"assets"
1972
+ FileUtils.mkdir_p(target_dir)
1973
+ target_file = target_dir/filename
1974
+ FileUtils.cp(source_file, target_file)
1975
+
1976
+ redirect "/asset_management?message=Asset '#{filename}' copied successfully to view"
1977
+ rescue => e
1978
+ redirect "/asset_management?error=Failed to copy asset: #{e.message}"
1979
+ end
1980
+ end
1981
+
1982
+ # Delete asset
1983
+ post '/asset_management/delete' do
1984
+ @current_view = @api&.current_view
1985
+ if @current_view.nil?
1986
+ redirect "/?error=No view selected. Please select a view first."
1987
+ return
1988
+ end
1989
+
1990
+ begin
1991
+ target = params[:target] # 'global', 'library', or 'view'
1992
+ filename = params[:filename]
1993
+
1994
+ if filename.nil? || filename.empty?
1995
+ redirect "/asset_management?error=No filename specified"
1996
+ return
1997
+ end
1998
+
1999
+ # Determine target file
2000
+ case target
2001
+ when 'global'
2002
+ target_file = @api.root/"assets"/filename
2003
+ when 'library'
2004
+ target_file = @api.root/"assets"/"library"/filename
2005
+ when 'view'
2006
+ target_file = @api.root/"views"/@current_view.name/"assets"/filename
2007
+ else
2008
+ redirect "/asset_management?error=Invalid target"
2009
+ return
2010
+ end
2011
+
2012
+ unless File.exist?(target_file)
2013
+ redirect "/asset_management?error=File not found"
2014
+ return
2015
+ end
2016
+
2017
+ # Delete the file
2018
+ File.delete(target_file)
2019
+
2020
+ redirect "/asset_management?message=Asset '#{filename}' deleted successfully"
2021
+ rescue => e
2022
+ redirect "/asset_management?error=Failed to delete asset: #{e.message}"
2023
+ end
2024
+ end
2025
+
2026
+ # Widget Management Routes
2027
+
2028
+ # List widgets for current view
2029
+ get '/widgets' do
2030
+ if @api&.instance_variable_get(:@repo) && @current_view
2031
+ @available_widgets = @api.widgets_available
2032
+ @configured_widgets = []
2033
+ @widget_containers = {}
2034
+
2035
+ @available_widgets.each do |widget|
2036
+ widget_dir = @api.root/"views"/@current_view.name/"widgets"/widget
2037
+ if Dir.exist?(widget_dir)
2038
+ @configured_widgets << widget
2039
+ # Determine which container this widget is in
2040
+ @widget_containers[widget] = determine_widget_container(@current_view)
2041
+ end
2042
+ end
2043
+
2044
+ erb :widgets
2045
+ else
2046
+ redirect "/?error=No repository or view selected"
2047
+ end
2048
+ end
2049
+
2050
+ # Add widget to current view
2051
+ post '/add_widget' do
2052
+ if @api&.instance_variable_get(:@repo) && @current_view
2053
+ widget_name = params[:widget_name]&.strip
2054
+
2055
+ if widget_name.nil? || widget_name.empty?
2056
+ redirect "/widgets?error=Widget name required"
2057
+ return
2058
+ end
2059
+
2060
+ # Check if widget is available
2061
+ available_widgets = @api.widgets_available
2062
+ unless available_widgets.include?(widget_name)
2063
+ redirect "/widgets?error=Widget '#{widget_name}' not available"
2064
+ return
2065
+ end
2066
+
2067
+ # Check if widget is already configured
2068
+ widget_dir = @api.root/"views"/@current_view.name/"widgets"/widget_name
2069
+ if Dir.exist?(widget_dir)
2070
+ redirect "/widgets?error=Widget '#{widget_name}' already configured"
2071
+ return
2072
+ end
2073
+
2074
+ # Determine container (left/right) for widget placement
2075
+ container = determine_widget_container(@current_view)
2076
+ unless container
2077
+ redirect "/widgets?error=No left or right container found in layout. Add a left or right container to your layout first."
2078
+ return
2079
+ end
2080
+
2081
+ # Create widget directory and list.txt
2082
+ FileUtils.mkdir_p(widget_dir)
2083
+ list_file = widget_dir/"list.txt"
2084
+ File.write(list_file, "# Add #{widget_name} items here\n")
2085
+
2086
+ # Generate the widget after creation
2087
+ begin
2088
+ @api.generate_widget(widget_name)
2089
+ redirect "/widgets?message=Widget '#{widget_name}' added successfully to #{container} container and generated"
2090
+ rescue => e
2091
+ # Widget created but generation failed
2092
+ redirect "/widgets?message=Widget '#{widget_name}' added successfully to #{container} container, but generation failed: #{e.message}"
2093
+ end
2094
+ else
2095
+ redirect "/?error=No repository or view selected"
2096
+ end
2097
+ end
2098
+
2099
+ # Configure widget data
2100
+ get '/config_widget/:widget_name' do
2101
+ if @api&.instance_variable_get(:@repo) && @current_view
2102
+ @widget_name = params[:widget_name]
2103
+ widget_dir = @api.root/"views"/@current_view.name/"widgets"/@widget_name
2104
+
2105
+ unless Dir.exist?(widget_dir)
2106
+ redirect "/widgets?error=Widget '#{@widget_name}' not configured"
2107
+ return
2108
+ end
2109
+
2110
+ list_file = widget_dir/"list.txt"
2111
+ @widget_data = File.exist?(list_file) ? File.read(list_file) : ""
2112
+
2113
+ erb :config_widget
2114
+ else
2115
+ redirect "/?error=No repository or view selected"
2116
+ end
2117
+ end
2118
+
2119
+ # Update widget data
2120
+ post '/update_widget/:widget_name' do
2121
+ if @api&.instance_variable_get(:@repo) && @current_view
2122
+ widget_name = params[:widget_name]
2123
+ widget_data = params[:widget_data]
2124
+
2125
+ widget_dir = @api.root/"views"/@current_view.name/"widgets"/widget_name
2126
+ list_file = widget_dir/"list.txt"
2127
+
2128
+ File.write(list_file, widget_data)
2129
+
2130
+ # Generate the widget after updating
2131
+ begin
2132
+ @api.generate_widget(widget_name)
2133
+ redirect "/widgets?message=Widget '#{widget_name}' updated and generated successfully"
2134
+ rescue => e
2135
+ # Widget updated but generation failed
2136
+ redirect "/widgets?error=Widget '#{widget_name}' updated successfully, but generation failed: #{e.message}"
2137
+ end
2138
+ else
2139
+ redirect "/?error=No repository or view selected"
2140
+ end
2141
+ end
2142
+
2143
+ # Remove widget from current view
2144
+ post '/remove_widget' do
2145
+ if @api&.instance_variable_get(:@repo) && @current_view
2146
+ widget_name = params[:widget_name]&.strip
2147
+
2148
+ if widget_name.nil? || widget_name.empty?
2149
+ redirect "/widgets?error=Widget name required"
2150
+ return
2151
+ end
2152
+
2153
+ widget_dir = @api.root/"views"/@current_view.name/"widgets"/widget_name
2154
+ if Dir.exist?(widget_dir)
2155
+ FileUtils.rm_rf(widget_dir)
2156
+ redirect "/widgets?message=Widget '#{widget_name}' removed successfully"
2157
+ else
2158
+ redirect "/widgets?error=Widget '#{widget_name}' not found"
2159
+ end
2160
+ else
2161
+ redirect "/?error=No repository or view selected"
2162
+ end
2163
+ end
2164
+
2165
+ # Helper method to update status
2166
+ private def update_config_status(view_name, config_name, status)
2167
+ status_file = @api.root/"views"/view_name/"config"/"status.txt"
2168
+ return unless File.exist?(status_file)
2169
+
2170
+ content = read_file(status_file)
2171
+ lines = content.lines.map do |line|
2172
+ if line.strip.start_with?("#{config_name} ")
2173
+ "#{config_name} #{status ? 'y' : 'n'}\n"
2174
+ else
2175
+ line
2176
+ end
2177
+ end
2178
+ write_file(status_file, lines.join)
2179
+ end
2180
+
2181
+ # Helper method for formatting file sizes
2182
+ def number_to_human_size(bytes)
2183
+ return '0 Bytes' if bytes == 0
2184
+ k = 1024
2185
+ sizes = ['Bytes', 'KB', 'MB', 'GB']
2186
+ i = (Math.log(bytes) / Math.log(k)).floor
2187
+ "#{(bytes / k**i.to_f).round(2)} #{sizes[i]}"
2188
+ end
2189
+
2190
+ # Helper method to determine which container (left/right) widgets should be placed in
2191
+ private def determine_widget_container(view)
2192
+ layout_file = @api.root/"views"/view.name/"config"/"layout.txt"
2193
+ return nil unless File.exist?(layout_file)
2194
+
2195
+ layout_config = @api.parse_commented_file(layout_file)
2196
+ containers = layout_config.keys
2197
+
2198
+ # Prefer left container, fall back to right
2199
+ containers.find { |c| c == 'left' } || containers.find { |c| c == 'right' }
2200
+ end
2201
+
2202
+ def get_image_dimensions(file_path)
2203
+ return nil unless file_path && File.exist?(file_path)
2204
+
2205
+ # Check if it's an image file
2206
+ image_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp']
2207
+ return nil unless image_extensions.any? { |ext| file_path.downcase.end_with?(ext) }
2208
+
2209
+ # Check if FastImage is available
2210
+ return nil unless defined?(FastImage)
2211
+
2212
+ dimensions = FastImage.size(file_path)
2213
+ return dimensions ? "#{dimensions[0]}×#{dimensions[1]}" : nil
2214
+ rescue => e
2215
+ # If FastImage fails, return nil
2216
+ return nil
2217
+ end
2218
+
2219
+ def get_post_title(post_id)
2220
+ begin
2221
+ post = @api.post(post_id)
2222
+ post.title
2223
+ rescue => e
2224
+ "Post ##{post_id}"
2225
+ end
2226
+ end
2227
+
2228
+ def format_backup_age(timestamp)
2229
+ # Parse timestamp (format: YYYYMMDD-HHMMSS)
2230
+ year = timestamp[0..3].to_i
2231
+ month = timestamp[4..5].to_i
2232
+ day = timestamp[6..7].to_i
2233
+ hour = timestamp[9..10].to_i
2234
+ minute = timestamp[11..12].to_i
2235
+ second = timestamp[13..14].to_i
2236
+
2237
+ backup_time = Time.new(year, month, day, hour, minute, second)
2238
+ now = Time.now
2239
+ diff = now - backup_time
2240
+
2241
+ if diff < 60
2242
+ "#{diff.to_i} seconds ago"
2243
+ elsif diff < 3600
2244
+ "#{(diff / 60).to_i} minutes ago"
2245
+ elsif diff < 86400
2246
+ "#{(diff / 3600).to_i} hours ago"
2247
+ elsif diff < 2592000 # 30 days
2248
+ "#{(diff / 86400).to_i} days ago"
2249
+ else
2250
+ "#{(diff / 2592000).to_i} months ago"
2251
+ end
2252
+ end
2253
+
2254
+
2255
+
2256
+ # Delete a post (move to _postnum directory)
2257
+ post '/delete_post/:id' do
2258
+ post_id = params[:id]
2259
+
2260
+ begin
2261
+ # Set current view before proceeding
2262
+ @current_view = @api&.current_view
2263
+ if @current_view.nil?
2264
+ redirect "/?error=No view selected"
2265
+ return
2266
+ end
2267
+
2268
+ post = @api.post(post_id.to_i)
2269
+ if post.nil?
2270
+ redirect "/?error=Post #{post_id} not found"
2271
+ return
2272
+ end
2273
+
2274
+ # Mark as deleted in metadata
2275
+ post.deleted = true
2276
+
2277
+ # Move post directory to _postnum
2278
+ post_dir = @api.root/"posts"/post.num
2279
+ deleted_dir = @api.root/"posts"/"_#{post.num}"
2280
+
2281
+ if Dir.exist?(post_dir)
2282
+ FileUtils.mkdir_p(File.dirname(deleted_dir))
2283
+ FileUtils.mv(post_dir, deleted_dir)
2284
+ else
2285
+ redirect "/?error=Post directory #{post_dir} not found"
2286
+ return
2287
+ end
2288
+
2289
+ # Preserve current page if available
2290
+ current_page = params[:page] || request.env['HTTP_REFERER']&.match(/[?&]page=(\d+)/)&.[](1) || 1
2291
+ redirect "/view/#{@current_view.name}?page=#{current_page}&message=Post #{post_id} deleted successfully"
2292
+ rescue => e
2293
+ redirect "/?error=Failed to delete post: #{e.message}"
2294
+ end
2295
+ end
2296
+
2297
+ # Restore a deleted post
2298
+ post '/restore_post/:id' do
2299
+ post_id = params[:id]
2300
+
2301
+ begin
2302
+ # Set current view before proceeding
2303
+ @current_view = @api&.current_view
2304
+ if @current_view.nil?
2305
+ redirect "/?error=No view selected"
2306
+ return
2307
+ end
2308
+
2309
+ # Find the deleted post directory
2310
+ formatted_id = post_id.to_s.rjust(4, '0') # Ensure 4-digit format (e.g., "28" -> "0028")
2311
+ deleted_dir = @api.root/"posts"/"_#{formatted_id}"
2312
+ post_dir = @api.root/"posts"/formatted_id
2313
+
2314
+ if Dir.exist?(deleted_dir)
2315
+ # Move back to normal posts directory
2316
+ FileUtils.mv(deleted_dir, post_dir)
2317
+
2318
+ # Update metadata to mark as not deleted
2319
+ post = @api.post(post_id.to_i)
2320
+ if post
2321
+ # Debug: log both date fields before and after
2322
+ File.write('/tmp/restore_debug.log', "Restoring post #{post_id}: pubdate before = #{post.pubdate}, created before = #{post.created}\n", mode: 'a')
2323
+ post.deleted = false
2324
+ File.write('/tmp/restore_debug.log', "Restoring post #{post_id}: pubdate after = #{post.pubdate}, created after = #{post.created}\n", mode: 'a')
2325
+ end
2326
+
2327
+ # Preserve current page if available
2328
+ current_page = params[:page] || request.env['HTTP_REFERER']&.match(/[?&]page=(\d+)/)&.[](1) || 1
2329
+ redirect "/view/#{@current_view.name}?page=#{current_page}&message=Post #{post_id} restored successfully"
2330
+ else
2331
+ redirect "/?error=Deleted post #{post_id} not found"
2332
+ end
2333
+ rescue => e
2334
+ redirect "/?error=Failed to restore post: #{e.message}"
2335
+ end
2336
+ end
2337
+
2338
+ # Toggle post published status
2339
+ post '/toggle_post_status/:id' do
2340
+ post_id = params[:id]
2341
+
2342
+ begin
2343
+ # Set current view before proceeding
2344
+ @current_view = @api&.current_view
2345
+ if @current_view.nil?
2346
+ redirect "/?error=No view selected"
2347
+ return
2348
+ end
2349
+
2350
+ post = @api.post(post_id.to_i)
2351
+ if post.nil?
2352
+ redirect "/?error=Post #{post_id} not found"
2353
+ return
2354
+ end
2355
+
2356
+ # Toggle between published and unpublished
2357
+ if post.meta["post.published"] == "no" || post.meta["post.published"].nil?
2358
+ # Publish the post - only change published status, don't touch pubdate
2359
+ post.meta["post.published"] = "yes"
2360
+ post.save_metadata
2361
+ content_type :json
2362
+ { success: true, message: "Post #{post_id} published successfully", published: true }.to_json
2363
+ else
2364
+ # Unpublish the post - only change published status, don't touch pubdate
2365
+ post.meta["post.published"] = "no"
2366
+ post.save_metadata
2367
+ content_type :json
2368
+ { success: true, message: "Post #{post_id} unpublished successfully", published: false }.to_json
2369
+ end
2370
+ rescue => e
2371
+ redirect "/?error=Failed to toggle post status: #{e.message}"
2372
+ end
2373
+ end
2374
+
2375
+ # Backup management
2376
+ get '/backup_management' do
2377
+ @current_view = @api&.current_view
2378
+ if @current_view.nil?
2379
+ redirect "/?error=No view selected. Please select a view first."
2380
+ return
2381
+ end
2382
+
2383
+ begin
2384
+ @backups = @api.list_backups
2385
+ # Sort backups by timestamp (newest first)
2386
+ @backups.sort_by! { |backup| backup[:timestamp] }.reverse!
2387
+
2388
+ # Add human-readable age to each backup
2389
+ @backups.each do |backup|
2390
+ # Convert Time object to string if needed
2391
+ timestamp_str = backup[:timestamp].is_a?(Time) ? backup[:timestamp].strftime("%Y%m%d-%H%M%S") : backup[:timestamp]
2392
+ backup[:age] = format_backup_age(timestamp_str)
2393
+ # Also ensure timestamp is a string for display
2394
+ backup[:timestamp] = timestamp_str
2395
+ end
2396
+ rescue => e
2397
+ @backups = []
2398
+ @error = "Failed to load backups: #{e.message}"
2399
+ end
2400
+
2401
+ erb :backup_management
2402
+ end
2403
+
2404
+ # Create backup
2405
+ post '/backup_management/create' do
2406
+ @current_view = @api&.current_view
2407
+ if @current_view.nil?
2408
+ redirect "/backup_management?error=No view selected. Please select a view first."
2409
+ return
2410
+ end
2411
+
2412
+ begin
2413
+ type = params[:type] # 'full' or 'incr'
2414
+ description = params[:description]
2415
+
2416
+ # Validate type
2417
+ unless %w[full incr].include?(type)
2418
+ redirect "/backup_management?error=Invalid backup type. Must be 'full' or 'incr'."
2419
+ return
2420
+ end
2421
+
2422
+ # Create backup
2423
+ timestamp = @api.create_backup(type: type.to_sym, label: description)
2424
+
2425
+ redirect "/backup_management?message=Backup created successfully: #{timestamp}"
2426
+ rescue => e
2427
+ redirect "/backup_management?error=Failed to create backup: #{e.message}"
2428
+ end
2429
+ end
2430
+
2431
+ # Theme management routes
2432
+
2433
+ # Theme management page
2434
+ get '/theme_management' do
2435
+ begin
2436
+ @themes = @api.themes_available
2437
+ @system_themes = @api.system_themes
2438
+ @user_themes = @api.user_themes
2439
+ erb :theme_management
2440
+ rescue => e
2441
+ redirect "/?error=Failed to load themes: #{e.message}"
2442
+ end
2443
+ end
2444
+
2445
+ # Clone theme
2446
+ post '/theme_management/clone' do
2447
+ begin
2448
+ source_theme = params[:source_theme]&.strip
2449
+ new_name = params[:new_name]&.strip
2450
+
2451
+ if source_theme.nil? || source_theme.empty?
2452
+ redirect "/theme_management?error=Source theme is required"
2453
+ return
2454
+ end
2455
+
2456
+ if new_name.nil? || new_name.empty?
2457
+ redirect "/theme_management?error=New theme name is required"
2458
+ return
2459
+ end
2460
+
2461
+ # Validate new name format
2462
+ unless new_name.match?(/^[a-zA-Z0-9_-]+$/)
2463
+ redirect "/theme_management?error=Theme name can only contain letters, numbers, hyphens, and underscores"
2464
+ return
2465
+ end
2466
+
2467
+ # Clone the theme
2468
+ @api.clone_theme(source_theme, new_name)
2469
+ redirect "/theme_management?message=Theme '#{new_name}' cloned successfully from '#{source_theme}'"
2470
+ rescue => e
2471
+ error_info = friendly_error_message(e)
2472
+ redirect "/theme_management?error=#{error_info[:message]}&suggestion=#{error_info[:suggestion]}"
2473
+ end
2474
+ end
2475
+
2476
+ # Delete theme (user themes only)
2477
+ post '/theme_management/delete' do
2478
+ begin
2479
+ theme_name = params[:theme_name]&.strip
2480
+
2481
+ if theme_name.nil? || theme_name.empty?
2482
+ redirect "/theme_management?error=Theme name is required"
2483
+ return
2484
+ end
2485
+
2486
+ # Check if it's a system theme
2487
+ if @api.system_themes.include?(theme_name)
2488
+ redirect "/theme_management?error=Cannot delete system theme '#{theme_name}'"
2489
+ return
2490
+ end
2491
+
2492
+ # Check if it's a user theme
2493
+ unless @api.user_themes.include?(theme_name)
2494
+ redirect "/theme_management?error=Theme '#{theme_name}' not found or not a user theme"
2495
+ return
2496
+ end
2497
+
2498
+ # Delete the theme directory
2499
+ theme_dir = @api.root/:themes/theme_name
2500
+ if Dir.exist?(theme_dir)
2501
+ FileUtils.rm_rf(theme_dir)
2502
+ redirect "/theme_management?message=Theme '#{theme_name}' deleted successfully"
2503
+ else
2504
+ redirect "/theme_management?error=Theme directory not found"
2505
+ end
2506
+ rescue => e
2507
+ redirect "/theme_management?error=Failed to delete theme: #{e.message}"
2508
+ end
2509
+ end
2510
+
2511
+ # Edit theme files
2512
+ get '/edit_theme/:theme_name' do
2513
+ begin
2514
+ theme_name = params[:theme_name]&.strip
2515
+ unless @api.theme_exists?(theme_name)
2516
+ redirect "/theme_management?error=Theme '#{theme_name}' not found."
2517
+ end
2518
+
2519
+ @theme_name = theme_name
2520
+ @theme_dir = @api.root/:themes/theme_name
2521
+
2522
+ # List all editable files within the theme directory
2523
+ @editable_files = []
2524
+ if Dir.exist?(@theme_dir)
2525
+ Dir.glob(File.join(@theme_dir, "**", "*.txt")).each do |file_path|
2526
+ relative_path = Pathname.new(file_path).relative_path_from(Pathname.new(@theme_dir)).to_s
2527
+ @editable_files << relative_path
2528
+ end
2529
+ end
2530
+
2531
+ erb :edit_theme
2532
+ rescue => e
2533
+ "Error in edit_theme route: #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
2534
+ end
2535
+ end
2536
+
2537
+ # Edit specific theme file
2538
+ get '/edit_theme/:theme_name/*' do
2539
+ theme_name = params[:theme_name]&.strip
2540
+ file_path_param = params[:splat].first
2541
+
2542
+ unless @api.theme_exists?(theme_name)
2543
+ redirect "/theme_management?error=Theme '#{theme_name}' not found."
2544
+ end
2545
+
2546
+ theme_file_path = File.join(@api.root/:themes/theme_name, file_path_param)
2547
+
2548
+ unless File.exist?(theme_file_path)
2549
+ redirect "/edit_theme/#{theme_name}?error=File '#{file_path_param}' not found in theme '#{theme_name}'."
2550
+ end
2551
+
2552
+ @theme_name = theme_name
2553
+ @file_name = file_path_param
2554
+ @file_content = File.read(theme_file_path)
2555
+
2556
+ erb :edit_theme_file
2557
+ end
2558
+
2559
+ # Save theme file
2560
+ post '/save_theme_file/:theme_name/*' do
2561
+ theme_name = params[:theme_name]&.strip
2562
+ file_path_param = params[:splat].first
2563
+ file_content = params[:content]
2564
+
2565
+ unless @api.theme_exists?(theme_name)
2566
+ redirect "/theme_management?error=Theme '#{theme_name}' not found."
2567
+ end
2568
+
2569
+ # Ensure it's a user theme if we're allowing edits
2570
+ unless @api.user_themes.include?(theme_name)
2571
+ redirect "/edit_theme/#{theme_name}?error=Cannot edit system theme files directly."
2572
+ end
2573
+
2574
+ theme_file_path = File.join(@api.root/:themes/theme_name, file_path_param)
2575
+
2576
+ unless File.exist?(theme_file_path)
2577
+ redirect "/edit_theme/#{theme_name}?error=File '#{file_path_param}' not found in theme '#{theme_name}'."
2578
+ end
2579
+
2580
+ File.write(theme_file_path, file_content)
2581
+ redirect "/edit_theme/#{theme_name}/#{file_path_param}?message=File saved successfully."
2582
+ rescue => e
2583
+ redirect "/edit_theme/#{theme_name}/#{file_path_param}?error=Failed to save file: #{e.message}"
2584
+ end
2585
+
2586
+ # Debug route to verify code is updated
2587
+ get '/debug' do
2588
+ "Server is running updated code at #{Time.now}"
2589
+ end
2590
+
2591
+
2592
+ end
2593
+
2594
+ # Start the server if this file is run directly
2595
+ if __FILE__ == $0
2596
+ ScriptoriumWeb.run!
2597
+ end
2598
+
2599
+ # Set initial test mode from command line after class definition
2600
+ ScriptoriumWeb.test_mode = TEST_MODE