wp2txt 1.1.3 → 2.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.
- checksums.yaml +4 -4
- data/.dockerignore +12 -0
- data/.github/workflows/ci.yml +13 -13
- data/.gitignore +14 -0
- data/CHANGELOG.md +284 -0
- data/DEVELOPMENT.md +415 -0
- data/DEVELOPMENT_ja.md +415 -0
- data/Dockerfile +19 -10
- data/Gemfile +2 -8
- data/README.md +259 -123
- data/README_ja.md +375 -0
- data/Rakefile +4 -0
- data/bin/wp2txt +863 -161
- data/lib/wp2txt/article.rb +98 -13
- data/lib/wp2txt/bz2_validator.rb +239 -0
- data/lib/wp2txt/category_cache.rb +313 -0
- data/lib/wp2txt/cli.rb +319 -0
- data/lib/wp2txt/cli_ui.rb +428 -0
- data/lib/wp2txt/config.rb +158 -0
- data/lib/wp2txt/constants.rb +134 -0
- data/lib/wp2txt/data/html_entities.json +2135 -0
- data/lib/wp2txt/data/language_metadata.json +4769 -0
- data/lib/wp2txt/data/language_tiers.json +59 -0
- data/lib/wp2txt/data/mediawiki_aliases.json +12366 -0
- data/lib/wp2txt/data/template_aliases.json +193 -0
- data/lib/wp2txt/data/wikipedia_entities.json +12 -0
- data/lib/wp2txt/extractor.rb +545 -0
- data/lib/wp2txt/file_utils.rb +91 -0
- data/lib/wp2txt/formatter.rb +352 -0
- data/lib/wp2txt/global_data_cache.rb +353 -0
- data/lib/wp2txt/index_cache.rb +258 -0
- data/lib/wp2txt/magic_words.rb +353 -0
- data/lib/wp2txt/memory_monitor.rb +236 -0
- data/lib/wp2txt/multistream.rb +1383 -0
- data/lib/wp2txt/output_writer.rb +182 -0
- data/lib/wp2txt/parser_functions.rb +606 -0
- data/lib/wp2txt/ractor_worker.rb +215 -0
- data/lib/wp2txt/regex.rb +396 -12
- data/lib/wp2txt/section_extractor.rb +354 -0
- data/lib/wp2txt/stream_processor.rb +271 -0
- data/lib/wp2txt/template_expander.rb +830 -0
- data/lib/wp2txt/text_processing.rb +337 -0
- data/lib/wp2txt/utils.rb +629 -270
- data/lib/wp2txt/version.rb +1 -1
- data/lib/wp2txt.rb +53 -26
- data/scripts/benchmark_regex.rb +161 -0
- data/scripts/fetch_html_entities.rb +94 -0
- data/scripts/fetch_language_metadata.rb +180 -0
- data/scripts/fetch_mediawiki_data.rb +334 -0
- data/scripts/fetch_template_data.rb +186 -0
- data/scripts/profile_memory.rb +139 -0
- data/spec/article_spec.rb +402 -0
- data/spec/auto_download_spec.rb +314 -0
- data/spec/bz2_validator_spec.rb +193 -0
- data/spec/category_cache_spec.rb +226 -0
- data/spec/category_fetcher_spec.rb +504 -0
- data/spec/cleanup_spec.rb +197 -0
- data/spec/cli_options_spec.rb +678 -0
- data/spec/cli_spec.rb +876 -0
- data/spec/config_spec.rb +194 -0
- data/spec/constants_spec.rb +138 -0
- data/spec/file_utils_spec.rb +170 -0
- data/spec/fixtures/samples.rb +181 -0
- data/spec/formatter_sections_spec.rb +382 -0
- data/spec/global_data_cache_spec.rb +186 -0
- data/spec/index_cache_spec.rb +210 -0
- data/spec/integration_spec.rb +543 -0
- data/spec/magic_words_spec.rb +261 -0
- data/spec/markers_spec.rb +476 -0
- data/spec/memory_monitor_spec.rb +192 -0
- data/spec/multistream_spec.rb +690 -0
- data/spec/output_writer_spec.rb +400 -0
- data/spec/parser_functions_spec.rb +455 -0
- data/spec/ractor_worker_spec.rb +197 -0
- data/spec/regex_spec.rb +281 -0
- data/spec/section_extractor_spec.rb +397 -0
- data/spec/spec_helper.rb +63 -0
- data/spec/stream_processor_spec.rb +579 -0
- data/spec/template_data_spec.rb +246 -0
- data/spec/template_expander_spec.rb +472 -0
- data/spec/template_processing_spec.rb +217 -0
- data/spec/text_processing_spec.rb +312 -0
- data/spec/utils_spec.rb +195 -16
- data/spec/wp2txt_spec.rb +510 -0
- data/wp2txt.gemspec +5 -3
- metadata +146 -18
- data/.rubocop.yml +0 -80
- data/data/output_samples/testdata_en.txt +0 -23002
- data/data/output_samples/testdata_en_category.txt +0 -132
- data/data/output_samples/testdata_en_summary.txt +0 -1376
- data/data/output_samples/testdata_ja.txt +0 -22774
- data/data/output_samples/testdata_ja_category.txt +0 -206
- data/data/output_samples/testdata_ja_summary.txt +0 -1560
- data/data/testdata_en.bz2 +0 -0
- data/data/testdata_ja.bz2 +0 -0
- data/image/screenshot.png +0 -0
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Wp2txt
|
|
6
|
+
# Evaluates MediaWiki parser functions
|
|
7
|
+
# Handles #if, #ifeq, #switch, #expr, #ifexpr, and string functions
|
|
8
|
+
class ParserFunctions
|
|
9
|
+
MONTH_NAMES = %w[
|
|
10
|
+
January February March April May June
|
|
11
|
+
July August September October November December
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
def initialize(reference_date: nil, preserve_unknown: false)
|
|
15
|
+
@reference_date = reference_date || Time.now
|
|
16
|
+
@preserve_unknown = preserve_unknown
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Main evaluation method
|
|
20
|
+
def evaluate(text)
|
|
21
|
+
return text if text.nil? || text.empty?
|
|
22
|
+
|
|
23
|
+
# Early exit: no parser functions to evaluate
|
|
24
|
+
return text unless text.include?("{{#")
|
|
25
|
+
|
|
26
|
+
result = text.dup
|
|
27
|
+
|
|
28
|
+
# Process parser functions from innermost to outermost
|
|
29
|
+
max_iterations = 10
|
|
30
|
+
iteration = 0
|
|
31
|
+
|
|
32
|
+
while result.include?("{{#") && iteration < max_iterations
|
|
33
|
+
previous = result.dup
|
|
34
|
+
result = evaluate_single_pass(result)
|
|
35
|
+
break if result == previous
|
|
36
|
+
iteration += 1
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
result
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def evaluate_single_pass(text)
|
|
45
|
+
result = +""
|
|
46
|
+
pos = 0
|
|
47
|
+
|
|
48
|
+
while pos < text.length
|
|
49
|
+
start_idx = text.index("{{#", pos)
|
|
50
|
+
|
|
51
|
+
if start_idx.nil?
|
|
52
|
+
result << text[pos..]
|
|
53
|
+
break
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Add text before parser function
|
|
57
|
+
result << text[pos...start_idx]
|
|
58
|
+
|
|
59
|
+
# Find matching }}
|
|
60
|
+
end_idx = find_template_end(text, start_idx + 2)
|
|
61
|
+
|
|
62
|
+
if end_idx.nil?
|
|
63
|
+
result << text[start_idx..]
|
|
64
|
+
break
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
content = text[(start_idx + 2)...end_idx]
|
|
68
|
+
expanded = evaluate_parser_function(content)
|
|
69
|
+
result << expanded
|
|
70
|
+
|
|
71
|
+
pos = end_idx + 2
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
result
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def find_template_end(text, start_pos)
|
|
78
|
+
depth = 1
|
|
79
|
+
pos = start_pos
|
|
80
|
+
|
|
81
|
+
while pos < text.length - 1
|
|
82
|
+
if text[pos, 2] == "{{"
|
|
83
|
+
depth += 1
|
|
84
|
+
pos += 2
|
|
85
|
+
elsif text[pos, 2] == "}}"
|
|
86
|
+
depth -= 1
|
|
87
|
+
return pos if depth.zero?
|
|
88
|
+
pos += 2
|
|
89
|
+
else
|
|
90
|
+
pos += 1
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def evaluate_parser_function(content)
|
|
98
|
+
# Parse function name and arguments
|
|
99
|
+
# Content starts with # (e.g., "#if:condition|then|else")
|
|
100
|
+
return "" unless content.start_with?("#")
|
|
101
|
+
|
|
102
|
+
# Find function name (up to first : or |)
|
|
103
|
+
colon_idx = content.index(":")
|
|
104
|
+
return "" if colon_idx.nil?
|
|
105
|
+
|
|
106
|
+
function_name = content[1...colon_idx].downcase
|
|
107
|
+
args_str = content[(colon_idx + 1)..]
|
|
108
|
+
args = split_arguments(args_str)
|
|
109
|
+
|
|
110
|
+
case function_name
|
|
111
|
+
when "if"
|
|
112
|
+
evaluate_if(args)
|
|
113
|
+
when "ifeq"
|
|
114
|
+
evaluate_ifeq(args)
|
|
115
|
+
when "iferror"
|
|
116
|
+
evaluate_iferror(args)
|
|
117
|
+
when "switch"
|
|
118
|
+
evaluate_switch(args)
|
|
119
|
+
when "ifexpr"
|
|
120
|
+
evaluate_ifexpr(args)
|
|
121
|
+
when "expr"
|
|
122
|
+
evaluate_expr(args)
|
|
123
|
+
when "len"
|
|
124
|
+
evaluate_len(args)
|
|
125
|
+
when "pos"
|
|
126
|
+
evaluate_pos(args)
|
|
127
|
+
when "rpos"
|
|
128
|
+
evaluate_rpos(args)
|
|
129
|
+
when "count"
|
|
130
|
+
evaluate_count(args)
|
|
131
|
+
when "sub"
|
|
132
|
+
evaluate_sub(args)
|
|
133
|
+
when "replace"
|
|
134
|
+
evaluate_replace(args)
|
|
135
|
+
when "explode"
|
|
136
|
+
evaluate_explode(args)
|
|
137
|
+
when "urldecode"
|
|
138
|
+
evaluate_urldecode(args)
|
|
139
|
+
when "urlencode"
|
|
140
|
+
evaluate_urlencode(args)
|
|
141
|
+
when "padleft"
|
|
142
|
+
evaluate_padleft(args)
|
|
143
|
+
when "padright"
|
|
144
|
+
evaluate_padright(args)
|
|
145
|
+
when "titleparts"
|
|
146
|
+
evaluate_titleparts(args)
|
|
147
|
+
when "time"
|
|
148
|
+
evaluate_time(args)
|
|
149
|
+
else
|
|
150
|
+
@preserve_unknown ? "{{##{content}}}" : ""
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def split_arguments(str)
|
|
155
|
+
args = []
|
|
156
|
+
current = +""
|
|
157
|
+
depth = 0
|
|
158
|
+
|
|
159
|
+
str.each_char do |c|
|
|
160
|
+
case c
|
|
161
|
+
when "{", "["
|
|
162
|
+
depth += 1
|
|
163
|
+
current << c
|
|
164
|
+
when "}", "]"
|
|
165
|
+
depth -= 1
|
|
166
|
+
current << c
|
|
167
|
+
when "|"
|
|
168
|
+
if depth.zero?
|
|
169
|
+
args << current
|
|
170
|
+
current = +""
|
|
171
|
+
else
|
|
172
|
+
current << c
|
|
173
|
+
end
|
|
174
|
+
else
|
|
175
|
+
current << c
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
args << current
|
|
180
|
+
args
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# #if: condition | then | else
|
|
184
|
+
def evaluate_if(args)
|
|
185
|
+
return "" if args.empty?
|
|
186
|
+
|
|
187
|
+
condition = args[0]&.strip || ""
|
|
188
|
+
then_value = args[1] || ""
|
|
189
|
+
else_value = args[2] || ""
|
|
190
|
+
|
|
191
|
+
if condition.empty?
|
|
192
|
+
else_value
|
|
193
|
+
else
|
|
194
|
+
then_value
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# #ifeq: value1 | value2 | then | else
|
|
199
|
+
def evaluate_ifeq(args)
|
|
200
|
+
return "" if args.length < 2
|
|
201
|
+
|
|
202
|
+
value1 = args[0]&.strip || ""
|
|
203
|
+
value2 = args[1]&.strip || ""
|
|
204
|
+
then_value = args[2] || ""
|
|
205
|
+
else_value = args[3] || ""
|
|
206
|
+
|
|
207
|
+
# Try numeric comparison first
|
|
208
|
+
if numeric?(value1) && numeric?(value2)
|
|
209
|
+
equal = value1.to_f == value2.to_f
|
|
210
|
+
else
|
|
211
|
+
equal = value1 == value2
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
equal ? then_value : else_value
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# #switch: value | case1=result1 | case2=result2 | #default=default
|
|
218
|
+
def evaluate_switch(args)
|
|
219
|
+
return "" if args.empty?
|
|
220
|
+
|
|
221
|
+
value = args[0]&.strip || ""
|
|
222
|
+
cases = args[1..]
|
|
223
|
+
default = ""
|
|
224
|
+
pending_cases = []
|
|
225
|
+
|
|
226
|
+
cases.each do |case_arg|
|
|
227
|
+
if case_arg.include?("=")
|
|
228
|
+
key, result = case_arg.split("=", 2)
|
|
229
|
+
key = key.strip
|
|
230
|
+
|
|
231
|
+
if key == "#default"
|
|
232
|
+
default = result
|
|
233
|
+
elsif key == value || pending_cases.include?(value)
|
|
234
|
+
return result
|
|
235
|
+
end
|
|
236
|
+
pending_cases.clear
|
|
237
|
+
else
|
|
238
|
+
# Fall-through case
|
|
239
|
+
trimmed = case_arg.strip
|
|
240
|
+
if trimmed == value
|
|
241
|
+
pending_cases << trimmed
|
|
242
|
+
else
|
|
243
|
+
pending_cases << trimmed
|
|
244
|
+
# Last unnamed value becomes default
|
|
245
|
+
default = case_arg.strip
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
default
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# #ifexpr: expression | then | else
|
|
254
|
+
def evaluate_ifexpr(args)
|
|
255
|
+
return "" if args.empty?
|
|
256
|
+
|
|
257
|
+
expr_str = args[0] || ""
|
|
258
|
+
then_value = args[1] || ""
|
|
259
|
+
else_value = args[2] || ""
|
|
260
|
+
|
|
261
|
+
result = calculate_expression(expr_str)
|
|
262
|
+
return else_value if result.nil?
|
|
263
|
+
|
|
264
|
+
result != 0 ? then_value : else_value
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# #expr: expression
|
|
268
|
+
def evaluate_expr(args)
|
|
269
|
+
return "" if args.empty?
|
|
270
|
+
|
|
271
|
+
expr_str = args[0] || ""
|
|
272
|
+
result = calculate_expression(expr_str)
|
|
273
|
+
return "" if result.nil?
|
|
274
|
+
|
|
275
|
+
# Format result
|
|
276
|
+
if result == result.to_i && !expr_str.include?("/")
|
|
277
|
+
result.to_i.to_s
|
|
278
|
+
elsif result == result.to_i
|
|
279
|
+
result.to_i.to_s
|
|
280
|
+
else
|
|
281
|
+
format("%.2f", result).sub(/0+$/, "").sub(/\.$/, "")
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def calculate_expression(expr_str)
|
|
286
|
+
# Normalize expression
|
|
287
|
+
expr = expr_str.strip
|
|
288
|
+
return nil if expr.empty?
|
|
289
|
+
|
|
290
|
+
# Check if expression contains logical operators
|
|
291
|
+
has_logical = expr.match?(/\b(and|or|not)\b/i)
|
|
292
|
+
|
|
293
|
+
# Replace MediaWiki operators with Ruby equivalents
|
|
294
|
+
expr = expr.gsub(/\bmod\b/i, " % ")
|
|
295
|
+
expr = expr.gsub("^", "**")
|
|
296
|
+
|
|
297
|
+
# Handle single = as equality (MediaWiki style)
|
|
298
|
+
# Be careful not to replace ==, <=, >=, !=
|
|
299
|
+
expr = expr.gsub(/(?<![=!<>])=(?!=)/, "==")
|
|
300
|
+
|
|
301
|
+
# Convert integers to floats for division
|
|
302
|
+
expr = expr.gsub(/\b(\d+)\b/) { "#{$1}.0" }
|
|
303
|
+
|
|
304
|
+
# For logical operators, convert numbers to booleans (0 = false, non-zero = true)
|
|
305
|
+
if has_logical
|
|
306
|
+
# Convert "X and Y" to "(X != 0) && (Y != 0) ? 1 : 0" style
|
|
307
|
+
# But simpler: replace and/or/not to work on != 0 comparison
|
|
308
|
+
expr = expr.gsub(/\band\b/i, "!= 0.0 && ")
|
|
309
|
+
expr = expr.gsub(/\bor\b/i, "!= 0.0 || ")
|
|
310
|
+
expr = expr.gsub(/\bnot\b/i, "== 0.0 ||")
|
|
311
|
+
# Add trailing != 0 for the last operand
|
|
312
|
+
expr = "(#{expr} != 0.0 ? 1.0 : 0.0)"
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Evaluate safely
|
|
316
|
+
begin
|
|
317
|
+
# Only allow safe characters (numbers, operators, parentheses, whitespace, ?)
|
|
318
|
+
return nil unless expr.match?(/\A[\d\s\+\-\*\/\%\(\)\.\<\>\=\!\&\|\?:]+\z/)
|
|
319
|
+
|
|
320
|
+
# Additional validation: reject invalid number formats like "1.0.38.0"
|
|
321
|
+
# These can appear from version numbers or IP addresses in templates
|
|
322
|
+
return nil if expr.match?(/\d+\.\d+\.\d+/)
|
|
323
|
+
|
|
324
|
+
result = eval(expr)
|
|
325
|
+
|
|
326
|
+
# Convert boolean results to 1/0
|
|
327
|
+
case result
|
|
328
|
+
when true then 1.0
|
|
329
|
+
when false then 0.0
|
|
330
|
+
else result.to_f
|
|
331
|
+
end
|
|
332
|
+
rescue StandardError, SyntaxError
|
|
333
|
+
nil
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def numeric?(str)
|
|
338
|
+
!!(str =~ /\A-?\d+\.?\d*\z/)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# #len: string
|
|
342
|
+
def evaluate_len(args)
|
|
343
|
+
str = args[0] || ""
|
|
344
|
+
str.length.to_s
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# #pos: string | search
|
|
348
|
+
def evaluate_pos(args)
|
|
349
|
+
str = args[0] || ""
|
|
350
|
+
search = args[1] || ""
|
|
351
|
+
pos = str.index(search)
|
|
352
|
+
pos.nil? ? "" : pos.to_s
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# #sub: string | start | length
|
|
356
|
+
def evaluate_sub(args)
|
|
357
|
+
str = args[0] || ""
|
|
358
|
+
start = (args[1] || "0").to_i
|
|
359
|
+
length = args[2]&.to_i
|
|
360
|
+
|
|
361
|
+
if length
|
|
362
|
+
str[start, length] || ""
|
|
363
|
+
else
|
|
364
|
+
str[start..] || ""
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# #replace: string | search | replace
|
|
369
|
+
def evaluate_replace(args)
|
|
370
|
+
str = args[0] || ""
|
|
371
|
+
search = args[1] || ""
|
|
372
|
+
replace = args[2] || ""
|
|
373
|
+
str.gsub(search, replace)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# #rpos: string | search (find last occurrence)
|
|
377
|
+
def evaluate_rpos(args)
|
|
378
|
+
str = args[0] || ""
|
|
379
|
+
search = args[1] || ""
|
|
380
|
+
pos = str.rindex(search)
|
|
381
|
+
pos.nil? ? "-1" : pos.to_s
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# #count: string | search (count occurrences)
|
|
385
|
+
def evaluate_count(args)
|
|
386
|
+
str = args[0] || ""
|
|
387
|
+
search = args[1] || ""
|
|
388
|
+
return "0" if search.empty?
|
|
389
|
+
# Non-overlapping count
|
|
390
|
+
str.scan(search).length.to_s
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# #explode: string | delimiter | index
|
|
394
|
+
def evaluate_explode(args)
|
|
395
|
+
str = args[0] || ""
|
|
396
|
+
delimiter = args[1] || ""
|
|
397
|
+
index = (args[2] || "0").to_i
|
|
398
|
+
|
|
399
|
+
parts = str.split(delimiter)
|
|
400
|
+
return "" if parts.empty?
|
|
401
|
+
|
|
402
|
+
# Handle negative index (from end)
|
|
403
|
+
if index.negative?
|
|
404
|
+
index = parts.length + index
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
return "" if index < 0 || index >= parts.length
|
|
408
|
+
parts[index] || ""
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# #urldecode: string
|
|
412
|
+
def evaluate_urldecode(args)
|
|
413
|
+
str = args[0] || ""
|
|
414
|
+
require "cgi"
|
|
415
|
+
CGI.unescape(str)
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# #urlencode: string
|
|
419
|
+
def evaluate_urlencode(args)
|
|
420
|
+
str = args[0] || ""
|
|
421
|
+
require "uri"
|
|
422
|
+
URI.encode_www_form_component(str).gsub("+", "%20")
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# #padleft: string | length | padding
|
|
426
|
+
def evaluate_padleft(args)
|
|
427
|
+
str = args[0] || ""
|
|
428
|
+
length = (args[1] || "0").to_i
|
|
429
|
+
padding = args[2] || " "
|
|
430
|
+
padding = " " if padding.empty?
|
|
431
|
+
|
|
432
|
+
return str if str.length >= length
|
|
433
|
+
(padding * ((length - str.length) / padding.length + 1))[0, length - str.length] + str
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# #padright: string | length | padding
|
|
437
|
+
def evaluate_padright(args)
|
|
438
|
+
str = args[0] || ""
|
|
439
|
+
length = (args[1] || "0").to_i
|
|
440
|
+
padding = args[2] || " "
|
|
441
|
+
padding = " " if padding.empty?
|
|
442
|
+
|
|
443
|
+
return str if str.length >= length
|
|
444
|
+
str + (padding * ((length - str.length) / padding.length + 1))[0, length - str.length]
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# #iferror: input | then | else
|
|
448
|
+
def evaluate_iferror(args)
|
|
449
|
+
input = args[0] || ""
|
|
450
|
+
then_value = args[1]
|
|
451
|
+
else_value = args[2] || ""
|
|
452
|
+
|
|
453
|
+
# Check for error indicators
|
|
454
|
+
has_error = input.include?('class="error"') ||
|
|
455
|
+
input.include?("class='error'") ||
|
|
456
|
+
input.match?(/Expression error/i)
|
|
457
|
+
|
|
458
|
+
if has_error
|
|
459
|
+
then_value || ""
|
|
460
|
+
elsif then_value.nil?
|
|
461
|
+
input
|
|
462
|
+
else
|
|
463
|
+
else_value
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# #titleparts: title | parts | offset
|
|
468
|
+
def evaluate_titleparts(args)
|
|
469
|
+
title = args[0] || ""
|
|
470
|
+
parts_count = (args[1] || "0").to_i
|
|
471
|
+
offset = (args[2] || "0").to_i
|
|
472
|
+
|
|
473
|
+
# Split by / but keep namespace prefix with first part
|
|
474
|
+
segments = title.split("/")
|
|
475
|
+
return title if segments.empty?
|
|
476
|
+
|
|
477
|
+
# Apply offset
|
|
478
|
+
if offset.positive?
|
|
479
|
+
segments = segments[offset..] || []
|
|
480
|
+
elsif offset.negative?
|
|
481
|
+
segments = segments[0...offset] || []
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# Apply parts count
|
|
485
|
+
if parts_count.positive?
|
|
486
|
+
segments = segments[0, parts_count]
|
|
487
|
+
elsif parts_count.negative?
|
|
488
|
+
segments = segments[0...parts_count]
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
segments.join("/")
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# #time: format | date
|
|
495
|
+
def evaluate_time(args)
|
|
496
|
+
format_str = args[0] || ""
|
|
497
|
+
date_str = args[1]
|
|
498
|
+
|
|
499
|
+
time = if date_str && !date_str.strip.empty?
|
|
500
|
+
parse_date(date_str.strip)
|
|
501
|
+
else
|
|
502
|
+
@reference_date
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
return "" unless time
|
|
506
|
+
|
|
507
|
+
format_time(time, format_str)
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def parse_date(str)
|
|
511
|
+
return nil if str.nil? || str.strip.empty?
|
|
512
|
+
|
|
513
|
+
# Try common formats
|
|
514
|
+
formats = ["%Y-%m-%d", "%Y/%m/%d", "%d %B %Y", "%B %d, %Y", "%Y"]
|
|
515
|
+
|
|
516
|
+
formats.each do |fmt|
|
|
517
|
+
begin
|
|
518
|
+
time = Time.strptime(str.strip, fmt)
|
|
519
|
+
# Validate the parsed time is reasonable (year 1-9999)
|
|
520
|
+
return time if time.year > 0 && time.year < 10000
|
|
521
|
+
rescue ArgumentError, RangeError
|
|
522
|
+
next
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
nil
|
|
527
|
+
rescue StandardError
|
|
528
|
+
# Catch any unexpected errors during date parsing
|
|
529
|
+
nil
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
DAY_NAMES = %w[Sunday Monday Tuesday Wednesday Thursday Friday Saturday].freeze
|
|
533
|
+
|
|
534
|
+
def format_time(time, format_str)
|
|
535
|
+
result = +""
|
|
536
|
+
i = 0
|
|
537
|
+
|
|
538
|
+
while i < format_str.length
|
|
539
|
+
c = format_str[i]
|
|
540
|
+
next_c = format_str[i + 1]
|
|
541
|
+
|
|
542
|
+
# Handle two-character sequences
|
|
543
|
+
if c == "j" && next_c == "S"
|
|
544
|
+
result << time.day.to_s << ordinal_suffix(time.day)
|
|
545
|
+
i += 2
|
|
546
|
+
next
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
result << case c
|
|
550
|
+
# Year
|
|
551
|
+
when "Y" then time.year.to_s
|
|
552
|
+
when "y" then (time.year % 100).to_s.rjust(2, "0")
|
|
553
|
+
# Month
|
|
554
|
+
when "m" then time.month.to_s.rjust(2, "0")
|
|
555
|
+
when "n" then time.month.to_s
|
|
556
|
+
when "F" then MONTH_NAMES[time.month - 1]
|
|
557
|
+
when "M" then MONTH_NAMES[time.month - 1][0, 3]
|
|
558
|
+
# Day
|
|
559
|
+
when "d" then time.day.to_s.rjust(2, "0")
|
|
560
|
+
when "j" then time.day.to_s
|
|
561
|
+
when "S" then ordinal_suffix(time.day)
|
|
562
|
+
# Day of week
|
|
563
|
+
when "l" then DAY_NAMES[time.wday]
|
|
564
|
+
when "D" then DAY_NAMES[time.wday][0, 3]
|
|
565
|
+
when "N" then (time.wday == 0 ? 7 : time.wday).to_s
|
|
566
|
+
when "w" then time.wday.to_s
|
|
567
|
+
# Week
|
|
568
|
+
when "W" then time.strftime("%V")
|
|
569
|
+
# Hour
|
|
570
|
+
when "H" then time.hour.to_s.rjust(2, "0")
|
|
571
|
+
when "G" then time.hour.to_s
|
|
572
|
+
when "g" then (time.hour % 12 == 0 ? 12 : time.hour % 12).to_s
|
|
573
|
+
when "h" then (time.hour % 12 == 0 ? 12 : time.hour % 12).to_s.rjust(2, "0")
|
|
574
|
+
# Minute/Second
|
|
575
|
+
when "i" then time.min.to_s.rjust(2, "0")
|
|
576
|
+
when "s" then time.sec.to_s.rjust(2, "0")
|
|
577
|
+
# AM/PM
|
|
578
|
+
when "a" then time.hour < 12 ? "am" : "pm"
|
|
579
|
+
when "A" then time.hour < 12 ? "AM" : "PM"
|
|
580
|
+
# Timezone
|
|
581
|
+
when "T" then time.strftime("%Z")
|
|
582
|
+
when "O" then time.strftime("%z")
|
|
583
|
+
# Unix timestamp
|
|
584
|
+
when "U" then time.to_i.to_s
|
|
585
|
+
else c
|
|
586
|
+
end
|
|
587
|
+
i += 1
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
result
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
def ordinal_suffix(day)
|
|
594
|
+
if (11..13).include?(day % 100)
|
|
595
|
+
"th"
|
|
596
|
+
else
|
|
597
|
+
case day % 10
|
|
598
|
+
when 1 then "st"
|
|
599
|
+
when 2 then "nd"
|
|
600
|
+
when 3 then "rd"
|
|
601
|
+
else "th"
|
|
602
|
+
end
|
|
603
|
+
end
|
|
604
|
+
end
|
|
605
|
+
end
|
|
606
|
+
end
|