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,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ require_relative 'base_repository'
6
+ require_relative 'storage/progress_file_store'
7
+
8
+ module Shoko
9
+ module Adapters::Storage::Repositories
10
+ # Repository for reading progress persistence, abstracting the underlying storage mechanism.
11
+ #
12
+ # This repository provides a clean domain interface for progress operations,
13
+ # hiding the file-based persistence details from domain services.
14
+ #
15
+ # @example Saving progress
16
+ # repo = ProgressRepository.new(dependencies)
17
+ # repo.save_for_book('/path/to/book.epub', chapter_index: 3, line_offset: 150)
18
+ #
19
+ # @example Loading progress
20
+ # progress = repo.find_by_book_path('/path/to/book.epub')
21
+ class ProgressRepository < BaseRepository
22
+ # Progress data structure
23
+ ProgressData = Struct.new(:chapter_index, :line_offset, :timestamp, keyword_init: true) do
24
+ def to_h
25
+ {
26
+ chapter: chapter_index,
27
+ line_offset: line_offset,
28
+ timestamp: timestamp,
29
+ }
30
+ end
31
+
32
+ def self.from_h(hash)
33
+ return nil unless hash
34
+
35
+ new(
36
+ chapter_index: hash['chapter'] || hash[:chapter],
37
+ line_offset: hash['line_offset'] || hash[:line_offset],
38
+ timestamp: hash['timestamp'] || hash[:timestamp]
39
+ )
40
+ end
41
+ end
42
+
43
+ def initialize(dependencies)
44
+ super
45
+ file_writer = dependencies.resolve(:file_writer)
46
+ @storage = Storage::ProgressFileStore.new(file_writer:)
47
+ end
48
+
49
+ # Save reading progress for a specific book
50
+ #
51
+ # @param book_path [String] Path to the EPUB file
52
+ # @param chapter_index [Integer] Chapter index (0-based)
53
+ # @param line_offset [Integer] Line offset within the chapter
54
+ # @return [ProgressData] The saved progress data
55
+ def save_for_book(book_path, chapter_index:, line_offset:)
56
+ validate_required_params(
57
+ { book_path: book_path, chapter_index: chapter_index, line_offset: line_offset },
58
+ %i[book_path chapter_index line_offset]
59
+ )
60
+
61
+ begin
62
+ @storage.save(book_path, chapter_index, line_offset)
63
+
64
+ # Return the progress data that was saved
65
+ ProgressData.new(
66
+ chapter_index: chapter_index,
67
+ line_offset: line_offset,
68
+ timestamp: Time.now.iso8601
69
+ )
70
+ rescue StandardError => e
71
+ handle_storage_error(e, "saving progress for #{book_path}")
72
+ end
73
+ end
74
+
75
+ # Find reading progress for a specific book
76
+ #
77
+ # @param book_path [String] Path to the EPUB file
78
+ # @return [ProgressData, nil] Progress data for the book, or nil if none exists
79
+ def find_by_book_path(book_path)
80
+ validate_required_params({ book_path: book_path }, [:book_path])
81
+
82
+ begin
83
+ progress_hash = @storage.load(book_path)
84
+ ProgressData.from_h(progress_hash)
85
+ rescue StandardError => e
86
+ handle_storage_error(e, "loading progress for #{book_path}")
87
+ end
88
+ end
89
+
90
+ # Find all reading progress across all books
91
+ #
92
+ # @return [Hash<String, ProgressData>] Hash mapping book paths to progress data
93
+ def find_all
94
+ all_progress = @storage.load_all
95
+ all_progress.transform_values { |progress_hash| ProgressData.from_h(progress_hash) }
96
+ rescue StandardError => e
97
+ handle_storage_error(e, 'loading all progress data')
98
+ end
99
+
100
+ # Check if progress exists for a book
101
+ #
102
+ # @param book_path [String] Path to the EPUB file
103
+ # @return [Boolean] True if progress data exists for the book
104
+ def exists_for_book?(book_path)
105
+ !find_by_book_path(book_path).nil?
106
+ rescue StandardError => e
107
+ handle_storage_error(e, "checking progress existence for #{book_path}")
108
+ end
109
+
110
+ # Get the timestamp of the last progress update for a book
111
+ #
112
+ # @param book_path [String] Path to the EPUB file
113
+ # @return [Time, nil] Last update timestamp, or nil if no progress exists
114
+ def last_updated_at(book_path)
115
+ progress = find_by_book_path(book_path)
116
+ ts = progress&.timestamp
117
+ return nil unless ts
118
+
119
+ Time.parse(ts)
120
+ rescue StandardError => e
121
+ handle_storage_error(e, "getting last update time for #{book_path}")
122
+ end
123
+
124
+ # Get books ordered by most recently read
125
+ #
126
+ # @param limit [Integer, nil] Maximum number of books to return
127
+ # @return [Array<String>] Array of book paths ordered by recency
128
+ def recent_books(limit: nil)
129
+ all_progress = find_all
130
+ sorted_paths = all_progress.sort_by do |_path, progress|
131
+ ts = progress.timestamp
132
+ ts ? Time.parse(ts) : Time.at(0)
133
+ end.reverse.map(&:first)
134
+
135
+ limit ? sorted_paths.take(limit) : sorted_paths
136
+ rescue StandardError => e
137
+ handle_storage_error(e, 'getting recent books')
138
+ end
139
+
140
+ # Update progress only if the new position is further than current
141
+ #
142
+ # @param book_path [String] Path to the EPUB file
143
+ # @param chapter_index [Integer] Chapter index (0-based)
144
+ # @param line_offset [Integer] Line offset within the chapter
145
+ # @return [ProgressData] The saved progress data
146
+ def save_if_further(book_path, chapter_index:, line_offset:)
147
+ current_progress = find_by_book_path(book_path)
148
+
149
+ should_save = if current_progress.nil?
150
+ true
151
+ else
152
+ cur_ch = current_progress.chapter_index
153
+ cur_off = current_progress.line_offset
154
+ chapter_index > cur_ch ||
155
+ (chapter_index == cur_ch && line_offset > cur_off)
156
+ end
157
+
158
+ return save_for_book(book_path, chapter_index: chapter_index, line_offset: line_offset) if should_save
159
+
160
+ current_progress
161
+ rescue StandardError => e
162
+ handle_storage_error(e, "conditionally saving progress for #{book_path}")
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+ require 'time'
6
+
7
+ require_relative '../../../output/terminal/terminal_sanitizer.rb'
8
+ require_relative 'file_store_utils'
9
+ require_relative '../../config_paths'
10
+ # Domain storage helpers should operate via injected services to avoid reaching into infrastructure.
11
+
12
+ module Shoko
13
+ module Adapters::Storage::Repositories::Storage
14
+ # File-backed annotation storage under Domain.
15
+ # Persists annotations to ${XDG_CONFIG_HOME:-~/.config}/shoko/annotations.json
16
+ class AnnotationFileStore
17
+ FILE_NAME = 'annotations.json'
18
+
19
+ def initialize(file_writer:)
20
+ @file_writer = file_writer
21
+ end
22
+
23
+ def all
24
+ sanitize_all(load_all)
25
+ rescue StandardError
26
+ {}
27
+ end
28
+
29
+ def get(path)
30
+ sanitize_list(load_all[path.to_s] || []).dup
31
+ rescue StandardError
32
+ []
33
+ end
34
+
35
+ def add(path, text, note, range, chapter_index, page_meta = nil)
36
+ data = load_all
37
+ key = path.to_s
38
+ list = data[key] || []
39
+ now = Time.now
40
+ ann = {
41
+ 'id' => now.to_f.to_s,
42
+ 'text' => sanitize_body(text),
43
+ 'note' => sanitize_body(note),
44
+ 'range' => range,
45
+ 'chapter_index' => chapter_index,
46
+ 'created_at' => now.iso8601,
47
+ }
48
+ if page_meta.is_a?(Hash)
49
+ ann['page_current'] = page_meta[:current] || page_meta['current']
50
+ ann['page_total'] = page_meta[:total] || page_meta['total']
51
+ ann['page_mode'] = page_meta[:type] || page_meta['type']
52
+ end
53
+ list << ann
54
+ data[key] = list
55
+ save_all(data)
56
+ true
57
+ rescue StandardError
58
+ false
59
+ end
60
+
61
+ def update(path, id, note)
62
+ data = load_all
63
+ key = path.to_s
64
+ list = data[key] || []
65
+ ann = list.find { |a| a['id'] == id }
66
+ return false unless ann
67
+
68
+ ann['note'] = sanitize_body(note)
69
+ ann['updated_at'] = Time.now.iso8601
70
+ data[key] = list
71
+ save_all(data)
72
+ true
73
+ rescue StandardError
74
+ false
75
+ end
76
+
77
+ def delete(path, id)
78
+ data = load_all
79
+ key = path.to_s
80
+ list = data[key] || []
81
+ list.reject! { |a| a['id'] == id }
82
+ list.empty? ? data.delete(key) : data[key] = list
83
+ save_all(data)
84
+ true
85
+ rescue StandardError
86
+ false
87
+ end
88
+
89
+ private
90
+
91
+ attr_reader :file_writer
92
+
93
+ def load_all
94
+ FileStoreUtils.load_json_or_empty(file_path)
95
+ end
96
+
97
+ def sanitize_all(data)
98
+ return {} unless data.is_a?(Hash)
99
+
100
+ data.transform_values { |list| sanitize_list(list) }
101
+ end
102
+
103
+ def sanitize_list(list)
104
+ Array(list).map do |ann|
105
+ next ann unless ann.is_a?(Hash)
106
+
107
+ safe = ann.dup
108
+ safe['text'] = sanitize_body(safe['text'])
109
+ safe['note'] = sanitize_body(safe['note'])
110
+ safe
111
+ end
112
+ end
113
+
114
+ def sanitize_body(text)
115
+ Shoko::Adapters::Output::Terminal::TerminalSanitizer.sanitize(text.to_s, preserve_newlines: true, preserve_tabs: true)
116
+ end
117
+
118
+ def save_all(data)
119
+ payload = JSON.pretty_generate(data)
120
+ file_writer.write(file_path, payload)
121
+ end
122
+
123
+ def file_path
124
+ Adapters::Storage::ConfigPaths.config_path(FILE_NAME)
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+ require 'time'
6
+
7
+ require_relative '../../../output/terminal/terminal_sanitizer.rb'
8
+ require_relative '../../../../core/models/bookmark.rb'
9
+ require_relative '../../../../core/models/bookmark_data.rb'
10
+ require_relative 'file_store_utils'
11
+ require_relative '../../config_paths'
12
+ # Domain storage helpers should operate via injected services to avoid reaching into infrastructure.
13
+
14
+ module Shoko
15
+ module Adapters::Storage::Repositories::Storage
16
+ # File-backed bookmark storage isolated under Domain.
17
+ # Persists bookmarks to ${XDG_CONFIG_HOME:-~/.config}/shoko/bookmarks.json
18
+ class BookmarkFileStore
19
+ FILE_NAME = 'bookmarks.json'
20
+
21
+ def initialize(file_writer:)
22
+ @file_writer = file_writer
23
+ end
24
+
25
+ def add(bookmark_data)
26
+ unless bookmark_data.is_a?(Shoko::Core::Models::BookmarkData)
27
+ raise ArgumentError, 'bookmark_data must be BookmarkData'
28
+ end
29
+
30
+ all = load_all
31
+ path = bookmark_data.path.to_s
32
+ list = all[path] || []
33
+ entry = {
34
+ 'chapter' => bookmark_data.chapter,
35
+ 'line_offset' => bookmark_data.line_offset,
36
+ 'text' => sanitize_text(bookmark_data.text),
37
+ 'timestamp' => Time.now.iso8601,
38
+ }
39
+ list << entry
40
+ all[path] = list
41
+ save_all(all)
42
+ entry
43
+ end
44
+
45
+ def get(path)
46
+ all = load_all
47
+ list = all[path.to_s] || []
48
+ list.map do |h|
49
+ safe = h.is_a?(Hash) ? h.dup : {}
50
+ safe['text'] = sanitize_text(safe['text'])
51
+ Shoko::Core::Models::Bookmark.from_h(safe)
52
+ end
53
+ rescue StandardError
54
+ []
55
+ end
56
+
57
+ def delete(path, bookmark)
58
+ all = load_all
59
+ key = path.to_s
60
+ list = all[key] || []
61
+ # Delete by matching serialized representation
62
+ predicate = if bookmark.respond_to?(:to_h)
63
+ target = bookmark.to_h
64
+ ->(stored_entry) { equivalent?(stored_entry, target) }
65
+ else
66
+ # Best-effort: match by position
67
+ chapter = bookmark.respond_to?(:chapter_index) ? bookmark.chapter_index : bookmark[:chapter_index]
68
+ offset = bookmark.respond_to?(:line_offset) ? bookmark.line_offset : bookmark[:line_offset]
69
+ lambda { |stored_entry|
70
+ stored_entry['chapter'] == chapter && stored_entry['line_offset'] == offset
71
+ }
72
+ end
73
+ list.reject!(&predicate)
74
+ list.empty? ? all.delete(key) : all[key] = list
75
+ save_all(all)
76
+ true
77
+ rescue StandardError
78
+ false
79
+ end
80
+
81
+ private
82
+
83
+ attr_reader :file_writer
84
+
85
+ def equivalent?(stored_entry, target)
86
+ stored_entry['chapter'] == target['chapter'] &&
87
+ stored_entry['line_offset'] == target['line_offset'] &&
88
+ (stored_entry['text'].to_s == target['text'].to_s)
89
+ end
90
+
91
+ def sanitize_text(text)
92
+ Shoko::Adapters::Output::Terminal::TerminalSanitizer.sanitize(text.to_s, preserve_newlines: false, preserve_tabs: false)
93
+ end
94
+
95
+ def load_all
96
+ FileStoreUtils.load_json_or_empty(file_path)
97
+ end
98
+
99
+ def save_all(data)
100
+ payload = JSON.pretty_generate(data)
101
+ file_writer.write(file_path, payload)
102
+ end
103
+
104
+ def file_path
105
+ Adapters::Storage::ConfigPaths.config_path(FILE_NAME)
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Shoko
6
+ module Adapters::Storage::Repositories::Storage
7
+ # Small, shared helpers for file-backed JSON stores.
8
+ module FileStoreUtils
9
+ module_function
10
+
11
+ def load_json_or_empty(file_path)
12
+ return {} unless File.exist?(file_path)
13
+
14
+ JSON.parse(File.read(file_path))
15
+ rescue StandardError
16
+ {}
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+ require 'time'
6
+ require_relative 'file_store_utils'
7
+ require_relative '../../config_paths'
8
+ # Domain storage helpers should operate via injected services to avoid reaching into infrastructure.
9
+
10
+ module Shoko
11
+ module Adapters::Storage::Repositories::Storage
12
+ # File-backed progress storage under Domain.
13
+ # Persists progress to ${XDG_CONFIG_HOME:-~/.config}/shoko/progress.json
14
+ class ProgressFileStore
15
+ FILE_NAME = 'progress.json'
16
+
17
+ def initialize(file_writer:)
18
+ @file_writer = file_writer
19
+ end
20
+
21
+ def save(path, chapter_index, line_offset)
22
+ all = load_all
23
+ all[path.to_s] = {
24
+ 'chapter' => chapter_index,
25
+ 'line_offset' => line_offset,
26
+ 'timestamp' => Time.now.iso8601,
27
+ }
28
+ save_all(all)
29
+ true
30
+ rescue StandardError
31
+ false
32
+ end
33
+
34
+ def load(path)
35
+ all = load_all
36
+ all[path.to_s]
37
+ rescue StandardError
38
+ nil
39
+ end
40
+
41
+ def load_all
42
+ FileStoreUtils.load_json_or_empty(file_path)
43
+ end
44
+
45
+ private
46
+
47
+ attr_reader :file_writer
48
+
49
+ def save_all(data)
50
+ payload = JSON.pretty_generate(data)
51
+ file_writer.write(file_path, payload)
52
+ end
53
+
54
+ def file_path
55
+ Adapters::Storage::ConfigPaths.config_path(FILE_NAME)
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'state/actions/update_selection_action.rb'
4
+ require_relative '../application/selectors/reader_selectors'
5
+
6
+ module Shoko
7
+ module Application
8
+ # Adapter exposing the annotation editor overlay through the same interface as
9
+ # the legacy screen component so Application commands can drive it via the input
10
+ # dispatcher.
11
+ class AnnotationEditorOverlaySession
12
+ def initialize(state, dependencies, ui_controller)
13
+ @state = state
14
+ @dependencies = dependencies
15
+ @ui_controller = ui_controller
16
+ end
17
+
18
+ def active?
19
+ overlay = current_overlay
20
+ overlay.respond_to?(:visible?) && overlay.visible?
21
+ end
22
+
23
+ def save_annotation
24
+ overlay = current_overlay
25
+ return :pass unless overlay
26
+
27
+ service, book_path = resolve_save_context
28
+ return cancel_and_pass unless service && book_path
29
+
30
+ persist_annotation(overlay, service, book_path)
31
+ :handled
32
+ end
33
+
34
+ def cancel_annotation
35
+ close_overlay
36
+ clear_selection
37
+ @ui_controller.set_message('Annotation cancelled', 2)
38
+ :handled
39
+ end
40
+
41
+ def cancel_and_pass
42
+ cancel_annotation
43
+ :pass
44
+ end
45
+
46
+ def handle_backspace
47
+ overlay = current_overlay
48
+ overlay&.handle_backspace
49
+ :handled
50
+ end
51
+
52
+ def handle_enter
53
+ overlay = current_overlay
54
+ overlay&.handle_enter if overlay.respond_to?(:handle_enter)
55
+ :handled
56
+ end
57
+
58
+ def handle_character(char)
59
+ overlay = current_overlay
60
+ overlay&.handle_character(char) if overlay.respond_to?(:handle_character)
61
+ :handled
62
+ end
63
+
64
+ private
65
+
66
+ def current_overlay
67
+ Shoko::Application::Selectors::ReaderSelectors.annotation_editor_overlay(@state)
68
+ end
69
+
70
+ def resolve_save_context
71
+ service = resolve_annotation_service
72
+ book_path = current_book_path
73
+ [service, book_path]
74
+ end
75
+
76
+ def current_book_path
77
+ return unless @ui_controller.respond_to?(:current_book_path)
78
+
79
+ @ui_controller.current_book_path
80
+ end
81
+
82
+ def persist_annotation(overlay, service, book_path)
83
+ save_overlay(overlay, service, book_path)
84
+ refresh_annotations
85
+ rescue StandardError => e
86
+ @ui_controller.set_message("Save failed: #{e.message}", 3)
87
+ ensure
88
+ close_overlay
89
+ clear_selection
90
+ end
91
+
92
+ def save_overlay(overlay, service, book_path)
93
+ if overlay.annotation_id
94
+ service.update(book_path, overlay.annotation_id, overlay.note)
95
+ @ui_controller.set_message('Annotation updated', 2)
96
+ else
97
+ service.add(book_path,
98
+ overlay.selected_text,
99
+ overlay.note,
100
+ overlay.selection_range,
101
+ overlay.chapter_index,
102
+ nil)
103
+ @ui_controller.set_message('Annotation saved!', 2)
104
+ end
105
+ end
106
+
107
+ def refresh_annotations
108
+ return unless @ui_controller.respond_to?(:refresh_annotations)
109
+
110
+ @ui_controller.refresh_annotations
111
+ end
112
+
113
+ def resolve_annotation_service
114
+ return unless @dependencies.respond_to?(:resolve)
115
+
116
+ @dependencies.resolve(:annotation_service)
117
+ rescue StandardError
118
+ nil
119
+ end
120
+
121
+ def close_overlay
122
+ return unless @ui_controller
123
+
124
+ begin
125
+ @ui_controller.send(:close_annotation_editor_overlay)
126
+ rescue StandardError
127
+ nil
128
+ end
129
+ end
130
+
131
+ def clear_selection
132
+ @state.dispatch(Shoko::Application::Actions::ClearSelectionAction.new)
133
+ rescue StandardError
134
+ nil
135
+ end
136
+ end
137
+ end
138
+ end