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,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'output'
4
+ require_relative 'text_metrics.rb'
5
+
6
+ module Shoko
7
+ module Adapters::Output::Terminal
8
+ # TerminalBuffer manages buffered writes and differential screen updates.
9
+ class TerminalBuffer
10
+ # In-memory frame buffer storing characters and style runs.
11
+ class Frame
12
+ CONTINUATION = :_wide_continuation
13
+
14
+ def initialize(width, height)
15
+ @width = width.to_i
16
+ @height = height.to_i
17
+ @chars = Array.new(@height) { Array.new(@width, ' ') }
18
+ @styles = Array.new(@height) { Array.new(@width, nil) }
19
+ end
20
+
21
+ def write(row, col, text)
22
+ return if @width <= 0 || @height <= 0
23
+
24
+ row_i = row.to_i - 1
25
+ col_i = col.to_i - 1
26
+ return if row_i.negative? || row_i >= @height
27
+ return if col_i.negative?
28
+ return if col_i >= @width
29
+
30
+ current_style = ''
31
+ col_pos = col_i
32
+
33
+ String(text).scan(Shoko::Adapters::Output::Terminal::TextMetrics::TOKEN_REGEX).each do |token|
34
+ if token.start_with?("\e[")
35
+ if token.end_with?('m')
36
+ current_style = '' if token == TerminalOutput::ANSI::RESET
37
+ current_style += token unless token == TerminalOutput::ANSI::RESET
38
+ end
39
+ next
40
+ end
41
+
42
+ break if col_pos >= @width
43
+
44
+ if token == "\t"
45
+ tab_size = Shoko::Adapters::Output::Terminal::TextMetrics::TAB_SIZE
46
+ spaces = tab_size - (col_pos % tab_size)
47
+ spaces.times do
48
+ break if col_pos >= @width
49
+
50
+ clear_wide_overlap(row_i, col_pos)
51
+ @chars[row_i][col_pos] = ' '
52
+ @styles[row_i][col_pos] = current_style.empty? ? nil : current_style
53
+ col_pos += 1
54
+ end
55
+ next
56
+ end
57
+
58
+ next if token == "\e"
59
+
60
+ if ["\n", "\r"].include?(token)
61
+ cluster = ' '
62
+ else
63
+ # Defense-in-depth: never render C0/C1 control characters from content.
64
+ next if token.match?(/[\u0000-\u001F\u007F-\u009F]/)
65
+
66
+ cluster = token
67
+ end
68
+ width = Shoko::Adapters::Output::Terminal::TextMetrics.display_width_for(cluster)
69
+ next if width <= 0
70
+
71
+ remaining = @width - col_pos
72
+ break if width > remaining
73
+
74
+ clear_wide_overlap(row_i, col_pos)
75
+
76
+ @chars[row_i][col_pos] = cluster
77
+ @styles[row_i][col_pos] = current_style.empty? ? nil : current_style
78
+
79
+ if width > 1
80
+ (1...width).each do |delta|
81
+ break if col_pos + delta >= @width
82
+
83
+ clear_wide_overlap(row_i, col_pos + delta)
84
+ @chars[row_i][col_pos + delta] = CONTINUATION
85
+ @styles[row_i][col_pos + delta] = nil
86
+ end
87
+ end
88
+
89
+ col_pos += width
90
+ end
91
+ rescue StandardError
92
+ nil
93
+ end
94
+
95
+ def rendered_rows
96
+ (0...@height).map { |row_i| render_row(row_i) }
97
+ end
98
+
99
+ private
100
+
101
+ def clear_wide_overlap(row_i, col_i)
102
+ cell = @chars[row_i][col_i]
103
+ if cell == CONTINUATION && col_i.positive?
104
+ @chars[row_i][col_i - 1] = ' '
105
+ @styles[row_i][col_i - 1] = nil
106
+ elsif col_i + 1 < @width && @chars[row_i][col_i + 1] == CONTINUATION
107
+ @chars[row_i][col_i + 1] = ' '
108
+ @styles[row_i][col_i + 1] = nil
109
+ end
110
+ @chars[row_i][col_i] = ' '
111
+ @styles[row_i][col_i] = nil
112
+ end
113
+
114
+ def render_row(row_i)
115
+ chars = @chars[row_i]
116
+ styles = @styles[row_i]
117
+ last_col = last_non_blank_col(chars, styles)
118
+ return '' if last_col.negative?
119
+
120
+ out = +''
121
+ active_style = nil
122
+ run = +''
123
+
124
+ col = 0
125
+ while col <= last_col
126
+ ch = chars[col]
127
+ if ch == CONTINUATION
128
+ col += 1
129
+ next
130
+ end
131
+
132
+ style = styles[col]
133
+ style = nil if style.nil? || style.empty?
134
+
135
+ if style != active_style
136
+ flush_run(out, run, active_style)
137
+ run = +''
138
+ active_style = style
139
+ end
140
+
141
+ run << (ch || ' ')
142
+ col += 1
143
+ end
144
+
145
+ flush_run(out, run, active_style)
146
+ out
147
+ end
148
+
149
+ def last_non_blank_col(chars, styles)
150
+ idx = chars.length - 1
151
+ while idx >= 0
152
+ ch = chars[idx]
153
+ style = styles[idx]
154
+ return idx if ch == CONTINUATION
155
+ return idx if style && !style.empty?
156
+ return idx if ch && ch != ' '
157
+
158
+ idx -= 1
159
+ end
160
+ -1
161
+ end
162
+
163
+ def flush_run(out, run, style)
164
+ return if run.empty?
165
+
166
+ if style
167
+ out << style << run << TerminalOutput::ANSI::RESET
168
+ else
169
+ out << run
170
+ end
171
+ end
172
+ end
173
+
174
+ attr_reader :buffer
175
+
176
+ def initialize(output = TerminalOutput.new)
177
+ @output = output
178
+ @buffer = []
179
+ @batch_mode = false
180
+ @batch_buffer = nil
181
+ @frame = nil
182
+ @previous_rows = []
183
+ @raw_sequences = []
184
+ @width = 0
185
+ @height = 0
186
+ end
187
+
188
+ def start_frame(width:, height:)
189
+ @raw_sequences = []
190
+ @buffer = []
191
+
192
+ width_i = width.to_i
193
+ height_i = height.to_i
194
+ width_i = 0 if width_i.negative?
195
+ height_i = 0 if height_i.negative?
196
+
197
+ size_changed = (width_i != @width) || (height_i != @height)
198
+ @width = width_i
199
+ @height = height_i
200
+ @previous_rows = Array.new(@height) if size_changed
201
+ @frame = Frame.new(@width, @height)
202
+ end
203
+
204
+ def end_frame
205
+ flush_frame
206
+ @output.flush
207
+ end
208
+
209
+ def raw(text)
210
+ return unless text
211
+
212
+ if @batch_mode
213
+ @batch_buffer << text.to_s
214
+ else
215
+ @raw_sequences << text.to_s
216
+ end
217
+ end
218
+
219
+ def write(row, col, text)
220
+ if @frame
221
+ @frame.write(row, col, text)
222
+ return
223
+ end
224
+
225
+ content = TerminalOutput::ANSI.move(row, col) + text.to_s
226
+ @output.print(content)
227
+ end
228
+
229
+ def write_differential(row, col, text)
230
+ write(row, col, text)
231
+ end
232
+
233
+ def clear_buffer_cache
234
+ @previous_rows = Array.new(@height)
235
+ end
236
+
237
+ def batch_write
238
+ @batch_mode = true
239
+ @batch_buffer = []
240
+ yield
241
+ @output.print(@batch_buffer.join)
242
+ @output.flush
243
+ ensure
244
+ @batch_mode = false
245
+ @batch_buffer = nil
246
+ end
247
+
248
+ private
249
+
250
+ def flush_frame
251
+ return unless @frame
252
+
253
+ rendered = @frame.rendered_rows
254
+ out = +''
255
+ @raw_sequences.each { |seq| out << seq }
256
+
257
+ rendered.each_with_index do |row_text, idx|
258
+ prev = @previous_rows[idx]
259
+ next if prev == row_text
260
+
261
+ row_number = idx + 1
262
+ out << TerminalOutput::ANSI.move(row_number, 1)
263
+ out << TerminalOutput::ANSI.clear_line
264
+ out << row_text unless row_text.empty?
265
+ @previous_rows[idx] = row_text
266
+ end
267
+
268
+ @output.print(out) unless out.empty?
269
+ ensure
270
+ @frame = nil
271
+ @raw_sequences = []
272
+ end
273
+ end
274
+ end
275
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shoko
4
+ module Adapters::Output::Terminal
5
+ # Default terminal dimensions used when IO.console is unavailable.
6
+ module TerminalDefaults
7
+ DEFAULT_ROWS = 24
8
+ DEFAULT_COLUMNS = 80
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,347 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shoko
4
+ module Adapters::Output::Terminal
5
+ class TerminalInput
6
+ # Utility helpers for timeouts and CSI formatting.
7
+ module DecoderUtils
8
+ module_function
9
+
10
+ def normalize_timeout(value, default)
11
+ seconds = value.to_f
12
+ seconds.positive? ? seconds : default
13
+ rescue StandardError
14
+ default
15
+ end
16
+
17
+ def monotonic_now
18
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
19
+ rescue StandardError
20
+ Time.now.to_f
21
+ end
22
+
23
+ def format_csi_output(raw, prefix_bytes, output_prefix)
24
+ return raw.force_encoding(Encoding::UTF_8) unless output_prefix
25
+
26
+ remainder = raw.byteslice(prefix_bytes..) || ''.b
27
+ (output_prefix + remainder.force_encoding(Encoding::UTF_8)).freeze
28
+ end
29
+ end
30
+
31
+ # UTF-8 decoder for buffered terminal input.
32
+ class Utf8Decoder
33
+ def initialize(buffer)
34
+ @buffer = buffer
35
+ end
36
+
37
+ def decode_at(offset)
38
+ lead_byte = @buffer.getbyte(offset)
39
+ return nil unless lead_byte
40
+ return [@buffer.byteslice(offset, 1).force_encoding(Encoding::UTF_8), 1] if lead_byte < 0x80
41
+
42
+ decode_multibyte_at(offset, lead_byte)
43
+ rescue StandardError
44
+ invalid_utf8_token
45
+ end
46
+
47
+ private
48
+
49
+ def decode_multibyte_at(offset, lead_byte)
50
+ byte_length =
51
+ (2 if lead_byte.between?(0xC2, 0xDF)) ||
52
+ (3 if lead_byte.between?(0xE0, 0xEF)) ||
53
+ (4 if lead_byte.between?(0xF0, 0xF4))
54
+ return invalid_utf8_token unless byte_length
55
+ return nil if @buffer.bytesize < offset + byte_length
56
+
57
+ decode_multibyte(offset, byte_length)
58
+ end
59
+
60
+ def decode_multibyte(offset, byte_length)
61
+ bytes = @buffer.byteslice(offset, byte_length)
62
+ Utf8Validator.new(bytes).valid? ? utf8_token(bytes, byte_length) : invalid_utf8_token
63
+ end
64
+
65
+ def utf8_token(bytes, byte_length)
66
+ char = bytes.dup.force_encoding(Encoding::UTF_8)
67
+ char.valid_encoding? ? [char, byte_length] : invalid_utf8_token
68
+ end
69
+
70
+ def invalid_utf8_token
71
+ ["\uFFFD", 1]
72
+ end
73
+ end
74
+
75
+ # Validates UTF-8 byte sequences.
76
+ class Utf8Validator
77
+ def initialize(bytes)
78
+ @bytes = bytes
79
+ end
80
+
81
+ def valid?
82
+ case @bytes.bytesize
83
+ when 2
84
+ valid_2_bytes?
85
+ when 3
86
+ valid_3_bytes?
87
+ when 4
88
+ valid_4_bytes?
89
+ else
90
+ false
91
+ end
92
+ end
93
+
94
+ private
95
+
96
+ def valid_2_bytes?
97
+ byte_at(1).between?(0x80, 0xBF)
98
+ end
99
+
100
+ def valid_3_bytes?
101
+ lead_byte = byte_at(0)
102
+ first_continuation = byte_at(1)
103
+ second_continuation = byte_at(2)
104
+ first_continuation.between?(0x80, 0xBF) &&
105
+ second_continuation.between?(0x80, 0xBF) &&
106
+ !(lead_byte == 0xE0 && first_continuation < 0xA0) &&
107
+ !(lead_byte == 0xED && first_continuation > 0x9F)
108
+ end
109
+
110
+ def valid_4_bytes?
111
+ lead_byte = byte_at(0)
112
+ first_continuation = byte_at(1)
113
+ second_continuation = byte_at(2)
114
+ third_continuation = byte_at(3)
115
+ first_continuation.between?(0x80, 0xBF) &&
116
+ second_continuation.between?(0x80, 0xBF) &&
117
+ third_continuation.between?(0x80, 0xBF) &&
118
+ !(lead_byte == 0xF0 && first_continuation < 0x90) &&
119
+ !(lead_byte == 0xF4 && first_continuation > 0x8F)
120
+ end
121
+
122
+ def byte_at(index)
123
+ @bytes.getbyte(index)
124
+ end
125
+ end
126
+
127
+ # Scanning helpers for CSI and string terminators.
128
+ class DecoderScanner
129
+ def initialize(buffer)
130
+ @buffer = buffer
131
+ end
132
+
133
+ def csi_final_index(start_offset)
134
+ index = start_offset
135
+ while index < @buffer.bytesize
136
+ return index if @buffer.getbyte(index).between?(0x40, 0x7E)
137
+
138
+ index += 1
139
+ end
140
+ end
141
+
142
+ def string_terminator_index(start_offset)
143
+ start_offset.upto(@buffer.bytesize - 1) do |index|
144
+ length = string_terminator_length(index)
145
+ return index + length if length
146
+ end
147
+ end
148
+
149
+ def osc_terminator_index(start_offset)
150
+ start_offset.upto(@buffer.bytesize - 1) do |index|
151
+ length = osc_terminator_length(index)
152
+ return index + length if length
153
+ end
154
+ end
155
+
156
+ private
157
+
158
+ def string_terminator_length(index)
159
+ case @buffer.getbyte(index)
160
+ when 0x9C
161
+ 1
162
+ when 0x1B
163
+ 2 if @buffer.getbyte(index + 1) == 0x5C
164
+ end
165
+ end
166
+
167
+ def osc_terminator_length(index)
168
+ case @buffer.getbyte(index)
169
+ when 0x07, 0x9C
170
+ 1
171
+ when 0x1B
172
+ 2 if @buffer.getbyte(index + 1) == 0x5C
173
+ end
174
+ end
175
+ end
176
+
177
+ # Dispatches ESC-prefixed sequences to decoder actions.
178
+ class EscSequenceParser
179
+ HANDLERS = {
180
+ 0x1B => :escape,
181
+ 0x5B => :csi,
182
+ 0x4F => :ss3,
183
+ 0x5D => :osc,
184
+ 0x50 => :string,
185
+ 0x58 => :string,
186
+ 0x5E => :string,
187
+ 0x5F => :string,
188
+ }.freeze
189
+
190
+ ACTIONS = {
191
+ escape: lambda do |_scanner|
192
+ consume_and_clear(1)
193
+ "\e"
194
+ end,
195
+ csi: ->(_scanner) { parse_csi_sequence(prefix_bytes: 2, output_prefix: nil) },
196
+ ss3: ->(_scanner) { parse_ss3_sequence },
197
+ osc: ->(scanner) { parse_string_sequence(scanner.osc_terminator_index(2)) },
198
+ string: ->(scanner) { parse_string_sequence(scanner.string_terminator_index(2)) },
199
+ alt: ->(_scanner) { parse_decoded_character(1, prefix: "\e") },
200
+ }.freeze
201
+
202
+ def initialize(buffer, decoder)
203
+ @buffer = buffer
204
+ @decoder = decoder
205
+ @scanner = DecoderScanner.new(buffer)
206
+ end
207
+
208
+ def parse
209
+ action = HANDLERS.fetch(@buffer.getbyte(1), :alt)
210
+ @decoder.instance_exec(@scanner, &ACTIONS.fetch(action))
211
+ end
212
+ end
213
+
214
+ # Stateful tokenizer for raw terminal input.
215
+ #
216
+ # Converts a stream of bytes into:
217
+ # - Full CSI sequences (e.g. "\e[1;5D", mouse "\e[<...M")
218
+ # - Full SS3 sequences (e.g. "\eOA")
219
+ # - UTF-8 characters (including multibyte)
220
+ # - A lone ESC ("\e") after a small timeout (to disambiguate from escapes)
221
+ class Decoder
222
+ DEFAULT_ESC_TIMEOUT = 0.05
223
+ DEFAULT_SEQUENCE_TIMEOUT = 0.5
224
+
225
+ ESC = 0x1B
226
+ CSI_8BIT = 0x9B
227
+
228
+ def initialize(esc_timeout: DEFAULT_ESC_TIMEOUT, sequence_timeout: DEFAULT_SEQUENCE_TIMEOUT)
229
+ @esc_timeout = DecoderUtils.normalize_timeout(esc_timeout, DEFAULT_ESC_TIMEOUT)
230
+ @sequence_timeout = DecoderUtils.normalize_timeout(sequence_timeout, DEFAULT_SEQUENCE_TIMEOUT)
231
+ @buffer = +''.b
232
+ @pending_started_at = nil
233
+ end
234
+
235
+ def feed(bytes)
236
+ chunk = bytes.to_s
237
+ return if chunk.empty?
238
+
239
+ chunk = String(chunk).dup.force_encoding(Encoding::BINARY)
240
+ @buffer << chunk
241
+ rescue StandardError
242
+ nil
243
+ end
244
+
245
+ # Returns the next decoded token, or nil if not enough bytes are available.
246
+ def next_token(now: DecoderUtils.monotonic_now)
247
+ return nil if @buffer.empty?
248
+
249
+ token = parse_token
250
+ return token if token
251
+
252
+ now < pending_deadline(now) ? nil : degrade_pending_token
253
+ end
254
+
255
+ # When a partial token is buffered, returns seconds to wait before a token
256
+ # should be emitted even if no further bytes arrive.
257
+ def pending_timeout(now: DecoderUtils.monotonic_now)
258
+ return nil if @buffer.empty?
259
+ return nil unless @pending_started_at
260
+
261
+ remaining = pending_deadline(now) - now
262
+ remaining.positive? ? remaining : 0
263
+ end
264
+
265
+ private
266
+
267
+ def consume_and_clear(byte_count)
268
+ consume_bytes(byte_count)
269
+ @pending_started_at = nil
270
+ end
271
+
272
+ def pending_deadline(now)
273
+ started = @pending_started_at || now
274
+ @pending_started_at = started
275
+ if @buffer.bytesize == 1 && @buffer.getbyte(0) == ESC
276
+ started + @esc_timeout
277
+ else
278
+ started + @sequence_timeout
279
+ end
280
+ end
281
+
282
+ def parse_token
283
+ first = @buffer.getbyte(0)
284
+ case first
285
+ when ESC
286
+ return nil if @buffer.bytesize < 2
287
+
288
+ EscSequenceParser.new(@buffer, self).parse
289
+ when CSI_8BIT
290
+ parse_csi_sequence(prefix_bytes: 1, output_prefix: "\e[")
291
+ else
292
+ parse_decoded_character(0)
293
+ end
294
+ end
295
+
296
+ def parse_ss3_sequence
297
+ return nil if @buffer.bytesize < 3
298
+
299
+ token = @buffer.byteslice(0, 3)
300
+ consume_and_clear(3)
301
+ token.force_encoding(Encoding::UTF_8)
302
+ end
303
+
304
+ def parse_csi_sequence(prefix_bytes:, output_prefix:)
305
+ return nil unless (final_index = DecoderScanner.new(@buffer).csi_final_index(prefix_bytes))
306
+
307
+ end_index = final_index + 1
308
+ raw = @buffer.byteslice(0, end_index)
309
+ consume_and_clear(end_index)
310
+ DecoderUtils.format_csi_output(raw, prefix_bytes, output_prefix)
311
+ end
312
+
313
+ def parse_string_sequence(end_index)
314
+ return nil unless end_index
315
+
316
+ raw = @buffer.byteslice(0, end_index)
317
+ consume_and_clear(end_index)
318
+ raw.force_encoding(Encoding::UTF_8)
319
+ end
320
+
321
+ def parse_decoded_character(offset, prefix: nil)
322
+ return nil unless (decoded = Utf8Decoder.new(@buffer).decode_at(offset))
323
+
324
+ char, consumed = decoded
325
+ consume_and_clear(offset + consumed)
326
+ prefix ? "#{prefix}#{char}" : char
327
+ end
328
+
329
+ def degrade_pending_token
330
+ lead_byte = @buffer.getbyte(0)
331
+ consume_and_clear(1)
332
+
333
+ { ESC => "\e", CSI_8BIT => "\e[" }.fetch(lead_byte, "\uFFFD")
334
+ end
335
+
336
+ def consume_bytes(byte_count)
337
+ count = byte_count.to_i
338
+ return if count <= 0
339
+
340
+ @buffer.slice!(0, count)
341
+ rescue StandardError
342
+ @buffer = +''.b
343
+ end
344
+ end
345
+ end
346
+ end
347
+ end