scriptorium 0.6.1 → 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/assets/icons/social/reddit.png +0 -0
- data/assets/icons/social/x-logo.png +0 -0
- data/assets/imagenotfound.jpg +0 -0
- data/bin/sblog +84 -5
- data/bin/scriptorium +1 -0
- data/doc/anti-amnesia/20250727-054000-scriptorium-overview.md +0 -1
- data/doc/anti-amnesia/20250727-123000-anti-amnesia-conventions.md +0 -29
- data/doc/anti-amnesia/20250727-172600-cursor-rbenv-ruby-version-mystery.md +0 -19
- data/doc/anti-amnesia/20250727-172900-ai-cognitive-assessment-capabilities.md +1 -1
- data/doc/anti-amnesia/20250728-124243-aaa-syntax-clarification.md +1 -1
- data/doc/anti-amnesia/20250729-210000-reddit-autopost-integration-complete.md +1 -1
- data/doc/anti-amnesia/20250804-190500-cognitive-loop-bug.md +0 -10
- data/doc/anti-amnesia/20250804-190700-anti-amnesia-timestamping-fix.md +1 -4
- 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/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/{userdoc-toc.txt → myuserdoc/userdoc-toc.txt} +27 -27
- 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_integration.md +2 -2
- data/doc/user.lt3 +0 -3
- data/lib/scriptorium/api.rb +1811 -78
- data/lib/scriptorium/banner_svg.rb +55 -68
- data/lib/scriptorium/contract.rb +3 -2
- data/lib/scriptorium/exceptions.rb +133 -102
- data/lib/scriptorium/helpers.rb +282 -82
- data/lib/scriptorium/post.rb +81 -17
- data/lib/scriptorium/reddit.rb +1 -1
- data/lib/scriptorium/repo.rb +478 -164
- data/lib/scriptorium/standard_files.rb +30 -396
- 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_template.txt +17 -0
- data/{test/scriptorium-TEST-1754622690-146/views/sample → lib/scriptorium/support}/config/social.txt +1 -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/{test/scriptorium-TEST-1754622690-146/themes/standard/initial/post.lt3 → lib/scriptorium/support/templates/initial_post.lt3} +5 -5
- data/lib/scriptorium/support/templates/post.lt3 +104 -0
- data/{test/scriptorium-TEST-1754622690-146/themes/standard/layout/config/header.txt → lib/scriptorium/support/theme/header.lt3} +1 -1
- data/lib/scriptorium/theme.rb +83 -70
- data/lib/scriptorium/version.rb +2 -2
- data/lib/scriptorium/view.rb +194 -149
- data/lib/scriptorium.rb +24 -1
- data/lib/skeleton.rb +4 -1
- data/scriptorium.gemspec +2 -1
- data/test/WEB_INTEGRATION_README.md +196 -0
- data/test/all +40 -0
- data/test/banner_svg/unit.rb +267 -35
- data/test/config/deployment.txt +5 -0
- data/test/integration/integration_test.rb +7 -7
- data/test/integration/preview_flow_test.rb +94 -0
- data/test/livetext_plugin_test.rb +453 -182
- data/test/manual/banner-tests/test01.html +82 -18
- data/test/manual/banner-tests/test02.html +82 -18
- data/test/manual/banner-tests/test03.html +82 -18
- data/test/manual/banner-tests/test04.html +89 -25
- data/test/manual/banner-tests/test05.html +89 -25
- data/test/manual/banner-tests/test06.html +89 -25
- data/test/manual/banner-tests/test07.html +89 -25
- data/test/manual/banner-tests/test08.html +82 -18
- data/test/manual/banner-tests/test09.html +82 -18
- data/test/manual/banner-tests/test10.html +82 -18
- data/test/manual/banner-tests/test11.html +82 -18
- data/test/manual/banner-tests/test12.html +82 -18
- data/test/manual/banner-tests/test13.html +82 -18
- data/test/manual/banner-tests/test14.html +82 -18
- data/test/manual/banner-tests/test15.html +82 -18
- data/test/manual/banner-tests/test16.html +82 -18
- data/test/manual/banner-tests/test17.html +82 -18
- data/test/manual/banner-tests/test18.html +90 -26
- data/test/manual/banner-tests/test19.html +90 -26
- data/test/manual/banner-tests/test20.html +90 -26
- data/test/manual/banner-tests/test21.html +90 -26
- data/test/manual/banner-tests/test22.html +90 -26
- data/test/manual/banner-tests/test23.html +90 -26
- data/test/manual/banner-tests/test24.html +90 -26
- data/test/manual/banner-tests/test25.html +89 -25
- data/test/manual/banner_environment.rb +15 -2
- data/test/manual/codemirror_demo.html +773 -0
- data/test/manual/create_posts_for_web.rb +114 -0
- data/test/manual/preview_manual_test.rb +129 -0
- data/test/manual/test_banner_features.rb +14 -14
- data/test/manual/test_banner_integration.rb +115 -0
- data/test/manual/test_banner_radial.rb +87 -0
- data/test/manual/test_syntax_highlighting.rb +60 -40
- data/test/support/preview_utils.rb +88 -0
- data/test/test_gem_assets.rb +48 -0
- data/test/test_helpers.rb +10 -0
- data/test/tui_editor_integration_test.rb +15 -15
- data/test/tui_integration_test.rb +687 -441
- data/test/unit/api.rb +757 -37
- data/test/unit/asset_management.rb +195 -221
- data/test/unit/backup_test.rb +451 -0
- data/test/unit/contract_test.rb +1 -23
- data/test/unit/core.rb +415 -61
- data/test/unit/deploy_config_test.rb +248 -0
- data/test/unit/deploy_test.rb +312 -21
- data/test/unit/edit_post_test.rb +168 -0
- data/test/unit/gem_asset_management.rb +36 -42
- data/test/unit/livetext_basic.rb +23 -35
- data/test/unit/livetext_compatibility.rb +7 -14
- data/test/unit/parse_cmd_test.rb +260 -0
- data/test/unit/{symlink_test.rb → permalink_copy_test.rb} +47 -49
- data/test/unit/post.rb +91 -26
- 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 +8 -6
- data/test/unit/repo.rb +75 -54
- data/test/unit/social_test.rb +41 -44
- data/test/unit/syntax_highlighting.rb +70 -0
- data/test/unit/theme_management_test.rb +91 -0
- data/test/unit/view.rb +79 -12
- data/test/unit/widgets.rb +8 -8
- 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/ui/tui/bin/scriptorium +885 -415
- data/ui/web/app/app.rb +1398 -176
- data/ui/web/app/assets/livetext_mode.js +244 -0
- data/ui/web/app/error_helpers.rb +16 -16
- data/ui/web/app/views/advanced_config.erb +8 -2
- data/ui/web/app/views/asset_management.erb +56 -0
- data/ui/web/app/views/backup_management.erb +238 -0
- data/ui/web/app/views/config_widget.erb +232 -0
- data/ui/web/app/views/dashboard.erb +64 -72
- data/ui/web/app/views/deploy_config.erb +3 -0
- data/ui/web/app/views/edit_pages.erb +170 -2
- data/ui/web/app/views/edit_post.erb +130 -9
- 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/theme_management.erb +130 -0
- data/ui/web/app/views/view_dashboard.erb +666 -25
- data/ui/web/app/views/widgets.erb +249 -0
- data/ui/web/bin/scriptorium-web +35 -24
- data/ui/web/tmp/timing.log +17 -0
- data/ui/web/tmp/web_server.log +0 -5
- metadata +190 -116
- data/assets/back-icon.png +0 -0
- data/assets/icons/facebook.svg +0 -1
- data/assets/icons/github.svg +0 -1
- data/assets/icons/instagram.svg +0 -1
- data/assets/icons/reddit.svg +0 -1
- data/assets/icons/x.svg +0 -1
- data/assets/icons/youtube.svg +0 -1
- data/bin/scriptorium +0 -1511
- data/doc/anti-amnesia/20250727-060000-api-design-tui-planning.md +0 -34
- data/doc/anti-amnesia/20250727-061000-runeblog-tui-analysis.md +0 -50
- data/doc/anti-amnesia/20250727-154000-livetext-plugin-file-stats.md +0 -73
- data/doc/anti-amnesia/20250727-172600-unified-minitest-framework.md +0 -70
- data/doc/anti-amnesia/20250727-173000-widget-testing-achievement.md +0 -110
- data/doc/anti-amnesia/20250727-180000-post-id-num-refactoring.md +0 -73
- data/doc/anti-amnesia/20250728-124421-conversation-summary-concise.md +0 -124
- data/doc/anti-amnesia/20250729-190000-scriptorium-tui-testing-complete.md +0 -46
- data/doc/anti-amnesia/20250729-200000-scriptorium-tui-testing-edit-file-workflow.md +0 -97
- data/doc/anti-amnesia/20250729-211500-dependency-management-system.md +0 -211
- data/doc/anti-amnesia/20250729-213000-python-virtual-environment-setup.md +0 -141
- data/doc/anti-amnesia/20250729-214500-theme-management-commands.md +0 -211
- data/doc/anti-amnesia/20250729-215000-version-update-to-0.6.0.md +0 -134
- data/doc/anti-amnesia/20250729-220000-user-guide-complete.md +0 -41
- data/doc/anti-amnesia/20250804-213700-publishing-test-fix.md +0 -49
- data/doc/anti-amnesia/20250804-214400-additional-test-fixes.md +0 -46
- data/doc/anti-amnesia/20250804-220000-asset-function-logic-clarification.md +0 -41
- data/doc/anti-amnesia/20250806-202032-asset-function-logic-clarification.md +0 -41
- data/doc/anti-amnesia/20250813-082428-syntax-highlighting-and-navigation-improvements.md +0 -256
- data/lib/scriptorium/syntax_highlighter.rb +0 -234
- data/test/manual/deploy_symlink_demo.rb +0 -142
- data/test/manual/symlink_demo.rb +0 -117
- data/test/manual/test2.rb +0 -12
- data/test/manual/test_banner_from_file.rb +0 -150
- data/test/manual/test_banner_in_header.rb +0 -35
- data/test/manual/test_code_highlighting.rb +0 -68
- data/test/manual/test_complex_header.rb +0 -74
- data/test/manual/test_empty_header.rb +0 -32
- data/test/manual/test_radial_custom.rb +0 -58
- data/test/manual/test_radial_large_radius.rb +0 -52
- data/test/manual/test_svg_debug.rb +0 -47
- data/test/pages-demo/config/currentview.txt +0 -1
- data/test/pages-demo/views/demo/config/common.js +0 -57
- data/test/pages-demo/views/demo/config/footer.txt +0 -1
- data/test/pages-demo/views/demo/config/global-head.txt +0 -8
- data/test/pages-demo/views/demo/config/header.txt +0 -1
- data/test/pages-demo/views/demo/config/layout.txt +0 -1
- data/test/pages-demo/views/demo/config/left.txt +0 -1
- data/test/pages-demo/views/demo/config/main.txt +0 -1
- data/test/pages-demo/views/demo/config/right.txt +0 -1
- data/test/pages-demo/views/demo/config.txt +0 -3
- data/test/pages-demo/views/demo/output/panes/footer.html +0 -1
- data/test/pages-demo/views/demo/output/panes/header.html +0 -1
- data/test/pages-demo/views/demo/output/panes/left.html +0 -1
- data/test/pages-demo/views/demo/output/panes/main.html +0 -1
- data/test/pages-demo/views/demo/output/panes/right.html +0 -1
- data/test/scriptorium-TEST-1754622690-146/config/bootstrap_css.txt +0 -5
- data/test/scriptorium-TEST-1754622690-146/config/bootstrap_js.txt +0 -4
- data/test/scriptorium-TEST-1754622690-146/config/common.js +0 -57
- data/test/scriptorium-TEST-1754622690-146/config/currentview.txt +0 -1
- data/test/scriptorium-TEST-1754622690-146/config/global-head.txt +0 -9
- data/test/scriptorium-TEST-1754622690-146/config/last_post_num.txt +0 -1
- data/test/scriptorium-TEST-1754622690-146/config/os_helpers.rb +0 -4
- data/test/scriptorium-TEST-1754622690-146/config/widgets.txt +0 -3
- data/test/scriptorium-TEST-1754622690-146/posts/0001/meta.txt +0 -8
- data/test/scriptorium-TEST-1754622690-146/posts/0001/source.lt3 +0 -6
- data/test/scriptorium-TEST-1754622690-146/themes/standard/README.txt +0 -1
- data/test/scriptorium-TEST-1754622690-146/themes/standard/config.txt +0 -1
- data/test/scriptorium-TEST-1754622690-146/themes/standard/layout/gen/text.css +0 -1
- data/test/scriptorium-TEST-1754622690-146/themes/standard/templates/index.lt3 +0 -1
- data/test/scriptorium-TEST-1754622690-146/themes/standard/templates/index_entry.lt3 +0 -14
- data/test/scriptorium-TEST-1754622690-146/themes/standard/templates/post.lt3 +0 -13
- data/test/scriptorium-TEST-1754622690-146/themes/standard/templates/widget.lt3 +0 -1
- data/test/scriptorium-TEST-1754622690-146/views/sample/config/bootstrap_css.txt +0 -5
- data/test/scriptorium-TEST-1754622690-146/views/sample/config/bootstrap_js.txt +0 -4
- data/test/scriptorium-TEST-1754622690-146/views/sample/config/common.js +0 -57
- data/test/scriptorium-TEST-1754622690-146/views/sample/config/deploy.txt +0 -5
- data/test/scriptorium-TEST-1754622690-146/views/sample/config/footer.txt +0 -2
- data/test/scriptorium-TEST-1754622690-146/views/sample/config/global-head.txt +0 -9
- data/test/scriptorium-TEST-1754622690-146/views/sample/config/header.txt +0 -4
- data/test/scriptorium-TEST-1754622690-146/views/sample/config/layout.txt +0 -5
- data/test/scriptorium-TEST-1754622690-146/views/sample/config/left.txt +0 -3
- data/test/scriptorium-TEST-1754622690-146/views/sample/config/main.txt +0 -5
- data/test/scriptorium-TEST-1754622690-146/views/sample/config/right.txt +0 -3
- data/test/scriptorium-TEST-1754622690-146/views/sample/config/status.txt +0 -7
- data/test/scriptorium-TEST-1754622690-146/views/sample/config.txt +0 -3
- data/test/scriptorium-TEST-1754622690-146/views/sample/layout/footer.html +0 -3
- data/test/scriptorium-TEST-1754622690-146/views/sample/layout/header.html +0 -3
- data/test/scriptorium-TEST-1754622690-146/views/sample/layout/left.html +0 -3
- data/test/scriptorium-TEST-1754622690-146/views/sample/layout/main.html +0 -3
- data/test/scriptorium-TEST-1754622690-146/views/sample/layout/right.html +0 -3
- data/test/scriptorium-TEST-1754622690-146/views/sample/output/panes/footer.html +0 -1
- data/test/scriptorium-TEST-1754622690-146/views/sample/output/panes/header.html +0 -1
- data/test/scriptorium-TEST-1754622690-146/views/sample/output/panes/left.html +0 -1
- data/test/scriptorium-TEST-1754622690-146/views/sample/output/panes/main.html +0 -1
- data/test/scriptorium-TEST-1754622690-146/views/sample/output/panes/right.html +0 -1
- data/ui/web/tmp/web_server.pid +0 -1
- /data/{test/pages-demo/views/demo/config/bootstrap_css.txt → lib/scriptorium/support/bootstrap/css.txt} +0 -0
- /data/{test/pages-demo/views/demo/config/bootstrap_js.txt → lib/scriptorium/support/bootstrap/js.txt} +0 -0
- /data/{test/scriptorium-TEST-1754622690-146/views/sample → lib/scriptorium/support}/config/reddit.txt +0 -0
- /data/{test/scriptorium-TEST-1754622690-146/themes/standard/layout → lib/scriptorium/support/templates}/layout.txt +0 -0
- /data/{test/scriptorium-TEST-1754622690-146/themes/standard/layout/config/footer.txt → lib/scriptorium/support/theme/footer.lt3} +0 -0
- /data/{test/scriptorium-TEST-1754622690-146/themes/standard/layout/config/left.txt → lib/scriptorium/support/theme/left.lt3} +0 -0
- /data/{test/scriptorium-TEST-1754622690-146/themes/standard/layout/config/main.txt → lib/scriptorium/support/theme/main.lt3} +0 -0
- /data/{test/scriptorium-TEST-1754622690-146/themes/standard/layout/config/right.txt → lib/scriptorium/support/theme/right.lt3} +0 -0
- /data/test/manual/banner-tests/{config.txt → svg.txt} +0 -0
- /data/test/manual/{test6.rb → test_advanced_widgets.rb} +0 -0
- /data/test/manual/{test1.rb → test_basic_posts.rb} +0 -0
- /data/test/manual/{test4.rb → test_layout_widgets.rb} +0 -0
- /data/test/manual/{test5.rb → test_pagination.rb} +0 -0
- /data/test/manual/{test3.rb → test_random_posts.rb} +0 -0
data/lib/scriptorium/api.rb
CHANGED
@@ -1,3 +1,6 @@
|
|
1
|
+
|
2
|
+
require 'set'
|
3
|
+
|
1
4
|
class Scriptorium::API
|
2
5
|
include Scriptorium::Exceptions
|
3
6
|
include Scriptorium::Helpers
|
@@ -12,7 +15,8 @@ class Scriptorium::API
|
|
12
15
|
end
|
13
16
|
|
14
17
|
def initialize(testmode: false)
|
15
|
-
|
18
|
+
msg = "testmode must be true or false, got #{testmode}"
|
19
|
+
assume(msg) { [true, false].include?(testmode) }
|
16
20
|
|
17
21
|
@testing = testmode
|
18
22
|
@repo = nil
|
@@ -28,7 +32,8 @@ class Scriptorium::API
|
|
28
32
|
|
29
33
|
def create_repo(path)
|
30
34
|
check_invariants
|
31
|
-
|
35
|
+
msg = "path must be a non-empty String, got #{path.class} (#{path.inspect})"
|
36
|
+
assume(msg) { path.is_a?(String) && !path.empty? }
|
32
37
|
|
33
38
|
raise RepoDirAlreadyExists if repo_exists?(path)
|
34
39
|
Scriptorium::Repo.create(path)
|
@@ -40,7 +45,8 @@ class Scriptorium::API
|
|
40
45
|
|
41
46
|
def open_repo(path)
|
42
47
|
check_invariants
|
43
|
-
|
48
|
+
msg = "path must be a non-empty String, got #{path.class} (#{path.inspect})"
|
49
|
+
assume(msg) { path.is_a?(String) && !path.empty? }
|
44
50
|
|
45
51
|
@repo = Scriptorium::Repo.open(path)
|
46
52
|
|
@@ -51,11 +57,16 @@ class Scriptorium::API
|
|
51
57
|
# View management
|
52
58
|
def create_view(name, title, subtitle = "", theme: "standard")
|
53
59
|
check_invariants
|
54
|
-
|
55
|
-
assume {
|
56
|
-
|
57
|
-
assume {
|
58
|
-
|
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) }
|
59
70
|
|
60
71
|
@repo.create_view(name, title, subtitle, theme: theme)
|
61
72
|
|
@@ -76,6 +87,10 @@ class Scriptorium::API
|
|
76
87
|
Scriptorium::VERSION
|
77
88
|
end
|
78
89
|
|
90
|
+
def testing
|
91
|
+
@testing
|
92
|
+
end
|
93
|
+
|
79
94
|
def apply_theme(theme)
|
80
95
|
@repo.view.apply_theme(theme)
|
81
96
|
end
|
@@ -106,15 +121,21 @@ class Scriptorium::API
|
|
106
121
|
# Post creation with convenience defaults
|
107
122
|
def create_post(title, body, views: nil, tags: nil, blurb: nil)
|
108
123
|
check_invariants
|
109
|
-
|
110
|
-
assume {
|
111
|
-
|
112
|
-
assume {
|
113
|
-
|
114
|
-
assume {
|
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) }
|
115
136
|
|
116
137
|
views ||= @repo.current_view&.name
|
117
|
-
raise
|
138
|
+
raise ViewTargetNil if views.nil?
|
118
139
|
|
119
140
|
post = @repo.create_post(
|
120
141
|
title: title,
|
@@ -132,7 +153,7 @@ class Scriptorium::API
|
|
132
153
|
# Draft management
|
133
154
|
def draft(title: nil, body: nil, views: nil, tags: nil, blurb: nil)
|
134
155
|
views ||= @repo.current_view&.name
|
135
|
-
raise
|
156
|
+
raise ViewTargetNil if views.nil?
|
136
157
|
|
137
158
|
@repo.create_draft(
|
138
159
|
title: title,
|
@@ -145,7 +166,7 @@ class Scriptorium::API
|
|
145
166
|
|
146
167
|
def create_draft(title: nil, body: nil, views: nil, tags: nil, blurb: nil)
|
147
168
|
views ||= @repo.current_view&.name
|
148
|
-
raise
|
169
|
+
raise ViewTargetNil if views.nil?
|
149
170
|
|
150
171
|
@repo.create_draft(
|
151
172
|
title: title,
|
@@ -156,6 +177,22 @@ class Scriptorium::API
|
|
156
177
|
)
|
157
178
|
end
|
158
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
|
+
|
159
196
|
def finish_draft(draft_path)
|
160
197
|
@repo.finish_draft(draft_path)
|
161
198
|
end
|
@@ -163,17 +200,27 @@ class Scriptorium::API
|
|
163
200
|
# Generation
|
164
201
|
def generate_front_page(view = nil)
|
165
202
|
view ||= @repo.current_view&.name
|
166
|
-
raise
|
203
|
+
raise ViewTargetNil if view.nil?
|
167
204
|
|
168
205
|
@repo.generate_front_page(view)
|
169
206
|
end
|
170
207
|
|
171
208
|
def generate_post_index(view = nil)
|
172
209
|
view ||= @repo.current_view&.name
|
173
|
-
raise
|
210
|
+
raise ViewTargetNil if view.nil?
|
174
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
|
175
214
|
@repo.generate_post_index(view)
|
176
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
|
177
224
|
|
178
225
|
def generate_post(post_id)
|
179
226
|
# Check if the post directory exists first
|
@@ -184,7 +231,7 @@ class Scriptorium::API
|
|
184
231
|
else
|
185
232
|
# Try to find the post through normal means
|
186
233
|
post = @repo.post(post_id)
|
187
|
-
raise "Post not found" if post.nil?
|
234
|
+
raise CannotGetPost("Post with ID #{post_id} not found") if post.nil?
|
188
235
|
|
189
236
|
@repo.generate_post(post_id)
|
190
237
|
end
|
@@ -195,31 +242,171 @@ class Scriptorium::API
|
|
195
242
|
end
|
196
243
|
|
197
244
|
# Publication system
|
198
|
-
def publish_post(num)
|
245
|
+
def publish_post(num, view = nil)
|
199
246
|
check_invariants
|
200
|
-
|
201
|
-
assume {
|
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) }
|
202
251
|
|
203
|
-
post = @repo.publish_post(num)
|
252
|
+
post = @repo.publish_post(num, view)
|
204
253
|
|
205
254
|
verify { post.is_a?(Scriptorium::Post) }
|
206
255
|
check_invariants
|
207
256
|
post
|
208
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
|
209
270
|
|
210
|
-
def post_published?(num)
|
211
|
-
@repo.post_published?(num)
|
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
|
212
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
|
+
|
213
370
|
|
214
|
-
|
371
|
+
|
372
|
+
def undeploy_post(num, view = nil)
|
215
373
|
view ||= @repo.current_view&.name
|
216
|
-
|
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
|
217
393
|
end
|
218
394
|
|
219
395
|
# Post retrieval
|
220
|
-
def posts(view = nil)
|
396
|
+
def posts(view = nil, include_deleted: false, published: false)
|
221
397
|
view ||= @repo.current_view&.name
|
222
|
-
|
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
|
223
410
|
end
|
224
411
|
|
225
412
|
def post_attrs(post_id, *keys)
|
@@ -257,10 +444,10 @@ class Scriptorium::API
|
|
257
444
|
def unlink_post(id, view = nil)
|
258
445
|
# Remove post from a specific view (or current view if none specified)
|
259
446
|
view ||= @repo.current_view&.name
|
260
|
-
raise
|
447
|
+
raise ViewTargetNil if view.nil?
|
261
448
|
|
262
449
|
post = @repo.post(id)
|
263
|
-
raise "Post not found" if post.nil?
|
450
|
+
raise CannotGetPost("Post with ID #{id} not found") if post.nil?
|
264
451
|
|
265
452
|
# Get current views from metadata (split string into array)
|
266
453
|
current_views = post.views.strip.split(/\s+/)
|
@@ -280,10 +467,10 @@ class Scriptorium::API
|
|
280
467
|
def link_post(id, view = nil)
|
281
468
|
# Add post to a specific view (or current view if none specified)
|
282
469
|
view ||= @repo.current_view&.name
|
283
|
-
raise
|
470
|
+
raise ViewTargetNil if view.nil?
|
284
471
|
|
285
472
|
post = @repo.post(id)
|
286
|
-
raise "Post not found" if post.nil?
|
473
|
+
raise CannotGetPost("Post with ID #{id} not found") if post.nil?
|
287
474
|
|
288
475
|
current_views = post.views.strip.split(/\s+/)
|
289
476
|
new_views = current_views.include?(view) ? current_views : current_views + [view]
|
@@ -309,7 +496,7 @@ class Scriptorium::API
|
|
309
496
|
def post_add_tag(id, tag)
|
310
497
|
# Add a tag to a post
|
311
498
|
post = @repo.post(id)
|
312
|
-
raise "Post not found" if post.nil?
|
499
|
+
raise CannotGetPost("Post with ID #{id} not found") if post.nil?
|
313
500
|
|
314
501
|
# Get current tags from metadata (split comma-separated string into array)
|
315
502
|
current_tags = post.tags.strip.split(/,\s*/)
|
@@ -329,7 +516,7 @@ class Scriptorium::API
|
|
329
516
|
def post_remove_tag(id, tag)
|
330
517
|
# Remove a tag from a post
|
331
518
|
post = @repo.post(id)
|
332
|
-
raise "Post not found" if post.nil?
|
519
|
+
raise CannotGetPost("Post with ID #{id} not found") if post.nil?
|
333
520
|
|
334
521
|
# Get current tags from metadata (split comma-separated string into array)
|
335
522
|
current_tags = post.tags.strip.split(/,\s*/)
|
@@ -348,12 +535,83 @@ class Scriptorium::API
|
|
348
535
|
|
349
536
|
# Theme management
|
350
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 = []
|
351
565
|
themes_dir = @repo.root/:themes
|
352
|
-
|
353
|
-
|
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
|
354
613
|
end
|
355
614
|
|
356
|
-
# Widget management
|
357
615
|
def widgets_available
|
358
616
|
widgets_file = @repo.root/:config/"widgets.txt"
|
359
617
|
return [] unless File.exist?(widgets_file)
|
@@ -365,13 +623,13 @@ class Scriptorium::API
|
|
365
623
|
# widget_name: string name of the widget (e.g., "links", "news")
|
366
624
|
# Returns true on success, raises error on failure
|
367
625
|
|
368
|
-
raise
|
369
|
-
raise
|
370
|
-
raise
|
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?
|
371
629
|
|
372
630
|
# Validate widget name format
|
373
631
|
unless widget_name.to_s.match?(/^[a-zA-Z0-9_]+$/)
|
374
|
-
raise
|
632
|
+
raise WidgetNameInvalid(widget_name)
|
375
633
|
end
|
376
634
|
|
377
635
|
# Convert to class name (capitalize first letter)
|
@@ -381,7 +639,7 @@ class Scriptorium::API
|
|
381
639
|
begin
|
382
640
|
widget_class = eval("Scriptorium::Widget::#{widget_class_name}")
|
383
641
|
rescue NameError
|
384
|
-
raise "Widget class not found: Scriptorium::Widget::#{widget_class_name}"
|
642
|
+
raise CannotBuildWidget("Widget class not found: Scriptorium::Widget::#{widget_class_name}")
|
385
643
|
end
|
386
644
|
|
387
645
|
# Create widget instance and generate
|
@@ -395,20 +653,20 @@ class Scriptorium::API
|
|
395
653
|
|
396
654
|
def edit_layout(view = nil)
|
397
655
|
view ||= @repo.current_view&.name
|
398
|
-
raise
|
656
|
+
raise ViewTargetNil if view.nil?
|
399
657
|
edit_file("views/#{view}/layout.txt")
|
400
658
|
end
|
401
659
|
|
402
660
|
def edit_config(view = nil)
|
403
661
|
view ||= @repo.current_view&.name
|
404
|
-
raise
|
662
|
+
raise ViewTargetNil if view.nil?
|
405
663
|
edit_file("views/#{view}/config.txt")
|
406
664
|
end
|
407
665
|
|
408
666
|
def edit_widget_data(view = nil, widget)
|
409
667
|
view ||= @repo.current_view&.name
|
410
|
-
raise
|
411
|
-
raise
|
668
|
+
raise ViewTargetNil if view.nil?
|
669
|
+
raise WidgetNameNil if widget.nil?
|
412
670
|
edit_file("views/#{view}/widgets/#{widget}/list.txt")
|
413
671
|
end
|
414
672
|
|
@@ -420,15 +678,49 @@ class Scriptorium::API
|
|
420
678
|
edit_file("config/deploy.txt")
|
421
679
|
end
|
422
680
|
|
423
|
-
|
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
|
+
|
424
687
|
post = @repo.post(post_id)
|
425
|
-
source_path = "posts/#{post.num}/source.lt3"
|
426
|
-
body_path = "posts/#{post.num}/body.html"
|
688
|
+
source_path = @repo.root/"posts/#{post.num}/source.lt3"
|
689
|
+
body_path = @repo.root/"posts/#{post.num}/body.html"
|
427
690
|
|
691
|
+
# Save checksum before edit
|
428
692
|
if File.exist?(source_path)
|
429
|
-
|
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
|
430
722
|
else
|
431
|
-
|
723
|
+
false # No changes
|
432
724
|
end
|
433
725
|
end
|
434
726
|
|
@@ -436,10 +728,17 @@ class Scriptorium::API
|
|
436
728
|
|
437
729
|
def edit_file(path)
|
438
730
|
# Input validation
|
439
|
-
raise
|
440
|
-
raise
|
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
|
441
741
|
|
442
|
-
editor = ENV['EDITOR'] || 'vim'
|
443
742
|
system!(editor, path)
|
444
743
|
end
|
445
744
|
|
@@ -479,7 +778,7 @@ class Scriptorium::API
|
|
479
778
|
when :blurb
|
480
779
|
post.blurb
|
481
780
|
else
|
482
|
-
raise
|
781
|
+
raise UnknownSearchField(field)
|
483
782
|
end
|
484
783
|
|
485
784
|
# Check if the pattern matches
|
@@ -501,13 +800,215 @@ class Scriptorium::API
|
|
501
800
|
# Generation
|
502
801
|
def generate_view(view = nil)
|
503
802
|
view ||= @repo.current_view&.name
|
504
|
-
raise
|
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)
|
505
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
|
+
|
506
886
|
@repo.generate_front_page(view)
|
507
887
|
true
|
508
888
|
end
|
509
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)
|
510
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
|
511
1012
|
|
512
1013
|
# Draft management
|
513
1014
|
def drafts
|
@@ -527,17 +1028,17 @@ class Scriptorium::API
|
|
527
1028
|
# Delete a draft file
|
528
1029
|
# draft_path: path to the draft file (e.g., from drafts() method)
|
529
1030
|
|
530
|
-
raise
|
531
|
-
raise
|
1031
|
+
raise DraftPathNil if draft_path.nil?
|
1032
|
+
raise DraftPathEmpty if draft_path.to_s.strip.empty?
|
532
1033
|
|
533
1034
|
# Ensure it's actually a draft file
|
534
1035
|
unless draft_path.to_s.end_with?('-draft.lt3')
|
535
|
-
raise
|
1036
|
+
raise DraftFileInvalid(draft_path)
|
536
1037
|
end
|
537
1038
|
|
538
1039
|
# Ensure it exists
|
539
1040
|
unless File.exist?(draft_path)
|
540
|
-
raise
|
1041
|
+
raise DraftFileNotFound(draft_path)
|
541
1042
|
end
|
542
1043
|
|
543
1044
|
# Delete the file
|
@@ -545,6 +1046,45 @@ class Scriptorium::API
|
|
545
1046
|
true
|
546
1047
|
end
|
547
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
|
+
|
548
1088
|
private def extract_title_from_draft(draft_path)
|
549
1089
|
# Quick scan for .title line in draft file
|
550
1090
|
return "Untitled" unless File.exist?(draft_path)
|
@@ -621,20 +1161,1213 @@ class Scriptorium::API
|
|
621
1161
|
# # finish_draft + generate_post combined?
|
622
1162
|
# end
|
623
1163
|
|
624
|
-
#
|
625
|
-
|
626
|
-
|
627
|
-
|
628
|
-
|
629
|
-
|
630
|
-
|
631
|
-
|
632
|
-
|
633
|
-
|
634
|
-
|
635
|
-
|
636
|
-
|
637
|
-
|
638
|
-
|
639
|
-
|
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
|
+
|
640
2373
|
end
|