エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

Go で Chrome Bookmark Fuzzy-finder を作ってたら無限ネスト JSON を平らにしてた

f:id:abctail30:20201222210244p:plain

エムスリーエンジニアリンググループ AI・機械学習チームの中村(@po3rin) です。 好きな言語はGo。仕事では主に検索周りを担当しています。今回はGoでfuzzy-findingツールを作ったのでその方法と、Chromeブックマークのような無限ネストJSONを Visitor Pattern でスライスにパースする方法を紹介します。

fuzzy-findingとは

いわゆる あいまい検索 で、文字列の部分一致でインタラクティブに絞り込みながら検索することです。有名どこだと fzf などが有名です。ファイル名などをガッとあいまい検索できます。

github.com

bmfzf

私は気になる記事はGoogle Chromeのブックマークで管理しています。スマホのChromeアプリとも同期できるので個人的には重宝しています。そこで趣味でGoogle Chromeのブックマークをfuzzy-findingできるツール bmfzf を作りました。

作ったツールはこちら。Google Chromeのブックマークをfuzzy-findingするツールです。

github.com

下記のようにChromeのブックマークをfuzzy-findingできます。ディレクトリ構造も含めてfuzzy-findingできます。

f:id:abctail30:20201222204500g:plain
bmfzfの利用例

bmfzfは下記のようにopenコマンドで選択したブックマークをChromeですぐに開けるようにしておくと便利です。

# MacOS example
$ open -a '/Applications/Google Chrome.app' $(bmfzf)

openまでbmfzfが行わないのはUNIX哲学の 「1つのプログラムには1つのことをうまくやらせる」 を守るためです。こうすることで他のツールと合わせて使いやすくなります。「UNIXという考え方」という本は僕がツールを作る際の指針となっている本なのでまだ読んだことがない人は是非読んでみてください。

UNIXという考え方―その設計思想と哲学

UNIXという考え方―その設計思想と哲学

  • 作者:Mike Gancarz
  • 発売日: 2001/02/01
  • メディア: 単行本

なぜAlfredじゃないのか

f:id:abctail30:20201222202122p:plain:w200
alfred

AlfredはMac全体に対してfuzzy searchできますが、ブックマーク階層を無視して、ブックマーク名だけで検索がかかります。私のようにブックマークをフォルダで階層的に管理している場合はfolderも含めてfuzzy-findingができると便利です。

fuzzy-finding

Goでfuzzy-findingツールを作るにはktr0731/go-fuzzyfinderパッケージを使うのがオススメです。下記のようにfuzzy-findingしたい対象をスライスとして用意すればすぐに実装ができます。

package main

import (
    // ...
    "github.com/ktr0731/go-fuzzyfinder"
)

type Bookmark struct {
    Name string
}

var bs = []Bookmark{
    {"Twitter"},
    {"Netflix"},
    {"AWS"},
}

func main() {
    id, _ := fuzzyfinder.Find(
        bs,
        func(i int) string {
            return bs[i].Name
        },
        fuzzyfinder.WithPreviewWindow(
            func(i, w, h int) string {
                if i == -1 {
                    return ""
                }
                return fmt.Sprintf("Name: %s", bs[i].Name)
            },
        ),
    )

    fmt.Printf("selected: %v\n", bs[id])
}

fuzzyfinder.WithPreviewWindowはOptionであり、fuzzy-finding中にwindowを表示できます。ここで重要なのはfuzzyfinder.Findメソッドはスライスで受ける必要があることです。ゆえにfuzzy-finding対象がネスト構造だとそれを平らにする必要があります。

複数type無限ネストJSONのパース

fuzzy-findingは紹介したktr0731/go-fuzzyfinderで簡単に実装できるので、後はChromeブックマークを構造体のスライスに詰めるだけです。ブックマークはJSONファイルでローカルに保存されているので、このファイルを読み込めば良さそうです。調べたところ、OSによってデフォルトのファイル位置が異なるのでこのように設定しています。

func bookmarkFileLocation() (string, error) {
    os := runtime.GOOS
    switch os {
    case "windows":
        return `AppData\Local\Google\Chrome\User Data\Default\bookmarks`, nil
    case "darwin":
        return `Library/Application Support/Google/Chrome/Default/Bookmarks`, nil
    case "linux":
        return `.config/google-chrome/Default`, nil
    default:
        return "", fmt.Errorf("sorry... your OS %v is not supported. please specify your bookmark file using -f flag.", os)
    }
}

さて、JSONファイルを見てみるとちょっと面倒な無限ネストです。

{
   "checksum": "b7f8b4409e53e46b25a6a4a6a29e85b7",
   "roots": {
      "bookmark_bar": {
         // ...
         "name": "ブックマークバー",
         "type": "folder",
         "children": [
            {
               // ..
               "type": "url",
               "name": "Google"
            },
            {
                // ...
               "name": "private",
               "type": "folder"
               "children": [
                  {
                     // ...
                     "type": "url",
                     "name": "Facebook"
                  },
                  {
                     // ...
                     "type": "url",
                     "name": "Twitter"
                  }
               ]
            }
         ]
      }
   }
}

上のようにurlタイプとfolderタイプがあり、childrenフィールドで無限ネストしてきます。ktr0731/go-fuzzyfinderはネストしている構造を平らにしてsliceとして渡す必要があります。

Nodeのタイプを判断して構造体に詰めていくときは少し工夫が必要です。今回は下記のように Visitor pattern で実装しています。

// Bookmark contains bookmark data that have url type
type Bookmark struct {
    Name string
    Path string
    URL  string
}

// Node has Bookmark info.
// folder type Node has child Nodes.
type Node struct {
    Name     string `json:"name"`
    Type     string `json:"type"`
    URL      string `json:"url"`
    Children []Node `json:"children"`
}

// Visitor visits Nodes.
type Visitor interface {
    Visit(n Node, path string) error
}

ここではブックマークのNodeを構造体で宣言します。NodeVisitorが1つずつ見ていき、[]Bookmarkに詰めていきます。Visotorの実装bookmarkRecoderは下記のようになります。

type bookmarkRecoder struct {
    data []Bookmark
}

func newBookmarkRecoder() *bookmarkRecoder {
    return &bookmarkRecoder{
        data: make([]Bookmark, 0, 100),
    }
}

func (b *bookmarkRecoder) Visit(n Node, path string) error {
    bm := Bookmark{
        Name: n.Name,
        Path: path,
        URL:  n.URL,
    }
    b.data = append(b.data, bm)
    return nil
}

後はwalkEdge関数を作り、NodeとVisitorを受けるようにすれば全てのノードを1つずつ訪問しながら[]Bookmarkに追加していきます。

func walkEdge(n Node, path string, v Visitor) error {
    switch n.Type {
    case "folder":
        for _, c := range n.Children {
            p := filepath.Join(path, c.Name)
            err := walkEdge(c, p, v)
            if err != nil {
                return err
            }
        }
    case "url":
        v.Visit(n, path)
    case "":
        // empty type
        return nil
    default:
        return fmt.Errorf("unsupported type: %+v", n.Type)
    }
    return nil
}

複数タイプ無限ネストJSONの処理は一手間かかりましたが、この関数を使えばktr0731/go-fuzzyfinderに渡すsliceが手に入ります。これで Chrome Bookmark Fuzzy-finder が完成しました。より詳しいコードはGitHubリポジトリでご確認ください。

まとめ

fuzzy-finderツールを作るにはktr0731/go-fuzzyfinderが便利です。一方でネストしたデータ構造をfuzzy-findingするには平らなデータ構造にする必要があるので、Visitor pattern などをうまく使って実装する必要があります。Terminalから一撃でChrome Bookmarkを検索したい人は是非bmfzf使ってみてください。

github.com

We're hiring !!!

エムスリーではGo大好きエンジニアを募集しています! 「ちょっと話聞いてみたいかも」という人はこちらから!

jobs.m3.com

参考

syfm.hatenablog.com

www.ubergizmo.com