advanced-link-searcher@0.2.0
使い方
code:js
import {crossSearch} from '/api/code/programming-notes/advanced-link-searcher@0.2.0/script.js';
const projectIds = [
'57b3fe09ec2b330f00f15382', // /icons Icons
'5ebf80b491582c001e38c967', // /Icons2 Icons2
'5adc2250d5caf30014910a83', // /emoji emoji
];
const {search, update} = await crossSearch('unique-id', icons);
// 補完ソースの作成・更新
await update();
// 検索
const {result} = await search('todo');
classを止める
必要以上に複雑になって、メンテしづらい
実装したいこと
半角全角カタカナひらがなは同一視したい
/icons/done.icon複数の補完ソースを切り替えられるようにしたい
searchとupdateをひとまとめにすると良さそう
crossSearch(...)を呼び出すと、{search, update}が返ってくるみたいな
/icons/done.icon補完ソースの寿命を延ばす
毎回作り直すのは時間がかかる
/icons/done.icon別々のprojectで同じ補完ソースを使ってしまっている
例えばproject A用の補完ソースを読み込んだあと、project Bのページを新しいタブで開くと、project Aの補完ソースをそのまま使ってしまう
もしくはproject B用の補完ソースでproject A用の補完ソースを上書きしてしまう
対策
projectごとに補完ソースを分けられるようにする
補完ソースの名前をprojectごとに変えればいい
2021-06-27
10:09:54 補完ソースのkeyを配列にした
文字列だと、${name}-takker-memexを${name}-takker-と誤認識するなど、面倒な現象が発生してしまう
04:28:40 projectごとに補完ソースを分けるようにした
2021-06-25
01:16:20 補完ソースの並び替え方法を変えた
icon
project関係なく長さ順→辞書順に並べる
外部project link
project関係なくshuffleする
2021-06-19
22:07:37 hasIconを消して補完ソースの作成処理を簡単にしてみた
これで速くなるといいのだが
20:50:26 updateに時間がかかりすぎて、何かDOMを変更しようとすると固まる
web workerで別threadに逃がすしかない
19:45:57 iconありなしを正常に判定できていなかった
コードがちょっと雑かも
17:41:23 だいたい完成
なぜか検索結果に同じものが複数混じっている
https://gyazo.com/32eecd96611624a2bac64665f8d3737d
workerのlogを有効にして調べてみる
17:46:29 やはり補完ソース自体にダブリができていしまっているっぽい
18:00:14 原因わかった
直す
18:14:48 ループで除外したら遅すぎてfreezeした
algorithmを変えてもう一度試す
18:19:16 直った!
16:25:21 icon検索のソースとlink検索のソースとは別にする?
単にsourceにiconかどうかのflagを入れておいて、icon検索するときだけiconでないものを逐次除外すればいい?
検索速度に影響しないか?
どうせ一つずつAsearchで除外判定を出していくんだから、変わらないか。
dependencies
code:script.js
import {get as getLinks} from '../scrapbox-link-database@0.2.0/script.js';
import {openDB} from '../idb/with-async-ittr.js';
import {throttle} from '../custom-throttle/script.js';
// Web Workerを作っておく
.map(_ =>
// 相対パスは使えないみたい
new Worker('/api/code/programming-notes/advanced-link-searcher@0.2.0/worker.js')
);
// databaseの設定
const DBName = 'UserScript-SearchEngine';
const StoreName = 'search-list';
const Version = 8;
// databaseの初期化
function initialize() {
_log('', 'Start initializing');
return openDB(DBName, Version, {
upgrade(db) {
// Object Storeをすべて消す
db.deleteObjectStore(storeName)
);
db.createObjectStore(StoreName, {keyPath: 'key'});
},
}).then(db => {
_log('', 'Finish initializing');
return db;
});
}
const getDB = initialize();
// 検索エンジンを作る
// iconをtrueにすると、iconになるページのみを検索ソースにする
export async function crossSearch(name, projectIds, {icon = false} = {}) {
// Workerが検索に使うリストを作る
async function update(options) {
_log('update', [${name}] Loadng link data...);
const {data, hasUpdate} = await getLinks(projectIds, options);
_log('update', [${name}] Loaded.);
// 何も更新がなければ補完ソースを更新しない
if (!hasUpdate) {
const db = await getDB;
const keys = await db.getAllKeys(StoreName);
if (keys.some(key => key0 === name && key1 === scrapbox.Project.name)) { _log('update', [${name}] Search items are up to date.);
return;
}
}
_log('update', [${name}] Creating search items...);
// 検索用リストを作る
const source = icon ?
// アイコン用リストは辞書順に並べておく
data.flatMap(({project, pages}) => {
const titles = pages.flatMap(({title, hasIcon}) => hasIcon ? title : []); return titles.map(title => ({project, title}));
}) :
// 外部project linkはprojectに関わらずshuffleする
// serendipityを向上させる
shuffle(data.flatMap(({project, pages}) => {
const titles = [...new Set(pages.flatMap(({title, links}) => title, ...links))]; return titles.map(title => ({project, title}));
}));
_log('update', [${name}] Created ${source.length} search items.);
// 検索用文字列リストをworkers分だけ分割する
const chunkLength = Math.floor(source.length/workers.length) + 1;
const lists = workers.map((_, i) => source.slice(i * chunkLength, (i + 1) * chunkLength));
// databaseに格納する
_log('update', [${name}] Putting ${source.length} search items on Indexed DB...);
const db = await getDB;
const tx = db.transaction(StoreName, 'readwrite');
const store = tx.objectStore(StoreName);
const keys = await store.getAllKeys();
await Promise.all(lists.map((list, index) => {
return keys.find(key => key0 === name && key1 === scrapbox.Project.name && key2 === index) ? }));
await tx.done;
_log('update', [${name}] Finish putting on Indexed DB.);
}
const search = throttle(async (query, searchOptions) => {
const pendings = workers.map((worker, key) => new Promise(resolve => {
const listener = ({data}) => {
worker.removeEventListener('message', listener);
resolve(data);
};
worker.addEventListener('message', listener);
}));
const results = await Promise.all(pendings);
// 転置して曖昧度順に結果を並べて、limit分だけ返す
//_log('search', 'Finish a search', results);
const limit = searchOptions?.limit ?? 30;
return {
result: transpose(results).flatMap(lists => lists.flat()).slice(0, limit),
};
});
return {search, update};
}
function _log(functionName, ...messages) {
console.log([${functionName}@advanced-link-searcher@0.2.0], ...messages);
}
code:script.js
function transpose(a) {
return a0.map((_, c) => a.map(r => rc)); }
function shuffle(array) {
let result = array;
for (let i = result.length; 1 < i; i--) {
const k = Math.floor(Math.random() * i);
}
return result;
}
code:disabled(js)
// 各配列を、順序を維持したまま合体させる
function shuffle(arrays) {
const result = [];
const counters = arrays.map(array => array.length);
const maxLength = Math.max(arrays.map(array => array.length));
while (counters.some(counter => counter > 0)) {
const index = Math.floor(Math.random() * arrays.length);
if (countersindex === 0) continue; }
return result;
}
workerのcode
code:worker.js
self.importScripts('../asearch@1.0.2/worker.js');
self.importScripts('../idb/worker.js');
let db = undefined;
self.addEventListener('message', async ({data}) => {
const {key, DBName, StoreName, Version, query, searchOptions} = data;
await openDB(DBName, Version);
const source = (await db.get(StoreName, key))?.list ?? [];
self.postMessage(search(source, query, searchOptions, key));
});
async function openDB(databaseName, databaseVersion) {
if (db) return;
db = await idb.openDB(databaseName, databaseVersion);
}
function search(source, query, searchOptions, key) {
if (!source || source.length === 0) return [];
const {timeout = 5000, limit = 30, ambig = undefined, icon = false} = searchOptions ?? {};
// 値のcheck
if (typeof query !== 'string') throw Error('query is not a string.');
if (typeof ambig !== 'number' && ambig !== undefined) throw Error('ambig is not a number');
if (typeof limit !== 'number' && limit !== undefined) throw Error('limit is not a number');
if (limit <= 0) throw Error('limit is not more than 0.');
if (typeof timeout !== 'number') throw Error('timeout is not a number.');
if (timeout <= 0) throw Error('timeout is not more than 0.');
// 検索語句が空のときは何もしない
if (query.trim() === '') {
_log(key, 'search', Finish a search for "${query}");
return [[]];
}
_log(key, 'search', 'Start fuzzy search for ', {query, ambig, limit, timeout, source});
// 空白文字で区切った文字列を並び替え、曖昧検索objectを作る
const asearches = getPermutation(query.split(/\s/))
.map(wordList => Asearch( ${wordList.join(' ')} ));
// ambigの最大値を計算する
const maxAmbig = Math.min(4, ambig ?? Math.floor( ${query} .length / 4));
// 検索する
const result = [];
const totalResults = new Set();
let cancel = false; // 計算を中断するflag
const start = (new Date()).getTime();
for (let ambig_ = 0; ambig_ < maxAmbig; ambig_++) {
const matches = [];
for (const asearch of asearches) {
// 検索した文字列を重複を取り除いて追加する
for (const item of source) {
const {project, title} = item;
if (limit && totalResults.size >= limit) {
cancel = true;
break;
}
if (totalResults.has(item) || !asearch(title, ambig_)) continue;
matches.push(item);
totalResults.add(item);
}
if (start + timeout < (new Date()).getTime()) {
_log(key, 'search', time out, {query});
cancel = true;
}
if (cancel) break;
}
result.push(matches);
if (cancel) break;
}
_log(key, 'search', Finish fuzzy search for "${query}": , result);
return result;
}
code:worker.js
// 重複は考慮していない
function getPermutation(list) {
if (list.length == 0) return list;
if (list.length == 1) return list; if (list.length == 2) return list0,list[1,[list1,list0]]; return list.flatMap(first => {
const restList = list.filter(item => item !== first);
});
}
function _log(key, functionName, ...messages) {
//console.log([${functionName}@advanced-link-searcher-worker@0.2.0(${key})], ...messages);
}
test code
code:js
import('/api/code/programming-notes/advanced-link-searcher@0.2.0/test1.js')
code:test1.js
import {crossSearch} from './script.js';
const watchListIds = Object.keys(JSON.parse(localStorage.getItem('projectsLastAccessed')));
const icons = [
'57b3fe09ec2b330f00f15382', // /icons Icons
'5ebf80b491582c001e38c967', // /Icons2 Icons2
'5adc2250d5caf30014910a83', // /emoji emoji
];
(async () => {
{
const {search, update} = await crossSearch('external-completion', watchListIds);
window.update = update;
window.search = search;
}
{
const {search, update} = await crossSearch('emoji-completion', icons, {icon: true});
window.updateIcons = update;
window.searchIcons = search;
}
})();