執筆の動機:
Bearを使ってみたら結構良さげだったので、(Evernoteだけでなく)Google Keepに溜まってるメモをインポートしてみようかと思って
どこでも気軽にメモが取れて、コード片も貼り付けられて、デバイス間で共有できて、検索機能がちゃんとあって、できればプライバシーポリシーに不安のないメモアプリを求めて(あと、無料であることよりも、サービスやポリシーの継続性を重視したい)
またはEvernote難民日記
Evernote
https://evernote.com/intl/jp/
何年か使っていたEvernoteはテキストの書式にストレスがあった。
思いついた事を書き留めたり、ブログ記事の切り抜きを保存したりする分には良かった(十分に便利)
コード片を貼り付けたりもしたいから、せめてmarkdown互換の記法が使えたら、と何度も思った。
無料プランで使用可能なデバイス数に制限が設けられたのは、MacとiPhoneで使えれば良かったから問題なかったのだけれど(有料プランに移行したいと思うほどの思い入れがなかったのは、書式ストレスのため)
去年12月に、Evernote社員が利用者のノートを閲覧可能にする規約改変が取り沙汰されたのを機に、全データをエクスポートし、Evernoteを退会することにした。
Kobito
http://kobito.qiita.com/
あとで(Qiitaで)公開するつもりの技術情報の整理に時々使っていた(今でもたまに)
リアルタイムプレビュー付きmarkdownエディタとして。
昔こんな記事を書いたことがある。
kobitonote.py - Kobitoで編集したアイテムをEvernoteに同期保存
Day One
http://dayoneapp.com/
日記アプリ。
macOS版(4800円)、iOS版(600円)。
昔のバージョン(Day One Classic)をmacOS(1200円ぐらいだった気がする)とiOSでかれこれ4年ほど使っている。
markdownが使える。
ただ、検索機能(特に日本語)がいまいち。(ちなみに、よく検索するのは「床屋にいつ行った」みたいな情報)
クラウドには保管されているデータは暗号化され、法的に求められた場合を除き暗号化されたファイルに社員がアクセスすることはない、というのはプライバシー保護としては当面満足できるレベルかと。
日記を同期してくれるのは嬉しいが、同じエントリを複数デバイスから編集してしまって競合した場合、(複数バージョンを残すのではなく)上書きされてしまうので、別のデバイスから日記を書く時に新たなエントリを立ち上げて書いて後で編集する、とか、日記エントリを編集モードのまま開きっ放しにせずとりあえず保存する、みたいな運用でカバーしている。
日記アプリとしては特に不満はない(このアプリのお陰で日記を書くという習慣が出来た)けれど、検索機能のせいでメモアプリとしてはあと一歩。
クラウドには保管されているデータは暗号化され法的に求められた場合に限り暗号化
プライベートなメモは(Evernoteがなくても)Day Oneに書けばいい。
macOS付属のメモ
Evernote難民を救済すべく、というかMacOS/iOS/iCloud陣営に囲い込むべく、というか、
macOS付属のメモアプリにはEvernoteのエクスポートファイル(.enex)をインポートする機能がある。(囲い込むと言ったのは、メモアプリからのエクスポート機能がないから)
OS標準だし、iOS上のメモアプリと同期してくれるし、これで良いかも?と思ったのも束の間。
検索機能が全く機能していない。
メモ数が多すぎる?いや、iOS側アプリでは検索が可能だったので、そんな事もないと思うのだけれど。
検索ができないのは電子メモとして全く意味がないので、他の選択肢を探す。
Google Keep
https://keep.google.com/
Google謹製のメモアプリ。Webブラウザで使う。
iOSアプリもある。
メモのプライバシーに不安がある(※プライバシーを守ると明記していないという意味で)けれど
普通に検索できて便利。
Google Docsからメモを参照できる。(これは執筆ツールとして有用)
Bear
 http://www.bear-writer.com/
http://www.bear-writer.com/
プライベートなメモはDay One (Classic)で、検索したいメモについてはGoogle Keepで、という棲み分けで3ヶ月過ごしていたのだけれど、昨日こんな記事を読んで。
- bearってアプリがかなり良い線いってる。markdownとかのイノベーションの本質の周りで色々やってるのでかなり面白い。 - toukubo.com
- "「ノートブック」の概念が無いこと。" - toukubo.com
- まじbearすごい。久々にめちゃくちゃ気に入った。情報設計としてものすごいエレガント。 - toukubo.com
とりあえずiOS版とmacOS版をインストールしてみた。
まず、デザインが秀逸。(いくつかのテーマから好きなのを選べる)
テキスト書式には独自の形式とmarkdownが使える。
メモ間の相互リンクが出来る(=wiki的に使える)。
Evernoteのエクスポートファイル(.enex)がインポートできる。その他rdf,mdのインポートも可能。(DayOneからもインポートできるらしい)
◎インポート時には「インボランタリータグをエスケープする」オプション必須。というのも、#の文字があるとその後の単語をタグとして認識してしまって、タグリストに数百の無意味なタグが羅列される羽目になる。タグは(テキストの属性ではなく)テキストの一部なので、タグを(タグリストからは1つずつしか消せないが)消去するとテキスト中のタグ表記(と思われた箇所、例えば#!/bin/sh)が消されてしまう。
検索にストレスはない。
プライバシーポリシーには安心感がある。
ただ同期中によく落ちる。(デバイス間同期にはProアカウント(月150円)が必要。)
Google KeepのメモをBearにインポートしたい
で、ここからがこのエントリーの本題。
Google Keepからのインポートは… Bearから直接はサポートされていないものの、Google Takeoutでエクスポートしてきた個別のHTMLファイルをmarkdownに変換するなどしてインポート可能。
$ unzip takeout-yyyymmddThhmmssZ-001.zip
./Takeout/Keep/ 以下に、HTMLファイル(1ファイル=1メモ)が展開されるので、適当なスクリプトを書くなどしてmarkdown化し、これをBearからインポートすれば出来上がり。
$ python html2md.py dir ./Takeout/Keep
html2md.py(うちで使った適当なスクリプト)を以下に貼るのでご参考までに。(要Click。ご利用は自己責任で、というかお好みで編集・改良されたし。HTMLファイルと同じディレクトリ(上の例では ./Takeout/Keep/ 以下)にmdファイルが大量に出来る。Bearはインポート時にHTMLファイルを選べないので、全部選んでもmdだけ拾える)
# !/usr/bin/env python
# -*- encoding: utf-8 -*-
import click
import os
import sys
from bs4 import BeautifulSoup
from datetime import datetime
import time
def datetime_to_unixtime(dt):
    assert isinstance(dt, datetime)
    return time.mktime(dt.timetuple())
def touch(fname, atime=datetime.now(), mtime=datetime.now()):
    if not os.path.exists(fname):
        open(fname, 'a').close()
    os.utime(fname, (datetime_to_unixtime(atime), datetime_to_unixtime(mtime)))
def parse(fp):
    soup = BeautifulSoup(fp, 'lxml')
    note = soup.find('div', class_='note')
    title, timestamp, archived, content, labels = None, None, False, None, []
    for div in note.find_all('div'):
        class1 = div['class'][0]
        if class1 == 'heading':
            tstr = div.text.strip()
            timestamp = datetime.strptime(tstr, '%Y/%m/%d %H:%M:%S')
            # print 'TIMESTAMP (%s)' % timestamp
        elif class1 == 'archived':
            archived = True
            # print '+ARCHIVED'
        elif class1 == 'title':
            title = div.text.strip()
            # print 'TITLE (%s)' % title.encode('utf-8')
        elif class1 == 'content':
            contents = []
            for content in div.contents:
                if isinstance(content, unicode):
                    contents.append(content)
                else:
                    if content.name == 'br':
                        contents.append('\n')
                    else:
                        contents.append(content.text)
            content = ''.join(contents)
            # print 'CONTENT', content.encode('utf-8')
        elif class1 == 'labels':
            labels = [label.text for label in div.find_all('span', class_='label')]
            # print 'LABELS', labels
        else:
            # print class1, div
            pass
    return (title, timestamp, archived, content, labels)
@click.group()
def cli():
    pass
def conv(html_path):
    with open(html_path, 'r') as fp:
        (title, timestamp, archived, content, labels) = parse(fp)
        print 'TITLE:', title
        print 'TIMESTAMP:', timestamp
        # print 'ARCHIVED:', archived
        if archived:
            labels.append('archived')
        # print content
        labels.append('keep')
        print 'LABELS:', ' '.join(['#%s' % label for label in labels])
        # print
    if html_path.endswith('.html'):
        md_path = html_path.replace('.html', '.md')
    else:
        md_path = html_path + '.md'
    with open(md_path, 'w') as fp:
        def _tagify(label):
            if ' ' in label:
                return '\\#%s\\#' % label
            else:
                return '#%s' % label
        if title:
            fp.write(title.encode('utf-8'))
        fp.write('\n\n')
        fp.write(content.encode('utf-8'))
        fp.write('\n')
        fp.write('%s\n' % ' '.join([_tagify(label.encode('utf-8')) for label in labels]))
    touch(md_path, atime=timestamp, mtime=timestamp)
@cli.command()
@click.argument('html-path', type=click.Path('r'))
def one(html_path):
    assert os.path.exists(html_path)
    conv(html_path)
@cli.command()
@click.argument('html-dir', type=click.Path('r'), default='.')
def dir(html_dir):
    assert os.path.exists(html_dir) and os.path.isdir(html_dir)
    for path in os.listdir(html_dir):
        full_path = os.path.join(html_dir, path)
        conv(full_path)
if __name__ == '__main__':
    cli()