rufio 1.1.0 → 1.4.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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -0
  3. data/README.md +26 -297
  4. data/README_ja.md +24 -261
  5. data/Rakefile +14 -1
  6. data/bin/rufio +31 -2
  7. data/docs/fast_zip_design_ja.md +167 -0
  8. data/docs/guide.md +410 -0
  9. data/docs/guide_ja.md +410 -0
  10. data/info/help.md +54 -17
  11. data/info/keybindings.md +50 -22
  12. data/info/welcome.md +14 -5
  13. data/lib/rufio/application.rb +12 -1
  14. data/lib/rufio/archive_controller.rb +211 -0
  15. data/lib/rufio/background_command_executor.rb +9 -0
  16. data/lib/rufio/background_status.rb +49 -0
  17. data/lib/rufio/bookmark_controller.rb +38 -121
  18. data/lib/rufio/bookmark_manager.rb +77 -331
  19. data/lib/rufio/command_completion.rb +28 -2
  20. data/lib/rufio/command_history.rb +5 -2
  21. data/lib/rufio/command_mode.rb +60 -134
  22. data/lib/rufio/command_mode_ui.rb +4 -42
  23. data/lib/rufio/config.rb +88 -136
  24. data/lib/rufio/config_loader.rb +4 -11
  25. data/lib/rufio/dialog_renderer.rb +90 -152
  26. data/lib/rufio/dialog_support.rb +69 -0
  27. data/lib/rufio/document_text_extractor.rb +167 -0
  28. data/lib/rufio/file_operation_controller.rb +49 -222
  29. data/lib/rufio/file_operations.rb +28 -6
  30. data/lib/rufio/file_preview.rb +46 -2
  31. data/lib/rufio/global_search.rb +125 -13
  32. data/lib/rufio/help_menu.rb +19 -7
  33. data/lib/rufio/interpreter_resolver.rb +6 -11
  34. data/lib/rufio/job_manager.rb +2 -5
  35. data/lib/rufio/key_input.rb +77 -0
  36. data/lib/rufio/keybind_handler.rb +83 -116
  37. data/lib/rufio/local_script_scanner.rb +6 -18
  38. data/lib/rufio/native/aarch64-linux-gnu/librufio_zig.so +0 -0
  39. data/lib/rufio/native/aarch64-linux-musl/librufio_zig.so +0 -0
  40. data/lib/rufio/native/aarch64-macos/librufio_zig.dylib +0 -0
  41. data/lib/rufio/native/aarch64-windows/rufio_zig.dll +0 -0
  42. data/lib/rufio/native/x86_64-linux-gnu/librufio_zig.so +0 -0
  43. data/lib/rufio/native/x86_64-linux-musl/librufio_zig.so +0 -0
  44. data/lib/rufio/native/x86_64-macos/librufio_zig.dylib +0 -0
  45. data/lib/rufio/native/x86_64-windows/rufio_zig.dll +0 -0
  46. data/lib/rufio/native_content_search.rb +153 -0
  47. data/lib/rufio/native_scanner.rb +13 -68
  48. data/lib/rufio/native_scanner_zig.rb +106 -56
  49. data/lib/rufio/native_zip.rb +121 -0
  50. data/lib/rufio/navigation_controller.rb +20 -26
  51. data/lib/rufio/path_jump.rb +55 -0
  52. data/lib/rufio/screen.rb +2 -12
  53. data/lib/rufio/script_executor.rb +6 -73
  54. data/lib/rufio/script_file.rb +44 -0
  55. data/lib/rufio/script_path_manager.rb +9 -43
  56. data/lib/rufio/script_runner.rb +13 -50
  57. data/lib/rufio/search_controller.rb +1 -1
  58. data/lib/rufio/shell_command_completion.rb +1 -5
  59. data/lib/rufio/shell_init.rb +90 -0
  60. data/lib/rufio/task_status.rb +5 -1
  61. data/lib/rufio/terminal_ui.rb +84 -298
  62. data/lib/rufio/text_utils.rb +26 -3
  63. data/lib/rufio/ui_renderer.rb +73 -45
  64. data/lib/rufio/version.rb +1 -1
  65. data/lib/rufio/zip_archive.rb +314 -0
  66. data/lib/rufio/zip_extractor.rb +346 -0
  67. data/lib/rufio/zip_writer.rb +268 -0
  68. data/lib/rufio/zoxide_integration.rb +5 -43
  69. data/lib/rufio.rb +6 -0
  70. data/lib_zig/rufio_native/Makefile +54 -17
  71. data/lib_zig/rufio_native/bench.sh +36 -0
  72. data/lib_zig/rufio_native/build.zig +32 -35
  73. data/lib_zig/rufio_native/src/cli.zig +99 -0
  74. data/lib_zig/rufio_native/src/main.zig +187 -83
  75. data/lib_zig/rufio_native/src/os.zig +889 -0
  76. data/lib_zig/rufio_native/src/search.zig +919 -0
  77. data/lib_zig/rufio_native/src/search_test.zig +88 -0
  78. data/lib_zig/rufio_native/src/zip.zig +482 -0
  79. metadata +128 -3
  80. data/lib/rufio/native/rufio_zig.bundle +0 -0
@@ -51,6 +51,7 @@ module Rufio
51
51
  refresh: 'R',
52
52
  # ファイル操作
53
53
  open_file: 'o',
54
+ preview: 'P',
54
55
  rename: 'r',
55
56
  delete: 'd',
56
57
  create_file: 'a',
@@ -77,7 +78,8 @@ module Rufio
77
78
  help: '?',
78
79
  log_viewer: 'L',
79
80
  command_mode: ':',
80
- quit: 'q'
81
+ quit: 'q',
82
+ quit_cd: 'T'
81
83
  }.freeze
82
84
  end
83
85
 
@@ -282,17 +284,8 @@ module Rufio
282
284
  Config.save_script_paths(SCRIPT_PATHS_YML, paths)
283
285
  end
284
286
 
285
- # YAMLファイルにセクションを保存(Config経由)
286
- def save_to_yaml(key, value)
287
- Config.save_yaml_config(YAML_CONFIG_PATH, key, value)
288
- end
289
-
290
287
  def filter_valid_bookmarks(bookmarks)
291
- return [] unless bookmarks.is_a?(Array)
292
-
293
- bookmarks.select do |b|
294
- b.is_a?(Hash) && b[:path] && b[:name]
295
- end
288
+ Config.filter_valid_bookmarks(bookmarks)
296
289
  end
297
290
 
298
291
  def load_config_file
@@ -19,186 +19,59 @@ module Rufio
19
19
  # @option options [String] :border_color Border color ANSI code
20
20
  # @option options [String] :title_color Title color ANSI code
21
21
  # @option options [String] :content_color Content color ANSI code
22
+ # @option options [Integer] :scroll_offset First visible content line
23
+ # @option options [Integer] :total_lines Total scrollable content lines
24
+ # @option options [Integer] :visible_lines Number of visible scrollable lines
22
25
  def draw_floating_window_to_buffer(screen, x, y, width, height, title, content_lines, options = {})
23
- # Default options
24
- border_color = options[:border_color] || "\e[37m" # White
25
- title_color = options[:title_color] || "\e[1;33m" # Bold yellow
26
- content_color = options[:content_color] || "\e[37m" # White
27
-
28
- # Draw top border
29
- screen.put_string(x, y, "┌#{'─' * (width - 2)}┐", fg: border_color)
30
-
31
- # Draw title line if title exists
32
- if title
33
- title_width = TextUtils.display_width(title)
34
- title_padding = (width - 2 - title_width) / 2
35
- padded_title = ' ' * title_padding + title
36
- title_line = TextUtils.pad_string_to_width(padded_title, width - 2)
37
-
38
- screen.put(x, y + 1, '│', fg: border_color)
39
- screen.put_string(x + 1, y + 1, title_line, fg: title_color)
40
- screen.put(x + width - 1, y + 1, '│', fg: border_color)
41
-
42
- # Draw title separator
43
- screen.put_string(x, y + 2, "├#{'─' * (width - 2)}┤", fg: border_color)
44
- content_start_y = y + 3
45
- else
46
- content_start_y = y + 1
47
- end
48
-
49
- # Draw content lines
50
- content_height = title ? height - 4 : height - 2
51
- content_lines.each_with_index do |line, index|
52
- break if index >= content_height
53
-
54
- line_y = content_start_y + index
55
- line_content = TextUtils.pad_string_to_width(line, width - 2)
56
-
57
- screen.put(x, line_y, '│', fg: border_color)
58
- screen.put_string(x + 1, line_y, line_content, fg: content_color)
59
- screen.put(x + width - 1, line_y, '│', fg: border_color)
60
- end
61
-
62
- # Fill remaining lines with empty space
63
- remaining_lines = content_height - content_lines.length
64
- remaining_lines.times do |i|
65
- line_y = content_start_y + content_lines.length + i
66
- empty_line = ' ' * (width - 2)
67
-
68
- screen.put(x, line_y, '│', fg: border_color)
69
- screen.put_string(x + 1, line_y, empty_line)
70
- screen.put(x + width - 1, line_y, '│', fg: border_color)
71
- end
72
-
73
- # Draw bottom border
74
- bottom_y = y + height - 1
75
- screen.put_string(x, bottom_y, "└#{'─' * (width - 2)}┘", fg: border_color)
26
+ draw_window_cells(x, y, width, height, title, content_lines, options,
27
+ put_cell: ->(cx, cy, ch, fg: nil) { screen.put(cx, cy, ch, fg: fg) },
28
+ put_str: ->(cx, cy, str, fg: nil) { screen.put_string(cx, cy, str, fg: fg) })
76
29
  end
77
30
 
78
31
  # オーバーレイレイヤーにフローティングウィンドウを描画
79
- # @param screen [Screen] Screen buffer with overlay enabled
80
- # @param x [Integer] X position (column)
81
- # @param y [Integer] Y position (row)
82
- # @param width [Integer] Window width
83
- # @param height [Integer] Window height
84
- # @param title [String, nil] Window title (optional)
85
- # @param content_lines [Array<String>] Content lines to display
86
- # @param options [Hash] Customization options
87
32
  def draw_floating_window_to_overlay(screen, x, y, width, height, title, content_lines, options = {})
88
- # オーバーレイが有効でない場合は有効化
89
33
  screen.enable_overlay unless screen.overlay_enabled?
90
34
 
91
- border_color = options[:border_color] || "\e[37m"
92
- title_color = options[:title_color] || "\e[1;33m"
93
- content_color = options[:content_color] || "\e[37m"
94
-
95
- # Draw top border
96
- screen.put_overlay_string(x, y, "┌#{'─' * (width - 2)}┐", fg: border_color)
97
-
98
- # Draw title line if title exists
99
- if title
100
- title_width = TextUtils.display_width(title)
101
- title_padding = (width - 2 - title_width) / 2
102
- padded_title = ' ' * title_padding + title
103
- title_line = TextUtils.pad_string_to_width(padded_title, width - 2)
104
-
105
- screen.put_overlay(x, y + 1, '│', fg: border_color)
106
- screen.put_overlay_string(x + 1, y + 1, title_line, fg: title_color)
107
- screen.put_overlay(x + width - 1, y + 1, '│', fg: border_color)
108
-
109
- # Draw title separator
110
- screen.put_overlay_string(x, y + 2, "├#{'─' * (width - 2)}┤", fg: border_color)
111
- content_start_y = y + 3
112
- else
113
- content_start_y = y + 1
114
- end
115
-
116
- # Draw content lines
117
- content_height = title ? height - 4 : height - 2
118
- content_lines.each_with_index do |line, index|
119
- break if index >= content_height
120
-
121
- line_y = content_start_y + index
122
- line_content = TextUtils.pad_string_to_width(line, width - 2)
123
-
124
- screen.put_overlay(x, line_y, '│', fg: border_color)
125
- screen.put_overlay_string(x + 1, line_y, line_content, fg: content_color)
126
- screen.put_overlay(x + width - 1, line_y, '│', fg: border_color)
127
- end
128
-
129
- # Fill remaining lines with empty space
130
- remaining_lines = content_height - content_lines.length
131
- remaining_lines.times do |i|
132
- line_y = content_start_y + content_lines.length + i
133
- empty_line = ' ' * (width - 2)
134
-
135
- screen.put_overlay(x, line_y, '│', fg: border_color)
136
- screen.put_overlay_string(x + 1, line_y, empty_line)
137
- screen.put_overlay(x + width - 1, line_y, '│', fg: border_color)
138
- end
139
-
140
- # Draw bottom border
141
- bottom_y = y + height - 1
142
- screen.put_overlay_string(x, bottom_y, "└#{'─' * (width - 2)}┘", fg: border_color)
35
+ draw_window_cells(x, y, width, height, title, content_lines, options,
36
+ put_cell: ->(cx, cy, ch, fg: nil) { screen.put_overlay(cx, cy, ch, fg: fg) },
37
+ put_str: ->(cx, cy, str, fg: nil) { screen.put_overlay_string(cx, cy, str, fg: fg) })
143
38
  end
144
39
 
145
40
  # Draw a floating window with title, content, and customizable colors
146
- # @param x [Integer] X position (column)
147
- # @param y [Integer] Y position (row)
148
- # @param width [Integer] Window width
149
- # @param height [Integer] Window height
150
- # @param title [String, nil] Window title (optional)
151
- # @param content_lines [Array<String>] Content lines to display
152
- # @param options [Hash] Customization options
153
- # @option options [String] :border_color Border color ANSI code
154
- # @option options [String] :title_color Title color ANSI code
155
- # @option options [String] :content_color Content color ANSI code
156
41
  def draw_floating_window(x, y, width, height, title, content_lines, options = {})
157
- # Default options
158
42
  border_color = options[:border_color] || "\e[37m" # White
159
43
  title_color = options[:title_color] || "\e[1;33m" # Bold yellow
160
44
  content_color = options[:content_color] || "\e[37m" # White
161
45
  reset_color = "\e[0m"
46
+ inner_width = width - 2
162
47
 
163
- # Draw top border
164
- print "\e[#{y};#{x}H#{border_color}┌#{'─' * (width - 2)}┐#{reset_color}"
48
+ print "\e[#{y};#{x}H#{border_color}┌#{'─' * inner_width}┐#{reset_color}"
165
49
 
166
- # Draw title line if title exists
167
50
  if title
168
- title_width = TextUtils.display_width(title)
169
- title_padding = (width - 2 - title_width) / 2
170
- padded_title = ' ' * title_padding + title
171
- title_line = TextUtils.pad_string_to_width(padded_title, width - 2)
51
+ title_line = format_centered_title(title, inner_width)
172
52
  print "\e[#{y + 1};#{x}H#{border_color}│#{title_color}#{title_line}#{border_color}│#{reset_color}"
173
-
174
- # Draw title separator
175
- print "\e[#{y + 2};#{x}H#{border_color}├#{'─' * (width - 2)}┤#{reset_color}"
53
+ print "\e[#{y + 2};#{x}H#{border_color}├#{'─' * inner_width}┤#{reset_color}"
176
54
  content_start_y = y + 3
177
55
  else
178
56
  content_start_y = y + 1
179
57
  end
180
58
 
181
- # Draw content lines
182
59
  content_height = title ? height - 4 : height - 2
183
- content_lines.each_with_index do |line, index|
184
- break if index >= content_height
185
-
60
+ content_height.times do |index|
186
61
  line_y = content_start_y + index
187
- line_content = TextUtils.pad_string_to_width(line, width - 2)
188
- print "\e[#{line_y};#{x}H#{border_color}│#{content_color}#{line_content}#{border_color}│#{reset_color}"
189
- end
190
-
191
- # Fill remaining lines with empty space
192
- remaining_lines = content_height - content_lines.length
193
- remaining_lines.times do |i|
194
- line_y = content_start_y + content_lines.length + i
195
- empty_line = ' ' * (width - 2)
196
- print "\e[#{line_y};#{x}H#{border_color}│#{empty_line}│#{reset_color}"
62
+ if index < content_lines.length
63
+ line_content = TextUtils.pad_string_to_width(content_lines[index], inner_width)
64
+ print "\e[#{line_y};#{x}H#{border_color}│#{content_color}#{line_content}#{border_color}│#{reset_color}"
65
+ else
66
+ print "\e[#{line_y};#{x}H#{border_color}│#{' ' * inner_width}│#{reset_color}"
67
+ end
197
68
  end
198
69
 
199
- # Draw bottom border
200
70
  bottom_y = y + height - 1
201
- print "\e[#{bottom_y};#{x}H#{border_color}└#{'─' * (width - 2)}┘#{reset_color}"
71
+ print "\e[#{bottom_y};#{x}H#{border_color}└#{'─' * inner_width}┘#{reset_color}"
72
+ draw_scroll_indicators(x, y, width, height, options) do |cx, cy, label|
73
+ print "\e[#{cy};#{cx}H#{border_color}#{label}#{reset_color}"
74
+ end
202
75
  end
203
76
 
204
77
  # Calculate center position for a window
@@ -307,6 +180,71 @@ module Rufio
307
180
 
308
181
  private
309
182
 
183
+ def format_centered_title(title, inner_width)
184
+ title_width = TextUtils.display_width(title)
185
+ title_padding = [(inner_width - title_width) / 2, 0].max
186
+ padded_title = ' ' * title_padding + title
187
+ TextUtils.pad_string_to_width(padded_title, inner_width)
188
+ end
189
+
190
+ def draw_window_cells(x, y, width, height, title, content_lines, options, put_cell:, put_str:)
191
+ border_color = options[:border_color] || "\e[37m"
192
+ title_color = options[:title_color] || "\e[1;33m"
193
+ content_color = options[:content_color] || "\e[37m"
194
+ inner_width = width - 2
195
+
196
+ put_str.call(x, y, "┌#{'─' * inner_width}┐", fg: border_color)
197
+
198
+ if title
199
+ title_line = format_centered_title(title, inner_width)
200
+ put_cell.call(x, y + 1, '│', fg: border_color)
201
+ put_str.call(x + 1, y + 1, title_line, fg: title_color)
202
+ put_cell.call(x + width - 1, y + 1, '│', fg: border_color)
203
+
204
+ put_str.call(x, y + 2, "├#{'─' * inner_width}┤", fg: border_color)
205
+ content_start_y = y + 3
206
+ else
207
+ content_start_y = y + 1
208
+ end
209
+
210
+ content_height = title ? height - 4 : height - 2
211
+ empty_line = ' ' * inner_width
212
+ content_height.times do |index|
213
+ line_y = content_start_y + index
214
+ put_cell.call(x, line_y, '│', fg: border_color)
215
+ if index < content_lines.length
216
+ line_content = TextUtils.pad_string_to_width(content_lines[index], inner_width)
217
+ put_str.call(x + 1, line_y, line_content, fg: content_color)
218
+ else
219
+ put_str.call(x + 1, line_y, empty_line)
220
+ end
221
+ put_cell.call(x + width - 1, line_y, '│', fg: border_color)
222
+ end
223
+
224
+ bottom_y = y + height - 1
225
+ put_str.call(x, bottom_y, "└#{'─' * inner_width}┘", fg: border_color)
226
+ draw_scroll_indicators(x, y, width, height, options) do |cx, cy, label|
227
+ put_str.call(cx, cy, label, fg: border_color)
228
+ end
229
+ end
230
+
231
+ # 枠内に表示し、本文や操作ガイドの行を消費しない。
232
+ def draw_scroll_indicators(x, y, width, height, options)
233
+ total = options[:total_lines]
234
+ visible = options[:visible_lines]
235
+ return unless total && visible && total > visible
236
+ return if width < 3 || height < 2
237
+
238
+ offset = [[options.fetch(:scroll_offset, 0), 0].max, total - visible].min
239
+ [[offset > 0, y, '▲ 上に続く'],
240
+ [offset + visible < total, y + height - 1, '▼ 下に続く']].each do |shown, row, text|
241
+ next unless shown
242
+
243
+ label = TextUtils.truncate_to_width(text, width - 2)
244
+ yield x + 1 + (width - 2 - TextUtils.display_width(label)) / 2, row, label
245
+ end
246
+ end
247
+
310
248
  # ASCII character range constants
311
249
  ASCII_PRINTABLE_START = 32
312
250
  ASCII_PRINTABLE_END = 127
@@ -329,7 +267,7 @@ module Rufio
329
267
  max_input_width = dialog_width - 8 # Leave space for borders and padding (border 2 + padding 4 + border 2)
330
268
 
331
269
  loop do
332
- char = STDIN.getch
270
+ char = KeyInput.getch
333
271
 
334
272
  case char
335
273
  when "\e" # Escape - cancel
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'io/console'
4
+
5
+ module Rufio
6
+ # ダイアログの表示方式選択と入力待ち・後片付けを共有する。
7
+ # 利用側は @dialog_renderer と @terminal_ui を設定する。
8
+ module DialogSupport
9
+ CONFIRMATION_CANCEL_KEYS = ["n", "\e", "\x03"].freeze
10
+
11
+ private
12
+
13
+ # 表示とキー判定を共有し、終了確認だけは従来どおり q を無視する。
14
+ def confirm_dialog(title, content_lines, options = {}, cancel_on_quit: true)
15
+ confirmed = false
16
+ show_overlay_dialog(title, content_lines, options) do
17
+ confirmed = read_confirmation(cancel_on_quit: cancel_on_quit)
18
+ nil
19
+ end
20
+ confirmed
21
+ end
22
+
23
+ def read_confirmation(cancel_on_quit: true)
24
+ loop do
25
+ input = KeyInput.getch.downcase
26
+ return true if input == 'y'
27
+ return false if CONFIRMATION_CANCEL_KEYS.include?(input) || (cancel_on_quit && input == 'q')
28
+ end
29
+ end
30
+
31
+ def show_overlay_dialog(title, content_lines, options = {}, &block)
32
+ use_overlay = @terminal_ui &&
33
+ @terminal_ui.respond_to?(:screen) &&
34
+ @terminal_ui.respond_to?(:renderer) &&
35
+ @terminal_ui.screen &&
36
+ @terminal_ui.renderer
37
+
38
+ if use_overlay
39
+ @terminal_ui.show_overlay_dialog(title, content_lines, options, &block)
40
+ else
41
+ width = options[:width]
42
+ height = options[:height]
43
+
44
+ unless width && height
45
+ width, height = @dialog_renderer.calculate_dimensions(content_lines, {
46
+ title: title,
47
+ min_width: options[:min_width] || 40,
48
+ max_width: options[:max_width] || 80
49
+ })
50
+ end
51
+
52
+ x, y = @dialog_renderer.calculate_center(width, height)
53
+
54
+ @dialog_renderer.draw_floating_window(x, y, width, height, title, content_lines, {
55
+ border_color: options[:border_color] || "\e[37m",
56
+ title_color: options[:title_color] || "\e[1;33m",
57
+ content_color: options[:content_color] || "\e[37m"
58
+ })
59
+
60
+ key = block_given? ? yield : KeyInput.getch
61
+
62
+ @dialog_renderer.clear_area(x, y, width, height)
63
+ @terminal_ui&.refresh_display
64
+
65
+ key
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pdf/reader'
4
+ require 'csv'
5
+ require 'base64'
6
+ require 'roo'
7
+ require 'zip'
8
+ require 'nokogiri'
9
+ require 'pathname'
10
+ require 'uri'
11
+
12
+ module Rufio
13
+ # 文書内の文字列を、元文書の位置情報付きで列挙する。外部コマンドは実行しない。
14
+ class DocumentTextExtractor
15
+ class ExtractionError < StandardError; end
16
+
17
+ EXTENSIONS = %w[.pdf .xlsx .pptx].freeze
18
+ DRAWING_NS = 'http://schemas.openxmlformats.org/drawingml/2006/main'
19
+ PART_URI_PARSER = URI::RFC2396_Parser.new
20
+
21
+ def supported?(path)
22
+ EXTENSIONS.include?(File.extname(path).downcase)
23
+ end
24
+
25
+ def each_record(path)
26
+ return enum_for(:each_record, path) unless block_given?
27
+
28
+ case File.extname(path).downcase
29
+ when '.pdf' then each_pdf_record(path) { |record| yield record }
30
+ when '.xlsx' then each_excel_record(path) { |record| yield record }
31
+ when '.pptx' then each_powerpoint_record(path) { |record| yield record }
32
+ else raise ExtractionError, "Unsupported document: #{File.extname(path)}"
33
+ end
34
+ rescue ExtractionError
35
+ raise
36
+ rescue StandardError => e
37
+ raise ExtractionError, "#{File.basename(path)}: #{e.message}"
38
+ end
39
+
40
+ private
41
+
42
+ def each_pdf_record(path)
43
+ File.open(path, 'rb') do |io|
44
+ reader = PDF::Reader.new(io)
45
+ reader.pages.each_with_index do |page, index|
46
+ page.text.each_line do |text|
47
+ next if text.strip.empty?
48
+
49
+ yield(text: text.chomp, document_type: :pdf, page: index + 1, location: "Page #{index + 1}")
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ def each_excel_record(path)
56
+ workbook = Roo::Excelx.new(path)
57
+ workbook.sheets.each do |name|
58
+ workbook.sheet(name).each_row_streaming do |row|
59
+ row.compact.each do |cell|
60
+ value = cell.value
61
+ next if value.nil? || value.to_s.empty?
62
+
63
+ row_number, column = cell.coordinate
64
+ address = "#{Roo::Utils.number_to_letter(column)}#{row_number}"
65
+ yield(text: value.to_s, document_type: :xlsx, sheet: name, cell: address, location: "#{name}!#{address}")
66
+ end
67
+ end
68
+ end
69
+ ensure
70
+ workbook.close if workbook
71
+ end
72
+
73
+ def read_xml(zip, part)
74
+ entry = zip.find_entry(part)
75
+ raise ExtractionError, "Missing document part: #{part}" unless entry
76
+
77
+ xml = entry.get_input_stream { |stream| stream.read }
78
+ Nokogiri::XML(xml) { |config| config.strict.nonet }
79
+ end
80
+
81
+ def each_powerpoint_record(path)
82
+ Zip::File.open(path) do |zip|
83
+ presentation = read_xml(zip, 'ppt/presentation.xml')
84
+ relationships = read_xml(zip, 'ppt/_rels/presentation.xml.rels')
85
+ slides = relationships.xpath('//*[local-name()="Relationship"]').each_with_object({}) do |node, result|
86
+ next unless node['Type'].to_s.end_with?('/slide') && node['TargetMode'] != 'External'
87
+
88
+ target = PART_URI_PARSER.unescape(node['Target'].to_s)
89
+ part = target.start_with?('/') ? target.delete_prefix('/') : "ppt/#{target}"
90
+ part = Pathname.new(part).cleanpath.to_s
91
+ raise ExtractionError, 'Invalid slide path' unless part.start_with?('ppt/')
92
+
93
+ result[node['Id']] = part
94
+ end
95
+ presentation.xpath('//*[local-name()="sldIdLst"]/*[local-name()="sldId"]').each_with_index do |node, index|
96
+ relationship = node.attribute_nodes.find { |attribute| attribute.name == 'id' && attribute.namespace }
97
+ part = slides[relationship&.value]
98
+ raise ExtractionError, 'Missing slide relationship' unless part
99
+
100
+ slide = read_xml(zip, part)
101
+ slide.xpath('//a:p', 'a' => DRAWING_NS).each do |paragraph|
102
+ text = paragraph.xpath('.//a:t | .//a:br', 'a' => DRAWING_NS).map do |fragment|
103
+ fragment.name == 'br' ? "\n" : fragment.text
104
+ end.join
105
+ next if text.strip.empty?
106
+
107
+ yield(text: text, document_type: :pptx, slide: index + 1, location: "Slide #{index + 1}")
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+
114
+ # 検索語を変えるたびに文書を再解析しないための、容量制限付きメモリキャッシュ。
115
+ # 完了した抽出のみ保持し、更新・置換されたファイルは再抽出する。
116
+ class DocumentTextCache
117
+ def initialize(extractor: DocumentTextExtractor.new, max_entries: 64, max_bytes: 16 * 1024 * 1024)
118
+ @extractor = extractor
119
+ @max_entries = max_entries
120
+ @max_bytes = max_bytes
121
+ @cache = {}
122
+ end
123
+
124
+ def each_record(path)
125
+ return enum_for(:each_record, path) unless block_given?
126
+
127
+ key = File.expand_path(path)
128
+ before = signature(path)
129
+ cached = @cache.delete(key)
130
+ if cached && cached[:signature] == before
131
+ @cache[key] = cached
132
+ cached[:records].each { |record| yield record }
133
+ return
134
+ end
135
+
136
+ records = []
137
+ bytes = 0
138
+ @extractor.each_record(path) do |record|
139
+ record = record.merge(search_text: record[:text].downcase).transform_values do |value|
140
+ value.is_a?(String) ? value.dup.freeze : value
141
+ end.freeze
142
+ bytes += record.values.sum { |value| value.is_a?(String) ? value.bytesize : 16 } + 64
143
+ if bytes <= @max_bytes
144
+ records << record if records
145
+ else
146
+ records = nil
147
+ end
148
+ yield record
149
+ end
150
+ return unless records && @max_entries.positive? && signature(path) == before
151
+
152
+ @cache.shift while !@cache.empty? && (@cache.length >= @max_entries || cache_bytes + bytes > @max_bytes)
153
+ @cache[key] = { signature: before, records: records.freeze, bytes: bytes }
154
+ end
155
+
156
+ private
157
+
158
+ def cache_bytes
159
+ @cache.values.sum { |entry| entry[:bytes] }
160
+ end
161
+
162
+ def signature(path)
163
+ stat = File.stat(path)
164
+ [stat.dev, stat.ino, stat.size, stat.mtime, stat.ctime]
165
+ end
166
+ end
167
+ end