cjk_index 0.2.0 → 1.0.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 +62 -0
- data/LICENSE-Unicode +39 -0
- data/NOTICE +17 -0
- data/README.md +164 -41
- data/data/ja-kyujitai.tsv +423 -292
- data/data/ko-hanyang-pua.tsv +5666 -0
- data/data/zh-hant-hans-safe.tsv +2774 -0
- data/exe/cjk_index +6 -0
- data/lib/cjk_index/bridgetown.rb +100 -0
- data/lib/cjk_index/builder.rb +247 -89
- data/lib/cjk_index/cli.rb +240 -0
- data/lib/cjk_index/index_file.rb +4 -0
- data/lib/cjk_index/jekyll.rb +21 -5
- data/lib/cjk_index/normalizer.rb +60 -12
- data/lib/cjk_index/postings.rb +218 -0
- data/lib/cjk_index/rake_task.rb +71 -0
- data/lib/cjk_index/runtime.js +243 -29
- data/lib/cjk_index/runtime.rb +1 -0
- data/lib/cjk_index/searcher.rb +101 -37
- data/lib/cjk_index/tokenizer.rb +30 -0
- data/lib/cjk_index/version.rb +1 -1
- data/lib/cjk_index.rb +1 -0
- metadata +16 -4
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optparse"
|
|
5
|
+
require_relative "../cjk_index"
|
|
6
|
+
|
|
7
|
+
module CJKIndex
|
|
8
|
+
# The command-line tool (exe/cjk_index) and the logic that the Rake task
|
|
9
|
+
# shares with it. Needs only Ruby's standard library; "csv" is loaded the
|
|
10
|
+
# first time a CSV file is read (see Records.csv).
|
|
11
|
+
#
|
|
12
|
+
# cjk_index build items.csv --ref objectid --fields title,creator --boost title=3 -o index.json
|
|
13
|
+
# cjk_index search index.json 鳥瞰図 --filter type=地図
|
|
14
|
+
# cjk_index runtime -o cjk-index.js
|
|
15
|
+
module CLI
|
|
16
|
+
# A mistake in the arguments or the input; run prints it and returns 2.
|
|
17
|
+
class Error < StandardError; end
|
|
18
|
+
|
|
19
|
+
USAGE = <<~TEXT
|
|
20
|
+
Usage: cjk_index COMMAND [options]
|
|
21
|
+
|
|
22
|
+
Commands:
|
|
23
|
+
build read records (CSV, JSON, JSON Lines) and write an index
|
|
24
|
+
search query an index file
|
|
25
|
+
runtime write the browser script
|
|
26
|
+
|
|
27
|
+
Run "cjk_index COMMAND --help" for the options of a command.
|
|
28
|
+
Options: -h, --help -v, --version
|
|
29
|
+
TEXT
|
|
30
|
+
|
|
31
|
+
FORMATS = %w[csv json jsonl].freeze
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
# Runs the tool and returns the exit status: 0 done, 1 failed (input could
|
|
36
|
+
# not be read), 2 wrong usage. Output goes to the given streams.
|
|
37
|
+
def run(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr)
|
|
38
|
+
argv = argv.dup
|
|
39
|
+
command = argv.shift
|
|
40
|
+
case command
|
|
41
|
+
when nil
|
|
42
|
+
stderr.puts USAGE
|
|
43
|
+
return 2
|
|
44
|
+
when "-h", "--help", "help" then stdout.puts USAGE
|
|
45
|
+
when "-v", "--version", "version" then stdout.puts VERSION
|
|
46
|
+
when "build" then build_command(argv, stdin, stdout)
|
|
47
|
+
when "search" then search_command(argv, stdout)
|
|
48
|
+
when "runtime" then runtime_command(argv, stdout)
|
|
49
|
+
else raise Error, "unknown command #{command.inspect}\n\n#{USAGE}"
|
|
50
|
+
end
|
|
51
|
+
0
|
|
52
|
+
rescue Error, OptionParser::ParseError, ArgumentError => e
|
|
53
|
+
stderr.puts "cjk_index: #{e.message.sub(/\Acjk_index: /, '')}"
|
|
54
|
+
2
|
|
55
|
+
rescue SystemCallError, JSON::ParserError, IOError => e
|
|
56
|
+
stderr.puts "cjk_index: #{e.message}"
|
|
57
|
+
1
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# ---- build -------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def build_command(argv, stdin, stdout)
|
|
63
|
+
opts = { fields: nil, boosts: {}, filters: {}, variants: Normalizer::DEFAULT_TABLES }
|
|
64
|
+
output = runtime = nil
|
|
65
|
+
parser = OptionParser.new do |o|
|
|
66
|
+
o.banner = "Usage: cjk_index build [FILE|-] [options]\n" \
|
|
67
|
+
"Records come from FILE or standard input: CSV, a JSON array, or JSON Lines."
|
|
68
|
+
o.on("--format FORMAT", FORMATS, "csv, json or jsonl (default: from the file name, or sniffed on stdin)") { |v| opts[:format] = v }
|
|
69
|
+
o.on("--ref COLUMN", "field that identifies a record (default: first field)") { |v| opts[:ref] = v }
|
|
70
|
+
o.on("--fields LIST", Array, "fields to index (default: all but the ref)") { |v| opts[:fields] = v }
|
|
71
|
+
o.on("--boost FIELD=N", "weight of a field, repeatable (default 1)") { |v| name, n = split_pair(v, "--boost"); opts[:boosts][name] = Float(n) }
|
|
72
|
+
o.on("--filter FIELD[=KIND[:SEP]]", "filter on a field: KIND value (default) or year; SEP splits several values in one cell") do |v|
|
|
73
|
+
name, spec = parse_filter(v)
|
|
74
|
+
opts[:filters][name] = spec
|
|
75
|
+
end
|
|
76
|
+
o.on("--variants LIST", Array, "variant tables: ja (default), zh-safe, zh, ko") { |v| opts[:variants] = v }
|
|
77
|
+
o.on("-o", "--output PATH", "index file (default: standard output)") { |v| output = v }
|
|
78
|
+
o.on("--runtime PATH", "also write the browser script for these variants") { |v| runtime = v }
|
|
79
|
+
end
|
|
80
|
+
parser.parse!(argv)
|
|
81
|
+
raise Error, "too many arguments: #{argv.drop(1).join(' ')}" if argv.length > 1
|
|
82
|
+
|
|
83
|
+
source = argv.first
|
|
84
|
+
source = nil if source == "-"
|
|
85
|
+
format = opts.delete(:format) || format_of(source)
|
|
86
|
+
builder = build_index(**opts, records: read_records(source, format, stdin))
|
|
87
|
+
write_output(output, stdout, builder.to_json)
|
|
88
|
+
write_output(runtime, nil, Runtime.source(normalizer: builder.normalizer)) if runtime
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Builds an index from records (an Array of Hashes with string keys).
|
|
92
|
+
# Shared by the command and CJKIndex::RakeTask.
|
|
93
|
+
def build_index(records:, ref: nil, fields: nil, boosts: {}, filters: {}, variants: Normalizer::DEFAULT_TABLES)
|
|
94
|
+
raise Error, "no records" if records.empty?
|
|
95
|
+
|
|
96
|
+
keys = records.first.keys.map(&:to_s)
|
|
97
|
+
ref = (ref || keys.first).to_s
|
|
98
|
+
raise Error, "no field #{ref.inspect} in the records (fields: #{keys.join(', ')})" unless keys.include?(ref)
|
|
99
|
+
|
|
100
|
+
fields = Array(fields || (keys - [ref])).map(&:to_s)
|
|
101
|
+
raise Error, "no fields to index" if fields.empty?
|
|
102
|
+
|
|
103
|
+
missing = (fields + filters.keys.map(&:to_s) + boosts.keys.map(&:to_s)) - keys
|
|
104
|
+
raise Error, "no such field in the records: #{missing.uniq.join(', ')} (fields: #{keys.join(', ')})" unless missing.empty?
|
|
105
|
+
|
|
106
|
+
builder = Builder.new(fields: fields, boosts: boosts, filters: filters,
|
|
107
|
+
normalizer: Normalizer.new(tables: Array(variants).map(&:to_s)))
|
|
108
|
+
records.each do |record|
|
|
109
|
+
value = record[ref] || record[ref.to_sym]
|
|
110
|
+
builder.add(value, record) unless value.to_s.empty?
|
|
111
|
+
end
|
|
112
|
+
builder
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# ---- reading records ---------------------------------------------------
|
|
116
|
+
|
|
117
|
+
def format_of(path)
|
|
118
|
+
case File.extname(path.to_s).downcase
|
|
119
|
+
when ".csv" then "csv"
|
|
120
|
+
when ".json" then "json"
|
|
121
|
+
when ".jsonl", ".ndjson" then "jsonl"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def read_records(path, format, stdin = $stdin)
|
|
126
|
+
text = path ? File.read(path, encoding: "UTF-8") : stdin.read.to_s.dup.force_encoding("UTF-8")
|
|
127
|
+
text = text.delete_prefix("")
|
|
128
|
+
format ||= text.lstrip.start_with?("[") ? "json" : "jsonl"
|
|
129
|
+
case format
|
|
130
|
+
when "csv" then csv(text)
|
|
131
|
+
when "json"
|
|
132
|
+
data = JSON.parse(text)
|
|
133
|
+
raise Error, "the JSON must be an array of objects" unless data.is_a?(Array) && data.all?(Hash)
|
|
134
|
+
|
|
135
|
+
data
|
|
136
|
+
else
|
|
137
|
+
text.each_line.reject { |l| l.strip.empty? }.map.with_index(1) do |line, n|
|
|
138
|
+
JSON.parse(line).tap { |r| raise Error, "line #{n} is not a JSON object" unless r.is_a?(Hash) }
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# CSV is a bundled gem, not a default one, since Ruby 3.4: under Bundler
|
|
144
|
+
# it can only be loaded when the Gemfile lists it. The gem does not depend
|
|
145
|
+
# on it, so it is loaded here and a missing one is reported.
|
|
146
|
+
def csv(text)
|
|
147
|
+
begin
|
|
148
|
+
require "csv"
|
|
149
|
+
rescue LoadError
|
|
150
|
+
raise Error, "reading CSV needs the csv gem (a bundled gem since Ruby 3.4); " \
|
|
151
|
+
"run `gem install csv`, add `gem \"csv\"` to your Gemfile, or give JSON / JSON Lines instead"
|
|
152
|
+
end
|
|
153
|
+
CSV.parse(text, headers: true).map(&:to_h)
|
|
154
|
+
rescue CSV::MalformedCSVError => e
|
|
155
|
+
raise Error, "invalid CSV: #{e.message}"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# ---- search ------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
def search_command(argv, stdout)
|
|
161
|
+
filter = {}
|
|
162
|
+
limit = 10
|
|
163
|
+
json = false
|
|
164
|
+
parser = OptionParser.new do |o|
|
|
165
|
+
o.banner = "Usage: cjk_index search INDEX.json QUERY [options]"
|
|
166
|
+
o.on("--filter FIELD=VALUE", "keep matching records; repeat for several values or fields; a year filter takes 1900 or 1890..1910") do |v|
|
|
167
|
+
name, value = split_pair(v, "--filter")
|
|
168
|
+
(filter[name] ||= []) << value
|
|
169
|
+
end
|
|
170
|
+
o.on("--limit N", Integer, "results to show (default 10, 0 for all)") { |v| limit = v }
|
|
171
|
+
o.on("--json", "print JSON: [{ref, score, fields}]") { json = true }
|
|
172
|
+
end
|
|
173
|
+
parser.parse!(argv)
|
|
174
|
+
path, *words = argv
|
|
175
|
+
raise Error, "usage: cjk_index search INDEX.json QUERY" if path.nil? || (words.empty? && filter.empty?)
|
|
176
|
+
|
|
177
|
+
searcher = Searcher.load(path)
|
|
178
|
+
filter = filter.transform_values { |values| values.map { |v| filter_value(v) } }
|
|
179
|
+
filter = filter.transform_values { |values| values.length == 1 ? values.first : values }
|
|
180
|
+
results = searcher.search(words.join(" "), filter: filter.empty? ? nil : filter)
|
|
181
|
+
shown = limit.positive? ? results.first(limit) : results
|
|
182
|
+
if json
|
|
183
|
+
stdout.puts JSON.generate(shown)
|
|
184
|
+
else
|
|
185
|
+
shown.each { |r| stdout.puts format("%s\t%.3f\t%s", r[:ref], r[:score], r[:fields].join(",")) }
|
|
186
|
+
stdout.puts "(#{results.length} result#{'s' unless results.length == 1})" if shown.length < results.length
|
|
187
|
+
end
|
|
188
|
+
rescue Errno::ENOENT
|
|
189
|
+
raise Error, "no such file: #{path}"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# ---- runtime -----------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
def runtime_command(argv, stdout)
|
|
195
|
+
variants = Normalizer::DEFAULT_TABLES
|
|
196
|
+
output = nil
|
|
197
|
+
OptionParser.new do |o|
|
|
198
|
+
o.banner = "Usage: cjk_index runtime [options]"
|
|
199
|
+
o.on("--variants LIST", Array, "variant tables: ja (default), zh-safe, zh, ko; must match the index") { |v| variants = v }
|
|
200
|
+
o.on("-o", "--output PATH", "script file (default: standard output)") { |v| output = v }
|
|
201
|
+
end.parse!(argv)
|
|
202
|
+
raise Error, "too many arguments: #{argv.join(' ')}" unless argv.empty?
|
|
203
|
+
|
|
204
|
+
write_output(output, stdout, Runtime.source(normalizer: Normalizer.new(tables: variants)))
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# ---- helpers -----------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
def split_pair(text, option)
|
|
210
|
+
name, value = text.split("=", 2)
|
|
211
|
+
raise Error, "#{option} takes NAME=VALUE, got #{text.inspect}" if name.to_s.empty? || value.nil?
|
|
212
|
+
|
|
213
|
+
[name, value]
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# "type", "date=year", "subject=value:;"
|
|
217
|
+
def parse_filter(text)
|
|
218
|
+
name, spec = text.split("=", 2)
|
|
219
|
+
kind, separator = (spec || "value").split(":", 2)
|
|
220
|
+
raise Error, "--filter #{text.inspect}: kind must be value or year" unless Builder::FILTER_KINDS.include?(kind)
|
|
221
|
+
|
|
222
|
+
[name, separator ? { "kind" => kind, "separator" => separator } : kind]
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def filter_value(text)
|
|
226
|
+
return text unless (m = text.match(/\A(\d*)\.\.(\d*)\z/)) && !(m[1].empty? && m[2].empty?)
|
|
227
|
+
|
|
228
|
+
{ "min" => (m[1].empty? ? nil : m[1].to_i), "max" => (m[2].empty? ? nil : m[2].to_i) }.compact
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def write_output(path, stdout, content)
|
|
232
|
+
if path
|
|
233
|
+
File.write(path, content)
|
|
234
|
+
else
|
|
235
|
+
stdout.write(content)
|
|
236
|
+
stdout.write("\n") unless content.end_with?("\n")
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
data/lib/cjk_index/index_file.rb
CHANGED
|
@@ -27,6 +27,10 @@ module CJKIndex
|
|
|
27
27
|
#
|
|
28
28
|
# Locks use flock, which is reliable on a local disk; do not share the file
|
|
29
29
|
# over NFS.
|
|
30
|
+
#
|
|
31
|
+
# The index is always one file (format 4, as Builder#write). A split index
|
|
32
|
+
# (Builder#write_split) is not supported here: it is meant for static
|
|
33
|
+
# sites, written once per build.
|
|
30
34
|
class IndexFile
|
|
31
35
|
# Raised by update and write when another writer holds the lock too long.
|
|
32
36
|
class LockTimeout < StandardError; end
|
data/lib/cjk_index/jekyll.rb
CHANGED
|
@@ -25,8 +25,15 @@ module CJKIndex
|
|
|
25
25
|
# date: year
|
|
26
26
|
# - name: search
|
|
27
27
|
# preset: collectionbuilder # reads CollectionBuilder's own config
|
|
28
|
+
# - name: texts
|
|
29
|
+
# collection: texts
|
|
30
|
+
# fields: [title, content]
|
|
31
|
+
# split: true # -> assets/cjk-index/texts/index.json and shards
|
|
32
|
+
# shard_size: 32000 # bytes per shard (optional)
|
|
28
33
|
#
|
|
29
|
-
# The browser script is written to <output>/cjk-index.js.
|
|
34
|
+
# The browser script is written to <output>/cjk-index.js. A split index is
|
|
35
|
+
# loaded with CJKIndex.load('<output>/<name>/index.json') and searched with
|
|
36
|
+
# searchAsync, which fetches only the shards a query needs (see Builder).
|
|
30
37
|
module Jekyll
|
|
31
38
|
DEFAULT_OUTPUT = "assets/cjk-index"
|
|
32
39
|
RUNTIME_NAME = "cjk-index.js"
|
|
@@ -61,9 +68,18 @@ module CJKIndex
|
|
|
61
68
|
site.static_files << GeneratedFile.new(site, output, RUNTIME_NAME, Runtime.source(normalizer: normalizer))
|
|
62
69
|
Array(config["indexes"]).each do |spec|
|
|
63
70
|
builder = build(site, spec, normalizer)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
71
|
+
if spec["split"]
|
|
72
|
+
dir = "#{output}/#{spec.fetch('name')}"
|
|
73
|
+
files = builder.each_split_file(shard_size: Integer(spec.fetch("shard_size", Builder::SHARD_SIZE)))
|
|
74
|
+
.map { |name, content| GeneratedFile.new(site, dir, name, content) }
|
|
75
|
+
site.static_files.concat(files)
|
|
76
|
+
::Jekyll.logger.info "cjk_index:", "#{dir}/#{Builder::MANIFEST} (#{builder.refs.length} documents, " \
|
|
77
|
+
"#{files.length - 2} shards)"
|
|
78
|
+
else
|
|
79
|
+
name = "#{spec.fetch('name')}.json"
|
|
80
|
+
site.static_files << GeneratedFile.new(site, output, name, builder.to_json)
|
|
81
|
+
::Jekyll.logger.info "cjk_index:", "#{output}/#{name} (#{builder.refs.length} documents)"
|
|
82
|
+
end
|
|
67
83
|
end
|
|
68
84
|
end
|
|
69
85
|
|
|
@@ -79,7 +95,7 @@ module CJKIndex
|
|
|
79
95
|
docs = site.collections.fetch(spec["collection"]) do
|
|
80
96
|
raise ::Jekyll::Errors::FatalException, "cjk_index: no collection #{spec['collection']}"
|
|
81
97
|
end.docs
|
|
82
|
-
docs.each { |doc| builder.add(doc.url, document_fields(doc, builder.fields)) }
|
|
98
|
+
docs.each { |doc| builder.add(doc.url, document_fields(doc, builder.fields | builder.filters.keys)) }
|
|
83
99
|
elsif spec["data"]
|
|
84
100
|
ref = spec.fetch("ref", "id")
|
|
85
101
|
Array(site.data[spec["data"]]).each do |row|
|
data/lib/cjk_index/normalizer.rb
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
3
6
|
module CJKIndex
|
|
4
7
|
# Folds text so that spelling variants a reader treats as "the same" compare
|
|
5
8
|
# equal: NFKC (full-width -> half-width etc.), lowercase, katakana ->
|
|
@@ -9,18 +12,29 @@ module CJKIndex
|
|
|
9
12
|
# The same rules run in the browser (see Runtime), so an index built here
|
|
10
13
|
# and a query typed there are folded identically.
|
|
11
14
|
#
|
|
12
|
-
# Variant tables (data/*.tsv):
|
|
13
|
-
# ja
|
|
14
|
-
#
|
|
15
|
-
#
|
|
15
|
+
# Variant tables (data/*.tsv, regenerated by script/variant_tables.rb):
|
|
16
|
+
# ja Japanese old forms and glyph variants -> one form (圖 -> 図),
|
|
17
|
+
# from the Joyo and Jinmeiyo kanji lists and Unihan. Default.
|
|
18
|
+
# zh Traditional -> Simplified Chinese, from OpenCC. Opt in: it also
|
|
19
|
+
# merges characters that are distinct in Japanese (後 and 后).
|
|
20
|
+
# zh-safe The pairs of zh that are one-to-one both ways (圖 -> 图 but
|
|
21
|
+
# not 後 -> 后 or 發/髮 -> 发). Opt in.
|
|
22
|
+
# ko Old Hangul in the Hanyang Private Use Area -> standard jamo
|
|
23
|
+
# sequences (one character -> several). Opt in.
|
|
16
24
|
# With several tables, characters linked by any table fold to one form,
|
|
17
25
|
# preferring the target of the table listed first: with [ja, zh],
|
|
18
26
|
# 圖, 図 and 图 all become 図.
|
|
27
|
+
#
|
|
28
|
+
# Table format: UTF-8 lines "variant<TAB>folded", optionally followed by
|
|
29
|
+
# another tab and a comment column; lines starting with "#" are comments.
|
|
30
|
+
# "folded" may be several characters.
|
|
19
31
|
class Normalizer
|
|
20
32
|
DATA_DIR = File.expand_path("../../data", __dir__)
|
|
21
33
|
TABLES = {
|
|
22
34
|
"ja" => "ja-kyujitai.tsv",
|
|
23
|
-
"zh" => "zh-hant-hans.tsv"
|
|
35
|
+
"zh" => "zh-hant-hans.tsv",
|
|
36
|
+
"zh-safe" => "zh-hant-hans-safe.tsv",
|
|
37
|
+
"ko" => "ko-hanyang-pua.tsv"
|
|
24
38
|
}.freeze
|
|
25
39
|
DEFAULT_TABLES = %w[ja].freeze
|
|
26
40
|
|
|
@@ -38,31 +52,65 @@ module CJKIndex
|
|
|
38
52
|
file = TABLES.fetch(name) { raise ArgumentError, "unknown variant table #{name.inspect} (#{TABLES.keys.join(', ')})" }
|
|
39
53
|
File.readlines(File.join(DATA_DIR, file), chomp: true, encoding: "UTF-8")
|
|
40
54
|
.reject { |line| line.empty? || line.start_with?("#") }
|
|
41
|
-
.map { |line| line.split("\t", 2
|
|
55
|
+
.map { |line| line.split("\t")[0, 2] }
|
|
42
56
|
end
|
|
43
57
|
|
|
44
|
-
attr_reader :tables, :variants
|
|
58
|
+
attr_reader :tables, :variants, :digest
|
|
59
|
+
|
|
60
|
+
KATAKANA = "#{KATAKANA_FIRST.chr(Encoding::UTF_8)}-#{KATAKANA_LAST.chr(Encoding::UTF_8)}"
|
|
61
|
+
HIRAGANA = "#{(KATAKANA_FIRST - KANA_OFFSET).chr(Encoding::UTF_8)}-#{(KATAKANA_LAST - KANA_OFFSET).chr(Encoding::UTF_8)}"
|
|
45
62
|
|
|
46
63
|
def initialize(tables: DEFAULT_TABLES)
|
|
47
64
|
@tables = tables.map(&:to_s).uniq.freeze
|
|
48
65
|
@variants = build_variants.freeze
|
|
66
|
+
keys = @variants.keys.select { |k| k.length == 1 }
|
|
67
|
+
@variant_chars = keys.empty? ? nil : Regexp.new("[#{keys.map { |k| format('\\u{%x}', k.ord) }.join}]")
|
|
68
|
+
# What the tables fold, not their names: an index built with an older
|
|
69
|
+
# "ja" table must not be read with a newer one.
|
|
70
|
+
@digest = Digest::SHA256.hexdigest(JSON.generate(@variants.sort)).slice(0, 16).freeze
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Raises unless data (an index) was built with the same tables as this
|
|
74
|
+
# normalizer. An index without a digest predates 1.0: it still loads, but
|
|
75
|
+
# it folded with older tables and should be rebuilt.
|
|
76
|
+
def check!(data)
|
|
77
|
+
if tables != data["variants"]
|
|
78
|
+
raise ArgumentError, "cjk_index: index built with variant tables #{data['variants'].inspect} " \
|
|
79
|
+
"but this normalizer folds with #{tables.inspect}; rebuild both together"
|
|
80
|
+
end
|
|
81
|
+
return if data["variant_digest"].nil? || data["variant_digest"] == digest
|
|
82
|
+
|
|
83
|
+
raise ArgumentError, "cjk_index: index built with other contents of the variant tables " \
|
|
84
|
+
"#{tables.inspect} (an older cjk_index?); rebuild it"
|
|
49
85
|
end
|
|
50
86
|
|
|
87
|
+
# Character by character: katakana to hiragana, then the variant tables,
|
|
88
|
+
# then 々 to the (folded) character before it. Done as passes over the
|
|
89
|
+
# whole string, which gives the same result as one loop over the
|
|
90
|
+
# characters (the loop in runtime.js) and is several times faster in Ruby.
|
|
51
91
|
def normalize(text)
|
|
52
92
|
return "" if text.nil?
|
|
53
93
|
|
|
94
|
+
s = text.to_s.unicode_normalize(:nfkc).downcase
|
|
95
|
+
s = s.tr(KATAKANA, HIRAGANA)
|
|
96
|
+
# 々 repeats the folded character before it, which may be several
|
|
97
|
+
# characters (ko): fold one character at a time, as runtime.js does
|
|
98
|
+
return iterate(s) if s.include?(ITERATION_MARK)
|
|
99
|
+
|
|
100
|
+
@variant_chars ? s.gsub(@variant_chars, @variants) : s
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def iterate(s)
|
|
54
106
|
prev = nil
|
|
55
|
-
|
|
56
|
-
code = ch.ord
|
|
57
|
-
ch = (code - KANA_OFFSET).chr(Encoding::UTF_8) if code.between?(KATAKANA_FIRST, KATAKANA_LAST)
|
|
107
|
+
s.each_char.map do |ch|
|
|
58
108
|
ch = @variants.fetch(ch, ch)
|
|
59
109
|
ch = prev if ch == ITERATION_MARK && prev
|
|
60
110
|
prev = ch
|
|
61
111
|
end.join
|
|
62
112
|
end
|
|
63
113
|
|
|
64
|
-
private
|
|
65
|
-
|
|
66
114
|
# Union-find over all pairs; each class folds to the target preferred by
|
|
67
115
|
# the earliest table (lowest code point among equals, for determinism).
|
|
68
116
|
def build_variants
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CJKIndex
|
|
4
|
+
# The compact encoding of a posting list in format 4, shared by Builder
|
|
5
|
+
# (writing) and Searcher (reading); runtime.js decodes it the same way.
|
|
6
|
+
#
|
|
7
|
+
# A list is a sequence of entries, ordered by document and field. Each
|
|
8
|
+
# entry is written as numbers:
|
|
9
|
+
#
|
|
10
|
+
# key gap, n, first position, gap to the next position, ...
|
|
11
|
+
#
|
|
12
|
+
# where key = document * number of fields + field, and the key gap is the
|
|
13
|
+
# difference to the key of the previous entry minus 1 (the first entry
|
|
14
|
+
# counts from -1). n is the number of positions. Each number is written in
|
|
15
|
+
# printable ASCII: the last digit in base 60 from the first 60 characters
|
|
16
|
+
# of ALPHABET, the digits before it in base 30 from the other 30, most
|
|
17
|
+
# significant first. 0..59 take one character, up to 1,799 two, up to
|
|
18
|
+
# 53,999 three. ALPHABET has neither " nor \, so a list is a JSON string
|
|
19
|
+
# without escapes.
|
|
20
|
+
module Postings
|
|
21
|
+
ALPHABET = ((0x23..0x7d).map(&:chr) - ["\\"]).join.freeze
|
|
22
|
+
LAST = 60
|
|
23
|
+
LEAD = 30
|
|
24
|
+
# The characters that end a number are the bytes below LEAD_BYTE
|
|
25
|
+
# (FINAL_BYTES in the notation of String#count).
|
|
26
|
+
LEAD_BYTE = ALPHABET.getbyte(LAST)
|
|
27
|
+
FINAL_BYTES = "#-[\\]-_"
|
|
28
|
+
# byte => digit; 0...60 ends a number, 60...90 leads.
|
|
29
|
+
DIGIT = Array.new(256, -1).tap { |t| ALPHABET.each_byte.with_index { |b, i| t[b] = i } }.freeze
|
|
30
|
+
|
|
31
|
+
def self.number_slow(value)
|
|
32
|
+
s = ALPHABET[value % LAST]
|
|
33
|
+
value /= LAST
|
|
34
|
+
while value.positive?
|
|
35
|
+
s = ALPHABET[LAST + (value % LEAD)] + s
|
|
36
|
+
value /= LEAD
|
|
37
|
+
end
|
|
38
|
+
s
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Every number of up to three characters, written once.
|
|
42
|
+
NUMBERS = Array.new(LAST * LEAD * LEAD) { |v| number_slow(v).freeze }.freeze
|
|
43
|
+
|
|
44
|
+
module_function
|
|
45
|
+
|
|
46
|
+
def number(value) = NUMBERS[value] || number_slow(value)
|
|
47
|
+
|
|
48
|
+
# Appends one entry to out. offsets are ascending positions.
|
|
49
|
+
def append(out, key_gap, offsets)
|
|
50
|
+
out << number(key_gap) << number(offsets.length)
|
|
51
|
+
last = 0
|
|
52
|
+
offsets.each do |pos|
|
|
53
|
+
out << number(pos - last)
|
|
54
|
+
last = pos
|
|
55
|
+
end
|
|
56
|
+
out
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# All numbers of a list.
|
|
60
|
+
def numbers(str)
|
|
61
|
+
out = []
|
|
62
|
+
acc = 0
|
|
63
|
+
str.each_byte do |b|
|
|
64
|
+
d = DIGIT[b]
|
|
65
|
+
if d >= LAST then acc = (acc * LEAD) + d - LAST
|
|
66
|
+
else
|
|
67
|
+
out << ((acc * LAST) + d)
|
|
68
|
+
acc = 0
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
out
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Yields key, start, body and stop for each entry: its key, and the byte
|
|
75
|
+
# offsets where the entry starts, where its key gap ends and where it
|
|
76
|
+
# ends. Loops over bytes rather than numbers, so that the changes below
|
|
77
|
+
# can copy the rest of a list as it is.
|
|
78
|
+
def each_entry(str)
|
|
79
|
+
return enum_for(:each_entry, str) unless block_given?
|
|
80
|
+
|
|
81
|
+
key = -1
|
|
82
|
+
i = 0
|
|
83
|
+
len = str.bytesize
|
|
84
|
+
while i < len
|
|
85
|
+
start = i
|
|
86
|
+
acc = 0
|
|
87
|
+
while (d = DIGIT[str.getbyte(i)]) >= LAST
|
|
88
|
+
acc = (acc * LEAD) + d - LAST
|
|
89
|
+
i += 1
|
|
90
|
+
end
|
|
91
|
+
key += (acc * LAST) + d + 1
|
|
92
|
+
i += 1
|
|
93
|
+
body = i
|
|
94
|
+
acc = 0
|
|
95
|
+
while (d = DIGIT[str.getbyte(i)]) >= LAST
|
|
96
|
+
acc = (acc * LEAD) + d - LAST
|
|
97
|
+
i += 1
|
|
98
|
+
end
|
|
99
|
+
n = (acc * LAST) + d
|
|
100
|
+
i += 1
|
|
101
|
+
# Skip n numbers: each ends with a byte below LEAD_BYTE. Many at a
|
|
102
|
+
# time are counted by String#count, which runs in C.
|
|
103
|
+
while n > 8
|
|
104
|
+
t = str.byteslice(i, n).count(FINAL_BYTES)
|
|
105
|
+
i += n
|
|
106
|
+
n -= t
|
|
107
|
+
end
|
|
108
|
+
while n.positive?
|
|
109
|
+
n -= 1 if str.getbyte(i) < LEAD_BYTE
|
|
110
|
+
i += 1
|
|
111
|
+
end
|
|
112
|
+
yield key, start, body, i
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Key of the last entry, -1 for an empty list.
|
|
117
|
+
def last_key(str)
|
|
118
|
+
key = -1
|
|
119
|
+
each_entry(str) { |k| key = k }
|
|
120
|
+
key
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Each key of a list, in order.
|
|
124
|
+
def each_key(str)
|
|
125
|
+
return enum_for(:each_key, str) unless block_given?
|
|
126
|
+
|
|
127
|
+
each_entry(str) { |key| yield key }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The list without the entries whose keys are in range (consecutive).
|
|
131
|
+
def delete(str, range)
|
|
132
|
+
before = -1
|
|
133
|
+
from = nil
|
|
134
|
+
each_entry(str) do |key, start, body|
|
|
135
|
+
if range.cover?(key)
|
|
136
|
+
from ||= start
|
|
137
|
+
elsif from
|
|
138
|
+
return str.byteslice(0, from) + number(key - before - 1) + str.byteslice(body..)
|
|
139
|
+
elsif key > range.end
|
|
140
|
+
return str
|
|
141
|
+
else
|
|
142
|
+
before = key
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
from ? str.byteslice(0, from) : str
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# The list with an entry for key (not yet in it) and offsets inserted in order.
|
|
149
|
+
def insert(str, key, offsets)
|
|
150
|
+
before = -1
|
|
151
|
+
each_entry(str) do |k, start, body|
|
|
152
|
+
if k > key
|
|
153
|
+
entry = append(+"", key - before - 1, offsets)
|
|
154
|
+
return str.byteslice(0, start) + entry + number(k - key - 1) + str.byteslice(body..)
|
|
155
|
+
end
|
|
156
|
+
before = k
|
|
157
|
+
end
|
|
158
|
+
append(+str, key - before - 1, offsets)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# The list with each key's document replaced by numbering[document], which
|
|
162
|
+
# keeps the order (removed documents have no entries).
|
|
163
|
+
def renumber(str, fields, numbering)
|
|
164
|
+
out = +""
|
|
165
|
+
old_before = -1
|
|
166
|
+
new_before = -1
|
|
167
|
+
copied = 0 # str up to here is in out or to be copied as it is
|
|
168
|
+
each_entry(str) do |key, start, body|
|
|
169
|
+
new_key = (numbering[key / fields] * fields) + (key % fields)
|
|
170
|
+
if new_key - new_before != key - old_before
|
|
171
|
+
out << str.byteslice(copied, start - copied) << number(new_key - new_before - 1)
|
|
172
|
+
copied = body
|
|
173
|
+
end
|
|
174
|
+
old_before = key
|
|
175
|
+
new_before = new_key
|
|
176
|
+
end
|
|
177
|
+
copied.zero? ? str : out << str.byteslice(copied..)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# The flat list of format 3, [doc, field, n, pos, ..., doc, ...].
|
|
181
|
+
def decode(str, fields)
|
|
182
|
+
nums = numbers(str)
|
|
183
|
+
out = []
|
|
184
|
+
key = -1
|
|
185
|
+
i = 0
|
|
186
|
+
while i < nums.length
|
|
187
|
+
key += nums[i] + 1
|
|
188
|
+
n = nums[i + 1]
|
|
189
|
+
out << (key / fields) << (key % fields) << n
|
|
190
|
+
pos = 0
|
|
191
|
+
j = i + 2
|
|
192
|
+
stop = j + n
|
|
193
|
+
while j < stop
|
|
194
|
+
pos += nums[j]
|
|
195
|
+
out << pos
|
|
196
|
+
j += 1
|
|
197
|
+
end
|
|
198
|
+
i = stop
|
|
199
|
+
end
|
|
200
|
+
out
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# A flat list of format 2 or 3 in this encoding.
|
|
204
|
+
def encode(flat, fields)
|
|
205
|
+
out = +""
|
|
206
|
+
last = -1
|
|
207
|
+
i = 0
|
|
208
|
+
while i < flat.length
|
|
209
|
+
key = (flat[i] * fields) + flat[i + 1]
|
|
210
|
+
n = flat[i + 2]
|
|
211
|
+
append(out, key - last - 1, flat[i + 3, n])
|
|
212
|
+
last = key
|
|
213
|
+
i += 3 + n
|
|
214
|
+
end
|
|
215
|
+
out
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|