shoko 0.1.0
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 +7 -0
- data/.bundle/config +4 -0
- data/.bundle/config.bak +3 -0
- data/.rspec_status +42 -0
- data/.rubocop.yml +124 -0
- data/Gemfile +19 -0
- data/LICENSE +21 -0
- data/README.md +82 -0
- data/Rakefile +29 -0
- data/bin/start +15 -0
- data/lib/shoko/adapters/book_sources/document_service.rb +201 -0
- data/lib/shoko/adapters/book_sources/download_service.rb +95 -0
- data/lib/shoko/adapters/book_sources/epub/epub_resource_loader.rb +137 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/html_processor.rb +151 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/metadata_extractor.rb +53 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/entry_reader.rb +77 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/metadata_extractor.rb +67 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_context.rb +86 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_document_index.rb +75 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_document_scanner.rb +47 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_extractor.rb +46 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_label_resolver.rb +83 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_list_item.rb +55 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_result.rb +8 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_selector.rb +100 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_source_locator.rb +93 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_traversal.rb +103 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_walker.rb +56 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf_processor.rb +102 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/xhtml_content_parser.rb +661 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/xml_text_normalizer.rb +41 -0
- data/lib/shoko/adapters/book_sources/epub_document.rb +253 -0
- data/lib/shoko/adapters/book_sources/epub_finder/directory_scanner.rb +134 -0
- data/lib/shoko/adapters/book_sources/epub_finder/scanner_context.rb +28 -0
- data/lib/shoko/adapters/book_sources/epub_finder.rb +161 -0
- data/lib/shoko/adapters/book_sources/epub_importer.rb +268 -0
- data/lib/shoko/adapters/book_sources/gutendex_client.rb +150 -0
- data/lib/shoko/adapters/book_sources/library_scanner.rb +93 -0
- data/lib/shoko/adapters/book_sources/source_fingerprint.rb +57 -0
- data/lib/shoko/adapters/input/annotations/mouse_handler.rb +84 -0
- data/lib/shoko/adapters/input/command_bridge.rb +148 -0
- data/lib/shoko/adapters/input/command_factory.rb +255 -0
- data/lib/shoko/adapters/input/commands.rb +60 -0
- data/lib/shoko/adapters/input/dispatcher.rb +69 -0
- data/lib/shoko/adapters/input/input_controller.rb +250 -0
- data/lib/shoko/adapters/input/key_definitions.rb +108 -0
- data/lib/shoko/adapters/input/validators/file_path_validator.rb +81 -0
- data/lib/shoko/adapters/input/validators/terminal_size_validator.rb +76 -0
- data/lib/shoko/adapters/monitoring/logger.rb +150 -0
- data/lib/shoko/adapters/monitoring/perf_tracer.rb +183 -0
- data/lib/shoko/adapters/monitoring/performance_monitor.rb +110 -0
- data/lib/shoko/adapters/output/clipboard/clipboard_service.rb +125 -0
- data/lib/shoko/adapters/output/formatting/formatting_service/line_assembler/image_builder.rb +149 -0
- data/lib/shoko/adapters/output/formatting/formatting_service/line_assembler/text_wrapper.rb +149 -0
- data/lib/shoko/adapters/output/formatting/formatting_service/line_assembler/tokenizer.rb +91 -0
- data/lib/shoko/adapters/output/formatting/formatting_service/line_assembler.rb +144 -0
- data/lib/shoko/adapters/output/formatting/formatting_service/plain_lines_builder.rb +54 -0
- data/lib/shoko/adapters/output/formatting/formatting_service.rb +247 -0
- data/lib/shoko/adapters/output/formatting/wrapping_service.rb +228 -0
- data/lib/shoko/adapters/output/instrumentation_service.rb +52 -0
- data/lib/shoko/adapters/output/kitty/image_transcoder.rb +71 -0
- data/lib/shoko/adapters/output/kitty/kitty_graphics.rb +114 -0
- data/lib/shoko/adapters/output/kitty/kitty_image_renderer.rb +239 -0
- data/lib/shoko/adapters/output/kitty/kitty_unicode_placeholders.rb +139 -0
- data/lib/shoko/adapters/output/kitty/kitty_unicode_placeholders_diacritic_codepoints.txt +26 -0
- data/lib/shoko/adapters/output/notification_service.rb +58 -0
- data/lib/shoko/adapters/output/render_registry.rb +45 -0
- data/lib/shoko/adapters/output/rendering/models/line_geometry.rb +60 -0
- data/lib/shoko/adapters/output/rendering/models/page_rendering_context.rb +22 -0
- data/lib/shoko/adapters/output/rendering/models/render_params.rb +28 -0
- data/lib/shoko/adapters/output/rendering/models/rendering_context.rb +58 -0
- data/lib/shoko/adapters/output/terminal/buffer.rb +275 -0
- data/lib/shoko/adapters/output/terminal/constants/terminal_defaults.rb +11 -0
- data/lib/shoko/adapters/output/terminal/input/decoder.rb +347 -0
- data/lib/shoko/adapters/output/terminal/input.rb +161 -0
- data/lib/shoko/adapters/output/terminal/output.rb +105 -0
- data/lib/shoko/adapters/output/terminal/terminal.rb +167 -0
- data/lib/shoko/adapters/output/terminal/terminal_sanitizer.rb +243 -0
- data/lib/shoko/adapters/output/terminal/terminal_service.rb +138 -0
- data/lib/shoko/adapters/output/terminal/text_metrics.rb +273 -0
- data/lib/shoko/adapters/output/ui/builders/page_setup_builder.rb +47 -0
- data/lib/shoko/adapters/output/ui/components/annotation_editor_overlay/footer_renderer.rb +80 -0
- data/lib/shoko/adapters/output/ui/components/annotation_editor_overlay/geometry.rb +61 -0
- data/lib/shoko/adapters/output/ui/components/annotation_editor_overlay/note_renderer.rb +86 -0
- data/lib/shoko/adapters/output/ui/components/annotation_editor_overlay_component.rb +234 -0
- data/lib/shoko/adapters/output/ui/components/annotations_overlay/list_renderer.rb +142 -0
- data/lib/shoko/adapters/output/ui/components/annotations_overlay_component.rb +185 -0
- data/lib/shoko/adapters/output/ui/components/base_component.rb +110 -0
- data/lib/shoko/adapters/output/ui/components/component_interface.rb +80 -0
- data/lib/shoko/adapters/output/ui/components/content_component.rb +61 -0
- data/lib/shoko/adapters/output/ui/components/enhanced_popup_menu.rb +191 -0
- data/lib/shoko/adapters/output/ui/components/footer_component.rb +120 -0
- data/lib/shoko/adapters/output/ui/components/header_component.rb +46 -0
- data/lib/shoko/adapters/output/ui/components/layouts/horizontal.rb +63 -0
- data/lib/shoko/adapters/output/ui/components/layouts/vertical.rb +73 -0
- data/lib/shoko/adapters/output/ui/components/main_menu_component.rb +103 -0
- data/lib/shoko/adapters/output/ui/components/reading/base_view_renderer.rb +199 -0
- data/lib/shoko/adapters/output/ui/components/reading/config_helpers.rb +42 -0
- data/lib/shoko/adapters/output/ui/components/reading/help_renderer.rb +62 -0
- data/lib/shoko/adapters/output/ui/components/reading/inline_segment_highlighter.rb +144 -0
- data/lib/shoko/adapters/output/ui/components/reading/kitty_image_line_renderer.rb +262 -0
- data/lib/shoko/adapters/output/ui/components/reading/line_content_composer.rb +114 -0
- data/lib/shoko/adapters/output/ui/components/reading/line_drawer.rb +87 -0
- data/lib/shoko/adapters/output/ui/components/reading/line_geometry_builder.rb +41 -0
- data/lib/shoko/adapters/output/ui/components/reading/rendered_lines_recorder.rb +64 -0
- data/lib/shoko/adapters/output/ui/components/reading/single_view_renderer.rb +156 -0
- data/lib/shoko/adapters/output/ui/components/reading/split_view_renderer.rb +221 -0
- data/lib/shoko/adapters/output/ui/components/reading/view_renderer_factory.rb +20 -0
- data/lib/shoko/adapters/output/ui/components/reading/wrapped_lines_fetcher.rb +139 -0
- data/lib/shoko/adapters/output/ui/components/rect.rb +15 -0
- data/lib/shoko/adapters/output/ui/components/render_style.rb +84 -0
- data/lib/shoko/adapters/output/ui/components/screen_component.rb +24 -0
- data/lib/shoko/adapters/output/ui/components/screens/annotation_detail_screen_component.rb +175 -0
- data/lib/shoko/adapters/output/ui/components/screens/annotation_edit_screen_component.rb +221 -0
- data/lib/shoko/adapters/output/ui/components/screens/annotation_editor_screen_component.rb +205 -0
- data/lib/shoko/adapters/output/ui/components/screens/annotation_rendering_helpers.rb +190 -0
- data/lib/shoko/adapters/output/ui/components/screens/annotations_screen_component.rb +266 -0
- data/lib/shoko/adapters/output/ui/components/screens/base_screen_component.rb +49 -0
- data/lib/shoko/adapters/output/ui/components/screens/browse_screen_component.rb +319 -0
- data/lib/shoko/adapters/output/ui/components/screens/download_books_screen_component.rb +340 -0
- data/lib/shoko/adapters/output/ui/components/screens/library_screen_component.rb +205 -0
- data/lib/shoko/adapters/output/ui/components/screens/loading_overlay_component.rb +49 -0
- data/lib/shoko/adapters/output/ui/components/screens/menu_screen_component.rb +107 -0
- data/lib/shoko/adapters/output/ui/components/screens/settings_screen_component.rb +238 -0
- data/lib/shoko/adapters/output/ui/components/sidebar/annotations_tab_renderer.rb +159 -0
- data/lib/shoko/adapters/output/ui/components/sidebar/bookmarks_tab_renderer.rb +139 -0
- data/lib/shoko/adapters/output/ui/components/sidebar/tab_header_component.rb +157 -0
- data/lib/shoko/adapters/output/ui/components/sidebar/toc_tab_renderer.rb +111 -0
- data/lib/shoko/adapters/output/ui/components/sidebar/toc_tab_support.rb +1606 -0
- data/lib/shoko/adapters/output/ui/components/sidebar_panel_component.rb +217 -0
- data/lib/shoko/adapters/output/ui/components/surface.rb +88 -0
- data/lib/shoko/adapters/output/ui/components/tooltip_overlay_component.rb +224 -0
- data/lib/shoko/adapters/output/ui/components/ui/box_drawer.rb +32 -0
- data/lib/shoko/adapters/output/ui/components/ui/list_helpers.rb +33 -0
- data/lib/shoko/adapters/output/ui/components/ui/overlay_layout.rb +79 -0
- data/lib/shoko/adapters/output/ui/components/ui/text_utils.rb +46 -0
- data/lib/shoko/adapters/output/ui/constants/highlighting.rb +21 -0
- data/lib/shoko/adapters/output/ui/constants/messages.rb +12 -0
- data/lib/shoko/adapters/output/ui/constants/themes.rb +79 -0
- data/lib/shoko/adapters/output/ui/constants/ui_constants.rb +85 -0
- data/lib/shoko/adapters/output/ui/rendering/frame_coordinator.rb +42 -0
- data/lib/shoko/adapters/output/ui/rendering/reader_render_coordinator.rb +169 -0
- data/lib/shoko/adapters/output/ui/rendering/render_pipeline.rb +55 -0
- data/lib/shoko/adapters/storage/atomic_file_writer.rb +43 -0
- data/lib/shoko/adapters/storage/background_worker.rb +66 -0
- data/lib/shoko/adapters/storage/book_cache_pipeline.rb +653 -0
- data/lib/shoko/adapters/storage/cache/epub/memory_cache.rb +99 -0
- data/lib/shoko/adapters/storage/cache/epub/persistence.rb +131 -0
- data/lib/shoko/adapters/storage/cache/epub/serializer/deserialize.rb +225 -0
- data/lib/shoko/adapters/storage/cache/epub/serializer/helpers.rb +63 -0
- data/lib/shoko/adapters/storage/cache/epub/serializer/serialize.rb +83 -0
- data/lib/shoko/adapters/storage/cache/epub/serializer.rb +5 -0
- data/lib/shoko/adapters/storage/cache/epub/source_reference.rb +58 -0
- data/lib/shoko/adapters/storage/cache_paths.rb +21 -0
- data/lib/shoko/adapters/storage/cache_pointer_manager.rb +60 -0
- data/lib/shoko/adapters/storage/config_paths.rb +30 -0
- data/lib/shoko/adapters/storage/epub_cache.rb +195 -0
- data/lib/shoko/adapters/storage/file_writer_service.rb +47 -0
- data/lib/shoko/adapters/storage/json_cache_store/chapters.rb +141 -0
- data/lib/shoko/adapters/storage/json_cache_store/layouts.rb +67 -0
- data/lib/shoko/adapters/storage/json_cache_store/manifest.rb +42 -0
- data/lib/shoko/adapters/storage/json_cache_store/payload_helpers.rb +113 -0
- data/lib/shoko/adapters/storage/json_cache_store/resources.rb +84 -0
- data/lib/shoko/adapters/storage/json_cache_store.rb +167 -0
- data/lib/shoko/adapters/storage/lazy_file_string.rb +65 -0
- data/lib/shoko/adapters/storage/pagination_cache.rb +127 -0
- data/lib/shoko/adapters/storage/recent_files.rb +78 -0
- data/lib/shoko/adapters/storage/repositories/annotation_repository.rb +182 -0
- data/lib/shoko/adapters/storage/repositories/base_repository.rb +81 -0
- data/lib/shoko/adapters/storage/repositories/bookmark_repository.rb +132 -0
- data/lib/shoko/adapters/storage/repositories/cached_library_repository.rb +129 -0
- data/lib/shoko/adapters/storage/repositories/config_repository.rb +262 -0
- data/lib/shoko/adapters/storage/repositories/progress_repository.rb +166 -0
- data/lib/shoko/adapters/storage/repositories/storage/annotation_file_store.rb +128 -0
- data/lib/shoko/adapters/storage/repositories/storage/bookmark_file_store.rb +109 -0
- data/lib/shoko/adapters/storage/repositories/storage/file_store_utils.rb +20 -0
- data/lib/shoko/adapters/storage/repositories/storage/progress_file_store.rb +59 -0
- data/lib/shoko/application/annotation_editor_overlay_session.rb +138 -0
- data/lib/shoko/application/cli.rb +134 -0
- data/lib/shoko/application/controllers/menu/input_controller.rb +189 -0
- data/lib/shoko/application/controllers/menu/state_controller.rb +642 -0
- data/lib/shoko/application/controllers/menu_controller.rb +469 -0
- data/lib/shoko/application/controllers/mouseable_reader.rb +377 -0
- data/lib/shoko/application/controllers/reader_controller.rb +449 -0
- data/lib/shoko/application/controllers/state_controller.rb +410 -0
- data/lib/shoko/application/controllers/ui_controller.rb +782 -0
- data/lib/shoko/application/dependency_container.rb +301 -0
- data/lib/shoko/application/infrastructure/event_bus.rb +80 -0
- data/lib/shoko/application/infrastructure/observer_state_store.rb +136 -0
- data/lib/shoko/application/infrastructure/state_store.rb +413 -0
- data/lib/shoko/application/main_menu/menu_progress_presenter.rb +83 -0
- data/lib/shoko/application/pending_jump_handler.rb +122 -0
- data/lib/shoko/application/reader_lifecycle.rb +65 -0
- data/lib/shoko/application/reader_startup_orchestrator.rb +113 -0
- data/lib/shoko/application/selectors/config_selectors.rb +62 -0
- data/lib/shoko/application/selectors/menu_selectors.rb +62 -0
- data/lib/shoko/application/selectors/reader_selectors.rb +186 -0
- data/lib/shoko/application/state/actions/base_action.rb +24 -0
- data/lib/shoko/application/state/actions/quit_to_menu_action.rb +16 -0
- data/lib/shoko/application/state/actions/switch_reader_mode_action.rb +22 -0
- data/lib/shoko/application/state/actions/toggle_view_mode_action.rb +31 -0
- data/lib/shoko/application/state/actions/update_annotation_editor_overlay_action.rb +27 -0
- data/lib/shoko/application/state/actions/update_annotations_action.rb +20 -0
- data/lib/shoko/application/state/actions/update_annotations_overlay_action.rb +27 -0
- data/lib/shoko/application/state/actions/update_bookmarks_action.rb +20 -0
- data/lib/shoko/application/state/actions/update_chapter_action.rb +24 -0
- data/lib/shoko/application/state/actions/update_config_action.rb +22 -0
- data/lib/shoko/application/state/actions/update_field_helpers.rb +26 -0
- data/lib/shoko/application/state/actions/update_menu_action.rb +21 -0
- data/lib/shoko/application/state/actions/update_message_action.rb +35 -0
- data/lib/shoko/application/state/actions/update_page_action.rb +21 -0
- data/lib/shoko/application/state/actions/update_pagination_state_action.rb +21 -0
- data/lib/shoko/application/state/actions/update_popup_menu_action.rb +27 -0
- data/lib/shoko/application/state/actions/update_reader_meta_action.rb +21 -0
- data/lib/shoko/application/state/actions/update_reader_mode_action.rb +20 -0
- data/lib/shoko/application/state/actions/update_rendered_lines_action.rb +40 -0
- data/lib/shoko/application/state/actions/update_selection_action.rb +27 -0
- data/lib/shoko/application/state/actions/update_selections_action.rb +21 -0
- data/lib/shoko/application/state/actions/update_sidebar_action.rb +34 -0
- data/lib/shoko/application/state/actions/update_ui_loading_action.rb +23 -0
- data/lib/shoko/application/ui/reader_view_model_builder.rb +74 -0
- data/lib/shoko/application/ui/view_models/reader_view_model.rb +177 -0
- data/lib/shoko/application/unified_application.rb +48 -0
- data/lib/shoko/application/use_cases/catalog_service.rb +117 -0
- data/lib/shoko/application/use_cases/commands/annotation_editor_commands.rb +105 -0
- data/lib/shoko/application/use_cases/commands/application_commands.rb +208 -0
- data/lib/shoko/application/use_cases/commands/base_command.rb +166 -0
- data/lib/shoko/application/use_cases/commands/bookmark_commands.rb +114 -0
- data/lib/shoko/application/use_cases/commands/conditional_navigation_commands.rb +57 -0
- data/lib/shoko/application/use_cases/commands/menu_commands.rb +170 -0
- data/lib/shoko/application/use_cases/commands/navigation_commands.rb +183 -0
- data/lib/shoko/application/use_cases/commands/reader_commands.rb +46 -0
- data/lib/shoko/application/use_cases/commands/sidebar_commands.rb +55 -0
- data/lib/shoko/application/use_cases/settings_service.rb +123 -0
- data/lib/shoko/core/events/annotation_events.rb +94 -0
- data/lib/shoko/core/events/base_domain_event.rb +169 -0
- data/lib/shoko/core/events/bookmark_events.rb +41 -0
- data/lib/shoko/core/events/domain_event_bus.rb +163 -0
- data/lib/shoko/core/events/progress_events.rb +108 -0
- data/lib/shoko/core/models/bookmark.rb +36 -0
- data/lib/shoko/core/models/bookmark_data.rb +10 -0
- data/lib/shoko/core/models/chapter.rb +25 -0
- data/lib/shoko/core/models/content_block.rb +44 -0
- data/lib/shoko/core/models/reader_settings.rb +20 -0
- data/lib/shoko/core/models/selection_anchor.rb +73 -0
- data/lib/shoko/core/models/toc_entry.rb +14 -0
- data/lib/shoko/core/ports/annotation_repository.rb +0 -0
- data/lib/shoko/core/ports/book_repository.rb +0 -0
- data/lib/shoko/core/ports/book_source.rb +0 -0
- data/lib/shoko/core/ports/bookmark_repository.rb +0 -0
- data/lib/shoko/core/ports/cache.rb +0 -0
- data/lib/shoko/core/ports/input_handler.rb +0 -0
- data/lib/shoko/core/ports/renderer.rb +0 -0
- data/lib/shoko/core/ports/storage.rb +0 -0
- data/lib/shoko/core/services/annotation_service.rb +102 -0
- data/lib/shoko/core/services/base_service.rb +60 -0
- data/lib/shoko/core/services/bookmark_service.rb +267 -0
- data/lib/shoko/core/services/coordinate_service.rb +265 -0
- data/lib/shoko/core/services/layout_service.rb +95 -0
- data/lib/shoko/core/services/navigation/absolute_change_applier.rb +96 -0
- data/lib/shoko/core/services/navigation/absolute_layout.rb +101 -0
- data/lib/shoko/core/services/navigation/absolute_strategy.rb +179 -0
- data/lib/shoko/core/services/navigation/context_builder.rb +52 -0
- data/lib/shoko/core/services/navigation/context_helpers.rb +63 -0
- data/lib/shoko/core/services/navigation/dynamic_change_applier.rb +50 -0
- data/lib/shoko/core/services/navigation/dynamic_strategy.rb +51 -0
- data/lib/shoko/core/services/navigation/image_offset_snapper.rb +150 -0
- data/lib/shoko/core/services/navigation/nav_context.rb +27 -0
- data/lib/shoko/core/services/navigation/state_updater.rb +29 -0
- data/lib/shoko/core/services/navigation/strategy_factory.rb +20 -0
- data/lib/shoko/core/services/navigation_service.rb +150 -0
- data/lib/shoko/core/services/page_calculator_service.rb +242 -0
- data/lib/shoko/core/services/pagination/internal/absolute_page_map_builder.rb +28 -0
- data/lib/shoko/core/services/pagination/internal/chapter_cache.rb +60 -0
- data/lib/shoko/core/services/pagination/internal/dynamic_page_map_builder.rb +157 -0
- data/lib/shoko/core/services/pagination/internal/layout_metrics_calculator.rb +73 -0
- data/lib/shoko/core/services/pagination/internal/page_hydrator.rb +145 -0
- data/lib/shoko/core/services/pagination/internal/pagination_workflow.rb +152 -0
- data/lib/shoko/core/services/pagination/page_info_calculator.rb +247 -0
- data/lib/shoko/core/services/pagination/pagination_cache_preloader.rb +173 -0
- data/lib/shoko/core/services/pagination/pagination_coordinator.rb +202 -0
- data/lib/shoko/core/services/pagination/pagination_orchestrator.rb +291 -0
- data/lib/shoko/core/services/pagination.rb +10 -0
- data/lib/shoko/core/services/progress_helper.rb +22 -0
- data/lib/shoko/core/services/selection_service.rb +126 -0
- data/lib/shoko/core/validator.rb +76 -0
- data/lib/shoko/shared/errors.rb +97 -0
- data/lib/shoko/shared/version.rb +5 -0
- data/lib/shoko/test_support/terminal_double.rb +175 -0
- data/lib/shoko/test_support/test_mode.rb +78 -0
- data/lib/shoko.rb +279 -0
- data/lib/zip.rb +732 -0
- data/zip.rb +5 -0
- metadata +370 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
require_relative 'atomic_file_writer'
|
|
6
|
+
require_relative '../monitoring/logger.rb'
|
|
7
|
+
|
|
8
|
+
module Shoko
|
|
9
|
+
module Adapters::Storage
|
|
10
|
+
# Manages pointer files that reference serialized cache payloads on disk.
|
|
11
|
+
class CachePointerManager
|
|
12
|
+
POINTER_FORMAT = 'shoko-cache'
|
|
13
|
+
LEGACY_POINTER_FORMATS = ['reader-cache', 'reader-marshal-cache'].freeze
|
|
14
|
+
POINTER_VERSION = 2
|
|
15
|
+
POINTER_KEYS = %w[format version sha256 source_path generated_at engine].freeze
|
|
16
|
+
SUPPORTED_FORMATS = [POINTER_FORMAT, *LEGACY_POINTER_FORMATS].freeze
|
|
17
|
+
SUPPORTED_ENGINES = %w[json marshal].freeze
|
|
18
|
+
SHA256_HEX_PATTERN = /\A[0-9a-f]{64}\z/i
|
|
19
|
+
|
|
20
|
+
def initialize(path)
|
|
21
|
+
@path = path
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
attr_reader :path
|
|
25
|
+
|
|
26
|
+
def read
|
|
27
|
+
return nil unless File.exist?(path)
|
|
28
|
+
|
|
29
|
+
content = File.read(path)
|
|
30
|
+
return nil if content.nil? || content.empty?
|
|
31
|
+
|
|
32
|
+
data = JSON.parse(content)
|
|
33
|
+
return nil unless valid_pointer?(data)
|
|
34
|
+
|
|
35
|
+
data
|
|
36
|
+
rescue JSON::ParserError
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def write(data)
|
|
41
|
+
AtomicFileWriter.write_using(path) do |io|
|
|
42
|
+
io.write(JSON.generate(data))
|
|
43
|
+
end
|
|
44
|
+
rescue StandardError => e
|
|
45
|
+
Shoko::Adapters::Monitoring::Logger.debug('CachePointerManager: write failed', path:, error: e.message)
|
|
46
|
+
false
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def valid_pointer?(data)
|
|
52
|
+
POINTER_KEYS.all? { |key| data.key?(key) } &&
|
|
53
|
+
SUPPORTED_FORMATS.include?(data['format'].to_s) &&
|
|
54
|
+
SUPPORTED_ENGINES.include?(data['engine'].to_s) &&
|
|
55
|
+
data['version'].to_i == POINTER_VERSION &&
|
|
56
|
+
data['sha256'].to_s.match?(SHA256_HEX_PATTERN)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shoko
|
|
4
|
+
module Adapters::Storage
|
|
5
|
+
# Resolves XDG-aware configuration paths for Shoko.
|
|
6
|
+
module ConfigPaths
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Root directory for Shoko config: ${XDG_CONFIG_HOME:-~/.config}/shoko
|
|
10
|
+
def config_root
|
|
11
|
+
env_home = ENV.fetch('XDG_CONFIG_HOME', nil)
|
|
12
|
+
config_root = if env_home && !env_home.empty?
|
|
13
|
+
env_home
|
|
14
|
+
else
|
|
15
|
+
File.join(Dir.home, '.config')
|
|
16
|
+
end
|
|
17
|
+
File.join(config_root, 'shoko')
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Downloaded books directory under config root.
|
|
21
|
+
def downloads_root
|
|
22
|
+
File.join(config_root, 'downloads')
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def config_path(*segments)
|
|
26
|
+
File.join(config_root, *segments)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
require_relative '../../core/models/chapter.rb'
|
|
7
|
+
require_relative '../../core/models/toc_entry.rb'
|
|
8
|
+
require_relative '../../core/models/content_block.rb'
|
|
9
|
+
require_relative '../../shared/errors.rb'
|
|
10
|
+
require_relative '../output/terminal/terminal_sanitizer.rb'
|
|
11
|
+
require_relative 'cache_paths'
|
|
12
|
+
require_relative 'json_cache_store'
|
|
13
|
+
require_relative 'cache_pointer_manager'
|
|
14
|
+
require_relative 'lazy_file_string'
|
|
15
|
+
require_relative '../monitoring/logger.rb'
|
|
16
|
+
|
|
17
|
+
module Shoko
|
|
18
|
+
module Adapters::Storage
|
|
19
|
+
# JSON-backed cache for imported EPUB data and derived pagination layouts.
|
|
20
|
+
# Pointer files keep lightweight `.cache` discovery while the bulk payload
|
|
21
|
+
# lives in JSON + binary blobs.
|
|
22
|
+
class EpubCache
|
|
23
|
+
CACHE_VERSION = 3
|
|
24
|
+
CACHE_EXTENSION = '.cache'
|
|
25
|
+
SHA256_HEX_PATTERN = /\A[0-9a-f]{64}\z/i
|
|
26
|
+
|
|
27
|
+
# Immutable representation of the persisted cache payload.
|
|
28
|
+
CachePayload = Struct.new(
|
|
29
|
+
:version,
|
|
30
|
+
:source_sha256,
|
|
31
|
+
:source_path,
|
|
32
|
+
:source_mtime,
|
|
33
|
+
:generated_at,
|
|
34
|
+
:book,
|
|
35
|
+
:layouts,
|
|
36
|
+
keyword_init: true
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Normalized in-memory representation of a parsed EPUB.
|
|
40
|
+
BookData = Struct.new(
|
|
41
|
+
:title,
|
|
42
|
+
:language,
|
|
43
|
+
:authors,
|
|
44
|
+
:chapters,
|
|
45
|
+
:toc_entries,
|
|
46
|
+
:opf_path,
|
|
47
|
+
:spine,
|
|
48
|
+
:chapter_hrefs,
|
|
49
|
+
:resources,
|
|
50
|
+
:metadata,
|
|
51
|
+
:container_path,
|
|
52
|
+
:container_xml,
|
|
53
|
+
:chapters_generation,
|
|
54
|
+
keyword_init: true
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
class << self
|
|
58
|
+
def cache_extension = CACHE_EXTENSION
|
|
59
|
+
|
|
60
|
+
def cache_file?(path)
|
|
61
|
+
File.file?(path) && File.extname(path).casecmp(CACHE_EXTENSION).zero?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def cache_path_for_sha(sha, cache_root: CachePaths.cache_root)
|
|
65
|
+
normalized = sha.to_s.strip
|
|
66
|
+
return nil unless normalized.match?(SHA256_HEX_PATTERN)
|
|
67
|
+
|
|
68
|
+
File.join(cache_root, "#{normalized.downcase}#{CACHE_EXTENSION}")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
attr_reader :cache_path, :source_path
|
|
73
|
+
|
|
74
|
+
def initialize(path, cache_root: CachePaths.cache_root, store: nil)
|
|
75
|
+
@cache_root = cache_root
|
|
76
|
+
@cache_store = store || JsonCacheStore.new(cache_root: @cache_root)
|
|
77
|
+
@raw_path = File.expand_path(path)
|
|
78
|
+
@payload_cache = nil
|
|
79
|
+
@layout_cache = {}
|
|
80
|
+
@pointer_metadata = nil
|
|
81
|
+
setup_source_reference
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Load pointer payload without validating source. Used by cached-library
|
|
85
|
+
# direct opens.
|
|
86
|
+
def read_cache(strict: false)
|
|
87
|
+
payload = load_payload
|
|
88
|
+
return nil unless payload
|
|
89
|
+
|
|
90
|
+
return payload unless strict
|
|
91
|
+
|
|
92
|
+
payload_valid?(payload) ? payload : invalidate_and_nil
|
|
93
|
+
rescue Shoko::CacheLoadError
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Load payload and ensure it matches the original EPUB file.
|
|
98
|
+
def load_for_source(strict: false)
|
|
99
|
+
payload = load_payload
|
|
100
|
+
return nil unless payload
|
|
101
|
+
|
|
102
|
+
if payload_valid?(payload) && payload_matches_source?(payload, strict:)
|
|
103
|
+
payload
|
|
104
|
+
else
|
|
105
|
+
invalidate_and_nil
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def write_book!(book_data)
|
|
110
|
+
ensure_sha!
|
|
111
|
+
return nil unless persist_payload(book_data, layouts_hash: {})
|
|
112
|
+
|
|
113
|
+
@layout_cache = {}
|
|
114
|
+
@payload_cache = load_payload_from_store(@source_sha)
|
|
115
|
+
rescue StandardError => e
|
|
116
|
+
Shoko::Adapters::Monitoring::Logger.debug('EpubCache: failed to write cache', path: @cache_path, error: e.message)
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def load_layout(key)
|
|
121
|
+
key_str = key.to_s
|
|
122
|
+
return deep_dup(@layout_cache[key_str]) if @layout_cache.key?(key_str)
|
|
123
|
+
|
|
124
|
+
payload = @cache_store.load_layout(@source_sha, key_str)
|
|
125
|
+
return nil unless payload
|
|
126
|
+
|
|
127
|
+
cache_layout!(key_str, payload)
|
|
128
|
+
deep_dup(payload)
|
|
129
|
+
rescue StandardError
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def mutate_layouts!
|
|
134
|
+
ensure_sha!
|
|
135
|
+
updated_layouts = nil
|
|
136
|
+
success = @cache_store.mutate_layouts(@source_sha) do |layouts|
|
|
137
|
+
yield layouts
|
|
138
|
+
updated_layouts = layouts
|
|
139
|
+
end
|
|
140
|
+
update_layout_cache_from_layouts(updated_layouts) if success
|
|
141
|
+
success
|
|
142
|
+
rescue StandardError => e
|
|
143
|
+
Shoko::Adapters::Monitoring::Logger.debug('EpubCache: failed to update layouts', path: @cache_path, error: e.message)
|
|
144
|
+
false
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def invalidate!
|
|
148
|
+
ensure_sha!
|
|
149
|
+
@cache_store.delete_payload(@source_sha) if @source_sha
|
|
150
|
+
FileUtils.rm_f(@cache_path) if @cache_path && File.exist?(@cache_path)
|
|
151
|
+
ensure
|
|
152
|
+
@payload_cache = nil
|
|
153
|
+
@layout_cache = {}
|
|
154
|
+
@pointer_metadata = nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def cache_file?
|
|
158
|
+
@source_type == :cache_pointer
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def sha256
|
|
162
|
+
ensure_sha!
|
|
163
|
+
@source_sha
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def layout_keys
|
|
167
|
+
ensure_sha!
|
|
168
|
+
keys = @cache_store.fetch_layouts(@source_sha).keys
|
|
169
|
+
keys |= @layout_cache.keys
|
|
170
|
+
keys
|
|
171
|
+
rescue StandardError
|
|
172
|
+
[]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def chapters_complete?(expected_count, generation: nil)
|
|
176
|
+
ensure_sha!
|
|
177
|
+
gen = generation
|
|
178
|
+
if gen.nil? && @payload_cache&.book.respond_to?(:chapters_generation)
|
|
179
|
+
gen = @payload_cache.book.chapters_generation
|
|
180
|
+
end
|
|
181
|
+
return false if gen.to_s.strip.empty?
|
|
182
|
+
|
|
183
|
+
@cache_store.respond_to?(:chapters_complete?) &&
|
|
184
|
+
@cache_store.chapters_complete?(@source_sha, gen, expected_count: expected_count)
|
|
185
|
+
rescue StandardError
|
|
186
|
+
false
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
require_relative 'cache/epub/serializer'
|
|
193
|
+
require_relative 'cache/epub/source_reference'
|
|
194
|
+
require_relative 'cache/epub/memory_cache'
|
|
195
|
+
require_relative 'cache/epub/persistence'
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require_relative '../../core/services/base_service.rb'
|
|
5
|
+
|
|
6
|
+
module Shoko
|
|
7
|
+
module Adapters::Storage
|
|
8
|
+
# Provides atomic file writing for domain repositories without coupling them
|
|
9
|
+
# to infrastructure implementations.
|
|
10
|
+
class FileWriterService < BaseService
|
|
11
|
+
def initialize(dependencies)
|
|
12
|
+
super
|
|
13
|
+
@writer = resolve_optional(:atomic_file_writer)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Write payload to path atomically when possible.
|
|
17
|
+
#
|
|
18
|
+
# Ensures the target directory exists before delegating to the underlying writer.
|
|
19
|
+
def write(path, payload)
|
|
20
|
+
dir = File.dirname(path)
|
|
21
|
+
FileUtils.mkdir_p(dir)
|
|
22
|
+
|
|
23
|
+
if @writer.respond_to?(:write)
|
|
24
|
+
@writer.write(path, payload)
|
|
25
|
+
else
|
|
26
|
+
default_write(path, payload)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def resolve_optional(name)
|
|
33
|
+
resolve(name)
|
|
34
|
+
rescue StandardError
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def default_write(path, payload)
|
|
39
|
+
tmp = "#{path}.tmp"
|
|
40
|
+
File.write(tmp, payload)
|
|
41
|
+
FileUtils.mv(tmp, path)
|
|
42
|
+
ensure
|
|
43
|
+
FileUtils.rm_f(tmp) if tmp && File.exist?(tmp)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shoko
|
|
4
|
+
module Adapters::Storage
|
|
5
|
+
# Chapter persistence helpers for `JsonCacheStore`.
|
|
6
|
+
class JsonCacheStore
|
|
7
|
+
CHAPTER_ROW_EXCLUDED_KEYS = %w[raw_content lines_json blocks_json].freeze
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def chapters_dir(sha)
|
|
12
|
+
File.join(@cache_root, CHAPTERS_DIRNAME, normalize_sha!(sha))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def chapter_generation_dir(sha, generation)
|
|
16
|
+
gen = generation.to_s.strip
|
|
17
|
+
raise ArgumentError, 'chapter generation is invalid' unless CHAPTERS_GENERATION_PATTERN.match?(gen)
|
|
18
|
+
|
|
19
|
+
File.join(chapters_dir(sha), gen.downcase)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def chapter_raw_dir(sha, generation)
|
|
23
|
+
File.join(chapter_generation_dir(sha, generation), CHAPTERS_RAW_DIRNAME)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def chapter_raw_file(sha, generation, position)
|
|
27
|
+
idx = Integer(position)
|
|
28
|
+
raise ArgumentError, 'chapter position must be >= 0' if idx.negative?
|
|
29
|
+
|
|
30
|
+
name = format("%0#{CHAPTER_FILENAME_DIGITS}d.xhtml", idx)
|
|
31
|
+
File.join(chapter_raw_dir(sha, generation), name)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def normalize_chapter_generation(generation)
|
|
35
|
+
gen = generation.to_s.strip.downcase
|
|
36
|
+
CHAPTERS_GENERATION_PATTERN.match?(gen) ? gen : nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def normalize_expected_chapter_count(expected_count)
|
|
40
|
+
count = expected_count.to_i
|
|
41
|
+
return nil if count.negative?
|
|
42
|
+
return nil if count > MAX_CHAPTER_COUNT
|
|
43
|
+
|
|
44
|
+
count
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def chapter_files_complete?(sha, generation, expected_count)
|
|
48
|
+
raw_dir = chapter_raw_dir(sha, generation)
|
|
49
|
+
return false unless Dir.exist?(raw_dir)
|
|
50
|
+
|
|
51
|
+
expected_count.times do |idx|
|
|
52
|
+
return false unless File.file?(chapter_raw_file(sha, generation, idx))
|
|
53
|
+
end
|
|
54
|
+
true
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def persist_chapters(sha, chapter_rows)
|
|
58
|
+
chapter_rows = Array(chapter_rows)
|
|
59
|
+
generation = new_chapter_generation
|
|
60
|
+
return [[], generation, 0] if chapter_rows.empty?
|
|
61
|
+
|
|
62
|
+
FileUtils.mkdir_p(chapter_raw_dir(sha, generation))
|
|
63
|
+
rows, total_bytes = persist_chapter_rows(sha, generation, chapter_rows)
|
|
64
|
+
[rows, generation, total_bytes]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def new_chapter_generation
|
|
68
|
+
SecureRandom.hex(CHAPTERS_GENERATION_BYTES)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def persist_chapter_rows(sha, generation, chapter_rows)
|
|
72
|
+
rows = []
|
|
73
|
+
total_bytes = 0
|
|
74
|
+
chapter_rows.each do |row|
|
|
75
|
+
filtered, bytesize = persist_chapter_row(sha, generation, row)
|
|
76
|
+
rows << filtered
|
|
77
|
+
total_bytes += bytesize
|
|
78
|
+
end
|
|
79
|
+
[rows, total_bytes]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def persist_chapter_row(sha, generation, row)
|
|
83
|
+
idx = chapter_row_index(row)
|
|
84
|
+
text = chapter_row_raw_content(row).to_s
|
|
85
|
+
AtomicFileWriter.write(chapter_raw_file(sha, generation, idx), text)
|
|
86
|
+
[filtered_chapter_index_row(row, idx), text.bytesize]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def chapter_row_index(row)
|
|
90
|
+
raise ArgumentError, 'chapter row must be a Hash' unless row.is_a?(Hash)
|
|
91
|
+
|
|
92
|
+
position = row[:position] || row['position']
|
|
93
|
+
idx = Integer(position)
|
|
94
|
+
raise ArgumentError, 'chapter position must be >= 0' if idx.negative?
|
|
95
|
+
|
|
96
|
+
idx
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def chapter_row_raw_content(row)
|
|
100
|
+
row[:raw_content] || row['raw_content']
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def filtered_chapter_index_row(row, idx)
|
|
104
|
+
filtered = {}
|
|
105
|
+
row.each do |key, value|
|
|
106
|
+
key_str = key.to_s
|
|
107
|
+
next if CHAPTER_ROW_EXCLUDED_KEYS.include?(key_str)
|
|
108
|
+
|
|
109
|
+
filtered[key_str] = value
|
|
110
|
+
end
|
|
111
|
+
filtered['position'] = idx
|
|
112
|
+
filtered
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def cleanup_old_chapter_generations(sha, keep:)
|
|
116
|
+
base = chapters_dir(sha)
|
|
117
|
+
return unless Dir.exist?(base)
|
|
118
|
+
|
|
119
|
+
keep_name = keep.to_s.strip.downcase
|
|
120
|
+
Dir.children(base).each do |entry|
|
|
121
|
+
next if entry == keep_name
|
|
122
|
+
|
|
123
|
+
path = File.join(base, entry)
|
|
124
|
+
next unless File.directory?(path)
|
|
125
|
+
next unless CHAPTERS_GENERATION_PATTERN.match?(entry)
|
|
126
|
+
|
|
127
|
+
FileUtils.rm_rf(path)
|
|
128
|
+
end
|
|
129
|
+
rescue StandardError
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def cleanup_failed_chapter_generation(sha, generation)
|
|
134
|
+
path = chapter_generation_dir(sha, generation)
|
|
135
|
+
FileUtils.rm_rf(path)
|
|
136
|
+
rescue StandardError
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shoko
|
|
4
|
+
module Adapters::Storage
|
|
5
|
+
# Layout storage helpers for `JsonCacheStore`.
|
|
6
|
+
class JsonCacheStore
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def layouts_dir(sha)
|
|
10
|
+
File.join(@cache_root, 'layouts', normalize_sha!(sha))
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def layout_file(sha, key)
|
|
14
|
+
File.join(layouts_dir(sha), "#{normalize_layout_key!(key)}.json")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def layout_key_for_entry(entry)
|
|
18
|
+
return nil unless entry.end_with?('.json')
|
|
19
|
+
|
|
20
|
+
key = entry.delete_suffix('.json')
|
|
21
|
+
layout_key_valid?(key) ? key : nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def read_layout_payload(dir, entry, sha:, key:)
|
|
25
|
+
JSON.parse(File.read(File.join(dir, entry)))
|
|
26
|
+
rescue StandardError => e
|
|
27
|
+
Shoko::Adapters::Monitoring::Logger.debug('JsonCacheStore: layout parse failed', sha: sha.to_s, key: key.to_s, error: e.message)
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def write_layouts(sha, layouts_hash)
|
|
32
|
+
dir = layouts_dir(sha)
|
|
33
|
+
FileUtils.mkdir_p(dir)
|
|
34
|
+
existing = Dir.exist?(dir) ? Dir.children(dir).select { |entry| entry.end_with?('.json') } : []
|
|
35
|
+
|
|
36
|
+
written = []
|
|
37
|
+
layouts_hash.each do |key, payload|
|
|
38
|
+
normalized_key = normalize_layout_key!(key)
|
|
39
|
+
file = File.join(dir, "#{normalized_key}.json")
|
|
40
|
+
AtomicFileWriter.write(file, JSON.generate(payload))
|
|
41
|
+
written << "#{normalized_key}.json"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
stale = existing - written
|
|
45
|
+
stale.each { |entry| FileUtils.rm_f(File.join(dir, entry)) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def layout_key_valid?(key)
|
|
49
|
+
normalize_layout_key!(key)
|
|
50
|
+
true
|
|
51
|
+
rescue ArgumentError
|
|
52
|
+
false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def normalize_layout_key!(key)
|
|
56
|
+
value = key.to_s
|
|
57
|
+
raise ArgumentError, 'layout key is blank' if value.empty?
|
|
58
|
+
raise ArgumentError, 'layout key too long' if value.bytesize > MAX_LAYOUT_KEY_BYTES
|
|
59
|
+
raise ArgumentError, 'layout key contains null byte' if value.include?("\0")
|
|
60
|
+
raise ArgumentError, 'layout key contains path separator' if value.include?('/') || value.include?('\\')
|
|
61
|
+
raise ArgumentError, 'layout key has invalid characters' unless LAYOUT_KEY_PATTERN.match?(value)
|
|
62
|
+
|
|
63
|
+
value
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shoko
|
|
4
|
+
module Adapters::Storage
|
|
5
|
+
# Manifest helpers for `JsonCacheStore` (cache listing).
|
|
6
|
+
class JsonCacheStore
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def manifest_path
|
|
10
|
+
File.join(@cache_root, MANIFEST_FILENAME)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def update_manifest(metadata_row, cache_size_bytes:)
|
|
14
|
+
row = metadata_row.merge('cache_size_bytes' => cache_size_bytes.to_i)
|
|
15
|
+
manifest = self.class.manifest_rows(@cache_root)
|
|
16
|
+
manifest.reject! { |entry| entry['source_sha'] == row['source_sha'] }
|
|
17
|
+
manifest << row
|
|
18
|
+
AtomicFileWriter.write(manifest_path, JSON.generate(manifest))
|
|
19
|
+
rescue StandardError => e
|
|
20
|
+
Shoko::Adapters::Monitoring::Logger.debug('JsonCacheStore: manifest write failed', error: e.message)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def remove_from_manifest(sha)
|
|
24
|
+
manifest = self.class.manifest_rows(@cache_root)
|
|
25
|
+
manifest.reject! { |entry| entry['source_sha'] == sha }
|
|
26
|
+
AtomicFileWriter.write(manifest_path, JSON.generate(manifest))
|
|
27
|
+
rescue StandardError
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.read_manifest_file(path)
|
|
32
|
+
return [] unless File.file?(path)
|
|
33
|
+
|
|
34
|
+
data = JSON.parse(File.read(path))
|
|
35
|
+
data.is_a?(Array) ? data : []
|
|
36
|
+
rescue StandardError
|
|
37
|
+
[]
|
|
38
|
+
end
|
|
39
|
+
private_class_method :read_manifest_file
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shoko
|
|
4
|
+
module Adapters::Storage
|
|
5
|
+
# Payload IO + normalization helpers for `JsonCacheStore`.
|
|
6
|
+
class JsonCacheStore
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def payload_path(sha)
|
|
10
|
+
File.join(@cache_root, "#{normalize_sha!(sha)}.json")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def load_payload_data(sha)
|
|
14
|
+
path = payload_path(sha)
|
|
15
|
+
return nil unless File.file?(path)
|
|
16
|
+
|
|
17
|
+
data = JSON.parse(File.read(path))
|
|
18
|
+
valid_payload_file?(data) ? data : nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def valid_payload_file?(data)
|
|
22
|
+
return false unless data.is_a?(Hash)
|
|
23
|
+
return false unless payload_header_valid?(data)
|
|
24
|
+
|
|
25
|
+
metadata_row = data['metadata_row']
|
|
26
|
+
return false unless metadata_row.is_a?(Hash)
|
|
27
|
+
return false unless payload_metadata_valid?(metadata_row)
|
|
28
|
+
return false unless payload_collections_valid?(data)
|
|
29
|
+
|
|
30
|
+
true
|
|
31
|
+
rescue StandardError
|
|
32
|
+
false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def payload_header_valid?(data)
|
|
36
|
+
data['format'] == FORMAT &&
|
|
37
|
+
data['format_version'].to_i == FORMAT_VERSION &&
|
|
38
|
+
data['engine'].to_s == ENGINE
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def payload_metadata_valid?(metadata_row)
|
|
42
|
+
CHAPTERS_GENERATION_PATTERN.match?(metadata_row['chapters_generation'].to_s)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def payload_collections_valid?(data)
|
|
46
|
+
data['chapters'].is_a?(Array) && data['resources'].is_a?(Array)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def build_metadata_row(serialized_book, normalized_sha, source_path:, source_mtime:, generated_at:)
|
|
50
|
+
now = Time.now.utc.to_f
|
|
51
|
+
stringify_keys(serialized_book).merge(
|
|
52
|
+
'source_sha' => normalized_sha,
|
|
53
|
+
'source_path' => source_path,
|
|
54
|
+
'source_mtime' => source_mtime&.to_f,
|
|
55
|
+
'source_size_bytes' => safe_file_size(source_path),
|
|
56
|
+
'source_fingerprint' => Shoko::Adapters::BookSources::SourceFingerprint.compute(source_path),
|
|
57
|
+
'generated_at' => generated_at&.to_f,
|
|
58
|
+
'created_at' => now,
|
|
59
|
+
'updated_at' => now,
|
|
60
|
+
'engine' => ENGINE
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def payload_hash(metadata_row, chapter_generation, indexes)
|
|
65
|
+
chapters_index = indexes.fetch(:chapters)
|
|
66
|
+
resources_index = indexes.fetch(:resources)
|
|
67
|
+
metadata_row['chapters_generation'] = chapter_generation
|
|
68
|
+
metadata_row['chapters_format_version'] = 1
|
|
69
|
+
|
|
70
|
+
{
|
|
71
|
+
'format' => FORMAT,
|
|
72
|
+
'format_version' => FORMAT_VERSION,
|
|
73
|
+
'engine' => ENGINE,
|
|
74
|
+
'metadata_row' => metadata_row,
|
|
75
|
+
'chapters' => chapters_index,
|
|
76
|
+
'resources' => resources_index,
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def write_payload_file(sha, payload)
|
|
81
|
+
AtomicFileWriter.write(payload_path(sha), JSON.generate(payload))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def post_write_housekeeping(sha, metadata_row, chapter_generation, cache_size_bytes, serialized_layouts:)
|
|
85
|
+
write_layouts(sha, serialized_layouts)
|
|
86
|
+
update_manifest(metadata_row, cache_size_bytes: cache_size_bytes)
|
|
87
|
+
cleanup_old_chapter_generations(sha, keep: chapter_generation)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def stringify_keys(hash)
|
|
91
|
+
(hash || {}).transform_keys(&:to_s)
|
|
92
|
+
rescue StandardError
|
|
93
|
+
hash || {}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def normalize_sha!(sha)
|
|
97
|
+
value = sha.to_s.strip
|
|
98
|
+
raise ArgumentError, 'sha is blank' if value.empty?
|
|
99
|
+
raise ArgumentError, 'sha must be a 64-char hex digest' unless SHA256_HEX_PATTERN.match?(value)
|
|
100
|
+
|
|
101
|
+
value.downcase
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def safe_file_size(path)
|
|
105
|
+
return nil if path.nil? || path.to_s.empty?
|
|
106
|
+
|
|
107
|
+
File.size(path)
|
|
108
|
+
rescue StandardError
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|