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,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rake"
|
|
4
|
+
require "rake/tasklib"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require_relative "cli"
|
|
7
|
+
|
|
8
|
+
module CJKIndex
|
|
9
|
+
# A Rake task that builds an index (and the browser script) from a CSV,
|
|
10
|
+
# JSON or JSON Lines file, usable from any Ruby build (Rails, Sinatra, a
|
|
11
|
+
# plain Rakefile). It does what `cjk_index build` does.
|
|
12
|
+
#
|
|
13
|
+
# require "cjk_index/rake_task"
|
|
14
|
+
#
|
|
15
|
+
# CJKIndex::RakeTask.new(:search_index) do |t|
|
|
16
|
+
# t.source = "data/items.csv"
|
|
17
|
+
# t.output = "public/search/items.json"
|
|
18
|
+
# t.runtime = "public/search/cjk-index.js" # optional
|
|
19
|
+
# t.ref = "id"
|
|
20
|
+
# t.fields = %w[title creator]
|
|
21
|
+
# t.boosts = { "title" => 3 }
|
|
22
|
+
# t.filters = { "type" => "value", "date" => "year" }
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# rake search_index
|
|
26
|
+
#
|
|
27
|
+
# records can be set instead of source: an Array of Hashes, or an object
|
|
28
|
+
# that responds to call and returns one (e.g. a lambda reading the database).
|
|
29
|
+
class RakeTask < Rake::TaskLib
|
|
30
|
+
attr_accessor :name, :source, :records, :output, :runtime, :ref, :fields, :boosts, :filters, :variants, :format,
|
|
31
|
+
:description
|
|
32
|
+
|
|
33
|
+
def initialize(name = :cjk_index)
|
|
34
|
+
super()
|
|
35
|
+
@name = name
|
|
36
|
+
@boosts = {}
|
|
37
|
+
@filters = {}
|
|
38
|
+
@variants = Normalizer::DEFAULT_TABLES
|
|
39
|
+
@description = "Build the CJK search index"
|
|
40
|
+
yield self if block_given?
|
|
41
|
+
define
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Builds the index now; the task calls this.
|
|
45
|
+
def build
|
|
46
|
+
raise CLI::Error, "#{name}: set output" unless output
|
|
47
|
+
|
|
48
|
+
data = records.respond_to?(:call) ? records.call : records
|
|
49
|
+
data ||= source ? CLI.read_records(source.to_s, format || CLI.format_of(source)) : raise(CLI::Error, "#{name}: set source or records")
|
|
50
|
+
builder = CLI.build_index(records: data, ref: ref, fields: fields, boosts: boosts, filters: filters, variants: variants)
|
|
51
|
+
write(output, builder.to_json)
|
|
52
|
+
write(runtime, Runtime.source(normalizer: builder.normalizer)) if runtime
|
|
53
|
+
builder
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def define
|
|
59
|
+
desc description
|
|
60
|
+
task(name) do
|
|
61
|
+
builder = build
|
|
62
|
+
puts "#{output}: #{builder.size} records"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def write(path, content)
|
|
67
|
+
FileUtils.mkdir_p(File.dirname(path.to_s))
|
|
68
|
+
File.write(path.to_s, content)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
data/lib/cjk_index/runtime.js
CHANGED
|
@@ -9,9 +9,49 @@
|
|
|
9
9
|
|
|
10
10
|
var VARIANTS = /*@VARIANTS@*/{};
|
|
11
11
|
var TABLES = /*@TABLES@*/[];
|
|
12
|
+
var DIGEST = /*@DIGEST@*/'';
|
|
12
13
|
var CJK = new RegExp(/*@CJK@*/'', 'u');
|
|
13
14
|
var SEPARATOR = new RegExp(/*@SEPARATOR@*/'', 'u');
|
|
14
|
-
var FORMATS = ['cjk_index/2', 'cjk_index/3'];
|
|
15
|
+
var FORMATS = ['cjk_index/2', 'cjk_index/3', 'cjk_index/4'];
|
|
16
|
+
var COMPACT = 'cjk_index/4';
|
|
17
|
+
var hasOwn = Object.prototype.hasOwnProperty;
|
|
18
|
+
|
|
19
|
+
// Posting lists of format 4 (CJKIndex::Postings in Ruby): numbers written
|
|
20
|
+
// with 90 printable characters, '#' to '}' without '\'. The first 60 are the
|
|
21
|
+
// last digit of a number (base 60), the other 30 lead it (base 30).
|
|
22
|
+
var DIGIT = [];
|
|
23
|
+
for (var code = 0x23, digit = 0; code <= 0x7d; code++) if (code !== 0x5c) DIGIT[code] = digit++;
|
|
24
|
+
|
|
25
|
+
// A posting list of format 4 as the flat list of format 3:
|
|
26
|
+
// [doc, field, n, pos, ..., doc, field, n, ...].
|
|
27
|
+
function decodePostings(s, nf) {
|
|
28
|
+
var out = [];
|
|
29
|
+
var key = -1, state = 0, left = 0, pos = 0, acc = 0;
|
|
30
|
+
for (var i = 0; i < s.length; i++) {
|
|
31
|
+
var d = DIGIT[s.charCodeAt(i)];
|
|
32
|
+
if (d >= 60) { acc = acc * 30 + d - 60; continue; }
|
|
33
|
+
var v = acc * 60 + d;
|
|
34
|
+
acc = 0;
|
|
35
|
+
if (state === 0) { key += v + 1; out.push(Math.floor(key / nf), key % nf); state = 1; }
|
|
36
|
+
else if (state === 1) { out.push(v); left = v; pos = 0; state = 2; }
|
|
37
|
+
else { pos += v; out.push(pos); if (--left === 0) state = 0; }
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Compares strings by code point, as Ruby compares UTF-8 strings (the
|
|
43
|
+
// order of the shards). JavaScript's < compares UTF-16 code units, which
|
|
44
|
+
// puts characters beyond U+FFFF before U+E000-U+FFFF.
|
|
45
|
+
function compareCodePoints(a, b) {
|
|
46
|
+
var i = 0, j = 0;
|
|
47
|
+
while (i < a.length && j < b.length) {
|
|
48
|
+
var x = a.codePointAt(i), y = b.codePointAt(j);
|
|
49
|
+
if (x !== y) return x < y ? -1 : 1;
|
|
50
|
+
i += x > 0xffff ? 2 : 1;
|
|
51
|
+
j += y > 0xffff ? 2 : 1;
|
|
52
|
+
}
|
|
53
|
+
return (i < a.length ? 1 : 0) - (j < b.length ? 1 : 0);
|
|
54
|
+
}
|
|
15
55
|
|
|
16
56
|
// BM25 parameters (the usual defaults, as in Lucene and Pagefind).
|
|
17
57
|
var K1 = 1.2;
|
|
@@ -119,6 +159,10 @@
|
|
|
119
159
|
throw new Error('cjk_index: index built with variant tables ' + JSON.stringify(data.variants) +
|
|
120
160
|
' but this script folds with ' + JSON.stringify(TABLES) + '; rebuild both together');
|
|
121
161
|
}
|
|
162
|
+
if (data.variant_digest !== undefined && data.variant_digest !== DIGEST) {
|
|
163
|
+
throw new Error('cjk_index: index built with other contents of the variant tables ' +
|
|
164
|
+
JSON.stringify(TABLES) + ' (an older cjk_index?); rebuild it');
|
|
165
|
+
}
|
|
122
166
|
options = options || {};
|
|
123
167
|
this.prefix = options.prefix !== false; // last Latin word matches as a prefix
|
|
124
168
|
this.typos = options.typos !== false; // Latin words of 5+ letters allow one edit
|
|
@@ -126,15 +170,30 @@
|
|
|
126
170
|
this.boosts = data.boosts;
|
|
127
171
|
this.refs = data.refs;
|
|
128
172
|
this.lengths = data.lengths;
|
|
129
|
-
this.tokens = data.tokens;
|
|
173
|
+
this.tokens = data.tokens || null;
|
|
174
|
+
this.encoded = data.format === COMPACT;
|
|
175
|
+
this.decoded = new Map();
|
|
176
|
+
if (data.shards) {
|
|
177
|
+
// Split index: the manifest lists the first token and file of each shard.
|
|
178
|
+
this.loadShard = options.loadShard;
|
|
179
|
+
if (!this.loadShard) throw new Error('cjk_index: a split index needs options.loadShard (CJKIndex.load sets it)');
|
|
180
|
+
this.shardFirst = data.shards.map(function (s) { return s[0]; });
|
|
181
|
+
this.shardFile = data.shards.map(function (s) { return s[1]; });
|
|
182
|
+
this.shards = new Map(); // file -> tokens, once loaded
|
|
183
|
+
this.pending = new Map(); // file -> Promise while loading
|
|
184
|
+
}
|
|
130
185
|
var nf = this.fields.length;
|
|
131
186
|
var avg = new Array(nf).fill(0);
|
|
132
187
|
for (var i = 0; i < this.lengths.length; i++) avg[i % nf] += this.lengths[i];
|
|
133
188
|
this.avgLength = avg.map(function (s) { return s / Math.max(1, data.refs.length) || 1; });
|
|
134
|
-
|
|
189
|
+
// A split index lists its Latin tokens in a file, fetched by prepare
|
|
190
|
+
// only for typo expansion.
|
|
191
|
+
this.latinFile = this.tokens ? null : data.latin;
|
|
192
|
+
if (this.tokens) this._setLatin(Object.keys(this.tokens).filter(isLatin));
|
|
135
193
|
this.slotOf = new Map();
|
|
136
194
|
for (var d = 0; d < this.refs.length; d++) if (!this.slotOf.has(this.refs[d])) this.slotOf.set(this.refs[d], d);
|
|
137
|
-
// Filter values by document:
|
|
195
|
+
// Filter values by document: a year, or the ids of values (value
|
|
196
|
+
// filters), with the folded key of each id, the ids of each key, the
|
|
138
197
|
// form to show for each key and its rank of first appearance.
|
|
139
198
|
this.filters = {};
|
|
140
199
|
var filters = data.filters || {};
|
|
@@ -142,19 +201,22 @@
|
|
|
142
201
|
var f = filters[name];
|
|
143
202
|
var docs = f.docs;
|
|
144
203
|
var display = new Map();
|
|
204
|
+
var keys = null;
|
|
205
|
+
var ids = new Map();
|
|
145
206
|
if (f.kind !== 'year') {
|
|
146
|
-
|
|
207
|
+
keys = f.values.map(filterKey);
|
|
147
208
|
f.values.forEach(function (v, i) { if (!display.has(keys[i])) display.set(keys[i], v); });
|
|
148
|
-
|
|
209
|
+
keys.forEach(function (key, id) { if (!ids.has(key)) ids.set(key, []); ids.get(key).push(id); });
|
|
149
210
|
}
|
|
150
211
|
var rank = new Map();
|
|
151
212
|
docs.forEach(function (value) {
|
|
152
|
-
(f.kind === 'year' ? (value === null ? [] : [value]) : value).forEach(function (
|
|
213
|
+
(f.kind === 'year' ? (value === null ? [] : [value]) : value).forEach(function (v) {
|
|
214
|
+
var key = f.kind === 'year' ? v : keys[v];
|
|
153
215
|
if (!rank.has(key)) rank.set(key, rank.size);
|
|
154
216
|
});
|
|
155
217
|
});
|
|
156
218
|
if (f.kind === 'year') rank.forEach(function (r, year) { display.set(year, year); });
|
|
157
|
-
this.filters[name] = { kind: f.kind, docs: docs, display: display, rank: rank };
|
|
219
|
+
this.filters[name] = { kind: f.kind, docs: docs, keys: keys, ids: ids, display: display, rank: rank };
|
|
158
220
|
}, this);
|
|
159
221
|
}
|
|
160
222
|
|
|
@@ -175,8 +237,12 @@
|
|
|
175
237
|
(max === null || max === undefined || year <= max);
|
|
176
238
|
}]);
|
|
177
239
|
} else {
|
|
178
|
-
|
|
179
|
-
|
|
240
|
+
// Compares value ids, not folded strings.
|
|
241
|
+
var ids = new Set();
|
|
242
|
+
(Array.isArray(c) ? c : [c]).forEach(function (v) {
|
|
243
|
+
(f.ids.get(filterKey(v)) || []).forEach(function (id) { ids.add(id); });
|
|
244
|
+
});
|
|
245
|
+
out.push([f, function (list) { return list.some(function (id) { return ids.has(id); }); }]);
|
|
180
246
|
}
|
|
181
247
|
});
|
|
182
248
|
return out;
|
|
@@ -206,7 +272,8 @@
|
|
|
206
272
|
var counts = new Map();
|
|
207
273
|
docs.forEach(function (doc) {
|
|
208
274
|
var v = f.docs[doc];
|
|
209
|
-
(f.kind === 'year' ? (v === null ? [] : [v]) : v).forEach(function (
|
|
275
|
+
(f.kind === 'year' ? (v === null ? [] : [v]) : v).forEach(function (x) {
|
|
276
|
+
var key = f.kind === 'year' ? x : f.keys[x];
|
|
210
277
|
counts.set(key, (counts.get(key) || 0) + 1);
|
|
211
278
|
});
|
|
212
279
|
});
|
|
@@ -215,12 +282,109 @@
|
|
|
215
282
|
return out.map(function (e) { return { value: f.display.get(e[0]), count: e[1] }; });
|
|
216
283
|
};
|
|
217
284
|
|
|
285
|
+
Index.prototype._setLatin = function (tokens) {
|
|
286
|
+
this.latinTokens = tokens.slice().sort();
|
|
287
|
+
this.latinSet = new Set(this.latinTokens);
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
// Number of the shard that holds token (split index), or -1.
|
|
291
|
+
Index.prototype._shardIndex = function (token) {
|
|
292
|
+
var lo = 0, hi = this.shardFirst.length;
|
|
293
|
+
while (lo < hi) {
|
|
294
|
+
var mid = (lo + hi) >> 1;
|
|
295
|
+
if (compareCodePoints(this.shardFirst[mid], token) <= 0) lo = mid + 1; else hi = mid;
|
|
296
|
+
}
|
|
297
|
+
return lo - 1;
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
function notLoaded(what) {
|
|
301
|
+
return new Error('cjk_index: ' + what + ' not loaded; use searchAsync(query) or await prepare(query)');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Tokens of shard i, or undefined when it is not loaded: then its file is
|
|
305
|
+
// added to missing (prepare), or an error is thrown (search).
|
|
306
|
+
Index.prototype._shard = function (i, missing) {
|
|
307
|
+
var tokens = this.shards.get(this.shardFile[i]);
|
|
308
|
+
if (tokens) return tokens;
|
|
309
|
+
if (!missing) throw notLoaded('the shard ' + this.shardFile[i] + ' is');
|
|
310
|
+
missing.files.add(this.shardFile[i]);
|
|
311
|
+
return undefined;
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// The object that holds token's posting list if it is in the index; null
|
|
315
|
+
// when it cannot be; undefined when its shard is not loaded (see _shard).
|
|
316
|
+
Index.prototype._table = function (token, missing) {
|
|
317
|
+
if (this.tokens) return this.tokens;
|
|
318
|
+
var i = this._shardIndex(token);
|
|
319
|
+
return i < 0 ? null : this._shard(i, missing);
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
// true or false, or undefined when not known yet (prepare).
|
|
323
|
+
Index.prototype._has = function (token, missing) {
|
|
324
|
+
var table = this._table(token, missing);
|
|
325
|
+
return table === undefined ? undefined : !!table && hasOwn.call(table, token);
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
// The posting list of a token as [doc, field, n, pos, ...], or null.
|
|
329
|
+
Index.prototype._postings = function (token) {
|
|
330
|
+
var table = this._table(token);
|
|
331
|
+
if (!table || !hasOwn.call(table, token)) return null;
|
|
332
|
+
if (!this.encoded) return table[token];
|
|
333
|
+
var p = this.decoded.get(token);
|
|
334
|
+
if (!p) { p = decodePostings(table[token], this.fields.length); this.decoded.set(token, p); }
|
|
335
|
+
return p;
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
Index.prototype._load = function (file, done) {
|
|
339
|
+
var self = this;
|
|
340
|
+
var p = this.pending.get(file);
|
|
341
|
+
if (!p) {
|
|
342
|
+
p = Promise.resolve(this.loadShard(file)).then(done);
|
|
343
|
+
p.catch(function () { self.pending.delete(file); });
|
|
344
|
+
this.pending.set(file, p);
|
|
345
|
+
}
|
|
346
|
+
return p;
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
// Fetches what a search for query needs from a split index, so that
|
|
350
|
+
// search(query) can then run: the shards of its tokens, of the words a
|
|
351
|
+
// last Latin word is a prefix of, and, for a Latin word of 5+ letters not
|
|
352
|
+
// in the index, the list of Latin tokens (once) and the shards of the
|
|
353
|
+
// words one edit away. Resolves to the index; at once for one file.
|
|
354
|
+
Index.prototype.prepare = function (query) {
|
|
355
|
+
var self = this;
|
|
356
|
+
if (!this.shardFirst) return Promise.resolve(this);
|
|
357
|
+
var missing = { files: new Set(), latin: false };
|
|
358
|
+
var qp = phrases(query, false);
|
|
359
|
+
qp.forEach(function (phrase, q) {
|
|
360
|
+
phrase.forEach(function (tok, i) {
|
|
361
|
+
self._alternatives(tok[0], q === qp.length - 1 && i === phrase.length - 1, missing).tokens.forEach(function (t) {
|
|
362
|
+
self._table(t, missing);
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
var loads = Array.from(missing.files).map(function (file) {
|
|
367
|
+
return self._load(file, function (tokens) { self.shards.set(file, tokens); });
|
|
368
|
+
});
|
|
369
|
+
if (missing.latin) loads.push(this._load(this.latinFile, function (tokens) { self._setLatin(tokens); }));
|
|
370
|
+
if (!loads.length) return Promise.resolve(this);
|
|
371
|
+
// What was loaded may show more to load (typo candidates).
|
|
372
|
+
return Promise.all(loads).then(function () { return self.prepare(query); });
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
// search, after fetching the shards it needs. Works for every index, so
|
|
376
|
+
// a page can use it whether or not the index is split.
|
|
377
|
+
Index.prototype.searchAsync = function (query, options) {
|
|
378
|
+
var self = this;
|
|
379
|
+
return this.prepare(query).then(function () { return self.search(query, options); });
|
|
380
|
+
};
|
|
381
|
+
|
|
218
382
|
// Map of "doc,field" key -> Set of positions, merged over the alternatives.
|
|
219
383
|
Index.prototype._positions = function (tokens) {
|
|
220
384
|
var nf = this.fields.length;
|
|
221
385
|
var map = new Map();
|
|
222
386
|
for (var t = 0; t < tokens.length; t++) {
|
|
223
|
-
var p = this.tokens[
|
|
387
|
+
var p = this._postings(tokens[t]);
|
|
224
388
|
if (!p) continue;
|
|
225
389
|
for (var i = 0; i < p.length;) {
|
|
226
390
|
var key = p[i] * nf + p[i + 1];
|
|
@@ -236,17 +400,36 @@
|
|
|
236
400
|
|
|
237
401
|
// Alternatives for one query token: itself, and for Latin words the index
|
|
238
402
|
// words it is a prefix of (last word only) or one edit away from.
|
|
239
|
-
|
|
403
|
+
// With missing (prepare), what is not loaded yet is noted there and left out.
|
|
404
|
+
Index.prototype._alternatives = function (token, isLast, missing) {
|
|
240
405
|
if (!isLatin(token)) return { tokens: [token], expanded: false };
|
|
241
|
-
var
|
|
242
|
-
var
|
|
406
|
+
var has = this.tokens ? this.latinSet.has(token) : this._has(token, missing);
|
|
407
|
+
var out = has ? [token] : [];
|
|
243
408
|
if (this.prefix && isLast) {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
409
|
+
if (this.tokens) {
|
|
410
|
+
var keys = this.latinTokens;
|
|
411
|
+
var lo = 0, hi = keys.length;
|
|
412
|
+
while (lo < hi) { var mid = (lo + hi) >> 1; if (keys[mid] < token) lo = mid + 1; else hi = mid; }
|
|
413
|
+
for (var i = lo; i < keys.length && keys[i].indexOf(token) === 0; i++) if (keys[i] !== token) out.push(keys[i]);
|
|
414
|
+
} else {
|
|
415
|
+
// The words starting with token are in its shard and the next ones
|
|
416
|
+
// that start with it (the tokens are sorted).
|
|
417
|
+
var first = Math.max(0, this._shardIndex(token));
|
|
418
|
+
for (var s = first; s < this.shardFirst.length && (s === first || this.shardFirst[s].indexOf(token) === 0); s++) {
|
|
419
|
+
var shard = this._shard(s, missing);
|
|
420
|
+
if (!shard) continue;
|
|
421
|
+
for (var k in shard) if (hasOwn.call(shard, k) && k !== token && k.indexOf(token) === 0) out.push(k);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
247
424
|
}
|
|
248
|
-
if (this.typos &&
|
|
249
|
-
|
|
425
|
+
if (this.typos && has === false && Array.from(token).length >= 5) {
|
|
426
|
+
if (this.latinTokens) {
|
|
427
|
+
this.latinTokens.forEach(function (k) { if (oneEdit(token, k)) out.push(k); });
|
|
428
|
+
} else if (missing) {
|
|
429
|
+
missing.latin = true;
|
|
430
|
+
} else {
|
|
431
|
+
throw notLoaded('the Latin tokens are');
|
|
432
|
+
}
|
|
250
433
|
}
|
|
251
434
|
return { tokens: out, expanded: out.length > 0 && out[0] !== token };
|
|
252
435
|
};
|
|
@@ -335,20 +518,38 @@
|
|
|
335
518
|
});
|
|
336
519
|
}
|
|
337
520
|
|
|
521
|
+
// Characters that NFKC may compose with the one before (combining marks,
|
|
522
|
+
// Hangul vowel and final jamo, half-width sound marks): kept in one cluster
|
|
523
|
+
// so that the cluster normalizes as it does inside the whole text.
|
|
524
|
+
var JOINS_PREVIOUS = /^[\p{M}\u1160-\u11ff\ud7b0-\ud7ff\u314f-\u3163\u3187-\u318e\uff9e\uff9f]$/u;
|
|
525
|
+
|
|
338
526
|
// [start, end) ranges, in code points of the original text, where a phrase
|
|
339
527
|
// of the query occurs after normalization.
|
|
340
528
|
function matchRanges(text, query) {
|
|
341
529
|
var chars = Array.from(String(text === null || text === undefined ? '' : text));
|
|
342
530
|
var norm = [];
|
|
343
|
-
var
|
|
531
|
+
var start = [];
|
|
532
|
+
var end = [];
|
|
344
533
|
var prev = null;
|
|
345
|
-
|
|
346
|
-
|
|
534
|
+
// Each folded code point remembers which original characters it came
|
|
535
|
+
// from. A character may fold to several (ko: one Private Use Area
|
|
536
|
+
// character -> a jamo sequence), and a cluster may compose into fewer.
|
|
537
|
+
function emit(str, from, to) {
|
|
538
|
+
for (var c of str.normalize('NFKC').toLowerCase()) {
|
|
347
539
|
prev = foldChar(c, prev);
|
|
348
|
-
norm.push(
|
|
349
|
-
origin.push(i);
|
|
540
|
+
for (var p of prev) { norm.push(p); start.push(from); end.push(to); }
|
|
350
541
|
}
|
|
351
|
-
}
|
|
542
|
+
}
|
|
543
|
+
for (var i = 0, j; i < chars.length; i = j) {
|
|
544
|
+
for (j = i + 1; j < chars.length && JOINS_PREVIOUS.test(chars[j]); j++);
|
|
545
|
+
var cluster = chars.slice(i, j);
|
|
546
|
+
var whole = cluster.join('').normalize('NFKC');
|
|
547
|
+
if (cluster.map(function (c) { return c.normalize('NFKC'); }).join('') === whole) {
|
|
548
|
+
cluster.forEach(function (c, k) { emit(c, i + k, i + k + 1); });
|
|
549
|
+
} else {
|
|
550
|
+
emit(cluster.join(''), i, j);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
352
553
|
var normText = norm.join('');
|
|
353
554
|
var ranges = [];
|
|
354
555
|
var needles = [];
|
|
@@ -363,7 +564,7 @@
|
|
|
363
564
|
var at;
|
|
364
565
|
while ((at = normText.indexOf(needle, from)) !== -1) {
|
|
365
566
|
var cp = Array.from(normText.slice(0, at)).length;
|
|
366
|
-
ranges.push([
|
|
567
|
+
ranges.push([start[cp], end[cp + len - 1]]);
|
|
367
568
|
from = at + needle.length;
|
|
368
569
|
}
|
|
369
570
|
});
|
|
@@ -400,11 +601,24 @@
|
|
|
400
601
|
return (start > 0 ? '…' : '') + highlight(part, query) + (end < m.chars.length ? '…' : '');
|
|
401
602
|
}
|
|
402
603
|
|
|
403
|
-
function
|
|
604
|
+
function fetchJson(url) {
|
|
404
605
|
return fetch(url).then(function (res) {
|
|
405
606
|
if (!res.ok) throw new Error('cjk_index: ' + url + ' returned ' + res.status);
|
|
406
607
|
return res.json();
|
|
407
|
-
})
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Loads an index file, or the manifest of a split index: its shards are
|
|
612
|
+
// then fetched from the same directory as queries need them (searchAsync).
|
|
613
|
+
function load(url, options) {
|
|
614
|
+
options = Object.assign({}, options);
|
|
615
|
+
return fetchJson(url).then(function (data) {
|
|
616
|
+
if (data && data.shards && !options.loadShard) {
|
|
617
|
+
var base = new URL(url, typeof location !== 'undefined' ? location.href : undefined);
|
|
618
|
+
options.loadShard = function (file) { return fetchJson(new URL(file, base).href); };
|
|
619
|
+
}
|
|
620
|
+
return new Index(data, options);
|
|
621
|
+
});
|
|
408
622
|
}
|
|
409
623
|
|
|
410
624
|
var api = {
|
data/lib/cjk_index/runtime.rb
CHANGED
|
@@ -17,6 +17,7 @@ module CJKIndex
|
|
|
17
17
|
# back-references and drop half of the regexp escapes.
|
|
18
18
|
.sub("/*@VARIANTS@*/{}") { JSON.generate(normalizer.variants) }
|
|
19
19
|
.sub("/*@TABLES@*/[]") { JSON.generate(normalizer.tables) }
|
|
20
|
+
.sub("/*@DIGEST@*/''") { JSON.generate(normalizer.digest) }
|
|
20
21
|
.sub("/*@CJK@*/''") { JSON.generate(Tokenizer.char_class(Tokenizer::CJK_RANGES)) }
|
|
21
22
|
.sub("/*@SEPARATOR@*/''") do
|
|
22
23
|
JSON.generate(Tokenizer.char_class(Tokenizer::SEPARATOR_RANGES, Tokenizer::SEPARATOR_CHARS))
|
data/lib/cjk_index/searcher.rb
CHANGED
|
@@ -28,13 +28,25 @@ module CJKIndex
|
|
|
28
28
|
# documents, also after later add, remove and update.
|
|
29
29
|
# prefix: the last Latin word of a query also matches longer words.
|
|
30
30
|
# typos: Latin words of 5+ letters also match words one edit away.
|
|
31
|
+
#
|
|
32
|
+
# path is an index file, or a split index (its directory or its
|
|
33
|
+
# manifest); the shards of a split index are read as queries need them.
|
|
31
34
|
def self.load(path, **options)
|
|
32
|
-
|
|
35
|
+
path = File.join(path, Builder::MANIFEST) if File.directory?(path)
|
|
36
|
+
data = JSON.parse(File.read(path, encoding: "UTF-8"))
|
|
37
|
+
if data.is_a?(Hash) && data["shards"]
|
|
38
|
+
dir = File.dirname(path)
|
|
39
|
+
options = { load_shard: ->(file) { JSON.parse(File.read(File.join(dir, file), encoding: "UTF-8")) } }.merge(options)
|
|
40
|
+
end
|
|
41
|
+
new(data, **options)
|
|
33
42
|
end
|
|
34
43
|
|
|
35
|
-
|
|
44
|
+
# load_shard: for the manifest of a split index, a callable that returns
|
|
45
|
+
# a shard (a Hash of token => posting list) given its file name.
|
|
46
|
+
def initialize(index, prefix: true, typos: true, normalizer: nil, load_shard: nil)
|
|
36
47
|
@prefix = prefix
|
|
37
48
|
@typos = typos
|
|
49
|
+
@load_shard = load_shard
|
|
38
50
|
if index.is_a?(Builder)
|
|
39
51
|
@builder = index
|
|
40
52
|
data = index.live_data
|
|
@@ -46,9 +58,10 @@ module CJKIndex
|
|
|
46
58
|
end
|
|
47
59
|
|
|
48
60
|
@normalizer = normalizer || Normalizer.new(tables: data["variants"])
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
61
|
+
@normalizer.check!(data)
|
|
62
|
+
|
|
63
|
+
if data["shards"] && !@load_shard
|
|
64
|
+
raise ArgumentError, "cjk_index: a split index needs load_shard (or Searcher.load(directory))"
|
|
52
65
|
end
|
|
53
66
|
|
|
54
67
|
setup(data)
|
|
@@ -149,7 +162,7 @@ module CJKIndex
|
|
|
149
162
|
docs.each do |doc|
|
|
150
163
|
value = filter[:docs][doc]
|
|
151
164
|
if filter[:kind] == "year" then counts[value] += 1 unless value.nil?
|
|
152
|
-
else value.each { |
|
|
165
|
+
else value.each { |id| counts[filter[:keys][id]] += 1 }
|
|
153
166
|
end
|
|
154
167
|
end
|
|
155
168
|
counts.sort_by { |key, count| [-count, filter[:rank][key]] }
|
|
@@ -169,39 +182,54 @@ module CJKIndex
|
|
|
169
182
|
@refs = data["refs"]
|
|
170
183
|
@lengths = data["lengths"]
|
|
171
184
|
@tokens = data["tokens"]
|
|
185
|
+
@encoded = data["format"] == Builder::FORMAT
|
|
186
|
+
@decoded = {}
|
|
187
|
+
if data["shards"]
|
|
188
|
+
@shard_first = data["shards"].map(&:first)
|
|
189
|
+
@shard_file = data["shards"].map(&:last)
|
|
190
|
+
@shards = {}
|
|
191
|
+
end
|
|
172
192
|
@count = data["count"] || @refs.length
|
|
173
193
|
nf = @fields.length
|
|
174
194
|
avg = Array.new(nf, 0.0)
|
|
175
195
|
@lengths.each_with_index { |len, i| avg[i % nf] += len if len }
|
|
176
196
|
@avg_length = avg.map { |s| (v = s / [1, @count].max).zero? ? 1 : v }
|
|
177
|
-
|
|
197
|
+
# A split index lists its Latin tokens in a file, read only for typos.
|
|
198
|
+
@latin_file = data["latin"] unless @tokens
|
|
199
|
+
@latin_tokens = @latin_file ? nil : @tokens.keys.select { |t| latin?(t) }.sort
|
|
200
|
+
@latin_set = @latin_tokens&.to_set
|
|
178
201
|
@slot_of = {}
|
|
179
202
|
@refs.each_with_index { |ref, doc| @slot_of[ref] ||= doc if ref }
|
|
180
203
|
@filters = (data["filters"] || {}).to_h { |name, f| [name, setup_filter(f)] }
|
|
181
204
|
end
|
|
182
205
|
|
|
183
|
-
# Filter values by document:
|
|
206
|
+
# Filter values by document: a year, or the ids of values (value
|
|
207
|
+
# filters), with the folded key of each id, the ids of each key, the
|
|
184
208
|
# form to show for each key and its rank of first appearance.
|
|
185
209
|
def setup_filter(f)
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
else
|
|
191
|
-
f["docs"].map { |pairs| pairs&.map(&:first) }
|
|
192
|
-
end
|
|
193
|
-
display = {}
|
|
194
|
-
if f["values"] then f["values"].each { |v| display[Builder.filter_key(v, @normalizer)] ||= v }
|
|
195
|
-
elsif f["kind"] == "value" then f["docs"].each { |pairs| pairs&.each { |key, v| display[key] ||= v } }
|
|
210
|
+
if f["kind"] == "year"
|
|
211
|
+
rank = {}
|
|
212
|
+
f["docs"].each_with_index { |year, doc| rank[year] ||= rank.size if @refs[doc] && !year.nil? }
|
|
213
|
+
return { kind: "year", docs: f["docs"], display: rank.keys.to_h { |year| [year, year] }, rank: rank }
|
|
196
214
|
end
|
|
197
|
-
rank = {}
|
|
198
|
-
docs.each_with_index do |value, doc|
|
|
199
|
-
next unless @refs[doc]
|
|
200
215
|
|
|
201
|
-
|
|
216
|
+
values = f["values"]
|
|
217
|
+
docs = f["docs"]
|
|
218
|
+
unless values
|
|
219
|
+
id_of = {}
|
|
220
|
+
values = []
|
|
221
|
+
docs = docs.map { |pairs| (pairs || []).map { |key, v| id_of[key] ||= (values << v).size - 1 } }
|
|
202
222
|
end
|
|
203
|
-
|
|
204
|
-
|
|
223
|
+
keys = values.map { |v| Builder.filter_key(v, @normalizer) }
|
|
224
|
+
ids = Hash.new { |h, key| h[key] = [] }
|
|
225
|
+
keys.each_with_index { |key, id| ids[key] << id }
|
|
226
|
+
display = {}
|
|
227
|
+
values.each_with_index { |v, id| display[keys[id]] ||= v }
|
|
228
|
+
rank = {}
|
|
229
|
+
docs.each_with_index do |list, doc|
|
|
230
|
+
list.each { |id| rank[keys[id]] ||= rank.size } if @refs[doc]
|
|
231
|
+
end
|
|
232
|
+
{ kind: f["kind"], docs: docs, keys: keys, ids: ids, display: display, rank: rank }
|
|
205
233
|
end
|
|
206
234
|
|
|
207
235
|
def refresh
|
|
@@ -222,8 +250,9 @@ module CJKIndex
|
|
|
222
250
|
end
|
|
223
251
|
[f, ->(year) { !year.nil? && range.cover?(year) }]
|
|
224
252
|
else
|
|
225
|
-
|
|
226
|
-
|
|
253
|
+
# Compares value ids, not folded strings.
|
|
254
|
+
ids = Array(condition).flat_map { |v| f[:ids].fetch(Builder.filter_key(v, @normalizer), []) }.to_set
|
|
255
|
+
[f, ->(list) { list.any? { |id| ids.include?(id) } }]
|
|
227
256
|
end
|
|
228
257
|
end
|
|
229
258
|
end
|
|
@@ -237,7 +266,7 @@ module CJKIndex
|
|
|
237
266
|
nf = @fields.length
|
|
238
267
|
map = {}
|
|
239
268
|
tokens.each do |token|
|
|
240
|
-
postings =
|
|
269
|
+
postings = postings(token) or next
|
|
241
270
|
i = 0
|
|
242
271
|
while i < postings.length
|
|
243
272
|
count = postings[i + 2]
|
|
@@ -248,21 +277,56 @@ module CJKIndex
|
|
|
248
277
|
map
|
|
249
278
|
end
|
|
250
279
|
|
|
280
|
+
# Number of the shard that holds token (split index), or -1.
|
|
281
|
+
def shard_index(token) = (@shard_first.bsearch_index { |first| first > token } || @shard_first.length) - 1
|
|
282
|
+
|
|
283
|
+
def shard(i) = @shards[@shard_file[i]] ||= @load_shard.call(@shard_file[i])
|
|
284
|
+
|
|
285
|
+
# The Hash that holds token's posting list if it is in the index, or nil.
|
|
286
|
+
def table(token)
|
|
287
|
+
return @tokens unless @shard_first
|
|
288
|
+
|
|
289
|
+
i = shard_index(token)
|
|
290
|
+
i.negative? ? nil : shard(i)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def token?(token) = !!table(token)&.key?(token)
|
|
294
|
+
|
|
295
|
+
# The posting list of a token as [doc, field, n, pos, ...], or nil.
|
|
296
|
+
def postings(token)
|
|
297
|
+
list = table(token)&.[](token)
|
|
298
|
+
return list unless list && @encoded
|
|
299
|
+
|
|
300
|
+
@decoded[token] ||= Postings.decode(list, @fields.length)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Index words that start with token, token itself left out.
|
|
304
|
+
def with_prefix(token)
|
|
305
|
+
unless @shard_first
|
|
306
|
+
from = @latin_tokens.bsearch_index { |k| k >= token } || @latin_tokens.length
|
|
307
|
+
return @latin_tokens[from..].take_while { |k| k.start_with?(token) }.reject { |k| k == token }
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# In token's shard and the next ones that start with it (tokens are sorted).
|
|
311
|
+
first = [shard_index(token), 0].max
|
|
312
|
+
last = first
|
|
313
|
+
last += 1 while last + 1 < @shard_first.length && @shard_first[last + 1].start_with?(token)
|
|
314
|
+
(first..last).flat_map { |i| shard(i).keys.select { |k| k != token && k.start_with?(token) } }
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def latin_tokens
|
|
318
|
+
@latin_tokens ||= @load_shard.call(@latin_file).sort
|
|
319
|
+
end
|
|
320
|
+
|
|
251
321
|
# Alternatives for one query token: itself, and for Latin words the index
|
|
252
322
|
# words it is a prefix of (last word only) or one edit away from.
|
|
253
323
|
def alternatives(token, last)
|
|
254
324
|
return [[token], false] unless latin?(token)
|
|
255
325
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
break unless k.start_with?(token)
|
|
261
|
-
|
|
262
|
-
out << k unless k == token
|
|
263
|
-
end
|
|
264
|
-
end
|
|
265
|
-
out.concat(@latin_tokens.select { |k| one_edit?(token, k) }) if @typos && !@tokens.key?(token) && token.length >= 5
|
|
326
|
+
has = @shard_first ? token?(token) : @latin_set.include?(token)
|
|
327
|
+
out = has ? [token] : []
|
|
328
|
+
out.concat(with_prefix(token)) if @prefix && last
|
|
329
|
+
out.concat(latin_tokens.select { |k| one_edit?(token, k) }) if @typos && !has && token.length >= 5
|
|
266
330
|
[out, !out.empty? && out.first != token]
|
|
267
331
|
end
|
|
268
332
|
|