scriptorium 0.0.3 → 0.6.1

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 (292) 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/back-icon.png +0 -0
  7. data/assets/icons/facebook.svg +1 -0
  8. data/assets/icons/github.svg +1 -0
  9. data/assets/icons/instagram.svg +1 -0
  10. data/assets/icons/reddit.svg +1 -0
  11. data/assets/icons/ui/.DS_Store +0 -0
  12. data/assets/icons/ui/back.png +0 -0
  13. data/assets/icons/ui/copy.png +0 -0
  14. data/assets/icons/ui/down.png +0 -0
  15. data/assets/icons/ui/end.png +0 -0
  16. data/assets/icons/ui/exit.png +0 -0
  17. data/assets/icons/ui/foo +10 -0
  18. data/assets/icons/ui/home.png +0 -0
  19. data/assets/icons/ui/left.png +0 -0
  20. data/assets/icons/ui/next.png +0 -0
  21. data/assets/icons/ui/right.png +0 -0
  22. data/assets/icons/ui/start.png +0 -0
  23. data/assets/icons/ui/up.png +0 -0
  24. data/assets/icons/x.svg +1 -0
  25. data/assets/icons/youtube.svg +1 -0
  26. data/assets/samples/placeholder.svg +9 -0
  27. data/assets/themes/standard/favicon.svg +6 -0
  28. data/bin/scriptorium +1511 -0
  29. data/doc/README.txt +6 -0
  30. data/doc/anti-amnesia/20250727-054000-scriptorium-overview.md +95 -0
  31. data/doc/anti-amnesia/20250727-060000-api-design-tui-planning.md +34 -0
  32. data/doc/anti-amnesia/20250727-061000-runeblog-tui-analysis.md +50 -0
  33. data/doc/anti-amnesia/20250727-123000-anti-amnesia-conventions.md +31 -0
  34. data/doc/anti-amnesia/20250727-154000-livetext-plugin-file-stats.md +73 -0
  35. data/doc/anti-amnesia/20250727-172600-cursor-rbenv-ruby-version-mystery.md +64 -0
  36. data/doc/anti-amnesia/20250727-172600-unified-minitest-framework.md +70 -0
  37. data/doc/anti-amnesia/20250727-172900-ai-cognitive-assessment-capabilities.md +40 -0
  38. data/doc/anti-amnesia/20250727-173000-widget-testing-achievement.md +110 -0
  39. data/doc/anti-amnesia/20250727-180000-post-id-num-refactoring.md +73 -0
  40. data/doc/anti-amnesia/20250728-124243-aaa-syntax-clarification.md +46 -0
  41. data/doc/anti-amnesia/20250728-124421-conversation-summary-concise.md +124 -0
  42. data/doc/anti-amnesia/20250729-190000-scriptorium-tui-testing-complete.md +46 -0
  43. data/doc/anti-amnesia/20250729-200000-scriptorium-tui-testing-edit-file-workflow.md +97 -0
  44. data/doc/anti-amnesia/20250729-210000-reddit-autopost-integration-complete.md +158 -0
  45. data/doc/anti-amnesia/20250729-211500-dependency-management-system.md +211 -0
  46. data/doc/anti-amnesia/20250729-213000-python-virtual-environment-setup.md +141 -0
  47. data/doc/anti-amnesia/20250729-214500-theme-management-commands.md +211 -0
  48. data/doc/anti-amnesia/20250729-215000-version-update-to-0.6.0.md +134 -0
  49. data/doc/anti-amnesia/20250729-220000-user-guide-complete.md +41 -0
  50. data/doc/anti-amnesia/20250804-190500-cognitive-loop-bug.md +45 -0
  51. data/doc/anti-amnesia/20250804-190700-anti-amnesia-timestamping-fix.md +30 -0
  52. data/doc/anti-amnesia/20250804-213700-publishing-test-fix.md +49 -0
  53. data/doc/anti-amnesia/20250804-214400-additional-test-fixes.md +46 -0
  54. data/doc/anti-amnesia/20250804-220000-asset-function-logic-clarification.md +41 -0
  55. data/doc/anti-amnesia/20250806-202032-asset-function-logic-clarification.md +41 -0
  56. data/doc/anti-amnesia/20250807-213025.md +116 -0
  57. data/doc/anti-amnesia/20250813-082428-syntax-highlighting-and-navigation-improvements.md +256 -0
  58. data/doc/banner_svg_config.md +114 -0
  59. data/doc/contrib.lt3 +8 -0
  60. data/doc/dependencies.md +281 -0
  61. data/doc/hacker.lt3 +5 -0
  62. data/doc/reddit_credentials_template.json +8 -0
  63. data/doc/reddit_integration.md +207 -0
  64. data/doc/user.lt3 +38 -0
  65. data/doc/user_guide_section_1.md +137 -0
  66. data/doc/user_guide_section_10.md +515 -0
  67. data/doc/user_guide_section_11.md +708 -0
  68. data/doc/user_guide_section_2.md +233 -0
  69. data/doc/user_guide_section_3.md +5 -0
  70. data/doc/user_guide_section_4.md +221 -0
  71. data/doc/user_guide_section_5.md +243 -0
  72. data/doc/user_guide_section_6.md +147 -0
  73. data/doc/user_guide_section_7.md +311 -0
  74. data/doc/user_guide_section_8.md +224 -0
  75. data/doc/user_guide_section_9.md +375 -0
  76. data/doc/userdoc-toc.txt +88 -0
  77. data/lib/rouge/lexers/livetext.rb +74 -0
  78. data/lib/scriptorium/api.rb +640 -0
  79. data/lib/scriptorium/banner_svg.rb +742 -0
  80. data/lib/scriptorium/contract.rb +33 -0
  81. data/lib/scriptorium/exceptions.rb +170 -1
  82. data/lib/scriptorium/helpers.rb +475 -0
  83. data/lib/scriptorium/post.rb +195 -0
  84. data/lib/scriptorium/reddit.rb +83 -0
  85. data/lib/scriptorium/repo.rb +624 -0
  86. data/lib/scriptorium/standard_files.rb +515 -0
  87. data/lib/scriptorium/syntax_highlighter.rb +234 -0
  88. data/lib/scriptorium/theme.rb +179 -0
  89. data/lib/scriptorium/version.rb +2 -2
  90. data/lib/scriptorium/view.rb +976 -0
  91. data/lib/scriptorium/widgets/featured_posts.rb +149 -0
  92. data/lib/scriptorium/widgets/links.rb +112 -0
  93. data/lib/scriptorium/widgets/pages.rb +133 -0
  94. data/lib/scriptorium/widgets/widget.rb +133 -0
  95. data/lib/scriptorium.rb +21 -40
  96. data/lib/skeleton.rb +8 -2
  97. data/scriptorium.gemspec +15 -4
  98. data/test/README.md +69 -0
  99. data/test/all +43 -0
  100. data/test/api_demo.rb +99 -0
  101. data/test/assets/imagenotfound.jpg +0 -0
  102. data/test/assets/images/.DS_Store +0 -0
  103. data/test/assets/images/README.md +27 -0
  104. data/test/assets/images/odd_aspect.png +0 -0
  105. data/test/assets/images/perfect.png +0 -0
  106. data/test/assets/images/small.png +0 -0
  107. data/test/assets/images/tall.png +0 -0
  108. data/test/assets/images/very_tall.png +0 -0
  109. data/test/assets/images/very_wide.png +0 -0
  110. data/test/assets/images/wide.png +0 -0
  111. data/test/assets/testbanner.jpg +0 -0
  112. data/test/banner_svg/simple_helpers.rb +13 -0
  113. data/test/banner_svg/unit.rb +768 -0
  114. data/test/ed_test.rb +204 -0
  115. data/test/integration/cursor_banner_combinations.rb +193 -0
  116. data/test/integration/cursor_banner_features.rb +374 -0
  117. data/test/integration/integration_test.rb +326 -0
  118. data/test/livetext_plugin_test.rb +229 -0
  119. data/test/manual/asset_mgmt.rb +67 -0
  120. data/test/manual/banner-tests/config.txt +3 -0
  121. data/test/manual/banner-tests/index.html +45 -0
  122. data/test/manual/banner-tests/test01.html +58 -0
  123. data/test/manual/banner-tests/test02.html +58 -0
  124. data/test/manual/banner-tests/test03.html +58 -0
  125. data/test/manual/banner-tests/test04.html +65 -0
  126. data/test/manual/banner-tests/test05.html +65 -0
  127. data/test/manual/banner-tests/test06.html +65 -0
  128. data/test/manual/banner-tests/test07.html +65 -0
  129. data/test/manual/banner-tests/test08.html +59 -0
  130. data/test/manual/banner-tests/test09.html +59 -0
  131. data/test/manual/banner-tests/test10.html +59 -0
  132. data/test/manual/banner-tests/test11.html +59 -0
  133. data/test/manual/banner-tests/test12.html +59 -0
  134. data/test/manual/banner-tests/test13.html +59 -0
  135. data/test/manual/banner-tests/test14.html +59 -0
  136. data/test/manual/banner-tests/test15.html +58 -0
  137. data/test/manual/banner-tests/test16.html +58 -0
  138. data/test/manual/banner-tests/test17.html +58 -0
  139. data/test/manual/banner-tests/test18.html +68 -0
  140. data/test/manual/banner-tests/test19.html +68 -0
  141. data/test/manual/banner-tests/test20.html +68 -0
  142. data/test/manual/banner-tests/test21.html +68 -0
  143. data/test/manual/banner-tests/test22.html +68 -0
  144. data/test/manual/banner-tests/test23.html +68 -0
  145. data/test/manual/banner-tests/test24.html +68 -0
  146. data/test/manual/banner-tests/test25.html +67 -0
  147. data/test/manual/banner_environment.rb +192 -0
  148. data/test/manual/deploy_symlink_demo.rb +142 -0
  149. data/test/manual/environment.rb +67 -0
  150. data/test/manual/make_banner.rb +153 -0
  151. data/test/manual/sample_banner_config.txt +12 -0
  152. data/test/manual/symlink_demo.rb +117 -0
  153. data/test/manual/test1.rb +47 -0
  154. data/test/manual/test2.rb +12 -0
  155. data/test/manual/test3.rb +38 -0
  156. data/test/manual/test4.rb +40 -0
  157. data/test/manual/test5.rb +24 -0
  158. data/test/manual/test6.rb +73 -0
  159. data/test/manual/test_banner_combinations.rb +120 -0
  160. data/test/manual/test_banner_features.rb +306 -0
  161. data/test/manual/test_banner_from_file.rb +150 -0
  162. data/test/manual/test_banner_in_header.rb +35 -0
  163. data/test/manual/test_code_highlighting.rb +68 -0
  164. data/test/manual/test_complex_header.rb +74 -0
  165. data/test/manual/test_empty_header.rb +32 -0
  166. data/test/manual/test_radial_custom.rb +58 -0
  167. data/test/manual/test_radial_large_radius.rb +52 -0
  168. data/test/manual/test_svg_debug.rb +47 -0
  169. data/test/manual/test_syntax_highlighting.rb +147 -0
  170. data/test/pages-demo/config/currentview.txt +1 -0
  171. data/test/pages-demo/views/demo/config/bootstrap_css.txt +5 -0
  172. data/test/pages-demo/views/demo/config/bootstrap_js.txt +4 -0
  173. data/test/pages-demo/views/demo/config/common.js +57 -0
  174. data/test/pages-demo/views/demo/config/footer.txt +1 -0
  175. data/test/pages-demo/views/demo/config/global-head.txt +8 -0
  176. data/test/pages-demo/views/demo/config/header.txt +1 -0
  177. data/test/pages-demo/views/demo/config/layout.txt +1 -0
  178. data/test/pages-demo/views/demo/config/left.txt +1 -0
  179. data/test/pages-demo/views/demo/config/main.txt +1 -0
  180. data/test/pages-demo/views/demo/config/right.txt +1 -0
  181. data/test/pages-demo/views/demo/config.txt +3 -0
  182. data/test/pages-demo/views/demo/output/panes/footer.html +1 -0
  183. data/test/pages-demo/views/demo/output/panes/header.html +1 -0
  184. data/test/pages-demo/views/demo/output/panes/left.html +1 -0
  185. data/test/pages-demo/views/demo/output/panes/main.html +1 -0
  186. data/test/pages-demo/views/demo/output/panes/right.html +1 -0
  187. data/test/rubytext/rubytext_comprehensive_test.rb +307 -0
  188. data/test/rubytext/rubytext_demo_test.rb +42 -0
  189. data/test/rubytext/rubytext_testing_guide.md +277 -0
  190. data/test/run_automated_tests.rb +45 -0
  191. data/test/scriptorium-TEST-1754622690-146/config/bootstrap_css.txt +5 -0
  192. data/test/scriptorium-TEST-1754622690-146/config/bootstrap_js.txt +4 -0
  193. data/test/scriptorium-TEST-1754622690-146/config/common.js +57 -0
  194. data/test/scriptorium-TEST-1754622690-146/config/currentview.txt +1 -0
  195. data/test/scriptorium-TEST-1754622690-146/config/global-head.txt +9 -0
  196. data/test/scriptorium-TEST-1754622690-146/config/last_post_num.txt +1 -0
  197. data/test/scriptorium-TEST-1754622690-146/config/os_helpers.rb +4 -0
  198. data/test/scriptorium-TEST-1754622690-146/config/widgets.txt +3 -0
  199. data/test/scriptorium-TEST-1754622690-146/posts/0001/meta.txt +8 -0
  200. data/test/scriptorium-TEST-1754622690-146/posts/0001/source.lt3 +6 -0
  201. data/test/scriptorium-TEST-1754622690-146/themes/standard/README.txt +1 -0
  202. data/test/scriptorium-TEST-1754622690-146/themes/standard/config.txt +1 -0
  203. data/test/scriptorium-TEST-1754622690-146/themes/standard/initial/post.lt3 +12 -0
  204. data/test/scriptorium-TEST-1754622690-146/themes/standard/layout/config/footer.txt +2 -0
  205. data/test/scriptorium-TEST-1754622690-146/themes/standard/layout/config/header.txt +4 -0
  206. data/test/scriptorium-TEST-1754622690-146/themes/standard/layout/config/left.txt +3 -0
  207. data/test/scriptorium-TEST-1754622690-146/themes/standard/layout/config/main.txt +5 -0
  208. data/test/scriptorium-TEST-1754622690-146/themes/standard/layout/config/right.txt +3 -0
  209. data/test/scriptorium-TEST-1754622690-146/themes/standard/layout/gen/text.css +1 -0
  210. data/test/scriptorium-TEST-1754622690-146/themes/standard/layout/layout.txt +5 -0
  211. data/test/scriptorium-TEST-1754622690-146/themes/standard/templates/index.lt3 +1 -0
  212. data/test/scriptorium-TEST-1754622690-146/themes/standard/templates/index_entry.lt3 +14 -0
  213. data/test/scriptorium-TEST-1754622690-146/themes/standard/templates/post.lt3 +13 -0
  214. data/test/scriptorium-TEST-1754622690-146/themes/standard/templates/widget.lt3 +1 -0
  215. data/test/scriptorium-TEST-1754622690-146/views/sample/config/bootstrap_css.txt +5 -0
  216. data/test/scriptorium-TEST-1754622690-146/views/sample/config/bootstrap_js.txt +4 -0
  217. data/test/scriptorium-TEST-1754622690-146/views/sample/config/common.js +57 -0
  218. data/test/scriptorium-TEST-1754622690-146/views/sample/config/deploy.txt +5 -0
  219. data/test/scriptorium-TEST-1754622690-146/views/sample/config/footer.txt +2 -0
  220. data/test/scriptorium-TEST-1754622690-146/views/sample/config/global-head.txt +9 -0
  221. data/test/scriptorium-TEST-1754622690-146/views/sample/config/header.txt +4 -0
  222. data/test/scriptorium-TEST-1754622690-146/views/sample/config/layout.txt +5 -0
  223. data/test/scriptorium-TEST-1754622690-146/views/sample/config/left.txt +3 -0
  224. data/test/scriptorium-TEST-1754622690-146/views/sample/config/main.txt +5 -0
  225. data/test/scriptorium-TEST-1754622690-146/views/sample/config/reddit.txt +10 -0
  226. data/test/scriptorium-TEST-1754622690-146/views/sample/config/right.txt +3 -0
  227. data/test/scriptorium-TEST-1754622690-146/views/sample/config/social.txt +7 -0
  228. data/test/scriptorium-TEST-1754622690-146/views/sample/config/status.txt +7 -0
  229. data/test/scriptorium-TEST-1754622690-146/views/sample/config.txt +3 -0
  230. data/test/scriptorium-TEST-1754622690-146/views/sample/layout/footer.html +3 -0
  231. data/test/scriptorium-TEST-1754622690-146/views/sample/layout/header.html +3 -0
  232. data/test/scriptorium-TEST-1754622690-146/views/sample/layout/left.html +3 -0
  233. data/test/scriptorium-TEST-1754622690-146/views/sample/layout/main.html +3 -0
  234. data/test/scriptorium-TEST-1754622690-146/views/sample/layout/right.html +3 -0
  235. data/test/scriptorium-TEST-1754622690-146/views/sample/output/panes/footer.html +1 -0
  236. data/test/scriptorium-TEST-1754622690-146/views/sample/output/panes/header.html +1 -0
  237. data/test/scriptorium-TEST-1754622690-146/views/sample/output/panes/left.html +1 -0
  238. data/test/scriptorium-TEST-1754622690-146/views/sample/output/panes/main.html +1 -0
  239. data/test/scriptorium-TEST-1754622690-146/views/sample/output/panes/right.html +1 -0
  240. data/test/staging/.DS_Store +0 -0
  241. data/test/syntax_highlighting_test.lt3 +124 -0
  242. data/test/test_helpers.rb +230 -0
  243. data/test/tui_editor_integration_test.rb +296 -0
  244. data/test/tui_integration_test.rb +637 -0
  245. data/test/unit/api.rb +1056 -0
  246. data/test/unit/asset_management.rb +245 -0
  247. data/test/unit/clipboard_test.rb +60 -0
  248. data/test/unit/contract_test.rb +91 -0
  249. data/test/unit/core.rb +857 -0
  250. data/test/unit/deploy_test.rb +187 -0
  251. data/test/unit/gem_asset_management.rb +189 -0
  252. data/test/unit/livetext_basic.rb +69 -0
  253. data/test/unit/livetext_compatibility.rb +89 -0
  254. data/test/unit/post.rb +244 -0
  255. data/test/unit/read_commented_file_test.rb +276 -0
  256. data/test/unit/reddit_test.rb +235 -0
  257. data/test/unit/repo.rb +548 -0
  258. data/test/unit/social_test.rb +369 -0
  259. data/test/unit/symlink_test.rb +213 -0
  260. data/test/unit/view.rb +431 -0
  261. data/test/unit/widgets.rb +669 -0
  262. data/test/wizard_test.rb +123 -0
  263. data/ui/README.md +67 -0
  264. data/ui/common/lib/ui_common.rb +8 -0
  265. data/ui/rubytext/README.md +191 -0
  266. data/ui/rubytext/bin/scriptorium-rubytext +402 -0
  267. data/ui/rubytext/lib/rubytext_ui.rb +300 -0
  268. data/ui/tui/bin/scriptorium +1420 -0
  269. data/ui/tui/test/tui_test.rb +23 -0
  270. data/ui/web/app/app.rb +1378 -0
  271. data/ui/web/app/error_helpers.rb +150 -0
  272. data/ui/web/app/views/advanced_config.erb +190 -0
  273. data/ui/web/app/views/asset_management.erb +589 -0
  274. data/ui/web/app/views/banner_config.erb +200 -0
  275. data/ui/web/app/views/configure_view.erb +401 -0
  276. data/ui/web/app/views/dashboard.erb +162 -0
  277. data/ui/web/app/views/deploy_config.erb +146 -0
  278. data/ui/web/app/views/edit_pages.erb +195 -0
  279. data/ui/web/app/views/edit_post.erb +54 -0
  280. data/ui/web/app/views/error_page.erb +29 -0
  281. data/ui/web/app/views/header_config.erb +155 -0
  282. data/ui/web/app/views/layout_config.erb +147 -0
  283. data/ui/web/app/views/navbar_config.erb +411 -0
  284. data/ui/web/app/views/view_dashboard.erb +138 -0
  285. data/ui/web/bin/scriptorium-web +153 -0
  286. data/ui/web/test/web_basic_test.rb +38 -0
  287. data/ui/web/test_navbar.txt +7 -0
  288. data/ui/web/tmp/web_server.log +5 -0
  289. data/ui/web/tmp/web_server.pid +1 -0
  290. metadata +359 -7
  291. data/lib/scriptorium/engine.rb +0 -22
  292. data/test/engine/unit.rb +0 -44
data/bin/scriptorium ADDED
@@ -0,0 +1,1511 @@
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
+ @api = Scriptorium::API.new(testmode: true)
13
+ @testing = true
14
+ setup_readline
15
+ end
16
+
17
+ def discover_repo
18
+ if @testing
19
+ if Dir.exist?("scriptorium-TEST")
20
+ puts "Found existing test repository: scriptorium-TEST"
21
+ @testing = "scriptorium-TEST"
22
+ @api = Scriptorium::API.new(testmode: true)
23
+ begin
24
+ @api.open_repo("scriptorium-TEST")
25
+ puts "Current view: #{@api.current_view&.name || 'nil'}"
26
+ puts "Loaded test repository"
27
+ return true
28
+ rescue => e
29
+ puts "Error opening repository: #{e.message}"
30
+ puts e.backtrace.first if @testing
31
+ return false
32
+ end
33
+ else
34
+ puts "No repository found."
35
+ return false
36
+ end
37
+ else
38
+ # Later: for production
39
+ end
40
+ return false
41
+ end
42
+
43
+ def create_new_repo
44
+ puts "Creating new repository..."
45
+ @testing = "scriptorium-TEST"
46
+ @api = Scriptorium::API.new(testmode: true)
47
+ begin
48
+ @api.create_repo("scriptorium-TEST")
49
+ puts "Created repository successfully."
50
+
51
+ # Run initial setup (like Runeblog)
52
+ get_started
53
+ rescue => e
54
+ puts "Error creating repository: #{e.message}"
55
+ puts e.backtrace.first if @testing
56
+ return false
57
+ end
58
+ end
59
+
60
+ def wizard_first_view
61
+ # Check if this is the first view (only sample view exists)
62
+ views = @api.views
63
+ if views.length == 1 && views[0].name == "sample"
64
+ puts "Let's set up your first view!"
65
+
66
+ # Create a new view using existing interactive method
67
+ create_view("view")
68
+
69
+ # Get the current view name (the one we just created)
70
+ current_view = @api.current_view
71
+ return unless current_view
72
+ name = current_view.name
73
+
74
+ # Ask about layout
75
+ puts
76
+ if yesno("Would you like to edit the layout?")
77
+ @api.edit_file("#{@api.root}/views/#{name}/config/layout.txt")
78
+ end
79
+
80
+ # Read the layout to see what containers we have
81
+ layout_file = "#{@api.root}/views/#{name}/config/layout.txt"
82
+ layout_content = read_file(layout_file)
83
+ file_containers = layout_content.lines.map { |line| line.split(/\s+/).first }.compact
84
+
85
+ # Define logical order for containers
86
+ logical_order = ['header', 'main', 'left', 'right', 'footer']
87
+
88
+ # Use logical order, but only include containers that exist in the file
89
+ containers = logical_order.select { |container| file_containers.include?(container) }
90
+
91
+ # Configure each container
92
+ containers.each do |container|
93
+ puts
94
+ if yesno("Would you like to configure #{container}?")
95
+ case container
96
+ when 'header'
97
+ # This is complex and will be expanded later
98
+ @api.edit_file("#{@api.root}/views/#{name}/config/header.txt")
99
+ when 'main'
100
+ puts "Main container is just a stub for now"
101
+ when 'left', 'right'
102
+ configure_sidebar_widgets(name, container)
103
+ when 'footer'
104
+ puts "Footer has no real config for now"
105
+ end
106
+ end
107
+ end
108
+
109
+ puts
110
+ puts "View setup complete!"
111
+ else
112
+ puts "Wizard is only available for the first view setup"
113
+ end
114
+ end
115
+
116
+ def configure_sidebar_widgets(view_name, container)
117
+ puts "Add widgets to #{container}? (y/n)"
118
+ return unless yesno("Add widgets to #{container}?")
119
+
120
+ # Show available widgets
121
+ available_widgets = @api.widgets_available
122
+ puts "Available widgets: #{available_widgets.join(', ')}"
123
+
124
+ selected_widgets = []
125
+ available_widgets.each do |widget|
126
+ if yesno("Add #{widget} widget?")
127
+ selected_widgets << widget
128
+ end
129
+ end
130
+
131
+ # Configure each selected widget
132
+ selected_widgets.each do |widget|
133
+ if yesno("Configure #{widget} widget?")
134
+ case widget
135
+ when 'links'
136
+ @api.edit_file("#{@api.root}/views/#{view_name}/widgets/links/list.txt")
137
+ when 'pages'
138
+ configure_pages_widget(view_name)
139
+ end
140
+ end
141
+ end
142
+ end
143
+
144
+ def configure_pages_widget(view_name)
145
+ list_file = "#{@api.root}/views/#{view_name}/widgets/pages/list.txt"
146
+ @api.edit_file(list_file)
147
+
148
+ # Check for missing pages
149
+ pages_list = read_file(list_file, lines: true, chomp: true)
150
+ missing_pages = []
151
+
152
+ pages_list.each do |page|
153
+ page_file = "#{@api.root}/views/#{view_name}/pages/#{page}.html"
154
+ unless File.exist?(page_file)
155
+ missing_pages << page
156
+ end
157
+ end
158
+
159
+ if missing_pages.any?
160
+ puts
161
+ puts "Found #{missing_pages.length} missing pages: #{missing_pages.join(', ')}"
162
+ if yesno("Do you want to edit the missing pages?")
163
+ missing_pages.each do |page|
164
+ if yesno("Edit #{page}?")
165
+ @api.edit_file("#{@api.root}/views/#{view_name}/pages/#{page}.html")
166
+ else
167
+ # Create empty .lt3 file
168
+ write_file("#{@api.root}/views/#{view_name}/pages/#{page}.lt3", "")
169
+ end
170
+ end
171
+ else
172
+ # Create empty .lt3 files for all missing pages
173
+ missing_pages.each do |page|
174
+ write_file("#{@api.root}/views/#{view_name}/pages/#{page}.lt3", "")
175
+ end
176
+ end
177
+ else
178
+ puts "[WIZARD] No missing pages found"
179
+ end
180
+ end
181
+
182
+ def yesno(question)
183
+ print "#{question} (y/n): "
184
+ response = get_string&.downcase
185
+ response == "y" || response == "yes"
186
+ end
187
+
188
+ def get_string
189
+ if STDIN.tty? && !ENV['NOREADLINE']
190
+ result = Readline.readline
191
+ result
192
+ else
193
+ result = gets&.chomp&.strip
194
+ result
195
+ end
196
+ end
197
+
198
+ def mainloop
199
+ loop do
200
+ begin
201
+ # Ensure we have a valid API with repository
202
+ if @api.nil? || @api.instance_variable_get(:@repo).nil?
203
+ puts "Error: No valid repository loaded. Exiting."
204
+ return
205
+ end
206
+
207
+ current_view = @api.current_view
208
+ current_view_name = current_view&.name || "no-view"
209
+ prompt = "[#{current_view_name}] "
210
+
211
+ # Use regular gets for automated tests, Readline for interactive
212
+ if STDIN.tty? && !ENV['NOREADLINE']
213
+ input = Readline.readline(prompt, true)
214
+ else
215
+ print prompt
216
+ input = gets&.chomp&.strip
217
+ end
218
+
219
+ break if input.nil? || input.downcase == "quit" || input.downcase == "q"
220
+
221
+ next if input.empty?
222
+
223
+ execute_command(input)
224
+ rescue Interrupt
225
+ puts "\nUse 'quit' to exit"
226
+ rescue CannotLookupView => e
227
+ # Extract view name from error message for user-friendly display
228
+ if e.message =~ /Cannot lookup view: (.+)/
229
+ view_name = $1
230
+ puts
231
+ puts " View '#{view_name}' not found"
232
+ puts
233
+ else
234
+ puts "Error: #{e.message}"
235
+ end
236
+ rescue => e
237
+ puts "Error: #{e.message}"
238
+ puts e.backtrace.first if @testing
239
+ end
240
+ end
241
+
242
+ puts
243
+ puts " Goodbye!"
244
+ puts
245
+ end
246
+
247
+ private
248
+
249
+ def setup_readline
250
+ # Only set up Readline if we're not in automated testing mode
251
+ return if ENV['NOREADLINE']
252
+
253
+ # Set up tab completion
254
+ Readline.completion_proc = proc do |input|
255
+ completions = []
256
+
257
+ # Split input to get command and arguments
258
+ parts = input.split(/\s+/)
259
+ command = parts[0]&.downcase
260
+ args = parts[1..-1] || []
261
+
262
+ if args.empty?
263
+ # Complete command names
264
+ commands = %w[view change list new version help quit cv lsv v h q]
265
+ completions = commands.select { |cmd| cmd.start_with?(command || "") }
266
+ elsif command == "change" || command == "cv"
267
+ # Complete view names
268
+ if @api
269
+ view_names = @api.views.map(&:name)
270
+ completions = view_names.select { |name| name.start_with?(args.last || "") }
271
+ end
272
+ elsif command == "list" && args.length == 1 && args[0] == "views"
273
+ # Complete "list views" command
274
+ completions = []
275
+ elsif command == "new" && args.length == 1 && args[0] == "view"
276
+ # Suggest common view names for new view
277
+ suggestions = %w[blog personal work tech travel]
278
+ completions = suggestions
279
+ end
280
+
281
+ completions
282
+ end
283
+ end
284
+
285
+ def create_test_repo
286
+ puts "Creating test repository..."
287
+ @testing = true
288
+ @api = Scriptorium::API.new(testmode: true)
289
+ @api.create_repo("scriptorium-TEST")
290
+ puts "Test repository created successfully!"
291
+ end
292
+
293
+ private def execute_command(input)
294
+ parts = input.split(/\s+/, 2)
295
+ cmd = parts[0].downcase
296
+ args = parts[1] || ""
297
+
298
+ # Handle multi-word commands first
299
+ if cmd == "list" && args.start_with?("views")
300
+ list_views
301
+ elsif cmd == "list" && args.start_with?("posts")
302
+ list_posts
303
+ elsif cmd == "list" && args.start_with?("drafts")
304
+ list_drafts
305
+ elsif cmd == "change" && args.start_with?("view")
306
+ change_view(args)
307
+ elsif cmd == "new" && args.start_with?("view")
308
+ create_view(args)
309
+ elsif cmd == "new" && args.start_with?("draft")
310
+ create_draft(args)
311
+ elsif cmd == "new" && args.start_with?("post")
312
+ create_post(args)
313
+ elsif cmd == "publish" && args.start_with?("post")
314
+ publish_post(args)
315
+ elsif cmd == "list" && args.start_with?("themes")
316
+ list_themes
317
+ elsif cmd == "clone" && args.include?(" ")
318
+ clone_theme(args)
319
+
320
+ else
321
+ # Handle single-word commands
322
+ case cmd
323
+ when "help", "h"
324
+ show_help
325
+ when "view"
326
+ show_current_view
327
+ when "cv"
328
+ change_view(args)
329
+ when "lsv"
330
+ list_views
331
+ when "lsp"
332
+ list_posts
333
+ when "lsd"
334
+ list_drafts
335
+ when "cd"
336
+ create_draft("draft")
337
+ when "version", "v"
338
+ show_version
339
+ when "deploy"
340
+ deploy_current_view
341
+ when "preview"
342
+ preview_current_view
343
+ when "browse"
344
+ browse_deployed_view
345
+ when "list" && args.start_with?("widgets")
346
+ list_widgets
347
+ when "add" && args.start_with?("widget")
348
+ add_widget(args)
349
+ when "config" && args.start_with?("widget")
350
+ config_widget(args)
351
+ when "config" && args.start_with?("social")
352
+ config_social
353
+ when "config" && args.start_with?("reddit")
354
+ config_reddit
355
+ when "generate"
356
+ generate_current_view
357
+ when "quit", "q"
358
+ exit 0
359
+ else
360
+ puts
361
+ puts " Unknown command: #{cmd}. Type 'help' for available commands."
362
+ puts
363
+ end
364
+ end
365
+ end
366
+
367
+ private def show_help
368
+ puts
369
+ puts <<~HELP
370
+
371
+ view - Show current view
372
+ change view [<name>] - Switch to a view
373
+ cv [<name>]
374
+ list views - List all views
375
+ lsv
376
+ new view [<name> <title>] - Create a new view
377
+
378
+ list posts - List posts in current view
379
+ lsp
380
+ list drafts - List all drafts
381
+ lsd
382
+ new post [<title>] - Create draft, edit, and convert to post
383
+ publish post <id> - Publish a post (generate and mark as public)
384
+
385
+ deploy - Deploy current view to server
386
+ preview - Preview current view locally
387
+ browse - Browse deployed view on server
388
+
389
+ list widgets - List available and configured widgets
390
+ add widget <name> - Add widget to current view
391
+ config widget <name> - Configure widget data
392
+
393
+ config social - Configure social media sharing
394
+ config reddit - Configure Reddit sharing buttons
395
+ generate - Regenerate current view
396
+
397
+ list themes - List available themes
398
+ clone <old> <new> - Clone a theme
399
+
400
+ version, v - Show version
401
+ help, h - Show this help
402
+ quit, q, ^D - Exit
403
+ HELP
404
+ puts
405
+ end
406
+
407
+ private def show_current_view
408
+ current_view = @api.current_view
409
+ current_view_name = current_view&.name || "none"
410
+ puts
411
+ puts " Current view: #{current_view_name}"
412
+ puts
413
+ end
414
+
415
+ private def change_view(args)
416
+ # Handle "change view <name>" format
417
+ if args == "view" || args.start_with?("view ")
418
+ # Remove "view " prefix if present, otherwise args is just "view"
419
+ view_name = args == "view" ? "" : args[5..-1].strip
420
+ else
421
+ view_name = args.strip
422
+ end
423
+
424
+ if view_name.empty?
425
+ # Interactive mode - prompt for view name
426
+ puts
427
+ puts " Available views:"
428
+ views = @api.views
429
+ if views.empty?
430
+ puts " No views found"
431
+ puts
432
+ return
433
+ else
434
+ current_view = @api.current_view
435
+ current_view_name = current_view&.name
436
+
437
+ views.each do |view|
438
+ current = view.name == current_view_name ? "*" : " "
439
+ puts " #{current} #{view.name} - #{view.title}"
440
+ end
441
+ puts
442
+ end
443
+
444
+ print " Enter view name: "
445
+ view_name = gets&.chomp&.strip
446
+ return if view_name.nil? || view_name.empty?
447
+ end
448
+
449
+ view = @api.lookup_view(view_name)
450
+ @api.view(view_name)
451
+ puts
452
+ puts " Switched to view '#{view_name}'"
453
+ puts
454
+ rescue => e
455
+ puts
456
+ puts " View '#{view_name}' not found"
457
+ puts
458
+ end
459
+
460
+ private def create_view(args)
461
+ # Handle "new view" format - prompt for all parameters
462
+ if args == "view" || args.start_with?("view ")
463
+ # Remove "view " prefix if present, otherwise args is just "view"
464
+ view_args = args == "view" ? "" : args[5..-1]
465
+
466
+ if view_args.strip.empty?
467
+ # Interactive mode - prompt for all parameters
468
+ print " Enter view name: "
469
+ name = get_string
470
+ return if name.nil? || name.empty?
471
+
472
+ print " Enter view title: "
473
+ title = get_string
474
+ return if title.nil? || title.empty?
475
+
476
+ print " Enter subtitle (optional): "
477
+ subtitle = get_string
478
+ subtitle = nil if subtitle.empty?
479
+
480
+ # Check if view already exists
481
+ existing_views = @api.views
482
+ if existing_views.any? { |view| view.name == name }
483
+ puts
484
+ puts " View '#{name}' already exists"
485
+ puts
486
+ return
487
+ end
488
+
489
+ # Create view with all parameters
490
+ begin
491
+ @api.create_view(name, title, subtitle, theme: "standard")
492
+ puts
493
+ puts " Created view '#{name}' with title '#{title}'"
494
+ puts " Switched to view '#{name}'"
495
+ puts
496
+ rescue Exception => e
497
+ puts
498
+ puts " #{e.message}"
499
+ puts
500
+ puts "DEBUG: Exception caught in create_view (interactive): #{e.class}: #{e.message}"
501
+ return # Exit the method when view creation fails
502
+ end
503
+ else
504
+ # Legacy mode - still support "new view <name> <title>"
505
+ parts = view_args.split(/\s+/, 2)
506
+ if parts.length < 2
507
+ puts
508
+ puts " Usage: new view [<name> <title>]"
509
+ puts
510
+ return
511
+ end
512
+
513
+ name, title = parts
514
+
515
+ # Prompt for subtitle
516
+ print " Enter subtitle (optional): "
517
+ subtitle = get_string
518
+ subtitle = nil if subtitle.empty?
519
+
520
+ # Check if view already exists
521
+ existing_views = @api.views
522
+ if existing_views.any? { |view| view.name == name }
523
+ puts
524
+ puts " View '#{name}' already exists"
525
+ puts
526
+ return
527
+ end
528
+
529
+ # Create view with all parameters
530
+ begin
531
+ @api.create_view(name, title, subtitle, theme: "standard")
532
+ puts
533
+ puts " Created view '#{name}' with title '#{title}'"
534
+ puts " Switched to view '#{name}'"
535
+ puts
536
+ rescue Exception => e
537
+ puts
538
+ puts " #{e.message}"
539
+ puts
540
+ puts "DEBUG: Exception caught in create_view (legacy): #{e.class}: #{e.message}"
541
+ end
542
+ end
543
+ else
544
+ puts
545
+ puts " Usage: new view [<name> <title>]"
546
+ puts
547
+ end
548
+ end
549
+
550
+ private def create_draft(args)
551
+ # Handle "new draft" format - prompt for all parameters
552
+ if args == "draft" || args.start_with?("draft ")
553
+ # Remove "draft " prefix if present, otherwise args is just "draft"
554
+ draft_args = args == "draft" ? "" : args[6..-1]
555
+
556
+ if draft_args.strip.empty?
557
+ # Interactive mode - prompt for all parameters
558
+ print " Enter draft title: "
559
+ title = gets&.chomp&.strip
560
+ return if title.nil? || title.empty?
561
+
562
+ print " Enter draft body: "
563
+ body = gets&.chomp&.strip
564
+ return if body.nil? || body.empty?
565
+
566
+ print " Enter tags (optional, comma-separated): "
567
+ tags_input = gets&.chomp&.strip
568
+ tags = tags_input.empty? ? nil : tags_input.split(",").map(&:strip)
569
+
570
+ print " Enter blurb (optional): "
571
+ blurb = gets&.chomp&.strip
572
+ blurb = nil if blurb.empty?
573
+
574
+ # Create draft with all parameters
575
+ draft_path = @api.create_draft(
576
+ title: title,
577
+ body: body,
578
+ views: @api.current_view&.name,
579
+ tags: tags,
580
+ blurb: blurb
581
+ )
582
+ puts
583
+ puts " Created draft: #{draft_path}"
584
+ puts
585
+ else
586
+ # Legacy mode - still support "new draft <title>"
587
+ title = draft_args.strip
588
+
589
+ print " Enter draft body: "
590
+ body = gets&.chomp&.strip
591
+ return if body.nil? || body.empty?
592
+
593
+ print " Enter tags (optional, comma-separated): "
594
+ tags_input = gets&.chomp&.strip
595
+ tags = tags_input.empty? ? nil : tags_input.split(",").map(&:strip)
596
+
597
+ print " Enter blurb (optional): "
598
+ blurb = gets&.chomp&.strip
599
+ blurb = nil if blurb.empty?
600
+
601
+ # Create draft with all parameters
602
+ draft_path = @api.create_draft(
603
+ title: title,
604
+ body: body,
605
+ views: @api.current_view&.name,
606
+ tags: tags,
607
+ blurb: blurb
608
+ )
609
+ puts
610
+ puts " Created draft: #{draft_path}"
611
+ puts
612
+ end
613
+ else
614
+ puts
615
+ puts " Usage: new draft [<title>]"
616
+ puts
617
+ end
618
+ end
619
+
620
+ def show_version
621
+ puts
622
+ puts " Scriptorium #{Scriptorium::VERSION}"
623
+ puts
624
+ end
625
+
626
+ def get_started
627
+ puts
628
+ puts " No editor configured. Let's set one up."
629
+ pick_editor
630
+
631
+ puts
632
+ puts " Setup complete!"
633
+ puts " You can now use 'new post <title>' to create posts with your editor."
634
+ puts
635
+ end
636
+
637
+ def pick_editor
638
+ puts
639
+ puts " Available editors:"
640
+
641
+ # Check for common editors (prioritized for single file editing)
642
+ editors = []
643
+ %w[nano vim emacs vi micro].each do |editor|
644
+ if which(editor)
645
+ editors << editor
646
+ end
647
+ end
648
+
649
+ # The original Unix line editor - for the brave souls who want ultimate speed
650
+ if which("ed")
651
+ editors << "ed"
652
+ end
653
+
654
+
655
+ if editors.empty?
656
+ puts " No common editors found. Please install nano, vim, emacs, vi, micro, or ed."
657
+ puts " You can manually set your editor later by editing config/editor.txt"
658
+ puts
659
+ return
660
+ end
661
+
662
+ # Show available editors
663
+ editors.each_with_index do |editor, index|
664
+ puts " #{index + 1}. #{editor}"
665
+ end
666
+
667
+ # Let user pick
668
+ print " Choose editor (1-#{editors.length}): "
669
+ choice = get_string
670
+
671
+ if choice && choice.match?(/^\d+$/) && choice.to_i.between?(1, editors.length)
672
+ selected_editor = editors[choice.to_i - 1]
673
+
674
+ # Save the choice
675
+ make_dir(@api.root/"config")
676
+ write_file(@api.root/"config/editor.txt", selected_editor)
677
+
678
+ puts
679
+ puts " Selected editor: #{selected_editor}"
680
+ puts " Editor preference saved to config/editor.txt"
681
+ else
682
+ puts
683
+ puts " Invalid choice. Editor not changed."
684
+ end
685
+ end
686
+
687
+ def list_views
688
+ puts
689
+ views = @api.views
690
+ if views.empty?
691
+ puts " No views found"
692
+ else
693
+ current_view = @api.current_view
694
+ current_view_name = current_view&.name
695
+
696
+ views.each do |view|
697
+ current = view.name == current_view_name ? "*" : " "
698
+ puts " #{current} #{view.name} #{view.title}"
699
+ end
700
+ end
701
+ puts
702
+ end
703
+
704
+ def which(command)
705
+ # Mock which in test mode to avoid hanging
706
+ if @testing
707
+ case command
708
+ when 'nano', 'vim', 'vi', 'ed'
709
+ "/usr/bin/#{command}"
710
+ else
711
+ nil
712
+ end
713
+ else
714
+ # Use File.which if available (Ruby 3.2+)
715
+ if File.respond_to?(:which)
716
+ File.which(command)
717
+ else
718
+ # Fall back to system call
719
+ result = `which #{command} 2>/dev/null`.chomp
720
+ result.empty? ? nil : result
721
+ end
722
+ end
723
+ end
724
+
725
+ private def create_post(args)
726
+ # Handle "new post <title>" format
727
+ if args == "post" || args.start_with?("post ")
728
+ # Remove "post " prefix if present, otherwise args is just "post"
729
+ post_args = args == "post" ? "" : args[5..-1]
730
+
731
+ if post_args.strip.empty?
732
+ # Interactive mode - prompt for title
733
+ print " Enter post title: "
734
+ title = gets&.chomp&.strip
735
+ return if title.nil? || title.empty?
736
+ else
737
+ # Use provided title
738
+ title = post_args.strip
739
+ end
740
+
741
+ # Check if editor is configured
742
+ editor_file = @api.root/"config/editor.txt"
743
+ unless File.exist?(editor_file)
744
+ puts
745
+ puts " No editor configured. Please configure an editor in config/editor.txt"
746
+ puts
747
+ return
748
+ end
749
+
750
+ editor = read_file(editor_file).strip
751
+
752
+ # Create draft
753
+ begin
754
+ draft_path = @api.create_draft(
755
+ title: title,
756
+ body: "", # Empty body to start
757
+ views: @api.current_view&.name,
758
+ tags: nil,
759
+ blurb: nil
760
+ )
761
+
762
+ puts
763
+ puts " Created draft: #{File.basename(draft_path)}"
764
+ puts " Opening in #{editor}..."
765
+ puts
766
+
767
+ # Open in editor
768
+ system("#{editor} #{draft_path}")
769
+
770
+ puts
771
+ puts " Converting draft to post..."
772
+
773
+ # Convert draft to post (like Runeblog)
774
+ begin
775
+ post_num = @api.finish_draft(draft_path)
776
+ post = @api.post(post_num)
777
+ if post && post.title
778
+ puts " Post created: ##{post_num} - #{post.title}"
779
+ else
780
+ puts " Post created: ##{post_num}"
781
+ end
782
+ puts " Use 'publish post #{post_num}' to publish, then 'deploy' to publish to server."
783
+ rescue => e
784
+ puts " Error converting to post: #{e.message}"
785
+ end
786
+
787
+ puts
788
+
789
+ rescue => e
790
+ puts
791
+ puts " Error creating post: #{e.message}"
792
+ puts
793
+ end
794
+ else
795
+ puts
796
+ puts " Usage: new post [<title>]"
797
+ puts
798
+ end
799
+ end
800
+
801
+ private def publish_post(args)
802
+ # Handle "publish post <id>" format
803
+ if args == "post" || args.start_with?("post ")
804
+ # Remove "post " prefix if present, otherwise args is just "post"
805
+ post_args = args == "post" ? "" : args[5..-1]
806
+
807
+ if post_args.strip.empty?
808
+ # Interactive mode - prompt for post ID
809
+ print " Enter post ID to publish: "
810
+ post_id = gets&.chomp&.strip
811
+ return if post_id.nil? || post_id.empty?
812
+ else
813
+ # Use provided post ID
814
+ post_id = post_args.strip
815
+ end
816
+
817
+ begin
818
+ # Validate post ID format
819
+ unless post_id.match?(/^\d+$/)
820
+ puts
821
+ puts " Invalid post ID format. Use numeric ID (e.g., 1, 0001)"
822
+ puts
823
+ return
824
+ end
825
+
826
+ # Publish the post
827
+ post = @api.publish_post(post_id.to_i)
828
+
829
+ puts
830
+ puts " ✅ Post ##{post_id} published: #{post.title}"
831
+ puts " Use 'deploy' to publish to server when ready."
832
+ puts
833
+
834
+ rescue => e
835
+ puts
836
+ puts " ❌ Error publishing post: #{e.message}"
837
+ puts
838
+ end
839
+ else
840
+ puts
841
+ puts " Usage: publish post <id>"
842
+ puts " Example: publish post 1"
843
+ puts
844
+ end
845
+ end
846
+
847
+ private def list_posts
848
+ current_view = @api.current_view
849
+ if current_view.nil?
850
+ puts
851
+ puts " No current view selected"
852
+ puts
853
+ return
854
+ end
855
+
856
+ posts = @api.all_posts(current_view)
857
+
858
+ puts
859
+ if posts.empty?
860
+ puts " No posts found in view '#{current_view.name}'"
861
+ else
862
+ puts " Posts in view '#{current_view.name}':"
863
+ posts.each do |post|
864
+ published = @api.post_published?(post.id)
865
+ status = published ? "✅" : "📝"
866
+ puts " #{status} ##{post.id}: #{post.title}"
867
+ end
868
+ puts
869
+ puts " 📝 = Draft (unpublished) ✅ = Published"
870
+ end
871
+ puts
872
+ end
873
+
874
+ private def list_drafts
875
+ drafts_dir = @api.root/:drafts
876
+ return unless Dir.exist?(drafts_dir)
877
+
878
+ draft_files = Dir.glob("#{drafts_dir}/*-draft.lt3")
879
+
880
+ puts
881
+ if draft_files.empty?
882
+ puts " No drafts found"
883
+ else
884
+ draft_files.each do |file|
885
+ filename = File.basename(file)
886
+ puts " #{filename}"
887
+ end
888
+ end
889
+ puts
890
+ end
891
+
892
+ private def deploy_current_view
893
+ current_view = @api.current_view
894
+ if current_view.nil?
895
+ puts
896
+ puts " No current view selected"
897
+ puts
898
+ return
899
+ end
900
+
901
+ # Check if deploy config exists
902
+ deploy_config_file = current_view.dir/:config/"deploy.txt"
903
+ unless File.exist?(deploy_config_file)
904
+ puts
905
+ puts " No deployment configuration found."
906
+ puts " Create #{deploy_config_file} with format:"
907
+ puts " user@server:path"
908
+ puts
909
+ return
910
+ end
911
+
912
+ # Read deployment configuration
913
+ deploy_config = read_file(deploy_config_file).strip
914
+ if deploy_config.empty?
915
+ puts
916
+ puts " Deployment configuration is empty."
917
+ puts
918
+ return
919
+ end
920
+
921
+ # Check if output directory exists
922
+ output_dir = current_view.dir/:output
923
+ unless Dir.exist?(output_dir)
924
+ puts
925
+ puts " Output directory does not exist: #{output_dir}"
926
+ puts " Generate content first with 'new post' or similar."
927
+ puts
928
+ return
929
+ end
930
+
931
+ # Check for unpublished posts
932
+ all_posts = @api.all_posts(current_view)
933
+ unpublished_posts = all_posts.reject { |post| @api.post_published?(post.id) }
934
+
935
+ if unpublished_posts.any?
936
+ puts
937
+ puts " ⚠️ Found unpublished posts:"
938
+ unpublished_posts.each do |post|
939
+ puts " Post ##{post.id}: #{post.title}"
940
+ end
941
+ puts
942
+
943
+ if yesno("Publish these posts now?")
944
+ unpublished_posts.each do |post|
945
+ begin
946
+ @api.publish_post(post.id)
947
+ puts " ✅ Published post ##{post.id}: #{post.title}"
948
+ rescue => e
949
+ puts " ❌ Failed to publish post ##{post.id}: #{e.message}"
950
+ end
951
+ end
952
+ puts
953
+ else
954
+ puts " Deployment will only include published posts."
955
+ puts
956
+ end
957
+ end
958
+
959
+ # Create deployment marker file
960
+ marker_content = "Deployed: #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
961
+ marker_file = output_dir/"last-deployed.txt"
962
+ write_file(marker_file, marker_content)
963
+
964
+ # Execute rsync command
965
+ puts
966
+ puts " Deploying view '#{current_view.name}' to #{deploy_config}..."
967
+
968
+ cmd = "rsync -r -z -l #{output_dir}/ #{deploy_config}/"
969
+ puts " Executing: #{cmd}"
970
+
971
+ result = system(cmd)
972
+
973
+ if result
974
+ puts " Deployment successful!"
975
+
976
+ # Extract domain and verify deployment
977
+ domain = extract_domain_from_deploy_config(deploy_config)
978
+ if domain
979
+ verify_deployment(domain)
980
+ end
981
+ else
982
+ puts " Deployment failed!"
983
+ end
984
+ puts
985
+ end
986
+
987
+ private def extract_domain_from_deploy_config(config)
988
+ # user@example.com:/path/ -> example.com
989
+ if config =~ /@([^:]+):/
990
+ $1
991
+ end
992
+ end
993
+
994
+ private def verify_deployment(domain)
995
+ url = "https://#{domain}/last-deployed.txt"
996
+ puts " Verifying deployment..."
997
+
998
+ require 'net/http'
999
+ begin
1000
+ response = Net::HTTP.get_response(URI(url))
1001
+ if response.code == "200"
1002
+ puts " ✅ Deployment verified!"
1003
+ else
1004
+ puts " ⚠️ Deployment verification failed (HTTP #{response.code})"
1005
+ end
1006
+ rescue => e
1007
+ puts " ⚠️ Deployment verification failed: #{e.message}"
1008
+ end
1009
+ end
1010
+
1011
+ private def preview_current_view
1012
+ current_view = @api.current_view
1013
+ if current_view.nil?
1014
+ puts
1015
+ puts " No current view selected"
1016
+ puts
1017
+ return
1018
+ end
1019
+
1020
+ # Check if output directory exists
1021
+ output_dir = current_view.dir/:output
1022
+ unless Dir.exist?(output_dir)
1023
+ puts
1024
+ puts " Output directory does not exist: #{output_dir}"
1025
+ puts " Generate content first with 'new post' or similar."
1026
+ puts
1027
+ return
1028
+ end
1029
+
1030
+ # Find the main index file
1031
+ index_file = output_dir/"index.html"
1032
+ unless File.exist?(index_file)
1033
+ puts
1034
+ puts " No index.html found in output directory"
1035
+ puts " Generate content first with 'new post' or similar."
1036
+ puts
1037
+ return
1038
+ end
1039
+
1040
+ # Load OS-specific helper and open the file
1041
+ load_os_helpers
1042
+ puts
1043
+ puts " Opening preview of view '#{current_view.name}'..."
1044
+ open_file(index_file)
1045
+ puts
1046
+ end
1047
+
1048
+ private def browse_deployed_view
1049
+ current_view = @api.current_view
1050
+ if current_view.nil?
1051
+ puts
1052
+ puts " No current view selected"
1053
+ puts
1054
+ return
1055
+ end
1056
+
1057
+ # Check if deploy config exists
1058
+ deploy_config_file = current_view.dir/:config/"deploy.txt"
1059
+ unless File.exist?(deploy_config_file)
1060
+ puts
1061
+ puts " No deployment configuration found."
1062
+ puts " Create #{deploy_config_file} with format:"
1063
+ puts " user@server:path"
1064
+ puts
1065
+ return
1066
+ end
1067
+
1068
+ # Read deployment configuration and extract domain
1069
+ deploy_config = read_file(deploy_config_file).strip
1070
+ if deploy_config.empty?
1071
+ puts
1072
+ puts " Deployment configuration is empty."
1073
+ puts
1074
+ return
1075
+ end
1076
+
1077
+ # Extract domain for browsing
1078
+ domain = extract_domain_from_deploy_config(deploy_config)
1079
+ unless domain
1080
+ puts
1081
+ puts " Could not extract domain from deployment configuration."
1082
+ puts
1083
+ return
1084
+ end
1085
+
1086
+ # Load OS-specific helper and open the URL
1087
+ load_os_helpers
1088
+ url = "https://#{domain}/"
1089
+ puts
1090
+ puts " Opening deployed view at: #{url}"
1091
+ open_file(url)
1092
+ puts
1093
+ end
1094
+
1095
+ private def load_os_helpers
1096
+ # Load the OS-specific helper functions
1097
+ os_helpers_file = @api.root/:config/"os_helpers.rb"
1098
+ if File.exist?(os_helpers_file)
1099
+ load os_helpers_file
1100
+ else
1101
+ puts " Warning: OS helpers not found. Preview/browse may not work."
1102
+ end
1103
+ end
1104
+
1105
+ private def list_widgets
1106
+ current_view = @api.current_view
1107
+ if current_view.nil?
1108
+ puts
1109
+ puts " No current view selected"
1110
+ puts
1111
+ return
1112
+ end
1113
+
1114
+ # Get available widgets
1115
+ available_widgets = @api.widgets_available
1116
+ puts
1117
+ puts " Available widgets: #{available_widgets.join(', ')}"
1118
+
1119
+ # Check which widgets are configured
1120
+ configured_widgets = []
1121
+ available_widgets.each do |widget|
1122
+ widget_dir = current_view.dir/:widgets/widget
1123
+ if Dir.exist?(widget_dir)
1124
+ configured_widgets << widget
1125
+ end
1126
+ end
1127
+
1128
+ puts " Configured widgets: #{configured_widgets.empty? ? 'none' : configured_widgets.join(', ')}"
1129
+ puts
1130
+ end
1131
+
1132
+ private def add_widget(args)
1133
+ current_view = @api.current_view
1134
+ if current_view.nil?
1135
+ puts
1136
+ puts " No current view selected"
1137
+ puts
1138
+ return
1139
+ end
1140
+
1141
+ # Parse widget name from args
1142
+ widget_name = args.sub(/^widget\s+/, '').strip
1143
+ if widget_name.empty?
1144
+ puts
1145
+ puts " Usage: add widget <name>"
1146
+ puts " Example: add widget links"
1147
+ puts
1148
+ return
1149
+ end
1150
+
1151
+ # Check if widget is available
1152
+ available_widgets = @api.widgets_available
1153
+ unless available_widgets.include?(widget_name)
1154
+ puts
1155
+ puts " Widget '#{widget_name}' is not available."
1156
+ puts " Available widgets: #{available_widgets.join(', ')}"
1157
+ puts
1158
+ return
1159
+ end
1160
+
1161
+ # Check if widget is already configured
1162
+ widget_dir = current_view.dir/:widgets/widget_name
1163
+ if Dir.exist?(widget_dir)
1164
+ puts
1165
+ puts " Widget '#{widget_name}' is already configured."
1166
+ puts
1167
+ return
1168
+ end
1169
+
1170
+ # Determine container (left/right)
1171
+ container = determine_widget_container(current_view)
1172
+ unless container
1173
+ puts
1174
+ puts " Error: No left or right container found in layout."
1175
+ puts " Add a left or right container to your layout first."
1176
+ puts
1177
+ return
1178
+ end
1179
+
1180
+ # Create widget directory and list.txt
1181
+ make_dir(widget_dir)
1182
+ list_file = widget_dir/"list.txt"
1183
+ write_file(list_file, "# Add #{widget_name} items here\n")
1184
+
1185
+ puts
1186
+ puts " Added widget '#{widget_name}' to #{container} container."
1187
+ puts " Use 'config widget #{widget_name}' to configure it."
1188
+ puts
1189
+ end
1190
+
1191
+ private def config_widget(args)
1192
+ current_view = @api.current_view
1193
+ if current_view.nil?
1194
+ puts
1195
+ puts " No current view selected"
1196
+ puts
1197
+ return
1198
+ end
1199
+
1200
+ # Parse widget name from args
1201
+ widget_name = args.sub(/^widget\s+/, '').strip
1202
+ if widget_name.empty?
1203
+ puts
1204
+ puts " Usage: config widget <name>"
1205
+ puts " Example: config widget links"
1206
+ puts
1207
+ return
1208
+ end
1209
+
1210
+ # Check if widget is configured
1211
+ widget_dir = current_view.dir/:widgets/widget_name
1212
+ unless Dir.exist?(widget_dir)
1213
+ puts
1214
+ puts " Widget '#{widget_name}' is not configured."
1215
+ puts " Use 'add widget #{widget_name}' to add it first."
1216
+ puts
1217
+ return
1218
+ end
1219
+
1220
+ list_file = widget_dir/"list.txt"
1221
+ unless File.exist?(list_file)
1222
+ puts
1223
+ puts " Error: Widget list file not found: #{list_file}"
1224
+ puts
1225
+ return
1226
+ end
1227
+
1228
+ # Show widget-specific instructions
1229
+ show_widget_instructions(widget_name)
1230
+
1231
+ puts " Press Enter to edit the widget data file..."
1232
+ gets
1233
+
1234
+ @api.edit_file(list_file)
1235
+
1236
+ # Regenerate the widget after editing
1237
+ puts " Regenerating widget..."
1238
+ begin
1239
+ @api.generate_widget(widget_name)
1240
+ puts " ✅ Widget regenerated successfully!"
1241
+ rescue => e
1242
+ puts " ⚠️ Widget regeneration failed: #{e.message}"
1243
+ end
1244
+ puts
1245
+ end
1246
+
1247
+ private def config_social
1248
+ current_view = @api.current_view
1249
+ if current_view.nil?
1250
+ puts
1251
+ puts " No current view selected"
1252
+ puts
1253
+ return
1254
+ end
1255
+
1256
+ social_config_file = current_view.dir/:config/"social.txt"
1257
+ unless File.exist?(social_config_file)
1258
+ puts
1259
+ puts " Social configuration file not found: #{social_config_file}"
1260
+ puts
1261
+ return
1262
+ end
1263
+
1264
+ puts
1265
+ puts " Social Media Sharing Configuration"
1266
+ puts " ================================="
1267
+ puts
1268
+ puts " This feature adds social media meta tags to your posts for better sharing."
1269
+ puts " When enabled, posts will have proper Open Graph and Twitter Card meta tags."
1270
+ puts
1271
+ puts " Configuration:"
1272
+ puts " - List one platform per line to enable (facebook, twitter, linkedin, reddit)"
1273
+ puts " - If no platforms listed, social meta tags are disabled"
1274
+ puts " - For Reddit buttons, also configure reddit.txt file"
1275
+ puts
1276
+ puts " No Facebook App ID or Twitter username required for basic meta tags."
1277
+ puts " These are only needed if you want to add social sharing buttons later."
1278
+ puts
1279
+ puts " Press Enter to edit the configuration file..."
1280
+ gets
1281
+
1282
+ @api.edit_file(social_config_file)
1283
+
1284
+ puts
1285
+ puts " Social configuration updated."
1286
+ puts " Regenerate your view to apply changes:"
1287
+ puts " generate"
1288
+ puts
1289
+ end
1290
+
1291
+ private def config_reddit
1292
+ current_view = @api.current_view
1293
+ if current_view.nil?
1294
+ puts
1295
+ puts " No current view selected"
1296
+ puts
1297
+ return
1298
+ end
1299
+
1300
+ reddit_config_file = current_view.dir/:config/"reddit.txt"
1301
+ unless File.exist?(reddit_config_file)
1302
+ puts
1303
+ puts " Reddit configuration file not found: #{reddit_config_file}"
1304
+ puts " Creating new Reddit configuration file..."
1305
+ puts
1306
+ # Create the file with default content
1307
+ write_file(reddit_config_file, @api.repo.predef.reddit_config)
1308
+ end
1309
+
1310
+ puts
1311
+ puts " Reddit Sharing Button Configuration"
1312
+ puts " =================================="
1313
+ puts
1314
+ puts " This feature adds Reddit share buttons to your posts."
1315
+ puts " When enabled, readers can easily share your posts to Reddit."
1316
+ puts
1317
+ puts " Configuration options:"
1318
+ puts " - button: true/false - Enable or disable Reddit share button"
1319
+ puts " - subreddit: <name> - Specify a subreddit for direct posting (optional)"
1320
+ puts " - hover_text: <text> - Custom hover text (optional)"
1321
+ puts
1322
+ puts " Examples:"
1323
+ puts " button true"
1324
+ puts " subreddit RubyElixirEtc"
1325
+ puts " hover_text \"Share on RubyElixirEtc\""
1326
+ puts
1327
+ puts " Note: Reddit must also be enabled in social.txt for buttons to appear."
1328
+ puts
1329
+ puts " Press Enter to edit the configuration file..."
1330
+ gets
1331
+
1332
+ @api.edit_file(reddit_config_file)
1333
+
1334
+ puts
1335
+ puts " Reddit configuration updated."
1336
+ puts " Regenerate your view to apply changes:"
1337
+ puts " generate"
1338
+ puts
1339
+ end
1340
+
1341
+ private def generate_current_view
1342
+ current_view = @api.current_view
1343
+ if current_view.nil?
1344
+ puts
1345
+ puts " No current view selected"
1346
+ puts
1347
+ return
1348
+ end
1349
+
1350
+ puts
1351
+ puts " Regenerating view '#{current_view.name}'..."
1352
+ begin
1353
+ @api.generate_view(current_view.name)
1354
+ puts " ✅ View regenerated successfully!"
1355
+ rescue => e
1356
+ puts " ⚠️ View regeneration failed: #{e.message}"
1357
+ end
1358
+ puts
1359
+ end
1360
+
1361
+ private def determine_widget_container(view)
1362
+ # Check which containers exist in the layout
1363
+ layout_file = view.dir/:config/"layout.txt"
1364
+ return nil unless File.exist?(layout_file)
1365
+
1366
+ layout_content = read_file(layout_file)
1367
+ has_left = layout_content.include?('left')
1368
+ has_right = layout_content.include?('right')
1369
+
1370
+ if has_left && has_right
1371
+ # Both exist, prompt user
1372
+ puts
1373
+ puts " Both left and right containers found."
1374
+ puts " Which container should the widget go in?"
1375
+ puts " (l) left (r) right"
1376
+ print " Choice: "
1377
+ choice = gets&.chomp&.downcase
1378
+
1379
+ case choice
1380
+ when 'l', 'left'
1381
+ 'left'
1382
+ when 'r', 'right'
1383
+ 'right'
1384
+ else
1385
+ puts " Invalid choice. Widget not added."
1386
+ nil
1387
+ end
1388
+ elsif has_left
1389
+ 'left'
1390
+ elsif has_right
1391
+ 'right'
1392
+ else
1393
+ nil
1394
+ end
1395
+ end
1396
+
1397
+ private def show_widget_instructions(widget_name)
1398
+ case widget_name
1399
+ when 'links'
1400
+ puts
1401
+ puts " Links Widget Configuration:"
1402
+ puts " Format: <url> <title>"
1403
+ puts " Example:"
1404
+ puts " https://example.com My Website"
1405
+ puts " https://github.com GitHub"
1406
+ puts
1407
+ when 'pages'
1408
+ puts
1409
+ puts " Pages Widget Configuration:"
1410
+ puts " Format: <filename> <title>"
1411
+ puts " Example:"
1412
+ puts " about.html About Us"
1413
+ puts " contact.html Contact"
1414
+ puts
1415
+ when 'featuredposts'
1416
+ puts
1417
+ puts " Featured Posts Widget Configuration:"
1418
+ puts " Format: <post_id> <optional_title>"
1419
+ puts " Example:"
1420
+ puts " 0001 My First Post"
1421
+ puts " 0002"
1422
+ puts
1423
+ else
1424
+ puts
1425
+ puts " Widget Configuration:"
1426
+ puts " Edit the list.txt file to configure widget data."
1427
+ puts
1428
+ end
1429
+ end
1430
+
1431
+ private def list_themes
1432
+ puts
1433
+ themes = @api.themes_available
1434
+ if themes.empty?
1435
+ puts " No themes found"
1436
+ else
1437
+ puts " Available themes:"
1438
+ themes.each do |theme|
1439
+ puts " #{theme}"
1440
+ end
1441
+ end
1442
+ puts
1443
+ end
1444
+
1445
+ private def clone_theme(args)
1446
+ parts = args.split(/\s+/)
1447
+ if parts.length != 2
1448
+ puts
1449
+ puts " Usage: clone <oldtheme> <newtheme>"
1450
+ puts " Example: clone standard mytheme"
1451
+ puts
1452
+ return
1453
+ end
1454
+
1455
+ old_theme, new_theme = parts[0], parts[1]
1456
+
1457
+ begin
1458
+ # Check if old theme exists
1459
+ old_theme_path = @api.root/:themes/old_theme
1460
+ unless Dir.exist?(old_theme_path)
1461
+ puts
1462
+ puts " Theme '#{old_theme}' not found"
1463
+ puts
1464
+ return
1465
+ end
1466
+
1467
+ # Check if new theme already exists
1468
+ new_theme_path = @api.root/:themes/new_theme
1469
+ if Dir.exist?(new_theme_path)
1470
+ puts
1471
+ puts " Theme '#{new_theme}' already exists"
1472
+ puts
1473
+ return
1474
+ end
1475
+
1476
+ # Clone the theme
1477
+ require 'fileutils'
1478
+ FileUtils.cp_r(old_theme_path, new_theme_path)
1479
+
1480
+ puts
1481
+ puts " ✅ Theme '#{old_theme}' cloned to '#{new_theme}'"
1482
+ puts " Edit #{new_theme_path} to customize your theme"
1483
+ puts
1484
+ rescue => e
1485
+ puts
1486
+ puts " ❌ Failed to clone theme: #{e.message}"
1487
+ puts
1488
+ end
1489
+ end
1490
+
1491
+ end
1492
+
1493
+ ###### Main ######
1494
+
1495
+ s = ScriptoriumTUI.new
1496
+
1497
+ # Auto-discovery: check for existing repo
1498
+ got_repo = s.discover_repo
1499
+
1500
+ unless got_repo
1501
+ if s.yesno("Create new repository?")
1502
+ s.create_new_repo
1503
+ ques = "Do you want assistance in creating your first view?"
1504
+ if s.yesno(ques)
1505
+ s.wizard_first_view
1506
+ end
1507
+ end
1508
+ end
1509
+
1510
+ # Main REPL loop
1511
+ s.mainloop