generated at
scrapbox-incremental-fulltext-search
Vomnibarみたいなやつで検索する

特徴
矢印キー、 Tab で項目選択&閲覧
簡易横断検索
project名の入力欄上でマウスホイールを動かすと、projectが切り替わって自動的に再検索が走る
project名の入力欄にfocusが当たっているときは、矢印キーや Tab でも切り替えられる
探したいprojectを絞り込む
project名の入力欄で、検索したいprojectをあいまい検索できる
URLに使われるproject nameと表示名のどちらでも検索できる
参加しているprojectから検索し、見つかったprojectだけを候補に残す
Search besides watch list にチェックを入れると、watch listにある全てのprojectに対して横断検索を実行する
たくさん実行しすぎると429 Too Many Requestsが発生して検索できなくなるので注意
どのくらい実行したら怒られるんでしょうかyosider
そのくらいですねtakker
watch listが100未満であれば2回APIを叩くだけで済むので、もう少し実行できるようになります
間隔は1時間くらい間隔を開ければまあ大丈夫でしょう

install
お試し
js
import('/api/code/programming-notes/scrapbox-incremental-fulltext-search/sample.js');
自分のprojectにコピペする時
sh
deno 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したくない時
sh
deno 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
VomnibarみたいなUIにする
scrapbox-history-slider@0.2.0みたいな感じに、backgroundを半透明にする
backgroundを押すか <Esc> を押すと終了する
入力欄の隣に、検索先projectを指定できる窓を用意する
検索方法
入力欄が更新されるたびに検索する
custom-throttleで少し間引いて負荷低減させている

既知の問題

2021-06-25
02:30:47 色を当て忘れていた箇所があった
2021-06-24
09:42:32 項目をhoverした時の背景色を設定していなかった
07:47:16 fuzzy-select-menu@0.1.0の配色を決めた
07:33:17 fuzzy-select-menu@0.1.0にして、projectを絞り込みできるようにした
初期projectの設定ができていないという問題はある
検索はできている
どのprojectなのか表示されないだけ
2021-06-08
15:31:04
どの要素にfocusが当たっていても、 <Esc> でformを閉じれるようにした
refactor: componentの条件分岐を簡略化した
2021-06-04
20:27:42 429 Too Many Requestsが出たときにfreezeしないようにした
Error messageを表示する
19:31:47 横断検索の範囲を参加しているprojectのみに絞れるようにした
429 Too Many Requestsが出てしまったのでテストはできていない
2021-06-01
16:54:31 横断検索できるようにした
16:40:10 横断検索するHookを作った
watch listの検索がうまく行かない……
16:41:27 q を入れてなかった
入れたら直った
16:45:49
15:55:41 全文検索をCustom Hookに切り出した
14:41:02 project横断検索用buttonをつけた
検索機能は未実装
何らかのフラグの変更を useEffect() で検知して検索するようにしようと思う
14:28:28 自分の参加しているprojectとwatch Listとで分けた
03:30:47 CSS custom propertyを導入した
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> を消す

既知の問題
doneスタイルがずれてる?
width: inherit; にしたら直った
done429 Too Many RequestsになるとFreezeする
例外処理を作っていない
1回の横断検索で、複数回APIを叩いているから、5,6回ボタンを押すだけですぐ上限に達してしまう
横断検索の範囲を絞ればいいんだろうけど、いちいち範囲選択するのはめんどくさい
watch listのproject横断検索を連続して実行すると490 Unknownが発生する
json
{ "name": "UpdatingSearchServerError", "message": "Updating search server. Please try again later." }
少し待ってから検索したほうが良さそう
formを閉じにくい
画面が広いときは <Backgroud> を押せる余白が広いから良いけど
画面が狭いときやmobile端末だと <Background> が殆どないので閉じにくい
解決策:閉じるボタンを用意する

実装したいこと
done横断検索
案1
最後までscroll or 検索件数が少ないときに自動実行
検索リストに加える
押すと検索先projectを押したやつに変える
(採用)案2
横断検索するボタンを用意する
自動では検索しない
APIの実行上限を超えてしまう
見つかったprojectのみをfuzzy-select-menu@0.1.0の候補に入れる
projectを上下キーで変えると、必ず1件以上の検索結果が返ってくる
キーワードに引っかかるprojectを手動で探さずに済む
検索語句が変わったらリセット
案3
検索実行後、1秒間検索語句に変化がなければ横断検索を実行する
project名を曖昧テキスト検索したい
project nameとproject display nameのどちらからでも検索できるようにする
fuzzy-select-menu@0.1.0として別Componentにするか
曖昧検索で絞り込む
上下キーで選択肢iを変えられる
donewheelでも変えられる
done自分が所属しているprojectが前の方に来るようにsort方法を変える
参加しているprojectを辞書順に並べた後、参加していないprojectをまた辞書順に並べる
2021-06-01 17:05:23 辞書順に並べるのは面倒なのでやっていない
failprojectの更新日時順に並べる?
最新情報がほしいわけではないのでいらない
done新しいタブでページを開いたときは検索windowを閉じないようにする
meta keysを押しながら開いた時
a[target="_blank"] を押した時
context menuから開くのには対応しなくていい
そもそもclickとみなされないから検索windowが消えることはない
done参加しているprojectだけを対象に横断検索するボタンを用意したい
check boxでもいいか
doneAPI rate limitに到達したなどでエラーが発生した時の処理を追加したい
開いているページのタイトルをデフォルトで入力しておく?
選択状態にしておいて、上書きできるようにしておく
APIが違うので特別な処理をかませる必要がありますが、できなくはないですねtakker
いや、そんなに難しくもなさそう
external-completion@0.4.0でタイトルと同じ文字列で検索するという手段もある
こちらはあいまい検索で全てのprojectから絞り込めるという強力なメリットがある


sample.js
import {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.js
import {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.js
function 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}; }
project横断検索をするHook
script.js
function 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
CSS custom propertyで色指定できるようにした
script.js
const 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.js
export 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
<select>を使う
依存配列にrefはいらないっぽい
selector.js
import {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.js
import {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.js
import {useState, useMemo, useEffect} from '../preact@10.5.13/hooks.js'; export const Description = ({text, words}) => { const = useMemo(() => {}, [text, words]); }

Qiita
Deno