ShellScriptでクリップボードの履歴管理する(ついでに水平連結もする)

ShellScriptでドヤりたいGWアドベントカレンダー5日目いくで。

クリップボード管理アプリをShellScriptで作った。
そもそもはスプレッドシートから列をコピーしてA列とB列を入れ替えて貼り付けたいと思ったのが発端で、
どちらかというと副産物的な感じでクリップボード管理ができた感じ。

github.com

デモ

こんな感じのやつね。

https://rasukarusan.github.io/blog-assets/shell_clipboard/demo1.gif

クリップボード履歴を表示して、fzfで選択した順に水平に連結してコピーする。

クリップボードの監視

とりあえず既存のクリップボード管理アプリはどうやっているのか調べてみた。

「Clipy」というアプリはtimerを使用しているみたい。

github.com

// MARK: - Clips
func startMonitoring() {
    disposeBag = DisposeBag()
    // Pasteboard observe timer
    Observable<Int>.interval(0.75, scheduler: scheduler)
        .map { _ in NSPasteboard.general.changeCount }
        .withLatestFrom(cachedChangeCount.asObservable()) { ($0, $1) }
        .filter { $0 != $1 }
        .subscribe(onNext: { [weak self] changeCount, _ in
            self?.cachedChangeCount.accept(changeCount)
            self?.create()
        })
        .disposed(by: disposeBag)

他のアプリも見てみる。「Maccy」というアプリ。これもtimerを利用している。

github.com

func startListening() {
Timer.scheduledTimer(timeInterval: timerInterval,
                     target: self,
                     selector: #selector(checkForChangesInPasteboard),
                     userInfo: nil,
                     repeats: true)
}

ということで割とタイマーでクリップボードを監視するのが一般的っぽい。 コピーの動作をhookしてやるのかなと思っていたのでちょっと意外だった。hookできないのかもしれん。まあとりあえず今は置いておこう。

データの保存方法

クリップボードの内容を保存する方法は自由。
一応、「Clipy」ではRealmでDB保存、別のアプリではUserDefaultsを使って.plistファイルに保存している。

「Clipy」の場合

fileprivate func save(with data: CPYClipData) {
    let realm = try! Realm()
    // Copy already copied history
    let isCopySameHistory = AppEnvironment.current.defaults.bool(forKey: Constants.UserDefaults.copySameHistory)
    if realm.object(ofType: CPYClip.self, forPrimaryKey: "\(data.hash)") != nil, !isCopySameHistory { return }
    // Don't save invalidated clip
    if let clip = realm.object(ofType: CPYClip.self, forPrimaryKey: "\(data.hash)"), clip.isInvalidated { return }
...

「Maccy」の場合

func all() -> [String] {
guard var savedHistory = UserDefaults.standard.array(forKey: storageKey) as? [String] else {
  return []
}

let maxSize = UserDefaults.standard.integer(forKey: sizeKey)
while savedHistory.count > maxSize {
  savedHistory.remove(at: maxSize - 1)
}

return savedHistory
}

とりあえずシンプルにファイル保存でいいかなと思う。

実装の流れ

ということでShellScriptでやるとしたらおそらくこの流れ。

  1. クリップボードの中身をファイルに書き込むShellScriptを作成(clilpObserver.sh)
  2. 1.で作ったスクリプトをcronで回してクリップボード監視
  3. ファイルを読み込んで履歴として表示する(clipboard.sh)

1. クリップボードの中身をファイルに書き込むShellScriptを作成

求めているのは以下。

  • クリップボードの中身が空だったら何もしない
  • 前の内容と同じだったら何もしない
  • クリップボードの内容をファイルに書き込む
  • 最大保存件数を超えていたら古いものから削除

clipObserver.sh

#!/bin/sh
#
# 現在のクリップボードの内容をログファイルとして保存する
# crontabにセットしてクリップボード監視を行うためのスクリプト
# ログ保存場所は~/.cliplog/連番のファイル
#

DIR_CLIP_LOG=~/.cliplog
MAX_CLIP_CNT=100

clip_log_cnt=$(ls -1 $DIR_CLIP_LOG | wc -l | tr -d " ")
latest_clip_log=$(ls -t $DIR_CLIP_LOG | head -n 1)
oldest_clip_log=$(ls -t $DIR_CLIP_LOG | tail -n 1)

# クリップボードのログ置き場が存在しなければ作成する
if [ ! -e $DIR_CLIP_LOG ]; then
    mkdir $DIR_CLIP_LOG
fi

# クリップボードが空の場合は何もしない
if [ "$(pbpaste)" = "" ]; then
    exit
fi

# 一番最初はファイルが存在しないのでログファイル作成して終了
if [ -z $latest_clip_log ]; then
    pbpaste 2>/dev/null | /usr/local/bin/nkf -w > $DIR_CLIP_LOG/1
    exit
fi

# 最大履歴保存数を超えていたら一番古いファイルを削除する
if [ $clip_log_cnt -gt $MAX_CLIP_CNT ]; then
    rm $DIR_CLIP_LOG/$oldest_clip_log 2>/dev/null
fi

new_clip_log=$(expr $latest_clip_log + 1)
# クリップボードログを作成
pbpaste 2>/dev/null | /usr/local/bin/nkf -w > $DIR_CLIP_LOG/$new_clip_log

# 最新のクリップボードのログが現在の内容と同じだったら今作ったログを削除する
# ファイルを一旦作ってdiffを取るほうが動作が安定する
is_different=$(diff -EbB $DIR_CLIP_LOG/$latest_clip_log $DIR_CLIP_LOG/$new_clip_log)
if [ -z "$is_different" ]; then
    rm $DIR_CLIP_LOG/$new_clip_log 2>/dev/null
fi

クリップボードの履歴表示をするときにsortで楽に並び替えするためにログファイル名を連番にしてる。idのような扱い。

$ sh ~/clipObserver.sh

を実行するとクリップボードの中身がファイルに出力される。

~/.cliplog
$ ls
1

https://rasukarusan.github.io/blog-assets/shell_clipboard/demo2.gif

2. スクリプトをcronで回してクリップボード監視

cronに登録するだけ。秒数は適当でいいと思うが、とりあえず1秒で設定してる。
別に5秒とかでも十分。そんなに急がなくてもいいじゃない。

$ crontab -l
* * * * * for i in `seq 0 1 59`;do (sleep ${i}; sh ~/clipObserver.sh) & done;

https://rasukarusan.github.io/blog-assets/shell_clipboard/demo3.gif

コピーするたびにログファイルが追加されていってますね。

3. ファイルを読み込んで履歴として表示する

1,2のスクリプトによって作成されたクリップボードのログファイルを閲覧・選択してコピーする、フロントエンド側のスクリプトを作る。
選んだものを水平連結したかったので、pasteコマンドに渡せるようfzfで選択したものはログ1 ログ2のような形で変数に格納し、

$ paste ログ1 ログ2

と出来るようにした。

clipboard.sh

#!/bin/sh
#
# クリップボードの履歴をfzfを表示し、選択したものを水平連結する
# clipObserver.shで作成されたクリップボードログファイルを参照するスクリプト
#
DIR_CLIP_LOG=~/.cliplog

clip_logs=$(ls -t $DIR_CLIP_LOG | fzf --height 100% --prompt "CHOOSE" --preview "cat $DIR_CLIP_LOG/{}")
select_files=''
for clip_log in $clip_logs; do
    select_files="$select_files ${DIR_CLIP_LOG}/${clip_log}"
done

if [ -n "$select_files" ]; then
    # 水平連結したものをクリップボードにコピーする
    paste $select_files | pbcopy
    pbpaste
fi

あとは呼び出しやすいようにaliasに設定して終わり。

.zshrc

alias cl='sh ~/clipboard.sh'

終わり

pbpasteを使っているのでMac限定となっているが、xclipとかに変えれば他のOSでも動くかな。たぶん。
今回のは完全にオナニースクリプトだ。

更新日を見ているので連番で振り直せないし、sqliteで管理したほうが楽だったんじゃないかとか思うけど、まあいいでしょう。 クリップボードの中身でfzfの絞り込みを使いたかったが、現状特に不便はない。大体直前のものが欲しいだけだから。

ただやっぱりcronで回すと読み書きが頻繁に起こることが嫌でもわかってしまうので、それだけは心苦しい。なんとかしたい。