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,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shoko
4
+ module Adapters::Storage::Repositories
5
+ # Base class for all repository implementations in the domain layer.
6
+ #
7
+ # Repositories provide an abstraction layer between domain services and
8
+ # infrastructure storage mechanisms, following the Repository pattern from
9
+ # Domain-Driven Design.
10
+ #
11
+ # All repositories should:
12
+ # - Provide domain-focused methods (not storage-focused)
13
+ # - Return domain objects or primitives
14
+ # - Handle storage-specific errors and convert to domain errors
15
+ # - Use dependency injection for storage implementations
16
+ #
17
+ # @example Implementing a repository
18
+ # class MyRepository < BaseRepository
19
+ # def find_by_id(id)
20
+ # storage_result = @storage.find(id)
21
+ # convert_to_domain_object(storage_result)
22
+ # rescue Storage::NotFoundError => e
23
+ # raise EntityNotFoundError, e.message
24
+ # end
25
+ # end
26
+ class BaseRepository
27
+ # Repository-specific errors
28
+ class RepositoryError < StandardError; end
29
+ class EntityNotFoundError < RepositoryError; end
30
+ class ValidationError < RepositoryError; end
31
+ class PersistenceError < RepositoryError; end
32
+
33
+ def initialize(dependencies)
34
+ @dependencies = dependencies
35
+ @logger = dependencies.resolve(:logger)
36
+ setup_repository_dependencies
37
+ end
38
+
39
+ protected
40
+
41
+ attr_reader :dependencies, :logger
42
+
43
+ # Template method for subclasses to set up their specific dependencies
44
+ def setup_repository_dependencies
45
+ # Override in subclasses to resolve storage dependencies
46
+ end
47
+
48
+ # Helper to handle common storage errors
49
+ def handle_storage_error(error, context = nil)
50
+ msg = error.message
51
+ message = context ? "#{context}: #{msg}" : msg
52
+ logger.error("Repository error - #{message}")
53
+
54
+ case error
55
+ when NoMethodError, ArgumentError
56
+ raise ValidationError, message
57
+ else
58
+ raise PersistenceError, message
59
+ end
60
+ end
61
+
62
+ # Helper to validate required parameters
63
+ def validate_required_params(params, required_keys)
64
+ missing_keys = required_keys.select do |key|
65
+ val = params[key]
66
+ !params.key?(key) || val.nil? || (val.respond_to?(:empty?) && val.empty?)
67
+ end
68
+ return if missing_keys.empty?
69
+
70
+ raise ValidationError, "Missing required parameters: #{missing_keys.join(', ')}"
71
+ end
72
+
73
+ # Helper to ensure entity exists before operations
74
+ def ensure_entity_exists(entity, entity_name = 'Entity')
75
+ return if entity
76
+
77
+ raise EntityNotFoundError, "#{entity_name} not found"
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_repository'
4
+ require_relative '../../../core/models/bookmark_data.rb'
5
+ require_relative 'storage/bookmark_file_store'
6
+
7
+ module Shoko
8
+ module Adapters::Storage::Repositories
9
+ # Repository for bookmark persistence, abstracting the underlying storage mechanism.
10
+ #
11
+ # This repository provides a clean domain interface for bookmark operations,
12
+ # hiding the file-based persistence details from domain services.
13
+ #
14
+ # @example Adding a bookmark
15
+ # repo = BookmarkRepository.new(dependencies)
16
+ # repo.add_for_book('/path/to/book.epub', chapter: 2, line: 50, text: 'Important quote')
17
+ #
18
+ # @example Getting bookmarks for a book
19
+ # bookmarks = repo.find_by_book_path('/path/to/book.epub')
20
+ class BookmarkRepository < BaseRepository
21
+ def initialize(dependencies)
22
+ super
23
+ file_writer = dependencies.resolve(:file_writer)
24
+ @storage = Storage::BookmarkFileStore.new(file_writer:)
25
+ end
26
+
27
+ # Add a bookmark for a specific book
28
+ #
29
+ # @param book_path [String] Path to the EPUB file
30
+ # @param chapter_index [Integer] Chapter index (0-based)
31
+ # @param line_offset [Integer] Line offset within the chapter
32
+ # @param text_snippet [String] Text snippet for the bookmark
33
+ # @return [Models::Bookmark] The created bookmark
34
+ def add_for_book(book_path, chapter_index:, line_offset:, text_snippet:)
35
+ validate_required_params(
36
+ { book_path: book_path, chapter_index: chapter_index, line_offset: line_offset },
37
+ %i[book_path chapter_index line_offset]
38
+ )
39
+
40
+ bookmark_data = Core::Models::BookmarkData.new(
41
+ path: book_path,
42
+ chapter: chapter_index,
43
+ line_offset: line_offset,
44
+ text: text_snippet || ''
45
+ )
46
+
47
+ begin
48
+ @storage.add(bookmark_data)
49
+
50
+ # Return the bookmark object that was created
51
+ bookmarks = find_by_book_path(book_path)
52
+ # Find the most recently added bookmark (by timestamp)
53
+ bookmarks.max_by(&:created_at)
54
+ rescue StandardError => e
55
+ handle_storage_error(e, "adding bookmark for #{book_path}")
56
+ end
57
+ end
58
+
59
+ # Find all bookmarks for a specific book
60
+ #
61
+ # @param book_path [String] Path to the EPUB file
62
+ # @return [Array<Models::Bookmark>] Array of bookmarks for the book
63
+ def find_by_book_path(book_path)
64
+ validate_required_params({ book_path: book_path }, [:book_path])
65
+
66
+ begin
67
+ @storage.get(book_path) || []
68
+ rescue StandardError => e
69
+ handle_storage_error(e, "loading bookmarks for #{book_path}")
70
+ end
71
+ end
72
+
73
+ # Delete a specific bookmark
74
+ #
75
+ # @param book_path [String] Path to the EPUB file
76
+ # @param bookmark [Models::Bookmark] The bookmark to delete
77
+ # @return [Boolean] True if deleted successfully
78
+ def delete_for_book(book_path, bookmark)
79
+ # Ensure entity existence takes precedence for clearer error semantics
80
+ ensure_entity_exists(bookmark, 'Bookmark')
81
+ validate_required_params({ book_path: book_path }, %i[book_path])
82
+
83
+ begin
84
+ @storage.delete(book_path, bookmark)
85
+ true
86
+ rescue StandardError => e
87
+ handle_storage_error(e, "deleting bookmark for #{book_path}")
88
+ end
89
+ end
90
+
91
+ # Check if a bookmark exists at the given position
92
+ #
93
+ # @param book_path [String] Path to the EPUB file
94
+ # @param chapter_index [Integer] Chapter index
95
+ # @param line_offset [Integer] Line offset within the chapter
96
+ # @return [Boolean] True if a bookmark exists at this position
97
+ def exists_at_position?(book_path, chapter_index, line_offset)
98
+ bookmarks = find_by_book_path(book_path)
99
+ bookmarks.any? do |bookmark|
100
+ bookmark.chapter_index == chapter_index && bookmark.line_offset == line_offset
101
+ end
102
+ rescue StandardError => e
103
+ handle_storage_error(e, "checking bookmark existence for #{book_path}")
104
+ end
105
+
106
+ # Get bookmark count for a book
107
+ #
108
+ # @param book_path [String] Path to the EPUB file
109
+ # @return [Integer] Number of bookmarks for the book
110
+ def count_for_book(book_path)
111
+ find_by_book_path(book_path).size
112
+ rescue StandardError => e
113
+ handle_storage_error(e, "counting bookmarks for #{book_path}")
114
+ end
115
+
116
+ # Find bookmark at a specific position
117
+ #
118
+ # @param book_path [String] Path to the EPUB file
119
+ # @param chapter_index [Integer] Chapter index
120
+ # @param line_offset [Integer] Line offset within the chapter
121
+ # @return [Models::Bookmark, nil] The bookmark at this position, or nil
122
+ def find_at_position(book_path, chapter_index, line_offset)
123
+ bookmarks = find_by_book_path(book_path)
124
+ bookmarks.find do |bookmark|
125
+ bookmark.chapter_index == chapter_index && bookmark.line_offset == line_offset
126
+ end
127
+ rescue StandardError => e
128
+ handle_storage_error(e, "finding bookmark at position for #{book_path}")
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+
6
+ require_relative '../cache_paths'
7
+ require_relative '../json_cache_store'
8
+ require_relative '../cache_pointer_manager'
9
+ require_relative '../epub_cache'
10
+ require_relative '../../output/terminal/terminal_sanitizer.rb'
11
+
12
+ module Shoko
13
+ module Adapters::Storage::Repositories
14
+ # Provides read-only access to cached library metadata on disk.
15
+ class CachedLibraryRepository
16
+ def initialize(cache_root: Adapters::Storage::CachePaths.cache_root, store: nil)
17
+ @cache_root = cache_root
18
+ @cache_store = store || Adapters::Storage::JsonCacheStore.new(cache_root:)
19
+ end
20
+
21
+ def list_entries
22
+ rows = fetch_manifest_rows
23
+ rows = fetch_rows if rows.empty?
24
+ return [] if rows.empty?
25
+
26
+ rows.filter_map { |row| build_entry_from_row(row) }
27
+ end
28
+
29
+ private
30
+
31
+ def fetch_rows
32
+ @cache_store.list_books
33
+ end
34
+
35
+ def fetch_manifest_rows
36
+ Adapters::Storage::JsonCacheStore.manifest_rows(@cache_root)
37
+ end
38
+
39
+ def build_entry_from_row(row)
40
+ sha = row.is_a?(Hash) ? (row['source_sha'] || row[:source_sha]) : nil
41
+ pointer_path = Adapters::Storage::EpubCache.cache_path_for_sha(sha, cache_root: @cache_root)
42
+ return nil unless pointer_path
43
+
44
+ ensure_pointer_file(row, pointer_path)
45
+
46
+ metadata = parse_json_object(row['metadata_json'])
47
+ authors = parse_json_array(row['authors_json']).map { |name| sanitize_display(name.to_s) }
48
+
49
+ {
50
+ title: sanitize_display(present_or_default(row['title'], 'Unknown')),
51
+ authors: authors.join(', '),
52
+ year: extract_year(metadata),
53
+ size_bytes: (row['cache_size_bytes'] || safe_file_size(pointer_path)).to_i,
54
+ open_path: pointer_path,
55
+ epub_path: row['source_path'].to_s,
56
+ }
57
+ end
58
+
59
+ def ensure_pointer_file(row, path)
60
+ return path if File.exist?(path) || row['source_sha'].to_s.empty?
61
+
62
+ generated_at = begin
63
+ raw = row['generated_at']
64
+ raw ? Time.at(raw.to_f).utc.iso8601 : Time.now.utc.iso8601
65
+ rescue StandardError
66
+ Time.now.utc.iso8601
67
+ end
68
+
69
+ metadata = {
70
+ 'format' => Adapters::Storage::CachePointerManager::POINTER_FORMAT,
71
+ 'version' => Adapters::Storage::CachePointerManager::POINTER_VERSION,
72
+ 'sha256' => row['source_sha'],
73
+ 'source_path' => row['source_path'],
74
+ 'generated_at' => generated_at,
75
+ 'engine' => Adapters::Storage::JsonCacheStore::ENGINE,
76
+ }
77
+
78
+ Adapters::Storage::CachePointerManager.new(path).write(metadata)
79
+ path
80
+ rescue StandardError
81
+ path
82
+ end
83
+
84
+ def parse_json_object(value)
85
+ return {} unless value
86
+ return value if value.is_a?(Hash)
87
+
88
+ parsed = JSON.parse(value)
89
+ parsed.is_a?(Hash) ? parsed : {}
90
+ rescue JSON::ParserError, TypeError
91
+ {}
92
+ end
93
+
94
+ def parse_json_array(value)
95
+ return [] unless value
96
+ return value if value.is_a?(Array)
97
+
98
+ parsed = JSON.parse(value)
99
+ parsed.is_a?(Array) ? parsed : []
100
+ rescue JSON::ParserError, TypeError
101
+ []
102
+ end
103
+
104
+ def extract_year(metadata)
105
+ return '' unless metadata.respond_to?(:[])
106
+
107
+ year = metadata['year'] || metadata[:year]
108
+ year ? year.to_s : ''
109
+ end
110
+
111
+ def present_or_default(value, fallback)
112
+ str = value.to_s.strip
113
+ str.empty? ? fallback : value
114
+ end
115
+
116
+ def safe_file_size(path)
117
+ File.size(path)
118
+ rescue StandardError
119
+ 0
120
+ end
121
+
122
+ def sanitize_display(text)
123
+ Shoko::Adapters::Output::Terminal::TerminalSanitizer.sanitize(text.to_s, preserve_newlines: false, preserve_tabs: false)
124
+ rescue StandardError
125
+ text.to_s
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_repository'
4
+
5
+ module Shoko
6
+ module Adapters::Storage::Repositories
7
+ # Repository for configuration persistence, abstracting the underlying storage mechanism.
8
+ #
9
+ # This repository provides a clean domain interface for configuration operations,
10
+ # using the StateStore as the single source of truth for all configuration data.
11
+ #
12
+ # @example Getting configuration
13
+ # repo = ConfigRepository.new(dependencies)
14
+ # view_mode = repo.view_mode
15
+ # line_spacing = repo.line_spacing
16
+ #
17
+ # @example Updating configuration
18
+ # repo.update_view_mode(:split)
19
+ # repo.update_line_spacing(:relaxed)
20
+ class ConfigRepository < BaseRepository
21
+ LINE_SPACING_ALIASES = {
22
+ tight: :compact,
23
+ wide: :relaxed,
24
+ }.freeze
25
+
26
+ LINE_SPACING_ALLOWED = %i[compact normal relaxed].freeze
27
+
28
+ # Default configuration values
29
+ DEFAULT_CONFIG = {
30
+ view_mode: :split,
31
+ page_numbering_mode: :dynamic,
32
+ show_page_numbers: true,
33
+ line_spacing: :compact,
34
+ highlight_quotes: true,
35
+ input_debounce_ms: 100,
36
+ search_highlight_timeout: 2000,
37
+ auto_save_interval: 30,
38
+ theme: :default,
39
+ }.freeze
40
+
41
+ def initialize(dependencies)
42
+ super
43
+ @state_store = dependencies.resolve(:global_state)
44
+ end
45
+
46
+ # Get the current view mode
47
+ #
48
+ # @return [Symbol] Current view mode (:split or :single)
49
+ def view_mode
50
+ get_config_value(:view_mode, DEFAULT_CONFIG[:view_mode])
51
+ end
52
+
53
+ # Update the view mode
54
+ #
55
+ # @param mode [Symbol] New view mode (:split or :single)
56
+ # @return [Boolean] True if updated successfully
57
+ def update_view_mode(mode)
58
+ validate_enum_value(:view_mode, mode, %i[split single])
59
+ update_config_value(:view_mode, mode)
60
+ end
61
+
62
+ # Get the current page numbering mode
63
+ #
64
+ # @return [Symbol] Current page numbering mode (:absolute or :dynamic)
65
+ def page_numbering_mode
66
+ get_config_value(:page_numbering_mode, DEFAULT_CONFIG[:page_numbering_mode])
67
+ end
68
+
69
+ # Update the page numbering mode
70
+ #
71
+ # @param mode [Symbol] New page numbering mode (:absolute or :dynamic)
72
+ # @return [Boolean] True if updated successfully
73
+ def update_page_numbering_mode(mode)
74
+ validate_enum_value(:page_numbering_mode, mode, %i[absolute dynamic])
75
+ update_config_value(:page_numbering_mode, mode)
76
+ end
77
+
78
+ # Get whether page numbers are shown
79
+ #
80
+ # @return [Boolean] True if page numbers should be shown
81
+ def show_page_numbers?
82
+ get_config_value(:show_page_numbers, DEFAULT_CONFIG[:show_page_numbers])
83
+ end
84
+
85
+ # Update whether to show page numbers
86
+ #
87
+ # @param show [Boolean] Whether to show page numbers
88
+ # @return [Boolean] True if updated successfully
89
+ def update_show_page_numbers(show)
90
+ validate_boolean_value(:show_page_numbers, show)
91
+ update_config_value(:show_page_numbers, show)
92
+ end
93
+
94
+ # Get the current line spacing
95
+ #
96
+ # @return [Symbol] Current line spacing (:compact, :normal, or :relaxed)
97
+ def line_spacing
98
+ raw = get_config_value(:line_spacing, DEFAULT_CONFIG[:line_spacing])
99
+ normalized = normalize_line_spacing(raw)
100
+ update_config_value(:line_spacing, normalized) if raw != normalized
101
+ normalized
102
+ end
103
+
104
+ # Update the line spacing
105
+ #
106
+ # @param spacing [Symbol] New line spacing (:compact, :normal, or :relaxed)
107
+ # @return [Boolean] True if updated successfully
108
+ def update_line_spacing(spacing)
109
+ normalized = normalize_line_spacing(spacing)
110
+ validate_enum_value(:line_spacing, normalized, LINE_SPACING_ALLOWED)
111
+ update_config_value(:line_spacing, normalized)
112
+ end
113
+
114
+ # Get the input debounce time in milliseconds
115
+ #
116
+ # @return [Integer] Input debounce time in milliseconds
117
+ def input_debounce_ms
118
+ get_config_value(:input_debounce_ms, DEFAULT_CONFIG[:input_debounce_ms])
119
+ end
120
+
121
+ # Update the input debounce time
122
+ #
123
+ # @param ms [Integer] New debounce time in milliseconds
124
+ # @return [Boolean] True if updated successfully
125
+ def update_input_debounce_ms(milliseconds)
126
+ validate_positive_integer(:input_debounce_ms, milliseconds)
127
+ update_config_value(:input_debounce_ms, milliseconds)
128
+ end
129
+
130
+ # Get all configuration as a hash
131
+ #
132
+ # @return [Hash] All configuration values
133
+ def all_config
134
+ config_state = @state_store.get(%i[config]) || {}
135
+ DEFAULT_CONFIG.merge(config_state)
136
+ end
137
+
138
+ # Update multiple configuration values at once
139
+ #
140
+ # @param config_hash [Hash] Hash of configuration key-value pairs
141
+ # @return [Boolean] True if updated successfully
142
+ def update_multiple(config_hash)
143
+ validate_required_params({ config_hash: config_hash }, [:config_hash])
144
+
145
+ begin
146
+ # Validate each value before applying any updates
147
+ normalized = {}
148
+ config_hash.each do |key, value|
149
+ coerced = key == :line_spacing ? normalize_line_spacing(value) : value
150
+ validate_config_key_value(key, coerced)
151
+ normalized[key] = coerced
152
+ end
153
+
154
+ # Apply updates as a single state transaction
155
+ @state_store.update(normalized.transform_keys { |k| [:config, k] })
156
+ true
157
+ rescue StandardError => e
158
+ # Bubble up validation errors directly; wrap others
159
+ raise e if e.is_a?(BaseRepository::ValidationError)
160
+
161
+ handle_storage_error(e, 'updating multiple config values')
162
+ end
163
+ end
164
+
165
+ # Reset configuration to defaults
166
+ #
167
+ # @return [Boolean] True if reset successfully
168
+ def reset_to_defaults
169
+ @state_store.update(DEFAULT_CONFIG.transform_keys { |k| [:config, k] })
170
+ true
171
+ rescue StandardError => e
172
+ handle_storage_error(e, 'resetting configuration to defaults')
173
+ end
174
+
175
+ # Check if a configuration key has been customized from default
176
+ #
177
+ # @param key [Symbol] Configuration key to check
178
+ # @return [Boolean] True if the value differs from default
179
+ def customized?(key)
180
+ current_value = get_config_value(key, nil)
181
+ default_value = DEFAULT_CONFIG[key]
182
+ current_value != default_value
183
+ end
184
+
185
+ private
186
+
187
+ # Get a configuration value with fallback to default
188
+ def get_config_value(key, default_value)
189
+ value = @state_store.get([:config, key])
190
+ value.nil? ? default_value : value
191
+ rescue StandardError => e
192
+ handle_storage_error(e, "getting config value #{key}")
193
+ end
194
+
195
+ # Update a single configuration value
196
+ def update_config_value(key, value)
197
+ stored_value = key == :line_spacing ? normalize_line_spacing(value) : value
198
+ @state_store.update({ [:config, key] => stored_value })
199
+ true
200
+ rescue StandardError => e
201
+ handle_storage_error(e, "updating config value #{key}")
202
+ end
203
+
204
+ # Validate that a value is one of the allowed enum values
205
+ def validate_enum_value(key, value, allowed_values)
206
+ return if allowed_values.include?(value)
207
+
208
+ raise ValidationError,
209
+ "Invalid #{key}: #{value}. Must be one of: #{allowed_values.join(', ')}"
210
+ end
211
+
212
+ # Validate that a value is a boolean
213
+ def validate_boolean_value(key, value)
214
+ return if value.is_a?(TrueClass) || value.is_a?(FalseClass)
215
+
216
+ raise ValidationError, "Invalid #{key}: #{value}. Must be true or false"
217
+ end
218
+
219
+ # Validate that a value is a positive integer
220
+ def validate_positive_integer(key, value)
221
+ return if value.is_a?(Integer) && value.positive?
222
+
223
+ raise ValidationError, "Invalid #{key}: #{value}. Must be a positive integer"
224
+ end
225
+
226
+ # Validate a specific config key-value pair
227
+ def validate_config_key_value(key, value)
228
+ case key
229
+ when :view_mode
230
+ validate_enum_value(key, value, %i[split single])
231
+ when :page_numbering_mode
232
+ validate_enum_value(key, value, %i[absolute dynamic])
233
+ when :line_spacing
234
+ normalized = normalize_line_spacing(value)
235
+ validate_enum_value(key, normalized, LINE_SPACING_ALLOWED)
236
+ when :show_page_numbers
237
+ validate_boolean_value(key, value)
238
+ when :input_debounce_ms, :search_highlight_timeout, :auto_save_interval
239
+ validate_positive_integer(key, value)
240
+ when :theme
241
+ # Allow any symbol for theme for extensibility
242
+ unless value.is_a?(Symbol)
243
+ raise ValidationError,
244
+ "Invalid #{key}: #{value}. Must be a symbol"
245
+ end
246
+ else
247
+ # Unknown keys are allowed for forward compatibility
248
+ logger.debug("Unknown config key: #{key}")
249
+ end
250
+ end
251
+
252
+ def normalize_line_spacing(value)
253
+ sym = begin
254
+ value.is_a?(String) ? value.downcase.to_sym : value&.to_sym
255
+ rescue StandardError
256
+ nil
257
+ end
258
+ LINE_SPACING_ALIASES.fetch(sym, sym || DEFAULT_CONFIG[:line_spacing])
259
+ end
260
+ end
261
+ end
262
+ end