Continued from the Previous (Customize)

01/22

Joplinを便利に使う

目次

リマインダ機能を付けたい

ちょっと荒技かもだがJoplinにリマインダ機能を付けてみたいと思う。
仕事しているなかで、Jopinにいろいろなことを書いていて、ふと思う。

このタスク、後で暇になったらやるから通知してほしいなぁ

って思ったときに、ちょっとしたリマインダが欲しい。
しかも何個も設定したい。

そこで、Joplinのプラグインとサーバサイドスクリプトを使ってこの仕組みを作ってみる。(あと見た目の変更もしたいのでuserstyle.cssも変更する)
サーバサイドスクリプトが必要な理由は、クライアント側に実装すると通知するのにJoplinを常に起動していないといけないため。
折角リモートにリソースがあるのだから、それを解析するのが一番手間いらず。

操作手順

例えばリマインドしたいタスクを書くとする。

- [ ] パンツの穴を見る

おいそれは仕事かね!?
ま、それはさておき、その末尾に以下のように書くと。

- [ ] パンツの穴を見る <span class='alarm'>2023-01-23 22:00</span>

これをsyncしておけば、この時間になったら自分の好きな方法で通知してくれると。
これでCSで菊池桃子さんが観れると。

また期日を設定して、何日か前から何回か通知してくれるのも欲しい。
例えばリマインドしたいタスクを書くとする。

- [ ] マルコビッチの穴を見る

いやだからそれ仕事かね?通知要るかね??
ま、それはさておき、その末尾に以下のように追記すると。

- [ ] マルコビッチの穴を見る <span class='todo'>2023-01-31 23:00</span>

こう書いておくことで、例えば1日前の同時間に通知して、さらに1時間前にも通知して、さらに15分前にも通知してくれると。
これでCSで忘れずにジョンに会えると。

なんて便利なんだ。
早速作ろう。

※イメージ図です

仕組み

仕組みはこんな感じ

flowchart TD
  subgraph クライアント
    a[[joplin]]
  end
  a-->|同期|b[(リモートリソース)]
  subgraph サーバ
    g[/script/]-.->|起動|h[[joplin]]
    b==>|取得|h
      h-->|取得|g
  end
  g-->|解析|d{the time has come?}
  d-->|yes|e[通知]
  d-->|no|f[なんもしない]

追加・修正する箇所は以下の通り。

箇所 概要説明
Markdown Editor <span>タグを簡単に入力できる仕組みを用意する。例:Slash Commands: Datetime & More
userstyle.css joplinのプレビュー画面では見えてほしくないのでカスタマイズするため。
joplin-reminder-support 今回の要。サーバサイドスクリプト。折角なのでNodeJSで作る。但しリモートのリソースを取得するのにターミナル版Joplinを使う。

こんな感じ。
さて早速取り掛かってみる。

開発

Customize Markdown Editor

最初はMarkdown Editorをカスタマイズしていく。
やりたいことは上述の<span>タグを便利に入力したいので、そのための仕組みを入れていく。
拙作のプラグインに追加してもよいのだが、もっと便利なプラグインがあったのでそちらを今回は紹介する。

それは、Slash Commands: Datetime & Moreである。
詳しくは上記のサイトを見てほしいのだが、このプラグインを使用するとかなり便利に日付付きの文字列を入力することができる。
かなり強力な補助ツール。

インストール

Joplinのオプション画面のプラグインからインストールする。
検索窓から

Slash

みたいに入力したら、プラグインが出てくるので[インストール]ボタンをクリックして再起動したら、完了。
簡単。

設定

ここからはSlash Commandsをカスタマイズする。
またオプション画面の「Slash Commands」をクリックする。
そうすると、テキストボックスにjson形式のコンフィグが入力されている。
ただこの設定画面、非常に使いづらい。
なので、一旦テキストボックスの中身を全部コピーして、Joplinの新規ノートなどに貼り付けてから編集した方がよい。

さて、そのjsonファイルの末尾らへんに今回のタグ用の設定を追記する。

..., ["datetime", "alarm", ["\"<span class='alarm'>\"yyyy-mm-dd HH:MM\"</span>\""]],  ["datetime", "todo", ["\"<span class='todo'>\"yyyy-mm-dd HH:MM\"</span>\""] ] ]

ここまで入れたら、一旦適用する。

使い方

さて使い方は非常に簡単。

/task

上記を入れると、、

- [ ] 

こんな感じに変換される。
もうこの時点で、使ってもらうと感じると思うが、かなり便利なのである。
そして実際のDescriptionを入れる。

- [ ] 虎の穴に行く

そうそう、ここに行ってね、秘密の特訓受けて、、、

富山敬さんの声がまた聴きたいよね・・・

俺は虎だ、虎に、、ならないから。
孤児院の子供たちのために働くほど意識高くないし。
それはよいとして、ここからがカスタマイズしたところ。

- [ ] 虎の穴に行く /alarm

こんな風に入れていくと・・・

- [ ] 虎の穴に行く <span class="alarm">2023-01-20 01:02</span>

こんな風に変換される。
これでさっき言った形式になる、、、ただこのプラグインの便利なところはこれだけではない。
さっきの /alarm のあとに

- [ ] 虎の穴に行く /alarm+3

ってやると

- [ ] 虎の穴に行く <span class="alarm">2023-01-23 01:05</span>

3日後に変換される。
その他にも /alarm+1:00 で1時間後にも、 /alarm-1-1:00 としたら1日と1時間前に 、 /alarm@fri+0:12 で来週の金曜日の今の時間から12分後に設定できる。
詳しいことはSlash Commands: Datetime & Moreを見ていただきたい。

これでMarkdown Editor側の準備はできた。
次はMarkdownのプレビュー画面の方を。

userstyle.css

さて入力してみたらわかるが、このままだとプレビュー画面にバッチリ映っちゃって

- [ ] 穴子さんと飲み 2023-01-31 17:30

「どぉおおおうううだい、フグ田くぅぅん、今日も一杯?Brrrrrrrrrああああ!!??」

こんな感じで、日付も時間すらも隣の同僚からの誘いすらも丸見え。
これはちょっといただけない。
なので、これはちょっと見えなくするのと、その代わりにアイコンは見せておくと。
早速編集する。

オプション画面からスタイルを選んで、「詳細設定を表示」から「カスタムスタイルシート (Markdownビューアー)」のcssファイル(userstyle.css1)を開く。
んで、以下の要素に対するスタイルを追記する。

userstyle.css

span.alarm, span.todo {
    color: #333;
}

span.alarm:before {
    content: "\1f4e2"; /* loudspeaker */
}

span.todo:before {
    content: "\1f514"; /* bell */
}

こうすることで、プレビュー画面にはこんな感じで表示される。

私はDraculaテーマにしているので、フォントの色は暗い色にしている。
ここらへんは環境によって適宜変えてほしい。
さてここからが本題。

Joplin Reminder Support

で、今回アラームのように通知するためにサーバにてターミナル版Joplinを入れて、以下のことを行う。

  1. ノートやToDoを取得する
  2. その中にある上記記述になっているものを見つける
  3. 日時を評価する
  4. 通知する

こんなことができればよいかなと。
では、やってみる。

Joplin for Terminal

ここら辺はササッと終わらせる。
詳しいことは本家のここを参照してほしい。

当方の環境はNodeJS 14.15.0以上でないとエラーが出て起動出来なかった。
anyenvを入れているので特に困りはしないけど。

$ NPM_CONFIG_PREFIX=~/.joplin-bin npm install -g joplin
$ sudo ln -s ~/.joplin-bin/bin/joplin /usr/bin/joplin

さてインストールが出来たので、設定を行う。
これも本題と逸れてしまうので、詳しいところは割愛。

$ joplin config sync.target 6
$ joplin config sync.6.path https://ometachi.minna.hogetara/da/
$ joplin config sync.6.username dororo
$ joplin config sync.6.password 100demonsMARU
$ joplin sync
$ joplin e2ee decrypt
$ joplin ls /

同期とブック一覧が取得できるのを確認しておく。
さて設定が終わったので、実際に使ってみる。
joplinのリソース取得手順は以下の通り。

flowchart TD
  a[synchronize with remote resource]
  b[decrypt items]
  c[set book]
  d[cat note or todo]

  a-->b
  b-->c
  c-->d

こんな感じ。
コマンド形式で実行して標準出力に結果が返るので、それを解析できるという仕掛け。
ちゃちゃっと環境を用意する。

$ sudo mkdir /srv/joplin-reminder-support
$ sudo chown me:me /srv/joplin-reminder-support
$ cd /srv/joplin-reminder-support
$ npm init
$ npm install child_process --save
$ npm install request --save

さて joplin_sync.js というファイルで以下のように書いてみる。

joplin_sync.js

const { exec } = require('child_process');
const { execSync } = require('child_process');
const joplin_config = require('./joplin_config');

/**
 *
 * joplin.exec={
 *    sync: joplin.process + " " + "sync --use-lock 0",
 *    syncResult: "done",
 *    decrypt: joplin.process + " " + "e2ee decrypt",
 *    decResult: "done",
 *    use: joplin.process + " " + "use" + " short_term_task",
 *    getnote: joplin.process + " " + "ls" + " |sed -e 's/^\\[[^\\]]*\\] //g' " + " |grep \"mytask\"",
 *    catnote: joplin.process + " " + "cat",
 *    tagAlarmPattern: "<span class='alarm'",
 *    tagToDoPattern: "<span class='todo'",
 * };
 *
 */
var joplin={};
joplin.process="joplin";
joplin.exec={
    sync: joplin.process + " " + joplin_config.syncArgs,
    syncResult: joplin_config.syncResult,
    decrypt: joplin.process + " " + joplin_config.decryptArgs,
    decResult: joplin_config.decResult,
    use: joplin.process + " " + "use" + " " + joplin_config.useArgs,
    getnote: joplin.process + " " + "ls" + " " + " |sed -e 's/^\\[[^\\]]*\\] //g'" + joplin_config.lsArgs,
    catnote: joplin.process + " " + "cat",
    tagAlarmPattern: joplin_config.tagAlarmPattern,
    tagToDoPattern: joplin_config.tagToDoPattern,
};
joplin.notices=[];
joplin.notes=[];

//don't use it
joplin.sync=async ()=>{
    return await exec(joplin.exec.sync, async (err, stdout, stderr)=>{
        if(err){
            console.log(`sync stderr: ${stderr}`);
            return await false;
        }
        console.log(`sync stdout: ${stdout}`);
        return await exec(joplin.exec.decrypt, async (err, stdout, stderr)=>{
            if(err){
                console.log(`decrypt stderr: ${stderr}`);
                return await false;
            }
            console.log(`decrypt stdout: ${stdout}`);
            return await true;
        });
    });
}

joplin.syncSync=async ()=>{
    const syncResult=execSync(joplin.exec.sync).toString();
    console.log("sync ["+syncResult+"]");
    //console.log(syncResult.search(`${joplin.exec.syncResult}`));
    if(syncResult.search(`${joplin.exec.syncResult}`)>-1){
        const decResult=execSync(joplin.exec.decrypt).toString();
        console.log("decrypt ["+decResult+"]");
        if(decResult.search(`${joplin.exec.decResult}`)>-1){
            return await true;
        }else{
            return await false;
        }
    }else{
        return await false;
    }
}

//don't use it
joplin.getnotes=async ()=>{
    return await exec(joplin.exec.use, async (err, stdout, stderr)=>{
        if(err){
            console.log(`use stderr: ${stderr}`);
            return await false;
        }
        console.log(`use stdout: ${stdout}`);
        console.log(joplin.exec.getnote);
        return await exec(joplin.exec.getnote, async (err, stdout, stderr)=>{
            if(err){
                console.log(`ls stderr: ${stderr}`);
                return await false;
            }
            console.log(`ls stdout: ${stdout}`);
            let notelist=[];
            notelist=stdout.split('\n');
            console.log(`notelist: ${notelist}`);
            return await notelist;
        });
    });
}

joplin.getnotesSync=async ()=>{
    const useResult=execSync(joplin.exec.use).toString();
    console.log("use ["+useResult+"]");
    if(useResult===""){
        const noteResult=execSync(joplin.exec.getnote).toString();
        console.log("note ["+noteResult+"]");
        if(noteResult!==""){
            joplin.notes=noteResult.split("\n");
            for(let i=0;i<joplin.notes.length;i++){
                joplin.notes[i]=joplin.notes[i].replace(/ /g,'');
            }
            joplin.notes.pop();
            return await joplin;
        }
    }
}

joplin.checkToDoAlarm=async ()=>{
    if(joplin.notes.length>0){
        let notices=[];
        let noticecol=[];
        let nowdatetime=new Date();
        console.log('note length ['+joplin.notes.length+']');
        joplin.notes.forEach((note)=>{
            let catResult=execSync(joplin.exec.catnote + " " + note).toString();
            let catResults=catResult.split('\n');
            catResults.forEach((result)=>{
                if(result.search(`${joplin.exec.tagAlarmPattern}`)>-1
                    &&result.search(/\- \[ \]/)>-1){
                    notices.push(result.replace(/\- \[ \]/, ''));
                }
                if(result.search(`${joplin.exec.tagToDoPattern}`)>-1
                    &&result.search(/\- \[ \]/)>-1){
                    notices.push(result.replace(/\- \[ \]/, ''));
                }
            });
        });
        notices.forEach((ntc)=>{
            var Notice={
                noticeType: null,
                noticeDateTime: null,
                noticeDescription: null,
                noticeUndone: null,
            };
            Notice.noticeType=ntc.replace(/^[^<]*<span class='([^']*)'[^$]*$/, '$1').toLowerCase();
            let ndatetime=ntc.replace(/^[^<]*<span[^>]*>([^<]*)<\/span>.*$/, '$1').replace(/([0-9][0-9][0-9][0-9])\-([0-9])\-/,'$1-0$2-').replace(/([0-9]) ([0-9]):/,'$1 0$2:').replace(/([0-9]) ([0-9])/,'$1T$2').replace(/\//g,'-');
            Notice.noticeDateTime=new Date(ndatetime);
            Notice.noticeDescription=ntc.replace(/^([^<]*)<.*$/, '$1').replace(/ /g,'');

            console.log(`Notice [${Notice.noticeType}]`);
            console.log(`Notice [${Notice.noticeDateTime}]`);
            console.log(`Notice [${Notice.noticeDescription}]`);
            noticecol.push(Notice);
        });
        noticecol.forEach((n)=>{
            let diffmsec=n.noticeDateTime - nowdatetime;
            let diffmin=parseInt(diffmsec/1000/60);
            let diffhour=parseInt(diffmin/60);
            let diffday=parseInt(diffhour/24);
            let diffmonth=parseInt(diffday/25);
            console.log(`diffmin [${diffmin}]`);
            console.log(`diffhour [${diffhour}]`);
            console.log(`diffday [${diffday}]`);
            let needToNotice=false;
            /**
             *
             * Alarm: notice you in that time
             *
             */
            if(n.noticeType==="alarm"){
                if(diffmin<=0){
                    needToNotice=true;
                }
            }
            /**
             *
             * ToDo: notice you when the time is...
             *  1. just now
             *  2. in half an hour
             *  3. in an hour
             *  4. in a day
             *  5. in 7 days
             *  
             */
            if(n.noticeType==="todo"){
                if(diffmin<=0
                    ||(diffmin<=31&&diffmin>=29)
                    ||(diffhour<=1&&diffmin>=59)
                    ||(diffday<=1&&diffhour>=23)
                    ||(diffday===7)){
                    needToNotice=true;
                }
            }
            if(needToNotice){
                joplin.notices.push(n);
            }
        });
        return await joplin.notices;
    }
}

module.exports=joplin;

こんな感じで組んでみた。
joplin_config.js という環境個別の設定ファイルを読み込むことになっていて、構造は以下の通り。

joplin_config.js

config={
    syncArgs: "sync --use-lock 0",
    syncResult: "完了",
    decryptArgs: "e2ee decrypt",
    decResult: "完了",
    useArgs: "わいの仕事",  //edit your book name
    lsArgs: " |grep \"わいのタスク\"", //edit it 
    tagAlarmPattern: "<span class='alarm'", //do NOT edit
    tagToDoPattern: "<span class='todo'", //do NOT edit
};

module.exports=config;

こんな風にしている。
恐らく日本語環境の場合、useArgsとlsArgs以外は変更しなくて問題ないと思われる。
useArgsやlsArgsを空にするのはダメ。

これを使って、動作するかを動かしてみる。

index.js

const webcli = require('request');
const joplin = require('./joplin_sync');
const thetimecoming="時間が来ました";
const thetimereminds="通知します";

var webNotify=(textMessage)=>{
    webcli.post({
        url: "{GOOGLE_GROUPS_API_URL or M365_TEAMS_CHANNEL_API_URL...}",
        headers: {
            "content-type": "application/json",
        },
        body: JSON.stringify({"text": textMessage}),
    }, (error, response, body)=>{
        console.log(body);
    });
}

var getnotes=async ()=>{
    let ret=await joplin.syncSync();
    console.log(`syncSync result[${ret}]`);
    if(ret){
        let result=await joplin.getnotesSync();
        console.log(`getnote [${result.notes}]`);
        if(result.notes.length>0){
            let result2=await joplin.checkToDoAlarm();
            result2.forEach((n)=>{
                let ndt=n.noticeDateTime;
                let formatteddt = `${ndt.getFullYear()}-${ndt.getMonth()+1}-${ndt.getDate()} ${ndt.getHours()}:${ndt.getMinutes()}:${ndt.getSeconds()}`;
                if(n.noticeType==="alarm"){
                    webNotify(thetimecoming+" TIME:"+formatteddt+" DESC:"+n.noticeDescription);
                }
                if(n.noticeType==="todo"){
                    webNotify(thetimereminds+" DUE:"+formatteddt+" DESC:"+n.noticeDescription);
                }
            });
        }
    }
}
getnotes();

one shotタイプだがこれで実行できる。
後は好みでGoogle GroupsだったりLINEだったりTeamsだったりに通知したらよい。(webNotify()の部分)
joplin_sync.jsは通知必要なものを返すだけにしている。

繰り返して実行する方法は、setInterval()してforeverで監視してもよいし、cronのような外部の力を借りるのもよい。
そこらへんは割愛。
私は簡単にcronにしている。

最後に

今のところ、便利に使っている。
これだからJoplinはやめられない。

あ、こういうときはgithubのを公開すればよかったんだろうけど、まだ改良をした方がよいだろうから、まだβ扱いってことで。


  1. 1.実ファイルは HOMEディレクトリ/.config/joplin-desktop/userstyle.css にある。

コメント: