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,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'io/console'
4
+ require_relative 'constants/terminal_defaults'
5
+ require_relative 'input/decoder'
6
+
7
+ module Shoko
8
+ module Adapters::Output::Terminal
9
+ # TerminalInput encapsulates input reading, console modes, and size queries.
10
+ class TerminalInput
11
+ SIZE_CACHE_INTERVAL = 0.5
12
+ READ_CHUNK_BYTES = 4096
13
+
14
+ def initialize(input: $stdin, output: $stdout, esc_timeout: Decoder::DEFAULT_ESC_TIMEOUT,
15
+ sequence_timeout: Decoder::DEFAULT_SEQUENCE_TIMEOUT)
16
+ @console = nil
17
+ @size_cache = { width: nil, height: nil, checked_at: nil }
18
+ @input = input
19
+ @output = output
20
+ @decoder = Decoder.new(esc_timeout: esc_timeout, sequence_timeout: sequence_timeout)
21
+ end
22
+
23
+ def size
24
+ update_size_cache if cache_expired?
25
+ [@size_cache[:height], @size_cache[:width]]
26
+ end
27
+
28
+ def setup_console
29
+ $stdout.sync = true
30
+ @console = resolve_console
31
+ @console.raw! if @console.respond_to?(:raw!)
32
+ end
33
+
34
+ def cleanup_console
35
+ @console.cooked! if @console.respond_to?(:cooked!)
36
+ @console = nil
37
+ end
38
+
39
+ def with_raw_console(&)
40
+ console = resolve_console
41
+ console.raw(&)
42
+ end
43
+
44
+ def read_key
45
+ with_raw_console do
46
+ pump_input
47
+ @decoder.next_token(now: monotonic_now)
48
+ end
49
+ rescue IO::WaitReadable, EOFError
50
+ nil
51
+ end
52
+
53
+ def read_key_blocking(timeout: nil)
54
+ deadline = timeout ? monotonic_now + timeout.to_f : nil
55
+ loop do
56
+ key = read_key
57
+ return key if key
58
+
59
+ now = monotonic_now
60
+ remaining = deadline ? (deadline - now) : nil
61
+ return nil if remaining && remaining <= 0
62
+
63
+ pending = @decoder.pending_timeout(now: now)
64
+ wait = if pending && remaining
65
+ [pending, remaining].min
66
+ else
67
+ pending || remaining
68
+ end
69
+
70
+ if wait
71
+ next if wait <= 0
72
+
73
+ @input.wait_readable(wait)
74
+ else
75
+ @input.wait_readable
76
+ end
77
+ end
78
+ end
79
+
80
+ # Mouse support
81
+ def enable_mouse
82
+ @output.print "\e[?1002h\e[?1006h"
83
+ @output.flush
84
+ end
85
+
86
+ def disable_mouse
87
+ @output.print "\e[?1002l\e[?1006l"
88
+ @output.flush
89
+ end
90
+
91
+ def read_input_with_mouse(timeout: nil)
92
+ read_key_blocking(timeout: timeout)
93
+ end
94
+
95
+ def setup_signal_handlers(&cleanup_callback)
96
+ %w[INT TERM].each do |signal|
97
+ trap(signal) do
98
+ cleanup_callback&.call
99
+ exit(0)
100
+ end
101
+ end
102
+ end
103
+
104
+ private
105
+
106
+ def cache_expired?
107
+ now = Time.now
108
+ checked = @size_cache[:checked_at]
109
+ checked.nil? || now - checked > SIZE_CACHE_INTERVAL
110
+ end
111
+
112
+ def update_size_cache
113
+ h, w = fetch_terminal_size
114
+ @size_cache = { width: w, height: h, checked_at: Time.now }
115
+ end
116
+
117
+ def fetch_terminal_size
118
+ IO.console.winsize
119
+ rescue StandardError
120
+ default_dimensions
121
+ end
122
+
123
+ def default_dimensions
124
+ [Adapters::Output::Terminal::TerminalDefaults::DEFAULT_ROWS,
125
+ Adapters::Output::Terminal::TerminalDefaults::DEFAULT_COLUMNS]
126
+ end
127
+
128
+ def resolve_console
129
+ return @console if @console
130
+
131
+ console = IO.console
132
+ if console
133
+ @console = console
134
+ return console
135
+ end
136
+
137
+ if @input.respond_to?(:tty?) && @input.tty? && @input.respond_to?(:raw)
138
+ @console = @input
139
+ return @input
140
+ end
141
+
142
+ raise Shoko::TerminalUnavailableError
143
+ end
144
+
145
+ def pump_input
146
+ loop do
147
+ chunk = @input.read_nonblock(READ_CHUNK_BYTES)
148
+ @decoder.feed(chunk)
149
+ end
150
+ rescue IO::WaitReadable, EOFError
151
+ nil
152
+ end
153
+
154
+ def monotonic_now
155
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
156
+ rescue StandardError
157
+ Time.now.to_f
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'io/console'
4
+
5
+ module Shoko
6
+ module Adapters::Output::Terminal
7
+ # TerminalOutput handles ANSI sequences and direct writes to an IO stream.
8
+ class TerminalOutput
9
+ attr_reader :io
10
+
11
+ def initialize(io = $stdout)
12
+ @io = io
13
+ end
14
+
15
+ # A collection of ANSI escape codes and helpers
16
+ module ANSI
17
+ RESET = "\e[0m"
18
+ BOLD = "\e[1m"
19
+ DIM = "\e[2m"
20
+ ITALIC = "\e[3m"
21
+ REVERSE = "\e[7m"
22
+
23
+ BLACK = "\e[30m"
24
+ RED = "\e[31m"
25
+ GREEN = "\e[32m"
26
+ YELLOW = "\e[33m"
27
+ BLUE = "\e[34m"
28
+ MAGENTA = "\e[35m"
29
+ CYAN = "\e[36m"
30
+ WHITE = "\e[37m"
31
+ GRAY = "\e[90m"
32
+ LIGHT_GREY = "\e[37;1m"
33
+
34
+ BRIGHT_RED = "\e[91m"
35
+ BRIGHT_GREEN = "\e[92m"
36
+ BRIGHT_YELLOW = "\e[93m"
37
+ BRIGHT_BLUE = "\e[94m"
38
+ BRIGHT_MAGENTA = "\e[95m"
39
+ BRIGHT_CYAN = "\e[96m"
40
+ BRIGHT_WHITE = "\e[97m"
41
+
42
+ BG_DARK = "\e[48;5;236m"
43
+ BG_BLACK = "\e[40m"
44
+ BG_BLUE = "\e[44m"
45
+ BG_CYAN = "\e[46m"
46
+ BG_GREY = "\e[48;5;240m"
47
+ BG_SLATE = "\e[48;5;238m"
48
+ BG_SOFT_GREEN = "\e[48;5;65m"
49
+ BG_BRIGHT_GREEN = "\e[102m"
50
+ BG_BRIGHT_YELLOW = "\e[103m"
51
+ BG_BRIGHT_WHITE = "\e[107m"
52
+
53
+ module Control
54
+ CLEAR = "\e[2J"
55
+ HOME = "\e[H"
56
+ HIDE_CURSOR = "\e[?25l"
57
+ SHOW_CURSOR = "\e[?25h"
58
+ SAVE_SCREEN = "\e[?1049h"
59
+ RESTORE_SCREEN = "\e[?1049l"
60
+ end
61
+
62
+ def self.move(row, col)
63
+ "\e[#{row};#{col}H"
64
+ end
65
+
66
+ def self.clear_line
67
+ "\e[2K"
68
+ end
69
+
70
+ def self.clear_below
71
+ "\e[J"
72
+ end
73
+ end
74
+
75
+ def print(str)
76
+ io.print(str)
77
+ end
78
+
79
+ def flush
80
+ io.flush
81
+ end
82
+
83
+ def clear
84
+ print(ANSI::Control::CLEAR + ANSI::Control::HOME)
85
+ flush
86
+ end
87
+
88
+ def hide_cursor
89
+ print(ANSI::Control::HIDE_CURSOR)
90
+ end
91
+
92
+ def show_cursor
93
+ print(ANSI::Control::SHOW_CURSOR)
94
+ end
95
+
96
+ def save_screen
97
+ print(ANSI::Control::SAVE_SCREEN)
98
+ end
99
+
100
+ def restore_screen
101
+ print(ANSI::Control::RESTORE_SCREEN)
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'io/console'
4
+ require_relative 'constants/terminal_defaults'
5
+ require_relative 'output'
6
+ require_relative 'buffer'
7
+ require_relative 'input'
8
+
9
+ module Shoko
10
+ module Adapters::Output::Terminal
11
+ # Facade that preserves the historical Terminal API
12
+ # while delegating to composable, testable components:
13
+ # - TerminalOutput
14
+ # - TerminalBuffer
15
+ # - TerminalInput
16
+ class Terminal
17
+ # Keep ANSI nested under Terminal for compatibility
18
+ ANSI = TerminalOutput::ANSI
19
+
20
+ # Defines constants for special keyboard keys to abstract away different
21
+ # terminal escape codes.
22
+ module Keys
23
+ UP = ["\e[A", "\eOA", 'k'].freeze
24
+ DOWN = ["\e[B", "\eOB", 'j'].freeze
25
+ ENTER = ["\r", "\n"].freeze
26
+ ESCAPE = ["\e", "\x1B", 'q'].freeze
27
+ end
28
+
29
+ @output = TerminalOutput.new($stdout)
30
+ @buffer_manager = TerminalBuffer.new(@output)
31
+ @input = TerminalInput.new
32
+ @buffer = @buffer_manager.buffer
33
+
34
+ class << self
35
+ # Expose a print wrapper for backward-compatible expectations in tests
36
+ def print(str)
37
+ @output.print(str)
38
+ end
39
+
40
+ def size
41
+ @input.size
42
+ end
43
+
44
+ def clear
45
+ print [ANSI::Control::CLEAR, ANSI::Control::HOME].join
46
+ clear_buffer_cache
47
+ $stdout.flush
48
+ end
49
+
50
+ def move(row, col)
51
+ # Historically this only queued the move; keep parity
52
+ @buffer << ANSI.move(row, col)
53
+ end
54
+
55
+ def write(row, col, text)
56
+ @buffer_manager.write(row, col, text)
57
+ end
58
+
59
+ def write_differential(row, col, text)
60
+ @buffer_manager.write_differential(row, col, text)
61
+ end
62
+
63
+ def clear_buffer_cache
64
+ @buffer_manager.clear_buffer_cache
65
+ end
66
+
67
+ def batch_write(&)
68
+ @buffer_manager.batch_write(&)
69
+ end
70
+
71
+ def start_frame(width: nil, height: nil)
72
+ if width && height
73
+ w = width.to_i
74
+ h = height.to_i
75
+ else
76
+ h, w = size
77
+ end
78
+
79
+ @buffer_manager.start_frame(width: w, height: h)
80
+ @buffer = @buffer_manager.buffer
81
+ end
82
+
83
+ def end_frame
84
+ @buffer_manager.end_frame
85
+ end
86
+
87
+ # Queue raw control sequences (e.g., Kitty graphics) for the current frame.
88
+ # These are emitted before any row diffs.
89
+ def raw(text)
90
+ @buffer_manager.raw(text)
91
+ end
92
+
93
+ def setup
94
+ @input.setup_console
95
+ print ANSI::Control::SAVE_SCREEN
96
+ print ANSI::Control::HIDE_CURSOR
97
+ print ANSI::BG_DARK
98
+ clear
99
+ @input.setup_signal_handlers { cleanup }
100
+ end
101
+
102
+ def cleanup
103
+ print([
104
+ ANSI::Control::CLEAR,
105
+ ANSI::Control::HOME,
106
+ ANSI::Control::SHOW_CURSOR,
107
+ ANSI::Control::RESTORE_SCREEN,
108
+ ANSI::RESET,
109
+ ].join)
110
+ @output.flush
111
+ @input.cleanup_console
112
+ end
113
+
114
+ def read_key
115
+ @input.read_key
116
+ end
117
+
118
+ def read_key_blocking(timeout: nil)
119
+ @input.read_key_blocking(timeout: timeout)
120
+ end
121
+
122
+ def enable_mouse
123
+ @input.enable_mouse
124
+ end
125
+
126
+ def disable_mouse
127
+ @input.disable_mouse
128
+ end
129
+
130
+ def read_input_with_mouse(timeout: nil)
131
+ @input.read_input_with_mouse(timeout: timeout)
132
+ end
133
+
134
+ def setup_signal_handlers(&cleanup_callback)
135
+ @input.setup_signal_handlers(&cleanup_callback)
136
+ end
137
+
138
+ def buffer
139
+ @buffer
140
+ end
141
+
142
+ def buffer_manager
143
+ @buffer_manager
144
+ end
145
+
146
+ def output
147
+ @output
148
+ end
149
+
150
+ def input
151
+ @input
152
+ end
153
+
154
+ def reset!
155
+ @output = TerminalOutput.new($stdout)
156
+ @buffer_manager = TerminalBuffer.new(@output)
157
+ @input = TerminalInput.new
158
+ @buffer = @buffer_manager.buffer
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
164
+
165
+ module Shoko
166
+ Terminal = Adapters::Output::Terminal::Terminal
167
+ end
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shoko
4
+ module Adapters
5
+ module Output
6
+ module Terminal
7
+ # Sanitizes untrusted text before it is rendered in a terminal.
8
+ #
9
+ # Removes ANSI/VT control sequences (OSC/DCS/CSI/etc.) and drops C0/C1 control
10
+ # characters to prevent terminal escape injection and layout corruption.
11
+ module TerminalSanitizer
12
+ module_function
13
+
14
+ # Pre-sanitizer for XML/XHTML sources before feeding them to an XML parser.
15
+ #
16
+ # Some EPUBs (and malicious inputs) include numeric character references to
17
+ # disallowed control characters (e.g. `&#x1b;` / `&#27;`), which can cause
18
+ # REXML to raise parse errors. To keep parsing resilient, this method:
19
+ # - Decodes *numeric* references for C0/C1/DEL into real codepoints so the
20
+ # control-sequence scanner can remove the entire escape sequence.
21
+ # - Drops numeric references to codepoints that are invalid in XML 1.0.
22
+ #
23
+ # It intentionally does not decode non-control references (e.g. `&#60;`)
24
+ # because doing so can change document structure before parsing.
25
+ def sanitize_xml_source(text, preserve_newlines: true, preserve_tabs: true)
26
+ return '' if text.nil?
27
+
28
+ str = coerce_utf8(String(text))
29
+ return '' if str.empty?
30
+
31
+ pre = decode_control_numeric_references(str)
32
+ sanitize(pre, preserve_newlines: preserve_newlines, preserve_tabs: preserve_tabs)
33
+ rescue StandardError
34
+ sanitize(text.to_s, preserve_newlines: preserve_newlines, preserve_tabs: preserve_tabs)
35
+ end
36
+
37
+ # @param text [String,nil]
38
+ # @param preserve_newlines [Boolean] keep `\n` (and normalize `\r` to `\n`)
39
+ # @param preserve_tabs [Boolean] keep `\t`
40
+ # @return [String] UTF-8 string safe for terminal rendering
41
+ def sanitize(text, preserve_newlines: false, preserve_tabs: false)
42
+ return '' if text.nil?
43
+
44
+ str = String(text)
45
+ return '' if str.empty?
46
+
47
+ str = coerce_utf8(str)
48
+ cps = str.codepoints
49
+ return '' if cps.empty?
50
+
51
+ out = +''
52
+ out.force_encoding(Encoding::UTF_8)
53
+
54
+ i = 0
55
+ while i < cps.length
56
+ cp = cps[i]
57
+
58
+ case cp
59
+ when 0x1B # ESC
60
+ i = skip_esc_sequence(cps, i + 1)
61
+ next
62
+ when 0x9B # CSI (8-bit)
63
+ i = skip_csi_sequence(cps, i + 1)
64
+ next
65
+ when 0x9D # OSC (8-bit)
66
+ i = skip_osc_sequence(cps, i + 1, c1_variant: true)
67
+ next
68
+ when 0x90, 0x98, 0x9E, 0x9F # DCS, SOS, PM, APC (8-bit)
69
+ i = skip_string_sequence(cps, i + 1, c1_variant: true)
70
+ next
71
+ end
72
+
73
+ if cp == 0x0A # \n
74
+ out << (preserve_newlines ? "\n" : ' ')
75
+ i += 1
76
+ next
77
+ end
78
+
79
+ if cp == 0x0D # \r
80
+ out << (preserve_newlines ? "\n" : ' ')
81
+ i += 1
82
+ next
83
+ end
84
+
85
+ if cp == 0x09 # \t
86
+ out << (preserve_tabs ? "\t" : ' ')
87
+ i += 1
88
+ next
89
+ end
90
+
91
+ if control_codepoint?(cp)
92
+ i += 1
93
+ next
94
+ end
95
+
96
+ out << cp
97
+ i += 1
98
+ end
99
+
100
+ out
101
+ rescue StandardError
102
+ String(text).encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "\uFFFD")
103
+ end
104
+
105
+ # Input filter for single-character text entry (search fields, editors).
106
+ # Prevents inserting C0/C1 control characters and DEL.
107
+ def printable_char?(key)
108
+ return false unless key.is_a?(String)
109
+ return false unless key.length == 1
110
+
111
+ cp = key.ord
112
+ return false if cp < 0x20
113
+ return false if cp == 0x7F
114
+ return false if cp.between?(0x80, 0x9F)
115
+
116
+ true
117
+ rescue StandardError
118
+ false
119
+ end
120
+
121
+ def coerce_utf8(str)
122
+ return str if str.encoding == Encoding::UTF_8 && str.valid_encoding?
123
+
124
+ str.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "\uFFFD")
125
+ rescue StandardError
126
+ str.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "\uFFFD")
127
+ end
128
+ private_class_method :coerce_utf8
129
+
130
+ def control_codepoint?(codepoint)
131
+ return true if codepoint < 0x20
132
+ return true if codepoint == 0x7F
133
+ return true if codepoint.between?(0x80, 0x9F)
134
+
135
+ false
136
+ end
137
+ private_class_method :control_codepoint?
138
+
139
+ def skip_esc_sequence(codepoints, index)
140
+ return codepoints.length if index >= codepoints.length
141
+
142
+ lead = codepoints[index]
143
+ case lead
144
+ when 0x5B # '[' CSI
145
+ skip_csi_sequence(codepoints, index + 1)
146
+ when 0x5D # ']' OSC
147
+ skip_osc_sequence(codepoints, index + 1, c1_variant: false)
148
+ when 0x50, 0x58, 0x5E, 0x5F # 'P' DCS, 'X' SOS, '^' PM, '_' APC
149
+ skip_string_sequence(codepoints, index + 1, c1_variant: false)
150
+ else
151
+ # 2-byte escape sequence (ESC + final byte)
152
+ index + 1
153
+ end
154
+ end
155
+ private_class_method :skip_esc_sequence
156
+
157
+ def skip_csi_sequence(codepoints, index)
158
+ while index < codepoints.length
159
+ cp = codepoints[index]
160
+ index += 1
161
+ # Final byte is 0x40..0x7E
162
+ break if cp.between?(0x40, 0x7E)
163
+ end
164
+ index
165
+ end
166
+ private_class_method :skip_csi_sequence
167
+
168
+ def skip_osc_sequence(codepoints, index, c1_variant:)
169
+ while index < codepoints.length
170
+ cp = codepoints[index]
171
+
172
+ return index + 1 if cp == 0x07 # BEL
173
+
174
+ return index + 2 if !c1_variant && cp == 0x1B && codepoints[index + 1] == 0x5C # ESC \
175
+
176
+ return index + 1 if c1_variant && cp == 0x9C # ST (8-bit)
177
+
178
+ # Allow ESC \ as terminator even for 8-bit variants (robustness).
179
+ return index + 2 if cp == 0x1B && codepoints[index + 1] == 0x5C
180
+
181
+ index += 1
182
+ end
183
+ index
184
+ end
185
+ private_class_method :skip_osc_sequence
186
+
187
+ def skip_string_sequence(codepoints, index, c1_variant:)
188
+ while index < codepoints.length
189
+ cp = codepoints[index]
190
+
191
+ return index + 1 if c1_variant && cp == 0x9C # ST
192
+
193
+ return index + 2 if cp == 0x1B && codepoints[index + 1] == 0x5C # ESC \
194
+
195
+ index += 1
196
+ end
197
+ index
198
+ end
199
+ private_class_method :skip_string_sequence
200
+
201
+ def decode_control_numeric_references(str)
202
+ # Hex numeric references
203
+ out = str.gsub(/&#x([0-9A-Fa-f]+);/) do |match|
204
+ cp = Regexp.last_match(1).to_i(16)
205
+ replacement_for_xml_numeric_ref(cp, match)
206
+ end
207
+
208
+ # Decimal numeric references
209
+ out.gsub(/&#(\d+);/) do |match|
210
+ cp = Regexp.last_match(1).to_i
211
+ replacement_for_xml_numeric_ref(cp, match)
212
+ end
213
+ end
214
+ private_class_method :decode_control_numeric_references
215
+
216
+ def replacement_for_xml_numeric_ref(codepoint, original)
217
+ return '' unless codepoint.is_a?(Integer)
218
+
219
+ # Decode control characters so the escape-sequence scanner can remove the
220
+ # full sequence (e.g. ESC + '[' + ... + 'm').
221
+ return [codepoint].pack('U') if codepoint < 0x20 || codepoint == 0x7F || codepoint.between?(0x80, 0x9F)
222
+
223
+ # Drop codepoints that are not valid in XML 1.0.
224
+ xml_allowed_codepoint?(codepoint) ? original : ''
225
+ rescue StandardError
226
+ ''
227
+ end
228
+ private_class_method :replacement_for_xml_numeric_ref
229
+
230
+ def xml_allowed_codepoint?(codepoint)
231
+ return true if [0x09, 0x0A, 0x0D].include?(codepoint)
232
+ return true if codepoint.between?(0x20, 0xD7FF)
233
+ return true if codepoint.between?(0xE000, 0xFFFD)
234
+ return true if codepoint.between?(0x10000, 0x10FFFF)
235
+
236
+ false
237
+ end
238
+ private_class_method :xml_allowed_codepoint?
239
+ end
240
+ end
241
+ end
242
+ end
243
+ end