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.
Files changed (294) hide show
  1. checksums.yaml +7 -0
  2. data/.bundle/config +4 -0
  3. data/.bundle/config.bak +3 -0
  4. data/.rspec_status +42 -0
  5. data/.rubocop.yml +124 -0
  6. data/Gemfile +19 -0
  7. data/LICENSE +21 -0
  8. data/README.md +82 -0
  9. data/Rakefile +29 -0
  10. data/bin/start +15 -0
  11. data/lib/shoko/adapters/book_sources/document_service.rb +201 -0
  12. data/lib/shoko/adapters/book_sources/download_service.rb +95 -0
  13. data/lib/shoko/adapters/book_sources/epub/epub_resource_loader.rb +137 -0
  14. data/lib/shoko/adapters/book_sources/epub/parsers/html_processor.rb +151 -0
  15. data/lib/shoko/adapters/book_sources/epub/parsers/metadata_extractor.rb +53 -0
  16. data/lib/shoko/adapters/book_sources/epub/parsers/opf/entry_reader.rb +77 -0
  17. data/lib/shoko/adapters/book_sources/epub/parsers/opf/metadata_extractor.rb +67 -0
  18. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_context.rb +86 -0
  19. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_document_index.rb +75 -0
  20. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_document_scanner.rb +47 -0
  21. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_extractor.rb +46 -0
  22. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_label_resolver.rb +83 -0
  23. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_list_item.rb +55 -0
  24. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_result.rb +8 -0
  25. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_selector.rb +100 -0
  26. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_source_locator.rb +93 -0
  27. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_traversal.rb +103 -0
  28. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_walker.rb +56 -0
  29. data/lib/shoko/adapters/book_sources/epub/parsers/opf_processor.rb +102 -0
  30. data/lib/shoko/adapters/book_sources/epub/parsers/xhtml_content_parser.rb +661 -0
  31. data/lib/shoko/adapters/book_sources/epub/parsers/xml_text_normalizer.rb +41 -0
  32. data/lib/shoko/adapters/book_sources/epub_document.rb +253 -0
  33. data/lib/shoko/adapters/book_sources/epub_finder/directory_scanner.rb +134 -0
  34. data/lib/shoko/adapters/book_sources/epub_finder/scanner_context.rb +28 -0
  35. data/lib/shoko/adapters/book_sources/epub_finder.rb +161 -0
  36. data/lib/shoko/adapters/book_sources/epub_importer.rb +268 -0
  37. data/lib/shoko/adapters/book_sources/gutendex_client.rb +150 -0
  38. data/lib/shoko/adapters/book_sources/library_scanner.rb +93 -0
  39. data/lib/shoko/adapters/book_sources/source_fingerprint.rb +57 -0
  40. data/lib/shoko/adapters/input/annotations/mouse_handler.rb +84 -0
  41. data/lib/shoko/adapters/input/command_bridge.rb +148 -0
  42. data/lib/shoko/adapters/input/command_factory.rb +255 -0
  43. data/lib/shoko/adapters/input/commands.rb +60 -0
  44. data/lib/shoko/adapters/input/dispatcher.rb +69 -0
  45. data/lib/shoko/adapters/input/input_controller.rb +250 -0
  46. data/lib/shoko/adapters/input/key_definitions.rb +108 -0
  47. data/lib/shoko/adapters/input/validators/file_path_validator.rb +81 -0
  48. data/lib/shoko/adapters/input/validators/terminal_size_validator.rb +76 -0
  49. data/lib/shoko/adapters/monitoring/logger.rb +150 -0
  50. data/lib/shoko/adapters/monitoring/perf_tracer.rb +183 -0
  51. data/lib/shoko/adapters/monitoring/performance_monitor.rb +110 -0
  52. data/lib/shoko/adapters/output/clipboard/clipboard_service.rb +125 -0
  53. data/lib/shoko/adapters/output/formatting/formatting_service/line_assembler/image_builder.rb +149 -0
  54. data/lib/shoko/adapters/output/formatting/formatting_service/line_assembler/text_wrapper.rb +149 -0
  55. data/lib/shoko/adapters/output/formatting/formatting_service/line_assembler/tokenizer.rb +91 -0
  56. data/lib/shoko/adapters/output/formatting/formatting_service/line_assembler.rb +144 -0
  57. data/lib/shoko/adapters/output/formatting/formatting_service/plain_lines_builder.rb +54 -0
  58. data/lib/shoko/adapters/output/formatting/formatting_service.rb +247 -0
  59. data/lib/shoko/adapters/output/formatting/wrapping_service.rb +228 -0
  60. data/lib/shoko/adapters/output/instrumentation_service.rb +52 -0
  61. data/lib/shoko/adapters/output/kitty/image_transcoder.rb +71 -0
  62. data/lib/shoko/adapters/output/kitty/kitty_graphics.rb +114 -0
  63. data/lib/shoko/adapters/output/kitty/kitty_image_renderer.rb +239 -0
  64. data/lib/shoko/adapters/output/kitty/kitty_unicode_placeholders.rb +139 -0
  65. data/lib/shoko/adapters/output/kitty/kitty_unicode_placeholders_diacritic_codepoints.txt +26 -0
  66. data/lib/shoko/adapters/output/notification_service.rb +58 -0
  67. data/lib/shoko/adapters/output/render_registry.rb +45 -0
  68. data/lib/shoko/adapters/output/rendering/models/line_geometry.rb +60 -0
  69. data/lib/shoko/adapters/output/rendering/models/page_rendering_context.rb +22 -0
  70. data/lib/shoko/adapters/output/rendering/models/render_params.rb +28 -0
  71. data/lib/shoko/adapters/output/rendering/models/rendering_context.rb +58 -0
  72. data/lib/shoko/adapters/output/terminal/buffer.rb +275 -0
  73. data/lib/shoko/adapters/output/terminal/constants/terminal_defaults.rb +11 -0
  74. data/lib/shoko/adapters/output/terminal/input/decoder.rb +347 -0
  75. data/lib/shoko/adapters/output/terminal/input.rb +161 -0
  76. data/lib/shoko/adapters/output/terminal/output.rb +105 -0
  77. data/lib/shoko/adapters/output/terminal/terminal.rb +167 -0
  78. data/lib/shoko/adapters/output/terminal/terminal_sanitizer.rb +243 -0
  79. data/lib/shoko/adapters/output/terminal/terminal_service.rb +138 -0
  80. data/lib/shoko/adapters/output/terminal/text_metrics.rb +273 -0
  81. data/lib/shoko/adapters/output/ui/builders/page_setup_builder.rb +47 -0
  82. data/lib/shoko/adapters/output/ui/components/annotation_editor_overlay/footer_renderer.rb +80 -0
  83. data/lib/shoko/adapters/output/ui/components/annotation_editor_overlay/geometry.rb +61 -0
  84. data/lib/shoko/adapters/output/ui/components/annotation_editor_overlay/note_renderer.rb +86 -0
  85. data/lib/shoko/adapters/output/ui/components/annotation_editor_overlay_component.rb +234 -0
  86. data/lib/shoko/adapters/output/ui/components/annotations_overlay/list_renderer.rb +142 -0
  87. data/lib/shoko/adapters/output/ui/components/annotations_overlay_component.rb +185 -0
  88. data/lib/shoko/adapters/output/ui/components/base_component.rb +110 -0
  89. data/lib/shoko/adapters/output/ui/components/component_interface.rb +80 -0
  90. data/lib/shoko/adapters/output/ui/components/content_component.rb +61 -0
  91. data/lib/shoko/adapters/output/ui/components/enhanced_popup_menu.rb +191 -0
  92. data/lib/shoko/adapters/output/ui/components/footer_component.rb +120 -0
  93. data/lib/shoko/adapters/output/ui/components/header_component.rb +46 -0
  94. data/lib/shoko/adapters/output/ui/components/layouts/horizontal.rb +63 -0
  95. data/lib/shoko/adapters/output/ui/components/layouts/vertical.rb +73 -0
  96. data/lib/shoko/adapters/output/ui/components/main_menu_component.rb +103 -0
  97. data/lib/shoko/adapters/output/ui/components/reading/base_view_renderer.rb +199 -0
  98. data/lib/shoko/adapters/output/ui/components/reading/config_helpers.rb +42 -0
  99. data/lib/shoko/adapters/output/ui/components/reading/help_renderer.rb +62 -0
  100. data/lib/shoko/adapters/output/ui/components/reading/inline_segment_highlighter.rb +144 -0
  101. data/lib/shoko/adapters/output/ui/components/reading/kitty_image_line_renderer.rb +262 -0
  102. data/lib/shoko/adapters/output/ui/components/reading/line_content_composer.rb +114 -0
  103. data/lib/shoko/adapters/output/ui/components/reading/line_drawer.rb +87 -0
  104. data/lib/shoko/adapters/output/ui/components/reading/line_geometry_builder.rb +41 -0
  105. data/lib/shoko/adapters/output/ui/components/reading/rendered_lines_recorder.rb +64 -0
  106. data/lib/shoko/adapters/output/ui/components/reading/single_view_renderer.rb +156 -0
  107. data/lib/shoko/adapters/output/ui/components/reading/split_view_renderer.rb +221 -0
  108. data/lib/shoko/adapters/output/ui/components/reading/view_renderer_factory.rb +20 -0
  109. data/lib/shoko/adapters/output/ui/components/reading/wrapped_lines_fetcher.rb +139 -0
  110. data/lib/shoko/adapters/output/ui/components/rect.rb +15 -0
  111. data/lib/shoko/adapters/output/ui/components/render_style.rb +84 -0
  112. data/lib/shoko/adapters/output/ui/components/screen_component.rb +24 -0
  113. data/lib/shoko/adapters/output/ui/components/screens/annotation_detail_screen_component.rb +175 -0
  114. data/lib/shoko/adapters/output/ui/components/screens/annotation_edit_screen_component.rb +221 -0
  115. data/lib/shoko/adapters/output/ui/components/screens/annotation_editor_screen_component.rb +205 -0
  116. data/lib/shoko/adapters/output/ui/components/screens/annotation_rendering_helpers.rb +190 -0
  117. data/lib/shoko/adapters/output/ui/components/screens/annotations_screen_component.rb +266 -0
  118. data/lib/shoko/adapters/output/ui/components/screens/base_screen_component.rb +49 -0
  119. data/lib/shoko/adapters/output/ui/components/screens/browse_screen_component.rb +319 -0
  120. data/lib/shoko/adapters/output/ui/components/screens/download_books_screen_component.rb +340 -0
  121. data/lib/shoko/adapters/output/ui/components/screens/library_screen_component.rb +205 -0
  122. data/lib/shoko/adapters/output/ui/components/screens/loading_overlay_component.rb +49 -0
  123. data/lib/shoko/adapters/output/ui/components/screens/menu_screen_component.rb +107 -0
  124. data/lib/shoko/adapters/output/ui/components/screens/settings_screen_component.rb +238 -0
  125. data/lib/shoko/adapters/output/ui/components/sidebar/annotations_tab_renderer.rb +159 -0
  126. data/lib/shoko/adapters/output/ui/components/sidebar/bookmarks_tab_renderer.rb +139 -0
  127. data/lib/shoko/adapters/output/ui/components/sidebar/tab_header_component.rb +157 -0
  128. data/lib/shoko/adapters/output/ui/components/sidebar/toc_tab_renderer.rb +111 -0
  129. data/lib/shoko/adapters/output/ui/components/sidebar/toc_tab_support.rb +1606 -0
  130. data/lib/shoko/adapters/output/ui/components/sidebar_panel_component.rb +217 -0
  131. data/lib/shoko/adapters/output/ui/components/surface.rb +88 -0
  132. data/lib/shoko/adapters/output/ui/components/tooltip_overlay_component.rb +224 -0
  133. data/lib/shoko/adapters/output/ui/components/ui/box_drawer.rb +32 -0
  134. data/lib/shoko/adapters/output/ui/components/ui/list_helpers.rb +33 -0
  135. data/lib/shoko/adapters/output/ui/components/ui/overlay_layout.rb +79 -0
  136. data/lib/shoko/adapters/output/ui/components/ui/text_utils.rb +46 -0
  137. data/lib/shoko/adapters/output/ui/constants/highlighting.rb +21 -0
  138. data/lib/shoko/adapters/output/ui/constants/messages.rb +12 -0
  139. data/lib/shoko/adapters/output/ui/constants/themes.rb +79 -0
  140. data/lib/shoko/adapters/output/ui/constants/ui_constants.rb +85 -0
  141. data/lib/shoko/adapters/output/ui/rendering/frame_coordinator.rb +42 -0
  142. data/lib/shoko/adapters/output/ui/rendering/reader_render_coordinator.rb +169 -0
  143. data/lib/shoko/adapters/output/ui/rendering/render_pipeline.rb +55 -0
  144. data/lib/shoko/adapters/storage/atomic_file_writer.rb +43 -0
  145. data/lib/shoko/adapters/storage/background_worker.rb +66 -0
  146. data/lib/shoko/adapters/storage/book_cache_pipeline.rb +653 -0
  147. data/lib/shoko/adapters/storage/cache/epub/memory_cache.rb +99 -0
  148. data/lib/shoko/adapters/storage/cache/epub/persistence.rb +131 -0
  149. data/lib/shoko/adapters/storage/cache/epub/serializer/deserialize.rb +225 -0
  150. data/lib/shoko/adapters/storage/cache/epub/serializer/helpers.rb +63 -0
  151. data/lib/shoko/adapters/storage/cache/epub/serializer/serialize.rb +83 -0
  152. data/lib/shoko/adapters/storage/cache/epub/serializer.rb +5 -0
  153. data/lib/shoko/adapters/storage/cache/epub/source_reference.rb +58 -0
  154. data/lib/shoko/adapters/storage/cache_paths.rb +21 -0
  155. data/lib/shoko/adapters/storage/cache_pointer_manager.rb +60 -0
  156. data/lib/shoko/adapters/storage/config_paths.rb +30 -0
  157. data/lib/shoko/adapters/storage/epub_cache.rb +195 -0
  158. data/lib/shoko/adapters/storage/file_writer_service.rb +47 -0
  159. data/lib/shoko/adapters/storage/json_cache_store/chapters.rb +141 -0
  160. data/lib/shoko/adapters/storage/json_cache_store/layouts.rb +67 -0
  161. data/lib/shoko/adapters/storage/json_cache_store/manifest.rb +42 -0
  162. data/lib/shoko/adapters/storage/json_cache_store/payload_helpers.rb +113 -0
  163. data/lib/shoko/adapters/storage/json_cache_store/resources.rb +84 -0
  164. data/lib/shoko/adapters/storage/json_cache_store.rb +167 -0
  165. data/lib/shoko/adapters/storage/lazy_file_string.rb +65 -0
  166. data/lib/shoko/adapters/storage/pagination_cache.rb +127 -0
  167. data/lib/shoko/adapters/storage/recent_files.rb +78 -0
  168. data/lib/shoko/adapters/storage/repositories/annotation_repository.rb +182 -0
  169. data/lib/shoko/adapters/storage/repositories/base_repository.rb +81 -0
  170. data/lib/shoko/adapters/storage/repositories/bookmark_repository.rb +132 -0
  171. data/lib/shoko/adapters/storage/repositories/cached_library_repository.rb +129 -0
  172. data/lib/shoko/adapters/storage/repositories/config_repository.rb +262 -0
  173. data/lib/shoko/adapters/storage/repositories/progress_repository.rb +166 -0
  174. data/lib/shoko/adapters/storage/repositories/storage/annotation_file_store.rb +128 -0
  175. data/lib/shoko/adapters/storage/repositories/storage/bookmark_file_store.rb +109 -0
  176. data/lib/shoko/adapters/storage/repositories/storage/file_store_utils.rb +20 -0
  177. data/lib/shoko/adapters/storage/repositories/storage/progress_file_store.rb +59 -0
  178. data/lib/shoko/application/annotation_editor_overlay_session.rb +138 -0
  179. data/lib/shoko/application/cli.rb +134 -0
  180. data/lib/shoko/application/controllers/menu/input_controller.rb +189 -0
  181. data/lib/shoko/application/controllers/menu/state_controller.rb +642 -0
  182. data/lib/shoko/application/controllers/menu_controller.rb +469 -0
  183. data/lib/shoko/application/controllers/mouseable_reader.rb +377 -0
  184. data/lib/shoko/application/controllers/reader_controller.rb +449 -0
  185. data/lib/shoko/application/controllers/state_controller.rb +410 -0
  186. data/lib/shoko/application/controllers/ui_controller.rb +782 -0
  187. data/lib/shoko/application/dependency_container.rb +301 -0
  188. data/lib/shoko/application/infrastructure/event_bus.rb +80 -0
  189. data/lib/shoko/application/infrastructure/observer_state_store.rb +136 -0
  190. data/lib/shoko/application/infrastructure/state_store.rb +413 -0
  191. data/lib/shoko/application/main_menu/menu_progress_presenter.rb +83 -0
  192. data/lib/shoko/application/pending_jump_handler.rb +122 -0
  193. data/lib/shoko/application/reader_lifecycle.rb +65 -0
  194. data/lib/shoko/application/reader_startup_orchestrator.rb +113 -0
  195. data/lib/shoko/application/selectors/config_selectors.rb +62 -0
  196. data/lib/shoko/application/selectors/menu_selectors.rb +62 -0
  197. data/lib/shoko/application/selectors/reader_selectors.rb +186 -0
  198. data/lib/shoko/application/state/actions/base_action.rb +24 -0
  199. data/lib/shoko/application/state/actions/quit_to_menu_action.rb +16 -0
  200. data/lib/shoko/application/state/actions/switch_reader_mode_action.rb +22 -0
  201. data/lib/shoko/application/state/actions/toggle_view_mode_action.rb +31 -0
  202. data/lib/shoko/application/state/actions/update_annotation_editor_overlay_action.rb +27 -0
  203. data/lib/shoko/application/state/actions/update_annotations_action.rb +20 -0
  204. data/lib/shoko/application/state/actions/update_annotations_overlay_action.rb +27 -0
  205. data/lib/shoko/application/state/actions/update_bookmarks_action.rb +20 -0
  206. data/lib/shoko/application/state/actions/update_chapter_action.rb +24 -0
  207. data/lib/shoko/application/state/actions/update_config_action.rb +22 -0
  208. data/lib/shoko/application/state/actions/update_field_helpers.rb +26 -0
  209. data/lib/shoko/application/state/actions/update_menu_action.rb +21 -0
  210. data/lib/shoko/application/state/actions/update_message_action.rb +35 -0
  211. data/lib/shoko/application/state/actions/update_page_action.rb +21 -0
  212. data/lib/shoko/application/state/actions/update_pagination_state_action.rb +21 -0
  213. data/lib/shoko/application/state/actions/update_popup_menu_action.rb +27 -0
  214. data/lib/shoko/application/state/actions/update_reader_meta_action.rb +21 -0
  215. data/lib/shoko/application/state/actions/update_reader_mode_action.rb +20 -0
  216. data/lib/shoko/application/state/actions/update_rendered_lines_action.rb +40 -0
  217. data/lib/shoko/application/state/actions/update_selection_action.rb +27 -0
  218. data/lib/shoko/application/state/actions/update_selections_action.rb +21 -0
  219. data/lib/shoko/application/state/actions/update_sidebar_action.rb +34 -0
  220. data/lib/shoko/application/state/actions/update_ui_loading_action.rb +23 -0
  221. data/lib/shoko/application/ui/reader_view_model_builder.rb +74 -0
  222. data/lib/shoko/application/ui/view_models/reader_view_model.rb +177 -0
  223. data/lib/shoko/application/unified_application.rb +48 -0
  224. data/lib/shoko/application/use_cases/catalog_service.rb +117 -0
  225. data/lib/shoko/application/use_cases/commands/annotation_editor_commands.rb +105 -0
  226. data/lib/shoko/application/use_cases/commands/application_commands.rb +208 -0
  227. data/lib/shoko/application/use_cases/commands/base_command.rb +166 -0
  228. data/lib/shoko/application/use_cases/commands/bookmark_commands.rb +114 -0
  229. data/lib/shoko/application/use_cases/commands/conditional_navigation_commands.rb +57 -0
  230. data/lib/shoko/application/use_cases/commands/menu_commands.rb +170 -0
  231. data/lib/shoko/application/use_cases/commands/navigation_commands.rb +183 -0
  232. data/lib/shoko/application/use_cases/commands/reader_commands.rb +46 -0
  233. data/lib/shoko/application/use_cases/commands/sidebar_commands.rb +55 -0
  234. data/lib/shoko/application/use_cases/settings_service.rb +123 -0
  235. data/lib/shoko/core/events/annotation_events.rb +94 -0
  236. data/lib/shoko/core/events/base_domain_event.rb +169 -0
  237. data/lib/shoko/core/events/bookmark_events.rb +41 -0
  238. data/lib/shoko/core/events/domain_event_bus.rb +163 -0
  239. data/lib/shoko/core/events/progress_events.rb +108 -0
  240. data/lib/shoko/core/models/bookmark.rb +36 -0
  241. data/lib/shoko/core/models/bookmark_data.rb +10 -0
  242. data/lib/shoko/core/models/chapter.rb +25 -0
  243. data/lib/shoko/core/models/content_block.rb +44 -0
  244. data/lib/shoko/core/models/reader_settings.rb +20 -0
  245. data/lib/shoko/core/models/selection_anchor.rb +73 -0
  246. data/lib/shoko/core/models/toc_entry.rb +14 -0
  247. data/lib/shoko/core/ports/annotation_repository.rb +0 -0
  248. data/lib/shoko/core/ports/book_repository.rb +0 -0
  249. data/lib/shoko/core/ports/book_source.rb +0 -0
  250. data/lib/shoko/core/ports/bookmark_repository.rb +0 -0
  251. data/lib/shoko/core/ports/cache.rb +0 -0
  252. data/lib/shoko/core/ports/input_handler.rb +0 -0
  253. data/lib/shoko/core/ports/renderer.rb +0 -0
  254. data/lib/shoko/core/ports/storage.rb +0 -0
  255. data/lib/shoko/core/services/annotation_service.rb +102 -0
  256. data/lib/shoko/core/services/base_service.rb +60 -0
  257. data/lib/shoko/core/services/bookmark_service.rb +267 -0
  258. data/lib/shoko/core/services/coordinate_service.rb +265 -0
  259. data/lib/shoko/core/services/layout_service.rb +95 -0
  260. data/lib/shoko/core/services/navigation/absolute_change_applier.rb +96 -0
  261. data/lib/shoko/core/services/navigation/absolute_layout.rb +101 -0
  262. data/lib/shoko/core/services/navigation/absolute_strategy.rb +179 -0
  263. data/lib/shoko/core/services/navigation/context_builder.rb +52 -0
  264. data/lib/shoko/core/services/navigation/context_helpers.rb +63 -0
  265. data/lib/shoko/core/services/navigation/dynamic_change_applier.rb +50 -0
  266. data/lib/shoko/core/services/navigation/dynamic_strategy.rb +51 -0
  267. data/lib/shoko/core/services/navigation/image_offset_snapper.rb +150 -0
  268. data/lib/shoko/core/services/navigation/nav_context.rb +27 -0
  269. data/lib/shoko/core/services/navigation/state_updater.rb +29 -0
  270. data/lib/shoko/core/services/navigation/strategy_factory.rb +20 -0
  271. data/lib/shoko/core/services/navigation_service.rb +150 -0
  272. data/lib/shoko/core/services/page_calculator_service.rb +242 -0
  273. data/lib/shoko/core/services/pagination/internal/absolute_page_map_builder.rb +28 -0
  274. data/lib/shoko/core/services/pagination/internal/chapter_cache.rb +60 -0
  275. data/lib/shoko/core/services/pagination/internal/dynamic_page_map_builder.rb +157 -0
  276. data/lib/shoko/core/services/pagination/internal/layout_metrics_calculator.rb +73 -0
  277. data/lib/shoko/core/services/pagination/internal/page_hydrator.rb +145 -0
  278. data/lib/shoko/core/services/pagination/internal/pagination_workflow.rb +152 -0
  279. data/lib/shoko/core/services/pagination/page_info_calculator.rb +247 -0
  280. data/lib/shoko/core/services/pagination/pagination_cache_preloader.rb +173 -0
  281. data/lib/shoko/core/services/pagination/pagination_coordinator.rb +202 -0
  282. data/lib/shoko/core/services/pagination/pagination_orchestrator.rb +291 -0
  283. data/lib/shoko/core/services/pagination.rb +10 -0
  284. data/lib/shoko/core/services/progress_helper.rb +22 -0
  285. data/lib/shoko/core/services/selection_service.rb +126 -0
  286. data/lib/shoko/core/validator.rb +76 -0
  287. data/lib/shoko/shared/errors.rb +97 -0
  288. data/lib/shoko/shared/version.rb +5 -0
  289. data/lib/shoko/test_support/terminal_double.rb +175 -0
  290. data/lib/shoko/test_support/test_mode.rb +78 -0
  291. data/lib/shoko.rb +279 -0
  292. data/lib/zip.rb +732 -0
  293. data/zip.rb +5 -0
  294. metadata +370 -0
@@ -0,0 +1,653 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../shared/errors.rb'
4
+ require_relative 'epub_cache'
5
+ require_relative '../book_sources/epub_importer.rb'
6
+ require_relative 'json_cache_store'
7
+ require_relative 'cache_pointer_manager'
8
+ require_relative '../monitoring/logger.rb'
9
+ require_relative '../book_sources/source_fingerprint.rb'
10
+ require 'fileutils'
11
+ require 'time'
12
+
13
+ module Shoko
14
+ module Adapters::Storage
15
+ # Coordinates importing EPUB files and storing/loading JSON-backed caches.
16
+ # Provides a single entry point that ensures cache integrity, re-importing
17
+ # whenever a cache is missing, corrupted, or outdated.
18
+ class BookCachePipeline
19
+ # Result payload returned by cache pipeline loads.
20
+ Result = Struct.new(
21
+ :book,
22
+ :cache_path,
23
+ :source_path,
24
+ :loaded_from_cache,
25
+ :payload,
26
+ keyword_init: true
27
+ )
28
+
29
+ # Wraps manifest row hashes with typed accessors.
30
+ class ManifestRow
31
+ def self.from(row)
32
+ return nil unless row.is_a?(Hash)
33
+
34
+ new(row)
35
+ end
36
+
37
+ def initialize(row)
38
+ source_path, source_mtime, source_size, source_fingerprint, updated_at, source_sha =
39
+ row.values_at(
40
+ 'source_path',
41
+ 'source_mtime',
42
+ 'source_size_bytes',
43
+ 'source_fingerprint',
44
+ 'updated_at',
45
+ 'source_sha'
46
+ )
47
+ fingerprint_value = source_fingerprint.to_s.strip
48
+ fingerprint_value = nil if fingerprint_value.empty?
49
+
50
+ @data = {
51
+ source_path: source_path.to_s,
52
+ source_mtime: source_mtime,
53
+ source_size_bytes: source_size,
54
+ source_fingerprint: fingerprint_value,
55
+ updated_at: updated_at.to_f,
56
+ source_sha: source_sha.to_s.strip,
57
+ }
58
+ end
59
+
60
+ def path_match?(source_path)
61
+ @data[:source_path] == source_path
62
+ end
63
+
64
+ def mtime_match?(source_mtime)
65
+ raw_mtime = @data[:source_mtime]
66
+ return false unless raw_mtime
67
+
68
+ (raw_mtime.to_f - source_mtime.to_f).abs <= 1.0
69
+ end
70
+
71
+ def size_match?(source_size_bytes)
72
+ raw_size = @data[:source_size_bytes]
73
+ return true unless raw_size
74
+
75
+ raw_size.to_i == source_size_bytes.to_i
76
+ end
77
+
78
+ def fingerprint_value
79
+ @data[:source_fingerprint]
80
+ end
81
+
82
+ def updated_at
83
+ @data[:updated_at]
84
+ end
85
+
86
+ def source_sha
87
+ sha = @data[:source_sha]
88
+ sha.empty? ? nil : sha
89
+ end
90
+ end
91
+ private_constant :ManifestRow
92
+
93
+ # Filters manifest rows by source fingerprint, keeping untagged rows.
94
+ class FingerprintFilter
95
+ def initialize(source_path)
96
+ @source_path = source_path
97
+ @fingerprint = nil
98
+ @fingerprint_blank = false
99
+ end
100
+
101
+ def call(rows)
102
+ matches = rows.select { |row| include_row?(row) }
103
+ [matches, applied?, blank?]
104
+ end
105
+
106
+ private
107
+
108
+ def include_row?(row)
109
+ value = row.fingerprint_value
110
+ return true unless value
111
+
112
+ ensure_fingerprint
113
+ return false if @fingerprint_blank
114
+
115
+ value == @fingerprint
116
+ end
117
+
118
+ def ensure_fingerprint
119
+ return if @fingerprint
120
+
121
+ @fingerprint = Shoko::Adapters::BookSources::SourceFingerprint.compute(@source_path).to_s
122
+ @fingerprint_blank = @fingerprint.empty?
123
+ end
124
+
125
+ def applied?
126
+ !!@fingerprint
127
+ end
128
+
129
+ def blank?
130
+ @fingerprint_blank
131
+ end
132
+ end
133
+ private_constant :FingerprintFilter
134
+
135
+ # Finds the best manifest SHA match for a source file.
136
+ class ManifestShaFinder
137
+ def initialize(rows:, source_path:, source_mtime:, source_size_bytes:)
138
+ @rows = rows.each_with_object([]) do |row, acc|
139
+ wrapper = ManifestRow.from(row)
140
+ acc << wrapper if wrapper
141
+ end
142
+ @source_path = source_path
143
+ @source_mtime = source_mtime
144
+ @source_size_bytes = source_size_bytes
145
+ end
146
+
147
+ def sha
148
+ best = best_match
149
+ best&.source_sha
150
+ rescue StandardError
151
+ nil
152
+ end
153
+
154
+ private
155
+
156
+ def best_match
157
+ filtered_rows.max_by(&:updated_at)
158
+ end
159
+
160
+ def filtered_rows
161
+ fingerprint_matches(size_matches(mtime_matches(path_matches)))
162
+ end
163
+
164
+ def path_matches
165
+ @rows.select { |row| row.path_match?(@source_path) }
166
+ end
167
+
168
+ def mtime_matches(rows)
169
+ rows.select { |row| row.mtime_match?(@source_mtime) }
170
+ end
171
+
172
+ def size_matches(rows)
173
+ rows.select { |row| row.size_match?(@source_size_bytes) }
174
+ end
175
+
176
+ def fingerprint_matches(rows)
177
+ matches, applied, blank = FingerprintFilter.new(@source_path).call(rows)
178
+ return rows unless applied
179
+ return [] if blank
180
+
181
+ matches
182
+ end
183
+ end
184
+ private_constant :ManifestShaFinder
185
+
186
+ # Removes stale pointer cache files after a rebuild.
187
+ class PointerCacheCleaner
188
+ def initialize(cache_path, rebuilt_path)
189
+ @cache_path = cache_path
190
+ @rebuilt_path = rebuilt_path
191
+ end
192
+
193
+ def call
194
+ return unless @rebuilt_path
195
+ return if same_path?
196
+
197
+ FileUtils.rm_f(@cache_path)
198
+ rescue StandardError
199
+ nil
200
+ end
201
+
202
+ private
203
+
204
+ def same_path?
205
+ File.expand_path(@cache_path) == File.expand_path(@rebuilt_path)
206
+ end
207
+ end
208
+ private_constant :PointerCacheCleaner
209
+
210
+ # Rebuilds pointer caches by loading the original source file.
211
+ class PointerRebuilder
212
+ def initialize(cache:, formatting_service:, load_callback:)
213
+ @cache = cache
214
+ @formatting_service = formatting_service
215
+ @load_callback = load_callback
216
+ @cache_class = cache.class
217
+ end
218
+
219
+ def call
220
+ return nil unless pointer_source_valid?
221
+
222
+ rebuild
223
+ rescue StandardError => e
224
+ log_failure(e)
225
+ nil
226
+ end
227
+
228
+ private
229
+
230
+ def pointer_source_valid?
231
+ path = source_path
232
+ return false if path.empty?
233
+ return false if @cache_class.cache_file?(path)
234
+
235
+ File.file?(path)
236
+ rescue StandardError
237
+ false
238
+ end
239
+
240
+ def rebuild
241
+ rebuilt = @load_callback.call(source_path, formatting_service: @formatting_service)
242
+ PointerCacheCleaner.new(cache_path, rebuilt&.cache_path).call
243
+ rebuilt
244
+ end
245
+
246
+ def cache_path
247
+ @cache.cache_path
248
+ end
249
+
250
+ def source_path
251
+ @source_path ||= @cache.source_path.to_s
252
+ end
253
+
254
+ def log_failure(error)
255
+ Shoko::Adapters::Monitoring::Logger.error(
256
+ 'Pointer cache rebuild failed',
257
+ cache: cache_path,
258
+ source: source_path,
259
+ error: error.message
260
+ )
261
+ end
262
+ end
263
+ private_constant :PointerRebuilder
264
+
265
+ # Ensures pointer metadata exists on disk for a cached source.
266
+ class PointerFileEnsurer
267
+ def initialize(pointer_path:, sha:, source_path:, manager_class:)
268
+ @pointer_path = pointer_path
269
+ @sha = sha
270
+ @source_path = source_path
271
+ @manager_class = manager_class
272
+ end
273
+
274
+ def call
275
+ manager = @manager_class.new(@pointer_path)
276
+ existing = manager.read
277
+ return if current?(existing)
278
+
279
+ manager.write(metadata)
280
+ rescue StandardError
281
+ nil
282
+ end
283
+
284
+ private
285
+
286
+ def current?(existing)
287
+ existing && existing['sha256'] == @sha && existing['source_path'].to_s == @source_path
288
+ end
289
+
290
+ def metadata
291
+ {
292
+ 'format' => @manager_class::POINTER_FORMAT,
293
+ 'version' => @manager_class::POINTER_VERSION,
294
+ 'sha256' => @sha,
295
+ 'source_path' => @source_path,
296
+ 'generated_at' => Time.now.utc.iso8601,
297
+ 'engine' => JsonCacheStore::ENGINE,
298
+ }
299
+ end
300
+ end
301
+ private_constant :PointerFileEnsurer
302
+
303
+ # Validates cached payload completeness.
304
+ class CacheIntegrityChecker
305
+ def initialize(cache:, payload:)
306
+ @cache = cache
307
+ @payload = payload
308
+ end
309
+
310
+ def incomplete?
311
+ chapters = Array(book&.chapters)
312
+ return true if chapters.empty? || chapters.any?(&:nil?)
313
+
314
+ !@cache.chapters_complete?(chapters.length, generation: chapters_generation)
315
+ rescue StandardError
316
+ true
317
+ end
318
+
319
+ private
320
+
321
+ def book
322
+ @payload&.book
323
+ end
324
+
325
+ def chapters_generation
326
+ book&.chapters_generation
327
+ rescue NoMethodError
328
+ nil
329
+ end
330
+ end
331
+ private_constant :CacheIntegrityChecker
332
+
333
+ # Tracks whether a payload originated from cache.
334
+ class CacheStatus
335
+ def self.hit(cache_marker)
336
+ new(cache_marker)
337
+ end
338
+
339
+ def self.miss
340
+ new(nil)
341
+ end
342
+
343
+ def initialize(cache_marker)
344
+ @cache_marker = cache_marker
345
+ end
346
+
347
+ def mark_rebuilt
348
+ @cache_marker = nil
349
+ end
350
+
351
+ def loaded_from_cache?
352
+ !!@cache_marker
353
+ end
354
+ end
355
+ private_constant :CacheStatus
356
+
357
+ # Bundles payload and cache status for cache session operations.
358
+ class PayloadContext
359
+ attr_reader :payload, :cache_status
360
+
361
+ def initialize(payload:, cache_status:)
362
+ @payload = payload
363
+ @cache_status = cache_status
364
+ end
365
+ end
366
+ private_constant :PayloadContext
367
+
368
+ # Handles cache loading/rebuilding for a specific cache instance.
369
+ class CacheSession
370
+ def initialize(cache:, formatting_service:, importer_class:, load_callback:, progress_reporter: nil)
371
+ @cache = cache
372
+ @formatting_service = formatting_service
373
+ @importer_class = importer_class
374
+ @load_callback = load_callback
375
+ @progress_reporter = progress_reporter
376
+ end
377
+
378
+ def load
379
+ cache_file = @cache.cache_file?
380
+ report('Loading cached book...') if cache_file
381
+ result = result_from_initial_payload
382
+ return result if result
383
+
384
+ return rebuild_from_pointer_or_raise if cache_file
385
+
386
+ import_and_result
387
+ end
388
+
389
+ def fast_load
390
+ report('Loading cached book...')
391
+ payload, cache_status = payload_from_cache
392
+ context = PayloadContext.new(payload: payload, cache_status: cache_status)
393
+ result_from_payload_or_nil(context)
394
+ end
395
+
396
+ private
397
+
398
+ def initial_payload
399
+ payload = @cache.cache_file? ? @cache.read_cache(strict: true) : payload_from_source
400
+ payload || payload_from_source
401
+ end
402
+
403
+ def payload_from_cache
404
+ payload = @cache.read_cache(strict: true)
405
+ cache_status = CacheStatus.hit(payload)
406
+ payload ||= rebuild_cache
407
+ [payload, cache_status]
408
+ end
409
+
410
+ def payload_from_source
411
+ @cache.load_for_source(strict: true) || @cache.load_for_source(strict: false)
412
+ end
413
+
414
+ def result_from_initial_payload
415
+ payload = initial_payload
416
+ payload && result_from_payload(
417
+ PayloadContext.new(payload: payload, cache_status: CacheStatus.hit(payload))
418
+ )
419
+ end
420
+
421
+ def result_from_payload(context)
422
+ payload = rebuild_if_incomplete(context)
423
+ result_for(payload, loaded_from_cache: context.cache_status.loaded_from_cache?)
424
+ end
425
+
426
+ def result_from_payload_or_nil(context)
427
+ context.payload && begin
428
+ payload = rebuild_if_incomplete(context)
429
+ payload && result_for(payload, loaded_from_cache: context.cache_status.loaded_from_cache?)
430
+ end
431
+ end
432
+
433
+ def rebuild_if_incomplete(context)
434
+ payload = context.payload
435
+ checker = CacheIntegrityChecker.new(cache: @cache, payload: payload)
436
+ return payload unless checker.incomplete?
437
+
438
+ context.cache_status.mark_rebuilt
439
+ rebuild_cache
440
+ end
441
+
442
+ def result_for(payload, loaded_from_cache:)
443
+ book = payload.book
444
+ cache_path = @cache.cache_path
445
+ source_path = payload.source_path || @cache.source_path
446
+ Result.new(
447
+ book: book,
448
+ cache_path: cache_path,
449
+ source_path: source_path,
450
+ loaded_from_cache: loaded_from_cache,
451
+ payload: payload
452
+ )
453
+ end
454
+
455
+ def import_and_result
456
+ context = PayloadContext.new(payload: rebuild_cache, cache_status: CacheStatus.miss)
457
+ result = result_from_payload_or_nil(context)
458
+ return result if result
459
+
460
+ raise Shoko::CacheLoadError.new(@cache.cache_path, 'cache write failed')
461
+ end
462
+
463
+ def rebuild_cache
464
+ importer = @importer_class.new(
465
+ formatting_service: @formatting_service,
466
+ progress_reporter: @progress_reporter
467
+ )
468
+ book_data = importer.import(@cache.source_path)
469
+ report('Creating JSON cache...', progress: 0.0)
470
+ @cache.write_book!(book_data)
471
+ report('Finalizing cache...', progress: 1.0)
472
+ payload_from_source
473
+ end
474
+
475
+ def rebuild_from_pointer_or_raise
476
+ rebuilt = rebuild_from_pointer
477
+ return rebuilt if rebuilt
478
+
479
+ raise Shoko::CacheLoadError, @cache.cache_path
480
+ end
481
+
482
+ def rebuild_from_pointer
483
+ report('Rebuilding cache from source...')
484
+ PointerRebuilder.new(
485
+ cache: @cache,
486
+ formatting_service: @formatting_service,
487
+ load_callback: @load_callback
488
+ ).call
489
+ end
490
+
491
+ def report(message, progress: nil)
492
+ reporter = @progress_reporter
493
+ return unless reporter
494
+ return if message.nil? || message.to_s.strip.empty?
495
+
496
+ if reporter.respond_to?(:call)
497
+ reporter.call(message: message, progress: progress)
498
+ elsif reporter.respond_to?(:update_status)
499
+ reporter.update_status(message: message, progress: progress)
500
+ end
501
+ rescue StandardError
502
+ nil
503
+ end
504
+ end
505
+ private_constant :CacheSession
506
+
507
+ # Raises standardized load errors for pipeline failures.
508
+ class LoadErrorHandler
509
+ def initialize(path)
510
+ @path = path
511
+ end
512
+
513
+ def call(error)
514
+ message = error.message
515
+ Shoko::Adapters::Monitoring::Logger.error('Book cache pipeline failed', path: @path, error: message)
516
+ raise Shoko::EPUBParseError.new(message, @path)
517
+ end
518
+ end
519
+ private_constant :LoadErrorHandler
520
+
521
+ def initialize(cache_class: EpubCache, cache_root: CachePaths.cache_root, progress_reporter: nil)
522
+ @cache_class = cache_class
523
+ @cache_root = cache_root
524
+ @importer_class = Shoko::Adapters::BookSources::EpubImporter
525
+ @pointer_manager_class = CachePointerManager
526
+ @progress_reporter = progress_reporter
527
+ end
528
+
529
+ def load(path, formatting_service: nil)
530
+ perform_load(path, formatting_service)
531
+ rescue StandardError => e
532
+ raise if e.is_a?(Shoko::Error)
533
+
534
+ LoadErrorHandler.new(path).call(e)
535
+ end
536
+
537
+ private
538
+
539
+ def perform_load(path, formatting_service)
540
+ report('Checking cache...')
541
+ expanded = File.expand_path(path)
542
+ fast = fast_load_if_available(expanded, formatting_service)
543
+ return fast if fast
544
+
545
+ cache = build_cache(expanded)
546
+ cache_session(cache, formatting_service).load
547
+ end
548
+
549
+ def fast_load_if_available(expanded, formatting_service)
550
+ return nil unless fast_source_path?(expanded)
551
+
552
+ fast_load_for_source(expanded, formatting_service)
553
+ end
554
+
555
+ def build_cache(path)
556
+ @cache_class.new(path, cache_root: @cache_root)
557
+ end
558
+
559
+ def cache_session(cache, formatting_service)
560
+ CacheSession.new(
561
+ cache: cache,
562
+ formatting_service: formatting_service,
563
+ importer_class: @importer_class,
564
+ load_callback: method(:load),
565
+ progress_reporter: @progress_reporter
566
+ )
567
+ end
568
+
569
+ def fast_source_path?(expanded)
570
+ return false if @cache_class.cache_file?(expanded)
571
+
572
+ File.file?(expanded)
573
+ rescue StandardError
574
+ false
575
+ end
576
+
577
+ def fast_load_for_source(source_path, formatting_service)
578
+ perform_fast_load(source_path, formatting_service)
579
+ rescue StandardError => e
580
+ Shoko::Adapters::Monitoring::Logger.debug('Fast cache load failed', path: source_path, error: e.message)
581
+ nil
582
+ end
583
+
584
+ def perform_fast_load(source_path, formatting_service)
585
+ report('Looking up cached data...')
586
+ pointer_path, sha = pointer_for_source(source_path)
587
+ return nil unless pointer_path
588
+
589
+ ensure_pointer_file(pointer_path, sha, source_path)
590
+
591
+ cache = build_cache(pointer_path)
592
+ cache_session(cache, formatting_service).fast_load
593
+ end
594
+
595
+ def pointer_for_source(source_path)
596
+ sha = sha_for_source(source_path)
597
+ return nil unless sha
598
+
599
+ pointer_path = pointer_path_for_sha(sha)
600
+ return nil unless pointer_path
601
+
602
+ [pointer_path, sha]
603
+ end
604
+
605
+ def sha_for_source(source_path)
606
+ source_mtime = File.mtime(source_path).utc
607
+ source_size_bytes = File.size(source_path)
608
+ sha_from_manifest(source_path, source_mtime, source_size_bytes)
609
+ end
610
+
611
+ def pointer_path_for_sha(sha)
612
+ @cache_class.cache_path_for_sha(sha, cache_root: @cache_root)
613
+ end
614
+
615
+ def sha_from_manifest(source_path, source_mtime, source_size_bytes)
616
+ rows = JsonCacheStore.manifest_rows(@cache_root)
617
+ return nil if rows.empty?
618
+
619
+ ManifestShaFinder.new(
620
+ rows: rows,
621
+ source_path: source_path,
622
+ source_mtime: source_mtime,
623
+ source_size_bytes: source_size_bytes
624
+ ).sha
625
+ rescue StandardError
626
+ nil
627
+ end
628
+
629
+ def ensure_pointer_file(pointer_path, sha, source_path)
630
+ PointerFileEnsurer.new(
631
+ pointer_path: pointer_path,
632
+ sha: sha,
633
+ source_path: source_path,
634
+ manager_class: @pointer_manager_class
635
+ ).call
636
+ end
637
+
638
+ def report(message, progress: nil)
639
+ reporter = @progress_reporter
640
+ return unless reporter
641
+ return if message.nil? || message.to_s.strip.empty?
642
+
643
+ if reporter.respond_to?(:call)
644
+ reporter.call(message: message, progress: progress)
645
+ elsif reporter.respond_to?(:update_status)
646
+ reporter.update_status(message: message, progress: progress)
647
+ end
648
+ rescue StandardError
649
+ nil
650
+ end
651
+ end
652
+ end
653
+ end