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.
Files changed (96) hide show
  1. checksums.yaml +4 -4
  2. data/.dockerignore +12 -0
  3. data/.github/workflows/ci.yml +13 -13
  4. data/.gitignore +14 -0
  5. data/CHANGELOG.md +284 -0
  6. data/DEVELOPMENT.md +415 -0
  7. data/DEVELOPMENT_ja.md +415 -0
  8. data/Dockerfile +19 -10
  9. data/Gemfile +2 -8
  10. data/README.md +259 -123
  11. data/README_ja.md +375 -0
  12. data/Rakefile +4 -0
  13. data/bin/wp2txt +863 -161
  14. data/lib/wp2txt/article.rb +98 -13
  15. data/lib/wp2txt/bz2_validator.rb +239 -0
  16. data/lib/wp2txt/category_cache.rb +313 -0
  17. data/lib/wp2txt/cli.rb +319 -0
  18. data/lib/wp2txt/cli_ui.rb +428 -0
  19. data/lib/wp2txt/config.rb +158 -0
  20. data/lib/wp2txt/constants.rb +134 -0
  21. data/lib/wp2txt/data/html_entities.json +2135 -0
  22. data/lib/wp2txt/data/language_metadata.json +4769 -0
  23. data/lib/wp2txt/data/language_tiers.json +59 -0
  24. data/lib/wp2txt/data/mediawiki_aliases.json +12366 -0
  25. data/lib/wp2txt/data/template_aliases.json +193 -0
  26. data/lib/wp2txt/data/wikipedia_entities.json +12 -0
  27. data/lib/wp2txt/extractor.rb +545 -0
  28. data/lib/wp2txt/file_utils.rb +91 -0
  29. data/lib/wp2txt/formatter.rb +352 -0
  30. data/lib/wp2txt/global_data_cache.rb +353 -0
  31. data/lib/wp2txt/index_cache.rb +258 -0
  32. data/lib/wp2txt/magic_words.rb +353 -0
  33. data/lib/wp2txt/memory_monitor.rb +236 -0
  34. data/lib/wp2txt/multistream.rb +1383 -0
  35. data/lib/wp2txt/output_writer.rb +182 -0
  36. data/lib/wp2txt/parser_functions.rb +606 -0
  37. data/lib/wp2txt/ractor_worker.rb +215 -0
  38. data/lib/wp2txt/regex.rb +396 -12
  39. data/lib/wp2txt/section_extractor.rb +354 -0
  40. data/lib/wp2txt/stream_processor.rb +271 -0
  41. data/lib/wp2txt/template_expander.rb +830 -0
  42. data/lib/wp2txt/text_processing.rb +337 -0
  43. data/lib/wp2txt/utils.rb +629 -270
  44. data/lib/wp2txt/version.rb +1 -1
  45. data/lib/wp2txt.rb +53 -26
  46. data/scripts/benchmark_regex.rb +161 -0
  47. data/scripts/fetch_html_entities.rb +94 -0
  48. data/scripts/fetch_language_metadata.rb +180 -0
  49. data/scripts/fetch_mediawiki_data.rb +334 -0
  50. data/scripts/fetch_template_data.rb +186 -0
  51. data/scripts/profile_memory.rb +139 -0
  52. data/spec/article_spec.rb +402 -0
  53. data/spec/auto_download_spec.rb +314 -0
  54. data/spec/bz2_validator_spec.rb +193 -0
  55. data/spec/category_cache_spec.rb +226 -0
  56. data/spec/category_fetcher_spec.rb +504 -0
  57. data/spec/cleanup_spec.rb +197 -0
  58. data/spec/cli_options_spec.rb +678 -0
  59. data/spec/cli_spec.rb +876 -0
  60. data/spec/config_spec.rb +194 -0
  61. data/spec/constants_spec.rb +138 -0
  62. data/spec/file_utils_spec.rb +170 -0
  63. data/spec/fixtures/samples.rb +181 -0
  64. data/spec/formatter_sections_spec.rb +382 -0
  65. data/spec/global_data_cache_spec.rb +186 -0
  66. data/spec/index_cache_spec.rb +210 -0
  67. data/spec/integration_spec.rb +543 -0
  68. data/spec/magic_words_spec.rb +261 -0
  69. data/spec/markers_spec.rb +476 -0
  70. data/spec/memory_monitor_spec.rb +192 -0
  71. data/spec/multistream_spec.rb +690 -0
  72. data/spec/output_writer_spec.rb +400 -0
  73. data/spec/parser_functions_spec.rb +455 -0
  74. data/spec/ractor_worker_spec.rb +197 -0
  75. data/spec/regex_spec.rb +281 -0
  76. data/spec/section_extractor_spec.rb +397 -0
  77. data/spec/spec_helper.rb +63 -0
  78. data/spec/stream_processor_spec.rb +579 -0
  79. data/spec/template_data_spec.rb +246 -0
  80. data/spec/template_expander_spec.rb +472 -0
  81. data/spec/template_processing_spec.rb +217 -0
  82. data/spec/text_processing_spec.rb +312 -0
  83. data/spec/utils_spec.rb +195 -16
  84. data/spec/wp2txt_spec.rb +510 -0
  85. data/wp2txt.gemspec +5 -3
  86. metadata +146 -18
  87. data/.rubocop.yml +0 -80
  88. data/data/output_samples/testdata_en.txt +0 -23002
  89. data/data/output_samples/testdata_en_category.txt +0 -132
  90. data/data/output_samples/testdata_en_summary.txt +0 -1376
  91. data/data/output_samples/testdata_ja.txt +0 -22774
  92. data/data/output_samples/testdata_ja_category.txt +0 -206
  93. data/data/output_samples/testdata_ja_summary.txt +0 -1560
  94. data/data/testdata_en.bz2 +0 -0
  95. data/data/testdata_ja.bz2 +0 -0
  96. 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