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,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shoko
4
+ module Adapters::Storage
5
+ # Resource persistence helpers for `JsonCacheStore`.
6
+ class JsonCacheStore
7
+ private
8
+
9
+ def resources_dir(sha)
10
+ File.join(@cache_root, 'resources', normalize_sha!(sha))
11
+ end
12
+
13
+ def resource_blob_path(sha, blob_key)
14
+ File.join(resources_dir(sha), "#{blob_key}.bin")
15
+ end
16
+
17
+ def hydrate_resources(sha, index_rows)
18
+ Array(index_rows).filter_map { |row| hydrate_resource_row(sha, row) }
19
+ end
20
+
21
+ def hydrate_resource_row(sha, row)
22
+ path, blob_key = resource_index_row_fields(row)
23
+ path_string = path.to_s
24
+ blob_key_string = blob_key.to_s
25
+ return nil if path_string.empty? || blob_key_string.empty?
26
+
27
+ data = File.binread(resource_blob_path(sha, blob_key_string))
28
+ data.force_encoding(Encoding::BINARY)
29
+ { path: path_string, data: data }
30
+ rescue StandardError
31
+ nil
32
+ end
33
+
34
+ def resource_index_row_fields(row)
35
+ return [nil, nil] unless row.is_a?(Hash)
36
+
37
+ [row['path'] || row[:path], row['blob'] || row[:blob]]
38
+ end
39
+
40
+ def persist_resources(sha, resources_rows)
41
+ resources_rows = Array(resources_rows)
42
+ return [[], 0] if resources_rows.empty?
43
+
44
+ FileUtils.mkdir_p(resources_dir(sha))
45
+
46
+ rows = []
47
+ total_bytes = 0
48
+ resources_rows.each do |row|
49
+ persisted = persist_resource_row(sha, row)
50
+ next unless persisted
51
+
52
+ rows << persisted[:index_row]
53
+ total_bytes += persisted[:bytesize]
54
+ end
55
+
56
+ [rows, total_bytes]
57
+ end
58
+
59
+ def persist_resource_row(sha, row)
60
+ path, data = resource_row_fields(row)
61
+ path_string = path.to_s
62
+ return nil if path_string.empty?
63
+
64
+ bytes = String(data).dup
65
+ bytes.force_encoding(Encoding::BINARY)
66
+ blob_key = Digest::SHA256.hexdigest(path_string)
67
+
68
+ AtomicFileWriter.write(resource_blob_path(sha, blob_key), bytes, binary: true)
69
+
70
+ bytesize = bytes.bytesize
71
+ {
72
+ bytesize: bytesize,
73
+ index_row: { 'path' => path_string, 'blob' => blob_key, 'bytesize' => bytesize },
74
+ }
75
+ end
76
+
77
+ def resource_row_fields(row)
78
+ return [nil, nil] unless row.is_a?(Hash)
79
+
80
+ [row[:path] || row['path'], row[:data] || row['data']]
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'fileutils'
5
+ require 'json'
6
+ require 'securerandom'
7
+
8
+ require_relative 'atomic_file_writer'
9
+ require_relative 'cache_paths'
10
+ require_relative '../monitoring/logger.rb'
11
+ require_relative '../book_sources/source_fingerprint.rb'
12
+
13
+ module Shoko
14
+ module Adapters::Storage
15
+ # JSON-backed cache store for EPUB payloads + layouts.
16
+ #
17
+ # This store persists only primitive JSON data and keeps binary resources
18
+ # as separate blobs on disk (referenced from the JSON payload).
19
+ class JsonCacheStore
20
+ ENGINE = 'json'
21
+ FORMAT = 'shoko-cache-payload'
22
+ FORMAT_VERSION = 2
23
+
24
+ # Raw payload read from disk (metadata + chapter/resource indexes + layouts).
25
+ Payload = Struct.new(:metadata_row, :chapters, :resources, :layouts, keyword_init: true)
26
+
27
+ MANIFEST_FILENAME = 'cache_manifest.json'
28
+
29
+ SHA256_HEX_PATTERN = /\A[0-9a-f]{64}\z/i
30
+
31
+ MAX_LAYOUT_KEY_BYTES = 200
32
+ LAYOUT_KEY_PATTERN = /\A[a-zA-Z0-9][a-zA-Z0-9._-]*\z/
33
+
34
+ CHAPTERS_DIRNAME = 'chapters'
35
+ CHAPTERS_RAW_DIRNAME = 'raw'
36
+ CHAPTERS_GENERATION_BYTES = 8
37
+ CHAPTERS_GENERATION_PATTERN = /\A[0-9a-f]{16}\z/i
38
+ CHAPTER_FILENAME_DIGITS = 6
39
+ MAX_CHAPTER_COUNT = 20_000
40
+
41
+ def initialize(cache_root: CachePaths.cache_root)
42
+ @cache_root = cache_root
43
+ FileUtils.mkdir_p(@cache_root)
44
+ end
45
+
46
+ def engine
47
+ ENGINE
48
+ end
49
+
50
+ def fetch_payload(sha, include_resources: false)
51
+ data = load_payload_data(sha)
52
+ return nil unless data
53
+
54
+ Payload.new(
55
+ metadata_row: data.fetch('metadata_row', {}),
56
+ chapters: data.fetch('chapters', []),
57
+ resources: include_resources ? hydrate_resources(sha, data.fetch('resources', [])) : [],
58
+ layouts: fetch_layouts(sha)
59
+ )
60
+ rescue StandardError => e
61
+ Shoko::Adapters::Monitoring::Logger.debug('JsonCacheStore: fetch failed', sha: sha.to_s, error: e.message)
62
+ nil
63
+ end
64
+
65
+ def write_payload(sha:, source_path:, source_mtime:, generated_at:, serialized_book:, serialized_chapters:,
66
+ serialized_resources:, serialized_layouts:)
67
+ normalized_sha = normalize_sha!(sha)
68
+
69
+ metadata_row = build_metadata_row(serialized_book, normalized_sha, source_path:, source_mtime:, generated_at:)
70
+ chapters_index, chapter_generation, chapter_bytes = persist_chapters(normalized_sha, serialized_chapters)
71
+ resources_index, resource_bytes = persist_resources(normalized_sha, serialized_resources)
72
+ size_bytes = chapter_bytes.to_i + resource_bytes.to_i
73
+ indexes = { chapters: chapters_index, resources: resources_index }
74
+ payload = payload_hash(metadata_row, chapter_generation, indexes)
75
+ write_payload_file(normalized_sha, payload)
76
+ post_write_housekeeping(normalized_sha, metadata_row, chapter_generation, size_bytes, serialized_layouts:)
77
+ true
78
+ rescue StandardError => e
79
+ Shoko::Adapters::Monitoring::Logger.debug('JsonCacheStore: write failed', sha: sha.to_s, error: e.message)
80
+ cleanup_failed_chapter_generation(normalized_sha, chapter_generation) if normalized_sha && chapter_generation
81
+ false
82
+ end
83
+
84
+ def load_layout(sha, key)
85
+ file = layout_file(sha, key)
86
+ return nil unless File.file?(file)
87
+
88
+ JSON.parse(File.read(file))
89
+ rescue StandardError => e
90
+ Shoko::Adapters::Monitoring::Logger.debug('JsonCacheStore: layout load failed', sha: sha.to_s, key: key.to_s, error: e.message)
91
+ nil
92
+ end
93
+
94
+ def fetch_layouts(sha)
95
+ dir = layouts_dir(sha)
96
+ return {} unless Dir.exist?(dir)
97
+
98
+ Dir.children(dir).each_with_object({}) do |entry, layouts|
99
+ key = layout_key_for_entry(entry)
100
+ next unless key
101
+
102
+ payload = read_layout_payload(dir, entry, sha: sha, key: key)
103
+ layouts[key] = payload if payload
104
+ end
105
+ rescue StandardError => e
106
+ Shoko::Adapters::Monitoring::Logger.debug('JsonCacheStore: layouts fetch failed', sha: sha.to_s, error: e.message)
107
+ {}
108
+ end
109
+
110
+ def chapters_complete?(sha, generation, expected_count:)
111
+ normalized_sha = normalize_sha!(sha)
112
+ gen = normalize_chapter_generation(generation)
113
+ count = normalize_expected_chapter_count(expected_count)
114
+ return false unless gen && count
115
+ return true if count.zero?
116
+
117
+ chapter_files_complete?(normalized_sha, gen, count)
118
+ rescue StandardError => e
119
+ Shoko::Adapters::Monitoring::Logger.debug('JsonCacheStore: chapters completeness check failed',
120
+ sha: sha.to_s, generation: generation.to_s, expected: expected_count.to_i,
121
+ error: e.message)
122
+ false
123
+ end
124
+
125
+ def mutate_layouts(sha)
126
+ layouts = fetch_layouts(sha)
127
+ yield layouts
128
+ write_layouts(sha, layouts)
129
+ true
130
+ rescue StandardError => e
131
+ Shoko::Adapters::Monitoring::Logger.debug('JsonCacheStore: mutate layouts failed', sha: sha.to_s, error: e.message)
132
+ false
133
+ end
134
+
135
+ def delete_payload(sha)
136
+ normalized_sha = normalize_sha!(sha)
137
+ FileUtils.rm_f(payload_path(normalized_sha))
138
+ FileUtils.rm_rf(layouts_dir(normalized_sha))
139
+ FileUtils.rm_rf(resources_dir(normalized_sha))
140
+ FileUtils.rm_rf(chapters_dir(normalized_sha))
141
+ remove_from_manifest(normalized_sha)
142
+ true
143
+ rescue StandardError => e
144
+ Shoko::Adapters::Monitoring::Logger.debug('JsonCacheStore: delete failed', sha: sha.to_s, error: e.message)
145
+ false
146
+ end
147
+
148
+ def list_books
149
+ self.class.manifest_rows(@cache_root)
150
+ rescue StandardError
151
+ []
152
+ end
153
+
154
+ def self.manifest_rows(cache_root)
155
+ read_manifest_file(File.join(cache_root, MANIFEST_FILENAME))
156
+ rescue StandardError
157
+ []
158
+ end
159
+ end
160
+ end
161
+ end
162
+
163
+ require_relative 'json_cache_store/payload_helpers'
164
+ require_relative 'json_cache_store/chapters'
165
+ require_relative 'json_cache_store/layouts'
166
+ require_relative 'json_cache_store/resources'
167
+ require_relative 'json_cache_store/manifest'
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../shared/errors.rb'
4
+
5
+ module Shoko
6
+ module Adapters::Storage
7
+ # Lazily reads a file into a String on first use.
8
+ #
9
+ # Used to avoid loading large chapter XHTML payloads until they are needed.
10
+ # Optionally sanitizes the loaded content before memoizing it.
11
+ class LazyFileString
12
+ def initialize(path, sanitizer: nil, encoding: Encoding::UTF_8)
13
+ @path = path.to_s
14
+ @sanitizer = sanitizer
15
+ @encoding = encoding
16
+ @loaded = nil
17
+ end
18
+
19
+ attr_reader :path
20
+
21
+ def to_s
22
+ load_string
23
+ end
24
+
25
+ def to_str
26
+ load_string
27
+ end
28
+
29
+ def inspect
30
+ "#<#{self.class.name} path=#{path.inspect} loaded=#{!@loaded.nil?}>"
31
+ end
32
+
33
+ def method_missing(name, *, &)
34
+ value = load_string
35
+ return super unless value.respond_to?(name)
36
+
37
+ value.public_send(name, *, &)
38
+ end
39
+
40
+ def respond_to_missing?(name, include_private = false)
41
+ ''.respond_to?(name, include_private) || super
42
+ end
43
+
44
+ private
45
+
46
+ def load_string
47
+ return @loaded if @loaded
48
+
49
+ bytes = File.binread(path)
50
+ bytes.force_encoding(@encoding)
51
+ text = @sanitizer ? @sanitizer.call(bytes) : bytes
52
+ text = text.to_s
53
+ unless text.encoding == Encoding::UTF_8
54
+ text = text.encode(Encoding::UTF_8, invalid: :replace, undef: :replace,
55
+ replace: "\uFFFD")
56
+ end
57
+ @loaded = text
58
+ rescue Shoko::Error
59
+ raise
60
+ rescue StandardError => e
61
+ raise Shoko::CacheLoadError.new(path, e.message)
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'epub_cache'
4
+ require_relative '../monitoring/perf_tracer.rb'
5
+
6
+ module Shoko
7
+ module Adapters::Storage
8
+ # Persists dynamic pagination layouts inside the on-disk cache for a book.
9
+ module PaginationCache
10
+ module_function
11
+
12
+ SCHEMA_VERSION = 3
13
+
14
+ def layout_key(width, height, view_mode, line_spacing, kitty_images: false)
15
+ suffix = kitty_images ? 'img1' : 'img0'
16
+ "#{width}x#{height}_#{view_mode}_#{line_spacing}_#{suffix}"
17
+ end
18
+
19
+ def parse_layout_key(key)
20
+ return nil unless key
21
+
22
+ dims, view_mode, line_spacing, image_mode = key.to_s.split('_', 4)
23
+ width_str, height_str = dims.to_s.split('x', 2)
24
+ return nil unless width_str && height_str && view_mode && line_spacing
25
+
26
+ {
27
+ width: width_str.to_i,
28
+ height: height_str.to_i,
29
+ view_mode: view_mode.to_sym,
30
+ line_spacing: line_spacing.to_sym,
31
+ kitty_images: image_mode.to_s == 'img1',
32
+ }
33
+ rescue StandardError
34
+ nil
35
+ end
36
+
37
+ def load_for_document(doc, key)
38
+ cache = cache_for(doc)
39
+ return nil unless cache
40
+
41
+ data = Adapters::Monitoring::PerfTracer.measure('cache.lookup') { cache.load_layout(key) }
42
+ extract_pages(data)
43
+ rescue StandardError
44
+ nil
45
+ end
46
+
47
+ def save_for_document(doc, key, pages_compact)
48
+ cache = cache_for(doc)
49
+ return false unless cache
50
+
51
+ payload = {
52
+ 'version' => SCHEMA_VERSION,
53
+ 'pages' => pages_compact,
54
+ }
55
+ cache.mutate_layouts! { |layouts| layouts[key] = payload }
56
+ end
57
+
58
+ def delete_for_document(doc, key)
59
+ cache = cache_for(doc)
60
+ return false unless cache
61
+
62
+ cache.mutate_layouts! { |layouts| layouts.delete(key) }
63
+ end
64
+
65
+ def exists_for_document?(doc, key)
66
+ cache = cache_for(doc)
67
+ return false unless cache
68
+
69
+ !!cache.load_layout(key)
70
+ rescue StandardError
71
+ false
72
+ end
73
+
74
+ def layout_keys_for_document(doc)
75
+ cache = cache_for(doc)
76
+ return [] unless cache
77
+
78
+ cache.layout_keys
79
+ rescue StandardError
80
+ []
81
+ end
82
+
83
+ def extract_pages(data)
84
+ return nil unless data.is_a?(Hash)
85
+
86
+ version = data['version'] || data[:version]
87
+ pages = data['pages'] || data[:pages]
88
+ return nil unless pages.is_a?(Array)
89
+ return nil if version && version.to_i != SCHEMA_VERSION
90
+
91
+ pages.map do |entry|
92
+ {
93
+ chapter_index: entry[:chapter_index] || entry['chapter_index'],
94
+ page_in_chapter: entry[:page_in_chapter] || entry['page_in_chapter'],
95
+ total_pages_in_chapter: entry[:total_pages_in_chapter] || entry['total_pages_in_chapter'],
96
+ start_line: entry[:start_line] || entry['start_line'],
97
+ end_line: entry[:end_line] || entry['end_line'],
98
+ }
99
+ end
100
+ end
101
+
102
+ def cache_for(doc)
103
+ path = resolve_cache_path(doc)
104
+ return nil unless path && File.exist?(path)
105
+
106
+ Shoko::Adapters::Storage::EpubCache.new(path)
107
+ rescue Shoko::Error, StandardError
108
+ nil
109
+ end
110
+ private_class_method :cache_for
111
+
112
+ def resolve_cache_path(doc)
113
+ return doc.cache_path if doc.respond_to?(:cache_path) && doc.cache_path && !doc.cache_path.to_s.empty?
114
+
115
+ if doc.respond_to?(:canonical_path) && doc.canonical_path && File.exist?(doc.canonical_path)
116
+ cache = Shoko::Adapters::Storage::EpubCache.new(doc.canonical_path)
117
+ return cache.cache_path if File.exist?(cache.cache_path)
118
+ end
119
+
120
+ nil
121
+ rescue Shoko::Error, StandardError
122
+ nil
123
+ end
124
+ private_class_method :resolve_cache_path
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'time'
6
+ require_relative 'atomic_file_writer'
7
+ require_relative 'config_paths'
8
+ require_relative '../output/terminal/terminal_sanitizer.rb'
9
+
10
+ module Shoko
11
+ module Adapters::Storage
12
+ # Manages a list of recently opened files.
13
+ class RecentFiles
14
+ CONFIG_DIR = Adapters::Storage::ConfigPaths.config_root
15
+ RECENT_FILE = File.join(CONFIG_DIR, 'recent.json')
16
+ MAX_RECENT_FILES = 10
17
+
18
+ class << self
19
+ # Adds a file path to the top of the recent files list.
20
+ #
21
+ # @param path [String] The path to the file to add.
22
+ def add(path)
23
+ recent_files = load.reject { |file| file['path'] == path }
24
+
25
+ raw_label = File.basename(path, File.extname(path)).tr('_-', ' ')
26
+ label = Shoko::Adapters::Output::Terminal::TerminalSanitizer.sanitize(raw_label, preserve_newlines: false, preserve_tabs: false)
27
+
28
+ new_entry = {
29
+ 'path' => path,
30
+ 'name' => label,
31
+ 'accessed' => Time.now.iso8601,
32
+ }
33
+
34
+ save([new_entry, *recent_files].first(MAX_RECENT_FILES))
35
+ end
36
+
37
+ # Loads the list of recent files from disk.
38
+ #
39
+ # @return [Array<Hash>] An array of recent file entries.
40
+ def load
41
+ return [] unless File.exist?(RECENT_FILE)
42
+
43
+ entries = JSON.parse(File.read(RECENT_FILE))
44
+ Array(entries).map do |row|
45
+ next row unless row.is_a?(Hash)
46
+
47
+ safe = row.dup
48
+ safe['name'] =
49
+ Shoko::Adapters::Output::Terminal::TerminalSanitizer.sanitize(safe['name'].to_s, preserve_newlines: false, preserve_tabs: false)
50
+ safe
51
+ end
52
+ rescue JSON::ParserError, Errno::ENOENT
53
+ []
54
+ end
55
+
56
+ # Clears the recent files list by removing the recent file.
57
+ def clear
58
+ FileUtils.rm_f(RECENT_FILE)
59
+ rescue Errno::EACCES, Errno::ENOENT
60
+ # Ignore errors
61
+ end
62
+
63
+ private
64
+
65
+ # Saves the list of recent files to disk.
66
+ #
67
+ # @param recent [Array<Hash>] The list of recent files to save.
68
+ def save(recent)
69
+ FileUtils.mkdir_p(File.dirname(RECENT_FILE))
70
+ payload = JSON.pretty_generate(recent)
71
+ Shoko::Adapters::Storage::AtomicFileWriter.write(RECENT_FILE, payload)
72
+ rescue StandardError
73
+ nil
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_repository'
4
+ require_relative 'storage/annotation_file_store'
5
+
6
+ module Shoko
7
+ module Adapters::Storage::Repositories
8
+ # Repository for annotation persistence, abstracting the underlying storage mechanism.
9
+ #
10
+ # This repository provides a clean domain interface for annotation operations,
11
+ # hiding the file-based persistence details from domain services.
12
+ #
13
+ # @example Adding an annotation
14
+ # repo = AnnotationRepository.new(dependencies)
15
+ # annotation = repo.add_for_book(
16
+ # '/path/to/book.epub',
17
+ # text: 'Selected text',
18
+ # note: 'My note',
19
+ # range: { start: 100, end: 120 },
20
+ # chapter_index: 2
21
+ # )
22
+ #
23
+ # @example Getting annotations for a book
24
+ # annotations = repo.find_by_book_path('/path/to/book.epub')
25
+ class AnnotationRepository < BaseRepository
26
+ def initialize(dependencies)
27
+ super
28
+ file_writer = dependencies.resolve(:file_writer)
29
+ @storage = Storage::AnnotationFileStore.new(file_writer:)
30
+ end
31
+
32
+ # Add a new annotation for a specific book
33
+ #
34
+ # @param book_path [String] Path to the EPUB file
35
+ # @param text [String] The selected text being annotated
36
+ # @param note [String] The annotation note
37
+ # @param range [Hash] Text selection range with :start and :end
38
+ # @param chapter_index [Integer] Chapter index (0-based)
39
+ # @param page_meta [Hash, nil] Optional page metadata
40
+ # @return [Hash] The created annotation data
41
+ def add_for_book(book_path, text:, note:, range:, chapter_index:, page_meta: nil)
42
+ validate_required_params(
43
+ { book_path: book_path, text: text, note: note, range: range,
44
+ chapter_index: chapter_index },
45
+ %i[book_path text note range chapter_index]
46
+ )
47
+
48
+ begin
49
+ @storage.add(book_path, text, note, range, chapter_index, page_meta)
50
+
51
+ # Return the most recently created annotation
52
+ annotations = find_by_book_path(book_path)
53
+ annotations.max_by do |a|
54
+ Time.parse(a['created_at'] || a['updated_at'] || Time.now.iso8601)
55
+ end
56
+ rescue StandardError => e
57
+ handle_storage_error(e, "adding annotation for #{book_path}")
58
+ end
59
+ end
60
+
61
+ # Find all annotations for a specific book
62
+ #
63
+ # @param book_path [String] Path to the EPUB file
64
+ # @return [Array<Hash>] Array of annotation hashes for the book
65
+ def find_by_book_path(book_path)
66
+ validate_required_params({ book_path: book_path }, [:book_path])
67
+
68
+ begin
69
+ @storage.get(book_path) || []
70
+ rescue StandardError => e
71
+ handle_storage_error(e, "loading annotations for #{book_path}")
72
+ end
73
+ end
74
+
75
+ # Find all annotations across all books
76
+ #
77
+ # @return [Hash] Hash mapping book paths to annotation arrays
78
+ def find_all
79
+ @storage.all || {}
80
+ rescue StandardError => e
81
+ handle_storage_error(e, 'loading all annotations')
82
+ end
83
+
84
+ # Update an existing annotation's note
85
+ #
86
+ # @param book_path [String] Path to the EPUB file
87
+ # @param annotation_id [String] ID of the annotation to update
88
+ # @param note [String] New note content
89
+ # @return [Boolean] True if updated successfully
90
+ def update_note(book_path, annotation_id, note)
91
+ validate_required_params(
92
+ { book_path: book_path, annotation_id: annotation_id, note: note },
93
+ %i[book_path annotation_id note]
94
+ )
95
+
96
+ begin
97
+ @storage.update(book_path, annotation_id, note)
98
+ true
99
+ rescue StandardError => e
100
+ handle_storage_error(e, "updating annotation #{annotation_id} for #{book_path}")
101
+ end
102
+ end
103
+
104
+ # Delete a specific annotation
105
+ #
106
+ # @param book_path [String] Path to the EPUB file
107
+ # @param annotation_id [String] ID of the annotation to delete
108
+ # @return [Boolean] True if deleted successfully
109
+ def delete_by_id(book_path, annotation_id)
110
+ validate_required_params(
111
+ { book_path: book_path, annotation_id: annotation_id },
112
+ %i[book_path annotation_id]
113
+ )
114
+
115
+ begin
116
+ @storage.delete(book_path, annotation_id)
117
+ true
118
+ rescue StandardError => e
119
+ handle_storage_error(e, "deleting annotation #{annotation_id} for #{book_path}")
120
+ end
121
+ end
122
+
123
+ # Find a specific annotation by ID
124
+ #
125
+ # @param book_path [String] Path to the EPUB file
126
+ # @param annotation_id [String] ID of the annotation to find
127
+ # @return [Hash, nil] The annotation hash, or nil if not found
128
+ def find_by_id(book_path, annotation_id)
129
+ annotations = find_by_book_path(book_path)
130
+ annotations.find { |a| a['id'] == annotation_id }
131
+ rescue StandardError => e
132
+ handle_storage_error(e, "finding annotation #{annotation_id} for #{book_path}")
133
+ end
134
+
135
+ # Get annotation count for a book
136
+ #
137
+ # @param book_path [String] Path to the EPUB file
138
+ # @return [Integer] Number of annotations for the book
139
+ def count_for_book(book_path)
140
+ find_by_book_path(book_path).size
141
+ rescue StandardError => e
142
+ handle_storage_error(e, "counting annotations for #{book_path}")
143
+ end
144
+
145
+ # Find annotations by chapter
146
+ #
147
+ # @param book_path [String] Path to the EPUB file
148
+ # @param chapter_index [Integer] Chapter index to filter by
149
+ # @return [Array<Hash>] Annotations in the specified chapter
150
+ def find_by_chapter(book_path, chapter_index)
151
+ annotations = find_by_book_path(book_path)
152
+ annotations.select { |a| a['chapter_index'] == chapter_index }
153
+ rescue StandardError => e
154
+ handle_storage_error(e, "finding annotations by chapter for #{book_path}")
155
+ end
156
+
157
+ # Check if any annotations exist at a text range
158
+ #
159
+ # @param book_path [String] Path to the EPUB file
160
+ # @param chapter_index [Integer] Chapter index
161
+ # @param range [Hash] Text range with :start and :end
162
+ # @return [Boolean] True if annotations exist in this range
163
+ def exists_in_range?(book_path, chapter_index, range)
164
+ annotations = find_by_chapter(book_path, chapter_index)
165
+ annotations.any? do |annotation|
166
+ annotation_range = annotation['range']
167
+ next false unless annotation_range
168
+
169
+ # Check for overlap
170
+ annotation_start = annotation_range['start'] || annotation_range[:start]
171
+ annotation_end = annotation_range['end'] || annotation_range[:end]
172
+ range_start = range['start'] || range[:start]
173
+ range_end = range['end'] || range[:end]
174
+
175
+ annotation_start < range_end && range_start < annotation_end
176
+ end
177
+ rescue StandardError => e
178
+ handle_storage_error(e, "checking annotation range for #{book_path}")
179
+ end
180
+ end
181
+ end
182
+ end