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,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shoko
|
|
4
|
+
module Adapters::Storage
|
|
5
|
+
# Resource persistence helpers for `JsonCacheStore`.
|
|
6
|
+
class JsonCacheStore
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def resources_dir(sha)
|
|
10
|
+
File.join(@cache_root, 'resources', normalize_sha!(sha))
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def resource_blob_path(sha, blob_key)
|
|
14
|
+
File.join(resources_dir(sha), "#{blob_key}.bin")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def hydrate_resources(sha, index_rows)
|
|
18
|
+
Array(index_rows).filter_map { |row| hydrate_resource_row(sha, row) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def hydrate_resource_row(sha, row)
|
|
22
|
+
path, blob_key = resource_index_row_fields(row)
|
|
23
|
+
path_string = path.to_s
|
|
24
|
+
blob_key_string = blob_key.to_s
|
|
25
|
+
return nil if path_string.empty? || blob_key_string.empty?
|
|
26
|
+
|
|
27
|
+
data = File.binread(resource_blob_path(sha, blob_key_string))
|
|
28
|
+
data.force_encoding(Encoding::BINARY)
|
|
29
|
+
{ path: path_string, data: data }
|
|
30
|
+
rescue StandardError
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def resource_index_row_fields(row)
|
|
35
|
+
return [nil, nil] unless row.is_a?(Hash)
|
|
36
|
+
|
|
37
|
+
[row['path'] || row[:path], row['blob'] || row[:blob]]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def persist_resources(sha, resources_rows)
|
|
41
|
+
resources_rows = Array(resources_rows)
|
|
42
|
+
return [[], 0] if resources_rows.empty?
|
|
43
|
+
|
|
44
|
+
FileUtils.mkdir_p(resources_dir(sha))
|
|
45
|
+
|
|
46
|
+
rows = []
|
|
47
|
+
total_bytes = 0
|
|
48
|
+
resources_rows.each do |row|
|
|
49
|
+
persisted = persist_resource_row(sha, row)
|
|
50
|
+
next unless persisted
|
|
51
|
+
|
|
52
|
+
rows << persisted[:index_row]
|
|
53
|
+
total_bytes += persisted[:bytesize]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
[rows, total_bytes]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def persist_resource_row(sha, row)
|
|
60
|
+
path, data = resource_row_fields(row)
|
|
61
|
+
path_string = path.to_s
|
|
62
|
+
return nil if path_string.empty?
|
|
63
|
+
|
|
64
|
+
bytes = String(data).dup
|
|
65
|
+
bytes.force_encoding(Encoding::BINARY)
|
|
66
|
+
blob_key = Digest::SHA256.hexdigest(path_string)
|
|
67
|
+
|
|
68
|
+
AtomicFileWriter.write(resource_blob_path(sha, blob_key), bytes, binary: true)
|
|
69
|
+
|
|
70
|
+
bytesize = bytes.bytesize
|
|
71
|
+
{
|
|
72
|
+
bytesize: bytesize,
|
|
73
|
+
index_row: { 'path' => path_string, 'blob' => blob_key, 'bytesize' => bytesize },
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def resource_row_fields(row)
|
|
78
|
+
return [nil, nil] unless row.is_a?(Hash)
|
|
79
|
+
|
|
80
|
+
[row[:path] || row['path'], row[:data] || row['data']]
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'securerandom'
|
|
7
|
+
|
|
8
|
+
require_relative 'atomic_file_writer'
|
|
9
|
+
require_relative 'cache_paths'
|
|
10
|
+
require_relative '../monitoring/logger.rb'
|
|
11
|
+
require_relative '../book_sources/source_fingerprint.rb'
|
|
12
|
+
|
|
13
|
+
module Shoko
|
|
14
|
+
module Adapters::Storage
|
|
15
|
+
# JSON-backed cache store for EPUB payloads + layouts.
|
|
16
|
+
#
|
|
17
|
+
# This store persists only primitive JSON data and keeps binary resources
|
|
18
|
+
# as separate blobs on disk (referenced from the JSON payload).
|
|
19
|
+
class JsonCacheStore
|
|
20
|
+
ENGINE = 'json'
|
|
21
|
+
FORMAT = 'shoko-cache-payload'
|
|
22
|
+
FORMAT_VERSION = 2
|
|
23
|
+
|
|
24
|
+
# Raw payload read from disk (metadata + chapter/resource indexes + layouts).
|
|
25
|
+
Payload = Struct.new(:metadata_row, :chapters, :resources, :layouts, keyword_init: true)
|
|
26
|
+
|
|
27
|
+
MANIFEST_FILENAME = 'cache_manifest.json'
|
|
28
|
+
|
|
29
|
+
SHA256_HEX_PATTERN = /\A[0-9a-f]{64}\z/i
|
|
30
|
+
|
|
31
|
+
MAX_LAYOUT_KEY_BYTES = 200
|
|
32
|
+
LAYOUT_KEY_PATTERN = /\A[a-zA-Z0-9][a-zA-Z0-9._-]*\z/
|
|
33
|
+
|
|
34
|
+
CHAPTERS_DIRNAME = 'chapters'
|
|
35
|
+
CHAPTERS_RAW_DIRNAME = 'raw'
|
|
36
|
+
CHAPTERS_GENERATION_BYTES = 8
|
|
37
|
+
CHAPTERS_GENERATION_PATTERN = /\A[0-9a-f]{16}\z/i
|
|
38
|
+
CHAPTER_FILENAME_DIGITS = 6
|
|
39
|
+
MAX_CHAPTER_COUNT = 20_000
|
|
40
|
+
|
|
41
|
+
def initialize(cache_root: CachePaths.cache_root)
|
|
42
|
+
@cache_root = cache_root
|
|
43
|
+
FileUtils.mkdir_p(@cache_root)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def engine
|
|
47
|
+
ENGINE
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def fetch_payload(sha, include_resources: false)
|
|
51
|
+
data = load_payload_data(sha)
|
|
52
|
+
return nil unless data
|
|
53
|
+
|
|
54
|
+
Payload.new(
|
|
55
|
+
metadata_row: data.fetch('metadata_row', {}),
|
|
56
|
+
chapters: data.fetch('chapters', []),
|
|
57
|
+
resources: include_resources ? hydrate_resources(sha, data.fetch('resources', [])) : [],
|
|
58
|
+
layouts: fetch_layouts(sha)
|
|
59
|
+
)
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
Shoko::Adapters::Monitoring::Logger.debug('JsonCacheStore: fetch failed', sha: sha.to_s, error: e.message)
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def write_payload(sha:, source_path:, source_mtime:, generated_at:, serialized_book:, serialized_chapters:,
|
|
66
|
+
serialized_resources:, serialized_layouts:)
|
|
67
|
+
normalized_sha = normalize_sha!(sha)
|
|
68
|
+
|
|
69
|
+
metadata_row = build_metadata_row(serialized_book, normalized_sha, source_path:, source_mtime:, generated_at:)
|
|
70
|
+
chapters_index, chapter_generation, chapter_bytes = persist_chapters(normalized_sha, serialized_chapters)
|
|
71
|
+
resources_index, resource_bytes = persist_resources(normalized_sha, serialized_resources)
|
|
72
|
+
size_bytes = chapter_bytes.to_i + resource_bytes.to_i
|
|
73
|
+
indexes = { chapters: chapters_index, resources: resources_index }
|
|
74
|
+
payload = payload_hash(metadata_row, chapter_generation, indexes)
|
|
75
|
+
write_payload_file(normalized_sha, payload)
|
|
76
|
+
post_write_housekeeping(normalized_sha, metadata_row, chapter_generation, size_bytes, serialized_layouts:)
|
|
77
|
+
true
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
Shoko::Adapters::Monitoring::Logger.debug('JsonCacheStore: write failed', sha: sha.to_s, error: e.message)
|
|
80
|
+
cleanup_failed_chapter_generation(normalized_sha, chapter_generation) if normalized_sha && chapter_generation
|
|
81
|
+
false
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def load_layout(sha, key)
|
|
85
|
+
file = layout_file(sha, key)
|
|
86
|
+
return nil unless File.file?(file)
|
|
87
|
+
|
|
88
|
+
JSON.parse(File.read(file))
|
|
89
|
+
rescue StandardError => e
|
|
90
|
+
Shoko::Adapters::Monitoring::Logger.debug('JsonCacheStore: layout load failed', sha: sha.to_s, key: key.to_s, error: e.message)
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def fetch_layouts(sha)
|
|
95
|
+
dir = layouts_dir(sha)
|
|
96
|
+
return {} unless Dir.exist?(dir)
|
|
97
|
+
|
|
98
|
+
Dir.children(dir).each_with_object({}) do |entry, layouts|
|
|
99
|
+
key = layout_key_for_entry(entry)
|
|
100
|
+
next unless key
|
|
101
|
+
|
|
102
|
+
payload = read_layout_payload(dir, entry, sha: sha, key: key)
|
|
103
|
+
layouts[key] = payload if payload
|
|
104
|
+
end
|
|
105
|
+
rescue StandardError => e
|
|
106
|
+
Shoko::Adapters::Monitoring::Logger.debug('JsonCacheStore: layouts fetch failed', sha: sha.to_s, error: e.message)
|
|
107
|
+
{}
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def chapters_complete?(sha, generation, expected_count:)
|
|
111
|
+
normalized_sha = normalize_sha!(sha)
|
|
112
|
+
gen = normalize_chapter_generation(generation)
|
|
113
|
+
count = normalize_expected_chapter_count(expected_count)
|
|
114
|
+
return false unless gen && count
|
|
115
|
+
return true if count.zero?
|
|
116
|
+
|
|
117
|
+
chapter_files_complete?(normalized_sha, gen, count)
|
|
118
|
+
rescue StandardError => e
|
|
119
|
+
Shoko::Adapters::Monitoring::Logger.debug('JsonCacheStore: chapters completeness check failed',
|
|
120
|
+
sha: sha.to_s, generation: generation.to_s, expected: expected_count.to_i,
|
|
121
|
+
error: e.message)
|
|
122
|
+
false
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def mutate_layouts(sha)
|
|
126
|
+
layouts = fetch_layouts(sha)
|
|
127
|
+
yield layouts
|
|
128
|
+
write_layouts(sha, layouts)
|
|
129
|
+
true
|
|
130
|
+
rescue StandardError => e
|
|
131
|
+
Shoko::Adapters::Monitoring::Logger.debug('JsonCacheStore: mutate layouts failed', sha: sha.to_s, error: e.message)
|
|
132
|
+
false
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def delete_payload(sha)
|
|
136
|
+
normalized_sha = normalize_sha!(sha)
|
|
137
|
+
FileUtils.rm_f(payload_path(normalized_sha))
|
|
138
|
+
FileUtils.rm_rf(layouts_dir(normalized_sha))
|
|
139
|
+
FileUtils.rm_rf(resources_dir(normalized_sha))
|
|
140
|
+
FileUtils.rm_rf(chapters_dir(normalized_sha))
|
|
141
|
+
remove_from_manifest(normalized_sha)
|
|
142
|
+
true
|
|
143
|
+
rescue StandardError => e
|
|
144
|
+
Shoko::Adapters::Monitoring::Logger.debug('JsonCacheStore: delete failed', sha: sha.to_s, error: e.message)
|
|
145
|
+
false
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def list_books
|
|
149
|
+
self.class.manifest_rows(@cache_root)
|
|
150
|
+
rescue StandardError
|
|
151
|
+
[]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def self.manifest_rows(cache_root)
|
|
155
|
+
read_manifest_file(File.join(cache_root, MANIFEST_FILENAME))
|
|
156
|
+
rescue StandardError
|
|
157
|
+
[]
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
require_relative 'json_cache_store/payload_helpers'
|
|
164
|
+
require_relative 'json_cache_store/chapters'
|
|
165
|
+
require_relative 'json_cache_store/layouts'
|
|
166
|
+
require_relative 'json_cache_store/resources'
|
|
167
|
+
require_relative 'json_cache_store/manifest'
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../../shared/errors.rb'
|
|
4
|
+
|
|
5
|
+
module Shoko
|
|
6
|
+
module Adapters::Storage
|
|
7
|
+
# Lazily reads a file into a String on first use.
|
|
8
|
+
#
|
|
9
|
+
# Used to avoid loading large chapter XHTML payloads until they are needed.
|
|
10
|
+
# Optionally sanitizes the loaded content before memoizing it.
|
|
11
|
+
class LazyFileString
|
|
12
|
+
def initialize(path, sanitizer: nil, encoding: Encoding::UTF_8)
|
|
13
|
+
@path = path.to_s
|
|
14
|
+
@sanitizer = sanitizer
|
|
15
|
+
@encoding = encoding
|
|
16
|
+
@loaded = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
attr_reader :path
|
|
20
|
+
|
|
21
|
+
def to_s
|
|
22
|
+
load_string
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_str
|
|
26
|
+
load_string
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def inspect
|
|
30
|
+
"#<#{self.class.name} path=#{path.inspect} loaded=#{!@loaded.nil?}>"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def method_missing(name, *, &)
|
|
34
|
+
value = load_string
|
|
35
|
+
return super unless value.respond_to?(name)
|
|
36
|
+
|
|
37
|
+
value.public_send(name, *, &)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def respond_to_missing?(name, include_private = false)
|
|
41
|
+
''.respond_to?(name, include_private) || super
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def load_string
|
|
47
|
+
return @loaded if @loaded
|
|
48
|
+
|
|
49
|
+
bytes = File.binread(path)
|
|
50
|
+
bytes.force_encoding(@encoding)
|
|
51
|
+
text = @sanitizer ? @sanitizer.call(bytes) : bytes
|
|
52
|
+
text = text.to_s
|
|
53
|
+
unless text.encoding == Encoding::UTF_8
|
|
54
|
+
text = text.encode(Encoding::UTF_8, invalid: :replace, undef: :replace,
|
|
55
|
+
replace: "\uFFFD")
|
|
56
|
+
end
|
|
57
|
+
@loaded = text
|
|
58
|
+
rescue Shoko::Error
|
|
59
|
+
raise
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
raise Shoko::CacheLoadError.new(path, e.message)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'epub_cache'
|
|
4
|
+
require_relative '../monitoring/perf_tracer.rb'
|
|
5
|
+
|
|
6
|
+
module Shoko
|
|
7
|
+
module Adapters::Storage
|
|
8
|
+
# Persists dynamic pagination layouts inside the on-disk cache for a book.
|
|
9
|
+
module PaginationCache
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
SCHEMA_VERSION = 3
|
|
13
|
+
|
|
14
|
+
def layout_key(width, height, view_mode, line_spacing, kitty_images: false)
|
|
15
|
+
suffix = kitty_images ? 'img1' : 'img0'
|
|
16
|
+
"#{width}x#{height}_#{view_mode}_#{line_spacing}_#{suffix}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def parse_layout_key(key)
|
|
20
|
+
return nil unless key
|
|
21
|
+
|
|
22
|
+
dims, view_mode, line_spacing, image_mode = key.to_s.split('_', 4)
|
|
23
|
+
width_str, height_str = dims.to_s.split('x', 2)
|
|
24
|
+
return nil unless width_str && height_str && view_mode && line_spacing
|
|
25
|
+
|
|
26
|
+
{
|
|
27
|
+
width: width_str.to_i,
|
|
28
|
+
height: height_str.to_i,
|
|
29
|
+
view_mode: view_mode.to_sym,
|
|
30
|
+
line_spacing: line_spacing.to_sym,
|
|
31
|
+
kitty_images: image_mode.to_s == 'img1',
|
|
32
|
+
}
|
|
33
|
+
rescue StandardError
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def load_for_document(doc, key)
|
|
38
|
+
cache = cache_for(doc)
|
|
39
|
+
return nil unless cache
|
|
40
|
+
|
|
41
|
+
data = Adapters::Monitoring::PerfTracer.measure('cache.lookup') { cache.load_layout(key) }
|
|
42
|
+
extract_pages(data)
|
|
43
|
+
rescue StandardError
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def save_for_document(doc, key, pages_compact)
|
|
48
|
+
cache = cache_for(doc)
|
|
49
|
+
return false unless cache
|
|
50
|
+
|
|
51
|
+
payload = {
|
|
52
|
+
'version' => SCHEMA_VERSION,
|
|
53
|
+
'pages' => pages_compact,
|
|
54
|
+
}
|
|
55
|
+
cache.mutate_layouts! { |layouts| layouts[key] = payload }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def delete_for_document(doc, key)
|
|
59
|
+
cache = cache_for(doc)
|
|
60
|
+
return false unless cache
|
|
61
|
+
|
|
62
|
+
cache.mutate_layouts! { |layouts| layouts.delete(key) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def exists_for_document?(doc, key)
|
|
66
|
+
cache = cache_for(doc)
|
|
67
|
+
return false unless cache
|
|
68
|
+
|
|
69
|
+
!!cache.load_layout(key)
|
|
70
|
+
rescue StandardError
|
|
71
|
+
false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def layout_keys_for_document(doc)
|
|
75
|
+
cache = cache_for(doc)
|
|
76
|
+
return [] unless cache
|
|
77
|
+
|
|
78
|
+
cache.layout_keys
|
|
79
|
+
rescue StandardError
|
|
80
|
+
[]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def extract_pages(data)
|
|
84
|
+
return nil unless data.is_a?(Hash)
|
|
85
|
+
|
|
86
|
+
version = data['version'] || data[:version]
|
|
87
|
+
pages = data['pages'] || data[:pages]
|
|
88
|
+
return nil unless pages.is_a?(Array)
|
|
89
|
+
return nil if version && version.to_i != SCHEMA_VERSION
|
|
90
|
+
|
|
91
|
+
pages.map do |entry|
|
|
92
|
+
{
|
|
93
|
+
chapter_index: entry[:chapter_index] || entry['chapter_index'],
|
|
94
|
+
page_in_chapter: entry[:page_in_chapter] || entry['page_in_chapter'],
|
|
95
|
+
total_pages_in_chapter: entry[:total_pages_in_chapter] || entry['total_pages_in_chapter'],
|
|
96
|
+
start_line: entry[:start_line] || entry['start_line'],
|
|
97
|
+
end_line: entry[:end_line] || entry['end_line'],
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def cache_for(doc)
|
|
103
|
+
path = resolve_cache_path(doc)
|
|
104
|
+
return nil unless path && File.exist?(path)
|
|
105
|
+
|
|
106
|
+
Shoko::Adapters::Storage::EpubCache.new(path)
|
|
107
|
+
rescue Shoko::Error, StandardError
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
private_class_method :cache_for
|
|
111
|
+
|
|
112
|
+
def resolve_cache_path(doc)
|
|
113
|
+
return doc.cache_path if doc.respond_to?(:cache_path) && doc.cache_path && !doc.cache_path.to_s.empty?
|
|
114
|
+
|
|
115
|
+
if doc.respond_to?(:canonical_path) && doc.canonical_path && File.exist?(doc.canonical_path)
|
|
116
|
+
cache = Shoko::Adapters::Storage::EpubCache.new(doc.canonical_path)
|
|
117
|
+
return cache.cache_path if File.exist?(cache.cache_path)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
nil
|
|
121
|
+
rescue Shoko::Error, StandardError
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
private_class_method :resolve_cache_path
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'time'
|
|
6
|
+
require_relative 'atomic_file_writer'
|
|
7
|
+
require_relative 'config_paths'
|
|
8
|
+
require_relative '../output/terminal/terminal_sanitizer.rb'
|
|
9
|
+
|
|
10
|
+
module Shoko
|
|
11
|
+
module Adapters::Storage
|
|
12
|
+
# Manages a list of recently opened files.
|
|
13
|
+
class RecentFiles
|
|
14
|
+
CONFIG_DIR = Adapters::Storage::ConfigPaths.config_root
|
|
15
|
+
RECENT_FILE = File.join(CONFIG_DIR, 'recent.json')
|
|
16
|
+
MAX_RECENT_FILES = 10
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
# Adds a file path to the top of the recent files list.
|
|
20
|
+
#
|
|
21
|
+
# @param path [String] The path to the file to add.
|
|
22
|
+
def add(path)
|
|
23
|
+
recent_files = load.reject { |file| file['path'] == path }
|
|
24
|
+
|
|
25
|
+
raw_label = File.basename(path, File.extname(path)).tr('_-', ' ')
|
|
26
|
+
label = Shoko::Adapters::Output::Terminal::TerminalSanitizer.sanitize(raw_label, preserve_newlines: false, preserve_tabs: false)
|
|
27
|
+
|
|
28
|
+
new_entry = {
|
|
29
|
+
'path' => path,
|
|
30
|
+
'name' => label,
|
|
31
|
+
'accessed' => Time.now.iso8601,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
save([new_entry, *recent_files].first(MAX_RECENT_FILES))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Loads the list of recent files from disk.
|
|
38
|
+
#
|
|
39
|
+
# @return [Array<Hash>] An array of recent file entries.
|
|
40
|
+
def load
|
|
41
|
+
return [] unless File.exist?(RECENT_FILE)
|
|
42
|
+
|
|
43
|
+
entries = JSON.parse(File.read(RECENT_FILE))
|
|
44
|
+
Array(entries).map do |row|
|
|
45
|
+
next row unless row.is_a?(Hash)
|
|
46
|
+
|
|
47
|
+
safe = row.dup
|
|
48
|
+
safe['name'] =
|
|
49
|
+
Shoko::Adapters::Output::Terminal::TerminalSanitizer.sanitize(safe['name'].to_s, preserve_newlines: false, preserve_tabs: false)
|
|
50
|
+
safe
|
|
51
|
+
end
|
|
52
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
53
|
+
[]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Clears the recent files list by removing the recent file.
|
|
57
|
+
def clear
|
|
58
|
+
FileUtils.rm_f(RECENT_FILE)
|
|
59
|
+
rescue Errno::EACCES, Errno::ENOENT
|
|
60
|
+
# Ignore errors
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# Saves the list of recent files to disk.
|
|
66
|
+
#
|
|
67
|
+
# @param recent [Array<Hash>] The list of recent files to save.
|
|
68
|
+
def save(recent)
|
|
69
|
+
FileUtils.mkdir_p(File.dirname(RECENT_FILE))
|
|
70
|
+
payload = JSON.pretty_generate(recent)
|
|
71
|
+
Shoko::Adapters::Storage::AtomicFileWriter.write(RECENT_FILE, payload)
|
|
72
|
+
rescue StandardError
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base_repository'
|
|
4
|
+
require_relative 'storage/annotation_file_store'
|
|
5
|
+
|
|
6
|
+
module Shoko
|
|
7
|
+
module Adapters::Storage::Repositories
|
|
8
|
+
# Repository for annotation persistence, abstracting the underlying storage mechanism.
|
|
9
|
+
#
|
|
10
|
+
# This repository provides a clean domain interface for annotation operations,
|
|
11
|
+
# hiding the file-based persistence details from domain services.
|
|
12
|
+
#
|
|
13
|
+
# @example Adding an annotation
|
|
14
|
+
# repo = AnnotationRepository.new(dependencies)
|
|
15
|
+
# annotation = repo.add_for_book(
|
|
16
|
+
# '/path/to/book.epub',
|
|
17
|
+
# text: 'Selected text',
|
|
18
|
+
# note: 'My note',
|
|
19
|
+
# range: { start: 100, end: 120 },
|
|
20
|
+
# chapter_index: 2
|
|
21
|
+
# )
|
|
22
|
+
#
|
|
23
|
+
# @example Getting annotations for a book
|
|
24
|
+
# annotations = repo.find_by_book_path('/path/to/book.epub')
|
|
25
|
+
class AnnotationRepository < BaseRepository
|
|
26
|
+
def initialize(dependencies)
|
|
27
|
+
super
|
|
28
|
+
file_writer = dependencies.resolve(:file_writer)
|
|
29
|
+
@storage = Storage::AnnotationFileStore.new(file_writer:)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Add a new annotation for a specific book
|
|
33
|
+
#
|
|
34
|
+
# @param book_path [String] Path to the EPUB file
|
|
35
|
+
# @param text [String] The selected text being annotated
|
|
36
|
+
# @param note [String] The annotation note
|
|
37
|
+
# @param range [Hash] Text selection range with :start and :end
|
|
38
|
+
# @param chapter_index [Integer] Chapter index (0-based)
|
|
39
|
+
# @param page_meta [Hash, nil] Optional page metadata
|
|
40
|
+
# @return [Hash] The created annotation data
|
|
41
|
+
def add_for_book(book_path, text:, note:, range:, chapter_index:, page_meta: nil)
|
|
42
|
+
validate_required_params(
|
|
43
|
+
{ book_path: book_path, text: text, note: note, range: range,
|
|
44
|
+
chapter_index: chapter_index },
|
|
45
|
+
%i[book_path text note range chapter_index]
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
begin
|
|
49
|
+
@storage.add(book_path, text, note, range, chapter_index, page_meta)
|
|
50
|
+
|
|
51
|
+
# Return the most recently created annotation
|
|
52
|
+
annotations = find_by_book_path(book_path)
|
|
53
|
+
annotations.max_by do |a|
|
|
54
|
+
Time.parse(a['created_at'] || a['updated_at'] || Time.now.iso8601)
|
|
55
|
+
end
|
|
56
|
+
rescue StandardError => e
|
|
57
|
+
handle_storage_error(e, "adding annotation for #{book_path}")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Find all annotations for a specific book
|
|
62
|
+
#
|
|
63
|
+
# @param book_path [String] Path to the EPUB file
|
|
64
|
+
# @return [Array<Hash>] Array of annotation hashes for the book
|
|
65
|
+
def find_by_book_path(book_path)
|
|
66
|
+
validate_required_params({ book_path: book_path }, [:book_path])
|
|
67
|
+
|
|
68
|
+
begin
|
|
69
|
+
@storage.get(book_path) || []
|
|
70
|
+
rescue StandardError => e
|
|
71
|
+
handle_storage_error(e, "loading annotations for #{book_path}")
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Find all annotations across all books
|
|
76
|
+
#
|
|
77
|
+
# @return [Hash] Hash mapping book paths to annotation arrays
|
|
78
|
+
def find_all
|
|
79
|
+
@storage.all || {}
|
|
80
|
+
rescue StandardError => e
|
|
81
|
+
handle_storage_error(e, 'loading all annotations')
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Update an existing annotation's note
|
|
85
|
+
#
|
|
86
|
+
# @param book_path [String] Path to the EPUB file
|
|
87
|
+
# @param annotation_id [String] ID of the annotation to update
|
|
88
|
+
# @param note [String] New note content
|
|
89
|
+
# @return [Boolean] True if updated successfully
|
|
90
|
+
def update_note(book_path, annotation_id, note)
|
|
91
|
+
validate_required_params(
|
|
92
|
+
{ book_path: book_path, annotation_id: annotation_id, note: note },
|
|
93
|
+
%i[book_path annotation_id note]
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
begin
|
|
97
|
+
@storage.update(book_path, annotation_id, note)
|
|
98
|
+
true
|
|
99
|
+
rescue StandardError => e
|
|
100
|
+
handle_storage_error(e, "updating annotation #{annotation_id} for #{book_path}")
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Delete a specific annotation
|
|
105
|
+
#
|
|
106
|
+
# @param book_path [String] Path to the EPUB file
|
|
107
|
+
# @param annotation_id [String] ID of the annotation to delete
|
|
108
|
+
# @return [Boolean] True if deleted successfully
|
|
109
|
+
def delete_by_id(book_path, annotation_id)
|
|
110
|
+
validate_required_params(
|
|
111
|
+
{ book_path: book_path, annotation_id: annotation_id },
|
|
112
|
+
%i[book_path annotation_id]
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
begin
|
|
116
|
+
@storage.delete(book_path, annotation_id)
|
|
117
|
+
true
|
|
118
|
+
rescue StandardError => e
|
|
119
|
+
handle_storage_error(e, "deleting annotation #{annotation_id} for #{book_path}")
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Find a specific annotation by ID
|
|
124
|
+
#
|
|
125
|
+
# @param book_path [String] Path to the EPUB file
|
|
126
|
+
# @param annotation_id [String] ID of the annotation to find
|
|
127
|
+
# @return [Hash, nil] The annotation hash, or nil if not found
|
|
128
|
+
def find_by_id(book_path, annotation_id)
|
|
129
|
+
annotations = find_by_book_path(book_path)
|
|
130
|
+
annotations.find { |a| a['id'] == annotation_id }
|
|
131
|
+
rescue StandardError => e
|
|
132
|
+
handle_storage_error(e, "finding annotation #{annotation_id} for #{book_path}")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Get annotation count for a book
|
|
136
|
+
#
|
|
137
|
+
# @param book_path [String] Path to the EPUB file
|
|
138
|
+
# @return [Integer] Number of annotations for the book
|
|
139
|
+
def count_for_book(book_path)
|
|
140
|
+
find_by_book_path(book_path).size
|
|
141
|
+
rescue StandardError => e
|
|
142
|
+
handle_storage_error(e, "counting annotations for #{book_path}")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Find annotations by chapter
|
|
146
|
+
#
|
|
147
|
+
# @param book_path [String] Path to the EPUB file
|
|
148
|
+
# @param chapter_index [Integer] Chapter index to filter by
|
|
149
|
+
# @return [Array<Hash>] Annotations in the specified chapter
|
|
150
|
+
def find_by_chapter(book_path, chapter_index)
|
|
151
|
+
annotations = find_by_book_path(book_path)
|
|
152
|
+
annotations.select { |a| a['chapter_index'] == chapter_index }
|
|
153
|
+
rescue StandardError => e
|
|
154
|
+
handle_storage_error(e, "finding annotations by chapter for #{book_path}")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Check if any annotations exist at a text range
|
|
158
|
+
#
|
|
159
|
+
# @param book_path [String] Path to the EPUB file
|
|
160
|
+
# @param chapter_index [Integer] Chapter index
|
|
161
|
+
# @param range [Hash] Text range with :start and :end
|
|
162
|
+
# @return [Boolean] True if annotations exist in this range
|
|
163
|
+
def exists_in_range?(book_path, chapter_index, range)
|
|
164
|
+
annotations = find_by_chapter(book_path, chapter_index)
|
|
165
|
+
annotations.any? do |annotation|
|
|
166
|
+
annotation_range = annotation['range']
|
|
167
|
+
next false unless annotation_range
|
|
168
|
+
|
|
169
|
+
# Check for overlap
|
|
170
|
+
annotation_start = annotation_range['start'] || annotation_range[:start]
|
|
171
|
+
annotation_end = annotation_range['end'] || annotation_range[:end]
|
|
172
|
+
range_start = range['start'] || range[:start]
|
|
173
|
+
range_end = range['end'] || range[:end]
|
|
174
|
+
|
|
175
|
+
annotation_start < range_end && range_start < annotation_end
|
|
176
|
+
end
|
|
177
|
+
rescue StandardError => e
|
|
178
|
+
handle_storage_error(e, "checking annotation range for #{book_path}")
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|