scriptorium 0.0.3 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.lt3 +324 -0
- data/README.md +3155 -1
- data/assets/.DS_Store +0 -0
- data/assets/README.md +44 -0
- data/assets/icons/social/reddit.png +0 -0
- data/assets/icons/social/x-logo.png +0 -0
- data/assets/icons/ui/.DS_Store +0 -0
- data/assets/icons/ui/back.png +0 -0
- data/assets/icons/ui/copy.png +0 -0
- data/assets/icons/ui/down.png +0 -0
- data/assets/icons/ui/end.png +0 -0
- data/assets/icons/ui/exit.png +0 -0
- data/assets/icons/ui/foo +10 -0
- data/assets/icons/ui/home.png +0 -0
- data/assets/icons/ui/left.png +0 -0
- data/assets/icons/ui/next.png +0 -0
- data/assets/icons/ui/right.png +0 -0
- data/assets/icons/ui/start.png +0 -0
- data/assets/icons/ui/up.png +0 -0
- data/assets/imagenotfound.jpg +0 -0
- data/assets/samples/placeholder.svg +9 -0
- data/assets/themes/standard/favicon.svg +6 -0
- data/bin/sblog +84 -5
- data/bin/scriptorium +1 -0
- data/doc/README.txt +6 -0
- data/doc/anti-amnesia/20250727-054000-scriptorium-overview.md +94 -0
- data/doc/anti-amnesia/20250727-123000-anti-amnesia-conventions.md +2 -0
- data/doc/anti-amnesia/20250727-172600-cursor-rbenv-ruby-version-mystery.md +45 -0
- data/doc/anti-amnesia/20250727-172900-ai-cognitive-assessment-capabilities.md +40 -0
- data/doc/anti-amnesia/20250728-124243-aaa-syntax-clarification.md +46 -0
- data/doc/anti-amnesia/20250729-210000-reddit-autopost-integration-complete.md +158 -0
- data/doc/anti-amnesia/20250804-190500-cognitive-loop-bug.md +35 -0
- data/doc/anti-amnesia/20250804-190700-anti-amnesia-timestamping-fix.md +27 -0
- data/doc/anti-amnesia/20250807-213025.md +116 -0
- data/doc/anti-amnesia/20250901-211714-codemirror-integration-and-web-tests.md +172 -0
- data/doc/anti-amnesia/20250902-002402-backup-restore-system.md +126 -0
- data/doc/anti-amnesia/20250907-203339-backup-metadata-implementation.md +66 -0
- data/doc/banner_svg_config.md +114 -0
- data/doc/contrib.lt3 +8 -0
- data/doc/dependencies.md +281 -0
- data/doc/hacker.lt3 +5 -0
- data/doc/imported/0001-elixir-conf-2014/metadata.txt +7 -0
- data/doc/imported/0001-elixir-conf-2014/post.html +37 -0
- data/doc/imported/0001-elixir-conf-2014/source.lt3 +22 -0
- data/doc/imported/0002-programmers-and-word-processing/metadata.txt +7 -0
- data/doc/imported/0002-programmers-and-word-processing/post.html +192 -0
- data/doc/imported/0002-programmers-and-word-processing/source.lt3 +146 -0
- data/doc/imported/0003-how-to-turn-your-brain-sideways/metadata.txt +7 -0
- data/doc/imported/0003-how-to-turn-your-brain-sideways/post.html +60 -0
- data/doc/imported/0003-how-to-turn-your-brain-sideways/source.lt3 +40 -0
- data/doc/imported/0004-upcoming-lone-star-ruby-conference/metadata.txt +7 -0
- data/doc/imported/0004-upcoming-lone-star-ruby-conference/post.html +42 -0
- data/doc/imported/0004-upcoming-lone-star-ruby-conference/source.lt3 +24 -0
- data/doc/imported/0005-elixir-conf-2015-announced/metadata.txt +7 -0
- data/doc/imported/0005-elixir-conf-2015-announced/post.html +30 -0
- data/doc/imported/0005-elixir-conf-2015-announced/source.lt3 +16 -0
- data/doc/imported/0006-ruby-for-dinosaurs/metadata.txt +7 -0
- data/doc/imported/0006-ruby-for-dinosaurs/post.html +43 -0
- data/doc/imported/0006-ruby-for-dinosaurs/source.lt3 +27 -0
- data/doc/imported/0007-phoenix-isnt-rails/metadata.txt +7 -0
- data/doc/imported/0007-phoenix-isnt-rails/post.html +116 -0
- data/doc/imported/0007-phoenix-isnt-rails/source.lt3 +87 -0
- data/doc/imported/0008-concerning-the-term-monkeypatching/metadata.txt +7 -0
- data/doc/imported/0008-concerning-the-term-monkeypatching/post.html +129 -0
- data/doc/imported/0008-concerning-the-term-monkeypatching/source.lt3 +92 -0
- data/doc/imported/0009-announcement-coming-soon/metadata.txt +7 -0
- data/doc/imported/0009-announcement-coming-soon/post.html +33 -0
- data/doc/imported/0009-announcement-coming-soon/source.lt3 +19 -0
- data/doc/imported/0010-immutable-data-ditching-the-wax-tablet/metadata.txt +7 -0
- data/doc/imported/0010-immutable-data-ditching-the-wax-tablet/post.html +175 -0
- data/doc/imported/0010-immutable-data-ditching-the-wax-tablet/source.lt3 +139 -0
- data/doc/imported/0011-computer-science-as-a-lost-art/metadata.txt +7 -0
- data/doc/imported/0011-computer-science-as-a-lost-art/post.html +139 -0
- data/doc/imported/0011-computer-science-as-a-lost-art/source.lt3 +104 -0
- data/doc/imported/0012-ruby-day-in-turin-italy/metadata.txt +7 -0
- data/doc/imported/0012-ruby-day-in-turin-italy/post.html +42 -0
- data/doc/imported/0012-ruby-day-in-turin-italy/source.lt3 +24 -0
- data/doc/imported/0013-rubyday-was-a-success/metadata.txt +7 -0
- data/doc/imported/0013-rubyday-was-a-success/post.html +44 -0
- data/doc/imported/0013-rubyday-was-a-success/source.lt3 +27 -0
- data/doc/imported/0014-working-on-the-blogging-software/metadata.txt +7 -0
- data/doc/imported/0014-working-on-the-blogging-software/post.html +63 -0
- data/doc/imported/0014-working-on-the-blogging-software/source.lt3 +41 -0
- data/doc/imported/0015-ok-its-not-really-a-lost-art/metadata.txt +7 -0
- data/doc/imported/0015-ok-its-not-really-a-lost-art/post.html +172 -0
- data/doc/imported/0015-ok-its-not-really-a-lost-art/source.lt3 +134 -0
- data/doc/imported/0016-an-in-operator-for-ruby/metadata.txt +7 -0
- data/doc/imported/0016-an-in-operator-for-ruby/post.html +155 -0
- data/doc/imported/0016-an-in-operator-for-ruby/source.lt3 +106 -0
- data/doc/imported/0017-the-forgotten-mathematician/metadata.txt +7 -0
- data/doc/imported/0017-the-forgotten-mathematician/post.html +161 -0
- data/doc/imported/0017-the-forgotten-mathematician/source.lt3 +119 -0
- data/doc/imported/0018-ruby-puns/metadata.txt +7 -0
- data/doc/imported/0018-ruby-puns/post.html +46 -0
- data/doc/imported/0018-ruby-puns/source.lt3 +28 -0
- data/doc/imported/0019-custom-exceptions-via-metaprogramming/metadata.txt +7 -0
- data/doc/imported/0019-custom-exceptions-via-metaprogramming/post.html +138 -0
- data/doc/imported/0019-custom-exceptions-via-metaprogramming/source.lt3 +101 -0
- data/doc/imported/0020-fffff/metadata.txt +7 -0
- data/doc/imported/0020-fffff/post.html +24 -0
- data/doc/imported/0020-fffff/source.lt3 +12 -0
- data/doc/imported/0021-trying-ror-yet-again/metadata.txt +7 -0
- data/doc/imported/0021-trying-ror-yet-again/post.html +26 -0
- data/doc/imported/0021-trying-ror-yet-again/source.lt3 +12 -0
- data/doc/imported/0023-doctor-sleep/metadata.txt +7 -0
- data/doc/imported/0023-doctor-sleep/post.html +63 -0
- data/doc/imported/0023-doctor-sleep/source.lt3 +44 -0
- data/doc/imported/0024-just-a-test/metadata.txt +7 -0
- data/doc/imported/0024-just-a-test/post.html +24 -0
- data/doc/imported/0024-just-a-test/source.lt3 +12 -0
- data/doc/imported/import_summary.txt +98 -0
- data/doc/livetext-informal-spec.txt +65 -0
- data/doc/myuserdoc/ch-0.lt3 +31 -0
- data/doc/myuserdoc/ch-1.lt3 +37 -0
- data/doc/myuserdoc/ch-10.lt3 +22 -0
- data/doc/myuserdoc/ch-2.lt3 +37 -0
- data/doc/myuserdoc/ch-3.lt3 +19 -0
- data/doc/myuserdoc/ch-4.lt3 +43 -0
- data/doc/myuserdoc/ch-5.lt3 +22 -0
- data/doc/myuserdoc/ch-6.lt3 +19 -0
- data/doc/myuserdoc/ch-7.lt3 +16 -0
- data/doc/myuserdoc/ch-8.lt3 +13 -0
- data/doc/myuserdoc/ch-9.lt3 +19 -0
- data/doc/myuserdoc/tweak.rb +18 -0
- data/doc/myuserdoc/userdoc-toc.txt +88 -0
- data/doc/old-posts/0001-elixir-conf-2014.lt3 +24 -0
- data/doc/old-posts/0002-programmers-and-word-processing.lt3 +150 -0
- data/doc/old-posts/0003-how-to-turn-your-brain-sideways.lt3 +43 -0
- data/doc/old-posts/0004-upcoming-lone-star-ruby-conference.lt3 +26 -0
- data/doc/old-posts/0005-elixir-conf-2015-announced.lt3 +17 -0
- data/doc/old-posts/0006-ruby-for-dinosaurs.lt3 +30 -0
- data/doc/old-posts/0007-phoenix-isnt-rails.lt3 +90 -0
- data/doc/old-posts/0008-concerning-the-term-monkeypatching.lt3 +105 -0
- data/doc/old-posts/0009-announcement-coming-soon.lt3 +20 -0
- data/doc/old-posts/0010-immutable-data-ditching-the-wax-tablet.lt3 +142 -0
- data/doc/old-posts/0011-computer-science-as-a-lost-art.lt3 +117 -0
- data/doc/old-posts/0012-ruby-day-in-turin-italy.lt3 +26 -0
- data/doc/old-posts/0013-rubyday-was-a-success.lt3 +28 -0
- data/doc/old-posts/0014-working-on-the-blogging-software.lt3 +42 -0
- data/doc/old-posts/0015-ok-its-not-really-a-lost-art.lt3 +137 -0
- data/doc/old-posts/0016-an-in-operator-for-ruby.lt3 +142 -0
- data/doc/old-posts/0017-the-forgotten-mathematician.lt3 +129 -0
- data/doc/old-posts/0018-ruby-puns.lt3 +31 -0
- data/doc/old-posts/0019-custom-exceptions-via-metaprogramming.lt3 +116 -0
- data/doc/old-posts/0021-trying-ror-yet-again.lt3 +35 -0
- data/doc/old-posts/0023-doctor-sleep.lt3 +43 -0
- data/doc/old-posts/0024-just-a-test.lt3 +12 -0
- data/doc/old-posts/0025-trying-another-post.lt3 +12 -0
- data/doc/old-repo +1 -0
- data/doc/reddit_credentials_template.json +8 -0
- data/doc/reddit_integration.md +207 -0
- data/doc/user.lt3 +35 -0
- data/doc/user_guide_section_1.md +137 -0
- data/doc/user_guide_section_10.md +515 -0
- data/doc/user_guide_section_11.md +708 -0
- data/doc/user_guide_section_2.md +233 -0
- data/doc/user_guide_section_3.md +5 -0
- data/doc/user_guide_section_4.md +221 -0
- data/doc/user_guide_section_5.md +243 -0
- data/doc/user_guide_section_6.md +147 -0
- data/doc/user_guide_section_7.md +311 -0
- data/doc/user_guide_section_8.md +224 -0
- data/doc/user_guide_section_9.md +375 -0
- data/lib/rouge/lexers/livetext.rb +74 -0
- data/lib/scriptorium/api.rb +2373 -0
- data/lib/scriptorium/banner_svg.rb +729 -0
- data/lib/scriptorium/contract.rb +34 -0
- data/lib/scriptorium/exceptions.rb +201 -1
- data/lib/scriptorium/helpers.rb +675 -0
- data/lib/scriptorium/post.rb +259 -0
- data/lib/scriptorium/reddit.rb +83 -0
- data/lib/scriptorium/repo.rb +938 -0
- data/lib/scriptorium/standard_files.rb +149 -0
- data/lib/scriptorium/support/bootstrap/css.txt +5 -0
- data/lib/scriptorium/support/bootstrap/js.txt +4 -0
- data/lib/scriptorium/support/common_js/clipboard.js +35 -0
- data/lib/scriptorium/support/common_js/content-loader.js +187 -0
- data/lib/scriptorium/support/common_js/navigation.js +52 -0
- data/lib/scriptorium/support/common_js/syntax-highlighting.js +27 -0
- data/lib/scriptorium/support/config/reddit.txt +10 -0
- data/lib/scriptorium/support/config/reddit_template.txt +17 -0
- data/lib/scriptorium/support/config/social.txt +8 -0
- data/lib/scriptorium/support/highlight/css.txt +2 -0
- data/lib/scriptorium/support/highlight/custom.css +119 -0
- data/lib/scriptorium/support/highlight/js.txt +1 -0
- data/lib/scriptorium/support/post_index/config.txt +15 -0
- data/lib/scriptorium/support/post_index/style.css +55 -0
- data/lib/scriptorium/support/templates/index_entry.lt3 +16 -0
- data/lib/scriptorium/support/templates/initial_post.lt3 +12 -0
- data/lib/scriptorium/support/templates/layout.txt +5 -0
- data/lib/scriptorium/support/templates/post.lt3 +104 -0
- data/lib/scriptorium/support/theme/footer.lt3 +2 -0
- data/lib/scriptorium/support/theme/header.lt3 +4 -0
- data/lib/scriptorium/support/theme/left.lt3 +3 -0
- data/lib/scriptorium/support/theme/main.lt3 +5 -0
- data/lib/scriptorium/support/theme/right.lt3 +3 -0
- data/lib/scriptorium/theme.rb +192 -0
- data/lib/scriptorium/version.rb +1 -1
- data/lib/scriptorium/view.rb +1021 -0
- data/lib/scriptorium/widgets/featured_posts.rb +149 -0
- data/lib/scriptorium/widgets/links.rb +112 -0
- data/lib/scriptorium/widgets/pages.rb +133 -0
- data/lib/scriptorium/widgets/widget.rb +133 -0
- data/lib/scriptorium.rb +38 -34
- data/lib/skeleton.rb +10 -1
- data/scriptorium.gemspec +17 -5
- data/test/README.md +69 -0
- data/test/WEB_INTEGRATION_README.md +196 -0
- data/test/all +83 -0
- data/test/api_demo.rb +99 -0
- data/test/assets/imagenotfound.jpg +0 -0
- data/test/assets/images/.DS_Store +0 -0
- data/test/assets/images/README.md +27 -0
- data/test/assets/images/odd_aspect.png +0 -0
- data/test/assets/images/perfect.png +0 -0
- data/test/assets/images/small.png +0 -0
- data/test/assets/images/tall.png +0 -0
- data/test/assets/images/very_tall.png +0 -0
- data/test/assets/images/very_wide.png +0 -0
- data/test/assets/images/wide.png +0 -0
- data/test/assets/testbanner.jpg +0 -0
- data/test/banner_svg/simple_helpers.rb +13 -0
- data/test/banner_svg/unit.rb +1000 -0
- data/test/config/deployment.txt +5 -0
- data/test/ed_test.rb +204 -0
- data/test/integration/cursor_banner_combinations.rb +193 -0
- data/test/integration/cursor_banner_features.rb +374 -0
- data/test/integration/integration_test.rb +326 -0
- data/test/integration/preview_flow_test.rb +94 -0
- data/test/livetext_plugin_test.rb +500 -0
- data/test/manual/asset_mgmt.rb +67 -0
- data/test/manual/banner-tests/index.html +45 -0
- data/test/manual/banner-tests/svg.txt +3 -0
- data/test/manual/banner-tests/test01.html +122 -0
- data/test/manual/banner-tests/test02.html +122 -0
- data/test/manual/banner-tests/test03.html +122 -0
- data/test/manual/banner-tests/test04.html +129 -0
- data/test/manual/banner-tests/test05.html +129 -0
- data/test/manual/banner-tests/test06.html +129 -0
- data/test/manual/banner-tests/test07.html +129 -0
- data/test/manual/banner-tests/test08.html +123 -0
- data/test/manual/banner-tests/test09.html +123 -0
- data/test/manual/banner-tests/test10.html +123 -0
- data/test/manual/banner-tests/test11.html +123 -0
- data/test/manual/banner-tests/test12.html +123 -0
- data/test/manual/banner-tests/test13.html +123 -0
- data/test/manual/banner-tests/test14.html +123 -0
- data/test/manual/banner-tests/test15.html +122 -0
- data/test/manual/banner-tests/test16.html +122 -0
- data/test/manual/banner-tests/test17.html +122 -0
- data/test/manual/banner-tests/test18.html +132 -0
- data/test/manual/banner-tests/test19.html +132 -0
- data/test/manual/banner-tests/test20.html +132 -0
- data/test/manual/banner-tests/test21.html +132 -0
- data/test/manual/banner-tests/test22.html +132 -0
- data/test/manual/banner-tests/test23.html +132 -0
- data/test/manual/banner-tests/test24.html +132 -0
- data/test/manual/banner-tests/test25.html +131 -0
- data/test/manual/banner_environment.rb +205 -0
- data/test/manual/codemirror_demo.html +773 -0
- data/test/manual/create_posts_for_web.rb +114 -0
- data/test/manual/environment.rb +67 -0
- data/test/manual/make_banner.rb +153 -0
- data/test/manual/preview_manual_test.rb +129 -0
- data/test/manual/sample_banner_config.txt +12 -0
- data/test/manual/test_advanced_widgets.rb +73 -0
- data/test/manual/test_banner_combinations.rb +120 -0
- data/test/manual/test_banner_features.rb +306 -0
- data/test/manual/test_banner_integration.rb +115 -0
- data/test/manual/test_banner_radial.rb +87 -0
- data/test/manual/test_basic_posts.rb +47 -0
- data/test/manual/test_layout_widgets.rb +40 -0
- data/test/manual/test_pagination.rb +24 -0
- data/test/manual/test_random_posts.rb +38 -0
- data/test/manual/test_syntax_highlighting.rb +167 -0
- data/test/rubytext/rubytext_comprehensive_test.rb +307 -0
- data/test/rubytext/rubytext_demo_test.rb +42 -0
- data/test/rubytext/rubytext_testing_guide.md +277 -0
- data/test/run_automated_tests.rb +45 -0
- data/test/staging/.DS_Store +0 -0
- data/test/support/preview_utils.rb +88 -0
- data/test/syntax_highlighting_test.lt3 +124 -0
- data/test/test_gem_assets.rb +48 -0
- data/test/test_helpers.rb +240 -0
- data/test/tui_editor_integration_test.rb +296 -0
- data/test/tui_integration_test.rb +883 -0
- data/test/unit/api.rb +1776 -0
- data/test/unit/asset_management.rb +219 -0
- data/test/unit/backup_test.rb +451 -0
- data/test/unit/clipboard_test.rb +60 -0
- data/test/unit/contract_test.rb +69 -0
- data/test/unit/core.rb +1211 -0
- data/test/unit/deploy_config_test.rb +248 -0
- data/test/unit/deploy_test.rb +478 -0
- data/test/unit/edit_post_test.rb +168 -0
- data/test/unit/gem_asset_management.rb +183 -0
- data/test/unit/livetext_basic.rb +57 -0
- data/test/unit/livetext_compatibility.rb +82 -0
- data/test/unit/parse_cmd_test.rb +260 -0
- data/test/unit/permalink_copy_test.rb +211 -0
- data/test/unit/post.rb +309 -0
- data/test/unit/post_index_config_test.rb +258 -0
- data/test/unit/post_state_helpers_test.rb +137 -0
- data/test/unit/read_commented_file_test.rb +278 -0
- data/test/unit/reddit_test.rb +235 -0
- data/test/unit/repo.rb +569 -0
- data/test/unit/social_test.rb +366 -0
- data/test/unit/syntax_highlighting.rb +70 -0
- data/test/unit/theme_management_test.rb +91 -0
- data/test/unit/view.rb +498 -0
- data/test/unit/widgets.rb +669 -0
- data/test/web_integration_test.rb +231 -0
- data/test/web_test_helper.rb +218 -0
- data/test/web_workflow_test.rb +527 -0
- data/test/wizard_test.rb +123 -0
- data/ui/README.md +67 -0
- data/ui/common/lib/ui_common.rb +8 -0
- data/ui/rubytext/README.md +191 -0
- data/ui/rubytext/bin/scriptorium-rubytext +402 -0
- data/ui/rubytext/lib/rubytext_ui.rb +300 -0
- data/ui/tui/bin/scriptorium +1890 -0
- data/ui/tui/test/tui_test.rb +23 -0
- data/ui/web/app/app.rb +2600 -0
- data/ui/web/app/assets/livetext_mode.js +244 -0
- data/ui/web/app/error_helpers.rb +150 -0
- data/ui/web/app/views/advanced_config.erb +196 -0
- data/ui/web/app/views/asset_management.erb +645 -0
- data/ui/web/app/views/backup_management.erb +238 -0
- data/ui/web/app/views/banner_config.erb +200 -0
- data/ui/web/app/views/config_widget.erb +232 -0
- data/ui/web/app/views/configure_view.erb +401 -0
- data/ui/web/app/views/dashboard.erb +154 -0
- data/ui/web/app/views/deploy_config.erb +149 -0
- data/ui/web/app/views/edit_pages.erb +363 -0
- data/ui/web/app/views/edit_post.erb +175 -0
- data/ui/web/app/views/edit_theme.erb +73 -0
- data/ui/web/app/views/edit_theme_file.erb +74 -0
- data/ui/web/app/views/error_page.erb +29 -0
- data/ui/web/app/views/header_config.erb +155 -0
- data/ui/web/app/views/layout_config.erb +147 -0
- data/ui/web/app/views/navbar_config.erb +411 -0
- data/ui/web/app/views/theme_management.erb +130 -0
- data/ui/web/app/views/view_dashboard.erb +779 -0
- data/ui/web/app/views/widgets.erb +249 -0
- data/ui/web/bin/scriptorium-web +164 -0
- data/ui/web/test/web_basic_test.rb +38 -0
- data/ui/web/test_navbar.txt +7 -0
- data/ui/web/tmp/timing.log +17 -0
- data/ui/web/tmp/web_server.log +0 -0
- metadata +434 -8
- data/lib/scriptorium/engine.rb +0 -22
- data/test/engine/unit.rb +0 -44
@@ -0,0 +1,2373 @@
|
|
1
|
+
|
2
|
+
require 'set'
|
3
|
+
|
4
|
+
class Scriptorium::API
|
5
|
+
include Scriptorium::Exceptions
|
6
|
+
include Scriptorium::Helpers
|
7
|
+
include Scriptorium::Contract
|
8
|
+
|
9
|
+
attr_reader :repo, :current_view
|
10
|
+
|
11
|
+
# Invariants
|
12
|
+
def define_invariants
|
13
|
+
invariant { [true, false].include?(@testing) }
|
14
|
+
invariant { @repo.nil? || @repo.is_a?(Scriptorium::Repo) }
|
15
|
+
end
|
16
|
+
|
17
|
+
def initialize(testmode: false)
|
18
|
+
msg = "testmode must be true or false, got #{testmode}"
|
19
|
+
assume(msg) { [true, false].include?(testmode) }
|
20
|
+
|
21
|
+
@testing = testmode
|
22
|
+
@repo = nil
|
23
|
+
|
24
|
+
define_invariants
|
25
|
+
verify { @testing == testmode }
|
26
|
+
check_invariants
|
27
|
+
end
|
28
|
+
|
29
|
+
def repo_exists?(path)
|
30
|
+
Dir.exist?(path)
|
31
|
+
end
|
32
|
+
|
33
|
+
def create_repo(path)
|
34
|
+
check_invariants
|
35
|
+
msg = "path must be a non-empty String, got #{path.class} (#{path.inspect})"
|
36
|
+
assume(msg) { path.is_a?(String) && !path.empty? }
|
37
|
+
|
38
|
+
raise RepoDirAlreadyExists if repo_exists?(path)
|
39
|
+
Scriptorium::Repo.create(path)
|
40
|
+
@repo = Scriptorium::Repo.open(path)
|
41
|
+
|
42
|
+
verify { @repo.is_a?(Scriptorium::Repo) }
|
43
|
+
check_invariants
|
44
|
+
end
|
45
|
+
|
46
|
+
def open_repo(path)
|
47
|
+
check_invariants
|
48
|
+
msg = "path must be a non-empty String, got #{path.class} (#{path.inspect})"
|
49
|
+
assume(msg) { path.is_a?(String) && !path.empty? }
|
50
|
+
|
51
|
+
@repo = Scriptorium::Repo.open(path)
|
52
|
+
|
53
|
+
verify { @repo.is_a?(Scriptorium::Repo) }
|
54
|
+
check_invariants
|
55
|
+
end
|
56
|
+
|
57
|
+
# View management
|
58
|
+
def create_view(name, title, subtitle = "", theme: "standard")
|
59
|
+
check_invariants
|
60
|
+
msg = "name must be a String, got #{name.class}"
|
61
|
+
assume(msg) { name.is_a?(String) }
|
62
|
+
msg = "title must be a String, got #{title.class}"
|
63
|
+
assume(msg) { title.is_a?(String) }
|
64
|
+
msg = "subtitle must be a String, got #{subtitle.class}"
|
65
|
+
assume(msg) { subtitle.is_a?(String) }
|
66
|
+
msg = "theme must be a String, got #{theme.class}"
|
67
|
+
assume(msg) { theme.is_a?(String) }
|
68
|
+
msg = "@repo must be a Scriptorium::Repo, got #{@repo.class}"
|
69
|
+
assume(msg) { @repo.is_a?(Scriptorium::Repo) }
|
70
|
+
|
71
|
+
@repo.create_view(name, title, subtitle, theme: theme)
|
72
|
+
|
73
|
+
verify { @repo.is_a?(Scriptorium::Repo) }
|
74
|
+
check_invariants
|
75
|
+
self
|
76
|
+
end
|
77
|
+
|
78
|
+
def current_view
|
79
|
+
@repo&.current_view
|
80
|
+
end
|
81
|
+
|
82
|
+
def root
|
83
|
+
@repo.root
|
84
|
+
end
|
85
|
+
|
86
|
+
def version
|
87
|
+
Scriptorium::VERSION
|
88
|
+
end
|
89
|
+
|
90
|
+
def testing
|
91
|
+
@testing
|
92
|
+
end
|
93
|
+
|
94
|
+
def apply_theme(theme)
|
95
|
+
@repo.view.apply_theme(theme)
|
96
|
+
end
|
97
|
+
|
98
|
+
# Post management
|
99
|
+
def view(name = nil)
|
100
|
+
if name.nil?
|
101
|
+
@repo.current_view
|
102
|
+
else
|
103
|
+
result = @repo.view(name)
|
104
|
+
result
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
def views
|
109
|
+
@repo&.views || []
|
110
|
+
end
|
111
|
+
|
112
|
+
def lookup_view(target)
|
113
|
+
@repo&.lookup_view(target)
|
114
|
+
end
|
115
|
+
|
116
|
+
def views_for(post_or_id)
|
117
|
+
post = post_or_id.is_a?(Integer) ? @repo.post(post_or_id) : post_or_id
|
118
|
+
post.views&.split(/\s+/) || []
|
119
|
+
end
|
120
|
+
|
121
|
+
# Post creation with convenience defaults
|
122
|
+
def create_post(title, body, views: nil, tags: nil, blurb: nil)
|
123
|
+
check_invariants
|
124
|
+
msg = "title must be a String, got #{title.class}"
|
125
|
+
assume(msg) { title.is_a?(String) }
|
126
|
+
msg = "body must be a String, got #{body.class}"
|
127
|
+
assume(msg) { body.is_a?(String) }
|
128
|
+
msg = "views must be nil, String, or Array, got #{views.class}"
|
129
|
+
assume(msg) { views.nil? || views.is_a?(String) || views.is_a?(Array) }
|
130
|
+
msg = "tags must be nil, String, or Array, got #{tags.class}"
|
131
|
+
assume(msg) { tags.nil? || tags.is_a?(String) || tags.is_a?(Array) }
|
132
|
+
msg = "blurb must be nil or String, got #{blurb.class}"
|
133
|
+
assume(msg) { blurb.nil? || blurb.is_a?(String) }
|
134
|
+
msg = "@repo must be a Scriptorium::Repo, got #{@repo.class}"
|
135
|
+
assume(msg) { @repo.is_a?(Scriptorium::Repo) }
|
136
|
+
|
137
|
+
views ||= @repo.current_view&.name
|
138
|
+
raise ViewTargetNil if views.nil?
|
139
|
+
|
140
|
+
post = @repo.create_post(
|
141
|
+
title: title,
|
142
|
+
body: body,
|
143
|
+
views: views,
|
144
|
+
tags: tags,
|
145
|
+
blurb: blurb
|
146
|
+
)
|
147
|
+
|
148
|
+
verify { post.is_a?(Scriptorium::Post) }
|
149
|
+
check_invariants
|
150
|
+
post
|
151
|
+
end
|
152
|
+
|
153
|
+
# Draft management
|
154
|
+
def draft(title: nil, body: nil, views: nil, tags: nil, blurb: nil)
|
155
|
+
views ||= @repo.current_view&.name
|
156
|
+
raise ViewTargetNil if views.nil?
|
157
|
+
|
158
|
+
@repo.create_draft(
|
159
|
+
title: title,
|
160
|
+
body: body,
|
161
|
+
views: views,
|
162
|
+
tags: tags,
|
163
|
+
blurb: blurb
|
164
|
+
)
|
165
|
+
end
|
166
|
+
|
167
|
+
def create_draft(title: nil, body: nil, views: nil, tags: nil, blurb: nil)
|
168
|
+
views ||= @repo.current_view&.name
|
169
|
+
raise ViewTargetNil if views.nil?
|
170
|
+
|
171
|
+
@repo.create_draft(
|
172
|
+
title: title,
|
173
|
+
body: body,
|
174
|
+
views: views,
|
175
|
+
tags: tags,
|
176
|
+
blurb: blurb
|
177
|
+
)
|
178
|
+
end
|
179
|
+
|
180
|
+
def create_page(view_name, page_name, title, content)
|
181
|
+
view = @repo.lookup_view(view_name)
|
182
|
+
raise ViewTargetNil if view.nil?
|
183
|
+
|
184
|
+
page_content = <<~LT3
|
185
|
+
.title #{title}
|
186
|
+
|
187
|
+
#{content}
|
188
|
+
LT3
|
189
|
+
|
190
|
+
page_file = "#{@repo.root}/views/#{view_name}/pages/#{page_name}.lt3"
|
191
|
+
write_file(page_file, page_content)
|
192
|
+
|
193
|
+
page_name
|
194
|
+
end
|
195
|
+
|
196
|
+
def finish_draft(draft_path)
|
197
|
+
@repo.finish_draft(draft_path)
|
198
|
+
end
|
199
|
+
|
200
|
+
# Generation
|
201
|
+
def generate_front_page(view = nil)
|
202
|
+
view ||= @repo.current_view&.name
|
203
|
+
raise ViewTargetNil if view.nil?
|
204
|
+
|
205
|
+
@repo.generate_front_page(view)
|
206
|
+
end
|
207
|
+
|
208
|
+
def generate_post_index(view = nil)
|
209
|
+
view ||= @repo.current_view&.name
|
210
|
+
raise ViewTargetNil if view.nil?
|
211
|
+
|
212
|
+
# Delegate to the view/repo implementation to ensure correct table layout
|
213
|
+
# and formatted dates via View#post_index_entry and format_date
|
214
|
+
@repo.generate_post_index(view)
|
215
|
+
end
|
216
|
+
|
217
|
+
# Note: post_index_entry handled by View#post_index_entry
|
218
|
+
|
219
|
+
private def substitute(post, template)
|
220
|
+
# Use the same substitution system as helpers - text % vars
|
221
|
+
vars = post.vars
|
222
|
+
template % vars
|
223
|
+
end
|
224
|
+
|
225
|
+
def generate_post(post_id)
|
226
|
+
# Check if the post directory exists first
|
227
|
+
post_dir = @repo.root/:posts/d4(post_id)
|
228
|
+
if Dir.exist?(post_dir)
|
229
|
+
# Post directory exists, proceed with generation
|
230
|
+
@repo.generate_post(post_id)
|
231
|
+
else
|
232
|
+
# Try to find the post through normal means
|
233
|
+
post = @repo.post(post_id)
|
234
|
+
raise CannotGetPost("Post with ID #{post_id} not found") if post.nil?
|
235
|
+
|
236
|
+
@repo.generate_post(post_id)
|
237
|
+
end
|
238
|
+
end
|
239
|
+
|
240
|
+
def lookup_view(view_name)
|
241
|
+
@repo.lookup_view(view_name)
|
242
|
+
end
|
243
|
+
|
244
|
+
# Publication system
|
245
|
+
def publish_post(num, view = nil)
|
246
|
+
check_invariants
|
247
|
+
msg = "num must be an Integer, got #{num.class}"
|
248
|
+
assume(msg) { num.is_a?(Integer) }
|
249
|
+
msg = "@repo must be a Scriptorium::Repo, got #{@repo.class}"
|
250
|
+
assume(msg) { @repo.is_a?(Scriptorium::Repo) }
|
251
|
+
|
252
|
+
post = @repo.publish_post(num, view)
|
253
|
+
|
254
|
+
verify { post.is_a?(Scriptorium::Post) }
|
255
|
+
check_invariants
|
256
|
+
post
|
257
|
+
end
|
258
|
+
|
259
|
+
def unpublish_post(num, view = nil)
|
260
|
+
check_invariants
|
261
|
+
msg = "num must be an Integer, got #{num.class}"
|
262
|
+
assume(msg) { num.is_a?(Integer) }
|
263
|
+
msg = "@repo must be a Scriptorium::Repo, got #{@repo.class}"
|
264
|
+
assume(msg) { @repo.is_a?(Scriptorium::Repo) }
|
265
|
+
|
266
|
+
@repo.unpublish_post(num, view)
|
267
|
+
|
268
|
+
check_invariants
|
269
|
+
end
|
270
|
+
|
271
|
+
def post_published?(num, view = nil)
|
272
|
+
@repo.post_published?(num, view)
|
273
|
+
end
|
274
|
+
|
275
|
+
# Deployment state management
|
276
|
+
def mark_post_deployed(num, view = nil)
|
277
|
+
check_invariants
|
278
|
+
msg = "num must be an Integer, got #{num.class}"
|
279
|
+
assume(msg) { num.is_a?(Integer) }
|
280
|
+
msg = "@repo must be a Scriptorium::Repo, got #{@repo.class}"
|
281
|
+
assume(msg) { @repo.is_a?(Scriptorium::Repo) }
|
282
|
+
|
283
|
+
@repo.mark_post_deployed(num, view)
|
284
|
+
|
285
|
+
check_invariants
|
286
|
+
end
|
287
|
+
|
288
|
+
def mark_post_undeployed(num, view = nil)
|
289
|
+
check_invariants
|
290
|
+
msg = "num must be an Integer, got #{num.class}"
|
291
|
+
assume(msg) { num.is_a?(Integer) }
|
292
|
+
msg = "@repo must be a Scriptorium::Repo, got #{@repo.class}"
|
293
|
+
assume(msg) { @repo.is_a?(Scriptorium::Repo) }
|
294
|
+
|
295
|
+
@repo.mark_post_undeployed(num, view)
|
296
|
+
|
297
|
+
check_invariants
|
298
|
+
end
|
299
|
+
|
300
|
+
def post_deployed?(num, view = nil)
|
301
|
+
@repo.post_deployed?(num, view)
|
302
|
+
end
|
303
|
+
|
304
|
+
def get_deployed_posts(view = nil)
|
305
|
+
view ||= @repo.current_view&.name
|
306
|
+
@repo.get_deployed_posts(view)
|
307
|
+
end
|
308
|
+
|
309
|
+
def get_post_states(view = nil)
|
310
|
+
view ||= @repo.current_view&.name
|
311
|
+
raise ViewTargetNil if view.nil?
|
312
|
+
|
313
|
+
# Get normal posts
|
314
|
+
posts = @repo.all_posts(view)
|
315
|
+
states = {}
|
316
|
+
|
317
|
+
# Add normal posts to states
|
318
|
+
posts.each do |post|
|
319
|
+
published = post_published?(post.id, view)
|
320
|
+
deployed = post_deployed?(post.id, view)
|
321
|
+
deleted = @repo.post_deleted?(post.id)
|
322
|
+
|
323
|
+
# Create concise state representation
|
324
|
+
state = ""
|
325
|
+
state += "P" if published
|
326
|
+
state += "D" if deployed
|
327
|
+
state += "X" if deleted
|
328
|
+
state = "-" if state.empty?
|
329
|
+
|
330
|
+
states[post.id] = {
|
331
|
+
id: post.id,
|
332
|
+
title: post.title,
|
333
|
+
state: state,
|
334
|
+
published: published,
|
335
|
+
deployed: deployed,
|
336
|
+
deleted: deleted
|
337
|
+
}
|
338
|
+
end
|
339
|
+
|
340
|
+
# Add deleted posts that were in this view
|
341
|
+
deleted_posts = @repo.all_posts_including_deleted(view)
|
342
|
+
deleted_posts.each do |post|
|
343
|
+
if @repo.post_deleted?(post.id)
|
344
|
+
states[post.id] = {
|
345
|
+
id: post.id,
|
346
|
+
title: post.title,
|
347
|
+
state: "X",
|
348
|
+
published: false,
|
349
|
+
deployed: false,
|
350
|
+
deleted: true
|
351
|
+
}
|
352
|
+
end
|
353
|
+
end
|
354
|
+
|
355
|
+
states
|
356
|
+
end
|
357
|
+
|
358
|
+
def delete_post(num)
|
359
|
+
@repo.delete_post(num)
|
360
|
+
end
|
361
|
+
|
362
|
+
def undelete_post(num)
|
363
|
+
@repo.undelete_post(num)
|
364
|
+
end
|
365
|
+
|
366
|
+
def post_deleted?(num)
|
367
|
+
@repo.post_deleted?(num)
|
368
|
+
end
|
369
|
+
|
370
|
+
|
371
|
+
|
372
|
+
def undeploy_post(num, view = nil)
|
373
|
+
view ||= @repo.current_view&.name
|
374
|
+
raise ViewTargetNil if view.nil?
|
375
|
+
|
376
|
+
# Check if post is actually deployed
|
377
|
+
unless post_deployed?(num, view)
|
378
|
+
puts "Post #{num} is not deployed in view '#{view}'"
|
379
|
+
return false
|
380
|
+
end
|
381
|
+
|
382
|
+
# Mark as undeployed
|
383
|
+
mark_post_undeployed(num, view)
|
384
|
+
|
385
|
+
# Regenerate the post
|
386
|
+
@repo.generate_post(num)
|
387
|
+
|
388
|
+
# Redeploy to update the server
|
389
|
+
deploy(view)
|
390
|
+
|
391
|
+
puts "Post #{num} undeployed and redeployed in view '#{view}'"
|
392
|
+
true
|
393
|
+
end
|
394
|
+
|
395
|
+
# Post retrieval
|
396
|
+
def posts(view = nil, include_deleted: false, published: false)
|
397
|
+
view ||= @repo.current_view&.name
|
398
|
+
if include_deleted
|
399
|
+
posts = @repo.all_posts_including_deleted(view)
|
400
|
+
else
|
401
|
+
posts = @repo.all_posts(view)
|
402
|
+
end
|
403
|
+
|
404
|
+
# Filter by published status if requested
|
405
|
+
if published
|
406
|
+
posts = posts.select { |post| post_published?(post.id, view) }
|
407
|
+
end
|
408
|
+
|
409
|
+
posts
|
410
|
+
end
|
411
|
+
|
412
|
+
def post_attrs(post_id, *keys)
|
413
|
+
post = post_id.is_a?(Integer) ? @repo.post(post_id) : post_id
|
414
|
+
post.attrs(*keys)
|
415
|
+
end
|
416
|
+
|
417
|
+
def post(id)
|
418
|
+
@repo.post(id)
|
419
|
+
end
|
420
|
+
|
421
|
+
# Post management
|
422
|
+
def delete_post(id)
|
423
|
+
post = @repo.post(id)
|
424
|
+
old_path = @repo.root/:posts/post.num
|
425
|
+
new_path = @repo.root/:posts/"_#{post.num}"
|
426
|
+
FileUtils.mv(old_path, new_path)
|
427
|
+
|
428
|
+
# Set the deleted flag in metadata
|
429
|
+
post.meta["post.deleted"] = "true"
|
430
|
+
post.save_metadata
|
431
|
+
end
|
432
|
+
|
433
|
+
def undelete_post(id)
|
434
|
+
post = @repo.post(id)
|
435
|
+
old_path = @repo.root/:posts/"_#{post.num}"
|
436
|
+
new_path = @repo.root/:posts/post.num
|
437
|
+
FileUtils.mv(old_path, new_path)
|
438
|
+
|
439
|
+
# Clear the deleted flag in metadata
|
440
|
+
post.meta["post.deleted"] = "false"
|
441
|
+
post.save_metadata
|
442
|
+
end
|
443
|
+
|
444
|
+
def unlink_post(id, view = nil)
|
445
|
+
# Remove post from a specific view (or current view if none specified)
|
446
|
+
view ||= @repo.current_view&.name
|
447
|
+
raise ViewTargetNil if view.nil?
|
448
|
+
|
449
|
+
post = @repo.post(id)
|
450
|
+
raise CannotGetPost("Post with ID #{id} not found") if post.nil?
|
451
|
+
|
452
|
+
# Get current views from metadata (split string into array)
|
453
|
+
current_views = post.views.strip.split(/\s+/)
|
454
|
+
|
455
|
+
# Remove the specified view
|
456
|
+
new_views = current_views - [view]
|
457
|
+
|
458
|
+
# Update the post with new views list
|
459
|
+
result = update_post(id, {views: new_views})
|
460
|
+
|
461
|
+
# Regenerate the post to update metadata
|
462
|
+
@repo.generate_post(id) if result
|
463
|
+
|
464
|
+
result
|
465
|
+
end
|
466
|
+
|
467
|
+
def link_post(id, view = nil)
|
468
|
+
# Add post to a specific view (or current view if none specified)
|
469
|
+
view ||= @repo.current_view&.name
|
470
|
+
raise ViewTargetNil if view.nil?
|
471
|
+
|
472
|
+
post = @repo.post(id)
|
473
|
+
raise CannotGetPost("Post with ID #{id} not found") if post.nil?
|
474
|
+
|
475
|
+
current_views = post.views.strip.split(/\s+/)
|
476
|
+
new_views = current_views.include?(view) ? current_views : current_views + [view]
|
477
|
+
result = update_post(id, {views: new_views})
|
478
|
+
|
479
|
+
@repo.generate_post(id) if result
|
480
|
+
|
481
|
+
result
|
482
|
+
end
|
483
|
+
|
484
|
+
def post_add_view(id, view)
|
485
|
+
# Add a view to a post (view can be string or View object)
|
486
|
+
view_name = view.is_a?(String) ? view : view.name
|
487
|
+
link_post(id, view_name)
|
488
|
+
end
|
489
|
+
|
490
|
+
def post_remove_view(id, view)
|
491
|
+
# Remove a view from a post (view can be string or View object)
|
492
|
+
view_name = view.is_a?(String) ? view : view.name
|
493
|
+
unlink_post(id, view_name)
|
494
|
+
end
|
495
|
+
|
496
|
+
def post_add_tag(id, tag)
|
497
|
+
# Add a tag to a post
|
498
|
+
post = @repo.post(id)
|
499
|
+
raise CannotGetPost("Post with ID #{id} not found") if post.nil?
|
500
|
+
|
501
|
+
# Get current tags from metadata (split comma-separated string into array)
|
502
|
+
current_tags = post.tags.strip.split(/,\s*/)
|
503
|
+
|
504
|
+
# Add the tag (avoid duplicates)
|
505
|
+
new_tags = current_tags.include?(tag) ? current_tags : current_tags + [tag]
|
506
|
+
|
507
|
+
# Update the post with new tags list
|
508
|
+
result = update_post(id, {tags: new_tags})
|
509
|
+
|
510
|
+
# Regenerate the post to update metadata
|
511
|
+
@repo.generate_post(id) if result
|
512
|
+
|
513
|
+
result
|
514
|
+
end
|
515
|
+
|
516
|
+
def post_remove_tag(id, tag)
|
517
|
+
# Remove a tag from a post
|
518
|
+
post = @repo.post(id)
|
519
|
+
raise CannotGetPost("Post with ID #{id} not found") if post.nil?
|
520
|
+
|
521
|
+
# Get current tags from metadata (split comma-separated string into array)
|
522
|
+
current_tags = post.tags.strip.split(/,\s*/)
|
523
|
+
|
524
|
+
# Remove the tag
|
525
|
+
new_tags = current_tags - [tag]
|
526
|
+
|
527
|
+
# Update the post with new tags list
|
528
|
+
result = update_post(id, {tags: new_tags})
|
529
|
+
|
530
|
+
# Regenerate the post to update metadata
|
531
|
+
@repo.generate_post(id) if result
|
532
|
+
|
533
|
+
result
|
534
|
+
end
|
535
|
+
|
536
|
+
# Theme management
|
537
|
+
def themes_available
|
538
|
+
themes = []
|
539
|
+
themes_dir = @repo.root/:themes
|
540
|
+
|
541
|
+
if Dir.exist?(themes_dir)
|
542
|
+
Dir.children(themes_dir).each do |item|
|
543
|
+
next if item == "system.txt" || item.start_with?(".")
|
544
|
+
next unless Dir.exist?(themes_dir/item)
|
545
|
+
themes << item
|
546
|
+
end
|
547
|
+
end
|
548
|
+
|
549
|
+
themes
|
550
|
+
end
|
551
|
+
|
552
|
+
def system_themes
|
553
|
+
themes = []
|
554
|
+
system_file = @repo.root/:themes/"system.txt"
|
555
|
+
|
556
|
+
if File.exist?(system_file)
|
557
|
+
themes = read_file(system_file, lines: true, chomp: true)
|
558
|
+
end
|
559
|
+
|
560
|
+
themes
|
561
|
+
end
|
562
|
+
|
563
|
+
def user_themes
|
564
|
+
themes = []
|
565
|
+
themes_dir = @repo.root/:themes
|
566
|
+
system_themes_list = system_themes
|
567
|
+
|
568
|
+
if Dir.exist?(themes_dir)
|
569
|
+
Dir.children(themes_dir).each do |item|
|
570
|
+
next if item == "system.txt" || item.start_with?(".")
|
571
|
+
next unless Dir.exist?(themes_dir/item)
|
572
|
+
next if system_themes_list.include?(item)
|
573
|
+
themes << item
|
574
|
+
end
|
575
|
+
end
|
576
|
+
|
577
|
+
themes
|
578
|
+
end
|
579
|
+
|
580
|
+
def theme_exists?(theme_name)
|
581
|
+
# Check if theme name exists in themes directory
|
582
|
+
themes = themes_available
|
583
|
+
themes.include?(theme_name)
|
584
|
+
end
|
585
|
+
|
586
|
+
def clone_theme(source_theme, new_name)
|
587
|
+
# Validate source theme exists
|
588
|
+
unless theme_exists?(source_theme)
|
589
|
+
raise ThemeNotFound(source_theme)
|
590
|
+
end
|
591
|
+
|
592
|
+
# Validate new name doesn't exist
|
593
|
+
if theme_exists?(new_name)
|
594
|
+
raise ThemeAlreadyExists(new_name)
|
595
|
+
end
|
596
|
+
|
597
|
+
# Validate new name format (alphanumeric, hyphen, underscore)
|
598
|
+
unless new_name.match?(/^[a-zA-Z0-9_-]+$/)
|
599
|
+
raise ThemeNameInvalid(new_name)
|
600
|
+
end
|
601
|
+
|
602
|
+
source_dir = @repo.root/:themes/source_theme
|
603
|
+
target_dir = @repo.root/:themes/new_name
|
604
|
+
|
605
|
+
# Copy theme directory
|
606
|
+
require 'fileutils'
|
607
|
+
FileUtils.cp_r(source_dir, target_dir)
|
608
|
+
|
609
|
+
# Cloned themes become user themes (not system themes)
|
610
|
+
# No need to modify system.txt
|
611
|
+
|
612
|
+
new_name
|
613
|
+
end
|
614
|
+
|
615
|
+
def widgets_available
|
616
|
+
widgets_file = @repo.root/:config/"widgets.txt"
|
617
|
+
return [] unless File.exist?(widgets_file)
|
618
|
+
read_file(widgets_file, lines: true, chomp: true)
|
619
|
+
end
|
620
|
+
|
621
|
+
def generate_widget(widget_name)
|
622
|
+
# Generate a specific widget for the current view
|
623
|
+
# widget_name: string name of the widget (e.g., "links", "news")
|
624
|
+
# Returns true on success, raises error on failure
|
625
|
+
|
626
|
+
raise ViewTargetNil if @repo.current_view.nil?
|
627
|
+
raise WidgetNameNil if widget_name.nil?
|
628
|
+
raise WidgetsArgEmpty if widget_name.to_s.strip.empty?
|
629
|
+
|
630
|
+
# Validate widget name format
|
631
|
+
unless widget_name.to_s.match?(/^[a-zA-Z0-9_]+$/)
|
632
|
+
raise WidgetNameInvalid(widget_name)
|
633
|
+
end
|
634
|
+
|
635
|
+
# Convert to class name (capitalize first letter)
|
636
|
+
widget_class_name = widget_name.to_s.capitalize
|
637
|
+
|
638
|
+
# Try to find the widget class
|
639
|
+
begin
|
640
|
+
widget_class = eval("Scriptorium::Widget::#{widget_class_name}")
|
641
|
+
rescue NameError
|
642
|
+
raise CannotBuildWidget("Widget class not found: Scriptorium::Widget::#{widget_class_name}")
|
643
|
+
end
|
644
|
+
|
645
|
+
# Create widget instance and generate
|
646
|
+
widget = widget_class.new(@repo, @repo.current_view)
|
647
|
+
widget.generate
|
648
|
+
|
649
|
+
true
|
650
|
+
end
|
651
|
+
|
652
|
+
# Convenience file editing methods
|
653
|
+
|
654
|
+
def edit_layout(view = nil)
|
655
|
+
view ||= @repo.current_view&.name
|
656
|
+
raise ViewTargetNil if view.nil?
|
657
|
+
edit_file("views/#{view}/layout.txt")
|
658
|
+
end
|
659
|
+
|
660
|
+
def edit_config(view = nil)
|
661
|
+
view ||= @repo.current_view&.name
|
662
|
+
raise ViewTargetNil if view.nil?
|
663
|
+
edit_file("views/#{view}/config.txt")
|
664
|
+
end
|
665
|
+
|
666
|
+
def edit_widget_data(view = nil, widget)
|
667
|
+
view ||= @repo.current_view&.name
|
668
|
+
raise ViewTargetNil if view.nil?
|
669
|
+
raise WidgetNameNil if widget.nil?
|
670
|
+
edit_file("views/#{view}/widgets/#{widget}/list.txt")
|
671
|
+
end
|
672
|
+
|
673
|
+
def edit_repo_config
|
674
|
+
edit_file("config/repo.txt")
|
675
|
+
end
|
676
|
+
|
677
|
+
def edit_deploy_config
|
678
|
+
edit_file("config/deploy.txt")
|
679
|
+
end
|
680
|
+
|
681
|
+
def edit_post(post_id, mock: false)
|
682
|
+
# Check if post is deleted first
|
683
|
+
if post_deleted?(post_id)
|
684
|
+
raise PostDeleted, "Post #{post_id} is deleted"
|
685
|
+
end
|
686
|
+
|
687
|
+
post = @repo.post(post_id)
|
688
|
+
source_path = @repo.root/"posts/#{post.num}/source.lt3"
|
689
|
+
body_path = @repo.root/"posts/#{post.num}/body.html"
|
690
|
+
|
691
|
+
# Save checksum before edit
|
692
|
+
if File.exist?(source_path)
|
693
|
+
before_checksum = Digest::MD5.file(source_path).hexdigest
|
694
|
+
|
695
|
+
if mock.is_a?(Array) && mock.include?(:checksum)
|
696
|
+
# Use mock checksum for testing
|
697
|
+
after_checksum = mock[mock.index(:checksum) + 1]
|
698
|
+
else
|
699
|
+
edit_file(source_path) unless mock
|
700
|
+
after_checksum = Digest::MD5.file(source_path).hexdigest
|
701
|
+
end
|
702
|
+
else
|
703
|
+
raise "Cannot edit post #{post_id}: source.lt3 file not found"
|
704
|
+
end
|
705
|
+
|
706
|
+
# Check if file was actually modified
|
707
|
+
if before_checksum != after_checksum
|
708
|
+
# Mark as unpublished and undeployed in all views
|
709
|
+
@repo.views.each do |view|
|
710
|
+
if post_deployed?(post_id, view.name)
|
711
|
+
mark_post_undeployed(post_id, view.name)
|
712
|
+
end
|
713
|
+
if post_published?(post_id, view.name)
|
714
|
+
unpublish_post(post_id, view.name)
|
715
|
+
end
|
716
|
+
end
|
717
|
+
|
718
|
+
# Regenerate the post
|
719
|
+
@repo.generate_post(post_id)
|
720
|
+
|
721
|
+
true # Changes were made
|
722
|
+
else
|
723
|
+
false # No changes
|
724
|
+
end
|
725
|
+
end
|
726
|
+
|
727
|
+
# File operations
|
728
|
+
|
729
|
+
def edit_file(path)
|
730
|
+
# Input validation
|
731
|
+
raise EditFilePathNil if path.nil?
|
732
|
+
raise EditFilePathEmpty if path.to_s.strip.empty?
|
733
|
+
|
734
|
+
# Try to use the TUI's editor configuration first
|
735
|
+
editor_file = @repo.root/"config/editor.txt"
|
736
|
+
editor = if File.exist?(editor_file)
|
737
|
+
read_file(editor_file).strip
|
738
|
+
else
|
739
|
+
ENV['EDITOR'] || 'vim'
|
740
|
+
end
|
741
|
+
|
742
|
+
system!(editor, path)
|
743
|
+
end
|
744
|
+
|
745
|
+
# Post selection and search
|
746
|
+
def select_posts(&block)
|
747
|
+
# Filter posts using a block
|
748
|
+
# Returns array of posts that match the block condition
|
749
|
+
# Example: api.select_posts { |post| post.views.include?("alpha") }
|
750
|
+
|
751
|
+
all_posts = @repo.all_posts
|
752
|
+
all_posts.select(&block)
|
753
|
+
end
|
754
|
+
|
755
|
+
def search_posts(**criteria)
|
756
|
+
# Search posts using keyword criteria
|
757
|
+
# criteria: hash of {field: pattern} where field is :title, :body, :tags, :blurb
|
758
|
+
# pattern: string (exact match) or regex (pattern match)
|
759
|
+
# Example: api.search_posts(title: /Ruby/, tags: "scriptorium")
|
760
|
+
|
761
|
+
all_posts = @repo.all_posts
|
762
|
+
matching_posts = []
|
763
|
+
|
764
|
+
all_posts.each do |post|
|
765
|
+
matches_all_criteria = true
|
766
|
+
|
767
|
+
criteria.each do |field, pattern|
|
768
|
+
# Get the field value from the post
|
769
|
+
field_value = case field
|
770
|
+
when :title
|
771
|
+
post.title
|
772
|
+
when :body
|
773
|
+
# Read the body from the source file
|
774
|
+
body_file = post.dir/"body.html"
|
775
|
+
File.exist?(body_file) ? read_file(body_file) : ""
|
776
|
+
when :tags
|
777
|
+
post.tags
|
778
|
+
when :blurb
|
779
|
+
post.blurb
|
780
|
+
else
|
781
|
+
raise UnknownSearchField(field)
|
782
|
+
end
|
783
|
+
|
784
|
+
# Check if the pattern matches
|
785
|
+
if pattern.is_a?(Regexp)
|
786
|
+
matches_all_criteria = false unless field_value.match?(pattern)
|
787
|
+
else
|
788
|
+
matches_all_criteria = false unless field_value.include?(pattern.to_s)
|
789
|
+
end
|
790
|
+
|
791
|
+
break unless matches_all_criteria
|
792
|
+
end
|
793
|
+
|
794
|
+
matching_posts << post if matches_all_criteria
|
795
|
+
end
|
796
|
+
|
797
|
+
matching_posts
|
798
|
+
end
|
799
|
+
|
800
|
+
# Generation
|
801
|
+
def generate_view(view = nil)
|
802
|
+
view ||= @repo.current_view&.name
|
803
|
+
raise ViewTargetNil if view.nil?
|
804
|
+
|
805
|
+
# Guard: skip regeneration if outputs are newer than inputs (simple mtime check)
|
806
|
+
begin
|
807
|
+
v = @repo.lookup_view(view)
|
808
|
+
view_dir = v.dir
|
809
|
+
output_dir = v.dir/:output
|
810
|
+
output_index = output_dir/:index.html
|
811
|
+
output_post_index = output_dir/:post_index.html
|
812
|
+
|
813
|
+
latest_input = Time.at(0)
|
814
|
+
|
815
|
+
# Posts: only count source.lt3 and post assets as inputs
|
816
|
+
posts_dir = @repo.root/:posts
|
817
|
+
if Dir.exist?(posts_dir)
|
818
|
+
Dir.glob("#{posts_dir}/[0-9][0-9][0-9][0-9]/source.lt3").each do |p|
|
819
|
+
m = File.mtime(p) rescue nil
|
820
|
+
latest_input = m if m && m > latest_input
|
821
|
+
end
|
822
|
+
Dir.glob("#{posts_dir}/[0-9][0-9][0-9][0-9]/assets/**/*").each do |p|
|
823
|
+
next unless File.file?(p)
|
824
|
+
m = File.mtime(p) rescue nil
|
825
|
+
latest_input = m if m && m > latest_input
|
826
|
+
end
|
827
|
+
end
|
828
|
+
|
829
|
+
# View inputs (exclude generated output/*)
|
830
|
+
if Dir.exist?(view_dir)
|
831
|
+
Dir.glob("#{view_dir}/**/*").each do |p|
|
832
|
+
next unless File.file?(p)
|
833
|
+
next if p.start_with?(output_dir.to_s)
|
834
|
+
m = File.mtime(p) rescue nil
|
835
|
+
latest_input = m if m && m > latest_input
|
836
|
+
end
|
837
|
+
end
|
838
|
+
|
839
|
+
# Global assets
|
840
|
+
global_assets = @repo.root/:assets
|
841
|
+
if Dir.exist?(global_assets)
|
842
|
+
Dir.glob("#{global_assets}/**/*").each do |p|
|
843
|
+
next unless File.file?(p)
|
844
|
+
m = File.mtime(p) rescue nil
|
845
|
+
latest_input = m if m && m > latest_input
|
846
|
+
end
|
847
|
+
end
|
848
|
+
|
849
|
+
# Themes (inputs that can affect templates)
|
850
|
+
themes_dir = @repo.root/:themes
|
851
|
+
if Dir.exist?(themes_dir)
|
852
|
+
Dir.glob("#{themes_dir}/**/*").each do |p|
|
853
|
+
next unless File.file?(p)
|
854
|
+
m = File.mtime(p) rescue nil
|
855
|
+
latest_input = m if m && m > latest_input
|
856
|
+
end
|
857
|
+
end
|
858
|
+
|
859
|
+
# Skip regeneration only if both primary outputs exist and are newer than inputs
|
860
|
+
if File.exist?(output_index) && File.exist?(output_post_index)
|
861
|
+
out_mtime = File.mtime(output_index) rescue nil
|
862
|
+
out2_mtime = File.mtime(output_post_index) rescue nil
|
863
|
+
if out_mtime && out2_mtime && out_mtime >= latest_input && out2_mtime >= latest_input
|
864
|
+
return true
|
865
|
+
end
|
866
|
+
end
|
867
|
+
rescue => _e
|
868
|
+
# If guard fails, fall through to full generation
|
869
|
+
end
|
870
|
+
|
871
|
+
# Copy all global assets to view assets
|
872
|
+
copy_global_assets_to_view(view)
|
873
|
+
|
874
|
+
# Copy post assets to view assets
|
875
|
+
copy_post_assets_to_view(view)
|
876
|
+
|
877
|
+
# Check for stale posts and regenerate them before view generation
|
878
|
+
regenerate_stale_posts(view)
|
879
|
+
|
880
|
+
# Copy view assets to output directory for web serving
|
881
|
+
copy_view_assets_to_output(view)
|
882
|
+
|
883
|
+
# Generate post index (with correct table structure and formatted dates)
|
884
|
+
@repo.generate_post_index(view)
|
885
|
+
|
886
|
+
@repo.generate_front_page(view)
|
887
|
+
true
|
888
|
+
end
|
889
|
+
|
890
|
+
def upload_asset(file_path, target = 'global', target_id = nil, filename: nil, **kwargs)
|
891
|
+
# Handle backward compatibility with keyword arguments
|
892
|
+
if kwargs.any?
|
893
|
+
target = kwargs[:target] || target
|
894
|
+
target_id = kwargs[:view] || target_id
|
895
|
+
end
|
896
|
+
unless File.exist?(file_path)
|
897
|
+
raise FileNotFoundError(file_path)
|
898
|
+
end
|
899
|
+
|
900
|
+
filename ||= File.basename(file_path)
|
901
|
+
|
902
|
+
# Determine target directory
|
903
|
+
target_dir = case target
|
904
|
+
when 'global'
|
905
|
+
@repo.root/"assets"
|
906
|
+
when 'library'
|
907
|
+
@repo.root/"assets"/"library"
|
908
|
+
when 'view'
|
909
|
+
target_id ||= @repo.current_view&.name
|
910
|
+
raise ViewTargetNil if target_id.nil?
|
911
|
+
@repo.root/"views"/target_id/"assets"
|
912
|
+
when 'post'
|
913
|
+
raise ArgumentError, "Post ID required for post uploads" if target_id.nil?
|
914
|
+
post_id = target_id.to_i
|
915
|
+
post_num = d4(post_id)
|
916
|
+
@repo.root/"posts"/post_num/"assets"
|
917
|
+
else
|
918
|
+
raise InvalidFormatError("target", target)
|
919
|
+
end
|
920
|
+
|
921
|
+
# Create target directory if it doesn't exist
|
922
|
+
FileUtils.mkdir_p(target_dir)
|
923
|
+
|
924
|
+
# Copy the file
|
925
|
+
target_file = target_dir/filename
|
926
|
+
FileUtils.cp(file_path, target_file)
|
927
|
+
|
928
|
+
target_file
|
929
|
+
end
|
930
|
+
|
931
|
+
def copy_global_assets_to_view(view_name)
|
932
|
+
view = @repo.lookup_view(view_name)
|
933
|
+
return unless view
|
934
|
+
|
935
|
+
view_assets_dir = view.dir/:assets
|
936
|
+
global_assets_dir = @repo.root/:assets
|
937
|
+
|
938
|
+
# Ensure view assets directory exists
|
939
|
+
FileUtils.mkdir_p(view_assets_dir) unless Dir.exist?(view_assets_dir)
|
940
|
+
|
941
|
+
# Copy all global assets (recursively) to view assets, preserving structure
|
942
|
+
if Dir.exist?(global_assets_dir)
|
943
|
+
Dir.glob("#{global_assets_dir}/**/*").each do |global_path|
|
944
|
+
next unless File.file?(global_path)
|
945
|
+
rel = Pathname.new(global_path).relative_path_from(Pathname.new(global_assets_dir))
|
946
|
+
dest = view_assets_dir/rel
|
947
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
948
|
+
FileUtils.cp(global_path, dest) unless File.exist?(dest)
|
949
|
+
end
|
950
|
+
end
|
951
|
+
end
|
952
|
+
|
953
|
+
def copy_post_assets_to_view(view_name)
|
954
|
+
view = @repo.lookup_view(view_name)
|
955
|
+
return unless view
|
956
|
+
|
957
|
+
view_assets_dir = view.dir/:assets
|
958
|
+
|
959
|
+
# Get all posts associated with this view
|
960
|
+
posts = @repo.all_posts.select { |post| post.views&.include?(view_name) }
|
961
|
+
|
962
|
+
posts.each do |post|
|
963
|
+
post_assets_dir = @repo.root/"posts"/post.num/"assets"
|
964
|
+
view_post_assets_dir = view_assets_dir/"posts"/post.num
|
965
|
+
|
966
|
+
# Skip if post has no assets
|
967
|
+
next unless Dir.exist?(post_assets_dir)
|
968
|
+
|
969
|
+
# Create view post assets directory
|
970
|
+
FileUtils.mkdir_p(view_post_assets_dir)
|
971
|
+
|
972
|
+
# Copy all post assets to view post assets directory
|
973
|
+
Dir.glob("#{post_assets_dir}/*").each do |post_asset|
|
974
|
+
next unless File.file?(post_asset)
|
975
|
+
filename = File.basename(post_asset)
|
976
|
+
view_asset_path = view_post_assets_dir/filename
|
977
|
+
|
978
|
+
# Copy if view asset doesn't exist (don't overwrite)
|
979
|
+
FileUtils.cp(post_asset, view_asset_path) unless File.exist?(view_asset_path)
|
980
|
+
end
|
981
|
+
end
|
982
|
+
end
|
983
|
+
|
984
|
+
def copy_view_assets_to_output(view_name)
|
985
|
+
view = @repo.lookup_view(view_name)
|
986
|
+
return unless view
|
987
|
+
|
988
|
+
view_assets_dir = view.dir/:assets
|
989
|
+
output_assets_dir = view.dir/:output/:assets
|
990
|
+
|
991
|
+
# Skip if view has no assets
|
992
|
+
return unless Dir.exist?(view_assets_dir)
|
993
|
+
|
994
|
+
# Create output assets directory
|
995
|
+
FileUtils.mkdir_p(output_assets_dir)
|
996
|
+
|
997
|
+
# Copy all view assets to output assets directory
|
998
|
+
Dir.glob("#{view_assets_dir}/**/*").each do |asset_path|
|
999
|
+
next unless File.file?(asset_path)
|
1000
|
+
|
1001
|
+
# Calculate relative path from view_assets_dir
|
1002
|
+
relative_path = Pathname.new(asset_path).relative_path_from(Pathname.new(view_assets_dir))
|
1003
|
+
output_asset_path = output_assets_dir/relative_path
|
1004
|
+
|
1005
|
+
# Create parent directory if needed
|
1006
|
+
FileUtils.mkdir_p(File.dirname(output_asset_path))
|
1007
|
+
|
1008
|
+
# Copy the asset
|
1009
|
+
FileUtils.cp(asset_path, output_asset_path)
|
1010
|
+
end
|
1011
|
+
end
|
1012
|
+
|
1013
|
+
# Draft management
|
1014
|
+
def drafts
|
1015
|
+
drafts_dir = @repo.root/:drafts
|
1016
|
+
return [] unless Dir.exist?(drafts_dir)
|
1017
|
+
|
1018
|
+
draft_files = Dir.children(drafts_dir).select { |f| f.end_with?('-draft.lt3') }
|
1019
|
+
draft_files.map do |filename|
|
1020
|
+
path = drafts_dir/filename
|
1021
|
+
# Quick scan for title from the draft file
|
1022
|
+
title = extract_title_from_draft(path)
|
1023
|
+
{ path: path, title: title }
|
1024
|
+
end
|
1025
|
+
end
|
1026
|
+
|
1027
|
+
def delete_draft(draft_path)
|
1028
|
+
# Delete a draft file
|
1029
|
+
# draft_path: path to the draft file (e.g., from drafts() method)
|
1030
|
+
|
1031
|
+
raise DraftPathNil if draft_path.nil?
|
1032
|
+
raise DraftPathEmpty if draft_path.to_s.strip.empty?
|
1033
|
+
|
1034
|
+
# Ensure it's actually a draft file
|
1035
|
+
unless draft_path.to_s.end_with?('-draft.lt3')
|
1036
|
+
raise DraftFileInvalid(draft_path)
|
1037
|
+
end
|
1038
|
+
|
1039
|
+
# Ensure it exists
|
1040
|
+
unless File.exist?(draft_path)
|
1041
|
+
raise DraftFileNotFound(draft_path)
|
1042
|
+
end
|
1043
|
+
|
1044
|
+
# Delete the file
|
1045
|
+
File.delete(draft_path)
|
1046
|
+
true
|
1047
|
+
end
|
1048
|
+
|
1049
|
+
private def regenerate_stale_posts(view)
|
1050
|
+
# Get all posts for this view
|
1051
|
+
posts = @repo.all_posts(view)
|
1052
|
+
|
1053
|
+
posts.each do |post|
|
1054
|
+
source_file = post.dir/"source.lt3"
|
1055
|
+
body_file = post.dir/"body.html"
|
1056
|
+
|
1057
|
+
# Skip if source file doesn't exist
|
1058
|
+
next unless File.exist?(source_file)
|
1059
|
+
|
1060
|
+
# Skip if body file doesn't exist (post needs initial generation)
|
1061
|
+
next unless File.exist?(body_file)
|
1062
|
+
|
1063
|
+
# Compare modification times
|
1064
|
+
source_mtime = File.mtime(source_file)
|
1065
|
+
body_mtime = File.mtime(body_file)
|
1066
|
+
|
1067
|
+
# If source is newer than body, regenerate the post
|
1068
|
+
if source_mtime > body_mtime
|
1069
|
+
@repo.generate_post(post.id)
|
1070
|
+
next
|
1071
|
+
end
|
1072
|
+
|
1073
|
+
# Check if any assets are newer than body file
|
1074
|
+
assets_dir = post.dir/"assets"
|
1075
|
+
if Dir.exist?(assets_dir)
|
1076
|
+
asset_files = Dir.glob("#{assets_dir}/*")
|
1077
|
+
asset_files.each do |asset_file|
|
1078
|
+
next unless File.file?(asset_file)
|
1079
|
+
if File.mtime(asset_file) > body_mtime
|
1080
|
+
@repo.generate_post(post.id)
|
1081
|
+
break
|
1082
|
+
end
|
1083
|
+
end
|
1084
|
+
end
|
1085
|
+
end
|
1086
|
+
end
|
1087
|
+
|
1088
|
+
private def extract_title_from_draft(draft_path)
|
1089
|
+
# Quick scan for .title line in draft file
|
1090
|
+
return "Untitled" unless File.exist?(draft_path)
|
1091
|
+
|
1092
|
+
File.foreach(draft_path) do |line|
|
1093
|
+
if line.strip.start_with?('.title')
|
1094
|
+
title = line.strip.split(/\s+/, 2)[1]
|
1095
|
+
return title || "Untitled"
|
1096
|
+
end
|
1097
|
+
end
|
1098
|
+
"Untitled"
|
1099
|
+
end
|
1100
|
+
|
1101
|
+
def update_post(id, fields)
|
1102
|
+
# Update fields in the post's source.lt3 file
|
1103
|
+
# fields: hash of {field: value} where field is livetext dotcmd (e.g., :views, :title, :tags)
|
1104
|
+
# value: string or array of strings
|
1105
|
+
|
1106
|
+
post = @repo.post(id)
|
1107
|
+
source_file = post.dir/"source.lt3"
|
1108
|
+
return false unless File.exist?(source_file)
|
1109
|
+
|
1110
|
+
# Read the file
|
1111
|
+
lines = read_file(source_file, lines: true, chomp: false)
|
1112
|
+
updated = false
|
1113
|
+
|
1114
|
+
# Process each field
|
1115
|
+
fields.each do |field, value|
|
1116
|
+
# Convert value to array
|
1117
|
+
value_array = Array(value)
|
1118
|
+
|
1119
|
+
# Handle different field types
|
1120
|
+
case field
|
1121
|
+
when :tags
|
1122
|
+
# Tags should be comma-separated
|
1123
|
+
new_value = value_array.join(", ")
|
1124
|
+
else
|
1125
|
+
# Other fields (views, etc.) should be space-separated
|
1126
|
+
new_value = value_array.join(' ')
|
1127
|
+
end
|
1128
|
+
|
1129
|
+
lines.map! do |line|
|
1130
|
+
if line.strip.start_with?(".#{field}")
|
1131
|
+
# Preserve trailing comments
|
1132
|
+
comment_match = line.match(/(\s+#.*)$/)
|
1133
|
+
comment = comment_match ? comment_match[1] : ""
|
1134
|
+
|
1135
|
+
# Add change comment
|
1136
|
+
timestamp = Time.now.strftime("%Y/%m/%d %H:%M:%S")
|
1137
|
+
change_comment = " # updated #{field} #{timestamp}"
|
1138
|
+
|
1139
|
+
updated = true
|
1140
|
+
".#{field} #{new_value}#{comment}#{change_comment}\n"
|
1141
|
+
else
|
1142
|
+
line
|
1143
|
+
end
|
1144
|
+
end
|
1145
|
+
end
|
1146
|
+
|
1147
|
+
return false unless updated
|
1148
|
+
|
1149
|
+
# Write the updated file
|
1150
|
+
write_file(source_file, lines.join)
|
1151
|
+
true
|
1152
|
+
end
|
1153
|
+
|
1154
|
+
# TODO: Discuss later - complex metadata vs source conflict handling
|
1155
|
+
# def update_post(id, attributes)
|
1156
|
+
# # Need to decide: source of truth, update strategy, concurrency handling
|
1157
|
+
# end
|
1158
|
+
|
1159
|
+
# TODO: Discuss later - publish draft workflow
|
1160
|
+
# def publish_draft(draft_path)
|
1161
|
+
# # finish_draft + generate_post combined?
|
1162
|
+
# end
|
1163
|
+
|
1164
|
+
# Asset management methods
|
1165
|
+
|
1166
|
+
def list_assets(target = 'global', target_id = nil, include_gem: true, **kwargs)
|
1167
|
+
# Handle backward compatibility with keyword arguments
|
1168
|
+
if kwargs.any?
|
1169
|
+
target = kwargs[:target] || target
|
1170
|
+
target_id = kwargs[:view] || target_id
|
1171
|
+
end
|
1172
|
+
assets = []
|
1173
|
+
|
1174
|
+
case target
|
1175
|
+
when 'view'
|
1176
|
+
target_id ||= @repo.current_view&.name
|
1177
|
+
raise ViewTargetNil if target_id.nil?
|
1178
|
+
assets_dir = @repo.root/"views"/target_id/"assets"
|
1179
|
+
if Dir.exist?(assets_dir)
|
1180
|
+
Dir.glob(assets_dir/"*").each do |file|
|
1181
|
+
next unless File.file?(file)
|
1182
|
+
assets << build_asset_info(file)
|
1183
|
+
end
|
1184
|
+
end
|
1185
|
+
when 'post'
|
1186
|
+
raise ArgumentError, "Post ID required for post assets" if target_id.nil?
|
1187
|
+
post_id = target_id.to_i
|
1188
|
+
post_num = d4(post_id)
|
1189
|
+
assets_dir = @repo.root/"posts"/post_num/"assets"
|
1190
|
+
if Dir.exist?(assets_dir)
|
1191
|
+
Dir.glob(assets_dir/"*").each do |file|
|
1192
|
+
next unless File.file?(file)
|
1193
|
+
assets << build_asset_info(file)
|
1194
|
+
end
|
1195
|
+
end
|
1196
|
+
when 'global'
|
1197
|
+
assets_dir = @repo.root/"assets"
|
1198
|
+
if Dir.exist?(assets_dir)
|
1199
|
+
Dir.glob(assets_dir/"*").each do |file|
|
1200
|
+
next unless File.file?(file)
|
1201
|
+
assets << build_asset_info(file)
|
1202
|
+
end
|
1203
|
+
end
|
1204
|
+
when 'library'
|
1205
|
+
assets_dir = @repo.root/"assets"/"library"
|
1206
|
+
if Dir.exist?(assets_dir)
|
1207
|
+
Dir.glob(assets_dir/"*").each do |file|
|
1208
|
+
next unless File.file?(file)
|
1209
|
+
assets << build_asset_info(file)
|
1210
|
+
end
|
1211
|
+
end
|
1212
|
+
when 'gem'
|
1213
|
+
if include_gem
|
1214
|
+
gem_spec = Gem.loaded_specs['scriptorium']
|
1215
|
+
if gem_spec
|
1216
|
+
gem_assets_dir = "#{gem_spec.full_gem_path}/assets"
|
1217
|
+
if Dir.exist?(gem_assets_dir)
|
1218
|
+
Dir.glob("#{gem_assets_dir}/**/*").each do |file|
|
1219
|
+
next unless File.file?(file)
|
1220
|
+
relative_path = file.sub("#{gem_assets_dir}/", "")
|
1221
|
+
assets << build_asset_info(file, relative_path)
|
1222
|
+
end
|
1223
|
+
end
|
1224
|
+
end
|
1225
|
+
end
|
1226
|
+
else
|
1227
|
+
raise InvalidFormatError("target", target)
|
1228
|
+
end
|
1229
|
+
|
1230
|
+
assets.sort_by { |asset| asset[:filename] }
|
1231
|
+
end
|
1232
|
+
|
1233
|
+
def get_asset_info(filename, target: 'global', view: nil, post: nil, include_gem: true)
|
1234
|
+
view ||= @repo.current_view&.name
|
1235
|
+
raise ViewTargetNil if target == 'view' && view.nil?
|
1236
|
+
raise ArgumentError, "Post ID required for post assets" if target == 'post' && post.nil?
|
1237
|
+
|
1238
|
+
case target
|
1239
|
+
when 'view'
|
1240
|
+
asset_path = @repo.root/"views"/view/"assets"/filename
|
1241
|
+
return build_asset_info(asset_path) if File.exist?(asset_path)
|
1242
|
+
when 'post'
|
1243
|
+
post_id = post.to_i
|
1244
|
+
post_num = d4(post_id)
|
1245
|
+
asset_path = @repo.root/"posts"/post_num/"assets"/filename
|
1246
|
+
return build_asset_info(asset_path) if File.exist?(asset_path)
|
1247
|
+
when 'global'
|
1248
|
+
asset_path = @repo.root/"assets"/filename
|
1249
|
+
return build_asset_info(asset_path) if File.exist?(asset_path)
|
1250
|
+
when 'library'
|
1251
|
+
asset_path = @repo.root/"assets"/"library"/filename
|
1252
|
+
return build_asset_info(asset_path) if File.exist?(asset_path)
|
1253
|
+
when 'gem'
|
1254
|
+
if include_gem
|
1255
|
+
gem_spec = Gem.loaded_specs['scriptorium']
|
1256
|
+
if gem_spec
|
1257
|
+
gem_asset_path = "#{gem_spec.full_gem_path}/assets/#{filename}"
|
1258
|
+
return build_asset_info(gem_asset_path, filename) if File.exist?(gem_asset_path)
|
1259
|
+
end
|
1260
|
+
end
|
1261
|
+
else
|
1262
|
+
raise InvalidFormatError("target", target)
|
1263
|
+
end
|
1264
|
+
|
1265
|
+
nil
|
1266
|
+
end
|
1267
|
+
|
1268
|
+
def asset_exists?(filename, target: 'global', view: nil, include_gem: true)
|
1269
|
+
!get_asset_info(filename, target: target, view: view, include_gem: include_gem).nil?
|
1270
|
+
end
|
1271
|
+
|
1272
|
+
def copy_asset(filename, from = 'global', to = 'view', from_id = nil, to_id = nil, **kwargs)
|
1273
|
+
# Handle backward compatibility with keyword arguments
|
1274
|
+
if kwargs.any?
|
1275
|
+
from = kwargs[:from] || from
|
1276
|
+
to = kwargs[:to] || to
|
1277
|
+
from_id = kwargs[:view] || from_id
|
1278
|
+
to_id = kwargs[:view] || to_id if to == 'view'
|
1279
|
+
end
|
1280
|
+
# Determine source path
|
1281
|
+
source_path = case from
|
1282
|
+
when 'gem'
|
1283
|
+
gem_spec = Gem.loaded_specs['scriptorium']
|
1284
|
+
if gem_spec
|
1285
|
+
"#{gem_spec.full_gem_path}/assets/#{filename}"
|
1286
|
+
else
|
1287
|
+
# Development environment fallback
|
1288
|
+
File.expand_path("assets/#{filename}")
|
1289
|
+
end
|
1290
|
+
when 'global'
|
1291
|
+
@repo.root/"assets"/filename
|
1292
|
+
when 'library'
|
1293
|
+
@repo.root/"assets"/"library"/filename
|
1294
|
+
when 'view'
|
1295
|
+
from_id ||= @repo.current_view&.name
|
1296
|
+
raise ViewTargetNil if from_id.nil?
|
1297
|
+
@repo.root/"views"/from_id/"assets"/filename
|
1298
|
+
when 'post'
|
1299
|
+
raise ArgumentError, "Post ID required for post assets" if from_id.nil?
|
1300
|
+
post_id = from_id.to_i
|
1301
|
+
post_num = d4(post_id)
|
1302
|
+
@repo.root/"posts"/post_num/"assets"/filename
|
1303
|
+
else
|
1304
|
+
raise InvalidFormatError("source", from)
|
1305
|
+
end
|
1306
|
+
|
1307
|
+
# Determine target path
|
1308
|
+
target_path = case to
|
1309
|
+
when 'global'
|
1310
|
+
@repo.root/"assets"/filename
|
1311
|
+
when 'library'
|
1312
|
+
@repo.root/"assets"/"library"/filename
|
1313
|
+
when 'view'
|
1314
|
+
to_id ||= @repo.current_view&.name
|
1315
|
+
raise ViewTargetNil if to_id.nil?
|
1316
|
+
@repo.root/"views"/to_id/"assets"/filename
|
1317
|
+
when 'post'
|
1318
|
+
raise ArgumentError, "Post ID required for post assets" if to_id.nil?
|
1319
|
+
post_id = to_id.to_i
|
1320
|
+
post_num = d4(post_id)
|
1321
|
+
@repo.root/"posts"/post_num/"assets"/filename
|
1322
|
+
else
|
1323
|
+
raise InvalidFormatError("target", to)
|
1324
|
+
end
|
1325
|
+
|
1326
|
+
# Validate source exists
|
1327
|
+
unless File.exist?(source_path)
|
1328
|
+
raise FileNotFoundError(source_path)
|
1329
|
+
end
|
1330
|
+
|
1331
|
+
# Create target directory and copy
|
1332
|
+
FileUtils.mkdir_p(File.dirname(target_path))
|
1333
|
+
FileUtils.cp(source_path, target_path)
|
1334
|
+
|
1335
|
+
target_path
|
1336
|
+
end
|
1337
|
+
|
1338
|
+
|
1339
|
+
def delete_asset(filename, target = 'global', target_id = nil, **kwargs)
|
1340
|
+
# Handle backward compatibility with keyword arguments
|
1341
|
+
if kwargs.any?
|
1342
|
+
target = kwargs[:target] || target
|
1343
|
+
target_id = kwargs[:view] || target_id
|
1344
|
+
end
|
1345
|
+
# Determine target file
|
1346
|
+
target_file = case target
|
1347
|
+
when 'global'
|
1348
|
+
@repo.root/"assets"/filename
|
1349
|
+
when 'library'
|
1350
|
+
@repo.root/"assets"/"library"/filename
|
1351
|
+
when 'view'
|
1352
|
+
target_id ||= @repo.current_view&.name
|
1353
|
+
raise ViewTargetNil if target_id.nil?
|
1354
|
+
@repo.root/"views"/target_id/"assets"/filename
|
1355
|
+
when 'post'
|
1356
|
+
raise ArgumentError, "Post ID required for post assets" if target_id.nil?
|
1357
|
+
post_id = target_id.to_i
|
1358
|
+
post_num = d4(post_id)
|
1359
|
+
@repo.root/"posts"/post_num/"assets"/filename
|
1360
|
+
else
|
1361
|
+
raise InvalidFormatError("target", target)
|
1362
|
+
end
|
1363
|
+
|
1364
|
+
unless File.exist?(target_file)
|
1365
|
+
raise FileNotFoundError(target_file)
|
1366
|
+
end
|
1367
|
+
|
1368
|
+
# Delete the file
|
1369
|
+
File.delete(target_file)
|
1370
|
+
true
|
1371
|
+
end
|
1372
|
+
|
1373
|
+
def get_asset_path(filename, target: 'global', view: nil, post: nil, include_gem: true)
|
1374
|
+
view ||= @repo.current_view&.name
|
1375
|
+
raise ViewTargetNil if target == 'view' && view.nil?
|
1376
|
+
raise ArgumentError, "Post ID required for post assets" if target == 'post' && post.nil?
|
1377
|
+
|
1378
|
+
case target
|
1379
|
+
when 'view'
|
1380
|
+
asset_path = @repo.root/"views"/view/"assets"/filename
|
1381
|
+
return asset_path.to_s if File.exist?(asset_path)
|
1382
|
+
when 'post'
|
1383
|
+
post_id = post.to_i
|
1384
|
+
post_num = d4(post_id)
|
1385
|
+
asset_path = @repo.root/"posts"/post_num/"assets"/filename
|
1386
|
+
return asset_path.to_s if File.exist?(asset_path)
|
1387
|
+
when 'global'
|
1388
|
+
asset_path = @repo.root/"assets"/filename
|
1389
|
+
return asset_path.to_s if File.exist?(asset_path)
|
1390
|
+
when 'library'
|
1391
|
+
asset_path = @repo.root/"assets"/"library"/filename
|
1392
|
+
return asset_path.to_s if File.exist?(asset_path)
|
1393
|
+
when 'gem'
|
1394
|
+
if include_gem
|
1395
|
+
gem_spec = Gem.loaded_specs['scriptorium']
|
1396
|
+
if gem_spec
|
1397
|
+
gem_asset_path = "#{gem_spec.full_gem_path}/assets/#{filename}"
|
1398
|
+
return gem_asset_path if File.exist?(gem_asset_path)
|
1399
|
+
end
|
1400
|
+
end
|
1401
|
+
else
|
1402
|
+
raise InvalidFormatError("target", target)
|
1403
|
+
end
|
1404
|
+
|
1405
|
+
nil
|
1406
|
+
end
|
1407
|
+
|
1408
|
+
def get_image_dimensions(file_path)
|
1409
|
+
return nil unless File.exist?(file_path)
|
1410
|
+
|
1411
|
+
# Check if it's an image file
|
1412
|
+
image_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp', '.svg']
|
1413
|
+
return nil unless image_extensions.any? { |ext| file_path.downcase.end_with?(ext) }
|
1414
|
+
|
1415
|
+
# Check if FastImage is available
|
1416
|
+
return nil unless defined?(FastImage)
|
1417
|
+
|
1418
|
+
dimensions = FastImage.size(file_path)
|
1419
|
+
return dimensions ? "#{dimensions[0]}×#{dimensions[1]}" : nil
|
1420
|
+
rescue => e
|
1421
|
+
# If FastImage fails, return nil
|
1422
|
+
return nil
|
1423
|
+
end
|
1424
|
+
|
1425
|
+
def get_asset_dimensions(filename, target: 'global', view: nil, include_gem: true)
|
1426
|
+
asset_info = get_asset_info(filename, target: target, view: view, include_gem: include_gem)
|
1427
|
+
asset_info&.dig(:dimensions)
|
1428
|
+
end
|
1429
|
+
|
1430
|
+
def get_asset_size(filename, target: 'global', view: nil, include_gem: true)
|
1431
|
+
asset_info = get_asset_info(filename, target: target, view: view, include_gem: include_gem)
|
1432
|
+
asset_info&.dig(:size)
|
1433
|
+
end
|
1434
|
+
|
1435
|
+
def get_asset_type(filename)
|
1436
|
+
return nil if filename.nil?
|
1437
|
+
|
1438
|
+
ext = File.extname(filename).downcase
|
1439
|
+
case ext
|
1440
|
+
when '.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp', '.svg'
|
1441
|
+
'image'
|
1442
|
+
when '.pdf', '.doc', '.docx', '.txt', '.md'
|
1443
|
+
'document'
|
1444
|
+
when '.mp4', '.avi', '.mov', '.wmv'
|
1445
|
+
'video'
|
1446
|
+
when '.mp3', '.wav', '.flac'
|
1447
|
+
'audio'
|
1448
|
+
else
|
1449
|
+
'other'
|
1450
|
+
end
|
1451
|
+
end
|
1452
|
+
|
1453
|
+
def bulk_copy_assets(filenames, from: 'global', to: 'view', view: nil)
|
1454
|
+
view ||= @repo.current_view&.name
|
1455
|
+
raise ViewTargetNil if to == 'view' && view.nil?
|
1456
|
+
|
1457
|
+
results = []
|
1458
|
+
filenames.each do |filename|
|
1459
|
+
begin
|
1460
|
+
target_path = copy_asset(filename, from: from, to: to, view: view)
|
1461
|
+
results << { filename: filename, success: true, target: target_path }
|
1462
|
+
rescue => e
|
1463
|
+
results << { filename: filename, success: false, error: e.message }
|
1464
|
+
end
|
1465
|
+
end
|
1466
|
+
|
1467
|
+
results
|
1468
|
+
end
|
1469
|
+
|
1470
|
+
private def build_asset_info(file_path, relative_path = nil)
|
1471
|
+
filename = relative_path || File.basename(file_path)
|
1472
|
+
size = File.size(file_path)
|
1473
|
+
dimensions = get_image_dimensions(file_path) if get_asset_type(filename) == 'image'
|
1474
|
+
|
1475
|
+
{
|
1476
|
+
filename: filename,
|
1477
|
+
size: size,
|
1478
|
+
path: file_path.to_s,
|
1479
|
+
dimensions: dimensions,
|
1480
|
+
type: get_asset_type(filename)
|
1481
|
+
}
|
1482
|
+
end
|
1483
|
+
|
1484
|
+
# Deployment methods
|
1485
|
+
|
1486
|
+
def can_deploy?(view = nil)
|
1487
|
+
view ||= @repo.current_view&.name
|
1488
|
+
raise ViewTargetNil if view.nil?
|
1489
|
+
# Check deployment status
|
1490
|
+
status_file = @repo.root/"views"/view/"config"/"status.txt"
|
1491
|
+
return false unless File.exist?(status_file)
|
1492
|
+
status_content = read_commented_file(status_file)
|
1493
|
+
deploy_status = status_content.any? { |line| line.start_with?('deploy ') && line.split(/\s+/, 2)[1] == 'y' }
|
1494
|
+
return false unless deploy_status
|
1495
|
+
# Check if deploy.txt exists and has valid content
|
1496
|
+
deploy_file = @repo.root/"views"/view/"config"/"deploy.txt"
|
1497
|
+
return false unless File.exist?(deploy_file)
|
1498
|
+
# Basic validation of deploy.txt content
|
1499
|
+
deploy_content = read_file(deploy_file)
|
1500
|
+
required_fields = ['user', 'server', 'docroot', 'path']
|
1501
|
+
return false unless required_fields.all? { |field| deploy_content.include?(field) }
|
1502
|
+
# Parse deploy config to get server and user for SSH test
|
1503
|
+
deploy_config = parse_commented_file(deploy_file)
|
1504
|
+
# Check SSH connectivity
|
1505
|
+
server, user = deploy_config[:server], deploy_config[:user]
|
1506
|
+
ok = ssh_keys_configured?(server, user)
|
1507
|
+
return false if !ok
|
1508
|
+
true
|
1509
|
+
end
|
1510
|
+
|
1511
|
+
private def ssh_keys_configured?(server, user)
|
1512
|
+
# Try to run a simple command via SSH
|
1513
|
+
cmd = "ssh -o ConnectTimeout=5 -o BatchMode=yes #{user}@#{server} 'echo' >/dev/null 2>&1"
|
1514
|
+
result = system(cmd)
|
1515
|
+
result && $?.exitstatus == 0
|
1516
|
+
end
|
1517
|
+
|
1518
|
+
def deploy(view = nil, dry_run: false)
|
1519
|
+
view ||= @repo.current_view&.name
|
1520
|
+
raise ViewTargetNil if view.nil?
|
1521
|
+
raise DeploymentNotReady(view) unless can_deploy?(view)
|
1522
|
+
|
1523
|
+
# Get published posts that are not yet deployed
|
1524
|
+
published_posts = posts(view, published: true)
|
1525
|
+
undeployed_posts = published_posts.select { |post| !post_deployed?(post.id, view) }
|
1526
|
+
|
1527
|
+
# Always deploy the entire output directory, regardless of post status
|
1528
|
+
|
1529
|
+
# Read deployment configuration
|
1530
|
+
deploy_file = @repo.root/"views"/view/"config"/"deploy.txt"
|
1531
|
+
deploy_config = parse_commented_file(deploy_file)
|
1532
|
+
|
1533
|
+
# Validate required fields
|
1534
|
+
required_fields = [:user, :server, :docroot, :path]
|
1535
|
+
missing_fields = required_fields - deploy_config.keys
|
1536
|
+
missing = missing_fields.join(', ')
|
1537
|
+
raise DeploymentFieldsMissing(missing) unless missing.empty?
|
1538
|
+
|
1539
|
+
# Construct paths
|
1540
|
+
output_dir = @repo.root/"views"/view/"output"
|
1541
|
+
remote_path = "#{deploy_config[:user]}@#{deploy_config[:server]}:#{deploy_config[:docroot]}/#{deploy_config[:path]}"
|
1542
|
+
|
1543
|
+
# Build rsync command
|
1544
|
+
cmd = "rsync -r -z -l #{output_dir}/ #{remote_path}/"
|
1545
|
+
|
1546
|
+
if dry_run
|
1547
|
+
puts "DRY RUN: Would execute: #{cmd}"
|
1548
|
+
puts "Output directory: #{output_dir}"
|
1549
|
+
puts "Remote path: #{remote_path}"
|
1550
|
+
puts "Deployment config: #{deploy_config}"
|
1551
|
+
puts "Posts to deploy: #{undeployed_posts.map(&:id).join(', ')}"
|
1552
|
+
return true
|
1553
|
+
end
|
1554
|
+
|
1555
|
+
# Log deployment details to /tmp
|
1556
|
+
log_file = "/tmp/deployment.log"
|
1557
|
+
File.open(log_file, 'a') do |f|
|
1558
|
+
f.puts "=== DEPLOYMENT DEBUG #{Time.now} ==="
|
1559
|
+
f.puts " Source directory: #{output_dir}"
|
1560
|
+
f.puts " Remote path: #{remote_path}"
|
1561
|
+
f.puts " Rsync command: #{cmd}"
|
1562
|
+
f.puts " Source directory exists: #{Dir.exist?(output_dir)}"
|
1563
|
+
f.puts " Source files: #{Dir.children(output_dir).join(', ')}"
|
1564
|
+
f.puts " Current working directory: #{Dir.pwd}"
|
1565
|
+
f.puts " Repo root: #{@repo.root}"
|
1566
|
+
end
|
1567
|
+
|
1568
|
+
# Execute deployment
|
1569
|
+
result = system(cmd)
|
1570
|
+
|
1571
|
+
# Log rsync result
|
1572
|
+
File.open("/tmp/deployment.log", 'a') do |f|
|
1573
|
+
f.puts " Rsync result: #{result}"
|
1574
|
+
f.puts " Exit status: #{$?.exitstatus}"
|
1575
|
+
f.puts " Exit success: #{$?.success?}"
|
1576
|
+
end
|
1577
|
+
|
1578
|
+
if result
|
1579
|
+
# Mark successfully deployed posts as deployed
|
1580
|
+
undeployed_posts.each do |post|
|
1581
|
+
mark_post_deployed(post.id, view)
|
1582
|
+
end
|
1583
|
+
|
1584
|
+
true
|
1585
|
+
else
|
1586
|
+
raise DeploymentFailed($?.exitstatus)
|
1587
|
+
end
|
1588
|
+
end
|
1589
|
+
|
1590
|
+
# Parse deployment configuration file
|
1591
|
+
def parse_deploy_config(config_content)
|
1592
|
+
lines = config_content.strip.split("\n")
|
1593
|
+
config = {}
|
1594
|
+
|
1595
|
+
# Parse space-separated key-value format
|
1596
|
+
lines.each do |line|
|
1597
|
+
line = line.strip
|
1598
|
+
next if line.empty? || line.start_with?('#')
|
1599
|
+
|
1600
|
+
if line.match(/^(\w+)\s+(.+)$/)
|
1601
|
+
key = $1.strip
|
1602
|
+
value = $2.strip
|
1603
|
+
config[key] = value
|
1604
|
+
end
|
1605
|
+
end
|
1606
|
+
|
1607
|
+
# Return the config hash (or empty hash if no valid entries)
|
1608
|
+
config
|
1609
|
+
end
|
1610
|
+
|
1611
|
+
# Build rsync destination from deployment config
|
1612
|
+
def build_rsync_destination(config)
|
1613
|
+
if config['user'] && config['server'] && config['path']
|
1614
|
+
return "#{config['user']}@#{config['server']}:#{config['path']}"
|
1615
|
+
end
|
1616
|
+
nil
|
1617
|
+
end
|
1618
|
+
|
1619
|
+
# Validate rsync destination format
|
1620
|
+
def validate_rsync_destination(destination)
|
1621
|
+
destination =~ /^[^@]+@[^:]+:.+/
|
1622
|
+
end
|
1623
|
+
|
1624
|
+
# Execute deployment rsync with validation
|
1625
|
+
def execute_deploy_rsync(source_dir, destination)
|
1626
|
+
# Validate destination format
|
1627
|
+
unless validate_rsync_destination(destination)
|
1628
|
+
puts " ❌ Invalid destination format: #{destination}"
|
1629
|
+
puts " Expected format: user@server:path"
|
1630
|
+
return false
|
1631
|
+
end
|
1632
|
+
|
1633
|
+
# Log the rsync command
|
1634
|
+
cmd = "rsync -r -z -l #{source_dir}/ #{destination}/"
|
1635
|
+
puts " Executing: #{cmd}"
|
1636
|
+
|
1637
|
+
# Execute rsync
|
1638
|
+
result = system(cmd)
|
1639
|
+
puts " rsync completed with result: #{result}"
|
1640
|
+
|
1641
|
+
result
|
1642
|
+
end
|
1643
|
+
|
1644
|
+
# Backup system methods
|
1645
|
+
|
1646
|
+
def get_backup_directory
|
1647
|
+
repo_path = Pathname.new(@repo.root)
|
1648
|
+
repo_parent = repo_path.parent
|
1649
|
+
repo_name = repo_path.basename.to_s
|
1650
|
+
if repo_name == "scriptorium-TEST"
|
1651
|
+
repo_parent/"backup-scriptorium-TEST"
|
1652
|
+
else
|
1653
|
+
repo_parent/"backup-scriptorium"
|
1654
|
+
end
|
1655
|
+
end
|
1656
|
+
|
1657
|
+
def create_backup(type: :incremental, label: nil)
|
1658
|
+
check_invariants
|
1659
|
+
msg = "type must be :full or :incremental, got #{type}"
|
1660
|
+
assume(msg) { [:full, :incremental].include?(type) }
|
1661
|
+
msg = "@repo must be a Scriptorium::Repo, got #{@repo.class}"
|
1662
|
+
assume(msg) { @repo.is_a?(Scriptorium::Repo) }
|
1663
|
+
|
1664
|
+
backup_dir = get_backup_directory
|
1665
|
+
data_dir = backup_dir/"data"
|
1666
|
+
FileUtils.mkdir_p(data_dir)
|
1667
|
+
|
1668
|
+
# Sleep 1 second to ensure backup timestamp is clearly after all existing files
|
1669
|
+
sleep(1)
|
1670
|
+
|
1671
|
+
if type == :full
|
1672
|
+
# Full backup - copy entire repository
|
1673
|
+
temp_backup_path = data_dir/"temp-full-backup"
|
1674
|
+
FileUtils.mkdir_p(temp_backup_path)
|
1675
|
+
copy_repo_to_backup(temp_backup_path)
|
1676
|
+
else
|
1677
|
+
# Incremental backup - copy only changed files since last backup
|
1678
|
+
temp_backup_path = data_dir/"temp-incr-backup"
|
1679
|
+
FileUtils.mkdir_p(temp_backup_path)
|
1680
|
+
copy_changed_files_to_backup(temp_backup_path)
|
1681
|
+
end
|
1682
|
+
|
1683
|
+
# Record timestamp AFTER backup is created
|
1684
|
+
timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
|
1685
|
+
backup_name = "#{timestamp}-#{type == :full ? 'full' : 'incr'}"
|
1686
|
+
|
1687
|
+
# Create final backup directory
|
1688
|
+
final_backup_path = data_dir/backup_name
|
1689
|
+
FileUtils.mkdir_p(final_backup_path)
|
1690
|
+
|
1691
|
+
# Create backup info file in final directory
|
1692
|
+
create_backup_info(final_backup_path, type, backup_name)
|
1693
|
+
|
1694
|
+
# Compress the backup data into data.tar.gz
|
1695
|
+
compress_backup_data(temp_backup_path, final_backup_path/"data.tar.gz")
|
1696
|
+
|
1697
|
+
# Remove temporary directory
|
1698
|
+
FileUtils.rm_rf(temp_backup_path)
|
1699
|
+
|
1700
|
+
# Update backup manifest
|
1701
|
+
update_backup_manifest(backup_name, type, label)
|
1702
|
+
|
1703
|
+
# Cleanup old backups
|
1704
|
+
cleanup_old_backups
|
1705
|
+
|
1706
|
+
verify { File.exist?(final_backup_path) }
|
1707
|
+
verify { File.exist?(final_backup_path/"data.tar.gz") }
|
1708
|
+
check_invariants
|
1709
|
+
backup_name
|
1710
|
+
end
|
1711
|
+
|
1712
|
+
def list_backups
|
1713
|
+
check_invariants
|
1714
|
+
backup_dir = get_backup_directory
|
1715
|
+
manifest_file = backup_dir/"manifest.txt"
|
1716
|
+
return [] unless File.exist?(manifest_file)
|
1717
|
+
|
1718
|
+
backups = []
|
1719
|
+
File.readlines(manifest_file).each do |line|
|
1720
|
+
line = line.strip
|
1721
|
+
next if line.empty? || line.start_with?('#')
|
1722
|
+
|
1723
|
+
parts = line.split(' ', 3)
|
1724
|
+
next if parts.length < 1
|
1725
|
+
|
1726
|
+
timestamp_type = parts[0]
|
1727
|
+
description = parts.length > 1 ? parts[1..-1].join(' ') : nil
|
1728
|
+
|
1729
|
+
# Parse timestamp-type
|
1730
|
+
if timestamp_type.match(/^(\d{8}-\d{6})-(full|incr)$/)
|
1731
|
+
timestamp_str = $1
|
1732
|
+
type = $2 == 'full' ? :full : :incremental
|
1733
|
+
|
1734
|
+
# Convert timestamp to Time object
|
1735
|
+
begin
|
1736
|
+
timestamp = Time.strptime(timestamp_str, "%Y%m%d-%H%M%S")
|
1737
|
+
backups << {
|
1738
|
+
name: timestamp_type,
|
1739
|
+
type: type,
|
1740
|
+
description: description,
|
1741
|
+
timestamp: timestamp,
|
1742
|
+
size: calculate_backup_size(timestamp_type),
|
1743
|
+
file_count: count_backup_files(timestamp_type)
|
1744
|
+
}
|
1745
|
+
rescue ArgumentError
|
1746
|
+
# Skip invalid timestamps
|
1747
|
+
next
|
1748
|
+
end
|
1749
|
+
end
|
1750
|
+
end
|
1751
|
+
|
1752
|
+
backups.sort_by { |b| b[:timestamp] }.reverse
|
1753
|
+
end
|
1754
|
+
|
1755
|
+
def restore_backup(backup_name, strategy: :safe)
|
1756
|
+
check_invariants
|
1757
|
+
backup_dir = get_backup_directory
|
1758
|
+
backup_path = backup_dir/"data"/backup_name
|
1759
|
+
raise BackupNotFound, "Backup '#{backup_name}' not found" unless File.exist?(backup_path)
|
1760
|
+
|
1761
|
+
case strategy
|
1762
|
+
when :safe
|
1763
|
+
# Always create pre-restore backup, then restore
|
1764
|
+
pre_restore = create_backup(type: :full, label: "pre-restore-#{backup_name}")
|
1765
|
+
# Small delay to ensure pre-restore backup has different timestamp
|
1766
|
+
sleep(2)
|
1767
|
+
restore_from_backup(backup_path)
|
1768
|
+
verify { File.exist?(@repo.root/"posts") }
|
1769
|
+
check_invariants
|
1770
|
+
{ restored: backup_name, pre_restore: pre_restore }
|
1771
|
+
|
1772
|
+
when :merge
|
1773
|
+
# Keep existing files, only restore backup files
|
1774
|
+
restore_files_from_backup(backup_path)
|
1775
|
+
verify { File.exist?(@repo.root/"posts") }
|
1776
|
+
check_invariants
|
1777
|
+
{ restored: backup_name, strategy: :merge }
|
1778
|
+
|
1779
|
+
when :destroy
|
1780
|
+
# Current behavior - clear everything and restore
|
1781
|
+
restore_from_backup(backup_path)
|
1782
|
+
verify { File.exist?(@repo.root/"posts") }
|
1783
|
+
check_invariants
|
1784
|
+
{ restored: backup_name, strategy: :destroy }
|
1785
|
+
|
1786
|
+
else
|
1787
|
+
raise ArgumentError, "Invalid restore strategy: #{strategy}. Must be :safe, :merge, or :destroy"
|
1788
|
+
end
|
1789
|
+
end
|
1790
|
+
|
1791
|
+
def delete_backup(backup_name)
|
1792
|
+
check_invariants
|
1793
|
+
backup_dir = get_backup_directory
|
1794
|
+
backup_path = backup_dir/"data"/backup_name
|
1795
|
+
raise BackupNotFound, "Backup '#{backup_name}' not found" unless File.exist?(backup_path)
|
1796
|
+
|
1797
|
+
# Remove backup directory
|
1798
|
+
FileUtils.rm_rf(backup_path)
|
1799
|
+
|
1800
|
+
# Update manifest
|
1801
|
+
update_backup_manifest_remove(backup_name)
|
1802
|
+
|
1803
|
+
verify { !File.exist?(backup_path) }
|
1804
|
+
check_invariants
|
1805
|
+
true
|
1806
|
+
end
|
1807
|
+
|
1808
|
+
private def copy_repo_to_backup(backup_path)
|
1809
|
+
# Copy all repository files except backups directory
|
1810
|
+
Dir.glob(@repo.root/"**/*").each do |file_path|
|
1811
|
+
next unless File.file?(file_path)
|
1812
|
+
next if file_path.to_s.include?("/backups/")
|
1813
|
+
|
1814
|
+
file_pathname = Pathname.new(file_path)
|
1815
|
+
repo_root_pathname = Pathname.new(@repo.root)
|
1816
|
+
relative_path = file_pathname.relative_path_from(repo_root_pathname)
|
1817
|
+
dest_path = backup_path/relative_path
|
1818
|
+
FileUtils.mkdir_p(File.dirname(dest_path))
|
1819
|
+
FileUtils.cp(file_path, dest_path)
|
1820
|
+
end
|
1821
|
+
end
|
1822
|
+
|
1823
|
+
private def compress_backup_data(source_dir, tar_gz_path)
|
1824
|
+
# Ensure target directory exists
|
1825
|
+
FileUtils.mkdir_p(File.dirname(tar_gz_path))
|
1826
|
+
|
1827
|
+
# Convert to absolute paths
|
1828
|
+
source_dir = File.absolute_path(source_dir)
|
1829
|
+
tar_gz_path = File.absolute_path(tar_gz_path)
|
1830
|
+
|
1831
|
+
# Check if source directory has any files
|
1832
|
+
files = Dir.glob(source_dir/"**/*").select { |f| File.file?(f) }
|
1833
|
+
if files.empty?
|
1834
|
+
# Create empty tar.gz if no files
|
1835
|
+
system("tar -czf '#{tar_gz_path}' -T /dev/null")
|
1836
|
+
else
|
1837
|
+
# Change to source directory to create relative paths in tar
|
1838
|
+
Dir.chdir(source_dir) do
|
1839
|
+
# Create tar.gz archive with all files in source directory
|
1840
|
+
system("tar -czf '#{tar_gz_path}' .")
|
1841
|
+
end
|
1842
|
+
end
|
1843
|
+
|
1844
|
+
raise "Failed to create compressed backup" unless $?.success?
|
1845
|
+
end
|
1846
|
+
|
1847
|
+
private def create_backup_info(backup_path, type, backup_name)
|
1848
|
+
# Get version information
|
1849
|
+
scriptorium_version = Scriptorium::VERSION
|
1850
|
+
livetext_version = get_livetext_version
|
1851
|
+
ruby_version = RUBY_VERSION
|
1852
|
+
platform = "#{RUBY_PLATFORM} #{RUBY_ENGINE}"
|
1853
|
+
|
1854
|
+
# Calculate backup statistics
|
1855
|
+
file_count = count_files_in_backup(backup_path)
|
1856
|
+
total_size = calculate_directory_size(backup_path)
|
1857
|
+
|
1858
|
+
# Get git commit if available
|
1859
|
+
git_commit = get_git_commit_hash
|
1860
|
+
|
1861
|
+
# Create backup info content
|
1862
|
+
info_content = <<~INFO
|
1863
|
+
# Scriptorium Backup Information
|
1864
|
+
# Generated: #{Time.now.strftime("%Y-%m-%d %H:%M:%S")}
|
1865
|
+
scriptorium_version: #{scriptorium_version}
|
1866
|
+
livetext_version: #{livetext_version}
|
1867
|
+
ruby_version: #{ruby_version}
|
1868
|
+
backup_type: #{type}
|
1869
|
+
backup_name: #{backup_name}
|
1870
|
+
repository_path: #{@repo.root}
|
1871
|
+
file_count: #{file_count}
|
1872
|
+
total_size: #{total_size}
|
1873
|
+
platform: #{platform}
|
1874
|
+
git_commit: #{git_commit}
|
1875
|
+
INFO
|
1876
|
+
|
1877
|
+
# Write backup info file
|
1878
|
+
info_file = backup_path/"backup-info.txt"
|
1879
|
+
File.write(info_file, info_content)
|
1880
|
+
end
|
1881
|
+
|
1882
|
+
private def get_livetext_version
|
1883
|
+
# Try to get LiveText version from command line
|
1884
|
+
result = `livetext -v 2>/dev/null`.strip
|
1885
|
+
result.empty? ? "unknown" : result
|
1886
|
+
rescue
|
1887
|
+
"unknown"
|
1888
|
+
end
|
1889
|
+
|
1890
|
+
private def get_git_commit_hash
|
1891
|
+
# Try to get git commit hash if in a git repository
|
1892
|
+
result = `git rev-parse HEAD 2>/dev/null`.strip
|
1893
|
+
result.empty? ? "unknown" : result[0..7] # First 8 characters
|
1894
|
+
rescue
|
1895
|
+
"unknown"
|
1896
|
+
end
|
1897
|
+
|
1898
|
+
private def count_files_in_backup(backup_path)
|
1899
|
+
# Check if this is a compressed backup
|
1900
|
+
tar_gz_path = backup_path/"data.tar.gz"
|
1901
|
+
if File.exist?(tar_gz_path)
|
1902
|
+
# Count files in compressed archive using tar -tf
|
1903
|
+
output = `tar -tf #{tar_gz_path} 2>/dev/null`
|
1904
|
+
return 0 unless $?.success?
|
1905
|
+
output.lines.count { |line| !line.strip.empty? }
|
1906
|
+
else
|
1907
|
+
# Legacy uncompressed backup
|
1908
|
+
count = 0
|
1909
|
+
Dir.glob(backup_path/"**/*").each do |file_path|
|
1910
|
+
count += 1 if File.file?(file_path)
|
1911
|
+
end
|
1912
|
+
count
|
1913
|
+
end
|
1914
|
+
end
|
1915
|
+
|
1916
|
+
private def calculate_directory_size(backup_path)
|
1917
|
+
# Check if this is a compressed backup
|
1918
|
+
tar_gz_path = backup_path/"data.tar.gz"
|
1919
|
+
if File.exist?(tar_gz_path)
|
1920
|
+
# Get size of compressed file plus backup-info.txt
|
1921
|
+
compressed_size = File.size(tar_gz_path)
|
1922
|
+
info_size = File.exist?(backup_path/"backup-info.txt") ? File.size(backup_path/"backup-info.txt") : 0
|
1923
|
+
compressed_size + info_size
|
1924
|
+
else
|
1925
|
+
# Legacy uncompressed backup
|
1926
|
+
size = 0
|
1927
|
+
Dir.glob(backup_path/"**/*").each do |file_path|
|
1928
|
+
size += File.size(file_path) if File.file?(file_path)
|
1929
|
+
end
|
1930
|
+
size
|
1931
|
+
end
|
1932
|
+
end
|
1933
|
+
|
1934
|
+
private def copy_changed_files_to_backup(backup_path)
|
1935
|
+
last_backup_time = get_last_backup_time
|
1936
|
+
changed_files = find_changed_files_since(last_backup_time)
|
1937
|
+
|
1938
|
+
changed_files.each do |file_path|
|
1939
|
+
file_pathname = Pathname.new(file_path)
|
1940
|
+
repo_root_pathname = Pathname.new(@repo.root)
|
1941
|
+
relative_path = file_pathname.relative_path_from(repo_root_pathname)
|
1942
|
+
dest_path = backup_path/relative_path
|
1943
|
+
FileUtils.mkdir_p(File.dirname(dest_path))
|
1944
|
+
FileUtils.cp(file_path, dest_path)
|
1945
|
+
end
|
1946
|
+
end
|
1947
|
+
|
1948
|
+
private def find_changed_files_since(since_time)
|
1949
|
+
return [] unless since_time
|
1950
|
+
|
1951
|
+
# Get the most recent backup to compare against
|
1952
|
+
last_backup = get_last_backup_name
|
1953
|
+
return [] unless last_backup
|
1954
|
+
|
1955
|
+
backup_dir = get_backup_directory
|
1956
|
+
last_backup_path = backup_dir/"data"/last_backup
|
1957
|
+
last_backup_tar = last_backup_path/"data.tar.gz"
|
1958
|
+
|
1959
|
+
# If no compressed backup exists, fall back to file system comparison
|
1960
|
+
unless File.exist?(last_backup_tar)
|
1961
|
+
return find_changed_files_since_filesystem(since_time)
|
1962
|
+
end
|
1963
|
+
|
1964
|
+
# Get file timestamps from the last backup's tar TOC
|
1965
|
+
last_backup_files = get_tar_file_timestamps(last_backup_tar)
|
1966
|
+
|
1967
|
+
changed_files = []
|
1968
|
+
Dir.glob(@repo.root/"**/*").each do |file_path|
|
1969
|
+
next unless File.file?(file_path)
|
1970
|
+
next if file_path.to_s.include?("/backups/")
|
1971
|
+
|
1972
|
+
file_pathname = Pathname.new(file_path)
|
1973
|
+
repo_root_pathname = Pathname.new(@repo.root)
|
1974
|
+
relative_path = file_pathname.relative_path_from(repo_root_pathname).to_s
|
1975
|
+
|
1976
|
+
current_mtime = File.mtime(file_path)
|
1977
|
+
# Try both with and without ./ prefix
|
1978
|
+
last_mtime = last_backup_files[relative_path] || last_backup_files["./#{relative_path}"]
|
1979
|
+
|
1980
|
+
# File is changed if it's new or modified
|
1981
|
+
if last_mtime.nil? || current_mtime > last_mtime
|
1982
|
+
changed_files << file_path
|
1983
|
+
end
|
1984
|
+
end
|
1985
|
+
changed_files
|
1986
|
+
end
|
1987
|
+
|
1988
|
+
private def find_changed_files_since_filesystem(since_time)
|
1989
|
+
changed_files = []
|
1990
|
+
Dir.glob(@repo.root/"**/*").each do |file_path|
|
1991
|
+
next unless File.file?(file_path)
|
1992
|
+
next if file_path.to_s.include?("/backups/")
|
1993
|
+
|
1994
|
+
if File.mtime(file_path) > since_time
|
1995
|
+
changed_files << file_path
|
1996
|
+
end
|
1997
|
+
end
|
1998
|
+
|
1999
|
+
changed_files
|
2000
|
+
end
|
2001
|
+
|
2002
|
+
private def get_last_backup_name
|
2003
|
+
backup_dir = get_backup_directory
|
2004
|
+
manifest_file = backup_dir/"manifest.txt"
|
2005
|
+
return nil unless File.exist?(manifest_file)
|
2006
|
+
|
2007
|
+
File.readlines(manifest_file).each do |line|
|
2008
|
+
line = line.strip
|
2009
|
+
next if line.empty? || line.start_with?('#')
|
2010
|
+
|
2011
|
+
parts = line.split(' ', 3)
|
2012
|
+
next if parts.length < 1
|
2013
|
+
|
2014
|
+
backup_name = parts[0]
|
2015
|
+
if backup_name.match(/^\d{8}-\d{6}-(full|incr)$/)
|
2016
|
+
return backup_name
|
2017
|
+
end
|
2018
|
+
end
|
2019
|
+
|
2020
|
+
nil
|
2021
|
+
end
|
2022
|
+
|
2023
|
+
private def get_tar_file_timestamps(tar_gz_path)
|
2024
|
+
file_timestamps = {}
|
2025
|
+
|
2026
|
+
# Use tar -tvf to get file list with timestamps
|
2027
|
+
output = `tar -tvf #{tar_gz_path} 2>/dev/null`
|
2028
|
+
return file_timestamps unless $?.success?
|
2029
|
+
|
2030
|
+
output.lines.each do |line|
|
2031
|
+
# Parse tar -tvf output format:
|
2032
|
+
# -rw-r--r-- user/group size date time filename
|
2033
|
+
# drwxr-xr-x user/group size date time filename
|
2034
|
+
# Format: drwxr-xr-x 0 Hal staff 0 Sep 7 22:06 ./
|
2035
|
+
if line.match(/^[d-]\S+\s+\d+\s+\S+\s+\S+\s+\d+\s+(\w{3})\s+(\d{1,2})\s+(\d{2}:\d{2})\s+(.+?)\s*$/)
|
2036
|
+
month_str = $1
|
2037
|
+
day_str = $2
|
2038
|
+
time_str = $3
|
2039
|
+
filename = $4
|
2040
|
+
|
2041
|
+
begin
|
2042
|
+
# Parse abbreviated month name and day
|
2043
|
+
timestamp = Time.strptime("#{month_str} #{day_str} #{time_str}", "%b %d %H:%M")
|
2044
|
+
# Set year to current year (tar doesn't include year)
|
2045
|
+
timestamp = Time.new(Time.now.year, timestamp.month, timestamp.day, timestamp.hour, timestamp.min)
|
2046
|
+
file_timestamps[filename] = timestamp
|
2047
|
+
rescue ArgumentError
|
2048
|
+
# Skip invalid timestamps
|
2049
|
+
end
|
2050
|
+
end
|
2051
|
+
end
|
2052
|
+
file_timestamps
|
2053
|
+
end
|
2054
|
+
|
2055
|
+
private def get_last_backup_time
|
2056
|
+
backup_dir = get_backup_directory
|
2057
|
+
manifest_file = backup_dir/"manifest.txt"
|
2058
|
+
return nil unless File.exist?(manifest_file)
|
2059
|
+
|
2060
|
+
last_time = nil
|
2061
|
+
File.readlines(manifest_file).each do |line|
|
2062
|
+
line = line.strip
|
2063
|
+
next if line.empty? || line.start_with?('#')
|
2064
|
+
|
2065
|
+
parts = line.split(' ', 3)
|
2066
|
+
next if parts.length < 2
|
2067
|
+
|
2068
|
+
timestamp_type = parts[0]
|
2069
|
+
if timestamp_type.match(/^(\d{8}-\d{6})-(full|incr)$/)
|
2070
|
+
timestamp_str = $1
|
2071
|
+
begin
|
2072
|
+
timestamp = Time.strptime(timestamp_str, "%Y%m%d-%H%M%S")
|
2073
|
+
last_time = timestamp if last_time.nil? || timestamp > last_time
|
2074
|
+
rescue ArgumentError
|
2075
|
+
next
|
2076
|
+
end
|
2077
|
+
end
|
2078
|
+
end
|
2079
|
+
|
2080
|
+
last_time
|
2081
|
+
end
|
2082
|
+
|
2083
|
+
private def update_backup_manifest(backup_name, type, label)
|
2084
|
+
backup_dir = get_backup_directory
|
2085
|
+
manifest_file = backup_dir/"manifest.txt"
|
2086
|
+
FileUtils.mkdir_p(File.dirname(manifest_file))
|
2087
|
+
|
2088
|
+
# Read existing manifest
|
2089
|
+
existing_lines = []
|
2090
|
+
if File.exist?(manifest_file)
|
2091
|
+
existing_lines = File.readlines(manifest_file).map(&:strip)
|
2092
|
+
end
|
2093
|
+
|
2094
|
+
# Add new backup entry
|
2095
|
+
timestamp_type = backup_name
|
2096
|
+
description = label ? "#{label}" : ""
|
2097
|
+
new_line = "#{timestamp_type} #{description}".strip
|
2098
|
+
|
2099
|
+
# Add to beginning of file (most recent first)
|
2100
|
+
existing_lines.unshift(new_line)
|
2101
|
+
|
2102
|
+
# Write back to file
|
2103
|
+
File.write(manifest_file, existing_lines.join("\n") + "\n")
|
2104
|
+
end
|
2105
|
+
|
2106
|
+
private def update_backup_manifest_remove(backup_name)
|
2107
|
+
backup_dir = get_backup_directory
|
2108
|
+
manifest_file = backup_dir/"manifest.txt"
|
2109
|
+
return unless File.exist?(manifest_file)
|
2110
|
+
|
2111
|
+
# Read existing manifest and remove the backup entry
|
2112
|
+
lines = File.readlines(manifest_file).map(&:strip)
|
2113
|
+
lines.reject! { |line| line.start_with?("#{backup_name} ") }
|
2114
|
+
|
2115
|
+
# Write back to file
|
2116
|
+
File.write(manifest_file, lines.join("\n") + "\n")
|
2117
|
+
end
|
2118
|
+
|
2119
|
+
private def calculate_backup_size(backup_name)
|
2120
|
+
backup_dir = get_backup_directory
|
2121
|
+
backup_path = backup_dir/"data"/backup_name
|
2122
|
+
return 0 unless File.exist?(backup_path)
|
2123
|
+
|
2124
|
+
total_size = 0
|
2125
|
+
Dir.glob(backup_path/"**/*").each do |file_path|
|
2126
|
+
total_size += File.size(file_path) if File.file?(file_path)
|
2127
|
+
end
|
2128
|
+
total_size
|
2129
|
+
end
|
2130
|
+
|
2131
|
+
private def count_backup_files(backup_name)
|
2132
|
+
backup_dir = get_backup_directory
|
2133
|
+
backup_path = backup_dir/"data"/backup_name
|
2134
|
+
return 0 unless File.exist?(backup_path)
|
2135
|
+
|
2136
|
+
Dir.glob(backup_path/"**/*").count { |f| File.file?(f) }
|
2137
|
+
end
|
2138
|
+
|
2139
|
+
private def restore_from_backup(backup_path)
|
2140
|
+
# Clear existing content first (except backups)
|
2141
|
+
clear_repo_content
|
2142
|
+
|
2143
|
+
# Find the most recent full backup before this backup
|
2144
|
+
full_backup_path = find_full_backup_for_restore(backup_path)
|
2145
|
+
|
2146
|
+
if full_backup_path
|
2147
|
+
# Restore from full backup first
|
2148
|
+
restore_files_from_backup(full_backup_path)
|
2149
|
+
|
2150
|
+
# Then apply all incrementals up to the target backup
|
2151
|
+
apply_incrementals_up_to(backup_path)
|
2152
|
+
else
|
2153
|
+
# No full backup found, just restore the files directly
|
2154
|
+
restore_files_from_backup(backup_path)
|
2155
|
+
end
|
2156
|
+
end
|
2157
|
+
|
2158
|
+
private def clear_repo_content
|
2159
|
+
# Remove existing content (except backups)
|
2160
|
+
Dir.glob(@repo.root/"*").each do |item|
|
2161
|
+
next if File.basename(item) == "backups"
|
2162
|
+
FileUtils.rm_rf(item)
|
2163
|
+
end
|
2164
|
+
end
|
2165
|
+
|
2166
|
+
private def restore_files_from_backup(backup_path)
|
2167
|
+
# Check if this is a compressed backup
|
2168
|
+
tar_gz_path = backup_path/"data.tar.gz"
|
2169
|
+
if File.exist?(tar_gz_path)
|
2170
|
+
# Decompress to temporary directory and restore from there
|
2171
|
+
temp_extract_dir = backup_path/"temp_extract"
|
2172
|
+
FileUtils.mkdir_p(temp_extract_dir)
|
2173
|
+
|
2174
|
+
begin
|
2175
|
+
# Extract tar.gz to temporary directory
|
2176
|
+
system("tar -xzf #{tar_gz_path} -C #{temp_extract_dir}")
|
2177
|
+
raise "Failed to extract compressed backup" unless $?.success?
|
2178
|
+
|
2179
|
+
# Restore files from extracted directory
|
2180
|
+
restore_files_from_directory(temp_extract_dir)
|
2181
|
+
ensure
|
2182
|
+
# Clean up temporary directory
|
2183
|
+
FileUtils.rm_rf(temp_extract_dir) if Dir.exist?(temp_extract_dir)
|
2184
|
+
end
|
2185
|
+
else
|
2186
|
+
# Legacy uncompressed backup - restore directly
|
2187
|
+
restore_files_from_directory(backup_path)
|
2188
|
+
end
|
2189
|
+
end
|
2190
|
+
|
2191
|
+
private def restore_files_from_directory(source_dir)
|
2192
|
+
# Copy all files from source directory to repo
|
2193
|
+
Dir.glob(source_dir/"**/*").each do |file_path|
|
2194
|
+
next unless File.file?(file_path)
|
2195
|
+
|
2196
|
+
file_pathname = Pathname.new(file_path)
|
2197
|
+
source_pathname = Pathname.new(source_dir)
|
2198
|
+
relative_path = file_pathname.relative_path_from(source_pathname)
|
2199
|
+
dest_path = @repo.root/relative_path
|
2200
|
+
FileUtils.mkdir_p(File.dirname(dest_path))
|
2201
|
+
FileUtils.cp(file_path, dest_path)
|
2202
|
+
end
|
2203
|
+
end
|
2204
|
+
|
2205
|
+
private def find_full_backup_for_restore(target_backup_path)
|
2206
|
+
target_name = File.basename(target_backup_path)
|
2207
|
+
target_timestamp = extract_timestamp_from_backup_name(target_name)
|
2208
|
+
|
2209
|
+
# Find the most recent full backup before the target backup
|
2210
|
+
backup_dir = get_backup_directory
|
2211
|
+
manifest_file = backup_dir/"manifest.txt"
|
2212
|
+
return nil unless File.exist?(manifest_file)
|
2213
|
+
|
2214
|
+
latest_full_backup = nil
|
2215
|
+
latest_full_timestamp = nil
|
2216
|
+
|
2217
|
+
File.readlines(manifest_file).each do |line|
|
2218
|
+
line = line.strip
|
2219
|
+
next if line.empty? || line.start_with?('#')
|
2220
|
+
|
2221
|
+
parts = line.split(' ', 3)
|
2222
|
+
next if parts.length < 1
|
2223
|
+
|
2224
|
+
backup_name = parts[0]
|
2225
|
+
if backup_name.end_with?('-full')
|
2226
|
+
# Skip pre-restore backups - they shouldn't be used as base for restore
|
2227
|
+
next if backup_name.include?('pre-restore')
|
2228
|
+
|
2229
|
+
backup_timestamp = extract_timestamp_from_backup_name(backup_name)
|
2230
|
+
if backup_timestamp && backup_timestamp < target_timestamp
|
2231
|
+
if latest_full_timestamp.nil? || backup_timestamp > latest_full_timestamp
|
2232
|
+
latest_full_backup = backup_name
|
2233
|
+
latest_full_timestamp = backup_timestamp
|
2234
|
+
end
|
2235
|
+
end
|
2236
|
+
end
|
2237
|
+
end
|
2238
|
+
|
2239
|
+
return nil unless latest_full_backup
|
2240
|
+
|
2241
|
+
backup_dir = get_backup_directory
|
2242
|
+
full_backup_path = backup_dir/"data"/latest_full_backup
|
2243
|
+
File.exist?(full_backup_path) ? full_backup_path : nil
|
2244
|
+
end
|
2245
|
+
|
2246
|
+
private def apply_incrementals_up_to(target_backup_path)
|
2247
|
+
target_name = File.basename(target_backup_path)
|
2248
|
+
target_timestamp = extract_timestamp_from_backup_name(target_name)
|
2249
|
+
|
2250
|
+
backup_dir = get_backup_directory
|
2251
|
+
manifest_file = backup_dir/"manifest.txt"
|
2252
|
+
return unless File.exist?(manifest_file)
|
2253
|
+
|
2254
|
+
# Get all incrementals between the full backup and target backup
|
2255
|
+
incrementals = []
|
2256
|
+
File.readlines(manifest_file).each do |line|
|
2257
|
+
line = line.strip
|
2258
|
+
next if line.empty? || line.start_with?('#')
|
2259
|
+
|
2260
|
+
parts = line.split(' ', 3)
|
2261
|
+
next if parts.length < 1
|
2262
|
+
|
2263
|
+
backup_name = parts[0]
|
2264
|
+
if backup_name.end_with?('-incr')
|
2265
|
+
backup_timestamp = extract_timestamp_from_backup_name(backup_name)
|
2266
|
+
if backup_timestamp && backup_timestamp <= target_timestamp
|
2267
|
+
incrementals << backup_name
|
2268
|
+
end
|
2269
|
+
end
|
2270
|
+
end
|
2271
|
+
|
2272
|
+
# Sort incrementals by timestamp and apply them
|
2273
|
+
incrementals.sort_by { |name| extract_timestamp_from_backup_name(name) }.each do |backup_name|
|
2274
|
+
backup_dir = get_backup_directory
|
2275
|
+
incremental_path = backup_dir/"data"/backup_name
|
2276
|
+
if File.exist?(incremental_path)
|
2277
|
+
restore_files_from_backup(incremental_path)
|
2278
|
+
end
|
2279
|
+
end
|
2280
|
+
end
|
2281
|
+
|
2282
|
+
private def extract_timestamp_from_backup_name(backup_name)
|
2283
|
+
if backup_name.match(/^(\d{8}-\d{6})-(full|incr)$/)
|
2284
|
+
timestamp_str = $1
|
2285
|
+
begin
|
2286
|
+
Time.strptime(timestamp_str, "%Y%m%d-%H%M%S")
|
2287
|
+
rescue ArgumentError
|
2288
|
+
nil
|
2289
|
+
end
|
2290
|
+
else
|
2291
|
+
nil
|
2292
|
+
end
|
2293
|
+
end
|
2294
|
+
|
2295
|
+
|
2296
|
+
|
2297
|
+
private def cleanup_old_backups
|
2298
|
+
# Keep backups for 30 days, but always keep the most recent full backup
|
2299
|
+
cutoff_time = Time.now - (30 * 24 * 60 * 60)
|
2300
|
+
|
2301
|
+
backup_dir = get_backup_directory
|
2302
|
+
manifest_file = backup_dir/"manifest.txt"
|
2303
|
+
return unless File.exist?(manifest_file)
|
2304
|
+
|
2305
|
+
# Find the most recent full backup
|
2306
|
+
most_recent_full_backup = nil
|
2307
|
+
most_recent_full_timestamp = nil
|
2308
|
+
|
2309
|
+
File.readlines(manifest_file).each do |line|
|
2310
|
+
line = line.strip
|
2311
|
+
next if line.empty? || line.start_with?('#')
|
2312
|
+
|
2313
|
+
parts = line.split(' ', 3)
|
2314
|
+
next if parts.length < 1
|
2315
|
+
|
2316
|
+
backup_name = parts[0]
|
2317
|
+
if backup_name.end_with?('-full')
|
2318
|
+
backup_timestamp = extract_timestamp_from_backup_name(backup_name)
|
2319
|
+
if backup_timestamp && (most_recent_full_timestamp.nil? || backup_timestamp > most_recent_full_timestamp)
|
2320
|
+
most_recent_full_backup = backup_name
|
2321
|
+
most_recent_full_timestamp = backup_timestamp
|
2322
|
+
end
|
2323
|
+
end
|
2324
|
+
end
|
2325
|
+
|
2326
|
+
lines_to_keep = []
|
2327
|
+
lines_to_remove = []
|
2328
|
+
|
2329
|
+
File.readlines(manifest_file).each do |line|
|
2330
|
+
line = line.strip
|
2331
|
+
next if line.empty? || line.start_with?('#')
|
2332
|
+
|
2333
|
+
parts = line.split(' ', 3)
|
2334
|
+
next if parts.length < 1
|
2335
|
+
|
2336
|
+
backup_name = parts[0]
|
2337
|
+
if backup_name.match(/^(\d{8}-\d{6})-(full|incr)$/)
|
2338
|
+
timestamp_str = $1
|
2339
|
+
begin
|
2340
|
+
timestamp = Time.strptime(timestamp_str, "%Y%m%d-%H%M%S")
|
2341
|
+
|
2342
|
+
# Always keep the most recent full backup
|
2343
|
+
if backup_name == most_recent_full_backup
|
2344
|
+
lines_to_keep << line
|
2345
|
+
# Keep all backups newer than cutoff
|
2346
|
+
elsif timestamp >= cutoff_time
|
2347
|
+
lines_to_keep << line
|
2348
|
+
# Keep incrementals that are newer than the most recent full backup
|
2349
|
+
elsif backup_name.end_with?('-incr') && most_recent_full_timestamp && timestamp > most_recent_full_timestamp
|
2350
|
+
lines_to_keep << line
|
2351
|
+
else
|
2352
|
+
lines_to_remove << backup_name
|
2353
|
+
end
|
2354
|
+
rescue ArgumentError
|
2355
|
+
lines_to_keep << line
|
2356
|
+
end
|
2357
|
+
else
|
2358
|
+
lines_to_keep << line
|
2359
|
+
end
|
2360
|
+
end
|
2361
|
+
|
2362
|
+
# Remove old backup directories
|
2363
|
+
lines_to_remove.each do |backup_name|
|
2364
|
+
backup_dir = get_backup_directory
|
2365
|
+
backup_path = backup_dir/"data"/backup_name
|
2366
|
+
FileUtils.rm_rf(backup_path) if File.exist?(backup_path)
|
2367
|
+
end
|
2368
|
+
|
2369
|
+
# Update manifest file
|
2370
|
+
File.write(manifest_file, lines_to_keep.join("\n") + "\n")
|
2371
|
+
end
|
2372
|
+
|
2373
|
+
end
|