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,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'nav_context'
4
+
5
+ module Shoko
6
+ module Core
7
+ module Services
8
+ module Navigation
9
+ # Computes absolute-mode navigation offsets for single and split view.
10
+ # Returns hashes describing desired state field updates; facade applies.
11
+ module AbsoluteStrategy
12
+ module_function
13
+
14
+ def next_page(context)
15
+ if context.view_mode == :split
16
+ next_page_split(context)
17
+ else
18
+ next_page_single(context)
19
+ end
20
+ end
21
+
22
+ def prev_page(context)
23
+ if context.view_mode == :split
24
+ prev_page_split(context)
25
+ else
26
+ prev_page_single(context)
27
+ end
28
+ end
29
+
30
+ def scroll(context, direction, lines)
31
+ lines = lines.to_i
32
+ return {} if lines <= 0
33
+
34
+ if context.view_mode == :split
35
+ scroll_split(context, direction, lines)
36
+ else
37
+ scroll_single(context, direction, lines)
38
+ end
39
+ end
40
+
41
+ def go_to_start(context)
42
+ if context.view_mode == :split
43
+ stride = split_stride(context)
44
+ { current_chapter: 0, current_page: 0, single_page: 0, left_page: 0, right_page: stride }
45
+ else
46
+ { current_chapter: 0, current_page: 0, single_page: 0, left_page: 0, right_page: split_stride(context) }
47
+ end
48
+ end
49
+
50
+ def go_to_end(context)
51
+ # Facade will compute last_page for last chapter; provide intent only.
52
+ last_chapter = [context.total_chapters - 1, 0].max
53
+ { current_chapter: last_chapter, align_to_last: true }
54
+ end
55
+
56
+ def jump_to_chapter(context, index)
57
+ if context.view_mode == :split
58
+ stride = split_stride(context)
59
+ { current_chapter: index, current_page: 0, single_page: 0, left_page: 0, right_page: stride }
60
+ else
61
+ { current_chapter: index, current_page: 0, single_page: 0, left_page: 0,
62
+ right_page: split_stride(context) }
63
+ end
64
+ end
65
+
66
+ # --- helpers ---
67
+ def next_page_single(context)
68
+ stride = single_stride(context)
69
+ cur = context.single_page.to_i
70
+ max_offset = max_offset(context)
71
+
72
+ if cur < max_offset
73
+ new_offset = [cur + stride, max_offset].min
74
+ { single_page: new_offset, current_page: new_offset }
75
+ elsif context.current_chapter < context.total_chapters - 1
76
+ { advance_chapter: :next }
77
+ else
78
+ {}
79
+ end
80
+ end
81
+
82
+ def prev_page_single(context)
83
+ stride = single_stride(context)
84
+ cur = context.single_page.to_i
85
+ if cur.positive?
86
+ new_offset = [cur - stride, 0].max
87
+ { single_page: new_offset, current_page: new_offset }
88
+ elsif context.current_chapter.positive?
89
+ { advance_chapter: :prev, stride: stride }
90
+ else
91
+ {}
92
+ end
93
+ end
94
+
95
+ def next_page_split(context)
96
+ stride = split_stride(context)
97
+ left = context.left_page.to_i
98
+ max_offset = max_offset(context)
99
+
100
+ if left < max_offset
101
+ new_left = [left + stride, max_offset].min
102
+ { left_page: new_left, right_page: new_left + stride, current_page: new_left }
103
+ elsif context.current_chapter < context.total_chapters - 1
104
+ { advance_chapter: :next }
105
+ else
106
+ {}
107
+ end
108
+ end
109
+
110
+ def prev_page_split(context)
111
+ stride = split_stride(context)
112
+ left = context.left_page.to_i
113
+ if left.positive?
114
+ new_left = [left - stride, 0].max
115
+ { left_page: new_left, right_page: new_left + stride, current_page: new_left }
116
+ elsif context.current_chapter.positive?
117
+ { advance_chapter: :prev, stride: stride }
118
+ else
119
+ {}
120
+ end
121
+ end
122
+
123
+ def scroll_single(context, direction, lines)
124
+ cur = context.single_page.to_i
125
+ max_offset = max_offset(context)
126
+ new_offset = case direction
127
+ when :up
128
+ [cur - lines, 0].max
129
+ when :down
130
+ [cur + lines, max_offset].min
131
+ else
132
+ raise ArgumentError, "Invalid scroll direction: #{direction}"
133
+ end
134
+
135
+ return {} if new_offset == cur
136
+
137
+ { single_page: new_offset, current_page: new_offset }
138
+ end
139
+
140
+ def scroll_split(context, direction, lines)
141
+ cur = context.left_page.to_i
142
+ stride = split_stride(context)
143
+ max_offset = max_offset(context)
144
+ new_offset = case direction
145
+ when :up
146
+ [cur - lines, 0].max
147
+ when :down
148
+ [cur + lines, max_offset].min
149
+ else
150
+ raise ArgumentError, "Invalid scroll direction: #{direction}"
151
+ end
152
+
153
+ return {} if new_offset == cur
154
+
155
+ { left_page: new_offset, right_page: new_offset + stride, current_page: new_offset }
156
+ end
157
+
158
+ def single_stride(context)
159
+ stride = context.lines_per_page.to_i
160
+ stride = context.column_lines_per_page.to_i if stride <= 0
161
+ stride = 1 if stride <= 0
162
+ stride
163
+ end
164
+
165
+ def split_stride(context)
166
+ stride = context.column_lines_per_page.to_i
167
+ stride = context.lines_per_page.to_i if stride <= 0
168
+ stride = 1 if stride <= 0
169
+ stride
170
+ end
171
+
172
+ def max_offset(context)
173
+ context.max_offset_in_chapter.to_i
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'nav_context'
4
+ require_relative 'context_helpers'
5
+
6
+ module Shoko
7
+ module Core
8
+ module Services
9
+ module Navigation
10
+ # Builds navigation context snapshots from the current state.
11
+ class ContextBuilder
12
+ def initialize(state_store, page_calculator)
13
+ @state_store = state_store
14
+ @page_calculator = page_calculator
15
+ end
16
+
17
+ def build
18
+ snapshot = ContextHelpers.safe_snapshot(@state_store)
19
+
20
+ NavContext.new(
21
+ mode: ContextHelpers.dynamic_mode?(snapshot) ? :dynamic : :absolute,
22
+ view_mode: ContextHelpers.current_view_mode(snapshot),
23
+ current_chapter: ContextHelpers.current_chapter(snapshot),
24
+ total_chapters: ContextHelpers.total_chapters(snapshot),
25
+ current_page_index: ContextHelpers.current_page_index(snapshot),
26
+ dynamic_total_pages: dynamic_total_pages,
27
+ single_page: ContextHelpers.single_page(snapshot),
28
+ left_page: ContextHelpers.left_page(snapshot),
29
+ right_page: ContextHelpers.right_page(snapshot),
30
+ max_page_in_chapter: 0,
31
+ lines_per_page: 0,
32
+ column_lines_per_page: 0,
33
+ max_offset_in_chapter: 0
34
+ )
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :page_calculator
40
+
41
+ def dynamic_total_pages
42
+ return 0 unless page_calculator.respond_to?(:total_pages)
43
+
44
+ page_calculator.total_pages.to_i
45
+ rescue StandardError
46
+ 0
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shoko
4
+ module Core
5
+ module Services
6
+ module Navigation
7
+ # Helpers for extracting navigation-relevant values from a state snapshot.
8
+ module ContextHelpers
9
+ module_function
10
+
11
+ def safe_snapshot(state_store)
12
+ return {} unless state_store.respond_to?(:current_state)
13
+
14
+ state_store.current_state || {}
15
+ rescue StandardError
16
+ {}
17
+ end
18
+
19
+ def dynamic_mode?(snapshot)
20
+ mode = snapshot.dig(:config, :page_numbering_mode)
21
+ mode == :dynamic
22
+ end
23
+
24
+ def current_view_mode(snapshot)
25
+ snapshot.dig(:config, :view_mode) || :split
26
+ end
27
+
28
+ def current_chapter(snapshot)
29
+ snapshot.dig(:reader, :current_chapter) || 0
30
+ end
31
+
32
+ def total_chapters(snapshot)
33
+ snapshot.dig(:reader, :total_chapters) || 0
34
+ end
35
+
36
+ def current_page_index(snapshot)
37
+ snapshot.dig(:reader, :current_page_index) || 0
38
+ end
39
+
40
+ def current_page(snapshot)
41
+ snapshot.dig(:reader, :current_page) || 0
42
+ end
43
+
44
+ def single_page(snapshot)
45
+ snapshot.dig(:reader, :single_page) || current_page(snapshot)
46
+ end
47
+
48
+ def left_page(snapshot)
49
+ snapshot.dig(:reader, :left_page) || current_page(snapshot)
50
+ end
51
+
52
+ def right_page(snapshot)
53
+ snapshot.dig(:reader, :right_page) || 0
54
+ end
55
+
56
+ def page_map(snapshot)
57
+ snapshot.dig(:reader, :page_map) || []
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shoko
4
+ module Core
5
+ module Services
6
+ module Navigation
7
+ # Applies dynamic-mode changes to the state store.
8
+ class DynamicChangeApplier
9
+ def initialize(state_store:, page_calculator:, state_updater:)
10
+ @state_store = state_store
11
+ @page_calculator = page_calculator
12
+ @state_updater = state_updater
13
+ end
14
+
15
+ def apply(changes)
16
+ return if changes.nil? || changes.empty?
17
+
18
+ update_page_index(changes[:current_page_index]) if changes.key?(:current_page_index)
19
+
20
+ return unless changes.key?(:current_chapter)
21
+
22
+ @state_updater.apply(%i[reader current_chapter] => changes[:current_chapter])
23
+ end
24
+
25
+ private
26
+
27
+ def update_page_index(new_index)
28
+ updates = { %i[reader current_page_index] => new_index }
29
+ page = @page_calculator&.get_page(new_index)
30
+ if page
31
+ current_chapter = page[:chapter_index]
32
+ current_chapter ||= current_chapter_from_state || 0
33
+ updates[%i[reader current_chapter]] = current_chapter
34
+ end
35
+
36
+ @state_updater.apply(updates)
37
+ end
38
+
39
+ def current_chapter_from_state
40
+ return nil unless @state_store.respond_to?(:current_state)
41
+
42
+ @state_store.current_state.dig(:reader, :current_chapter)
43
+ rescue StandardError
44
+ nil
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'nav_context'
4
+
5
+ module Shoko
6
+ module Core
7
+ module Services
8
+ module Navigation
9
+ # Computes dynamic-mode navigation targets from an immutable context.
10
+ # Returns simple hashes describing state changes; facade applies them.
11
+ module DynamicStrategy
12
+ module_function
13
+
14
+ def next_page(context)
15
+ inc = context.view_mode == :split ? 2 : 1
16
+ total = context.dynamic_total_pages.to_i
17
+ return {} if total <= 0
18
+
19
+ new_index = (context.current_page_index + inc).clamp(0, total - 1)
20
+ { current_page_index: new_index }
21
+ end
22
+
23
+ def prev_page(context)
24
+ dec = context.view_mode == :split ? 2 : 1
25
+ total = context.dynamic_total_pages.to_i
26
+ return {} if total <= 0
27
+
28
+ new_index = (context.current_page_index - dec).clamp(0, total - 1)
29
+ { current_page_index: new_index }
30
+ end
31
+
32
+ def go_to_start(_context)
33
+ { current_chapter: 0, current_page_index: 0 }
34
+ end
35
+
36
+ def go_to_end(context)
37
+ total = context.dynamic_total_pages.to_i
38
+ return {} if total <= 0
39
+
40
+ { current_page_index: total - 1 }
41
+ end
42
+
43
+ def jump_to_chapter(_context, index)
44
+ # Facade will map chapter -> page index precisely; here provide chapter intent.
45
+ { current_chapter: index, current_page_index: 0 }
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'context_helpers'
4
+ require_relative 'absolute_layout'
5
+ require_relative '../../../adapters/output/kitty/kitty_graphics.rb'
6
+
7
+ module Shoko
8
+ module Core
9
+ module Services
10
+ module Navigation
11
+ # Snaps absolute offsets so image blocks render from their first line.
12
+ class ImageOffsetSnapper
13
+ def initialize(state_store:, layout_service:, formatting_service:, document:)
14
+ @state_store = state_store
15
+ @layout_service = layout_service
16
+ @formatting_service = formatting_service
17
+ @document = document
18
+ @layout = AbsoluteLayout.new(state_store: state_store, layout_service: layout_service)
19
+ end
20
+
21
+ def snap(updates, layout_state)
22
+ return updates unless enabled?
23
+ return updates if updates.nil? || updates.empty?
24
+
25
+ snapshot = layout_state.snapshot
26
+ view_mode = layout_state.view_mode
27
+ stride = layout_state.stride
28
+ chapter_index = updates[%i[reader current_chapter]] || ContextHelpers.current_chapter(snapshot)
29
+ col_width = @layout.column_width(snapshot, view_mode)
30
+
31
+ if view_mode == :split
32
+ snap_split(updates, chapter_index, col_width, stride, snapshot)
33
+ else
34
+ snap_single(updates, chapter_index, col_width, stride, snapshot)
35
+ end
36
+ rescue StandardError
37
+ updates
38
+ end
39
+
40
+ private
41
+
42
+ def enabled?
43
+ return false unless @layout_service && @formatting_service && @document
44
+
45
+ Shoko::Adapters::Output::Kitty::KittyGraphics.enabled_for?(@state_store)
46
+ end
47
+
48
+ def snap_split(updates, chapter_index, col_width, stride, snapshot)
49
+ left = (updates[%i[reader left_page]] || snapshot.dig(:reader, :left_page) || 0).to_i
50
+ snapped = snap_offset(chapter_index, col_width, left, stride)
51
+ return updates if snapped == left
52
+
53
+ updates[%i[reader left_page]] = snapped
54
+ updates[%i[reader current_page]] = snapped
55
+ updates[%i[reader right_page]] = snapped + stride
56
+ updates
57
+ end
58
+
59
+ def snap_single(updates, chapter_index, col_width, stride, snapshot)
60
+ offset = (updates[%i[reader single_page]] || snapshot.dig(:reader, :single_page) || 0).to_i
61
+ snapped = snap_offset(chapter_index, col_width, offset, stride)
62
+ return updates if snapped == offset
63
+
64
+ updates[%i[reader single_page]] = snapped
65
+ updates[%i[reader current_page]] = snapped
66
+ updates
67
+ end
68
+
69
+ def snap_offset(chapter_index, col_width, offset, lines_per_page)
70
+ offset_i = offset.to_i
71
+ return offset_i if offset_i <= 0
72
+
73
+ lines = wrapped_lines(chapter_index, col_width, lines_per_page)
74
+ return offset_i unless lines && lines[offset_i]
75
+
76
+ image_start_for(lines, offset_i) || offset_i
77
+ rescue StandardError
78
+ offset_i
79
+ end
80
+
81
+ def wrapped_lines(chapter_index, col_width, lines_per_page)
82
+ @formatting_service.wrap_all(
83
+ @document,
84
+ chapter_index,
85
+ col_width,
86
+ config: @state_store,
87
+ lines_per_page: lines_per_page
88
+ )
89
+ end
90
+
91
+ def image_start_for(lines, offset)
92
+ meta = line_metadata(lines[offset])
93
+ render = image_render(meta)
94
+ return nil unless render
95
+
96
+ src = image_src(meta)
97
+ return nil if src.to_s.empty?
98
+ return offset if render_line?(meta)
99
+
100
+ idx = offset
101
+ while idx.positive?
102
+ cur_meta = line_metadata(lines[idx])
103
+ break unless same_image?(cur_meta, src)
104
+ return idx if render_line?(cur_meta)
105
+
106
+ idx -= 1
107
+ end
108
+
109
+ 0
110
+ end
111
+
112
+ def same_image?(meta, src)
113
+ return false unless image_render(meta)
114
+
115
+ image_src(meta).to_s == src.to_s
116
+ end
117
+
118
+ def render_line?(meta)
119
+ return false unless meta
120
+
121
+ meta.key?(:image_render_line) ? meta[:image_render_line] == true : meta['image_render_line'] == true
122
+ end
123
+
124
+ def image_render(meta)
125
+ return nil unless meta
126
+
127
+ render = meta[:image_render] || meta['image_render']
128
+ render.is_a?(Hash) ? render : nil
129
+ end
130
+
131
+ def line_metadata(line)
132
+ return nil unless line.respond_to?(:metadata)
133
+
134
+ meta = line.metadata
135
+ meta.is_a?(Hash) ? meta : nil
136
+ rescue StandardError
137
+ nil
138
+ end
139
+
140
+ def image_src(meta)
141
+ image = meta[:image] || meta['image'] || {}
142
+ image[:src] || image['src']
143
+ rescue StandardError
144
+ nil
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shoko
4
+ module Core
5
+ module Services
6
+ module Navigation
7
+ # Immutable snapshot of navigation-relevant state.
8
+ NavContext = Struct.new(
9
+ :mode, # :dynamic or :absolute
10
+ :view_mode, # :single or :split
11
+ :current_chapter, # Integer
12
+ :total_chapters, # Integer
13
+ :current_page_index, # Integer (dynamic)
14
+ :dynamic_total_pages, # Integer (dynamic total pages)
15
+ :single_page, # Integer (absolute single)
16
+ :left_page, # Integer (absolute split left)
17
+ :right_page, # Integer (absolute split right)
18
+ :max_page_in_chapter, # Integer (absolute page count)
19
+ :lines_per_page, # Integer (absolute single stride)
20
+ :column_lines_per_page, # Integer (absolute split stride)
21
+ :max_offset_in_chapter, # Integer (absolute last offset)
22
+ keyword_init: true
23
+ )
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shoko
4
+ module Core
5
+ module Services
6
+ module Navigation
7
+ # Applies state updates using the most appropriate state_store API.
8
+ class StateUpdater
9
+ def initialize(state_store)
10
+ @state_store = state_store
11
+ end
12
+
13
+ def apply(updates)
14
+ return if updates.nil? || updates.empty?
15
+
16
+ can_update = @state_store.respond_to?(:update)
17
+ can_set = @state_store.respond_to?(:set)
18
+
19
+ if can_update && (!can_set || updates.length > 1)
20
+ @state_store.update(updates)
21
+ elsif can_set
22
+ updates.each { |path, value| @state_store.set(path, value) }
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'nav_context'
4
+
5
+ module Shoko
6
+ module Core
7
+ module Services
8
+ module Navigation
9
+ # Chooses the appropriate navigation strategy for the current mode.
10
+ module StrategyFactory
11
+ module_function
12
+
13
+ def select(context)
14
+ context.mode == :dynamic ? DynamicStrategy : AbsoluteStrategy
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end