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,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ require_relative 'atomic_file_writer'
6
+ require_relative '../monitoring/logger.rb'
7
+
8
+ module Shoko
9
+ module Adapters::Storage
10
+ # Manages pointer files that reference serialized cache payloads on disk.
11
+ class CachePointerManager
12
+ POINTER_FORMAT = 'shoko-cache'
13
+ LEGACY_POINTER_FORMATS = ['reader-cache', 'reader-marshal-cache'].freeze
14
+ POINTER_VERSION = 2
15
+ POINTER_KEYS = %w[format version sha256 source_path generated_at engine].freeze
16
+ SUPPORTED_FORMATS = [POINTER_FORMAT, *LEGACY_POINTER_FORMATS].freeze
17
+ SUPPORTED_ENGINES = %w[json marshal].freeze
18
+ SHA256_HEX_PATTERN = /\A[0-9a-f]{64}\z/i
19
+
20
+ def initialize(path)
21
+ @path = path
22
+ end
23
+
24
+ attr_reader :path
25
+
26
+ def read
27
+ return nil unless File.exist?(path)
28
+
29
+ content = File.read(path)
30
+ return nil if content.nil? || content.empty?
31
+
32
+ data = JSON.parse(content)
33
+ return nil unless valid_pointer?(data)
34
+
35
+ data
36
+ rescue JSON::ParserError
37
+ nil
38
+ end
39
+
40
+ def write(data)
41
+ AtomicFileWriter.write_using(path) do |io|
42
+ io.write(JSON.generate(data))
43
+ end
44
+ rescue StandardError => e
45
+ Shoko::Adapters::Monitoring::Logger.debug('CachePointerManager: write failed', path:, error: e.message)
46
+ false
47
+ end
48
+
49
+ private
50
+
51
+ def valid_pointer?(data)
52
+ POINTER_KEYS.all? { |key| data.key?(key) } &&
53
+ SUPPORTED_FORMATS.include?(data['format'].to_s) &&
54
+ SUPPORTED_ENGINES.include?(data['engine'].to_s) &&
55
+ data['version'].to_i == POINTER_VERSION &&
56
+ data['sha256'].to_s.match?(SHA256_HEX_PATTERN)
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shoko
4
+ module Adapters::Storage
5
+ # Resolves XDG-aware configuration paths for Shoko.
6
+ module ConfigPaths
7
+ module_function
8
+
9
+ # Root directory for Shoko config: ${XDG_CONFIG_HOME:-~/.config}/shoko
10
+ def config_root
11
+ env_home = ENV.fetch('XDG_CONFIG_HOME', nil)
12
+ config_root = if env_home && !env_home.empty?
13
+ env_home
14
+ else
15
+ File.join(Dir.home, '.config')
16
+ end
17
+ File.join(config_root, 'shoko')
18
+ end
19
+
20
+ # Downloaded books directory under config root.
21
+ def downloads_root
22
+ File.join(config_root, 'downloads')
23
+ end
24
+
25
+ def config_path(*segments)
26
+ File.join(config_root, *segments)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'time'
5
+
6
+ require_relative '../../core/models/chapter.rb'
7
+ require_relative '../../core/models/toc_entry.rb'
8
+ require_relative '../../core/models/content_block.rb'
9
+ require_relative '../../shared/errors.rb'
10
+ require_relative '../output/terminal/terminal_sanitizer.rb'
11
+ require_relative 'cache_paths'
12
+ require_relative 'json_cache_store'
13
+ require_relative 'cache_pointer_manager'
14
+ require_relative 'lazy_file_string'
15
+ require_relative '../monitoring/logger.rb'
16
+
17
+ module Shoko
18
+ module Adapters::Storage
19
+ # JSON-backed cache for imported EPUB data and derived pagination layouts.
20
+ # Pointer files keep lightweight `.cache` discovery while the bulk payload
21
+ # lives in JSON + binary blobs.
22
+ class EpubCache
23
+ CACHE_VERSION = 3
24
+ CACHE_EXTENSION = '.cache'
25
+ SHA256_HEX_PATTERN = /\A[0-9a-f]{64}\z/i
26
+
27
+ # Immutable representation of the persisted cache payload.
28
+ CachePayload = Struct.new(
29
+ :version,
30
+ :source_sha256,
31
+ :source_path,
32
+ :source_mtime,
33
+ :generated_at,
34
+ :book,
35
+ :layouts,
36
+ keyword_init: true
37
+ )
38
+
39
+ # Normalized in-memory representation of a parsed EPUB.
40
+ BookData = Struct.new(
41
+ :title,
42
+ :language,
43
+ :authors,
44
+ :chapters,
45
+ :toc_entries,
46
+ :opf_path,
47
+ :spine,
48
+ :chapter_hrefs,
49
+ :resources,
50
+ :metadata,
51
+ :container_path,
52
+ :container_xml,
53
+ :chapters_generation,
54
+ keyword_init: true
55
+ )
56
+
57
+ class << self
58
+ def cache_extension = CACHE_EXTENSION
59
+
60
+ def cache_file?(path)
61
+ File.file?(path) && File.extname(path).casecmp(CACHE_EXTENSION).zero?
62
+ end
63
+
64
+ def cache_path_for_sha(sha, cache_root: CachePaths.cache_root)
65
+ normalized = sha.to_s.strip
66
+ return nil unless normalized.match?(SHA256_HEX_PATTERN)
67
+
68
+ File.join(cache_root, "#{normalized.downcase}#{CACHE_EXTENSION}")
69
+ end
70
+ end
71
+
72
+ attr_reader :cache_path, :source_path
73
+
74
+ def initialize(path, cache_root: CachePaths.cache_root, store: nil)
75
+ @cache_root = cache_root
76
+ @cache_store = store || JsonCacheStore.new(cache_root: @cache_root)
77
+ @raw_path = File.expand_path(path)
78
+ @payload_cache = nil
79
+ @layout_cache = {}
80
+ @pointer_metadata = nil
81
+ setup_source_reference
82
+ end
83
+
84
+ # Load pointer payload without validating source. Used by cached-library
85
+ # direct opens.
86
+ def read_cache(strict: false)
87
+ payload = load_payload
88
+ return nil unless payload
89
+
90
+ return payload unless strict
91
+
92
+ payload_valid?(payload) ? payload : invalidate_and_nil
93
+ rescue Shoko::CacheLoadError
94
+ nil
95
+ end
96
+
97
+ # Load payload and ensure it matches the original EPUB file.
98
+ def load_for_source(strict: false)
99
+ payload = load_payload
100
+ return nil unless payload
101
+
102
+ if payload_valid?(payload) && payload_matches_source?(payload, strict:)
103
+ payload
104
+ else
105
+ invalidate_and_nil
106
+ end
107
+ end
108
+
109
+ def write_book!(book_data)
110
+ ensure_sha!
111
+ return nil unless persist_payload(book_data, layouts_hash: {})
112
+
113
+ @layout_cache = {}
114
+ @payload_cache = load_payload_from_store(@source_sha)
115
+ rescue StandardError => e
116
+ Shoko::Adapters::Monitoring::Logger.debug('EpubCache: failed to write cache', path: @cache_path, error: e.message)
117
+ nil
118
+ end
119
+
120
+ def load_layout(key)
121
+ key_str = key.to_s
122
+ return deep_dup(@layout_cache[key_str]) if @layout_cache.key?(key_str)
123
+
124
+ payload = @cache_store.load_layout(@source_sha, key_str)
125
+ return nil unless payload
126
+
127
+ cache_layout!(key_str, payload)
128
+ deep_dup(payload)
129
+ rescue StandardError
130
+ nil
131
+ end
132
+
133
+ def mutate_layouts!
134
+ ensure_sha!
135
+ updated_layouts = nil
136
+ success = @cache_store.mutate_layouts(@source_sha) do |layouts|
137
+ yield layouts
138
+ updated_layouts = layouts
139
+ end
140
+ update_layout_cache_from_layouts(updated_layouts) if success
141
+ success
142
+ rescue StandardError => e
143
+ Shoko::Adapters::Monitoring::Logger.debug('EpubCache: failed to update layouts', path: @cache_path, error: e.message)
144
+ false
145
+ end
146
+
147
+ def invalidate!
148
+ ensure_sha!
149
+ @cache_store.delete_payload(@source_sha) if @source_sha
150
+ FileUtils.rm_f(@cache_path) if @cache_path && File.exist?(@cache_path)
151
+ ensure
152
+ @payload_cache = nil
153
+ @layout_cache = {}
154
+ @pointer_metadata = nil
155
+ end
156
+
157
+ def cache_file?
158
+ @source_type == :cache_pointer
159
+ end
160
+
161
+ def sha256
162
+ ensure_sha!
163
+ @source_sha
164
+ end
165
+
166
+ def layout_keys
167
+ ensure_sha!
168
+ keys = @cache_store.fetch_layouts(@source_sha).keys
169
+ keys |= @layout_cache.keys
170
+ keys
171
+ rescue StandardError
172
+ []
173
+ end
174
+
175
+ def chapters_complete?(expected_count, generation: nil)
176
+ ensure_sha!
177
+ gen = generation
178
+ if gen.nil? && @payload_cache&.book.respond_to?(:chapters_generation)
179
+ gen = @payload_cache.book.chapters_generation
180
+ end
181
+ return false if gen.to_s.strip.empty?
182
+
183
+ @cache_store.respond_to?(:chapters_complete?) &&
184
+ @cache_store.chapters_complete?(@source_sha, gen, expected_count: expected_count)
185
+ rescue StandardError
186
+ false
187
+ end
188
+ end
189
+ end
190
+ end
191
+
192
+ require_relative 'cache/epub/serializer'
193
+ require_relative 'cache/epub/source_reference'
194
+ require_relative 'cache/epub/memory_cache'
195
+ require_relative 'cache/epub/persistence'
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require_relative '../../core/services/base_service.rb'
5
+
6
+ module Shoko
7
+ module Adapters::Storage
8
+ # Provides atomic file writing for domain repositories without coupling them
9
+ # to infrastructure implementations.
10
+ class FileWriterService < BaseService
11
+ def initialize(dependencies)
12
+ super
13
+ @writer = resolve_optional(:atomic_file_writer)
14
+ end
15
+
16
+ # Write payload to path atomically when possible.
17
+ #
18
+ # Ensures the target directory exists before delegating to the underlying writer.
19
+ def write(path, payload)
20
+ dir = File.dirname(path)
21
+ FileUtils.mkdir_p(dir)
22
+
23
+ if @writer.respond_to?(:write)
24
+ @writer.write(path, payload)
25
+ else
26
+ default_write(path, payload)
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def resolve_optional(name)
33
+ resolve(name)
34
+ rescue StandardError
35
+ nil
36
+ end
37
+
38
+ def default_write(path, payload)
39
+ tmp = "#{path}.tmp"
40
+ File.write(tmp, payload)
41
+ FileUtils.mv(tmp, path)
42
+ ensure
43
+ FileUtils.rm_f(tmp) if tmp && File.exist?(tmp)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shoko
4
+ module Adapters::Storage
5
+ # Chapter persistence helpers for `JsonCacheStore`.
6
+ class JsonCacheStore
7
+ CHAPTER_ROW_EXCLUDED_KEYS = %w[raw_content lines_json blocks_json].freeze
8
+
9
+ private
10
+
11
+ def chapters_dir(sha)
12
+ File.join(@cache_root, CHAPTERS_DIRNAME, normalize_sha!(sha))
13
+ end
14
+
15
+ def chapter_generation_dir(sha, generation)
16
+ gen = generation.to_s.strip
17
+ raise ArgumentError, 'chapter generation is invalid' unless CHAPTERS_GENERATION_PATTERN.match?(gen)
18
+
19
+ File.join(chapters_dir(sha), gen.downcase)
20
+ end
21
+
22
+ def chapter_raw_dir(sha, generation)
23
+ File.join(chapter_generation_dir(sha, generation), CHAPTERS_RAW_DIRNAME)
24
+ end
25
+
26
+ def chapter_raw_file(sha, generation, position)
27
+ idx = Integer(position)
28
+ raise ArgumentError, 'chapter position must be >= 0' if idx.negative?
29
+
30
+ name = format("%0#{CHAPTER_FILENAME_DIGITS}d.xhtml", idx)
31
+ File.join(chapter_raw_dir(sha, generation), name)
32
+ end
33
+
34
+ def normalize_chapter_generation(generation)
35
+ gen = generation.to_s.strip.downcase
36
+ CHAPTERS_GENERATION_PATTERN.match?(gen) ? gen : nil
37
+ end
38
+
39
+ def normalize_expected_chapter_count(expected_count)
40
+ count = expected_count.to_i
41
+ return nil if count.negative?
42
+ return nil if count > MAX_CHAPTER_COUNT
43
+
44
+ count
45
+ end
46
+
47
+ def chapter_files_complete?(sha, generation, expected_count)
48
+ raw_dir = chapter_raw_dir(sha, generation)
49
+ return false unless Dir.exist?(raw_dir)
50
+
51
+ expected_count.times do |idx|
52
+ return false unless File.file?(chapter_raw_file(sha, generation, idx))
53
+ end
54
+ true
55
+ end
56
+
57
+ def persist_chapters(sha, chapter_rows)
58
+ chapter_rows = Array(chapter_rows)
59
+ generation = new_chapter_generation
60
+ return [[], generation, 0] if chapter_rows.empty?
61
+
62
+ FileUtils.mkdir_p(chapter_raw_dir(sha, generation))
63
+ rows, total_bytes = persist_chapter_rows(sha, generation, chapter_rows)
64
+ [rows, generation, total_bytes]
65
+ end
66
+
67
+ def new_chapter_generation
68
+ SecureRandom.hex(CHAPTERS_GENERATION_BYTES)
69
+ end
70
+
71
+ def persist_chapter_rows(sha, generation, chapter_rows)
72
+ rows = []
73
+ total_bytes = 0
74
+ chapter_rows.each do |row|
75
+ filtered, bytesize = persist_chapter_row(sha, generation, row)
76
+ rows << filtered
77
+ total_bytes += bytesize
78
+ end
79
+ [rows, total_bytes]
80
+ end
81
+
82
+ def persist_chapter_row(sha, generation, row)
83
+ idx = chapter_row_index(row)
84
+ text = chapter_row_raw_content(row).to_s
85
+ AtomicFileWriter.write(chapter_raw_file(sha, generation, idx), text)
86
+ [filtered_chapter_index_row(row, idx), text.bytesize]
87
+ end
88
+
89
+ def chapter_row_index(row)
90
+ raise ArgumentError, 'chapter row must be a Hash' unless row.is_a?(Hash)
91
+
92
+ position = row[:position] || row['position']
93
+ idx = Integer(position)
94
+ raise ArgumentError, 'chapter position must be >= 0' if idx.negative?
95
+
96
+ idx
97
+ end
98
+
99
+ def chapter_row_raw_content(row)
100
+ row[:raw_content] || row['raw_content']
101
+ end
102
+
103
+ def filtered_chapter_index_row(row, idx)
104
+ filtered = {}
105
+ row.each do |key, value|
106
+ key_str = key.to_s
107
+ next if CHAPTER_ROW_EXCLUDED_KEYS.include?(key_str)
108
+
109
+ filtered[key_str] = value
110
+ end
111
+ filtered['position'] = idx
112
+ filtered
113
+ end
114
+
115
+ def cleanup_old_chapter_generations(sha, keep:)
116
+ base = chapters_dir(sha)
117
+ return unless Dir.exist?(base)
118
+
119
+ keep_name = keep.to_s.strip.downcase
120
+ Dir.children(base).each do |entry|
121
+ next if entry == keep_name
122
+
123
+ path = File.join(base, entry)
124
+ next unless File.directory?(path)
125
+ next unless CHAPTERS_GENERATION_PATTERN.match?(entry)
126
+
127
+ FileUtils.rm_rf(path)
128
+ end
129
+ rescue StandardError
130
+ nil
131
+ end
132
+
133
+ def cleanup_failed_chapter_generation(sha, generation)
134
+ path = chapter_generation_dir(sha, generation)
135
+ FileUtils.rm_rf(path)
136
+ rescue StandardError
137
+ nil
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shoko
4
+ module Adapters::Storage
5
+ # Layout storage helpers for `JsonCacheStore`.
6
+ class JsonCacheStore
7
+ private
8
+
9
+ def layouts_dir(sha)
10
+ File.join(@cache_root, 'layouts', normalize_sha!(sha))
11
+ end
12
+
13
+ def layout_file(sha, key)
14
+ File.join(layouts_dir(sha), "#{normalize_layout_key!(key)}.json")
15
+ end
16
+
17
+ def layout_key_for_entry(entry)
18
+ return nil unless entry.end_with?('.json')
19
+
20
+ key = entry.delete_suffix('.json')
21
+ layout_key_valid?(key) ? key : nil
22
+ end
23
+
24
+ def read_layout_payload(dir, entry, sha:, key:)
25
+ JSON.parse(File.read(File.join(dir, entry)))
26
+ rescue StandardError => e
27
+ Shoko::Adapters::Monitoring::Logger.debug('JsonCacheStore: layout parse failed', sha: sha.to_s, key: key.to_s, error: e.message)
28
+ nil
29
+ end
30
+
31
+ def write_layouts(sha, layouts_hash)
32
+ dir = layouts_dir(sha)
33
+ FileUtils.mkdir_p(dir)
34
+ existing = Dir.exist?(dir) ? Dir.children(dir).select { |entry| entry.end_with?('.json') } : []
35
+
36
+ written = []
37
+ layouts_hash.each do |key, payload|
38
+ normalized_key = normalize_layout_key!(key)
39
+ file = File.join(dir, "#{normalized_key}.json")
40
+ AtomicFileWriter.write(file, JSON.generate(payload))
41
+ written << "#{normalized_key}.json"
42
+ end
43
+
44
+ stale = existing - written
45
+ stale.each { |entry| FileUtils.rm_f(File.join(dir, entry)) }
46
+ end
47
+
48
+ def layout_key_valid?(key)
49
+ normalize_layout_key!(key)
50
+ true
51
+ rescue ArgumentError
52
+ false
53
+ end
54
+
55
+ def normalize_layout_key!(key)
56
+ value = key.to_s
57
+ raise ArgumentError, 'layout key is blank' if value.empty?
58
+ raise ArgumentError, 'layout key too long' if value.bytesize > MAX_LAYOUT_KEY_BYTES
59
+ raise ArgumentError, 'layout key contains null byte' if value.include?("\0")
60
+ raise ArgumentError, 'layout key contains path separator' if value.include?('/') || value.include?('\\')
61
+ raise ArgumentError, 'layout key has invalid characters' unless LAYOUT_KEY_PATTERN.match?(value)
62
+
63
+ value
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shoko
4
+ module Adapters::Storage
5
+ # Manifest helpers for `JsonCacheStore` (cache listing).
6
+ class JsonCacheStore
7
+ private
8
+
9
+ def manifest_path
10
+ File.join(@cache_root, MANIFEST_FILENAME)
11
+ end
12
+
13
+ def update_manifest(metadata_row, cache_size_bytes:)
14
+ row = metadata_row.merge('cache_size_bytes' => cache_size_bytes.to_i)
15
+ manifest = self.class.manifest_rows(@cache_root)
16
+ manifest.reject! { |entry| entry['source_sha'] == row['source_sha'] }
17
+ manifest << row
18
+ AtomicFileWriter.write(manifest_path, JSON.generate(manifest))
19
+ rescue StandardError => e
20
+ Shoko::Adapters::Monitoring::Logger.debug('JsonCacheStore: manifest write failed', error: e.message)
21
+ end
22
+
23
+ def remove_from_manifest(sha)
24
+ manifest = self.class.manifest_rows(@cache_root)
25
+ manifest.reject! { |entry| entry['source_sha'] == sha }
26
+ AtomicFileWriter.write(manifest_path, JSON.generate(manifest))
27
+ rescue StandardError
28
+ nil
29
+ end
30
+
31
+ def self.read_manifest_file(path)
32
+ return [] unless File.file?(path)
33
+
34
+ data = JSON.parse(File.read(path))
35
+ data.is_a?(Array) ? data : []
36
+ rescue StandardError
37
+ []
38
+ end
39
+ private_class_method :read_manifest_file
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shoko
4
+ module Adapters::Storage
5
+ # Payload IO + normalization helpers for `JsonCacheStore`.
6
+ class JsonCacheStore
7
+ private
8
+
9
+ def payload_path(sha)
10
+ File.join(@cache_root, "#{normalize_sha!(sha)}.json")
11
+ end
12
+
13
+ def load_payload_data(sha)
14
+ path = payload_path(sha)
15
+ return nil unless File.file?(path)
16
+
17
+ data = JSON.parse(File.read(path))
18
+ valid_payload_file?(data) ? data : nil
19
+ end
20
+
21
+ def valid_payload_file?(data)
22
+ return false unless data.is_a?(Hash)
23
+ return false unless payload_header_valid?(data)
24
+
25
+ metadata_row = data['metadata_row']
26
+ return false unless metadata_row.is_a?(Hash)
27
+ return false unless payload_metadata_valid?(metadata_row)
28
+ return false unless payload_collections_valid?(data)
29
+
30
+ true
31
+ rescue StandardError
32
+ false
33
+ end
34
+
35
+ def payload_header_valid?(data)
36
+ data['format'] == FORMAT &&
37
+ data['format_version'].to_i == FORMAT_VERSION &&
38
+ data['engine'].to_s == ENGINE
39
+ end
40
+
41
+ def payload_metadata_valid?(metadata_row)
42
+ CHAPTERS_GENERATION_PATTERN.match?(metadata_row['chapters_generation'].to_s)
43
+ end
44
+
45
+ def payload_collections_valid?(data)
46
+ data['chapters'].is_a?(Array) && data['resources'].is_a?(Array)
47
+ end
48
+
49
+ def build_metadata_row(serialized_book, normalized_sha, source_path:, source_mtime:, generated_at:)
50
+ now = Time.now.utc.to_f
51
+ stringify_keys(serialized_book).merge(
52
+ 'source_sha' => normalized_sha,
53
+ 'source_path' => source_path,
54
+ 'source_mtime' => source_mtime&.to_f,
55
+ 'source_size_bytes' => safe_file_size(source_path),
56
+ 'source_fingerprint' => Shoko::Adapters::BookSources::SourceFingerprint.compute(source_path),
57
+ 'generated_at' => generated_at&.to_f,
58
+ 'created_at' => now,
59
+ 'updated_at' => now,
60
+ 'engine' => ENGINE
61
+ )
62
+ end
63
+
64
+ def payload_hash(metadata_row, chapter_generation, indexes)
65
+ chapters_index = indexes.fetch(:chapters)
66
+ resources_index = indexes.fetch(:resources)
67
+ metadata_row['chapters_generation'] = chapter_generation
68
+ metadata_row['chapters_format_version'] = 1
69
+
70
+ {
71
+ 'format' => FORMAT,
72
+ 'format_version' => FORMAT_VERSION,
73
+ 'engine' => ENGINE,
74
+ 'metadata_row' => metadata_row,
75
+ 'chapters' => chapters_index,
76
+ 'resources' => resources_index,
77
+ }
78
+ end
79
+
80
+ def write_payload_file(sha, payload)
81
+ AtomicFileWriter.write(payload_path(sha), JSON.generate(payload))
82
+ end
83
+
84
+ def post_write_housekeeping(sha, metadata_row, chapter_generation, cache_size_bytes, serialized_layouts:)
85
+ write_layouts(sha, serialized_layouts)
86
+ update_manifest(metadata_row, cache_size_bytes: cache_size_bytes)
87
+ cleanup_old_chapter_generations(sha, keep: chapter_generation)
88
+ end
89
+
90
+ def stringify_keys(hash)
91
+ (hash || {}).transform_keys(&:to_s)
92
+ rescue StandardError
93
+ hash || {}
94
+ end
95
+
96
+ def normalize_sha!(sha)
97
+ value = sha.to_s.strip
98
+ raise ArgumentError, 'sha is blank' if value.empty?
99
+ raise ArgumentError, 'sha must be a 64-char hex digest' unless SHA256_HEX_PATTERN.match?(value)
100
+
101
+ value.downcase
102
+ end
103
+
104
+ def safe_file_size(path)
105
+ return nil if path.nil? || path.to_s.empty?
106
+
107
+ File.size(path)
108
+ rescue StandardError
109
+ nil
110
+ end
111
+ end
112
+ end
113
+ end