検索したときもAutoPagerが機能するようにした。

ブツ

インストール&実行

NAME='Electron.MyLog.20220925123308'
git clone https://github.com/ytyaru/$NAME
cd $NAME
npm install
npm start

動作確認

  • 検索ボックスにを入力すると23件取得できる(20件以上の場合)
  • 検索ボックスのを消すと全件取得する(58件(AutoPagerで3回取得する))
  • 検索ボックスにを入力すると6件取得する(20件未満の場合)
  • 検索ボックスにを入力すると0件取得する(0件の場合)

それぞれ想定どおりに表示されたことを確認した。

やったこと。

検索したときもAutoPagerが機能するようにした。

全件のときと検索のときとでSQLが異なる。検索のときはキーワードという引数が必要になるため、全件のときと異なるインタフェースになる。

全件

select * from comments order by created desc limit ${limit} offset ${offset};

検索

select * from comments where content like '%${keyword}%' order by created desc limit ${limit} offset ${offset};

それぞれの件数を取得するSQLも次のように用意した。

select count(*) from comments;
select count(*) from comments where content like '%${keyword}%';

これをElectronのIPC通信用インタフェースとしてmain.jsとpreload.jsに実装した。今回追記したのは検索用のもの。

ipcMain.handle('searchPage', async(event, keyword, limit, offset) => {
    const sql = `select * from comments where content like '%${keyword}%' order by created desc limit ${limit} offset ${offset};`
    const res = lib.get(`DB`).exec(sql)
    return (0 === res.length) ? null : res[0].values
})
ipcMain.handle('searchCount', async(event, keyword) => {
    const res = lib.get(`DB`).exec(`select count(*) from comments where content like '%${keyword}%';`)
    return (0 === res.length) ? res : res[0].values[0][0]
})
searchPage:async(keyword, limit, offset)=>await ipcRenderer.invoke('searchPage', keyword, limit, offset),
searchCount:async(keyword)=>await ipcRenderer.invoke('searchCount', keyword),

全件 auto-pager-calc.js

全件から20件ずつ取得するクラス。

class AutoPagerCalc {
    async clear() {
        this.limit = 20
        this.page = -1
        this.offset = this.limit * this.page
        this.count = await this.getCount()
    }
    async next() {
        if (this.offset < this.count && this.limit < this.count) {
            this.page++;
            this.offset = this.limit * this.page
            return true
        } else { return false }
    }
    async getPage() { return await window.myApi.getPage(this.limit, this.offset) }
    async getCount() { return await window.myApi.count() }
}

検索 auto-pager-calc-search.js

全件のそれを継承した。呼び出すSQLメソッドを検索のそれに変更した。20件ずつ取得するところは共通。

class AutoPagerCalcSearch extends AutoPagerCalc {
    constructor(searchElQuery) {
        super()
        this.searchElQuery = searchElQuery
    }
    async getPage() { return await window.myApi.searchPage(document.querySelector(this.searchElQuery).value, this.limit, this.offset) }
    async getCount() { return await window.myApi.searchCount(document.querySelector(this.searchElQuery).value) }
}

というのが理想だった。実際は検索キーワードの取得はdocument.querySelector(this.searchElQuery).valueではできない。正確にはinputイベント発生時の値を取得できない。なのでメソッドの引数としてイベント発火時に渡されたものを受け取る必要がある。そのせいで引数が必要になり、親クラスと異なるインタフェースになってしまう。メソッド名がおなじで引数や戻り値がちがうもの、すなわちオーバーロードである。

だがじつはJavaScriptはオーバーロードができない仕様だった。親のメソッドが呼ばれてしまう。

なのでやむなくメソッド名を変えて引数をつけた。もう完全に別名。そのせいで呼び出し元を共通化できず、ifswitchにより分岐式を書かねばならなくなってしまった。これが嫌でメソッドの名前や引数をおなじにしたかったのに……。

class AutoPagerCalcSearch extends AutoPagerCalc {
    constructor(searchElQuery) {
        super()
        this.searchElQuery = searchElQuery
    }
    // inputイベント時の値が取得できない。引数で受け取るしかない。
    //async getPage() { console.log('Search.getPage()', this.limit, this.offset); return await window.myApi.searchPage(document.querySelector(this.searchElQuery).value, this.limit, this.offset) }
    // 親メソッドが呼ばれてしまう。JSはオーバーロードできない仕様。
    //async getPage(keyword) { console.log('Search.getPage()', keyword, this.limit, this.offset); return await window.myApi.searchPage(keyword, this.limit, this.offset) }
    async getSearchPage(keyword) { console.log('Search.getPage()', keyword, this.limit, this.offset); return await window.myApi.searchPage(keyword, this.limit, this.offset) }

    async getCount() { console.log('Search.getCount()'); return await window.myApi.searchCount(document.querySelector(this.searchElQuery).value) }

auto-pager.js

上記をうまいこと呼び出す。

class AutoPager {
    constructor(setting, scrollElQuery, searchElQuery) {
        console.log(setting, scrollElQuery, searchElQuery)
        this.MODES = ['all', 'search']
        this.mode = 'all' // 'all' or 'search'
        this.pager = {
            'all': new AutoPagerCalc(),
            'search': new AutoPagerCalcSearch(searchElQuery),
        }
        this.setting = setting
        //this.scrollEl = document.querySelector('#post-list')
        this.scrollEl = document.querySelector(scrollElQuery)
        this.searchEl = document.querySelector(searchElQuery)
        this.timeoutId = 0
        //console.log('AutoPager.count:', this.count, this.offset)
    }
    async changeMode(mode) {
        console.log('AutoPager.changeMode():', mode)
        if (mode === this.mode) { return }
        this.mode = mode
        console.log('AutoPager.changeMode():', 'this.clear()')
        await this.clear()
        this.scrollEl.innerHTML = ''
    }
    //async setup(scrollElId, searchElId, mode='all') {
    async setup() {
        console.log('AutoPager.setup()')
        this.mode = 'all'
        await this.clear()
        //this.count = await window.myApi.count()
        this.scrollEl.addEventListener('scroll', async(event) => {
            clearTimeout(this.timeoutId);
            this.timeoutId = setTimeout(async()=>{
                if (this.#isFullScrolled(event)) {
                    console.log('scroll event!!:', this.mode)
                    //this.#toHtml(await this.#next())
                    //await this.next()
                    this.next(this.searchEl.value)
                }
            }, 200);
        })
        this.searchEl.addEventListener('input', async(e)=>{
            await this.changeMode((0 < e.target.value.length) ? 'search' : 'all')
            await this.next(e.target.value)
        })
        this.next(this.searchEl.value)
        //this.next()
        //this.#toHtml(await this.#next())
    }
    //async next() { this.#toHtml(await this.#next()) }
    async next(keyword) { this.#toHtml(await this.#next(keyword)) }
    async clear(mode=null) {
        if (mode) { await this.pager[mode].clear() }
        else { for await (var m of this.MODES) { await this.pager[m].clear() } }
    }
    #isFullScrolled(event) {
        const adjustmentValue = 60 // ブラウザ設定にもよる。一番下までいかずとも許容する
        const positionWithAdjustmentValue = event.target.clientHeight + event.target.scrollTop + adjustmentValue
        console.log(`isFullScrolled: ${positionWithAdjustmentValue >= event.target.scrollHeight}`)
        return positionWithAdjustmentValue >= event.target.scrollHeight
    }
    async #next(keyword) {
        console.log('AutoPager.next(): ', this.mode)
        const next = await this.pager[this.mode].next()
        console.log('next():', next, this.mode)
        switch (this.mode) {
            case 'all': return await this.pager[this.mode].getPage(keyword)
            case 'search': return await this.pager[this.mode].getSearchPage(keyword)
            default: throw new Error(`this.modeは all か search にしてください。`)
        }
        //return await this.pager[this.mode].getPage(keyword)
        /*
        if (this.offset < this.count) {
            this.page++;
            this.offset = this.limit * this.page
            console.log(this.limit, this.offset)
            return await window.myApi.getPage(this.limit, this.offset)
        } else { return [] }
        */
    }
    #toHtml(records) {
        console.log(records)
        if (records) {
            this.scrollEl.insertAdjacentHTML('beforeend', records.map(r=>TextToHtml.toHtml(r[0], r[1], r[2], this.setting.mona.address)).join(''))
        }
    }
}

this.mode, this.pagerあたりを追加した。コメントアウトは前回から消したもの。

スクロールイベントを登録し、20件ずつ取得する処理を呼び出してページングを実現している。

全件、検索、それぞれの場合のページング処理用インスタンスをもつ。指定されたモードのそれを使用する。モードは文字列でなくもっとスマートな設計がありそうな気がする。思いつかなかったのでこうした。

なるだけ差異を吸収すべくクラスの継承をもちいた。呼出元は共通インタフェースをもちいて分岐処理をなくそうとした。しかし呼出の共通化はできなかった。原因はJavaScriptがオーバーロードできない仕様だったこと。オブジェクト指向やプロトタイプチェーン、デザインパターンなど設計に関するあれこれを理解できていないせいか超ハマった。

バグ修正

text-to-html.js

static countUrl(text) { // text内にあるURLの数を数える
    const regexp_url = /(([http|https|ipfs]?:\/\/[a-zA-Z0-9.\-_@:/~?%&;=+#',()*!]+))/g;
    const match = text.match(regexp_url)
    return (match) ? text.match(regexp_url).length : 0
    //return text.match(regexp_url).length
}

URLらしき文字列がゼロ件のときlengthがないと怒られたのでコメントアウト部分を修正した。これはURLを5件に制限する機能を実装したときに発見すべきバグだった。テストまったくしてないからね。

テストデータ修正

つぶやきのテストデータのうち31としたつもりが32になってた。32が2つあったので修正した。

update comments set content='31' where id=39;

ハマった所

preload.js

いつもコピペミスしてしまう所。invokeに渡す第一引数のテキストを修正し忘れてしまい、違うメソッドを呼び出していた。しかも類似メソッドのせいで気づくのにものすごい時間がかかってしまった。

searchPage:async(keyword, limit, offset)=>await ipcRenderer.invoke('search', keyword, limit, offset),
searchPage:async(keyword, limit, offset)=>await ipcRenderer.invoke('searchPage', keyword, limit, offset),

ほかにも第二引数以降を修正し忘れることもよくある。

このIPC通信用メソッドの作成は本当にただただ面倒で厄介なだけの嫌な作業。

for await

for文内でawaitするとき特殊な記法をせねばならない。

auto-pager.js

for await (var m of this.MODES) { await this.pager[m].clear() }

forの後ろにawaitを書き忘れたせいでawaitされていなかった。そのせいでclear()される前にnext()が呼び出されてしまいハマった。

こんな特殊な記法をしなくちゃいけないという事実そのものを忘れていた。

event.target.value

inputイベント時、その要素のvalueを取得する。このときイベントのコールバック関数から与えられたevent.target.valueでないと正しい値を取得できない。

要素のIDなどを引数に渡してほかのクラスのメソッド内でdocument.querySelector(...).valueとしても、イベント時の値は取得できない。そのせいでイベント時に取得できる値event.target.valueをメソッドの引数として渡さねばならない。

このとき呼び出すメソッドはつぶやきを取得するもの。そのとき状況によって引数なしで呼んだり、引数ありで呼んだりする。

条件 引数
全件 なし
検索 検索キーワード

ダサいやり方としてはif文で分岐すること。これが最も単純。というか、JavaScriptではそのように実装することしかできなかった。

オブジェクト指向ではSteteパターンで共通メソッドを呼び出す方法がある。今回はそれで実装しようとしたのだが、JavaScriptにはインタフェースもなければオーバーロードすらできない。そのことを理解できておらず、中途半端な実装になってしまった。

オーバーロードしたメソッドは呼び出されなかった。親側メソッドが呼ばれるだけで子は呼ばれなかった。たぶんプロトタイプチェーンをよく理解していないせいだと思う。

結局、以下のように分岐処理を書いてメソッドを呼び分けるというダサい書き方になった。

switch (this.mode) {
    case 'all': return await this.pager[this.mode].getPage(keyword)
    case 'search': return await this.pager[this.mode].getSearchPage(keyword)
    default: throw new Error(`this.modeは all か search にしてください。`)
}

わざわざ別クラスにした意味ないのでは……。

どうせ分岐処理を書かねばならないなら中間クラスをやめて以下のように直接呼び出したほうがマシだったような。

switch (this.mode) {
    case 'all': return await window.myApi.getPage(limit, offset)
    case 'search': return await window.myApi.searchPage(keyword, limit, offset)
    default: throw new Error(`this.modeは all か search にしてください。`)
}

本当は以下のように呼び出したかったのに。そのために中間クラスを書いたのに。

return await this.pager[this.mode].getPage()

せめてオーバーロードが使えたら以下でもよかったはずなのに。

return await this.pager[this.mode].getPage(keyword)

むしろ中間クラスのせいでムダに冗長化しただけという残念な結果になった。とはいえ、シンプルなif文にするとそれはそれで冗長になりそう。limitoffsetの計算をsearchallでそれぞれもたせるとか。

いい感じにDRYに書けない。

もしTypeScriptなら実装できたのだろうか。ググってみると一応できそうだった。でも書き方がなんかダサい。もっとシンプルに書けないものか。

JavaScriptの限界をみた。JavaやC#ならこのへんをスッキリ書けそうな気がする。

検索での課題

  • [x] バグ修正。スクロールを最下端にやると全件取得の最新から20件を取得してしまう(検索キーワードと関係なく)
  • [x] ページング実装。(最新順20件ずつ。LIMIT句、OFFSET句)
    • [ ] 大文字と小文字を区別したい(LIKE句では区別しない仕様)
    • [ ] メタ文字をエスケープしたい(%, _がメタ文字。like '%10$%' escape '$'
  • [ ] リファクタリング。Steteパターンで書けないのでシンプルな条件分岐にする
  • [ ] 取得件数を表示したい
  • [ ] FTS(Full Text Search)を使うべき
    • [ ] AND, OR検索したい
      • [ ] キーワードをスペース区切りにしたら各語ごとにANDをかけたい(現状はスペースもキーワードの一部になってしまう)
      • [ ] 表記ゆれに該当するものをOR検索したい
    • [ ] 一致率に応じて優先順位を算出したい
  • [ ] スクロールバーにフォーカスするショートカットキーを設定したい
  • [ ] 出力サイト側でも検索したい

最低限やりたかったことはできた。ほかは未定。

それ以前にJavaScriptの言語仕様をよくわかってないのが問題か。