scriptorium 0.0.3 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (353) hide show
  1. checksums.yaml +4 -4
  2. data/README.lt3 +324 -0
  3. data/README.md +3155 -1
  4. data/assets/.DS_Store +0 -0
  5. data/assets/README.md +44 -0
  6. data/assets/icons/social/reddit.png +0 -0
  7. data/assets/icons/social/x-logo.png +0 -0
  8. data/assets/icons/ui/.DS_Store +0 -0
  9. data/assets/icons/ui/back.png +0 -0
  10. data/assets/icons/ui/copy.png +0 -0
  11. data/assets/icons/ui/down.png +0 -0
  12. data/assets/icons/ui/end.png +0 -0
  13. data/assets/icons/ui/exit.png +0 -0
  14. data/assets/icons/ui/foo +10 -0
  15. data/assets/icons/ui/home.png +0 -0
  16. data/assets/icons/ui/left.png +0 -0
  17. data/assets/icons/ui/next.png +0 -0
  18. data/assets/icons/ui/right.png +0 -0
  19. data/assets/icons/ui/start.png +0 -0
  20. data/assets/icons/ui/up.png +0 -0
  21. data/assets/imagenotfound.jpg +0 -0
  22. data/assets/samples/placeholder.svg +9 -0
  23. data/assets/themes/standard/favicon.svg +6 -0
  24. data/bin/sblog +84 -5
  25. data/bin/scriptorium +1 -0
  26. data/doc/README.txt +6 -0
  27. data/doc/anti-amnesia/20250727-054000-scriptorium-overview.md +94 -0
  28. data/doc/anti-amnesia/20250727-123000-anti-amnesia-conventions.md +2 -0
  29. data/doc/anti-amnesia/20250727-172600-cursor-rbenv-ruby-version-mystery.md +45 -0
  30. data/doc/anti-amnesia/20250727-172900-ai-cognitive-assessment-capabilities.md +40 -0
  31. data/doc/anti-amnesia/20250728-124243-aaa-syntax-clarification.md +46 -0
  32. data/doc/anti-amnesia/20250729-210000-reddit-autopost-integration-complete.md +158 -0
  33. data/doc/anti-amnesia/20250804-190500-cognitive-loop-bug.md +35 -0
  34. data/doc/anti-amnesia/20250804-190700-anti-amnesia-timestamping-fix.md +27 -0
  35. data/doc/anti-amnesia/20250807-213025.md +116 -0
  36. data/doc/anti-amnesia/20250901-211714-codemirror-integration-and-web-tests.md +172 -0
  37. data/doc/anti-amnesia/20250902-002402-backup-restore-system.md +126 -0
  38. data/doc/anti-amnesia/20250907-203339-backup-metadata-implementation.md +66 -0
  39. data/doc/banner_svg_config.md +114 -0
  40. data/doc/contrib.lt3 +8 -0
  41. data/doc/dependencies.md +281 -0
  42. data/doc/hacker.lt3 +5 -0
  43. data/doc/imported/0001-elixir-conf-2014/metadata.txt +7 -0
  44. data/doc/imported/0001-elixir-conf-2014/post.html +37 -0
  45. data/doc/imported/0001-elixir-conf-2014/source.lt3 +22 -0
  46. data/doc/imported/0002-programmers-and-word-processing/metadata.txt +7 -0
  47. data/doc/imported/0002-programmers-and-word-processing/post.html +192 -0
  48. data/doc/imported/0002-programmers-and-word-processing/source.lt3 +146 -0
  49. data/doc/imported/0003-how-to-turn-your-brain-sideways/metadata.txt +7 -0
  50. data/doc/imported/0003-how-to-turn-your-brain-sideways/post.html +60 -0
  51. data/doc/imported/0003-how-to-turn-your-brain-sideways/source.lt3 +40 -0
  52. data/doc/imported/0004-upcoming-lone-star-ruby-conference/metadata.txt +7 -0
  53. data/doc/imported/0004-upcoming-lone-star-ruby-conference/post.html +42 -0
  54. data/doc/imported/0004-upcoming-lone-star-ruby-conference/source.lt3 +24 -0
  55. data/doc/imported/0005-elixir-conf-2015-announced/metadata.txt +7 -0
  56. data/doc/imported/0005-elixir-conf-2015-announced/post.html +30 -0
  57. data/doc/imported/0005-elixir-conf-2015-announced/source.lt3 +16 -0
  58. data/doc/imported/0006-ruby-for-dinosaurs/metadata.txt +7 -0
  59. data/doc/imported/0006-ruby-for-dinosaurs/post.html +43 -0
  60. data/doc/imported/0006-ruby-for-dinosaurs/source.lt3 +27 -0
  61. data/doc/imported/0007-phoenix-isnt-rails/metadata.txt +7 -0
  62. data/doc/imported/0007-phoenix-isnt-rails/post.html +116 -0
  63. data/doc/imported/0007-phoenix-isnt-rails/source.lt3 +87 -0
  64. data/doc/imported/0008-concerning-the-term-monkeypatching/metadata.txt +7 -0
  65. data/doc/imported/0008-concerning-the-term-monkeypatching/post.html +129 -0
  66. data/doc/imported/0008-concerning-the-term-monkeypatching/source.lt3 +92 -0
  67. data/doc/imported/0009-announcement-coming-soon/metadata.txt +7 -0
  68. data/doc/imported/0009-announcement-coming-soon/post.html +33 -0
  69. data/doc/imported/0009-announcement-coming-soon/source.lt3 +19 -0
  70. data/doc/imported/0010-immutable-data-ditching-the-wax-tablet/metadata.txt +7 -0
  71. data/doc/imported/0010-immutable-data-ditching-the-wax-tablet/post.html +175 -0
  72. data/doc/imported/0010-immutable-data-ditching-the-wax-tablet/source.lt3 +139 -0
  73. data/doc/imported/0011-computer-science-as-a-lost-art/metadata.txt +7 -0
  74. data/doc/imported/0011-computer-science-as-a-lost-art/post.html +139 -0
  75. data/doc/imported/0011-computer-science-as-a-lost-art/source.lt3 +104 -0
  76. data/doc/imported/0012-ruby-day-in-turin-italy/metadata.txt +7 -0
  77. data/doc/imported/0012-ruby-day-in-turin-italy/post.html +42 -0
  78. data/doc/imported/0012-ruby-day-in-turin-italy/source.lt3 +24 -0
  79. data/doc/imported/0013-rubyday-was-a-success/metadata.txt +7 -0
  80. data/doc/imported/0013-rubyday-was-a-success/post.html +44 -0
  81. data/doc/imported/0013-rubyday-was-a-success/source.lt3 +27 -0
  82. data/doc/imported/0014-working-on-the-blogging-software/metadata.txt +7 -0
  83. data/doc/imported/0014-working-on-the-blogging-software/post.html +63 -0
  84. data/doc/imported/0014-working-on-the-blogging-software/source.lt3 +41 -0
  85. data/doc/imported/0015-ok-its-not-really-a-lost-art/metadata.txt +7 -0
  86. data/doc/imported/0015-ok-its-not-really-a-lost-art/post.html +172 -0
  87. data/doc/imported/0015-ok-its-not-really-a-lost-art/source.lt3 +134 -0
  88. data/doc/imported/0016-an-in-operator-for-ruby/metadata.txt +7 -0
  89. data/doc/imported/0016-an-in-operator-for-ruby/post.html +155 -0
  90. data/doc/imported/0016-an-in-operator-for-ruby/source.lt3 +106 -0
  91. data/doc/imported/0017-the-forgotten-mathematician/metadata.txt +7 -0
  92. data/doc/imported/0017-the-forgotten-mathematician/post.html +161 -0
  93. data/doc/imported/0017-the-forgotten-mathematician/source.lt3 +119 -0
  94. data/doc/imported/0018-ruby-puns/metadata.txt +7 -0
  95. data/doc/imported/0018-ruby-puns/post.html +46 -0
  96. data/doc/imported/0018-ruby-puns/source.lt3 +28 -0
  97. data/doc/imported/0019-custom-exceptions-via-metaprogramming/metadata.txt +7 -0
  98. data/doc/imported/0019-custom-exceptions-via-metaprogramming/post.html +138 -0
  99. data/doc/imported/0019-custom-exceptions-via-metaprogramming/source.lt3 +101 -0
  100. data/doc/imported/0020-fffff/metadata.txt +7 -0
  101. data/doc/imported/0020-fffff/post.html +24 -0
  102. data/doc/imported/0020-fffff/source.lt3 +12 -0
  103. data/doc/imported/0021-trying-ror-yet-again/metadata.txt +7 -0
  104. data/doc/imported/0021-trying-ror-yet-again/post.html +26 -0
  105. data/doc/imported/0021-trying-ror-yet-again/source.lt3 +12 -0
  106. data/doc/imported/0023-doctor-sleep/metadata.txt +7 -0
  107. data/doc/imported/0023-doctor-sleep/post.html +63 -0
  108. data/doc/imported/0023-doctor-sleep/source.lt3 +44 -0
  109. data/doc/imported/0024-just-a-test/metadata.txt +7 -0
  110. data/doc/imported/0024-just-a-test/post.html +24 -0
  111. data/doc/imported/0024-just-a-test/source.lt3 +12 -0
  112. data/doc/imported/import_summary.txt +98 -0
  113. data/doc/livetext-informal-spec.txt +65 -0
  114. data/doc/myuserdoc/ch-0.lt3 +31 -0
  115. data/doc/myuserdoc/ch-1.lt3 +37 -0
  116. data/doc/myuserdoc/ch-10.lt3 +22 -0
  117. data/doc/myuserdoc/ch-2.lt3 +37 -0
  118. data/doc/myuserdoc/ch-3.lt3 +19 -0
  119. data/doc/myuserdoc/ch-4.lt3 +43 -0
  120. data/doc/myuserdoc/ch-5.lt3 +22 -0
  121. data/doc/myuserdoc/ch-6.lt3 +19 -0
  122. data/doc/myuserdoc/ch-7.lt3 +16 -0
  123. data/doc/myuserdoc/ch-8.lt3 +13 -0
  124. data/doc/myuserdoc/ch-9.lt3 +19 -0
  125. data/doc/myuserdoc/tweak.rb +18 -0
  126. data/doc/myuserdoc/userdoc-toc.txt +88 -0
  127. data/doc/old-posts/0001-elixir-conf-2014.lt3 +24 -0
  128. data/doc/old-posts/0002-programmers-and-word-processing.lt3 +150 -0
  129. data/doc/old-posts/0003-how-to-turn-your-brain-sideways.lt3 +43 -0
  130. data/doc/old-posts/0004-upcoming-lone-star-ruby-conference.lt3 +26 -0
  131. data/doc/old-posts/0005-elixir-conf-2015-announced.lt3 +17 -0
  132. data/doc/old-posts/0006-ruby-for-dinosaurs.lt3 +30 -0
  133. data/doc/old-posts/0007-phoenix-isnt-rails.lt3 +90 -0
  134. data/doc/old-posts/0008-concerning-the-term-monkeypatching.lt3 +105 -0
  135. data/doc/old-posts/0009-announcement-coming-soon.lt3 +20 -0
  136. data/doc/old-posts/0010-immutable-data-ditching-the-wax-tablet.lt3 +142 -0
  137. data/doc/old-posts/0011-computer-science-as-a-lost-art.lt3 +117 -0
  138. data/doc/old-posts/0012-ruby-day-in-turin-italy.lt3 +26 -0
  139. data/doc/old-posts/0013-rubyday-was-a-success.lt3 +28 -0
  140. data/doc/old-posts/0014-working-on-the-blogging-software.lt3 +42 -0
  141. data/doc/old-posts/0015-ok-its-not-really-a-lost-art.lt3 +137 -0
  142. data/doc/old-posts/0016-an-in-operator-for-ruby.lt3 +142 -0
  143. data/doc/old-posts/0017-the-forgotten-mathematician.lt3 +129 -0
  144. data/doc/old-posts/0018-ruby-puns.lt3 +31 -0
  145. data/doc/old-posts/0019-custom-exceptions-via-metaprogramming.lt3 +116 -0
  146. data/doc/old-posts/0021-trying-ror-yet-again.lt3 +35 -0
  147. data/doc/old-posts/0023-doctor-sleep.lt3 +43 -0
  148. data/doc/old-posts/0024-just-a-test.lt3 +12 -0
  149. data/doc/old-posts/0025-trying-another-post.lt3 +12 -0
  150. data/doc/old-repo +1 -0
  151. data/doc/reddit_credentials_template.json +8 -0
  152. data/doc/reddit_integration.md +207 -0
  153. data/doc/user.lt3 +35 -0
  154. data/doc/user_guide_section_1.md +137 -0
  155. data/doc/user_guide_section_10.md +515 -0
  156. data/doc/user_guide_section_11.md +708 -0
  157. data/doc/user_guide_section_2.md +233 -0
  158. data/doc/user_guide_section_3.md +5 -0
  159. data/doc/user_guide_section_4.md +221 -0
  160. data/doc/user_guide_section_5.md +243 -0
  161. data/doc/user_guide_section_6.md +147 -0
  162. data/doc/user_guide_section_7.md +311 -0
  163. data/doc/user_guide_section_8.md +224 -0
  164. data/doc/user_guide_section_9.md +375 -0
  165. data/lib/rouge/lexers/livetext.rb +74 -0
  166. data/lib/scriptorium/api.rb +2373 -0
  167. data/lib/scriptorium/banner_svg.rb +729 -0
  168. data/lib/scriptorium/contract.rb +34 -0
  169. data/lib/scriptorium/exceptions.rb +201 -1
  170. data/lib/scriptorium/helpers.rb +675 -0
  171. data/lib/scriptorium/post.rb +259 -0
  172. data/lib/scriptorium/reddit.rb +83 -0
  173. data/lib/scriptorium/repo.rb +938 -0
  174. data/lib/scriptorium/standard_files.rb +149 -0
  175. data/lib/scriptorium/support/bootstrap/css.txt +5 -0
  176. data/lib/scriptorium/support/bootstrap/js.txt +4 -0
  177. data/lib/scriptorium/support/common_js/clipboard.js +35 -0
  178. data/lib/scriptorium/support/common_js/content-loader.js +187 -0
  179. data/lib/scriptorium/support/common_js/navigation.js +52 -0
  180. data/lib/scriptorium/support/common_js/syntax-highlighting.js +27 -0
  181. data/lib/scriptorium/support/config/reddit.txt +10 -0
  182. data/lib/scriptorium/support/config/reddit_template.txt +17 -0
  183. data/lib/scriptorium/support/config/social.txt +8 -0
  184. data/lib/scriptorium/support/highlight/css.txt +2 -0
  185. data/lib/scriptorium/support/highlight/custom.css +119 -0
  186. data/lib/scriptorium/support/highlight/js.txt +1 -0
  187. data/lib/scriptorium/support/post_index/config.txt +15 -0
  188. data/lib/scriptorium/support/post_index/style.css +55 -0
  189. data/lib/scriptorium/support/templates/index_entry.lt3 +16 -0
  190. data/lib/scriptorium/support/templates/initial_post.lt3 +12 -0
  191. data/lib/scriptorium/support/templates/layout.txt +5 -0
  192. data/lib/scriptorium/support/templates/post.lt3 +104 -0
  193. data/lib/scriptorium/support/theme/footer.lt3 +2 -0
  194. data/lib/scriptorium/support/theme/header.lt3 +4 -0
  195. data/lib/scriptorium/support/theme/left.lt3 +3 -0
  196. data/lib/scriptorium/support/theme/main.lt3 +5 -0
  197. data/lib/scriptorium/support/theme/right.lt3 +3 -0
  198. data/lib/scriptorium/theme.rb +192 -0
  199. data/lib/scriptorium/version.rb +1 -1
  200. data/lib/scriptorium/view.rb +1021 -0
  201. data/lib/scriptorium/widgets/featured_posts.rb +149 -0
  202. data/lib/scriptorium/widgets/links.rb +112 -0
  203. data/lib/scriptorium/widgets/pages.rb +133 -0
  204. data/lib/scriptorium/widgets/widget.rb +133 -0
  205. data/lib/scriptorium.rb +38 -34
  206. data/lib/skeleton.rb +10 -1
  207. data/scriptorium.gemspec +17 -5
  208. data/test/README.md +69 -0
  209. data/test/WEB_INTEGRATION_README.md +196 -0
  210. data/test/all +83 -0
  211. data/test/api_demo.rb +99 -0
  212. data/test/assets/imagenotfound.jpg +0 -0
  213. data/test/assets/images/.DS_Store +0 -0
  214. data/test/assets/images/README.md +27 -0
  215. data/test/assets/images/odd_aspect.png +0 -0
  216. data/test/assets/images/perfect.png +0 -0
  217. data/test/assets/images/small.png +0 -0
  218. data/test/assets/images/tall.png +0 -0
  219. data/test/assets/images/very_tall.png +0 -0
  220. data/test/assets/images/very_wide.png +0 -0
  221. data/test/assets/images/wide.png +0 -0
  222. data/test/assets/testbanner.jpg +0 -0
  223. data/test/banner_svg/simple_helpers.rb +13 -0
  224. data/test/banner_svg/unit.rb +1000 -0
  225. data/test/config/deployment.txt +5 -0
  226. data/test/ed_test.rb +204 -0
  227. data/test/integration/cursor_banner_combinations.rb +193 -0
  228. data/test/integration/cursor_banner_features.rb +374 -0
  229. data/test/integration/integration_test.rb +326 -0
  230. data/test/integration/preview_flow_test.rb +94 -0
  231. data/test/livetext_plugin_test.rb +500 -0
  232. data/test/manual/asset_mgmt.rb +67 -0
  233. data/test/manual/banner-tests/index.html +45 -0
  234. data/test/manual/banner-tests/svg.txt +3 -0
  235. data/test/manual/banner-tests/test01.html +122 -0
  236. data/test/manual/banner-tests/test02.html +122 -0
  237. data/test/manual/banner-tests/test03.html +122 -0
  238. data/test/manual/banner-tests/test04.html +129 -0
  239. data/test/manual/banner-tests/test05.html +129 -0
  240. data/test/manual/banner-tests/test06.html +129 -0
  241. data/test/manual/banner-tests/test07.html +129 -0
  242. data/test/manual/banner-tests/test08.html +123 -0
  243. data/test/manual/banner-tests/test09.html +123 -0
  244. data/test/manual/banner-tests/test10.html +123 -0
  245. data/test/manual/banner-tests/test11.html +123 -0
  246. data/test/manual/banner-tests/test12.html +123 -0
  247. data/test/manual/banner-tests/test13.html +123 -0
  248. data/test/manual/banner-tests/test14.html +123 -0
  249. data/test/manual/banner-tests/test15.html +122 -0
  250. data/test/manual/banner-tests/test16.html +122 -0
  251. data/test/manual/banner-tests/test17.html +122 -0
  252. data/test/manual/banner-tests/test18.html +132 -0
  253. data/test/manual/banner-tests/test19.html +132 -0
  254. data/test/manual/banner-tests/test20.html +132 -0
  255. data/test/manual/banner-tests/test21.html +132 -0
  256. data/test/manual/banner-tests/test22.html +132 -0
  257. data/test/manual/banner-tests/test23.html +132 -0
  258. data/test/manual/banner-tests/test24.html +132 -0
  259. data/test/manual/banner-tests/test25.html +131 -0
  260. data/test/manual/banner_environment.rb +205 -0
  261. data/test/manual/codemirror_demo.html +773 -0
  262. data/test/manual/create_posts_for_web.rb +114 -0
  263. data/test/manual/environment.rb +67 -0
  264. data/test/manual/make_banner.rb +153 -0
  265. data/test/manual/preview_manual_test.rb +129 -0
  266. data/test/manual/sample_banner_config.txt +12 -0
  267. data/test/manual/test_advanced_widgets.rb +73 -0
  268. data/test/manual/test_banner_combinations.rb +120 -0
  269. data/test/manual/test_banner_features.rb +306 -0
  270. data/test/manual/test_banner_integration.rb +115 -0
  271. data/test/manual/test_banner_radial.rb +87 -0
  272. data/test/manual/test_basic_posts.rb +47 -0
  273. data/test/manual/test_layout_widgets.rb +40 -0
  274. data/test/manual/test_pagination.rb +24 -0
  275. data/test/manual/test_random_posts.rb +38 -0
  276. data/test/manual/test_syntax_highlighting.rb +167 -0
  277. data/test/rubytext/rubytext_comprehensive_test.rb +307 -0
  278. data/test/rubytext/rubytext_demo_test.rb +42 -0
  279. data/test/rubytext/rubytext_testing_guide.md +277 -0
  280. data/test/run_automated_tests.rb +45 -0
  281. data/test/staging/.DS_Store +0 -0
  282. data/test/support/preview_utils.rb +88 -0
  283. data/test/syntax_highlighting_test.lt3 +124 -0
  284. data/test/test_gem_assets.rb +48 -0
  285. data/test/test_helpers.rb +240 -0
  286. data/test/tui_editor_integration_test.rb +296 -0
  287. data/test/tui_integration_test.rb +883 -0
  288. data/test/unit/api.rb +1776 -0
  289. data/test/unit/asset_management.rb +219 -0
  290. data/test/unit/backup_test.rb +451 -0
  291. data/test/unit/clipboard_test.rb +60 -0
  292. data/test/unit/contract_test.rb +69 -0
  293. data/test/unit/core.rb +1211 -0
  294. data/test/unit/deploy_config_test.rb +248 -0
  295. data/test/unit/deploy_test.rb +478 -0
  296. data/test/unit/edit_post_test.rb +168 -0
  297. data/test/unit/gem_asset_management.rb +183 -0
  298. data/test/unit/livetext_basic.rb +57 -0
  299. data/test/unit/livetext_compatibility.rb +82 -0
  300. data/test/unit/parse_cmd_test.rb +260 -0
  301. data/test/unit/permalink_copy_test.rb +211 -0
  302. data/test/unit/post.rb +309 -0
  303. data/test/unit/post_index_config_test.rb +258 -0
  304. data/test/unit/post_state_helpers_test.rb +137 -0
  305. data/test/unit/read_commented_file_test.rb +278 -0
  306. data/test/unit/reddit_test.rb +235 -0
  307. data/test/unit/repo.rb +569 -0
  308. data/test/unit/social_test.rb +366 -0
  309. data/test/unit/syntax_highlighting.rb +70 -0
  310. data/test/unit/theme_management_test.rb +91 -0
  311. data/test/unit/view.rb +498 -0
  312. data/test/unit/widgets.rb +669 -0
  313. data/test/web_integration_test.rb +231 -0
  314. data/test/web_test_helper.rb +218 -0
  315. data/test/web_workflow_test.rb +527 -0
  316. data/test/wizard_test.rb +123 -0
  317. data/ui/README.md +67 -0
  318. data/ui/common/lib/ui_common.rb +8 -0
  319. data/ui/rubytext/README.md +191 -0
  320. data/ui/rubytext/bin/scriptorium-rubytext +402 -0
  321. data/ui/rubytext/lib/rubytext_ui.rb +300 -0
  322. data/ui/tui/bin/scriptorium +1890 -0
  323. data/ui/tui/test/tui_test.rb +23 -0
  324. data/ui/web/app/app.rb +2600 -0
  325. data/ui/web/app/assets/livetext_mode.js +244 -0
  326. data/ui/web/app/error_helpers.rb +150 -0
  327. data/ui/web/app/views/advanced_config.erb +196 -0
  328. data/ui/web/app/views/asset_management.erb +645 -0
  329. data/ui/web/app/views/backup_management.erb +238 -0
  330. data/ui/web/app/views/banner_config.erb +200 -0
  331. data/ui/web/app/views/config_widget.erb +232 -0
  332. data/ui/web/app/views/configure_view.erb +401 -0
  333. data/ui/web/app/views/dashboard.erb +154 -0
  334. data/ui/web/app/views/deploy_config.erb +149 -0
  335. data/ui/web/app/views/edit_pages.erb +363 -0
  336. data/ui/web/app/views/edit_post.erb +175 -0
  337. data/ui/web/app/views/edit_theme.erb +73 -0
  338. data/ui/web/app/views/edit_theme_file.erb +74 -0
  339. data/ui/web/app/views/error_page.erb +29 -0
  340. data/ui/web/app/views/header_config.erb +155 -0
  341. data/ui/web/app/views/layout_config.erb +147 -0
  342. data/ui/web/app/views/navbar_config.erb +411 -0
  343. data/ui/web/app/views/theme_management.erb +130 -0
  344. data/ui/web/app/views/view_dashboard.erb +779 -0
  345. data/ui/web/app/views/widgets.erb +249 -0
  346. data/ui/web/bin/scriptorium-web +164 -0
  347. data/ui/web/test/web_basic_test.rb +38 -0
  348. data/ui/web/test_navbar.txt +7 -0
  349. data/ui/web/tmp/timing.log +17 -0
  350. data/ui/web/tmp/web_server.log +0 -0
  351. metadata +434 -8
  352. data/lib/scriptorium/engine.rb +0 -22
  353. data/test/engine/unit.rb +0 -44
@@ -0,0 +1,1890 @@
1
+ #!/Users/Hal/.rbenv/versions/3.2.3/bin/ruby
2
+
3
+ require_relative "../../../lib/scriptorium"
4
+ require 'readline' unless ENV['NOREADLINE']
5
+
6
+ # Main entry point for Scriptorium TUI
7
+ class ScriptoriumTUI
8
+ include Scriptorium::Exceptions
9
+ include Scriptorium::Helpers
10
+
11
+ def initialize
12
+ # Parse command line arguments for test mode
13
+ @testing = ARGV.include?('--test')
14
+
15
+ # Remove --test from ARGV so it doesn't interfere with other processing
16
+ ARGV.delete('--test')
17
+
18
+ # Default to production mode (use core Repo default: ~/.scriptorium)
19
+ @api = Scriptorium::API.new(testmode: @testing)
20
+ setup_readline
21
+ end
22
+
23
+ def discover_repo
24
+ if @testing
25
+ if Dir.exist?("scriptorium-TEST")
26
+ puts "Found existing test repository: scriptorium-TEST"
27
+ @testing = "scriptorium-TEST"
28
+ @api = Scriptorium::API.new(testmode: true)
29
+ begin
30
+ @api.open_repo("scriptorium-TEST")
31
+ puts "Current view: #{@api.current_view&.name || 'nil'}"
32
+ puts "Loaded test repository"
33
+ return true
34
+ rescue => e
35
+ puts "Error opening repository: #{e.message}"
36
+ puts e.backtrace.first if @testing
37
+ return false
38
+ end
39
+ else
40
+ puts "No repository found."
41
+ return false
42
+ end
43
+ else
44
+ # Production mode: use core Repo default (~/.scriptorium)
45
+ home = ENV['HOME']
46
+ production_path = "#{home}/.scriptorium"
47
+
48
+ if Dir.exist?(production_path)
49
+ puts "Found existing production repository: #{production_path}"
50
+ begin
51
+ @api.open_repo(production_path)
52
+ puts "Current view: #{@api.current_view&.name || 'nil'}"
53
+ puts "Loaded production repository"
54
+ return true
55
+ rescue => e
56
+ puts "Error opening repository: #{e.message}"
57
+ return false
58
+ end
59
+ else
60
+ puts "No repository found."
61
+ return false
62
+ end
63
+ end
64
+ return false
65
+ end
66
+
67
+ def create_new_repo
68
+ if @testing
69
+ puts "Creating new test repository..."
70
+ @testing = "scriptorium-TEST"
71
+ @api = Scriptorium::API.new(testmode: true)
72
+ begin
73
+ File.open('/dev/tty', 'w') { |f| f.puts " DEBUG: About to create repo" }
74
+ @api.create_repo("scriptorium-TEST")
75
+ File.open('/dev/tty', 'w') { |f| f.puts " DEBUG: Repo created, about to call get_started" }
76
+ puts "Created test repository successfully."
77
+
78
+ # Run initial setup (like Runeblog)
79
+ get_started
80
+ File.open('/dev/tty', 'w') { |f| f.puts " DEBUG: get_started completed" }
81
+ rescue => e
82
+ puts " DEBUG: Exception in create_new_repo: #{e.class} - #{e.message}"
83
+ puts "Error creating repository: #{e.message}"
84
+ puts e.backtrace.first if @testing
85
+ return false
86
+ end
87
+ else
88
+ puts "Creating new production repository..."
89
+ home = ENV['HOME']
90
+ production_path = "#{home}/.scriptorium"
91
+
92
+ begin
93
+ # For production, use the full home path
94
+ @api.create_repo(production_path)
95
+ puts "Created production repository successfully."
96
+
97
+ # Run initial setup (like Runeblog)
98
+ get_started
99
+ rescue => e
100
+ puts "Error creating repository: #{e.message}"
101
+ return false
102
+ end
103
+ end
104
+ end
105
+
106
+ def wizard_first_view
107
+ # Check if this is the first view (only sample view exists)
108
+ views = @api.views
109
+ if views.length == 1 && views[0].name == "sample"
110
+ puts "Let's set up your first view!"
111
+
112
+ # Create a new view using existing interactive method
113
+ create_view
114
+
115
+ # Get the current view name (the one we just created)
116
+ current_view = @api.current_view
117
+ return unless current_view
118
+ name = current_view.name
119
+
120
+ # Ask about layout
121
+ puts
122
+ if yesno("Would you like to edit the layout?")
123
+ @api.edit_file("#{@api.root}/views/#{name}/config/layout.txt")
124
+ end
125
+
126
+ # Read the layout to see what containers we have
127
+ layout_file = "#{@api.root}/views/#{name}/config/layout.txt"
128
+ layout_content = read_file(layout_file)
129
+ file_containers = layout_content.lines.map { |line| line.split(/\s+/).first }.compact
130
+
131
+ # Define logical order for containers
132
+ logical_order = ['header', 'main', 'left', 'right', 'footer']
133
+
134
+ # Use logical order, but only include containers that exist in the file
135
+ containers = logical_order.select { |container| file_containers.include?(container) }
136
+
137
+ # Configure each container
138
+ containers.each do |container|
139
+ puts
140
+ if yesno("Would you like to configure #{container}?")
141
+ case container
142
+ when 'header'
143
+ # This is complex and will be expanded later
144
+ @api.edit_file("#{@api.root}/views/#{name}/config/header.txt")
145
+ when 'main'
146
+ puts "Main container is just a stub for now"
147
+ when 'left', 'right'
148
+ configure_sidebar_widgets(name, container)
149
+ when 'footer'
150
+ puts "Footer has no real config for now"
151
+ end
152
+ end
153
+ end
154
+
155
+ puts
156
+ puts "View setup complete!"
157
+ else
158
+ puts "Wizard is only available for the first view setup"
159
+ end
160
+ end
161
+
162
+ def configure_sidebar_widgets(view_name, container)
163
+ puts "Add widgets to #{container}? (y/n)"
164
+ return unless yesno("Add widgets to #{container}?")
165
+
166
+ # Show available widgets
167
+ available_widgets = @api.widgets_available
168
+ puts "Available widgets: #{available_widgets.join(', ')}"
169
+
170
+ selected_widgets = []
171
+ available_widgets.each do |widget|
172
+ if yesno("Add #{widget} widget?")
173
+ selected_widgets << widget
174
+ end
175
+ end
176
+
177
+ # Configure each selected widget
178
+ selected_widgets.each do |widget|
179
+ if yesno("Configure #{widget} widget?")
180
+ case widget
181
+ when 'links'
182
+ @api.edit_file("#{@api.root}/views/#{view_name}/widgets/links/list.txt")
183
+ when 'pages'
184
+ configure_pages_widget(view_name)
185
+ end
186
+ end
187
+ end
188
+ end
189
+
190
+ def configure_pages_widget(view_name)
191
+ list_file = "#{@api.root}/views/#{view_name}/widgets/pages/list.txt"
192
+ @api.edit_file(list_file)
193
+
194
+ # Check for missing pages
195
+ pages_list = read_file(list_file, lines: true, chomp: true)
196
+ missing_pages = []
197
+
198
+ pages_list.each do |page|
199
+ page_file = "#{@api.root}/views/#{view_name}/pages/#{page}.html"
200
+ unless File.exist?(page_file)
201
+ missing_pages << page
202
+ end
203
+ end
204
+
205
+ if missing_pages.any?
206
+ puts
207
+ puts "Found #{missing_pages.length} missing pages: #{missing_pages.join(', ')}"
208
+ if yesno("Do you want to edit the missing pages?")
209
+ missing_pages.each do |page|
210
+ if yesno("Edit #{page}?")
211
+ @api.edit_file("#{@api.root}/views/#{view_name}/pages/#{page}.html")
212
+ else
213
+ # Create empty .lt3 file
214
+ write_file("#{@api.root}/views/#{view_name}/pages/#{page}.lt3", "")
215
+ end
216
+ end
217
+ else
218
+ # Create empty .lt3 files for all missing pages
219
+ missing_pages.each do |page|
220
+ write_file("#{@api.root}/views/#{view_name}/pages/#{page}.lt3", "")
221
+ end
222
+ end
223
+ else
224
+ puts "[WIZARD] No missing pages found"
225
+ end
226
+ end
227
+
228
+ def yesno(question)
229
+ print "#{question} (y/n): "
230
+ response = get_string&.downcase
231
+ response == "y" || response == "yes"
232
+ end
233
+
234
+ def get_string
235
+ if STDIN.tty? && !ENV['NOREADLINE']
236
+ result = Readline.readline
237
+ result
238
+ else
239
+ result = gets&.chomp&.strip
240
+ result
241
+ end
242
+ end
243
+
244
+ def mainloop
245
+ # Ensure we have a valid API with repository
246
+ if @api.nil? || @api.instance_variable_get(:@repo).nil?
247
+ puts "Error: No valid repository loaded. Exiting."
248
+ return
249
+ end
250
+ loop do
251
+ begin
252
+ current_view = @api.current_view
253
+ current_view_name = current_view&.name || "no-view"
254
+ prompt = "[#{current_view_name}] "
255
+
256
+ # Use regular gets for automated tests, Readline for interactive
257
+ if STDIN.tty? && !ENV['NOREADLINE']
258
+ input = Readline.readline(prompt, true)
259
+ else
260
+ print prompt
261
+ input = gets&.chomp&.strip
262
+ end
263
+ break if input.nil? || input.downcase == "quit" || input.downcase == "q"
264
+ next if input.empty?
265
+ execute_command(input)
266
+ rescue Interrupt
267
+ puts "\nUse 'quit' to exit"
268
+ rescue => e
269
+ puts e.message
270
+ puts e.backtrace.first if @testing
271
+ end
272
+ end
273
+
274
+ puts
275
+ puts " Goodbye!"
276
+ puts
277
+ end
278
+
279
+ private def setup_readline
280
+ # Only set up Readline if we're not in automated testing mode
281
+ return if ENV['NOREADLINE']
282
+
283
+ # Set up tab completion
284
+ Readline.completion_proc = proc do |input|
285
+ completions = []
286
+
287
+ # Split input to get command and arguments
288
+ parts = input.split(/\s+/)
289
+ command = parts[0]&.downcase
290
+ args = parts[1..-1] || []
291
+
292
+ if args.empty?
293
+ # Complete command names
294
+ commands = %w[view change list new version help quit cv lsv v h q upload copy delete asset configure]
295
+ completions = commands.select { |cmd| cmd.start_with?(command || "") }
296
+ elsif command == "change" || command == "cv"
297
+ # Complete view names
298
+ if @api
299
+ view_names = @api.views.map(&:name)
300
+ completions = view_names.select { |name| name.start_with?(args.last || "") }
301
+ end
302
+ elsif command == "list" && args.length == 1 && args[0] == "views"
303
+ # Complete "list views" command
304
+ completions = []
305
+ elsif command == "list" && args.length == 1 && args[0] == "assets"
306
+ # Complete asset targets
307
+ completions = %w[global library view gem]
308
+ elsif command == "new" && args.length == 1 && args[0] == "view"
309
+ # Suggest common view names for new view
310
+ suggestions = %w[blog personal work tech travel]
311
+ completions = suggestions
312
+ elsif command == "upload" && args.length == 1 && args[0] == "asset"
313
+ # Complete asset targets
314
+ completions = %w[global library view]
315
+ elsif command == "copy" && args.length == 1 && args[0] == "asset"
316
+ # Complete asset targets
317
+ completions = %w[global library view gem]
318
+ elsif command == "delete" && args.length == 1 && args[0] == "asset"
319
+ # Complete asset targets
320
+ completions = %w[global library view]
321
+ elsif command == "asset" && args.length == 1 && args[0] == "info"
322
+ # Complete asset targets
323
+ completions = %w[global library view gem]
324
+ elsif command == "configure" && args.length == 1 && args[0] == "deployment"
325
+ # Complete view names for deployment config
326
+ if @api
327
+ view_names = @api.views.map(&:name)
328
+ completions = view_names
329
+ end
330
+ end
331
+
332
+ completions
333
+ end
334
+ end
335
+
336
+ def create_test_repo
337
+ puts "Creating test repository..."
338
+ @testing = true
339
+ @api = Scriptorium::API.new(testmode: true)
340
+ @api.create_repo("scriptorium-TEST")
341
+ puts "Test repository created successfully!"
342
+ end
343
+
344
+ def which(command)
345
+ # Mock which in test mode to avoid hanging
346
+ if @testing
347
+ case command
348
+ when 'nano', 'vim', 'vi', 'ed'
349
+ "/usr/bin/#{command}"
350
+ else
351
+ nil
352
+ end
353
+ else
354
+ # Use File.which if available (Ruby 3.2+)
355
+ if File.respond_to?(:which)
356
+ File.which(command)
357
+ else
358
+ # Fall back to system call
359
+ result = `which #{command} 2>/dev/null`.chomp
360
+ result.empty? ? nil : result
361
+ end
362
+ end
363
+ end
364
+
365
+ def get_started
366
+ puts
367
+ puts " No editor configured. Let's set one up."
368
+ File.open('/dev/tty', 'w') { |f| f.puts " DEBUG: About to call pick_editor" }
369
+ pick_editor
370
+ File.open('/dev/tty', 'w') { |f| f.puts " DEBUG: pick_editor completed" }
371
+
372
+ puts
373
+ puts " Setup complete!"
374
+ puts " You can now use 'new post <title>' to create posts with your editor."
375
+ puts
376
+ end
377
+
378
+ def pick_editor
379
+ puts
380
+ puts " Available editors:"
381
+
382
+ # Check for common editors (prioritized for single file editing)
383
+ editors = []
384
+ File.open('/dev/tty', 'w') { |f| f.puts " DEBUG: Checking for editors" }
385
+ %w[nano vim emacs vi micro].each do |editor|
386
+ if which(editor)
387
+ editors << editor
388
+ File.open('/dev/tty', 'w') { |f| f.puts " DEBUG: Found editor: #{editor}" }
389
+ end
390
+ end
391
+
392
+ # The original Unix line editor - for the brave souls who want ultimate speed
393
+ if which("ed")
394
+ editors << "ed"
395
+ File.open('/dev/tty', 'w') { |f| f.puts " DEBUG: Found editor: ed" }
396
+ end
397
+
398
+ File.open('/dev/tty', 'w') { |f| f.puts " DEBUG: Total editors found: #{editors.length}" }
399
+
400
+ if editors.empty?
401
+ puts " No common editors found. Please install nano, vim, emacs, vi, micro, or ed."
402
+ puts " You can manually set your editor later by editing config/editor.txt"
403
+ puts
404
+ return
405
+ end
406
+
407
+ # Show available editors
408
+ editors.each_with_index do |editor, index|
409
+ puts " #{index + 1}. #{editor}"
410
+ end
411
+
412
+ # Let user pick
413
+ print " Choose editor (1-#{editors.length}): "
414
+ File.open('/dev/tty', 'w') { |f| f.puts " DEBUG: About to call get_string for editor choice" }
415
+ choice = get_string
416
+ File.open('/dev/tty', 'w') { |f| f.puts " DEBUG: get_string returned: '#{choice}'" }
417
+
418
+ if choice && choice.match?(/^\d+$/) && choice.to_i.between?(1, editors.length)
419
+ selected_editor = editors[choice.to_i - 1]
420
+
421
+ # Save the choice
422
+ make_dir(@api.root/"config")
423
+ write_file(@api.root/"config/editor.txt", selected_editor)
424
+
425
+ puts
426
+ puts " Selected editor: #{selected_editor}"
427
+ puts " Editor preference saved to config/editor.txt"
428
+ else
429
+ puts
430
+ puts " Invalid choice. Editor not changed."
431
+ end
432
+ end
433
+
434
+ private def execute_command(input)
435
+ calling = parse_cmd(input.strip)
436
+ name, *args = calling
437
+ send(name, *args)
438
+ rescue NoMethodError => e
439
+ puts
440
+ puts e.message
441
+ puts " ...Unknown command: #{name}. Type 'help' for available commands."
442
+ puts
443
+ end
444
+
445
+ private def two_words?(parts)
446
+ cmds = ["list views", "list posts", "list drafts", "list assets",
447
+ "list widgets", "list themes", "list backups", "change view", "new view",
448
+ "new post", "upload asset", "copy asset", "delete asset",
449
+ "delete theme", "delete backup", "asset info", "configure deployment",
450
+ "add widget", "config widget", "config social",
451
+ "config reddit", "clone theme"]
452
+ two_word_cmd = parts[0..1].join(" ")
453
+ flag = cmds.include?(two_word_cmd)
454
+ return flag ? two_word_cmd : nil
455
+ end
456
+
457
+ private def one_word?(parts)
458
+ cmds = %w[help h view cv lsv lsp lsd version v deploy preview browse
459
+ generate quit q backup restore]
460
+ one_word_cmd = parts[0]
461
+ flag = cmds.include?(one_word_cmd)
462
+ return flag ? one_word_cmd : nil
463
+ end
464
+
465
+ private def parse_cmd(cmdstr)
466
+ parts = cmdstr.downcase.split
467
+ case
468
+ when cmd = two_words?(parts)
469
+ args = parts[2..-1]
470
+ when cmd = one_word?(parts)
471
+ args = parts[1..-1]
472
+ else
473
+ puts " Unknown command: #{cmdstr}"
474
+ return [:unknown_command, cmdstr]
475
+ end
476
+ transform(cmd, args)
477
+ end
478
+
479
+ private def transform(cmd, args)
480
+ command_map = {
481
+ # Single word commands
482
+ "help" => [:show_help],
483
+ "h" => [:show_help],
484
+ "view" => [:show_current_view],
485
+ "cv" => [:change_view, args.join(" ")],
486
+ "lsv" => [:list_views],
487
+ "lsp" => [:list_posts],
488
+ "lsd" => [:list_drafts],
489
+ "version" => [:show_version],
490
+ "v" => [:show_version],
491
+ "deploy" => [:deploy_current_view],
492
+ "preview" => [:preview_current_view],
493
+ "browse" => [:browse_deployed_view],
494
+ "generate" => [:generate_current_view],
495
+ "quit" => [:exit, 0],
496
+ "q" => [:exit, 0],
497
+ "backup" => [:create_backup, args],
498
+ "restore" => [:restore_backup, args],
499
+
500
+ # Two word commands
501
+ "list views" => [:list_views],
502
+ "list posts" => [:list_posts],
503
+ "list drafts" => [:list_drafts],
504
+ "list assets" => [:list_assets, args],
505
+ "list widgets" => [:list_widgets, args],
506
+ "list themes" => [:list_themes, args],
507
+ "list backups" => [:list_backups],
508
+ "change view" => [:create_view, args],
509
+ "new view" => [:create_view, args],
510
+ "new post" => [:create_post, args],
511
+ "upload asset" => [:upload_asset, args],
512
+ "copy asset" => [:copy_asset, args],
513
+ "delete asset" => [:delete_asset, args],
514
+ "delete theme" => [:delete_theme, args],
515
+ "delete backup" => [:delete_backup, args],
516
+ "asset info" => [:asset_info, args],
517
+ "configure deployment" => [:configure_deployment, args],
518
+ "add widget" => [:add_widget, args],
519
+ "config widget" => [:config_widget, args],
520
+ "config social" => [:config_social],
521
+ "config reddit" => [:config_reddit],
522
+ "clone theme" => [:clone_theme, args]
523
+ }
524
+
525
+ command_map[cmd] || [:unknown_command, cmd]
526
+ end
527
+
528
+
529
+ private def unknown_command(cmd)
530
+ puts
531
+ puts " Unknown command: #{cmd}. Type 'help' for available commands."
532
+ puts
533
+ end
534
+
535
+ private def show_help
536
+ puts
537
+ puts <<~HELP
538
+ Scriptorium CLI - Blog Management Tool
539
+
540
+ Usage: scriptorium [--test]
541
+
542
+ Flags:
543
+ --test - Use test repository (scriptorium-TEST)
544
+ - Default: production repository (~/.scriptorium)
545
+
546
+ Commands:
547
+ view - Show current view
548
+ change view [<name>] - Switch to a view
549
+ cv [<name>]
550
+ list views - List all views
551
+ lsv
552
+ new view [<name> <title>] - Create a new view
553
+
554
+ list posts - List posts in current view
555
+ lsp
556
+ list drafts - List all drafts
557
+ lsd
558
+ new post [<title>] - Create draft, edit, and convert to post
559
+
560
+ list assets [target] - List assets (global, library, view, gem)
561
+ upload asset [file] [target] - Upload file to asset location
562
+ copy asset [file] [from] [to] - Copy asset between locations
563
+ delete asset [file] [target] - Delete asset from location
564
+ asset info [file] [target] - Show asset information
565
+ configure deployment [view] - Edit deployment configuration
566
+ deploy - Deploy current view to server
567
+ preview - Preview current view locally
568
+ browse - Browse deployed view on server
569
+
570
+ list widgets - List available and configured widgets
571
+ add widget <name> - Add widget to current view
572
+ config widget <name> - Configure widget data
573
+
574
+ config social - Configure social media sharing
575
+ config reddit - Configure Reddit sharing buttons
576
+ generate - Regenerate current view
577
+
578
+ list themes - List available themes
579
+ clone theme <source> <name> - Clone a theme
580
+ delete theme <path> - Delete a user theme
581
+
582
+ list backups - List available backups
583
+ backup [type] [desc] - Create backup (full/incr)
584
+ restore [timestamp] [strategy] - Restore from backup
585
+ delete backup [timestamp] - Delete a backup
586
+
587
+ version, v - Show version
588
+ help, h - Show this help
589
+ quit, q, ^D - Exit
590
+ HELP
591
+ puts
592
+ end
593
+
594
+ private def show_current_view
595
+ current_view = @api.current_view
596
+ current_view_name = current_view&.name || "none"
597
+ puts
598
+ puts " Current view: #{current_view_name}"
599
+ puts
600
+ end
601
+
602
+ private def change_view(args)
603
+ # Handle "change view <name>" format
604
+ if args == "view" || args.start_with?("view ")
605
+ # Remove "view " prefix if present, otherwise args is just "view"
606
+ view_name = args == "view" ? "" : args[5..-1].strip
607
+ else
608
+ view_name = args.strip
609
+ end
610
+
611
+ if view_name.empty?
612
+ # Interactive mode - prompt for view name
613
+ puts
614
+ puts " Available views:"
615
+ views = @api.views
616
+ if views.empty?
617
+ puts " No views found"
618
+ puts
619
+ return
620
+ else
621
+ current_view = @api.current_view
622
+ current_view_name = current_view&.name
623
+
624
+ views.each do |view|
625
+ current = view.name == current_view_name ? "*" : " "
626
+ puts " #{current} #{view.name} - #{view.title}"
627
+ end
628
+ puts
629
+ end
630
+
631
+ print " Enter view name: "
632
+ view_name = gets&.chomp&.strip
633
+ return if view_name.nil? || view_name.empty?
634
+ end
635
+
636
+ begin
637
+ view = @api.lookup_view(view_name)
638
+ @api.view(view_name)
639
+ puts
640
+ puts " Switched to view '#{view_name}'"
641
+ puts
642
+ rescue Exception => e
643
+ puts
644
+ puts " View '#{view_name}' not found"
645
+ puts
646
+ end
647
+ end
648
+
649
+ private def create_view(args = nil)
650
+ # Handle array arguments from parse_cmd
651
+ if args.nil? || args.empty?
652
+ # No arguments provided - interactive mode
653
+ print " Enter view name: "
654
+ name = get_string
655
+ return if name.nil? || name.empty?
656
+
657
+ print " Enter view title: "
658
+ title = get_string
659
+ return if title.nil? || title.empty?
660
+
661
+ print " Enter subtitle (optional): "
662
+ subtitle = get_string
663
+ subtitle = "" if subtitle.nil? || subtitle.empty?
664
+ elsif args.length == 1
665
+ # One argument provided - use as name, prompt for title and subtitle
666
+ name = args[0]
667
+ print " Enter view title: "
668
+ title = get_string
669
+ return if title.nil? || title.empty?
670
+
671
+ print " Enter subtitle (optional): "
672
+ subtitle = get_string
673
+ subtitle = "" if subtitle.nil? || subtitle.empty?
674
+ elsif args.length >= 2
675
+ # Two or more arguments provided - use first two as name and title, prompt for subtitle
676
+ name = args[0]
677
+ title = args[1]
678
+ print " Enter subtitle (optional): "
679
+ subtitle = get_string
680
+ subtitle = "" if subtitle.nil? || subtitle.empty?
681
+ end
682
+
683
+ # Check if view already exists
684
+ existing_views = @api.views
685
+ if existing_views.any? { |view| view.name == name }
686
+ puts
687
+ puts " View '#{name}' already exists"
688
+ puts
689
+ return
690
+ end
691
+
692
+ # Create view with all parameters
693
+ begin
694
+ @api.create_view(name, title, subtitle, theme: "standard")
695
+ puts
696
+ puts " Created view '#{name}' with title '#{title}'"
697
+ puts " Switched to view '#{name}'"
698
+ puts
699
+ rescue Exception => e
700
+ puts
701
+ puts " #{e.message}"
702
+ puts
703
+ end
704
+ end
705
+
706
+ def show_version
707
+ puts
708
+ puts " Scriptorium #{Scriptorium::VERSION}"
709
+ puts
710
+ end
711
+
712
+ def list_views
713
+ puts
714
+ views = @api.views
715
+ if views.empty?
716
+ puts " No views found"
717
+ else
718
+ current_view = @api.current_view
719
+ current_view_name = current_view&.name
720
+
721
+ views.each do |view|
722
+ current = view.name == current_view_name ? "*" : " "
723
+ puts " #{current} #{view.name} #{view.title}"
724
+ end
725
+ end
726
+ puts
727
+ end
728
+
729
+ def which(command)
730
+ # Mock which in test mode to avoid hanging
731
+ if @testing
732
+ case command
733
+ when 'nano', 'vim', 'vi', 'ed'
734
+ "/usr/bin/#{command}"
735
+ else
736
+ nil
737
+ end
738
+ else
739
+ # Use File.which if available (Ruby 3.2+)
740
+ if File.respond_to?(:which)
741
+ File.which(command)
742
+ else
743
+ # Fall back to system call
744
+ result = `which #{command} 2>/dev/null`.chomp
745
+ result.empty? ? nil : result
746
+ end
747
+ end
748
+ end
749
+
750
+ def create_post(args)
751
+ # Handle array arguments from parse_cmd
752
+ if args.nil? || args.empty?
753
+ # No arguments provided - interactive mode
754
+ print " Enter post title: "
755
+ title = gets&.chomp&.strip
756
+ return if title.nil? || title.empty?
757
+ else
758
+ # Use first argument as title
759
+ title = args[0]
760
+ end
761
+
762
+ # Check if editor is configured
763
+ editor_file = @api.root/"config/editor.txt"
764
+ unless File.exist?(editor_file)
765
+ puts
766
+ puts " No editor configured. Please configure an editor in config/editor.txt"
767
+ puts
768
+ return
769
+ end
770
+
771
+ editor = read_file(editor_file).strip
772
+
773
+ # Create draft
774
+ begin
775
+ draft_path = @api.create_draft(
776
+ title: title,
777
+ body: "", # Empty body to start
778
+ views: @api.current_view&.name,
779
+ tags: nil,
780
+ blurb: nil
781
+ )
782
+
783
+ puts
784
+ puts " Created draft: #{File.basename(draft_path)}"
785
+ puts " Opening in #{editor}..."
786
+ puts
787
+
788
+ # Open in editor
789
+ system("#{editor} #{draft_path}")
790
+
791
+ puts
792
+ puts " Converting draft to post..."
793
+
794
+ # Convert draft to post (like Runeblog)
795
+ begin
796
+ post_num = @api.finish_draft(draft_path)
797
+ post = @api.post(post_num)
798
+ if post && post.title
799
+ puts " Post created: ##{post_num} - #{post.title}"
800
+ else
801
+ puts " Post created: ##{post_num}"
802
+ end
803
+ puts " Use 'deploy' to publish to server when ready."
804
+ rescue => e
805
+ puts " Error converting to post: #{e.message}"
806
+ end
807
+
808
+ puts
809
+
810
+ rescue => e
811
+ puts
812
+ puts " Error creating post: #{e.message}"
813
+ puts
814
+ end
815
+ end
816
+
817
+ def list_posts
818
+ current_view = @api.current_view
819
+ if current_view.nil?
820
+ puts
821
+ puts " No current view selected"
822
+ puts
823
+ return
824
+ end
825
+
826
+ posts = @api.posts(current_view)
827
+
828
+ puts
829
+ if posts.empty?
830
+ puts " No posts found in view '#{current_view.name}'"
831
+ else
832
+ puts " Posts in view '#{current_view.name}':"
833
+ posts.each do |post|
834
+ puts " #{post.title}"
835
+ end
836
+ end
837
+ puts
838
+ end
839
+
840
+ def list_drafts
841
+ drafts_dir = @api.root/:drafts
842
+ return unless Dir.exist?(drafts_dir)
843
+
844
+ draft_files = Dir.glob("#{drafts_dir}/*-draft.lt3")
845
+
846
+ puts
847
+ if draft_files.empty?
848
+ puts " No drafts found"
849
+ else
850
+ draft_files.each do |file|
851
+ filename = File.basename(file)
852
+ puts " #{filename}"
853
+ end
854
+ end
855
+ puts
856
+ end
857
+
858
+ private def log_tty(str)
859
+ log_entry = "DEBUG: #{s}"
860
+ File.open('debug.log', 'a') { |f| f.puts log_entry }
861
+ tty = File.open('/dev/tty', 'w')
862
+ tty.puts log_entry
863
+ tty.close
864
+ end
865
+
866
+ def deploy_current_view
867
+ current_view = @api.current_view
868
+ if current_view.nil?
869
+ puts
870
+ puts " No current view selected"
871
+ puts
872
+ return
873
+ end
874
+
875
+ # Check deployment readiness first
876
+ unless @api.can_deploy?(current_view.name)
877
+ puts " Deployment error: View '#{current_view.name}' is not ready for deployment. Check status and configuration."
878
+ puts
879
+ return
880
+ end
881
+
882
+ # Use the API's deploy method
883
+ result = @api.deploy(current_view.name)
884
+ if result
885
+ puts " Deployment completed!"
886
+ else
887
+ puts " Deployment failed!"
888
+ end
889
+ puts
890
+ end
891
+
892
+ private def extract_domain_from_deploy_config(config)
893
+ # user@example.com:/path/ -> example.com
894
+ if config =~ /@([^:]+):/
895
+ $1
896
+ end
897
+ end
898
+
899
+ private def verify_deployment(domain)
900
+ url = "https://#{domain}/last-deployed.txt"
901
+ puts " Verifying deployment..."
902
+
903
+ require 'net/http'
904
+ begin
905
+ response = Net::HTTP.get_response(URI(url))
906
+ if response.code == "200"
907
+ puts " ✅ Deployment verified!"
908
+ else
909
+ puts " ⚠️ Deployment verification failed (HTTP #{response.code})"
910
+ end
911
+ rescue => e
912
+ puts " ⚠️ Deployment verification failed: #{e.message}"
913
+ end
914
+ end
915
+
916
+ private def preview_current_view
917
+ current_view = @api.current_view
918
+ if current_view.nil?
919
+ puts
920
+ puts " No current view selected"
921
+ puts
922
+ return
923
+ end
924
+
925
+ # Check if output directory exists
926
+ output_dir = current_view.dir/:output
927
+ unless Dir.exist?(output_dir)
928
+ puts
929
+ puts " Output directory does not exist: #{output_dir}"
930
+ puts " Generate content first with 'new post' or similar."
931
+ puts
932
+ return
933
+ end
934
+
935
+ # Find the main index file
936
+ index_file = output_dir/"index.html"
937
+ unless File.exist?(index_file)
938
+ puts
939
+ puts " No index.html found in output directory"
940
+ puts " Generate content first with 'new post' or similar."
941
+ puts
942
+ return
943
+ end
944
+
945
+ # Load OS-specific helper and open the file
946
+ load_os_helpers
947
+ puts
948
+ puts " Opening preview of view '#{current_view.name}'..."
949
+ open_file(index_file)
950
+ puts
951
+ end
952
+
953
+ private def browse_deployed_view
954
+ current_view = @api.current_view
955
+ if current_view.nil?
956
+ puts
957
+ puts " No current view selected"
958
+ puts
959
+ return
960
+ end
961
+
962
+ # Check if deploy config exists
963
+ deploy_config_file = current_view.dir/:config/"deploy.txt"
964
+ unless File.exist?(deploy_config_file)
965
+ puts
966
+ puts " No deployment configuration found."
967
+ puts " Create #{deploy_config_file} with format:"
968
+ puts " user@server:path"
969
+ puts
970
+ return
971
+ end
972
+
973
+ # Read deployment configuration and extract domain
974
+ deploy_config = read_file(deploy_config_file).strip
975
+ if deploy_config.empty?
976
+ puts
977
+ puts " Deployment configuration is empty."
978
+ puts
979
+ return
980
+ end
981
+
982
+ # Extract domain for browsing
983
+ domain = extract_domain_from_deploy_config(deploy_config)
984
+ unless domain
985
+ puts
986
+ puts " Could not extract domain from deployment configuration."
987
+ puts
988
+ return
989
+ end
990
+
991
+ # Load OS-specific helper and open the URL
992
+ load_os_helpers
993
+ url = "https://#{domain}/"
994
+ puts
995
+ puts " Opening deployed view at: #{url}"
996
+ open_file(url)
997
+ puts
998
+ end
999
+
1000
+ private def load_os_helpers
1001
+ # Load the OS-specific helper functions
1002
+ os_helpers_file = @api.root/:config/"os_helpers.rb"
1003
+ if File.exist?(os_helpers_file)
1004
+ load os_helpers_file
1005
+ else
1006
+ puts " Warning: OS helpers not found. Preview/browse may not work."
1007
+ end
1008
+ end
1009
+
1010
+ def list_widgets
1011
+ current_view = @api.current_view
1012
+ if current_view.nil?
1013
+ puts
1014
+ puts " No current view selected"
1015
+ puts
1016
+ return
1017
+ end
1018
+
1019
+ # Get available widgets
1020
+ available_widgets = @api.widgets_available
1021
+ puts
1022
+ puts " Available widgets: #{available_widgets.join(', ')}"
1023
+
1024
+ # Check which widgets are configured
1025
+ configured_widgets = []
1026
+ available_widgets.each do |widget|
1027
+ widget_dir = current_view.dir/:widgets/widget
1028
+ if Dir.exist?(widget_dir)
1029
+ configured_widgets << widget
1030
+ end
1031
+ end
1032
+
1033
+ puts " Configured widgets: #{configured_widgets.empty? ? 'none' : configured_widgets.join(', ')}"
1034
+ puts
1035
+ end
1036
+
1037
+ private def add_widget(args)
1038
+ current_view = @api.current_view
1039
+ if current_view.nil?
1040
+ puts
1041
+ puts " No current view selected"
1042
+ puts
1043
+ return
1044
+ end
1045
+
1046
+ # Parse widget name from args
1047
+ widget_name = args.sub(/^widget\s+/, '').strip
1048
+ if widget_name.empty?
1049
+ puts
1050
+ puts " Usage: add widget <name>"
1051
+ puts " Example: add widget links"
1052
+ puts
1053
+ return
1054
+ end
1055
+
1056
+ # Check if widget is available
1057
+ available_widgets = @api.widgets_available
1058
+ unless available_widgets.include?(widget_name)
1059
+ puts
1060
+ puts " Widget '#{widget_name}' is not available."
1061
+ puts " Available widgets: #{available_widgets.join(', ')}"
1062
+ puts
1063
+ return
1064
+ end
1065
+
1066
+ # Check if widget is already configured
1067
+ widget_dir = current_view.dir/:widgets/widget_name
1068
+ if Dir.exist?(widget_dir)
1069
+ puts
1070
+ puts " Widget '#{widget_name}' is already configured."
1071
+ puts
1072
+ return
1073
+ end
1074
+
1075
+ # Determine container (left/right)
1076
+ container = determine_widget_container(current_view)
1077
+ unless container
1078
+ puts
1079
+ puts " Error: No left or right container found in layout."
1080
+ puts " Add a left or right container to your layout first."
1081
+ puts
1082
+ return
1083
+ end
1084
+
1085
+ # Create widget directory and list.txt
1086
+ make_dir(widget_dir)
1087
+ list_file = widget_dir/"list.txt"
1088
+ write_file(list_file, "# Add #{widget_name} items here\n")
1089
+
1090
+ puts
1091
+ puts " Added widget '#{widget_name}' to #{container} container."
1092
+ puts " Use 'config widget #{widget_name}' to configure it."
1093
+ puts
1094
+ end
1095
+
1096
+ private def config_widget(args)
1097
+ current_view = @api.current_view
1098
+ if current_view.nil?
1099
+ puts
1100
+ puts " No current view selected"
1101
+ puts
1102
+ return
1103
+ end
1104
+
1105
+ # Parse widget name from args
1106
+ widget_name = args.sub(/^widget\s+/, '').strip
1107
+ if widget_name.empty?
1108
+ puts
1109
+ puts " Usage: config widget <name>"
1110
+ puts " Example: config widget links"
1111
+ puts
1112
+ return
1113
+ end
1114
+
1115
+ # Check if widget is configured
1116
+ widget_dir = current_view.dir/:widgets/widget_name
1117
+ unless Dir.exist?(widget_dir)
1118
+ puts
1119
+ puts " Widget '#{widget_name}' is not configured."
1120
+ puts " Use 'add widget #{widget_name}' to add it first."
1121
+ puts
1122
+ return
1123
+ end
1124
+
1125
+ list_file = widget_dir/"list.txt"
1126
+ unless File.exist?(list_file)
1127
+ puts
1128
+ puts " Error: Widget list file not found: #{list_file}"
1129
+ puts
1130
+ return
1131
+ end
1132
+
1133
+ # Show widget-specific instructions
1134
+ show_widget_instructions(widget_name)
1135
+
1136
+ puts " Press Enter to edit the widget data file..."
1137
+ gets
1138
+
1139
+ @api.edit_file(list_file)
1140
+
1141
+ # Regenerate the widget after editing
1142
+ puts " Regenerating widget..."
1143
+ begin
1144
+ @api.generate_widget(widget_name)
1145
+ puts " ✅ Widget regenerated successfully!"
1146
+ rescue => e
1147
+ puts " ⚠️ Widget regeneration failed: #{e.message}"
1148
+ end
1149
+ puts
1150
+ end
1151
+
1152
+ private def config_social
1153
+ current_view = @api.current_view
1154
+ if current_view.nil?
1155
+ puts
1156
+ puts " No current view selected"
1157
+ puts
1158
+ return
1159
+ end
1160
+
1161
+ social_config_file = current_view.dir/:config/"social.txt"
1162
+ unless File.exist?(social_config_file)
1163
+ puts
1164
+ puts " Social configuration file not found: #{social_config_file}"
1165
+ puts
1166
+ return
1167
+ end
1168
+
1169
+ puts
1170
+ puts " Social Media Sharing Configuration"
1171
+ puts " ================================="
1172
+ puts
1173
+ puts " This feature adds social media meta tags to your posts for better sharing."
1174
+ puts " When enabled, posts will have proper Open Graph and Twitter Card meta tags."
1175
+ puts
1176
+ puts " Configuration:"
1177
+ puts " - List one platform per line to enable (facebook, twitter, linkedin, reddit)"
1178
+ puts " - If no platforms listed, social meta tags are disabled"
1179
+ puts " - For Reddit buttons, also configure reddit.txt file"
1180
+ puts
1181
+ puts " No Facebook App ID or Twitter username required for basic meta tags."
1182
+ puts " These are only needed if you want to add social sharing buttons later."
1183
+ puts
1184
+ puts " Press Enter to edit the configuration file..."
1185
+ gets
1186
+
1187
+ @api.edit_file(social_config_file)
1188
+
1189
+ puts
1190
+ puts " Social configuration updated."
1191
+ puts " Regenerate your view to apply changes:"
1192
+ puts " generate"
1193
+ puts
1194
+ end
1195
+
1196
+ private def config_reddit
1197
+ current_view = @api.current_view
1198
+ if current_view.nil?
1199
+ puts
1200
+ puts " No current view selected"
1201
+ puts
1202
+ return
1203
+ end
1204
+
1205
+ reddit_config_file = current_view.dir/:config/"reddit.txt"
1206
+ unless File.exist?(reddit_config_file)
1207
+ puts
1208
+ puts " Reddit configuration file not found: #{reddit_config_file}"
1209
+ puts " Creating new Reddit configuration file..."
1210
+ puts
1211
+ # Create the file with default content
1212
+ write_file(reddit_config_file, @api.repo.predef.reddit_config)
1213
+ end
1214
+
1215
+ puts
1216
+ puts " Reddit Sharing Button Configuration"
1217
+ puts " =================================="
1218
+ puts
1219
+ puts " This feature adds Reddit share buttons to your posts."
1220
+ puts " When enabled, readers can easily share your posts to Reddit."
1221
+ puts
1222
+ puts " Configuration options:"
1223
+ puts " - button: true/false - Enable or disable Reddit share button"
1224
+ puts " - subreddit: <name> - Specify a subreddit for direct posting (optional)"
1225
+ puts " - hover_text: <text> - Custom hover text (optional)"
1226
+ puts
1227
+ puts " Examples:"
1228
+ puts " button true"
1229
+ puts " subreddit RubyElixirEtc"
1230
+ puts " hover_text \"Share on RubyElixirEtc\""
1231
+ puts
1232
+ puts " Note: Reddit must also be enabled in social.txt for buttons to appear."
1233
+ puts
1234
+ puts " Press Enter to edit the configuration file..."
1235
+ gets
1236
+
1237
+ @api.edit_file(reddit_config_file)
1238
+
1239
+ puts
1240
+ puts " Reddit configuration updated."
1241
+ puts " Regenerate your view to apply changes:"
1242
+ puts " generate"
1243
+ puts
1244
+ end
1245
+
1246
+ private def generate_current_view
1247
+ current_view = @api.current_view
1248
+ if current_view.nil?
1249
+ puts
1250
+ puts " No current view selected"
1251
+ puts
1252
+ return
1253
+ end
1254
+
1255
+ puts
1256
+ puts " Regenerating view '#{current_view.name}'..."
1257
+ begin
1258
+ @api.generate_view(current_view.name)
1259
+ puts " ✅ View regenerated successfully!"
1260
+ rescue => e
1261
+ puts " ⚠️ View regeneration failed: #{e.message}"
1262
+ end
1263
+ puts
1264
+ end
1265
+
1266
+ private def determine_widget_container(view)
1267
+ # Check which containers exist in the layout
1268
+ layout_file = view.dir/:config/"layout.txt"
1269
+ return nil unless File.exist?(layout_file)
1270
+
1271
+ layout_content = read_file(layout_file)
1272
+ has_left = layout_content.include?('left')
1273
+ has_right = layout_content.include?('right')
1274
+
1275
+ if has_left && has_right
1276
+ # Both exist, prompt user
1277
+ puts
1278
+ puts " Both left and right containers found."
1279
+ puts " Which container should the widget go in?"
1280
+ puts " (l) left (r) right"
1281
+ print " Choice: "
1282
+ choice = gets&.chomp&.downcase
1283
+
1284
+ case choice
1285
+ when 'l', 'left'
1286
+ 'left'
1287
+ when 'r', 'right'
1288
+ 'right'
1289
+ else
1290
+ puts " Invalid choice. Widget not added."
1291
+ nil
1292
+ end
1293
+ elsif has_left
1294
+ 'left'
1295
+ elsif has_right
1296
+ 'right'
1297
+ else
1298
+ nil
1299
+ end
1300
+ end
1301
+
1302
+ private def show_widget_instructions(widget_name)
1303
+ case widget_name
1304
+ when 'links'
1305
+ puts
1306
+ puts " Links Widget Configuration:"
1307
+ puts " Format: <url> <title>"
1308
+ puts " Example:"
1309
+ puts " https://example.com My Website"
1310
+ puts " https://github.com GitHub"
1311
+ puts
1312
+ when 'pages'
1313
+ puts
1314
+ puts " Pages Widget Configuration:"
1315
+ puts " Format: <filename> <title>"
1316
+ puts " Example:"
1317
+ puts " about.html About Us"
1318
+ puts " contact.html Contact"
1319
+ puts
1320
+ when 'featuredposts'
1321
+ puts
1322
+ puts " Featured Posts Widget Configuration:"
1323
+ puts " Format: <post_id> <optional_title>"
1324
+ puts " Example:"
1325
+ puts " 0001 My First Post"
1326
+ puts " 0002"
1327
+ puts
1328
+ else
1329
+ puts
1330
+ puts " Widget Configuration:"
1331
+ puts " Edit the list.txt file to configure widget data."
1332
+ puts
1333
+ end
1334
+ end
1335
+
1336
+ private def list_themes
1337
+ puts
1338
+ themes = @api.themes_available
1339
+
1340
+ if themes.empty?
1341
+ puts " No themes found"
1342
+ else
1343
+ # Group by type
1344
+ system_themes = themes.select { |t| t[:type] == 'system' }
1345
+ user_themes = themes.select { |t| t[:type] == 'user' }
1346
+ shared_themes = themes.select { |t| t[:type] == 'shared' }
1347
+
1348
+ puts " System Themes (Read-only):"
1349
+ if system_themes.empty?
1350
+ puts " none"
1351
+ else
1352
+ system_themes.each { |t| puts " #{t[:name]} (#{t[:path]})" }
1353
+ end
1354
+
1355
+ puts " User Themes (Editable):"
1356
+ if user_themes.empty?
1357
+ puts " none"
1358
+ else
1359
+ user_themes.each { |t| puts " #{t[:name]} (#{t[:path]})" }
1360
+ end
1361
+
1362
+ puts " Shared Themes (Community):"
1363
+ if shared_themes.empty?
1364
+ puts " none"
1365
+ else
1366
+ shared_themes.each { |t| puts " #{t[:name]} (#{t[:path]})" }
1367
+ end
1368
+ end
1369
+ puts
1370
+ end
1371
+
1372
+ private def clone_theme(args)
1373
+ parts = args.is_a?(String) ? args.split(/\s+/) : args
1374
+ if parts.length != 2
1375
+ puts
1376
+ puts " Usage: clone theme <source> <newname>"
1377
+ puts " Example: clone theme standard my-custom"
1378
+ puts " Note: Cloned themes become user themes"
1379
+ puts
1380
+ return
1381
+ end
1382
+
1383
+ source, newname = parts[0], parts[1]
1384
+
1385
+ begin
1386
+ # Use the new API method for cloning
1387
+ result = @api.clone_theme(source, newname)
1388
+
1389
+ if result
1390
+ puts
1391
+ puts " ✅ Theme cloned successfully: #{source} → #{result}"
1392
+ puts " Use 'config theme #{result}' to customize your theme"
1393
+ puts
1394
+ else
1395
+ puts
1396
+ puts " ❌ Failed to clone theme"
1397
+ puts
1398
+ end
1399
+ rescue => e
1400
+ puts
1401
+ puts " ❌ Failed to clone theme: #{e.message}"
1402
+ puts
1403
+ puts
1404
+ end
1405
+ end
1406
+
1407
+ private def delete_theme(args)
1408
+ parts = args.is_a?(String) ? args.split(/\s+/) : args
1409
+ if parts.length != 2
1410
+ puts
1411
+ puts " Usage: delete theme <theme_path>"
1412
+ puts " Example: delete theme user/my-custom"
1413
+ puts " Note: Can only delete user themes"
1414
+ puts
1415
+ return
1416
+ end
1417
+
1418
+ theme_path = parts[1]
1419
+
1420
+ begin
1421
+ # Use the new API method for deleting
1422
+ result = @api.delete_theme(theme_path)
1423
+
1424
+ if result
1425
+ puts
1426
+ puts " ✅ Theme deleted successfully: #{theme_path}"
1427
+ puts
1428
+ else
1429
+ puts
1430
+ puts " ❌ Failed to delete theme"
1431
+ puts
1432
+ end
1433
+ rescue => e
1434
+ puts
1435
+ puts " ❌ Failed to delete theme: #{e.message}"
1436
+ puts
1437
+ end
1438
+ end
1439
+
1440
+ # Asset Management Commands
1441
+
1442
+ def list_assets(args)
1443
+ target = args[0] || 'global'
1444
+
1445
+ unless %w[global library view gem].include?(target)
1446
+ puts
1447
+ puts " Usage: list assets [target]"
1448
+ puts " Targets: global, library, view, gem"
1449
+ puts " Example: list assets view"
1450
+ puts
1451
+ return
1452
+ end
1453
+
1454
+ begin
1455
+ assets = @api.list_assets(target: target, view: @api.current_view&.name)
1456
+ if assets.empty?
1457
+ puts
1458
+ puts " No assets found in #{target}"
1459
+ puts
1460
+ else
1461
+ puts
1462
+ puts " Assets in #{target}:"
1463
+ assets.each do |asset|
1464
+ puts " #{asset[:filename]} (#{asset[:size]} bytes, #{asset[:type]})"
1465
+ puts " Path: #{asset[:path]}"
1466
+ puts " Dimensions: #{asset[:dimensions] || 'N/A'}"
1467
+ end
1468
+ puts
1469
+ end
1470
+ rescue => e
1471
+ puts
1472
+ puts " ❌ Failed to list assets: #{e.message}"
1473
+ puts
1474
+ end
1475
+ end
1476
+
1477
+ private def upload_asset(args)
1478
+ parts = args.is_a?(String) ? args.split(/\s+/) : args
1479
+ if parts.length < 2
1480
+ puts
1481
+ puts " Usage: upload asset <filepath> [target]"
1482
+ puts " Targets: global, library, view"
1483
+ puts " Example: upload asset /path/to/image.jpg global"
1484
+ puts
1485
+ return
1486
+ end
1487
+
1488
+ filepath = parts[1]
1489
+ target = parts[2] || 'global'
1490
+
1491
+ unless %w[global library view].include?(target)
1492
+ puts
1493
+ puts " Invalid target: #{target}"
1494
+ puts " Valid targets: global, library, view"
1495
+ puts
1496
+ return
1497
+ end
1498
+
1499
+ unless File.exist?(filepath)
1500
+ puts
1501
+ puts " ❌ File not found: #{filepath}"
1502
+ puts
1503
+ return
1504
+ end
1505
+
1506
+ begin
1507
+ result = @api.upload_asset(filepath, target: target, view: @api.current_view&.name)
1508
+ puts
1509
+ puts " ✅ Asset uploaded successfully to #{target}"
1510
+ puts " Filename: #{result[:filename]}"
1511
+ puts " Size: #{result[:size]} bytes"
1512
+ puts
1513
+ rescue => e
1514
+ puts
1515
+ puts " ❌ Failed to upload asset: #{e.message}"
1516
+ puts
1517
+ end
1518
+ end
1519
+
1520
+ private def copy_asset(args)
1521
+ parts = args.is_a?(String) ? args.split(/\s+/) : args
1522
+ if parts.length < 3
1523
+ puts
1524
+ puts " Usage: copy asset <filename> <from> <to>"
1525
+ puts " From/To: global, library, view, gem"
1526
+ puts " Example: copy asset logo.png gem global"
1527
+ puts
1528
+ return
1529
+ end
1530
+
1531
+ filename = parts[1]
1532
+ from = parts[2]
1533
+ to = parts[3]
1534
+
1535
+ unless %w[global library view gem].include?(from) && %w[global library view].include?(to)
1536
+ puts
1537
+ puts " Invalid source or target"
1538
+ puts " Valid sources: global, library, view, gem"
1539
+ puts " Valid targets: global, library, view"
1540
+ puts
1541
+ return
1542
+ end
1543
+
1544
+ begin
1545
+ result = @api.copy_asset(filename, from: from, to: to, view: @api.current_view&.name)
1546
+ puts
1547
+ puts " ✅ Asset copied successfully"
1548
+ puts " From: #{from} (#{result[:from_path]})"
1549
+ puts " To: #{to} (#{result[:to_path]})"
1550
+ puts
1551
+ rescue => e
1552
+ puts
1553
+ puts " ❌ Failed to copy asset: #{e.message}"
1554
+ puts
1555
+ end
1556
+ end
1557
+
1558
+ private def delete_asset(args)
1559
+ parts = args.is_a?(String) ? args.split(/\s+/) : args
1560
+ if parts.length < 2
1561
+ puts
1562
+ puts " Usage: delete asset <filename> [target]"
1563
+ puts " Targets: global, library, view"
1564
+ puts
1565
+ return
1566
+ end
1567
+
1568
+ filename = parts[1]
1569
+ target = parts[2] || 'global'
1570
+
1571
+ unless %w[global library view].include?(target)
1572
+ puts
1573
+ puts " Invalid target: #{target}"
1574
+ puts " Valid targets: global, library, view"
1575
+ puts
1576
+ return
1577
+ end
1578
+
1579
+ begin
1580
+ @api.delete_asset(filename, target: target, view: @api.current_view&.name)
1581
+ puts
1582
+ puts " ✅ Asset deleted successfully from #{target}"
1583
+ puts
1584
+ rescue => e
1585
+ puts
1586
+ puts " ❌ Failed to delete asset: #{e.message}"
1587
+ puts
1588
+ end
1589
+ end
1590
+
1591
+ private def asset_info(args)
1592
+ parts = args.is_a?(String) ? args.split(/\s+/) : args
1593
+ if parts.length < 1
1594
+ puts
1595
+ puts " Usage: asset info <filename> [target]"
1596
+ puts " Targets: global, library, view, gem"
1597
+ puts " Example: asset info logo.png global"
1598
+ puts
1599
+ return
1600
+ end
1601
+
1602
+ filename = parts[0]
1603
+ target = parts[1] || 'global'
1604
+
1605
+ unless %w[global library view gem].include?(target)
1606
+ puts
1607
+ puts " Invalid target: #{target}"
1608
+ puts " Valid targets: global, library, view, gem"
1609
+ puts
1610
+ return
1611
+ end
1612
+
1613
+ begin
1614
+ info = @api.get_asset_info(filename, target: target, view: @api.current_view&.name)
1615
+ if info.nil?
1616
+ puts
1617
+ puts " ❌ Asset not found: #{filename}"
1618
+ puts
1619
+ return
1620
+ end
1621
+
1622
+ puts
1623
+ puts " Asset Information:"
1624
+ puts " Filename: #{info[:filename]}"
1625
+ puts " Size: #{info[:size]} bytes"
1626
+ puts " Type: #{info[:type]}"
1627
+ puts " Path: #{info[:path]}"
1628
+ puts " Dimensions: #{info[:dimensions] || 'N/A'}"
1629
+ puts
1630
+ rescue => e
1631
+ puts
1632
+ puts " ❌ Failed to get asset info: #{e.message}"
1633
+ puts
1634
+ end
1635
+ end
1636
+
1637
+ private def configure_deployment(args)
1638
+ parts = args.is_a?(String) ? args.split(/\s+/) : args
1639
+ view_name = parts[1] || @api.current_view&.name
1640
+
1641
+ unless view_name
1642
+ puts
1643
+ puts " ❌ No view specified and no current view"
1644
+ puts
1645
+ return
1646
+ end
1647
+
1648
+ begin
1649
+ deploy_file = @api.root/"views"/view_name/"config"/"deploy.txt"
1650
+ puts " DEBUG: About to edit file: #{deploy_file}"
1651
+ puts " DEBUG: File exists: #{File.exist?(deploy_file)}"
1652
+
1653
+ # Use TUI's editor configuration instead of API's edit_file
1654
+ editor_file = @api.root/"config/editor.txt"
1655
+ if File.exist?(editor_file)
1656
+ editor = read_file(editor_file).strip
1657
+ puts " DEBUG: Using TUI editor: #{editor}"
1658
+ system("#{editor} #{deploy_file}")
1659
+ else
1660
+ puts " DEBUG: No TUI editor config, using API edit_file"
1661
+ @api.edit_file(deploy_file)
1662
+ end
1663
+
1664
+ puts " DEBUG: edit_file returned"
1665
+ puts
1666
+ puts " ✅ Deployment configuration edited for view: #{view_name}"
1667
+ puts
1668
+ rescue => e
1669
+ puts " DEBUG: Exception in configure_deployment: #{e.class} - #{e.message}"
1670
+ puts
1671
+ puts " ❌ Failed to edit deployment configuration: #{e.message}"
1672
+ puts
1673
+ end
1674
+ end
1675
+
1676
+ end
1677
+
1678
+ def list_backups
1679
+ puts
1680
+ begin
1681
+ backups = @api.list_backups
1682
+ if backups.empty?
1683
+ puts " No backups available"
1684
+ else
1685
+ puts " Available backups:"
1686
+ backups.each do |backup|
1687
+ timestamp = backup[:timestamp]
1688
+ type = backup[:type]
1689
+ description = backup[:description]
1690
+ desc_text = description ? " - #{description}" : ""
1691
+ puts " #{timestamp} (#{type})#{desc_text}"
1692
+ end
1693
+ end
1694
+ rescue => e
1695
+ puts " ❌ Failed to list backups: #{e.message}"
1696
+ end
1697
+ puts
1698
+ end
1699
+
1700
+ def create_backup(args)
1701
+ puts
1702
+ begin
1703
+ # Parse arguments
1704
+ parts = args.is_a?(String) ? args.split(/\s+/) : args
1705
+ type = parts[0]&.downcase
1706
+ description = parts[1..-1]&.join(" ")
1707
+
1708
+ # Prompt for missing type
1709
+ unless %w[full incr].include?(type)
1710
+ print " Backup type (full/incr): "
1711
+ type = gets.chomp.downcase
1712
+ unless %w[full incr].include?(type)
1713
+ puts " ❌ Invalid backup type. Must be 'full' or 'incr'"
1714
+ puts
1715
+ return
1716
+ end
1717
+ end
1718
+
1719
+ # Prompt for description if not provided
1720
+ if description.nil? || description.empty?
1721
+ print " Description (optional): "
1722
+ description = gets.chomp
1723
+ description = nil if description.empty?
1724
+ end
1725
+
1726
+ # Create backup
1727
+ backup_type = type.to_sym
1728
+ timestamp = @api.create_backup(type: backup_type, label: description)
1729
+
1730
+ puts " ✅ Backup created: #{timestamp}"
1731
+ if description
1732
+ puts " Description: #{description}"
1733
+ end
1734
+ rescue => e
1735
+ puts " ❌ Failed to create backup: #{e.message}"
1736
+ end
1737
+ puts
1738
+ end
1739
+
1740
+ def restore_backup(args)
1741
+ puts
1742
+ begin
1743
+ # Parse arguments
1744
+ parts = args.is_a?(String) ? args.split(/\s+/) : args
1745
+ timestamp = parts[0]
1746
+ strategy = parts[1]&.downcase
1747
+
1748
+ # Prompt for missing timestamp
1749
+ if timestamp.nil? || timestamp.empty?
1750
+ backups = @api.list_backups
1751
+ if backups.empty?
1752
+ puts " No backups available to restore"
1753
+ puts
1754
+ return
1755
+ end
1756
+
1757
+ puts " Available backups:"
1758
+ backups.each_with_index do |backup, index|
1759
+ desc_text = backup[:description] ? " - #{backup[:description]}" : ""
1760
+ puts " #{index + 1}. #{backup[:timestamp]} (#{backup[:type]})#{desc_text}"
1761
+ end
1762
+
1763
+ print " Select backup (number or timestamp): "
1764
+ selection = gets.chomp
1765
+
1766
+ # Check if it's a number
1767
+ if selection.match?(/^\d+$/)
1768
+ index = selection.to_i - 1
1769
+ if index >= 0 && index < backups.length
1770
+ timestamp = backups[index][:timestamp]
1771
+ else
1772
+ puts " ❌ Invalid selection"
1773
+ puts
1774
+ return
1775
+ end
1776
+ else
1777
+ timestamp = selection
1778
+ end
1779
+ end
1780
+
1781
+ # Prompt for strategy if not provided
1782
+ unless %w[safe merge destroy].include?(strategy)
1783
+ print " Strategy (Enter=safe, or type: merge, destroy): "
1784
+ strategy_input = gets.chomp.downcase
1785
+ strategy = strategy_input.empty? ? "safe" : strategy_input
1786
+
1787
+ unless %w[safe merge destroy].include?(strategy)
1788
+ puts " ❌ Invalid strategy. Must be 'safe', 'merge', or 'destroy'"
1789
+ puts
1790
+ return
1791
+ end
1792
+ end
1793
+
1794
+ # Confirm restore
1795
+ puts " About to restore backup: #{timestamp}"
1796
+ puts " Strategy: #{strategy}"
1797
+ unless yesno("Continue?")
1798
+ puts " Restore cancelled"
1799
+ puts
1800
+ return
1801
+ end
1802
+
1803
+ # Restore backup
1804
+ strategy_sym = strategy.to_sym
1805
+ @api.restore_backup(timestamp, strategy: strategy_sym)
1806
+
1807
+ puts " ✅ Backup restored successfully"
1808
+ rescue => e
1809
+ puts " ❌ Failed to restore backup: #{e.message}"
1810
+ end
1811
+ puts
1812
+ end
1813
+
1814
+ def delete_backup(args)
1815
+ puts
1816
+ begin
1817
+ # Parse arguments
1818
+ parts = args.is_a?(String) ? args.split(/\s+/) : args
1819
+ timestamp = parts[0]
1820
+
1821
+ # Prompt for missing timestamp
1822
+ if timestamp.nil? || timestamp.empty?
1823
+ backups = @api.list_backups
1824
+ if backups.empty?
1825
+ puts " No backups available to delete"
1826
+ puts
1827
+ return
1828
+ end
1829
+
1830
+ puts " Available backups:"
1831
+ backups.each_with_index do |backup, index|
1832
+ desc_text = backup[:description] ? " - #{backup[:description]}" : ""
1833
+ puts " #{index + 1}. #{backup[:timestamp]} (#{backup[:type]})#{desc_text}"
1834
+ end
1835
+
1836
+ print " Select backup to delete (number or timestamp): "
1837
+ selection = gets.chomp
1838
+
1839
+ # Check if it's a number
1840
+ if selection.match?(/^\d+$/)
1841
+ index = selection.to_i - 1
1842
+ if index >= 0 && index < backups.length
1843
+ timestamp = backups[index][:timestamp]
1844
+ else
1845
+ puts " ❌ Invalid selection"
1846
+ puts
1847
+ return
1848
+ end
1849
+ else
1850
+ timestamp = selection
1851
+ end
1852
+ end
1853
+
1854
+ # Confirm deletion
1855
+ puts " About to delete backup: #{timestamp}"
1856
+ unless yesno("Are you sure?")
1857
+ puts " Deletion cancelled"
1858
+ puts
1859
+ return
1860
+ end
1861
+
1862
+ # Delete backup
1863
+ @api.delete_backup(timestamp)
1864
+
1865
+ puts " ✅ Backup deleted successfully"
1866
+ rescue => e
1867
+ puts " ❌ Failed to delete backup: #{e.message}"
1868
+ end
1869
+ puts
1870
+ end
1871
+
1872
+ ###### Main ######
1873
+
1874
+ s = ScriptoriumTUI.new
1875
+
1876
+ # Auto-discovery: check for existing repo
1877
+ got_repo = s.discover_repo
1878
+
1879
+ unless got_repo
1880
+ if s.yesno("Create new repository?")
1881
+ s.create_new_repo
1882
+ ques = "Do you want assistance in creating your first view?"
1883
+ if s.yesno(ques)
1884
+ s.wizard_first_view
1885
+ end
1886
+ end
1887
+ end
1888
+
1889
+ # Main REPL loop
1890
+ s.mainloop