scrapbox-incremental-fulltext-search
特徴
矢印キー、 Tab
で項目選択&閲覧
簡易横断検索
project名の入力欄上で
マウスホイールを動かすと、projectが切り替わって自動的に再検索が走る
project名の入力欄にfocusが当たっているときは、矢印キーや Tab
でも切り替えられる
探したいprojectを絞り込む
project名の入力欄で、検索したいprojectをあいまい検索できる
URLに使われるproject nameと表示名のどちらでも検索できる
参加しているprojectから検索し、見つかったprojectだけを候補に残す
Search besides watch list
にチェックを入れると、watch listにある全てのprojectに対して横断検索を実行する
どのくらい実行したら怒られるんでしょうか
そのくらいですね
watch listが100未満であれば2回APIを叩くだけで済むので、もう少し実行できるようになります
間隔は1時間くらい間隔を開ければまあ大丈夫でしょう
install
お試し
jsimport('/api/code/programming-notes/scrapbox-incremental-fulltext-search/sample.js');
自分のprojectにコピペする時
shdeno run --allow-net --allow-read --allow-write --allow-run --allow-env --unstable https://scrapbox.io/api/code/takker/UserScriptをbundleするDeno_script/build.ts https://scrapbox.io/api/code/programming-notes/scrapbox-incremental-fulltext-search/sample.js --bundle --minify --outfile=script.min.js
Preactをbundleしたくない時
shdeno run --allow-net --allow-read --allow-write --allow-run --allow-env --unstable https://scrapbox.io/api/code/takker/UserScriptをbundleするDeno_script/build.ts https://scrapbox.io/api/code/programming-notes/scrapbox-incremental-fulltext-search/sample.js --bundle --minify --outfile=script.min.js --external=../htm@3.0.4%2Fpreact/script.js --external=../preact@10.5.13/hooks.js
実装
UI
backgroundを押すか <Esc>
を押すと終了する
入力欄の隣に、検索先projectを指定できる窓を用意する
検索方法
入力欄が更新されるたびに検索する
既知の問題
2021-06-25
02:30:47 色を当て忘れていた箇所があった
2021-06-24
09:42:32 項目をhoverした時の背景色を設定していなかった
初期projectの設定ができていないという問題はある
検索はできている
どのprojectなのか表示されないだけ
2021-06-08
15:31:04
どの要素にfocusが当たっていても、 <Esc>
でformを閉じれるようにした
refactor: componentの条件分岐を簡略化した
2021-06-04
Error messageを表示する
19:31:47 横断検索の範囲を参加しているprojectのみに絞れるようにした
2021-06-01
16:54:31 横断検索できるようにした
16:40:10 横断検索するHookを作った
watch listの検索がうまく行かない……
16:41:27 q
を入れてなかった
入れたら直った
16:45:49
15:55:41 全文検索をCustom Hookに切り出した
検索機能は未実装
何らかのフラグの変更を useEffect()
で検知して検索するようにしようと思う
14:28:28 自分の参加しているprojectとwatch Listとで分けた
02:56:22 .info
の文字色を変えた
見にくかったので
02:49:14 handleKeydown()
を .container
に移した
2021-05-31
14:28:36
読み込み中か検索件数かのどちらかのみを表示する
文字色を変えた
00:41:05 <style>
を <App>
の外に出した
00:37:10 検索件数が0のときは <ul>
を消す
既知の問題
スタイルがずれてる?
width: inherit;
にしたら直った
例外処理を作っていない
1回の横断検索で、複数回APIを叩いているから、5,6回ボタンを押すだけですぐ上限に達してしまう
横断検索の範囲を絞ればいいんだろうけど、いちいち範囲選択するのはめんどくさい
json{
"name": "UpdatingSearchServerError",
"message": "Updating search server. Please try again later."
}
少し待ってから検索したほうが良さそう
formを閉じにくい
画面が広いときは <Backgroud>
を押せる余白が広いから良いけど
画面が狭いときやmobile端末だと <Background>
が殆どないので閉じにくい
解決策:閉じるボタンを用意する
実装したいこと
横断検索
案1
最後までscroll or 検索件数が少ないときに自動実行
検索リストに加える
押すと検索先projectを押したやつに変える
(採用)案2
横断検索するボタンを用意する
自動では検索しない
APIの実行上限を超えてしまう
projectを上下キーで変えると、必ず1件以上の検索結果が返ってくる
キーワードに引っかかるprojectを手動で探さずに済む
検索語句が変わったらリセット
案3
検索実行後、1秒間検索語句に変化がなければ横断検索を実行する
project nameとproject display nameのどちらからでも検索できるようにする
曖昧検索で絞り込む
上下キーで選択肢iを変えられる
自分が所属しているprojectが前の方に来るようにsort方法を変える
参加しているprojectを辞書順に並べた後、参加していないprojectをまた辞書順に並べる
2021-06-01 17:05:23 辞書順に並べるのは面倒なのでやっていない
projectの更新日時順に並べる?
最新情報がほしいわけではないのでいらない
新しいタブでページを開いたときは検索windowを閉じないようにする
meta keysを押しながら開いた時
a[target="_blank"]
を押した時
context menuから開くのには対応しなくていい
そもそもclickとみなされないから検索windowが消えることはない
参加しているprojectだけを対象に横断検索するボタンを用意したい
check boxでもいいか
API rate limitに到達したなどでエラーが発生した時の処理を追加したい
開いているページのタイトルをデフォルトで入力しておく?
選択状態にしておいて、上書きできるようにしておく
APIが違うので特別な処理をかませる必要がありますが、できなくはないですね
いや、そんなに難しくもなさそう
こちらはあいまい検索で全てのprojectから絞り込めるという強力なメリットがある
sample.jsimport {mount} from './script.js';
import {getProjectInfo} from '../scrapboxのproject情報を一括して取得するUserScript/script.js';
(async () => {
// watch listからもとってくる
const watchListIds = Object.keys(JSON.parse(localStorage.getItem('projectsLastAccessed')));
const watchList = [...await getProjectInfo(watchListIds)]
.map(({id, name, displayName}) => ({id, name, displayName}))
.sort((a, b) => a.displayName.localeCompare(b.displayName));
mount(watchList);
})();
dependencies
script.jsimport {html, render} from '../htm@3.0.4%2Fpreact/script.js';
import {throttle} from '../custom-throttle/script.js';
import {FuzzySelect} from '../fuzzy-select-menu@0.1.0/script.js';
import {useState, useMemo, useEffect} from '../preact@10.5.13/hooks.js';
import {useLoader} from '../use-loader/script.js';
import {Background, CSS as BackgroundCSS} from '../modal-background@0.1.0/script.js';
import {ProjectSelector} from './selector.js';
import {ErrorMsg, CSS as ErrorCSS} from './error.js';
import {toLc} from '../scrapbox-titleLc/script.js';
const App = ({watchList}) => {
const [open, setOpen] = useState(false);
const [project, setProject] = useState(scrapbox.Project.name);
const [query, setQuery] = useState('');
const [disabled, setDisabled] = useState(true);
// ボタンの状態から、横断検索の開始を判定する
const queryForProject = useMemo(() => disabled ? query : '', [disabled, query]);
const [includeWatchList, setIncludeWatchList] = useState(false);
const {loading, items} = useSearch({project, query}); // 全文検索する
const {searching, error, projects} = useProjectSearch({ // projectを横断検索する
query: queryForProject,
watchList: watchList,
includeWatchList,
});
// FuzzySelect用にデータを加工する
const list = useMemo(() =>
projects.map(({id, name, displayName}) => ({key: name, text: displayName})),
[projects]);
// 横断検索のAPI limitに引っかかっているときはボタンを無効化する
useEffect(() => error && setDisabled(true), [error]);
// 入力欄の値を反映する
const onProjectChange = ({key}) => setProject(key);
const onInput = ({target: {value}}) => {
setDisabled(false);
setQuery(value);
};
// componentを閉じる
const close = () => setOpen(false);
const handleKeydown = ({key}) => {
if (key !== 'Escape') return;
close();
}
const onClick = ({ctrlKey, shiftKey, altKey, metaKey, target}) => {
if (target.target === '_blank' || ctrlKey || shiftKey || altKey || metaKey) return;
close();
};
// project横断検索を開始する
const onFilter = () => {
setDisabled(true);
}
// Page Menuから操作する
useEffect(() => scrapbox.PageMenu.addItem({
title: 'Fulltext Search',
image: 'https://raw.githubusercontent.com/nota/kamon/master/svg/search.svg',
onClick: () => setOpen(true),
}), []);
return open && html`
<${Background} onClose="${close}"/>
<div class="container" onKeydownCapture="${handleKeydown}">
<div class="search-form">
<${FuzzySelect}
list="${list}"
convert="${({key, text}) => `${key} ${text}`}"
onSelect="${onProjectChange}" />
<input type="text" value="${query}" onInput="${onInput}" />
<button type="button" onClick="${onFilter}" disabled="${disabled}">
${disabled ?
`${searching ? 'Searching...: ' : ''}Found ${projects.length} projects` :
'Search for all projects'
}
</button>
<input
type="checkbox"
value="${!includeWatchList}"
onChange="${({target}) => setIncludeWatchList(target.value)}" />
<label>Search besides watch list</label>
<span class="info">
${loading ? `Searching for ${query}...` : `${items.length} results`}
</span>
<${ErrorMsg} error="${error}" />
</div>
${items.length > 0 && html`
<ul class="dropdown">
${items.map(item => html`<li key="${item.title}">
<a href="/${item.project}/${toLc(item.title)}"
target="${item.project === scrapbox.Project.name ? '' : '_blank'}"
rel="${item.project === scrapbox.Project.name ? 'route' : 'noopener noreferrer'}"
onClick="${onClick}">
${item.title}
<div class="description">
${item.lines.map(line => html`<span>${line}</span>`)}
</div>
</a>
</li>`)}
</ul>
`}
</div>`;
};
Custom Hooks
全文検索するHook
script.jsfunction useSearch({project, query}) {
const [items, setItems] = useState([]);
const search = useMemo(() => throttle(async (_project, _query) => {
if (_query === '' || _project === '') {
setItems([]);
return;
}
try {
const res = await fetch(`/api/pages/${_project}/search/query?q=${encodeURIComponent(_query)}`);
const {pages} = await res.json();
setItems(pages.map(({title, words, lines}) => ({project: _project, title, words, lines})));
} catch(e) {
console.error(e);
setItems([]);
}
}, 500, {immediate: false}), []);
const {loading} = useLoader(
async () => await search(project, query),
{delay: 1500},
[search, project, query]
);
return {loading, items};
}
script.jsfunction useProjectSearch({query, watchList: _watchList, includeWatchList}) {
const [joinedList, setJoinedList] = useState([]); // 参加しているprojectのlist
const watchList = useMemo(() => { // watchListから参加しているprojectを予め除いておく
const ids = joinedList.map(({id}) => id);
return _watchList.filter(({id}) => !ids.some(_id => _id === id));
}, [joinedList, _watchList]);
const [projects, setProjects] = useState([]); // 検索結果
const [searching, setSearching] = useState(false); // 検索中かどうか
const [error, setError] = useState(undefined);
// project listを初期化する
useEffect(() => (async () => {
// 参加しているprojectを取得する
const res = await fetch('/api/projects');
if (!res.ok) return [];
const json = await res.json();
setJoinedList(json.projects?.map?.
(({id, name, displayName}) => ({id, name, displayName})) ?? []);
})(), []);
// projectを横断検索する
useEffect(() => (async () => {
setError(undefined);
// 検索語句が空なら、defaultのproject listを返す
if (query === '') {
setProjects([...joinedList, ...watchList]);
return;
}
setSearching(true);
setProjects([]); // 一旦クリア
try {
// 参加しているprojectから検索する
{
const res = await fetch(`/api/projects/search/query?q=${query}`);
const json = await res.json();
if (!res.ok) throw Error(json.message);
setProjects(json.projects);
}
// watch listから検索する
if (includeWatchList) {
// 100件ずつ検索する
const chunkNum = Math.floor(watchList.length / 100) + 1;
for (let index = 0; index < chunkNum; index++) {
const params = new URLSearchParams();
params.append('q', query);
watchList.slice(index * 100, 100 + index * 100)
.forEach(({id}) => params.append('ids', id));
const res = await fetch(`/api/projects/search/watch-list?${params.toString()}`);
const json = await res.json();
if (!res.ok) throw Error(json.message);
setProjects(before => [...before, ...json.projects]);
}
}
} catch(e) {
setError(e.toString());
} finally {
setSearching(false);
}
})(),
[query, watchList, joinedList, includeWatchList],
);
return {searching, error, projects};
}
CSS
script.jsconst CSS = `
:host {
--dropdown-text-color: var(--incremental-fulltext-search-text-color, #333);
--dropdown-bg: var(--incremental-fulltext-search-result-bg, #fff);
--dropdown-border-color: var(--body-bg, rgba(0,0,0,0.15));
--dropdown-shadow-color: rgba(0,0,0,0.175);
--dropdown-item-hover-text-color: var(--incremental-fulltext-search-hover-text-color, #333);
--dropdown-item-hover-bg: var(--incremental-fulltext-search-result-hover-bg, #f5f5f5);
--dropdown-item-select-border-color: #66afe999;
}
.container {
display: block;
position: fixed;
width: calc(100% - 20px);
top: 5vh;
left: 10px;
color: var(--incremental-fulltext-search-text-color, #4a4a4a);
z-index: 90000;
}
span {
margin-right: .5em;
}
.search-form {
width: inherit;
border-radius: 5px;
padding: 0 10px;
border: transparent;
box-shadow: none;
font-size: 14px;
color: var(--search-form-text-color, rgba(255,255,255,0.35));
background-color: var(--search-form-bg, rgba(255,255,255,0.15));
}
.info {
display: block;
}
.dropdown {
max-height: 80vh;
flex-direction: column;
width: 100%;
padding: 5px 0;
margin: 2px 0 0;
list-style: none;
font-size: 14px;
font-weight: normal;
line-height: 28px;
text-align: left;
border: 1px solid rgba(0,0,0,0.15);
border-radius: 4px;
background-clip: padding-box;
background-color: var(--incremental-fulltext-search-result-bg, #fefefe);
white-space: nowrap;
overflow-x: hidden;
overflow-y: auto;
text-overflow: ellipsis;
}
a {
display: block;
padding: 3px 20px;
clear: both;
align-items: center;
font-weight:normal;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
text-decoration: none;
text-overflow: ellipsis;
color: var(--incremental-fulltext-search-text-color, #262626);
background-color: var(--incremental-fulltext-search-result-bg, #f5f5f5);
}
a:hover {
text-decoration: none;
color: var(--incremental-fulltext-search-hover-text-color, #262626);
background-color: var(--incremental-fulltext-search-result-hover-bg, #f5f5f5);
}
a:focus {
color: var(--incremental-fulltext-search-hover-text-color, #262626);
background-color: var(--incremental-fulltext-search-result-hover-bg, #f5f5f5);
outline: 0;
box-shadow: 0 0px 0px 3px rgba(102,175,233,0.6);
border-color: #66afe9;
transition: border-color ease-in-out 0.15s,box-shadow ease-in-out 0.15s;
}
.description {
display: block;
margin-top: 0.5em;
color: var(--incremental-fulltext-search-description-text-color, gray);
font-size: 12px;
line-height: 14px;
max-height: 28px;
overflow: hidden;
text-overflow: ellipsis;
}
${BackgroundCSS}
${ErrorCSS}
`;
Renderingする
script.jsexport function mount(watchList) {
const app = document.createElement('div');
app.dataset.userscriptName = 'incremental-fulltext-search-form';
app.attachShadow({mode: 'open'});
document.body.append(app);
render(html`
<style>
:host {
--incremental-fulltext-search-text-color: var(--page-text-color, #4a4a4a);
--incremental-fulltext-search-hover-text-color: var(--page-text-color, #4a4a4a);
--incremental-fulltext-search-description-text-color: var(--card-description-color, gray);
--incremental-fulltext-search-result-bg: var(--page-bg, #fefefe);
--incremental-fulltext-search-result-hover-bg: var(--card-hover-bg, #f5f5f5);
}
${CSS}
</style>
<${App} watchList="${watchList}"/>
`, app.shadowRoot);
}
Components
(deprecated)project選択用select box
selector.jsimport {html} from '../htm@3.0.4%2Fpreact/script.js';
import {useRef, useCallback} from '../preact@10.5.13/hooks.js';
export const ProjectSelector = ({projects, selectedProject, onSelect}) => {
const ref = useRef(null);
const handleWheel = useCallback(e => {
e.preventDefault();
e.stopPropagation();
ref.current.selectedIndex = e.deltaY < 0 ?
Math.max(ref.current.selectedIndex - 1, 0) :
Math.min(ref.current.selectedIndex + 1, ref.current.length);
onSelect?.(ref.current.value);
}, []);
return html`
<select ref="${ref}" value="${selectedProject}" onChange="${({target: {value}}) => onSelect(value)}" onWheel="${handleWheel}">
${projects.map(project => html`<option key="${project.id}" value="${project.name}">${project.displayName}</option>`)}
</select>
`;
};
error message表示欄
error.jsimport {html} from '../htm@3.0.4%2Fpreact/script.js';
export const ErrorMsg = ({error}) => error && html`
<div class="error">${error}</div>
`;
export const CSS = `
.error {
display: block;
padding: 15px;
margin: 20px;
text-align: center;
border: 1px solid #ebccd1;
background-color: #f2dede;
color: #a94442;
}
.error::before {
font: normal normal normal 14px/1 FontAwesome;
content: '\f071';
margin-right: .3em;
}
`;
(WIP)検索して見つかった文字を強調できるやつ
description.jsimport {useState, useMemo, useEffect} from '../preact@10.5.13/hooks.js';
export const Description = ({text, words}) => {
const = useMemo(() => {}, [text, words]);
}