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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +50 -0
- data/README.md +26 -297
- data/README_ja.md +24 -261
- data/Rakefile +14 -1
- data/bin/rufio +31 -2
- data/docs/fast_zip_design_ja.md +167 -0
- data/docs/guide.md +410 -0
- data/docs/guide_ja.md +410 -0
- data/info/help.md +54 -17
- data/info/keybindings.md +50 -22
- data/info/welcome.md +14 -5
- data/lib/rufio/application.rb +12 -1
- data/lib/rufio/archive_controller.rb +211 -0
- data/lib/rufio/background_command_executor.rb +9 -0
- data/lib/rufio/background_status.rb +49 -0
- data/lib/rufio/bookmark_controller.rb +38 -121
- data/lib/rufio/bookmark_manager.rb +77 -331
- data/lib/rufio/command_completion.rb +28 -2
- data/lib/rufio/command_history.rb +5 -2
- data/lib/rufio/command_mode.rb +60 -134
- data/lib/rufio/command_mode_ui.rb +4 -42
- data/lib/rufio/config.rb +88 -136
- data/lib/rufio/config_loader.rb +4 -11
- data/lib/rufio/dialog_renderer.rb +90 -152
- data/lib/rufio/dialog_support.rb +69 -0
- data/lib/rufio/document_text_extractor.rb +167 -0
- data/lib/rufio/file_operation_controller.rb +49 -222
- data/lib/rufio/file_operations.rb +28 -6
- data/lib/rufio/file_preview.rb +46 -2
- data/lib/rufio/global_search.rb +125 -13
- data/lib/rufio/help_menu.rb +19 -7
- data/lib/rufio/interpreter_resolver.rb +6 -11
- data/lib/rufio/job_manager.rb +2 -5
- data/lib/rufio/key_input.rb +77 -0
- data/lib/rufio/keybind_handler.rb +83 -116
- data/lib/rufio/local_script_scanner.rb +6 -18
- data/lib/rufio/native/aarch64-linux-gnu/librufio_zig.so +0 -0
- data/lib/rufio/native/aarch64-linux-musl/librufio_zig.so +0 -0
- data/lib/rufio/native/aarch64-macos/librufio_zig.dylib +0 -0
- data/lib/rufio/native/aarch64-windows/rufio_zig.dll +0 -0
- data/lib/rufio/native/x86_64-linux-gnu/librufio_zig.so +0 -0
- data/lib/rufio/native/x86_64-linux-musl/librufio_zig.so +0 -0
- data/lib/rufio/native/x86_64-macos/librufio_zig.dylib +0 -0
- data/lib/rufio/native/x86_64-windows/rufio_zig.dll +0 -0
- data/lib/rufio/native_content_search.rb +153 -0
- data/lib/rufio/native_scanner.rb +13 -68
- data/lib/rufio/native_scanner_zig.rb +106 -56
- data/lib/rufio/native_zip.rb +121 -0
- data/lib/rufio/navigation_controller.rb +20 -26
- data/lib/rufio/path_jump.rb +55 -0
- data/lib/rufio/screen.rb +2 -12
- data/lib/rufio/script_executor.rb +6 -73
- data/lib/rufio/script_file.rb +44 -0
- data/lib/rufio/script_path_manager.rb +9 -43
- data/lib/rufio/script_runner.rb +13 -50
- data/lib/rufio/search_controller.rb +1 -1
- data/lib/rufio/shell_command_completion.rb +1 -5
- data/lib/rufio/shell_init.rb +90 -0
- data/lib/rufio/task_status.rb +5 -1
- data/lib/rufio/terminal_ui.rb +84 -298
- data/lib/rufio/text_utils.rb +26 -3
- data/lib/rufio/ui_renderer.rb +73 -45
- data/lib/rufio/version.rb +1 -1
- data/lib/rufio/zip_archive.rb +314 -0
- data/lib/rufio/zip_extractor.rb +346 -0
- data/lib/rufio/zip_writer.rb +268 -0
- data/lib/rufio/zoxide_integration.rb +5 -43
- data/lib/rufio.rb +6 -0
- data/lib_zig/rufio_native/Makefile +54 -17
- data/lib_zig/rufio_native/bench.sh +36 -0
- data/lib_zig/rufio_native/build.zig +32 -35
- data/lib_zig/rufio_native/src/cli.zig +99 -0
- data/lib_zig/rufio_native/src/main.zig +187 -83
- data/lib_zig/rufio_native/src/os.zig +889 -0
- data/lib_zig/rufio_native/src/search.zig +919 -0
- data/lib_zig/rufio_native/src/search_test.zig +88 -0
- data/lib_zig/rufio_native/src/zip.zig +482 -0
- metadata +128 -3
- data/lib/rufio/native/rufio_zig.bundle +0 -0
data/lib/rufio/config_loader.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
break if index >= content_height
|
|
185
|
-
|
|
60
|
+
content_height.times do |index|
|
|
186
61
|
line_y = content_start_y + index
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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}└#{'─' *
|
|
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 =
|
|
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
|