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,642 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../mouseable_reader'
4
+ require_relative '../../../adapters/output/terminal/terminal_sanitizer.rb'
5
+ require_relative '../../../adapters/storage/epub_cache'
6
+ require_relative '../../../adapters/storage/recent_files'
7
+ require_relative '../../../core/services/pagination/pagination_orchestrator.rb'
8
+ require_relative '../../main_menu/menu_progress_presenter'
9
+
10
+ module Shoko
11
+ module Application::Controllers
12
+ module Menu
13
+ # Handles main-menu side effects such as launching books, refreshing scans,
14
+ # and coordinating annotation mutations.
15
+ class StateController
16
+ def initialize(menu)
17
+ @menu = menu
18
+ @pagination_orchestrator = Core::Services::Pagination::PaginationOrchestrator.new(
19
+ terminal_service: menu.terminal_service,
20
+ pagination_cache: resolve_optional(:pagination_cache),
21
+ frame_coordinator: menu.frame_coordinator
22
+ )
23
+ end
24
+
25
+ def open_selected_book
26
+ book = menu.selected_book
27
+ book ||= begin
28
+ idx = selectors.browse_selected(state)
29
+ menu.filtered_epubs && menu.filtered_epubs[idx]
30
+ end
31
+
32
+ return unless book
33
+
34
+ path = book['path']
35
+ if path && File.exist?(path)
36
+ load_and_open_with_progress(path)
37
+ else
38
+ catalog.scan_message = 'File not found'
39
+ catalog.scan_status = :error
40
+ end
41
+ end
42
+
43
+ def open_book(path)
44
+ return file_not_found unless File.exist?(path)
45
+
46
+ load_and_open_with_progress(path)
47
+ rescue StandardError => e
48
+ handle_reader_error(path, e)
49
+ end
50
+
51
+ def run_reader(path)
52
+ prior_mode = selectors.mode(state)
53
+
54
+ return unless ensure_reader_document_for(path)
55
+
56
+ recent_path = canonical_recent_path(path)
57
+ Adapters::Storage::RecentFiles.add(recent_path) if recent_path
58
+ state.dispatch(action(:update_reader_meta, book_path: path, running: true))
59
+ state.dispatch(action(:update_reader_mode, :read))
60
+
61
+ MouseableReader.new(path, nil, dependencies).run
62
+ ensure
63
+ menu.switch_to_mode(prior_mode || :browse)
64
+ end
65
+
66
+ def load_and_open_with_progress(path)
67
+ return launch_without_overlay(path) if skip_progress_overlay?
68
+
69
+ launch_with_overlay(path)
70
+ end
71
+
72
+ def file_not_found
73
+ catalog.scan_message = 'File not found'
74
+ catalog.scan_status = :error
75
+ end
76
+
77
+ def handle_reader_error(path, error)
78
+ logger&.error('Failed to open book', error: error.message, path: path)
79
+ catalog.scan_message = "Failed: #{error.class}: #{error.message[0, 60]}"
80
+ catalog.scan_status = :error
81
+
82
+ return unless Adapters::BookSources::EPUBFinder::DEBUG_MODE
83
+
84
+ logger&.debug('Reader error backtrace',
85
+ path: path,
86
+ backtrace: Array(error.backtrace).join("\n"))
87
+ end
88
+
89
+ def valid_cache_path?(path)
90
+ return false unless path && File.file?(path)
91
+ return false unless cache_pointer?(path)
92
+
93
+ !!cache_payload(path, strict: true)
94
+ rescue StandardError
95
+ false
96
+ end
97
+
98
+ def refresh_scan(force: false)
99
+ catalog.start_scan(force: force)
100
+ end
101
+
102
+ def search_downloads(query:, page_url: nil)
103
+ service = download_service
104
+ unless service
105
+ update_download_state(download_status: :error, download_message: 'Download service unavailable')
106
+ return
107
+ end
108
+
109
+ update_download_state(
110
+ download_status: :searching,
111
+ download_message: 'Searching Gutendex...',
112
+ download_progress: 0.0,
113
+ download_results: [],
114
+ download_count: 0,
115
+ download_next: nil,
116
+ download_prev: nil,
117
+ download_selected: 0
118
+ )
119
+ menu.draw_screen
120
+
121
+ result = service.search(query: query, page_url: page_url)
122
+ message = if result[:books].empty?
123
+ 'No results'
124
+ else
125
+ "Found #{result[:books].length} of #{result[:count]}"
126
+ end
127
+ update_download_state(
128
+ download_results: result[:books],
129
+ download_count: result[:count],
130
+ download_next: result[:next],
131
+ download_prev: result[:previous],
132
+ download_selected: 0,
133
+ download_status: :done,
134
+ download_message: message,
135
+ download_progress: 0.0
136
+ )
137
+ rescue StandardError => e
138
+ update_download_state(download_status: :error,
139
+ download_message: "Search failed: #{e.message}",
140
+ download_progress: 0.0)
141
+ ensure
142
+ menu.draw_screen
143
+ end
144
+
145
+ def download_book(book)
146
+ service = download_service
147
+ unless service
148
+ update_download_state(download_status: :error, download_message: 'Download service unavailable')
149
+ menu.draw_screen
150
+ return
151
+ end
152
+
153
+ title = safe_book_title(book)
154
+ update_download_state(download_status: :downloading,
155
+ download_message: "Downloading #{title}...",
156
+ download_progress: 0.0)
157
+ menu.draw_screen
158
+
159
+ last_draw = Process.clock_gettime(Process::CLOCK_MONOTONIC)
160
+ result = service.download(book) do |done, total|
161
+ progress = total.to_i.positive? ? done.to_f / total : 0.0
162
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
163
+ next if (now - last_draw) < 0.08 && progress < 1.0
164
+
165
+ percent = total.to_i.positive? ? (progress * 100).round : nil
166
+ message = percent ? "Downloading #{title}... #{percent}%" : "Downloading #{title}..."
167
+ update_download_state(download_progress: progress, download_message: message)
168
+ menu.draw_screen
169
+ last_draw = now
170
+ end
171
+
172
+ downloaded_message = result[:existing] ? 'Already downloaded' : "Saved to #{File.basename(result[:path])}"
173
+ update_download_state(download_status: :done,
174
+ download_message: downloaded_message,
175
+ download_progress: 0.0)
176
+ refresh_scan(force: true)
177
+ rescue StandardError => e
178
+ update_download_state(download_status: :error,
179
+ download_message: "Download failed: #{e.message}",
180
+ download_progress: 0.0)
181
+ ensure
182
+ menu.draw_screen
183
+ end
184
+
185
+ def open_selected_annotation
186
+ annotation_actions.open_selected_annotation
187
+ end
188
+
189
+ def open_selected_annotation_for_edit
190
+ annotation_actions.open_selected_annotation_for_edit
191
+ end
192
+
193
+ def delete_selected_annotation
194
+ annotation_actions.delete_selected_annotation
195
+ end
196
+
197
+ def save_current_annotation_edit
198
+ annotation_actions.save_current_annotation_edit
199
+ end
200
+
201
+ private
202
+
203
+ attr_reader :menu
204
+
205
+ def state
206
+ menu.state
207
+ end
208
+
209
+ def dependencies
210
+ menu.dependencies
211
+ end
212
+
213
+ def catalog
214
+ menu.catalog
215
+ end
216
+
217
+ def terminal_service
218
+ menu.terminal_service
219
+ end
220
+
221
+ def selectors
222
+ Shoko::Application::Selectors::MenuSelectors
223
+ end
224
+
225
+ def action(type, payload = nil)
226
+ case type
227
+ when :update_reader_meta
228
+ Shoko::Application::Actions::UpdateReaderMetaAction.new(payload)
229
+ when :update_reader_mode
230
+ Shoko::Application::Actions::UpdateReaderModeAction.new(payload)
231
+ when :update_menu
232
+ Shoko::Application::Actions::UpdateMenuAction.new(payload)
233
+ else
234
+ raise ArgumentError, "Unknown action #{type}"
235
+ end
236
+ end
237
+
238
+ def progress_presenter
239
+ @progress_presenter ||= Application::MainMenu::MenuProgressPresenter.new(state)
240
+ end
241
+
242
+ def download_service
243
+ @download_service ||= resolve_optional(:download_service)
244
+ end
245
+
246
+ def logger
247
+ @logger ||= resolve_optional(:logger)
248
+ end
249
+
250
+ def resolve_optional(name)
251
+ dependencies.resolve(name)
252
+ rescue StandardError
253
+ nil
254
+ end
255
+
256
+ def update_download_state(payload)
257
+ state.dispatch(action(:update_menu, payload))
258
+ end
259
+
260
+ def safe_book_title(book)
261
+ return 'book' unless book.respond_to?(:[])
262
+
263
+ title = book[:title] || book['title'] || 'book'
264
+ Shoko::Adapters::Output::Terminal::TerminalSanitizer.sanitize(title.to_s, preserve_newlines: false,
265
+ preserve_tabs: false)
266
+ end
267
+
268
+ def build_background_worker(name:)
269
+ factory = resolve_optional(:background_worker_factory)
270
+ return nil unless factory.respond_to?(:call)
271
+
272
+ factory.call(name:)
273
+ rescue StandardError
274
+ nil
275
+ end
276
+
277
+ def canonical_recent_path(path)
278
+ canonical_source_path(path)
279
+ end
280
+
281
+ def canonical_reader_path(path)
282
+ return nil unless path
283
+
284
+ canonical = canonical_source_path(path)
285
+ File.expand_path(canonical)
286
+ rescue StandardError
287
+ path
288
+ end
289
+
290
+ def cache_pointer?(path)
291
+ Adapters::Storage::EpubCache.cache_file?(path)
292
+ end
293
+
294
+ def cache_payload(path, strict:)
295
+ cache = Adapters::Storage::EpubCache.new(path)
296
+ cache.read_cache(strict: strict)
297
+ end
298
+
299
+ def canonical_source_path(path)
300
+ return path unless cache_pointer?(path)
301
+
302
+ payload = cache_payload(path, strict: false)
303
+ source = payload&.source_path
304
+ source && !source.empty? ? source : path
305
+ rescue StandardError
306
+ path
307
+ end
308
+
309
+ def document_matches_path?(document, target_path)
310
+ return false unless document && target_path
311
+
312
+ doc_path = if document.respond_to?(:canonical_path)
313
+ document.canonical_path
314
+ elsif document.respond_to?(:source_path)
315
+ document.source_path
316
+ elsif document.respond_to?(:path)
317
+ document.path
318
+ end
319
+ return false unless doc_path
320
+
321
+ File.expand_path(doc_path) == File.expand_path(target_path)
322
+ rescue StandardError
323
+ false
324
+ end
325
+
326
+ def prepare_reader_launch(path, presenter)
327
+ height, width = terminal_service.size
328
+ warm_launch_dependencies
329
+
330
+ progress_reporter = progress_reporter_for(presenter)
331
+ document = load_document_for(path, progress_reporter: progress_reporter)
332
+ if document_cached?(document)
333
+ register_document(document)
334
+ update_total_chapters(document)
335
+ preload_cached_pagination(document, width, height)
336
+ return path
337
+ end
338
+
339
+ register_document(document)
340
+ update_total_chapters(document)
341
+ build_pagination(document, width, height, presenter)
342
+ nil
343
+ rescue StandardError => e
344
+ handle_reader_error(path, e)
345
+ nil
346
+ end
347
+
348
+ def warm_launch_dependencies
349
+ dependencies.resolve(:layout_service)
350
+ dependencies.resolve(:wrapping_service) if dependencies.registered?(:wrapping_service)
351
+ page_calculator
352
+ ensure_background_worker
353
+ end
354
+
355
+ def load_document_for(path, progress_reporter: nil)
356
+ factory = dependencies.resolve(:document_service_factory)
357
+ factory.call(path, progress_reporter: progress_reporter).load_document
358
+ end
359
+
360
+ def ensure_reader_document_for(path)
361
+ target_path = canonical_reader_path(path)
362
+ existing = resolve_optional(:document)
363
+ return true if document_matches_path?(existing, target_path)
364
+
365
+ document = load_document_for(path)
366
+ register_document(document)
367
+ update_total_chapters(document)
368
+ true
369
+ rescue StandardError => e
370
+ handle_reader_error(path, e)
371
+ false
372
+ end
373
+
374
+ def document_cached?(document)
375
+ document.respond_to?(:cached?) && document.cached?
376
+ end
377
+
378
+ def register_document(document)
379
+ dependencies.register(:document, document)
380
+ end
381
+
382
+ def update_total_chapters(document)
383
+ total = document&.chapter_count || 0
384
+ state.dispatch(Shoko::Application::Actions::UpdatePaginationStateAction.new(total_chapters: total))
385
+ end
386
+
387
+ def ensure_background_worker
388
+ return if resolve_optional(:background_worker)
389
+
390
+ worker = build_background_worker(name: 'document-preload')
391
+ dependencies.register(:background_worker, worker) if worker
392
+ rescue StandardError
393
+ nil
394
+ end
395
+
396
+ def build_pagination(document, width, height, presenter)
397
+ calculator = page_calculator
398
+ return unless calculator
399
+ return unless width && height
400
+
401
+ session = @pagination_orchestrator.session(
402
+ doc: document,
403
+ state: state,
404
+ page_calculator: calculator,
405
+ dimensions: [width, height]
406
+ )
407
+ return unless session
408
+
409
+ if presenter.respond_to?(:update_message)
410
+ presenter.update_message('Calculating pages...')
411
+ menu.draw_screen
412
+ end
413
+
414
+ session.build_full_map! do |done, total|
415
+ presenter.update(done: done, total: total)
416
+ menu.draw_screen
417
+ end
418
+ presenter.update(done: 1, total: 1)
419
+ end
420
+
421
+ def progress_reporter_for(presenter)
422
+ return nil unless presenter.respond_to?(:update_status)
423
+
424
+ last_update = nil
425
+ lambda do |message: nil, progress: nil|
426
+ changed = presenter.update_status(message: message, progress: progress)
427
+ return unless changed
428
+
429
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
430
+ if last_update.nil? || (now - last_update) >= 0.05
431
+ menu.draw_screen
432
+ last_update = now
433
+ end
434
+ end
435
+ end
436
+
437
+ def skip_progress_overlay?
438
+ primary = ENV.fetch('SHOKO_SKIP_PROGRESS_OVERLAY', '').to_s.strip
439
+ primary == '1'
440
+ end
441
+
442
+ def annotation_actions
443
+ @annotation_actions ||= AnnotationActions.new(self)
444
+ end
445
+
446
+ def page_calculator
447
+ @page_calculator ||= dependencies.resolve(:page_calculator)
448
+ rescue StandardError
449
+ nil
450
+ end
451
+
452
+ def preload_cached_pagination(document, width, height)
453
+ preloader = resolve_optional(:pagination_cache_preloader)
454
+ return unless preloader
455
+
456
+ preloader.preload(document, width:, height:)
457
+ rescue StandardError => e
458
+ begin
459
+ logger&.debug('StateController: cached pagination preload failed',
460
+ error: e.message, path: @path)
461
+ rescue StandardError
462
+ nil
463
+ end
464
+ nil
465
+ end
466
+
467
+ def launch_without_overlay(path)
468
+ warm_launch_dependencies
469
+ target_path = prepare_reader_launch(path, null_presenter)
470
+ run_reader(target_path || path)
471
+ rescue StandardError => e
472
+ handle_reader_error(path, e)
473
+ end
474
+
475
+ def launch_with_overlay(path)
476
+ index = selectors.browse_selected(state) || 0
477
+ mode = selectors.mode(state)
478
+ presenter = progress_presenter
479
+ presenter.show(path: path, index: index, mode: mode)
480
+
481
+ target_path = nil
482
+ begin
483
+ target_path = prepare_reader_launch(path, presenter)
484
+ ensure
485
+ presenter.clear
486
+ end
487
+
488
+ run_reader(target_path || path)
489
+ end
490
+
491
+ def null_presenter
492
+ @null_presenter ||= NullProgressPresenter.new
493
+ end
494
+ end
495
+ end
496
+ end
497
+ end
498
+
499
+ module Shoko
500
+ module Application::Controllers
501
+ module Menu
502
+ # Collection of annotation-related behaviours factored out of StateController.
503
+ class AnnotationActions
504
+ def initialize(controller)
505
+ @controller = controller
506
+ end
507
+
508
+ def open_selected_annotation
509
+ annotation, book_path = selected_annotation_and_path
510
+ return unless annotation && book_path
511
+
512
+ normalized = normalize_annotation(annotation)
513
+ state.dispatch(Shoko::Application::Actions::UpdateReaderMetaAction.new(book_path: book_path))
514
+ pending_payload = {
515
+ chapter_index: normalized[:chapter_index],
516
+ selection_range: normalized[:range],
517
+ annotation: annotation,
518
+ edit: false,
519
+ }
520
+ state.dispatch(Shoko::Application::Actions::UpdateSelectionsAction.new(pending_jump: pending_payload))
521
+
522
+ controller.run_reader(book_path)
523
+ end
524
+
525
+ def open_selected_annotation_for_edit
526
+ annotation, book_path = selected_annotation_and_path
527
+ return unless annotation && book_path
528
+
529
+ note_text = annotation[:note] || annotation['note'] || ''
530
+ state.dispatch(action(:update_menu,
531
+ selected_annotation: annotation,
532
+ selected_annotation_book: book_path,
533
+ annotation_edit_text: note_text,
534
+ annotation_edit_cursor: note_text.length))
535
+ menu.switch_to_mode(:annotation_editor)
536
+ end
537
+
538
+ def delete_selected_annotation
539
+ annotation, book_path = selected_annotation_and_path
540
+ return unless annotation && book_path
541
+
542
+ ann_id = annotation[:id] || annotation['id']
543
+ return unless ann_id
544
+
545
+ service = dependencies.resolve(:annotation_service)
546
+ begin
547
+ service.delete(book_path, ann_id)
548
+ state.dispatch(action(:update_menu, annotations_all: service.list_all))
549
+ rescue StandardError => e
550
+ logger&.error('Failed to delete annotation', error: e.message, path: book_path)
551
+ end
552
+
553
+ menu.annotations_screen.refresh_data
554
+ end
555
+
556
+ def save_current_annotation_edit
557
+ context = current_annotation_edit_context
558
+ return unless context
559
+
560
+ with_annotation_service do |service|
561
+ service.update(context[:path], context[:id], context[:text])
562
+ state.dispatch(action(:update_menu, annotations_all: service.list_all))
563
+ end
564
+
565
+ menu.switch_to_mode(:annotations)
566
+ menu.annotations_screen.refresh_data
567
+ end
568
+
569
+ private
570
+
571
+ attr_reader :controller
572
+
573
+ def menu
574
+ controller.send(:menu)
575
+ end
576
+
577
+ def state
578
+ controller.send(:state)
579
+ end
580
+
581
+ def dependencies
582
+ controller.send(:dependencies)
583
+ end
584
+
585
+ def action(type, payload = nil)
586
+ controller.send(:action, type, payload)
587
+ end
588
+
589
+ def logger
590
+ dependencies.resolve(:logger)
591
+ rescue StandardError
592
+ nil
593
+ end
594
+
595
+ def selected_annotation_and_path
596
+ screen = menu.annotations_screen
597
+ [screen.current_annotation, screen.current_book_path]
598
+ end
599
+
600
+ def normalize_annotation(annotation)
601
+ return {} unless annotation.is_a?(Hash)
602
+
603
+ annotation.transform_keys { |key| key.is_a?(String) ? key.to_sym : key }
604
+ end
605
+
606
+ def current_annotation_edit_context
607
+ annotation = state.get(%i[menu selected_annotation]) || {}
608
+ path = state.get(%i[menu selected_annotation_book])
609
+ text = state.get(%i[menu annotation_edit_text]) || ''
610
+ return unless path && annotation
611
+
612
+ ann_id = annotation[:id] || annotation['id']
613
+ return unless ann_id
614
+
615
+ { path: path, id: ann_id, text: text }
616
+ end
617
+
618
+ def with_annotation_service
619
+ service = dependencies.resolve(:annotation_service)
620
+ yield(service)
621
+ rescue StandardError => e
622
+ logger&.error('Annotation service failure', error: e.message)
623
+ end
624
+ end
625
+
626
+ # No-op progress presenter used when the overlay is skipped.
627
+ class NullProgressPresenter
628
+ def show(*) end
629
+
630
+ def update(*) end
631
+
632
+ def update_status(*) end
633
+
634
+ def update_message(*) end
635
+
636
+ def set_progress(*) end
637
+
638
+ def clear(*) end
639
+ end
640
+ end
641
+ end
642
+ end