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
@@ -0,0 +1,640 @@
1
+ class Scriptorium::API
2
+ include Scriptorium::Exceptions
3
+ include Scriptorium::Helpers
4
+ include Scriptorium::Contract
5
+
6
+ attr_reader :repo, :current_view
7
+
8
+ # Invariants
9
+ def define_invariants
10
+ invariant { [true, false].include?(@testing) }
11
+ invariant { @repo.nil? || @repo.is_a?(Scriptorium::Repo) }
12
+ end
13
+
14
+ def initialize(testmode: false)
15
+ assume { [true, false].include?(testmode) }
16
+
17
+ @testing = testmode
18
+ @repo = nil
19
+
20
+ define_invariants
21
+ verify { @testing == testmode }
22
+ check_invariants
23
+ end
24
+
25
+ def repo_exists?(path)
26
+ Dir.exist?(path)
27
+ end
28
+
29
+ def create_repo(path)
30
+ check_invariants
31
+ assume { path.is_a?(String) && !path.empty? }
32
+
33
+ raise RepoDirAlreadyExists if repo_exists?(path)
34
+ Scriptorium::Repo.create(path)
35
+ @repo = Scriptorium::Repo.open(path)
36
+
37
+ verify { @repo.is_a?(Scriptorium::Repo) }
38
+ check_invariants
39
+ end
40
+
41
+ def open_repo(path)
42
+ check_invariants
43
+ assume { path.is_a?(String) && !path.empty? }
44
+
45
+ @repo = Scriptorium::Repo.open(path)
46
+
47
+ verify { @repo.is_a?(Scriptorium::Repo) }
48
+ check_invariants
49
+ end
50
+
51
+ # View management
52
+ def create_view(name, title, subtitle = "", theme: "standard")
53
+ check_invariants
54
+ assume { name.is_a?(String) }
55
+ assume { title.is_a?(String) }
56
+ assume { subtitle.is_a?(String) }
57
+ assume { theme.is_a?(String) }
58
+ assume { @repo.is_a?(Scriptorium::Repo) }
59
+
60
+ @repo.create_view(name, title, subtitle, theme: theme)
61
+
62
+ verify { @repo.is_a?(Scriptorium::Repo) }
63
+ check_invariants
64
+ self
65
+ end
66
+
67
+ def current_view
68
+ @repo&.current_view
69
+ end
70
+
71
+ def root
72
+ @repo.root
73
+ end
74
+
75
+ def version
76
+ Scriptorium::VERSION
77
+ end
78
+
79
+ def apply_theme(theme)
80
+ @repo.view.apply_theme(theme)
81
+ end
82
+
83
+ # Post management
84
+ def view(name = nil)
85
+ if name.nil?
86
+ @repo.current_view
87
+ else
88
+ result = @repo.view(name)
89
+ result
90
+ end
91
+ end
92
+
93
+ def views
94
+ @repo&.views || []
95
+ end
96
+
97
+ def lookup_view(target)
98
+ @repo&.lookup_view(target)
99
+ end
100
+
101
+ def views_for(post_or_id)
102
+ post = post_or_id.is_a?(Integer) ? @repo.post(post_or_id) : post_or_id
103
+ post.views&.split(/\s+/) || []
104
+ end
105
+
106
+ # Post creation with convenience defaults
107
+ def create_post(title, body, views: nil, tags: nil, blurb: nil)
108
+ check_invariants
109
+ assume { title.is_a?(String) }
110
+ assume { body.is_a?(String) }
111
+ assume { views.nil? || views.is_a?(String) || views.is_a?(Array) }
112
+ assume { tags.nil? || tags.is_a?(String) || tags.is_a?(Array) }
113
+ assume { blurb.nil? || blurb.is_a?(String) }
114
+ assume { @repo.is_a?(Scriptorium::Repo) }
115
+
116
+ views ||= @repo.current_view&.name
117
+ raise "No view specified and no current view set" if views.nil?
118
+
119
+ post = @repo.create_post(
120
+ title: title,
121
+ body: body,
122
+ views: views,
123
+ tags: tags,
124
+ blurb: blurb
125
+ )
126
+
127
+ verify { post.is_a?(Scriptorium::Post) }
128
+ check_invariants
129
+ post
130
+ end
131
+
132
+ # Draft management
133
+ def draft(title: nil, body: nil, views: nil, tags: nil, blurb: nil)
134
+ views ||= @repo.current_view&.name
135
+ raise "No view specified and no current view set" if views.nil?
136
+
137
+ @repo.create_draft(
138
+ title: title,
139
+ body: body,
140
+ views: views,
141
+ tags: tags,
142
+ blurb: blurb
143
+ )
144
+ end
145
+
146
+ def create_draft(title: nil, body: nil, views: nil, tags: nil, blurb: nil)
147
+ views ||= @repo.current_view&.name
148
+ raise "No view specified and no current view set" if views.nil?
149
+
150
+ @repo.create_draft(
151
+ title: title,
152
+ body: body,
153
+ views: views,
154
+ tags: tags,
155
+ blurb: blurb
156
+ )
157
+ end
158
+
159
+ def finish_draft(draft_path)
160
+ @repo.finish_draft(draft_path)
161
+ end
162
+
163
+ # Generation
164
+ def generate_front_page(view = nil)
165
+ view ||= @repo.current_view&.name
166
+ raise "No view specified and no current view set" if view.nil?
167
+
168
+ @repo.generate_front_page(view)
169
+ end
170
+
171
+ def generate_post_index(view = nil)
172
+ view ||= @repo.current_view&.name
173
+ raise "No view specified and no current view set" if view.nil?
174
+
175
+ @repo.generate_post_index(view)
176
+ end
177
+
178
+ def generate_post(post_id)
179
+ # Check if the post directory exists first
180
+ post_dir = @repo.root/:posts/d4(post_id)
181
+ if Dir.exist?(post_dir)
182
+ # Post directory exists, proceed with generation
183
+ @repo.generate_post(post_id)
184
+ else
185
+ # Try to find the post through normal means
186
+ post = @repo.post(post_id)
187
+ raise "Post not found" if post.nil?
188
+
189
+ @repo.generate_post(post_id)
190
+ end
191
+ end
192
+
193
+ def lookup_view(view_name)
194
+ @repo.lookup_view(view_name)
195
+ end
196
+
197
+ # Publication system
198
+ def publish_post(num)
199
+ check_invariants
200
+ assume { num.is_a?(Integer) }
201
+ assume { @repo.is_a?(Scriptorium::Repo) }
202
+
203
+ post = @repo.publish_post(num)
204
+
205
+ verify { post.is_a?(Scriptorium::Post) }
206
+ check_invariants
207
+ post
208
+ end
209
+
210
+ def post_published?(num)
211
+ @repo.post_published?(num)
212
+ end
213
+
214
+ def get_published_posts(view = nil)
215
+ view ||= @repo.current_view&.name
216
+ @repo.get_published_posts(view)
217
+ end
218
+
219
+ # Post retrieval
220
+ def posts(view = nil)
221
+ view ||= @repo.current_view&.name
222
+ @repo.all_posts(view)
223
+ end
224
+
225
+ def post_attrs(post_id, *keys)
226
+ post = post_id.is_a?(Integer) ? @repo.post(post_id) : post_id
227
+ post.attrs(*keys)
228
+ end
229
+
230
+ def post(id)
231
+ @repo.post(id)
232
+ end
233
+
234
+ # Post management
235
+ def delete_post(id)
236
+ post = @repo.post(id)
237
+ old_path = @repo.root/:posts/post.num
238
+ new_path = @repo.root/:posts/"_#{post.num}"
239
+ FileUtils.mv(old_path, new_path)
240
+
241
+ # Set the deleted flag in metadata
242
+ post.meta["post.deleted"] = "true"
243
+ post.save_metadata
244
+ end
245
+
246
+ def undelete_post(id)
247
+ post = @repo.post(id)
248
+ old_path = @repo.root/:posts/"_#{post.num}"
249
+ new_path = @repo.root/:posts/post.num
250
+ FileUtils.mv(old_path, new_path)
251
+
252
+ # Clear the deleted flag in metadata
253
+ post.meta["post.deleted"] = "false"
254
+ post.save_metadata
255
+ end
256
+
257
+ def unlink_post(id, view = nil)
258
+ # Remove post from a specific view (or current view if none specified)
259
+ view ||= @repo.current_view&.name
260
+ raise "No view specified and no current view set" if view.nil?
261
+
262
+ post = @repo.post(id)
263
+ raise "Post not found" if post.nil?
264
+
265
+ # Get current views from metadata (split string into array)
266
+ current_views = post.views.strip.split(/\s+/)
267
+
268
+ # Remove the specified view
269
+ new_views = current_views - [view]
270
+
271
+ # Update the post with new views list
272
+ result = update_post(id, {views: new_views})
273
+
274
+ # Regenerate the post to update metadata
275
+ @repo.generate_post(id) if result
276
+
277
+ result
278
+ end
279
+
280
+ def link_post(id, view = nil)
281
+ # Add post to a specific view (or current view if none specified)
282
+ view ||= @repo.current_view&.name
283
+ raise "No view specified and no current view set" if view.nil?
284
+
285
+ post = @repo.post(id)
286
+ raise "Post not found" if post.nil?
287
+
288
+ current_views = post.views.strip.split(/\s+/)
289
+ new_views = current_views.include?(view) ? current_views : current_views + [view]
290
+ result = update_post(id, {views: new_views})
291
+
292
+ @repo.generate_post(id) if result
293
+
294
+ result
295
+ end
296
+
297
+ def post_add_view(id, view)
298
+ # Add a view to a post (view can be string or View object)
299
+ view_name = view.is_a?(String) ? view : view.name
300
+ link_post(id, view_name)
301
+ end
302
+
303
+ def post_remove_view(id, view)
304
+ # Remove a view from a post (view can be string or View object)
305
+ view_name = view.is_a?(String) ? view : view.name
306
+ unlink_post(id, view_name)
307
+ end
308
+
309
+ def post_add_tag(id, tag)
310
+ # Add a tag to a post
311
+ post = @repo.post(id)
312
+ raise "Post not found" if post.nil?
313
+
314
+ # Get current tags from metadata (split comma-separated string into array)
315
+ current_tags = post.tags.strip.split(/,\s*/)
316
+
317
+ # Add the tag (avoid duplicates)
318
+ new_tags = current_tags.include?(tag) ? current_tags : current_tags + [tag]
319
+
320
+ # Update the post with new tags list
321
+ result = update_post(id, {tags: new_tags})
322
+
323
+ # Regenerate the post to update metadata
324
+ @repo.generate_post(id) if result
325
+
326
+ result
327
+ end
328
+
329
+ def post_remove_tag(id, tag)
330
+ # Remove a tag from a post
331
+ post = @repo.post(id)
332
+ raise "Post not found" if post.nil?
333
+
334
+ # Get current tags from metadata (split comma-separated string into array)
335
+ current_tags = post.tags.strip.split(/,\s*/)
336
+
337
+ # Remove the tag
338
+ new_tags = current_tags - [tag]
339
+
340
+ # Update the post with new tags list
341
+ result = update_post(id, {tags: new_tags})
342
+
343
+ # Regenerate the post to update metadata
344
+ @repo.generate_post(id) if result
345
+
346
+ result
347
+ end
348
+
349
+ # Theme management
350
+ def themes_available
351
+ themes_dir = @repo.root/:themes
352
+ return [] unless Dir.exist?(themes_dir)
353
+ Dir.children(themes_dir).select { |d| Dir.exist?(themes_dir/d) }
354
+ end
355
+
356
+ # Widget management
357
+ def widgets_available
358
+ widgets_file = @repo.root/:config/"widgets.txt"
359
+ return [] unless File.exist?(widgets_file)
360
+ read_file(widgets_file, lines: true, chomp: true)
361
+ end
362
+
363
+ def generate_widget(widget_name)
364
+ # Generate a specific widget for the current view
365
+ # widget_name: string name of the widget (e.g., "links", "news")
366
+ # Returns true on success, raises error on failure
367
+
368
+ raise "No current view set" if @repo.current_view.nil?
369
+ raise "Widget name cannot be nil" if widget_name.nil?
370
+ raise "Widget name cannot be empty" if widget_name.to_s.strip.empty?
371
+
372
+ # Validate widget name format
373
+ unless widget_name.to_s.match?(/^[a-zA-Z0-9_]+$/)
374
+ raise "Invalid widget name: #{widget_name} (must be alphanumeric and underscore only)"
375
+ end
376
+
377
+ # Convert to class name (capitalize first letter)
378
+ widget_class_name = widget_name.to_s.capitalize
379
+
380
+ # Try to find the widget class
381
+ begin
382
+ widget_class = eval("Scriptorium::Widget::#{widget_class_name}")
383
+ rescue NameError
384
+ raise "Widget class not found: Scriptorium::Widget::#{widget_class_name}"
385
+ end
386
+
387
+ # Create widget instance and generate
388
+ widget = widget_class.new(@repo, @repo.current_view)
389
+ widget.generate
390
+
391
+ true
392
+ end
393
+
394
+ # Convenience file editing methods
395
+
396
+ def edit_layout(view = nil)
397
+ view ||= @repo.current_view&.name
398
+ raise "No view specified and no current view set" if view.nil?
399
+ edit_file("views/#{view}/layout.txt")
400
+ end
401
+
402
+ def edit_config(view = nil)
403
+ view ||= @repo.current_view&.name
404
+ raise "No view specified and no current view set" if view.nil?
405
+ edit_file("views/#{view}/config.txt")
406
+ end
407
+
408
+ def edit_widget_data(view = nil, widget)
409
+ view ||= @repo.current_view&.name
410
+ raise "No view specified and no current view set" if view.nil?
411
+ raise "Widget name cannot be nil" if widget.nil?
412
+ edit_file("views/#{view}/widgets/#{widget}/list.txt")
413
+ end
414
+
415
+ def edit_repo_config
416
+ edit_file("config/repo.txt")
417
+ end
418
+
419
+ def edit_deploy_config
420
+ edit_file("config/deploy.txt")
421
+ end
422
+
423
+ def edit_post(post_id)
424
+ post = @repo.post(post_id)
425
+ source_path = "posts/#{post.num}/source.lt3"
426
+ body_path = "posts/#{post.num}/body.html"
427
+
428
+ if File.exist?(source_path)
429
+ edit_file(source_path)
430
+ else
431
+ edit_file(body_path)
432
+ end
433
+ end
434
+
435
+ # File operations
436
+
437
+ def edit_file(path)
438
+ # Input validation
439
+ raise CannotEditFilePathNil if path.nil?
440
+ raise CannotEditFilePathEmpty if path.to_s.strip.empty?
441
+
442
+ editor = ENV['EDITOR'] || 'vim'
443
+ system!(editor, path)
444
+ end
445
+
446
+ # Post selection and search
447
+ def select_posts(&block)
448
+ # Filter posts using a block
449
+ # Returns array of posts that match the block condition
450
+ # Example: api.select_posts { |post| post.views.include?("alpha") }
451
+
452
+ all_posts = @repo.all_posts
453
+ all_posts.select(&block)
454
+ end
455
+
456
+ def search_posts(**criteria)
457
+ # Search posts using keyword criteria
458
+ # criteria: hash of {field: pattern} where field is :title, :body, :tags, :blurb
459
+ # pattern: string (exact match) or regex (pattern match)
460
+ # Example: api.search_posts(title: /Ruby/, tags: "scriptorium")
461
+
462
+ all_posts = @repo.all_posts
463
+ matching_posts = []
464
+
465
+ all_posts.each do |post|
466
+ matches_all_criteria = true
467
+
468
+ criteria.each do |field, pattern|
469
+ # Get the field value from the post
470
+ field_value = case field
471
+ when :title
472
+ post.title
473
+ when :body
474
+ # Read the body from the source file
475
+ body_file = post.dir/"body.html"
476
+ File.exist?(body_file) ? read_file(body_file) : ""
477
+ when :tags
478
+ post.tags
479
+ when :blurb
480
+ post.blurb
481
+ else
482
+ raise "Unknown search field: #{field}"
483
+ end
484
+
485
+ # Check if the pattern matches
486
+ if pattern.is_a?(Regexp)
487
+ matches_all_criteria = false unless field_value.match?(pattern)
488
+ else
489
+ matches_all_criteria = false unless field_value.include?(pattern.to_s)
490
+ end
491
+
492
+ break unless matches_all_criteria
493
+ end
494
+
495
+ matching_posts << post if matches_all_criteria
496
+ end
497
+
498
+ matching_posts
499
+ end
500
+
501
+ # Generation
502
+ def generate_view(view = nil)
503
+ view ||= @repo.current_view&.name
504
+ raise "No view specified and no current view set" if view.nil?
505
+
506
+ @repo.generate_front_page(view)
507
+ true
508
+ end
509
+
510
+
511
+
512
+ # Draft management
513
+ def drafts
514
+ drafts_dir = @repo.root/:drafts
515
+ return [] unless Dir.exist?(drafts_dir)
516
+
517
+ draft_files = Dir.children(drafts_dir).select { |f| f.end_with?('-draft.lt3') }
518
+ draft_files.map do |filename|
519
+ path = drafts_dir/filename
520
+ # Quick scan for title from the draft file
521
+ title = extract_title_from_draft(path)
522
+ { path: path, title: title }
523
+ end
524
+ end
525
+
526
+ def delete_draft(draft_path)
527
+ # Delete a draft file
528
+ # draft_path: path to the draft file (e.g., from drafts() method)
529
+
530
+ raise "Draft path cannot be nil" if draft_path.nil?
531
+ raise "Draft path cannot be empty" if draft_path.to_s.strip.empty?
532
+
533
+ # Ensure it's actually a draft file
534
+ unless draft_path.to_s.end_with?('-draft.lt3')
535
+ raise "Not a valid draft file: #{draft_path}"
536
+ end
537
+
538
+ # Ensure it exists
539
+ unless File.exist?(draft_path)
540
+ raise "Draft file not found: #{draft_path}"
541
+ end
542
+
543
+ # Delete the file
544
+ File.delete(draft_path)
545
+ true
546
+ end
547
+
548
+ private def extract_title_from_draft(draft_path)
549
+ # Quick scan for .title line in draft file
550
+ return "Untitled" unless File.exist?(draft_path)
551
+
552
+ File.foreach(draft_path) do |line|
553
+ if line.strip.start_with?('.title')
554
+ title = line.strip.split(/\s+/, 2)[1]
555
+ return title || "Untitled"
556
+ end
557
+ end
558
+ "Untitled"
559
+ end
560
+
561
+ def update_post(id, fields)
562
+ # Update fields in the post's source.lt3 file
563
+ # fields: hash of {field: value} where field is livetext dotcmd (e.g., :views, :title, :tags)
564
+ # value: string or array of strings
565
+
566
+ post = @repo.post(id)
567
+ source_file = post.dir/"source.lt3"
568
+ return false unless File.exist?(source_file)
569
+
570
+ # Read the file
571
+ lines = read_file(source_file, lines: true, chomp: false)
572
+ updated = false
573
+
574
+ # Process each field
575
+ fields.each do |field, value|
576
+ # Convert value to array
577
+ value_array = Array(value)
578
+
579
+ # Handle different field types
580
+ case field
581
+ when :tags
582
+ # Tags should be comma-separated
583
+ new_value = value_array.join(", ")
584
+ else
585
+ # Other fields (views, etc.) should be space-separated
586
+ new_value = value_array.join(' ')
587
+ end
588
+
589
+ lines.map! do |line|
590
+ if line.strip.start_with?(".#{field}")
591
+ # Preserve trailing comments
592
+ comment_match = line.match(/(\s+#.*)$/)
593
+ comment = comment_match ? comment_match[1] : ""
594
+
595
+ # Add change comment
596
+ timestamp = Time.now.strftime("%Y/%m/%d %H:%M:%S")
597
+ change_comment = " # updated #{field} #{timestamp}"
598
+
599
+ updated = true
600
+ ".#{field} #{new_value}#{comment}#{change_comment}\n"
601
+ else
602
+ line
603
+ end
604
+ end
605
+ end
606
+
607
+ return false unless updated
608
+
609
+ # Write the updated file
610
+ write_file(source_file, lines.join)
611
+ true
612
+ end
613
+
614
+ # TODO: Discuss later - complex metadata vs source conflict handling
615
+ # def update_post(id, attributes)
616
+ # # Need to decide: source of truth, update strategy, concurrency handling
617
+ # end
618
+
619
+ # TODO: Discuss later - publish draft workflow
620
+ # def publish_draft(draft_path)
621
+ # # finish_draft + generate_post combined?
622
+ # end
623
+
624
+ # Utility methods
625
+
626
+ # Convenience workflow methods
627
+
628
+ # # Delegate common repo methods
629
+ # def method_missing(method, *args, &block)
630
+ # if @repo.respond_to?(method)
631
+ # @repo.send(method, *args, &block)
632
+ # else
633
+ # super
634
+ # end
635
+ # end
636
+ #
637
+ # def respond_to_missing?(method, include_private = false)
638
+ # @repo.respond_to?(method, include_private) || super
639
+ # end
640
+ end