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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4da504e314b4ccf14caada8bcdb128ea1e128c3eb391e51ee85ef41529003fb5
4
+ data.tar.gz: 356baf7e71baf08181a92eb4d582c3e21e248ce121414c6ea1313c881f54ca9c
5
+ SHA512:
6
+ metadata.gz: c31d6dac31624b683b5bb672174f5d47c1943d63bd8f2677806e42cd48943f9bd2ac83c73d72f9d4b4ace1416423d4361b00d688b23649b9a80edd7c4c839a27
7
+ data.tar.gz: 52f71139fc47843e8d078ad5fc23a874d8b0807002275781f6bbb99ac01f58b73ca216f2af3218f8905a3070ee5d6726108355d902cc276b760e141e7d4644a7
data/.bundle/config ADDED
@@ -0,0 +1,4 @@
1
+ ---
2
+ BUNDLE_BIN: "bin"
3
+ BUNDLE_PATH: "vendor/bundle"
4
+ BUNDLE_WITHOUT: "development:test"
@@ -0,0 +1,3 @@
1
+ ---
2
+ BUNDLE_BIN: "bin"
3
+ BUNDLE_PATH: "vendor/bundle"
data/.rspec_status ADDED
@@ -0,0 +1,42 @@
1
+ example_id | status | run_time |
2
+ ----------------------------------------------- | ------ | --------------- |
3
+ ./spec/comprehensive_coverage_spec.rb[1:1:1:1] | passed | 0.0089 seconds |
4
+ ./spec/comprehensive_coverage_spec.rb[1:1:1:2] | passed | 0.00391 seconds |
5
+ ./spec/comprehensive_coverage_spec.rb[1:1:1:3] | passed | 0.00181 seconds |
6
+ ./spec/comprehensive_coverage_spec.rb[1:1:1:4] | passed | 0.00271 seconds |
7
+ ./spec/comprehensive_coverage_spec.rb[1:1:1:5] | failed | 0.00894 seconds |
8
+ ./spec/comprehensive_coverage_spec.rb[1:1:1:6] | passed | 0.00246 seconds |
9
+ ./spec/comprehensive_coverage_spec.rb[1:1:1:7] | failed | 0.00194 seconds |
10
+ ./spec/comprehensive_coverage_spec.rb[1:1:1:8] | passed | 0.00167 seconds |
11
+ ./spec/comprehensive_coverage_spec.rb[1:1:1:9] | passed | 0.00327 seconds |
12
+ ./spec/comprehensive_coverage_spec.rb[1:1:1:10] | passed | 0.00172 seconds |
13
+ ./spec/comprehensive_coverage_spec.rb[1:1:1:11] | passed | 0.22362 seconds |
14
+ ./spec/comprehensive_coverage_spec.rb[1:1:2:1] | failed | 0.00262 seconds |
15
+ ./spec/comprehensive_coverage_spec.rb[1:1:2:2] | passed | 0.00312 seconds |
16
+ ./spec/comprehensive_coverage_spec.rb[1:1:2:3] | passed | 0.0047 seconds |
17
+ ./spec/comprehensive_coverage_spec.rb[1:1:3:1] | passed | 0.0022 seconds |
18
+ ./spec/comprehensive_coverage_spec.rb[1:1:3:2] | failed | 0.00193 seconds |
19
+ ./spec/comprehensive_coverage_spec.rb[1:1:3:3] | passed | 0.00264 seconds |
20
+ ./spec/comprehensive_coverage_spec.rb[1:2:1:1] | passed | 0.00124 seconds |
21
+ ./spec/comprehensive_coverage_spec.rb[1:2:1:2] | passed | 0.00094 seconds |
22
+ ./spec/comprehensive_coverage_spec.rb[1:2:1:3] | passed | 0.00128 seconds |
23
+ ./spec/comprehensive_coverage_spec.rb[1:2:1:4] | passed | 0.0018 seconds |
24
+ ./spec/comprehensive_coverage_spec.rb[1:2:2:1] | passed | 0.00142 seconds |
25
+ ./spec/comprehensive_coverage_spec.rb[1:2:2:2] | passed | 0.00105 seconds |
26
+ ./spec/comprehensive_coverage_spec.rb[1:2:3:1] | passed | 0.00189 seconds |
27
+ ./spec/comprehensive_coverage_spec.rb[1:2:3:2] | passed | 0.00288 seconds |
28
+ ./spec/comprehensive_coverage_spec.rb[1:2:4:1] | passed | 0.00163 seconds |
29
+ ./spec/comprehensive_coverage_spec.rb[1:2:4:2] | passed | 0.00181 seconds |
30
+ ./spec/comprehensive_coverage_spec.rb[1:3:1:1] | passed | 0.0228 seconds |
31
+ ./spec/comprehensive_coverage_spec.rb[1:3:1:2] | passed | 0.00391 seconds |
32
+ ./spec/comprehensive_coverage_spec.rb[1:4:1:1] | passed | 0.00306 seconds |
33
+ ./spec/comprehensive_coverage_spec.rb[1:4:1:2] | passed | 0.00191 seconds |
34
+ ./spec/comprehensive_coverage_spec.rb[1:4:1:3] | passed | 0.00109 seconds |
35
+ ./spec/comprehensive_coverage_spec.rb[1:5:1:1] | passed | 0.00094 seconds |
36
+ ./spec/comprehensive_coverage_spec.rb[1:5:1:2] | passed | 0.00254 seconds |
37
+ ./spec/comprehensive_coverage_spec.rb[1:6:1:1] | passed | 0.00089 seconds |
38
+ ./spec/comprehensive_coverage_spec.rb[1:6:1:2] | passed | 0.00071 seconds |
39
+ ./spec/comprehensive_coverage_spec.rb[1:6:1:3] | passed | 0.0591 seconds |
40
+ ./spec/infrastructure_validator_spec.rb[1:1] | passed | 0.00546 seconds |
41
+ ./spec/infrastructure_validator_spec.rb[1:2] | passed | 0.00201 seconds |
42
+ ./spec/infrastructure_validator_spec.rb[1:3] | passed | 0.00107 seconds |
data/.rubocop.yml ADDED
@@ -0,0 +1,124 @@
1
+ plugins:
2
+ - rubocop-performance
3
+ - rubocop-rails
4
+
5
+ AllCops:
6
+ TargetRubyVersion: 3.3
7
+ NewCops: enable
8
+ Exclude:
9
+ - 'vendor/**/*'
10
+ - 'tmp/**/*'
11
+
12
+ # Layout
13
+ Layout/LineLength:
14
+ Max: 120
15
+ Exclude:
16
+ - 'spec/**/*'
17
+ - 'test/**/*'
18
+
19
+ # Metrics
20
+ Metrics/ClassLength:
21
+ Max: 200
22
+ CountComments: false
23
+ Exclude:
24
+ - 'spec/**/*'
25
+ - 'test/**/*'
26
+
27
+ Metrics/ModuleLength:
28
+ Max: 200
29
+ CountComments: false
30
+ Exclude:
31
+ - 'spec/**/*'
32
+ - 'test/**/*'
33
+
34
+ Metrics/MethodLength:
35
+ Max: 15
36
+ CountComments: false
37
+ AllowedMethods:
38
+ - 'initialize'
39
+ Exclude:
40
+ - 'spec/**/*'
41
+ - 'test/**/*'
42
+
43
+ Metrics/AbcSize:
44
+ Max: 20
45
+ Exclude:
46
+ - 'spec/**/*'
47
+ - 'test/**/*'
48
+
49
+ Metrics/CyclomaticComplexity:
50
+ Max: 8
51
+ Exclude:
52
+ - 'spec/**/*'
53
+ - 'test/**/*'
54
+
55
+ Metrics/PerceivedComplexity:
56
+ Max: 9
57
+ Exclude:
58
+ - 'spec/**/*'
59
+ - 'test/**/*'
60
+
61
+ Metrics/ParameterLists:
62
+ Max: 4
63
+ CountKeywordArgs: false
64
+ Exclude:
65
+ - 'spec/**/*'
66
+ - 'test/**/*'
67
+
68
+ Metrics/BlockLength:
69
+ Max: 35
70
+ Exclude:
71
+ - 'spec/**/*'
72
+ - 'test/**/*'
73
+
74
+ # Style
75
+ Style/Documentation:
76
+ Enabled: true
77
+ Exclude:
78
+ - 'spec/**/*'
79
+
80
+ Style/StringLiterals:
81
+ EnforcedStyle: single_quotes
82
+ ConsistentQuotesInMultiline: true
83
+
84
+ Style/FrozenStringLiteralComment:
85
+ Enabled: true
86
+ EnforcedStyle: always
87
+
88
+ Style/GuardClause:
89
+ Enabled: true
90
+ MinBodyLength: 1
91
+
92
+ Style/ModuleFunction:
93
+ Enabled: true
94
+ EnforcedStyle: module_function
95
+
96
+ Style/TrailingCommaInArrayLiteral:
97
+ EnforcedStyleForMultiline: comma
98
+
99
+ Style/TrailingCommaInHashLiteral:
100
+ EnforcedStyleForMultiline: comma
101
+
102
+ # Security
103
+ Security/Eval:
104
+ Enabled: true
105
+
106
+ Security/Open:
107
+ Enabled: true
108
+
109
+ # Performance
110
+ Performance/Count:
111
+ Enabled: true
112
+
113
+ Performance/Detect:
114
+ Enabled: true
115
+
116
+ # Additional cops
117
+ Lint/UselessAssignment:
118
+ Enabled: true
119
+
120
+ Lint/DuplicateMethods:
121
+ Enabled: true
122
+
123
+ Rails:
124
+ Enabled: false
data/Gemfile ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
6
+
7
+ group :development do
8
+ gem 'reek'
9
+ gem 'rubocop', require: false
10
+ gem 'rubocop-performance', require: false
11
+ gem 'rubocop-rails', require: false
12
+ gem 'rubocop-rspec', require: false
13
+ end
14
+
15
+ group :test do
16
+ gem 'fakefs', require: 'fakefs/spec_helpers'
17
+ gem 'rspec'
18
+ gem 'simplecov', require: false
19
+ end
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Shayan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # Shoko
2
+
3
+ Terminal ebook reader for EPUB files.
4
+
5
+ ## What it does
6
+
7
+ - Scans common folders for EPUB files and shows them in a menu.
8
+ - Opens a specific file directly when a path is provided.
9
+ - Reads in split or single view with adjustable line spacing and themes.
10
+ - Provides a TOC sidebar, bookmarks, and annotations.
11
+ - Supports mouse selection for highlighting and annotation editing.
12
+ - Can download public-domain EPUBs from Gutendex.
13
+ - Optional Kitty inline image rendering (when supported).
14
+
15
+ ## How it works
16
+
17
+ - `bin/start` runs the CLI and enters menu mode or opens a file directly.
18
+ - State lives in a single store; actions update state and selectors read it.
19
+ - Rendering is component-based and drawn through a terminal buffer with diff updates.
20
+ - Selection/highlighting uses recorded line geometry from the render pass.
21
+
22
+ ## Usage
23
+
24
+ From source:
25
+
26
+ ```bash
27
+ bundle install
28
+ bin/start
29
+ ```
30
+
31
+ Open a file directly:
32
+
33
+ ```bash
34
+ bin/start /path/to/book.epub
35
+ ```
36
+
37
+ Options:
38
+
39
+ - `-d`, `--debug` Enable debug logging.
40
+ - `--log PATH` Write JSON logs to PATH.
41
+ - `--log-level LEVEL` Set log level (`debug`, `info`, `warn`, `error`, `fatal`).
42
+ - `--profile PATH` Write a concise performance profile to PATH.
43
+ - `-h`, `--help` Show help.
44
+
45
+ ## Controls (basics)
46
+
47
+ Menu:
48
+
49
+ - `j`/`k` or arrow keys to move
50
+ - `Enter` to select
51
+ - `Esc` to go back
52
+ - `/` to search in browse mode
53
+ - `q` to quit
54
+
55
+ Reader:
56
+
57
+ - `h`/`l` or arrow keys to change pages
58
+ - `j`/`k` to scroll
59
+ - `Space` for next page
60
+ - `t` for TOC
61
+ - `b` to add bookmark, `B` to open bookmarks
62
+ - `A` to open annotations
63
+ - `?` for help
64
+ - `q` to return to menu, `Q` to quit
65
+
66
+ ## Data locations
67
+
68
+ - Config and data: `~/.config/shoko/`
69
+ - `config.json`
70
+ - `annotations.json`, `bookmarks.json`, `progress.json`, `recent.json`
71
+ - `downloads/` (Gutendex downloads)
72
+ - `epub_cache.json` (scan cache)
73
+ - Cache: `~/.cache/shoko/`
74
+
75
+ ## Logging and profiling
76
+
77
+ You can also configure logging with environment variables:
78
+
79
+ - `DEBUG=1` Enable debug logging.
80
+ - `SHOKO_LOG_PATH=/path/to/log` Write JSON logs to a file.
81
+ - `SHOKO_LOG_LEVEL=info` Set log level.
82
+ - `SHOKO_PROFILE_PATH=/path/to/profile` Write a performance profile.
data/Rakefile ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+ require 'rubocop/rake_task'
6
+
7
+ RSpec::Core::RakeTask.new(:spec)
8
+ RuboCop::RakeTask.new
9
+
10
+ desc 'Run all quality checks'
11
+ task quality: %i[spec rubocop]
12
+
13
+ task default: :quality
14
+
15
+ namespace :test do
16
+ desc 'Run tests with coverage'
17
+ task :coverage do
18
+ ENV['COVERAGE'] = 'true'
19
+ Rake::Task['spec'].invoke
20
+ end
21
+ end
22
+
23
+ desc 'Console with library loaded'
24
+ task :console do
25
+ require 'irb'
26
+ require 'shoko'
27
+ ARGV.clear
28
+ IRB.start
29
+ end
data/bin/start ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
5
+
6
+ begin
7
+ require "bundler/setup"
8
+ rescue LoadError
9
+ # Bundler isn't required when running from a packaged gem.
10
+ end
11
+
12
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
13
+ require "shoko"
14
+
15
+ Shoko::CLI.run(ARGV)
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../monitoring/performance_monitor.rb'
4
+ require_relative 'epub_document'
5
+
6
+ module Shoko
7
+ module Adapters::BookSources
8
+ # Document service for loading and accessing EPUB content.
9
+ # Provides clean interface to document operations without coupling to controllers.
10
+ class DocumentService
11
+ def initialize(epub_path, wrapping_service = nil, formatting_service: nil, background_worker: nil,
12
+ progress_reporter: nil)
13
+ @epub_path = epub_path
14
+ @document = nil
15
+ @content_cache = {}
16
+ @wrapping_service = wrapping_service
17
+ @formatting_service = formatting_service
18
+ @background_worker = background_worker
19
+ @progress_reporter = progress_reporter
20
+ end
21
+
22
+ # Load the EPUB document
23
+ #
24
+ # @return [EPUBDocument] Loaded document
25
+ def load_document
26
+ @document ||= Adapters::Monitoring::PerformanceMonitor.time('import.document.load') do
27
+ EPUBDocument.new(@epub_path,
28
+ formatting_service: @formatting_service,
29
+ background_worker: @background_worker,
30
+ progress_reporter: @progress_reporter)
31
+ end
32
+ rescue StandardError => e
33
+ Adapters::Monitoring::Logger.error('Failed to load document', path: @epub_path, error: e.message)
34
+ create_error_document(e.message)
35
+ end
36
+
37
+ # Get chapter by index
38
+ #
39
+ # @param index [Integer] Chapter index
40
+ # @return [Chapter] Chapter object or nil
41
+ def chapter_at(index)
42
+ return nil unless @document
43
+
44
+ @document.get_chapter(index)
45
+ end
46
+
47
+ # Get table of contents
48
+ #
49
+ # @return [Array<Hash>] Array of TOC entries
50
+ def get_table_of_contents
51
+ return [] unless @document
52
+
53
+ @document.chapters.map.with_index do |chapter, index|
54
+ {
55
+ index: index,
56
+ title: chapter.title || "Chapter #{index + 1}",
57
+ level: 0, # Could be enhanced to support nested TOC
58
+ }
59
+ end
60
+ end
61
+
62
+ # Get content for specific page
63
+ #
64
+ # @param chapter_index [Integer] Chapter index
65
+ # @param page_offset [Integer] Page offset within chapter
66
+ # @param lines_per_page [Integer] Number of lines per page
67
+ # @return [Array<String>] Array of content lines
68
+ def get_page_content(chapter_index, page_offset, lines_per_page = 20)
69
+ cache_key = "#{chapter_index}_#{page_offset}_#{lines_per_page}"
70
+ cached_fetch(cache_key, default: []) do
71
+ with_chapter(chapter_index, default: []) do |chapter|
72
+ lines = chapter.lines || []
73
+ start_line = page_offset * lines_per_page
74
+ end_line = start_line + lines_per_page - 1
75
+ lines[start_line..end_line] || []
76
+ end
77
+ end
78
+ end
79
+
80
+ # Get wrapped content for specific page
81
+ #
82
+ # @param chapter_index [Integer] Chapter index
83
+ # @param page_offset [Integer] Page offset within chapter
84
+ # @param column_width [Integer] Column width for wrapping
85
+ # @param lines_per_page [Integer] Number of lines per page
86
+ # @return [Array<String>] Array of wrapped content lines
87
+ def get_wrapped_page_content(chapter_index, page_offset, column_width, lines_per_page = 20)
88
+ cache_key = "wrapped_#{chapter_index}_#{page_offset}_#{column_width}_#{lines_per_page}"
89
+ cached_fetch(cache_key, default: []) do
90
+ with_chapter(chapter_index, default: []) do |chapter|
91
+ lines = chapter.lines || []
92
+ wrapped_lines = wrap_lines(chapter_index, lines, column_width)
93
+ start_line = page_offset * lines_per_page
94
+ end_line = start_line + lines_per_page - 1
95
+ wrapped_lines[start_line..end_line] || []
96
+ end
97
+ end
98
+ end
99
+
100
+ # Get total wrapped lines for chapter
101
+ #
102
+ # @param chapter_index [Integer] Chapter index
103
+ # @param column_width [Integer] Column width for wrapping
104
+ # @return [Integer] Total wrapped lines
105
+ def get_chapter_wrapped_line_count(chapter_index, column_width)
106
+ cache_key = "line_count_#{chapter_index}_#{column_width}"
107
+ cached_fetch(cache_key, default: 0) do
108
+ with_chapter(chapter_index, default: 0) do |chapter|
109
+ lines = chapter.lines || []
110
+ wrapped_lines = wrap_lines(chapter_index, lines, column_width)
111
+ wrapped_lines.size
112
+ end
113
+ end
114
+ end
115
+
116
+ # Clear content cache
117
+ def clear_cache
118
+ @content_cache.clear
119
+ end
120
+
121
+ # Clear cache for specific width
122
+ #
123
+ # @param width [Integer] Column width
124
+ def clear_cache_for_width(width)
125
+ @content_cache.delete_if { |key, _| key.include?("_#{width}_") }
126
+ end
127
+
128
+ private
129
+
130
+ def create_error_document(error_message)
131
+ # Create a simple document with error information
132
+ ErrorDocument.new(error_message)
133
+ end
134
+
135
+ def wrap_lines(chapter_index, lines, column_width)
136
+ return lines if column_width <= 0
137
+ return @wrapping_service.wrap_lines(lines, chapter_index, column_width) if @wrapping_service
138
+
139
+ # Minimal fallback for tests/dev without DI
140
+ lines
141
+ end
142
+
143
+ def cached_fetch(key, default: nil)
144
+ return @content_cache[key] if @content_cache.key?(key)
145
+
146
+ value = yield
147
+ value = default if value.nil?
148
+ @content_cache[key] = value
149
+ value
150
+ end
151
+
152
+ def with_chapter(index, default: nil)
153
+ chapter = chapter_at(index)
154
+ return default unless chapter
155
+
156
+ yield chapter
157
+ end
158
+ end
159
+
160
+ # Simple error document for when EPUB loading fails
161
+ class ErrorDocument
162
+ attr_reader :error_message
163
+
164
+ def initialize(error_message)
165
+ @error_message = error_message
166
+ end
167
+
168
+ def chapter_count
169
+ 1
170
+ end
171
+
172
+ def get_chapter(index)
173
+ return nil unless index.zero?
174
+
175
+ ErrorChapter.new(@error_message)
176
+ end
177
+
178
+ def chapters
179
+ [get_chapter(0)]
180
+ end
181
+ end
182
+
183
+ # Simple error chapter
184
+ class ErrorChapter
185
+ attr_reader :title, :lines
186
+
187
+ def initialize(error_message)
188
+ @title = 'Error Loading Book'
189
+ @lines = [
190
+ 'Failed to load the EPUB file:',
191
+ '',
192
+ error_message,
193
+ '',
194
+ 'Please check that the file exists and is a valid EPUB.',
195
+ '',
196
+ "Press 'q' to return to the main menu.",
197
+ ]
198
+ end
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require_relative '../../core/services/base_service.rb'
5
+ require_relative '../storage/config_paths'
6
+
7
+ module Shoko
8
+ module Adapters::BookSources
9
+ # Coordinates Gutendex search + download to the local library.
10
+ class DownloadService < BaseService
11
+ class DownloadError < StandardError; end
12
+
13
+ def search(query:, page_url: nil)
14
+ payload = client.search(query: query, page_url: page_url)
15
+ {
16
+ count: payload['count'].to_i,
17
+ next: payload['next'],
18
+ previous: payload['previous'],
19
+ books: normalize_books(payload['results']),
20
+ }
21
+ end
22
+
23
+ def download(book)
24
+ url = pick_download_url(book)
25
+ raise DownloadError, 'No EPUB format available' unless url
26
+
27
+ dest_dir = downloads_root
28
+ FileUtils.mkdir_p(dest_dir)
29
+ dest_path = File.join(dest_dir, filename_for(book))
30
+ return { path: dest_path, existing: true } if File.exist?(dest_path)
31
+
32
+ client.download(url, dest_path) { |done, total| yield(done, total) if block_given? }
33
+ { path: dest_path, existing: false }
34
+ end
35
+
36
+ protected
37
+
38
+ def required_dependencies
39
+ [:gutendex_client]
40
+ end
41
+
42
+ private
43
+
44
+ def client
45
+ @client ||= resolve(:gutendex_client)
46
+ end
47
+
48
+ def downloads_root
49
+ Adapters::Storage::ConfigPaths.downloads_root
50
+ end
51
+
52
+ def normalize_books(items)
53
+ Array(items).map do |raw|
54
+ {
55
+ id: raw['id'],
56
+ title: raw['title'],
57
+ authors: Array(raw['authors']).filter_map { |a| a['name'] },
58
+ languages: Array(raw['languages']).map(&:to_s),
59
+ download_count: raw['download_count'],
60
+ formats: raw['formats'] || {},
61
+ }
62
+ end
63
+ end
64
+
65
+ def pick_download_url(book)
66
+ formats = value_for(book, :formats, 'formats', {})
67
+ return nil unless formats.respond_to?(:each)
68
+
69
+ keys = formats.keys.map(&:to_s)
70
+ epub_key = keys.find { |k| k.start_with?('application/epub+zip') } ||
71
+ keys.find { |k| k.include?('application/epub') } ||
72
+ keys.find { |k| k.include?('epub') }
73
+ return nil unless epub_key
74
+
75
+ formats[epub_key] || formats[epub_key.to_sym]
76
+ end
77
+
78
+ def filename_for(book)
79
+ id = value_for(book, :id, 'id', 'book').to_s
80
+ title = value_for(book, :title, 'title', 'book').to_s
81
+ slug = title.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/^-|-$/, '')
82
+ slug = "book-#{id}" if slug.empty?
83
+ "#{slug}-#{id}.epub"
84
+ end
85
+
86
+ def value_for(book, key_sym, key_str, default)
87
+ return book[key_sym] if book.respond_to?(:key?) && book.key?(key_sym)
88
+ return book[key_str] if book.respond_to?(:key?) && book.key?(key_str)
89
+
90
+ default
91
+ end
92
+
93
+ end
94
+ end
95
+ end