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,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'navigation_source_locator'
4
+ require_relative 'navigation_selector'
5
+ require_relative 'navigation_traversal'
6
+ require_relative 'navigation_result'
7
+
8
+ module Shoko
9
+ module Adapters::BookSources::Epub::Parsers
10
+ # Coordinates extraction of navigation entries from nav/NCX sources.
11
+ class OPFNavigationExtractor
12
+ def initialize(opf:, entry_reader:)
13
+ @source_locator = OPFNavigationSourceLocator.new(opf: opf, entry_reader: entry_reader)
14
+ @traversal = OPFNavigationTraversal.new(entry_reader: entry_reader)
15
+ @selector = OPFNavigationSelector.new(opf: opf)
16
+ @result_class = OPFNavigationResult
17
+ end
18
+
19
+ def extract(manifest)
20
+ nav_bundle = extract_from_nav
21
+ ncx_bundle = extract_from_ncx(manifest)
22
+ @selector.choose(nav_bundle, ncx_bundle, manifest)
23
+ end
24
+
25
+ private
26
+
27
+ def extract_from_nav
28
+ nav_path = @source_locator.nav_path
29
+ return empty_result unless nav_path
30
+
31
+ @traversal.from_nav_path(nav_path)
32
+ end
33
+
34
+ def extract_from_ncx(manifest)
35
+ ncx_path = @source_locator.ncx_path(manifest)
36
+ return empty_result unless ncx_path
37
+
38
+ @traversal.from_ncx_path(ncx_path)
39
+ end
40
+
41
+ def empty_result
42
+ @result_class.new(toc_entries: [], titles: {})
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+
5
+ require_relative '../html_processor'
6
+ require_relative 'navigation_document_index'
7
+
8
+ module Shoko
9
+ module Adapters::BookSources::Epub::Parsers
10
+ # Resolves placeholder navigation labels using heading fallbacks.
11
+ class OPFNavigationLabelResolver
12
+ PLACEHOLDER_PATTERN = /\A[cC][0-9A-Za-z]{2,}\z/
13
+
14
+ attr_reader :source_path
15
+
16
+ def initialize(entry_reader:, source_path:)
17
+ @entry_reader = entry_reader
18
+ @source_path = source_path
19
+ @text_cleaner = HTMLProcessor
20
+ @document_index = OPFNavigationDocumentIndex.new(entry_reader: entry_reader, cleaner: self)
21
+ end
22
+
23
+ def clean_label(text)
24
+ @text_cleaner.clean_html(text.to_s).strip
25
+ end
26
+
27
+ def resolve(href:, title:)
28
+ stripped = title.to_s.strip
29
+ return stripped unless href && (stripped.empty? || stripped.match?(PLACEHOLDER_PATTERN))
30
+
31
+ clean_label(fallback_label_for(href, stripped))
32
+ rescue StandardError
33
+ stripped
34
+ end
35
+
36
+ def document_and_anchor(href:)
37
+ decoded = CGI.unescape(href.to_s)
38
+ return [nil, nil] if decoded.empty?
39
+
40
+ base, anchor = decoded.split('#', 2)
41
+ return [nil, anchor] if base.to_s.empty? || @source_path.to_s.empty?
42
+
43
+ [expanded_document_path(base), anchor]
44
+ end
45
+
46
+ def target_for(href:)
47
+ document_path, = document_and_anchor(href: href)
48
+ return [nil, nil] unless document_path
49
+
50
+ [document_path, @entry_reader.opf_relative_path(document_path)]
51
+ end
52
+
53
+ private
54
+
55
+ def fallback_label_for(href, stripped)
56
+ candidate = heading_label_for(href)
57
+ candidate.to_s.strip.empty? ? stripped : candidate
58
+ end
59
+
60
+ def expanded_document_path(base)
61
+ base_dir = File.dirname(@source_path)
62
+ @entry_reader.expand_path(base_dir, base)
63
+ end
64
+
65
+ def heading_label_for(href)
66
+ document_path, anchor = document_and_anchor(href: href)
67
+ return nil unless document_path
68
+
69
+ return anchor_label(document_path, anchor) if anchor
70
+
71
+ @document_index.next_heading(document_path)
72
+ end
73
+
74
+ def anchor_label(document_path, anchor)
75
+ candidate = @document_index.anchor_label(document_path, anchor)
76
+ @document_index.remove_heading(document_path, candidate) if candidate
77
+ return candidate unless candidate.to_s.strip.empty?
78
+
79
+ @document_index.next_heading(document_path)
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rexml/document'
4
+
5
+ module Shoko
6
+ module Adapters::BookSources::Epub::Parsers
7
+ # Extracts href and cleaned label text from a nav list item.
8
+ class OPFNavigationListItem
9
+ def initialize(list_item, cleaner:)
10
+ @list_item = list_item
11
+ @cleaner = cleaner
12
+ @empty_text = ''
13
+ end
14
+
15
+ def href
16
+ anchor&.attributes&.[]('href')
17
+ end
18
+
19
+ def title
20
+ return clean_text(anchor.texts.join) if anchor
21
+
22
+ clean_text(list_item_text)
23
+ end
24
+
25
+ private
26
+
27
+ def clean_text(text)
28
+ @cleaner.clean_label(text)
29
+ end
30
+
31
+ def anchor
32
+ @anchor ||= begin
33
+ elements = @list_item.elements
34
+ elements['./*[local-name()="a"]'] || elements['.//*[local-name()="a"]']
35
+ end
36
+ end
37
+
38
+ def list_item_text
39
+ stop_element = list_container_element
40
+ @list_item.children.take_while { |child| child != stop_element }.each_with_object(+'') do |child, buffer|
41
+ buffer << node_text(child)
42
+ end
43
+ end
44
+
45
+ def list_container_element
46
+ @list_item.elements['./*[local-name()="ol" or local-name()="ul"]']
47
+ end
48
+
49
+ def node_text(child)
50
+ text = child.to_s
51
+ text.empty? ? @empty_text : text
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shoko
4
+ module Adapters::BookSources::Epub::Parsers
5
+ # Value object for extracted TOC entries and title map.
6
+ OPFNavigationResult = Struct.new(:toc_entries, :titles, keyword_init: true)
7
+ end
8
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'navigation_result'
4
+
5
+ module Shoko
6
+ module Adapters::BookSources::Epub::Parsers
7
+ # Chooses the best navigation source based on spine coverage.
8
+ class OPFNavigationSelector
9
+ # Value object for paired nav/NCX entries.
10
+ EntryPair = Struct.new(:nav_entries, :ncx_entries, keyword_init: true)
11
+ private_constant :EntryPair
12
+
13
+ # Value object for availability of entries in each source.
14
+ EntryAvailability = Struct.new(:nav_entries, :ncx_entries, keyword_init: true) do
15
+ def selection
16
+ nav_empty = nav_entries.empty?
17
+ ncx_empty = ncx_entries.empty?
18
+ if nav_empty && ncx_empty
19
+ []
20
+ elsif ncx_empty
21
+ nav_entries
22
+ elsif nav_empty
23
+ ncx_entries
24
+ end
25
+ end
26
+ end
27
+ private_constant :EntryAvailability
28
+
29
+ # Selects entries with fallback to spine coverage comparison.
30
+ class EntrySelection
31
+ def initialize(pair, spine_index)
32
+ @pair = pair
33
+ @availability = EntryAvailability.new(
34
+ nav_entries: pair.nav_entries,
35
+ ncx_entries: pair.ncx_entries
36
+ )
37
+ @coverage = SpineCoverage.new(spine_index)
38
+ end
39
+
40
+ def entries
41
+ selection = @availability.selection
42
+ return selection if selection
43
+
44
+ @coverage.prefer(@pair.nav_entries, @pair.ncx_entries)
45
+ end
46
+ end
47
+
48
+ # Computes spine coverage to decide between nav and NCX entries.
49
+ class SpineCoverage
50
+ def initialize(spine_index)
51
+ @spine_index = spine_index
52
+ end
53
+
54
+ def prefer(nav_entries, ncx_entries)
55
+ nav_score = score(nav_entries)
56
+ ncx_score = score(ncx_entries)
57
+ nav_score >= ncx_score ? nav_entries : ncx_entries
58
+ end
59
+
60
+ private
61
+
62
+ def score(entries)
63
+ entries.count do |entry|
64
+ href = entry[:opf_href]
65
+ href && @spine_index[href]
66
+ end
67
+ end
68
+ end
69
+
70
+ def initialize(opf:)
71
+ @opf = opf
72
+ @result_class = OPFNavigationResult
73
+ end
74
+
75
+ def choose(nav_bundle, ncx_bundle, manifest)
76
+ pair = EntryPair.new(nav_entries: nav_bundle.toc_entries, ncx_entries: ncx_bundle.toc_entries)
77
+ entries = EntrySelection.new(pair, build_spine_index(manifest)).entries
78
+ return empty_result if entries.empty?
79
+
80
+ titles = ncx_bundle.titles.merge(nav_bundle.titles)
81
+ @result_class.new(toc_entries: entries, titles: titles)
82
+ end
83
+
84
+ private
85
+
86
+ def empty_result
87
+ @result_class.new(toc_entries: [], titles: {})
88
+ end
89
+
90
+ def build_spine_index(manifest)
91
+ hrefs = {}
92
+ @opf.elements.each('//spine/itemref') do |itemref|
93
+ href = manifest[itemref.attributes['idref']]
94
+ hrefs[href] = true if href
95
+ end
96
+ hrefs
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+
5
+ module Shoko
6
+ module Adapters::BookSources::Epub::Parsers
7
+ # Locates nav and NCX sources referenced by an OPF manifest.
8
+ class OPFNavigationSourceLocator
9
+ NAV_PROPERTY = 'nav'
10
+ NCX_EXTENSION = '.ncx'
11
+ NCX_MEDIA_HINT = 'ncx'
12
+
13
+ def initialize(opf:, entry_reader:)
14
+ @opf = opf
15
+ @entry_reader = entry_reader
16
+ @decoder = CGI
17
+ end
18
+
19
+ def nav_path
20
+ @opf.elements.each('//manifest/item') do |item|
21
+ nav_path = nav_item_path(item)
22
+ return nav_path if nav_path
23
+ end
24
+
25
+ nil
26
+ end
27
+
28
+ def ncx_path(manifest)
29
+ spine_path = ncx_path_from_spine(manifest)
30
+ return spine_path if spine_path
31
+
32
+ candidate_ncx_paths.each do |candidate|
33
+ return candidate if candidate && @entry_reader.entry_exists?(candidate)
34
+ end
35
+
36
+ nil
37
+ end
38
+
39
+ private
40
+
41
+ def nav_item_path(item)
42
+ properties_attr, href_attr = item.attributes.values_at('properties', 'href')
43
+ properties = properties_attr&.value.to_s.split
44
+ return nil unless properties.include?(NAV_PROPERTY)
45
+
46
+ entry_path_for(href_attr&.value)
47
+ end
48
+
49
+ def ncx_path_from_spine(manifest)
50
+ ncx_id = @opf.elements['//spine']&.attributes&.[]('toc')
51
+ return nil unless ncx_id
52
+
53
+ ncx_href = manifest[ncx_id]
54
+ entry_path_for(ncx_href)
55
+ end
56
+
57
+ def candidate_ncx_paths
58
+ @opf.elements.each_with_object([]) do |item, paths|
59
+ candidate_path = ncx_candidate_path(item)
60
+ paths << candidate_path if candidate_path
61
+ end
62
+ end
63
+
64
+ def ncx_candidate_path(item)
65
+ href_attr, media_attr = item.attributes.values_at('href', 'media-type')
66
+ decoded = decoded_href(href_attr&.value)
67
+ media_type = media_attr&.value.to_s.downcase
68
+ return @entry_reader.join_path(decoded) if decoded &&
69
+ (media_type.include?(NCX_MEDIA_HINT) ||
70
+ decoded.downcase.end_with?(NCX_EXTENSION))
71
+
72
+ nil
73
+ end
74
+
75
+ def decoded_href(href)
76
+ href_string = href.to_s
77
+ return nil if href_string.empty?
78
+
79
+ @decoder.unescape(href_string)
80
+ end
81
+
82
+ def entry_path_for(href)
83
+ decoded = decoded_href(href)
84
+ return nil unless decoded
85
+
86
+ path = @entry_reader.join_path(decoded)
87
+ return nil unless path && @entry_reader.entry_exists?(path)
88
+
89
+ path
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rexml/document'
4
+
5
+ require_relative 'navigation_context'
6
+ require_relative 'navigation_walker'
7
+ require_relative 'navigation_result'
8
+
9
+ module Shoko
10
+ module Adapters::BookSources::Epub::Parsers
11
+ # Parses nav/NCX documents and builds navigation entries with fallback labels.
12
+ class OPFNavigationTraversal
13
+ NAV_TYPE_ATTRIBUTES = %w[epub:type type role].freeze
14
+ NAV_TOC_TYPES = %w[toc doc-toc].freeze
15
+
16
+ def initialize(entry_reader:)
17
+ @entry_reader = entry_reader
18
+ @result_class = OPFNavigationResult
19
+ @type_attributes = NAV_TYPE_ATTRIBUTES
20
+ @toc_types = NAV_TOC_TYPES
21
+ end
22
+
23
+ def from_nav_path(path)
24
+ list = nav_list_from_path(path)
25
+ return empty_result unless list
26
+
27
+ context = build_context(path)
28
+ OPFNavigationWalker.new(context).walk_nav_list(list)
29
+ context.to_result(@result_class)
30
+ end
31
+
32
+ def from_ncx_path(path)
33
+ nav_map = nav_map_from_path(path)
34
+ return empty_result unless nav_map
35
+
36
+ context = build_context(path)
37
+ OPFNavigationWalker.new(context).walk_nav_points(nav_map)
38
+ context.to_result(@result_class)
39
+ end
40
+
41
+ private
42
+
43
+ def empty_result
44
+ @result_class.new(toc_entries: [], titles: {})
45
+ end
46
+
47
+ def build_context(source_path)
48
+ OPFNavigationContext.root(source_path: source_path, entry_reader: @entry_reader)
49
+ end
50
+
51
+ def find_nav_toc_node(doc)
52
+ doc.elements.each('//*[local-name()="nav"]') do |nav|
53
+ return nav if nav_toc_type?(nav_type_value(nav))
54
+ end
55
+
56
+ nil
57
+ end
58
+
59
+ def nav_toc_type?(value)
60
+ return false unless value
61
+
62
+ @toc_types.include?(value.to_s.strip.downcase)
63
+ end
64
+
65
+ def nav_type_value(nav)
66
+ attributes = nav.attributes
67
+ attribute = attributes.enum_for(:each_attribute).find do |attr|
68
+ type_attribute?(attr.expanded_name) || type_attribute?(attr.name)
69
+ end
70
+ attribute&.value
71
+ end
72
+
73
+ def type_attribute?(name)
74
+ @type_attributes.include?(name)
75
+ end
76
+
77
+ def nav_list_from_path(path)
78
+ content = @entry_reader.safe_read_entry(path)
79
+ return nil unless content
80
+
81
+ doc = REXML::Document.new(content)
82
+ nav_list_from_document(doc)
83
+ rescue REXML::ParseException
84
+ nil
85
+ end
86
+
87
+ def nav_list_from_document(doc)
88
+ nav = find_nav_toc_node(doc)
89
+ return nil unless nav
90
+
91
+ nav.elements['(.//*[local-name()="ol"] | .//*[local-name()="ul"])[1]']
92
+ end
93
+
94
+ def nav_map_from_path(path)
95
+ ncx_content = @entry_reader.read_entry(path)
96
+ ncx = REXML::Document.new(ncx_content)
97
+ ncx.elements['//navMap']
98
+ rescue StandardError
99
+ nil
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rexml/document'
4
+
5
+ module Shoko
6
+ module Adapters::BookSources::Epub::Parsers
7
+ # Walks nav/NCX trees to populate navigation context entries.
8
+ class OPFNavigationWalker
9
+ def initialize(context)
10
+ @context = context
11
+ @list_item_tag = 'li'
12
+ @list_container_tags = %w[ol ul]
13
+ end
14
+
15
+ def walk_nav_points(node)
16
+ node.each_element('navPoint') do |nav_point|
17
+ title, href = @context.entry_for_nav_point(nav_point)
18
+ @context.add_entry(title: title, href: href)
19
+ self.class.new(@context.next_level).walk_nav_points(nav_point)
20
+ end
21
+ end
22
+
23
+ def walk_nav_list(list)
24
+ list.each_element do |child|
25
+ next unless child.is_a?(REXML::Element) && child.name.casecmp(@list_item_tag).zero?
26
+
27
+ process_list_item(child)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def process_list_item(child)
34
+ title, href = @context.entry_for_list_item(child)
35
+ @context.add_entry(title: title, href: href)
36
+ walk_nested_list(child)
37
+ end
38
+
39
+ def walk_nested_list(child)
40
+ nested = nested_list(child)
41
+ return unless nested
42
+
43
+ self.class.new(@context.next_level).walk_nav_list(nested)
44
+ end
45
+
46
+ def nested_list(child)
47
+ elements = child.elements
48
+ @list_container_tags.each do |tag|
49
+ list = elements["./*[local-name()=\"#{tag}\"]"]
50
+ return list if list
51
+ end
52
+ nil
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+ require 'rexml/document'
5
+
6
+ require_relative '../../../monitoring/perf_tracer.rb'
7
+ require_relative 'opf/entry_reader'
8
+ require_relative 'opf/metadata_extractor'
9
+ require_relative 'opf/navigation_extractor'
10
+
11
+ module Shoko
12
+ module Adapters::BookSources::Epub::Parsers
13
+ # Processes OPF files.
14
+ class OPFProcessor
15
+ # Value object describing a resolved spine item.
16
+ SpineItem = Struct.new(:file_path, :number, :title, :href, keyword_init: true)
17
+ private_constant :SpineItem
18
+
19
+ attr_reader :toc_entries
20
+
21
+ def initialize(opf_path, zip: nil)
22
+ @opf_path = opf_path
23
+ @entry_reader = OPFEntryReader.new(opf_path, zip: zip)
24
+ content = read_opf_content
25
+ @opf = Shoko::Adapters::Monitoring::PerfTracer.measure('opf.parse') do
26
+ REXML::Document.new(content)
27
+ end
28
+ @toc_entries = []
29
+ @navigation_extractor = OPFNavigationExtractor.new(opf: @opf, entry_reader: @entry_reader)
30
+ end
31
+
32
+ def extract_metadata
33
+ OPFMetadataExtractor.new(@opf).extract
34
+ end
35
+
36
+ def build_manifest_map
37
+ manifest = {}
38
+ @opf.elements.each('//manifest/item') do |item|
39
+ id, href = manifest_item_id_href(item)
40
+ next unless id && href
41
+
42
+ normalized = @entry_reader.normalize_opf_relative_href(CGI.unescape(href))
43
+ manifest[id] = normalized if normalized
44
+ end
45
+ manifest
46
+ end
47
+
48
+ def extract_chapter_titles(manifest)
49
+ result = @navigation_extractor.extract(manifest)
50
+ @toc_entries = result.toc_entries
51
+ result.titles
52
+ end
53
+
54
+ def process_spine(manifest, chapter_titles)
55
+ chapter_num = 1
56
+ @opf.elements.each('//spine/itemref') do |itemref|
57
+ item = build_spine_item(itemref, chapter_num, manifest, chapter_titles)
58
+ next unless item
59
+
60
+ yield(item)
61
+ chapter_num += 1
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def read_opf_content
68
+ raw = if @entry_reader.zip?
69
+ Shoko::Adapters::Monitoring::PerfTracer.measure('zip.read') do
70
+ @entry_reader.read_raw(@opf_path)
71
+ end
72
+ else
73
+ @entry_reader.read_raw(@opf_path)
74
+ end
75
+ @entry_reader.normalize_xml_text(raw)
76
+ end
77
+
78
+ def manifest_item_id_href(item)
79
+ attrs = item.attributes
80
+ [attrs['id'], attrs['href']]
81
+ end
82
+
83
+ def build_spine_item(itemref, number, manifest, chapter_titles)
84
+ idref = itemref.attributes['idref']
85
+ return nil unless idref
86
+
87
+ href = manifest[idref]
88
+ return nil unless href
89
+
90
+ file_path = @entry_reader.join_path(href)
91
+ return nil unless file_path && @entry_reader.entry_exists?(file_path)
92
+
93
+ SpineItem.new(
94
+ file_path: file_path,
95
+ number: number,
96
+ title: chapter_titles[href],
97
+ href: href
98
+ )
99
+ end
100
+ end
101
+ end
102
+ end