- 投稿日:2020-11-14T18:29:25+09:00
セミコロンのつけ忘れを、JSはどこまで許してくれるのかを試してみた
はじめに
IT初心者の私ですが、普段はJavaScript, Node.js(以下JS)を使った開発をしています。
ここ数日、Javaを学び直そうとしているのですが、とにかくセミコロンをつけ忘れてはコンパイラに怒られています。「JSなら実行してくれるのに・・」
むしろ逆にJavaScriptがセミコロンのつけ忘れに寛容すぎるのでは?
そのような背景もあり、JavaScriptがどこまでセミコロンのつけ忘れを許してくれるのかを、簡単に検証してみました。
この記事でわかること
- JSだとOKだけどJava先生は怒る状態はどういうものか
- JSが怒らないセミコロンのつけ忘れのギリギリのライン
実行環境
- Java : paiza.io (openjdk version "15" 2020-09-15)
- JS : paiza.io(Node.js v12.18.3)
Javaで怒られたソースコード
まずこちらがJava学習中に怒られた案件です。
import java.util.*; import java.util.ArrayList; public class Main { public static void main(String[] args) throws Exception { // Your code here! List<Integer> names = new ArrayList<Integer>(); names.add(1); names.add(2); System.out.println(names.get(0)); System.out.println(names.get(1)) // ここのセミコロンつけ忘れで怒ってる } } // Main.java:12: error: ';' expected // System.out.println(names.get(1)) //1 error「後ろに何も処理書いてないんだからいいじゃん!セミコロンなしでも分かってくれよ~!わかるだろ~!?」と、
セミコロンを忘れがちな私は思うわけです。
実際JSでは、後ろに処理がなければ実行できる(できてしまう)のだし。実際JSだとどこまで許されるのか
const a = "hoge"; console.log(a); // hogeconst a = "hoge"; console.log(a) // hogeconst a = "hoge" console.log(a) // hogeconst a = "hoge"console.log(a) // SyntaxError: Unexpected identifierconst a = ("hoge");console.log(a) //hogeセミコロンがなくとも、最悪改行さえしていれば実行できるようですね。
そしてこれはJavaも同様ですが、セミコロンで区切りさえすれば問題なく実行できます。おわりに
これらの仕様に対して、良し悪しの解釈は人により分かれると思いますが、
「実行できる」とはいえ、私はセミコロンを書き込む癖をつけようと感じました。
- 投稿日:2020-11-14T16:51:41+09:00
[eleventy] addWatchTarget で指定したパスをウォッチしてくれない
config ファイルの名前を
.eleventy.js
いがいにするとこの現象がおきます。バグ?
- 投稿日:2020-11-14T05:39:46+09:00
ブラウザから外部プログラムを起動するサーバー
概要
ブラウザから外部プログラムを起動することはブラウザ単体では(拡張機能を使っても)出来ないので、node.jsで外部プログラムを起動するサーバーを作ってみた。
クエリ文字列でコマンドを渡すのでブックマーク機能を使えば簡単なランチャーにもなります。ただし、アイコンはデフォルトのままなので、変えたいなら拡張機能を使う必要があります。
ソース
Executer.jsvar http = require('http'); var url = require('url'); var proc = require('child_process'); //コマンドラインからポート番号の設定、デフォルトは8888 var port = 8888 if(process.argv.length > 2) { port = process.argv[2]; } //ヘッダHTMLの出力 var header = function(res, title) { res.write(` <html> <head> <title>${title}</title> </head> <body> `); }; //フォームHTMLの出力 var form = function(res) { res.write(` <form action="http://localhost:${port}" method="GET"> Command:<input type="text" name="command" value="" /> <input type="submit" value="Execute!"> </form> `); }; //フッターHTMLの出力 var footer = function(res) { res.write(` </body> </html> `); res.end(); }; //サーバー起動 http.createServer(function (req, res) { if(req.method === 'GET'){ //キャッシュしない設定でHTTPヘッダを出力 res.writeHead(200, {'Content-Type': 'text/html', 'Cache-Control': 'private, no-cache, no-store, must-revalidate'}); var query = url.parse(req.url, true).query; var out = ''; var iserr = false //HTTP Queryからコマンド文字列を取得 if(typeof query.command !== 'undefined' && query.command.length > 0) { //クエリにコマンド文字列が設定されていればspawnで起動 header(res, query.command); res.write('<pre id="output" style="color:red">'); var spwn = proc.spawn(query.command, [], { shell: true, env: process.env }); //spawnのエラーイベント・エラー出力があればそのままレスポンスとして出力 spwn.on('error', (err) => { iserr = true; res.write(data); console.log(data); }); spwn.stdout.on('data', (data) => { //標準出力は出力しない //res.write(data); }); spwn.stderr.on('data', (data) => { iserr = true; res.write(data); }); //2秒後にフッタを出力して切断(起動したアプリはそのまま) setTimeout(() => { res.write('</pre>'); //エラーがなければそのまま前のページに戻る if(!iserr) res.write('<script>history.back();</script>'); footer(res); }, 2000); } else { //コマンド文字列が設定されていなければフォームを出力して切断 header(res, 'Node Executer'); form(res); footer(res); } } else { //GETメソッド以外は404エラーを返す res.writeHead(404, {'Content-Type': 'text/plain'}); res.write('Not Found'); res.end(); } }).listen(port, '127.0.0.1');起動は
> node Executer.js 8888起動した後ブラウザから
http://localhost:8888
に接続すればしょぼいコマンド入力フォームが出てきますのでコマンドを入力します。アドレスバーに
http://localhost:8888?command=[コマンド文字列]
と入力すれば直接実行できます。この形式でブックマークすることもできます。起動に成功すれば自動的に前のページに戻ります。spawnでエラーが出たり、エラー出力があった場合は内容が赤字で表示されて前のページに戻りません。
サーバーの終了はCtrl-Cで。
外部からサーバーを(強制)終了させる
ポート番号がわかっていればnetstatコマンドでポートを確保しているプロセス番号がわかるので、それを元にkillすることで正常終了させる仕組みがないサーバーでも外部からサーバーを強制終了させることができるらしい。
下記はWindowsが対象だが、たぶんLinuxでも同じようなことは出来ると思う。参考:
- ポートを握っているプロセスを見たい時
- Windowsで、特定のポート番号でLISTENINGしているプロセスを強制終了させる方法
- Node.jsでport 3000のプロセスを探してkillするDOS バッチファイル(Windows10)
portkill.bat@echo off set __KILLPID= set __TEMPPID= if "%1"=="" ( echo 使い方: portkill.bat [ポート番号] goto end ) for /F "delims=" %%i in ('netstat -aon ^| findstr /r 0\.[01]:%1[^^^^0-9]') do set __TEMPPID=%%i for %%a in (%__TEMPPID%) do ( set __KILLPID=%%a ) if not defined __KILLPID ( echo %1番ポートを待ち受けているプログラムが見つかりませんでした。 goto end ) echo %__KILLPID% taskkill /pid %__KILLPID% /F :end set __KILLPID= set __TEMPPID=こんな感じのバッチファイルを作っておけば外部から任意のポートを待ち受けているプログラムをkillできる。
ブラウザと連動する
いろいろやり方があるような気がしないでもないですが、一番手っ取り早いのは
- 先にサーバーを別コンソールで起動しておいて
- コンソールでブラウザを起動すると終了するまでコンソールに制御が戻らないことを利用して終了を待ち
- ブラウザが終了してコンソールに制御が戻ったらサーバーも終了させる
という動作をするバッチファイルを作ることでしょうか…
start.bat@if not "%~0"=="%~dp0.\%~nx0" start /min cmd /c,"%~dp0.\%~nx0" %* & goto :eof @echo off start /min cmd /c node Executer.js 8888 C:\Program Files\Google\Chrome\Application\Chrome.exe call portkill.bat 8888
- 投稿日:2020-11-14T03:37:25+09:00
Electronでタスク管理アプリ作ってみた
本記事について
こんにちは。あかいです。
この記事は、勉強を兼ねてElectronでタスク管理アプリを作成した際の備忘録です。
環境構築はすでに記事が出回っているので、作成までの検討事項を簡単にまとめます。
なお、簡単のため、アプリはローカルに閉じるものとし、Exe化までは行いません。以下の内容を載せています。
・Electron概要
・検討までの流れ
・ソースの一部の解説
→タスク名変更時の処理の流れ
→ドラッグ&ドロップの処理の流れ
→データ保存の処理の流れ
・ソース全体作成物
まず、今回作成したのは以下です。よくあるKanbanboardをイメージしています。
初心者なので、できるだけシンプルな構成となるよう1ページにしています。
環境
以下の通りです。
- Node.js : v12.19.0
- jquery : v3.5.1(CDN)
- jquery-ui : v1.12.1(CDN)
- Electron : v10.1.3(ローカルインストール)
- Bootstrap : v4.5.0(CDN)
(参考:Electronの環境構築(for Windows))
フレームワーク、ライブラリは、機能が実現でき、できるだけ学習コストが低そうな、環境構築の手間の少ないもの、を選んでいます。
WebまわりのGUIはReactやViewの記事が多くヒットしますが、初学ということで、長年使用されているjqueryとしました。Electron概要
ドキュメント:https://www.electronjs.org/docs
Electron は ChromiumとNode.jsを利用しているため、HTML, CSS, JavaScriptを利用してアプリを開発することができます。
Electronではフロントエンドの技術でデスクトップアプリを作成することができます。
プロセスは、メインプロセスとレンダラープロセスに分けられます。
メインプロセスはpacakge.jsonにおいて、mainで指定したエントリポイントを起点として起動します。package.json{ "name": "taskboard", "version": "1.0.0", "main": "index.js", "author": "", "description": "" }例えば上記のpackage.jsonであれば、index.js(任意名称、main.jsでも可)を起点として起動しますので、このindex.jsにElectronのAPIによるメインウィンドウの起動などを記述します。ここではElectron APIやNode.jsが使用できます。
index.js(一部)"use strct"; // Electronのモジュール const electron = require("electron"); // アプリケーションをコントロールするモジュール const app = electron.app; // ウィンドウを作成するモジュール const BrowserWindow = electron.BrowserWindow; // Electronの初期化完了後に実行 app.on("ready", () => { // ウィンドウサイズを1000*800(フレームサイズを含まない)に設定する // HTML側でNode.js使用可能とする(レンダラープロセスで使用可能) mainWindow = new BrowserWindow({ width: 1000, height: 800, minWidth: 700, minHeight: 700, webPreferences: { nodeIntegration: true, nodeIntegrationInWorker : true } }); // <= レンダラープロセス(mainWindow) }レンダラープロセスはメインプロセスから呼び出され(BrowserWindow インスタンスとして生成され)ます。
したがって、メインプロセスは必ず1つですが、レンダラープロセスは複数存在しえます。
(本家のドキュメントが参考になります。https://www.electronjs.org/docs/tutorial/quick-start#create-a-basic-application)レンダラープロセスは、メインプロセスのBrowserWindowで指定したHTMLや、そのHTML内で読み込んだcss, jsファイルで記述します。
通常のWebページと同じですが、Electronでは、レンダラープロセスのjsファイル内では(制限付きの)Electron APIやNode.jsが使用できます※。※ElectronAPI自体の制限やデフォルトでは使えない場合があります。つまったところを参照してください。
作成までの流れ
1. 「タスク」でブレスト
2. タスク管理の仕様をまとめる
3. 実装のポリシー決め
4. 画面のワイヤーフレーム
5. 画面実装
6. 機能実装
7. テスト・要件の確認1.「タスク」でブレスト
仕様を決定するにあたって、まずタスクとは何かを5分間で考えました。
「タスク」とは
・期限がある
・ステータスがある(登録済み、開始、待ち、終了)
・階層構造(プロジェクト→大タスク→中タスク...)がある。(際限がない)
・ステークホルダがある
・リマインドされる
・一覧がある(一意に特定される)
・詳細がある(メモ、関連)
・関連がある
・分類がある(習慣、臨時)
・進捗率がある
・所有者がある
・アウトプットがある(完了状態率などの統計情報、報告書など)2.タスク管理の仕様をまとめる
ブレインストーミングの結果をグルーピングし、要件としてまとめます。
●構造
・(ルート→)プロジェクト→タスク→サブタスク(打ち止めとする)
・タスク間関連(フローチャート。今回は対象外とする。)●要素
要素 プロジェクト タスク サブタスク id ◎ ◎ ◎ 名称 ◎ ◎ ◎ 日付(開始、完了、期限) ○ ◎ ○ 詳細(メモ) ○ ◎ ○ ステークホルダ ○ ○ ○ 下位の統計情報 ○ ○ ✕ 状態(待ち、実行中、完了) ○ ◎ △ 分類(タグ) ○ ○ ○ 関連(方向、プロパティ)
ex)タスクA→タスクB、順序✕ ○ ○ 添付ファイル ○ ○ ○ (凡例)
○:要素として持ちうる
✕:要素として持たない
◎:今回の対象とする
△:一部、今回の対象とする●機能
機能 プロジェクト タスク サブタスク 要素の変更 ◎ ◎ ◎ 追加 ◎ ◎ ◎ 削除 ◎ ◎ ◎ 統計情報の計算 ○ ○ ○ フローの出力 ○ ○ ✕ 報告書の出力 ○ ✕ ✕ 添付のアップ・ダウンロード ○ ○ ○ (凡例)
○:機能として持ちうる
✕:機能として持たない
◎:今回の対象とする3.実装のポリシー決め
- まずは動くものを作成し、作りながら改善する →CSS設計は考えず、必要あればリファクタリング
- 機能はメインプロセスに集約し、レンダラープロセスは表示に専念
- データ管理はメインプロセスで行い、レンダラープロセスはメインプロセスに問い合わせて表示を更新する
- 簡単のため、レンダラープロセスの画面描画は一部の変更であってもすべて再描画する
- 問い合わせはIPC通信を用いて、API的に使用する
- IPC通信は非同期には行わず、すべて同期通信とする(後述のデータ保存の独立性のため)
- 簡単のため、シングルページとする
- データの保存にはファイルを利用する
- JSON形式で保存し、簡単のため、毎回フルでの書き出しとする
- 独立性のため、データの保存は並行して行わない
以下にポリシーに基づく処理の概略図を示します。
ElectronはElectronの概要 に示す通り、メインプロセスとレンダラープロセスで動作します。今回はシングルページのためレンダラープロセスは一つだけです。メインプロセスからBrowserWindow()
でウィンドウを作成し、mainWindow.loadURL(\`file://${__dirname}/index.html\`)
でindex.htmlを読み込みます。
index.html内で指定したcss, jsをCDNおよびローカルから読み込み、メインプロセスにデータを要求し、メインプロセスはデータが存在しなければ、ローカルのjsonファイル(data.json)を読み込みます。(data.jsonには後述のデータ構造のjsonデータが保存されています。)
レンダラープロセスは、メインプロセスから返却されたデータに基づき、画面を描画します。
ユーザーが画面入力した場合は、起動時と同様に、レンダラープロセスからIPC同期通信で、入力に基づく要求をメインプロセス送り、メインプロセス側でデータ変更処理をかけた後、jsonファイルを更新し、レンダラープロセスに成功可否を連絡します。
レンダラープロセスでは、成功を受けた場合にメインプロセスにデータ要求をし、応答に基づいてページを再描画します。次に示すのは、メインプロセスで操作し、ファイルに保存するオブジェクトの形式です。
メインプロセスにてこのオブジェクトを操作・保存し、レンダラプロセスで受け取って描画します。data.json(作成例){ "projects": [ { "id": "project1", "name": "プロジェクト1", "tasks": [ { "id": "task1", "name": "タスク名", "status": "Wait", "start_date": "2020/11/12", "due_date": "", "end_date": "", "detail": "", "subtasks": [] }, { "id": "task2", "name": "タスク名", "status": "Wait", "start_date": "2020/11/12", "due_date": "", "end_date": "2020/11/12", "detail": "", "subtasks": [] }, { "id": "task3", "name": "タスク名", "status": "Doing", "start_date": "2020/11/12", "due_date": "", "end_date": "", "detail": "", "subtasks": [] } ] }, { "id": "project2", "name": "プロジェクト2", "tasks": [] }, { "id": "project3", "name": "プロジェクト名", "tasks": [] } ] }4.画面のワイヤーフレーム
Googleスライドで作成しました。タスクを示すカードの下部に
・ステークホルダの表示
・タスク同士の関連を示す前後のタスク(前:タスク0、後:1)
がありますが、後々簡単のために取りやめました。5.画面実装
画面表示は作成物と同様のためここでは省略します。
BootstrapやCSS、jqueryの実装例を検索しつつ、つぎはぎしました。ソースは最後に載せます。
今回はBEMなどのcss設計は全く意識していません。命名や実装に一貫性がないかもしれませんが、とりあえず動く、が目標のためご容赦ください。
(次回があればCSS設計完全ガイドを参考にしようと思っています。)ファイル構成は次です。前述のindex.htmlを起点とし、画面左部(left_menu.css, left_menu.js)と画面右部(right_body.css, right_body.js)、共通処理(common.css, renderer.js)に分けて記載しています。
(data.json, index.js, pachage.jsonは前述。start.batはelectronの起動コマンドを記述しているだけです。)
(参考)
start.batrem .\node_modules\.bin\electron . --inspect-brk .\node_modules\.bin\electron . exit 0
では、画面周りの処理内容を下記のタスク名を例に簡単に解説します。HTML
タスク名は以下のようなHTMLです。なお、タスク全体はBootstrapのcardで作成しています。
index.html(一部)<!-- カードタイトルここから --> <div class="card_title col-8"> <div class="wrap_task_name" > <input type="text" class="task_name"> </div> <div class="task_name_toolchip"></div> </div> <!-- カードタイトルここまで -->表示最大幅を超えた入力に備えて、「タスク名…」で表示するためにwrap要素を追加しています。
また、「タスク名…」表記時にツールチップですべてを表示するようにします。CSS
right_body.css(一部).task_name { font-size: 1.5rem; margin: auto auto; width: 100%; background-color: whitesmoke; border-radius: 0.3rem; border: hidden; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .wrap_task_name { display: inline-block; overflow: hidden } .card_title { position:relative; /* for toolchip */ } .task_name_toolchip { max-width: 26rem; display: none; position: absolute; top: 3.5em; left: 5rem; z-index: 9999; padding: 0.3em 0.5em; color: #FFFFFF; background: rgb(124, 124, 124); border-radius: 0.5em; } .task_name_toolchip:after { width: 100%; content: ""; display: block; position: absolute; left: 0.5em; top: -0.8rem; border-top:0.8rem solid transparent; border-left:0.8rem solid rgb(124, 124, 124); }
overflow: hidden;
が「タスク名…」表記のための部分です。また、wrapにも
text-overflow: ellipsis;overflow: hidden
を設定する必要があります。
(参考:入らなかった文字を三点リーダで省略表示)javascript
タスク名の編集のためのjavascriptです。大きく分けて、フォーカス時操作とフォーカスアウト時操作,ツールチップ表示の3つを記述しています。
right_body.js(一部)// イベント操作 // 対象:タスク名 // 動作:フォーカス // 内容:編集があった場合にタスク名を変更する。 $(document).on('focus', '.task_name', (e) => { $(e.target).select(); // エンター押下時にフォーカスアウト(Shift+EnterはOK) $(e.target).keypress(function(e){ if(! event.shiftKey){ if (e.keyCode == 13) { $(e.target).blur(); } } }); }); // フォーカスアウト時に変更反映 $(document).on('blur', '.task_name', (e) => { // 空なら変更せず再ロード。空でなければ変更を反映する。 if ($(e.target).val()) { project_id = $('#left_menu div.project.active').attr("id"); task_id = $(e.target).parents(".card").attr("id"); task_name = $(e.target).val(); rc = ipcRenderer.sendSync('changeTaskName', {project_id: project_id, task_id: task_id, task_name: task_name}); if (rc) { $(e.target).scrollLeft(0); // はみ出た分表示がずれるので最初の位置に戻す } } LOAD_TASKS(); }); // イベント操作 // 対象:タスク名 // 動作:マウスオーバー // 内容:ツールチップを表示する。 $(document).on('mouseover', '.task_name', (e) => { if ($(e.target).parent().width() < e.target.scrollWidth) { var task_name = $(e.target).val(); $(e.target).closest(".card_title").find(".task_name_toolchip").text(task_name); $(e.target).closest(".card_title").find(".task_name_toolchip").css("display", "block"); $(e.target).on('mouseleave', () => { $(e.target).closest(".card_title").find(".task_name_toolchip").css("display", "none"); }) } });まず、
$(document).on('focus', '.task_name', (e) => {}
のアロー関数に、task_nameクラスにフォーカスがあった場合のイベント処理を記述しています。
DOM変更時にイベントを反映させるため、$(セレクタ).on("イベント名", function())
でなく、$(document).on("セレクタ", "イベント名", function())
としています。
なお、task_nameクラスはindex.htmlで<input type="text" class="task_name">
と定義しています。
ここのアロー関数内で記述しているのは2点で、
・$(e.target).select();
で、画面からユーザが編集しやすいように、タスク名を全選択する
・$(e.target).keypress(function(e){}
でエンター(Shiftとの同時押しを除く)押下時にフォーカスアウトする
です。次にフォーカスアウト時の操作を
$(document).on('blur', '.task_name', (e) => {}
に記述しています。
フォーカスアウト時には、タスク名が空でなければ変更を反映するようにしています。空の状態で編集を終えた場合は、LOAD_TASKS();
(rendere.jsに記載)のところでタスク一覧の再読み込みを実施しているため、編集前の状態に戻ります。
タスク名が空でなければ、プロジェクトID、タスクID、変更したタスク名を取得し、rc = ipcRenderer.sendSync('changeTaskName', {project_id: project_id, task_id: task_id, task_name: task_name});
の部分で、メインプロセスにタスク名変更処理をIPC同期通信で要求しています。プロジェクトIDは画面左部のプロジェクト一覧のうちアクティブな要素から、タスクIDは自身の親要素のうちタスクIDを要素のIDとして持っているcardクラスから取得しています。なお、一つ一つのタスクはBootstrapのcardで作成しています。
メインプロセスから正常終了処理が返ってきた場合にはタスク名の表示位置を戻す処理を$(e.target).scrollLeft(0); // はみ出た分表示がずれるので最初の位置に戻す
で行っています。ツールチップの表示はマウスオーバー時にCSSのdisplayをBlockに変更、マウスリムーブ時にCSSのdisplayをNoneに変更しています。
if ($(e.target).parent().width() < e.target.scrollWidth) {}
この部分で表示最大幅を超えているかのチェックをしています。画面の参考
また、ここでは特に触れませんでしたが、以下2点の実装時の参考先を載せておきます。
- ドラッグ&ドロップ※(参考先:Draggable | jQuery UI 1.10 日本語リファレンス | js STUDIO
- DOM要素追加時のテンプレート(参考先:template要素についてのお勉強)
※ ドラッグ&ドロップの処理について、追記しました。(●タスクの順番変更処理(2020/11/15))
6.機能実装
機能はメインプロセスに実装します。既述のように、レンダラープロセスからAPI的にIPC通信を行います。
したがって、メインプロセス側ではレンダラープロセスからの通信要求を待ち受けて処理を返すような記述になります。
以下にメインプロセス側でのIPC通信の待ち受けの例を挙げます。index.js(一部)// on change taskName. ipcMain.on('changeTaskName', (event, arg) => { // "arg" is "{project_id: , task_id: , task_name:}" [index_project, index_task] = searchTask(arg.project_id, arg.task_id); if (index_project !== -1 && index_task !== -1) { projects[index_project].tasks[index_task].name = arg.task_name; event.returnValue = true; // if successfull, return true. data_control.set("projects", projects); } else { event.returnValue = false; // if failed, return false. } return });ここではタスク名の変更を例に見ていきます。
メイン側ではipcMainという名称のAPIです。ipcMain.on('changeTaskName', (event, arg) => {}
この部分のアロー関数内で、処理内容を記述しています。
同期通信の場合は、必ずreturnが必要となるので、event.returnValue = true; // if successfull, return true.
の部分などでリターン内容を設定して、returnしています。
処理内容を簡単に見ていくと、
[index_project, index_task] = searchTask(arg.project_id, arg.task_id);
この部分は、別途実装したsearchTask関数で、プロジェクトID、タスクIDから、projectsオブジェクト(プロジェクトすべての情報が入ったオブジェクト、data.jsonで読み書きするのもこのオブジェクト)内のアレイのインデックスを取得しています。
searchTask関数では、対象が見つからなかった場合に-1をインデックスとして返しますので、次の行のif (index_project !== -1 && index_task !== -1) {
では対象タスクが見つかった場合に処理を続行するようなif文としています。
対象が見つかった場合は、projectsオブジェクトのタスク名をprojects[index_project].tasks[index_task].name = arg.task_name;
で書き換えて、returnに成功を意図するtrueを返すよう設定しています。ちなみに、returnはオブジェクトも返せます。
最後に、次の行のdata_control.set("projects", projects);
でdata.jsonにキー:projects、バリュー:projectsオブジェクト、として書き込みます。次にdata_controlを確認します。
data_control.js(一部)// data_control // require. const fs = require('fs'); let data_json = {} // set as key-value. value can be json, string or list. exports.set = function (key, value) { // set. data_json[key] = value; // rewrite into file. const data_string = JSON.stringify(data_json); fs.writeFile(file_path, data_string, (err) => { if (err) throw err; }); }data_controlはデータのファイル書き込み、読み出し用の自作モジュールです。
data_controlは以下の実装ポリシーのもと、書き込み・読み出し時の、排他処理 と 検索処理を省いています。
- IPC通信は非同期には行わず、すべて同期通信とする(後述のデータ保存の独立性のため)
- JSON形式で保存し、簡単のため、毎回フルでの書き出しとする
- 独立性のため、データの保存は並行して行わない
上のスクリプトでは、データ保存時の処理を記述しています。
ElectronではNode.jsのモジュールが使用可能です。ファイル操作のためconst fs = require('fs');
で、fsをrequireします。
exports.set = function (key, value) {}
にset関数を記述しています。set関数では、data_json[key] = value;
でdata_json(書き出し用データ)にKey-value形式でデータを挿入し、const data_string = JSON.stringify(data_json);
でjsonオブジェクトから文字列に変換、fs.writeFile(file_path, data_string, (err) => {}
で書き込みの流れとなっています。書き込みエラー時には単にエラーを投げるようにしています。(参考:Node.jsのfsモジュールの使い方)
7.テスト・要件の確認
軽く動かしてみて異常がないか、2. タスク管理の仕様をまとめるを満たしているかを確認しました。
本来は、異常ケース含めてテストすべきですが、今回は省略しました。
また、性能は体感で問題なければOKとし、セキュリティもローカルで閉じることから問題なしとしました。つまったところ
2点あります。
レンダラープロセスでは、
- Node.jsが使えない
- jqueryが使えない
使用したElectronのバージョンでは、デフォルト設定でレンダラプロセスを起こすと(Windowを作ると)、レンダラープロセス内でNode.jsが使えません。すなわち、
require('electron')
できないので、ElectronのAPIも使えません。
これは、セキュリティ対策のようで、以下のようにnodeIntegration: true
を設定してあげればOKです。index.js(一部)mainWindow = new BrowserWindow({ width: 1000, height: 800, webPreferences: { nodeIntegration: true } });(参考:【エラー対処】Electronでレンダラープロセスrequireができない)
これで、レンダラープロセスでもNode.jsは使えますが、まだjqueryが使えません。
これはjqueryの既知の問題のようで、jQuery contains something along this lines:
if ( typeof module === "object" && typeof module.exports === "object" ) { // set jQuery in \`module\` } else { // set jQuery in \`window\` }module is defined, even in the browser-side scripts. This causes jQuery to ignore the window object and use module, so >the other scripts won't find $ nor jQuery in global scope..
の通り、jqueryのソース内に、moduleが存在すればjqueryをwindowに設定しない分岐があり、このために$, jqueryがグローバルに設定されません。以下の参考記事では、
<script>if (typeof module === 'object') {window.module = module; module = undefined;}</script> jqueryなどの読み込み <script>if (window.module) {module = window.module;}</script>のように、jquery読み込み前にmoduleオブジェクトを退避し、moduleオブジェクトをundefinedに設定してから、jqueryを読み込んでグローバルに設定させ、読み込み後に退避したmoduleをもとに戻す作業で解決しています。
これにならって作成し、問題なく挙動しています。(参考:ElectronでjQueryが読み込めない問題の解決策と原因)
(参考:jQuery isn't set globally because "module" is defined #254)以上の2点ように、レンダラープロセスでモジュールが使えるような設定をしましたが、これはアプリがローカルに閉じることを前提としているために可能な対処です。
nodeIntegration: true
を設定すると、レンダラープロセスでネイティブな操作が可能になります。
したがって、ローカルでないアプリケーションを作成する場合、レンダラープロセスでのディレクトリ操作などが可能となるため、クロスサイトスクリプティングの危険度が上がります。今回は特に実施していませんが、セキュアな通信には以下が参考になりそうです。
(参考:ElectronでcontextBridgeによる安全なIPC通信)
(参考:Electron(v10.1.5現在)の IPC 通信入門 - よりセキュアな方法への変遷)なお、
nodeIntegration: false
の場合、jqueryの読み込みのための処理も不要となるようですので、contextBrideを使用した通信にすると、nodeIntegrationをtrueにする必要がなくなるので、jqueryの読み込みの問題も発生しないかもしれません。
(参考:ElectronでjQueryがundefinedになる)追記
● タスクの順番変更処理(2020/11/15)
GUIで並び替えた際の、メインプロセス側での順番の変更処理を実装しそびれていました。
ソースのindex.js
,renderer.js
を修正しました。
この修正によりドラッグ&ドロップで順番を入れ替えた後リフレッシュしても順番が保たれます。
あわせて簡単に説明します。index.js(ソート部)//on sort task. ipcMain.on('sortTask', (event, arg) => { //"arg" is "{project_id: project_id, key: "array", task_array: task_array}" if (arg.key === "array") { task_array = arg.task_array; var index = -1; var rc = false; index = projects.findIndex((project) => {return project.id === arg.project_id;}); if (index !== -1) { projects[index].tasks.sort((task1, task2) => { var index_task1 = task_array.findIndex((task_id) => {return task_id === task1.id}); var index_task2 = task_array.findIndex((task_id) => {return task_id === task2.id}); return index_task1 - index_task2; }); rc = true; } else { rc = false; } } event.returnValue = rc; return });まず、メインプロセス側で並び替え用のIPC通信を待ち受けます。拡張を考慮して、並び替え方法として key を与えています。
今回はkey: "array"
として、並べ替えたいタスクIDの配列(task_array)を与えて、その他のタスクはそのままに、task_array内のタスクIDの順序となるよう並び替えます。
例えば、タスク一覧のIDの順序がtask1, task2, task3, task4, task5
となっているところに、task_array=[task3, task1]
と与えられた場合は、task2, task4, task5
の順序は保ったまま、task3, task1
となるよう入れ替えます。
projects[index].tasks
でアクティブなプロジェクトのタスク一覧にアクセスし、.sort((task1, task2) => {})
で並び替えます。
sortのコールバック関数では、task1, task2 をとりだして比較する処理を記述します。
コールバック関数でのreturnの値によって次のように動作します。
- 正の値:task2が前に来るようソート
- 負の値:task1が前に来るようソート
- 0:何もしない
arrayオブジェクト.findIndex()
では要素がなければ―1を返すので、return index_task1 - index_task2
を考慮して表にすると次になります。
task1 task2 task1とtask2の位置関係 コールバック関数のリターン 並び替え ○ ○ task1 > task2 正 task_arrayの順序となるようソート ○ ○ task1 < task2 負 task_arrayの順序となるようソート ✕ ○ なし 負 task_array内のタスクが後ろに行くようソート ○ ✕ なし 正 task_array内のタスクが後ろに行くようソート ✕ ✕ なし 0 並び変えない 例えば、タスク一覧のIDの順序が
task1, task2, task3, task4, task5
となっているところに、task_array=[task3, task1]
と与えられた場合は、task2, task4, task5, task3, task1
となります。index.js(ソート部)// ソート可能にする。ロード時に読み込みが必要なため、$(function(){})で定義。 $(function() { $('.cards').each((index, element) => { $(element).sortable({ // オプション connectWith: '.cards', revert: 100, cursor: 'move', delay: 100, // ドロップした時のイベント(e.targetに受け取り側が、ui.itemにドラッグした要素がはいる) receive: (e, ui) => { // ステータスを受け取り側に合わせて変更 var task_status; switch ($(e.target).attr("id")) { case "cards_wait": task_status = "Wait"; break; case "cards_doing": task_status = "Doing"; break; case "cards_done": task_status = "Done"; break; default: return false; } project_id = $('#left_menu div.project.active').attr("id"); task_id = $(ui.item).attr("id"); rc = ipcRenderer.sendSync("changeTaskStatus", {project_id: project_id, task_id: task_id, task_status:task_status}); if (rc) { // ステータストグルの変更 $(e.target).find(`input[type=radio].${task_status}`).prop('checked', true); // Doneクラスのリセット後、ステータスがDoneなら追加(ユーザ入力でないためchangeで発火されない) $(ui.item).removeClass("Done"); if (task_status === "Done") { $(ui.item).addClass("Done"); } } return rc }, update: (e, ui) => { let task_id = ui.item.attr("id"); let task_array = $(element).sortable("toArray"); let rc = true; if (task_array.includes(task_id)) { project_id = $('#left_menu div.project.active').attr("id"); rc = ipcRenderer.sendSync("sortTask", {project_id: project_id, key: "array", task_array: task_array}); } return rc } }); }); });ドラッグ&ドロップはjquery uiのsortableを使用しています。
.cards
セレクタでタスクを格納している要素(Wait, Doing, Done)にアクセスしています。connectWith
オプションでドロップ可能な要素を指定します。これによってWaitからDoingなどの移動が可能になります。これを指定しないとWaitはWait、DoingはDoing、DoneはDone内でしか移動できません。
receive
、update
にはそれぞれ、別の領域から要素をドロップされた場合、ドラッグ&ドロップが完了した場合、の処理を記述します。
receive
では、受け取り側のidにより、Wait、Doing、Doneのどこにドロップされたかを判定して、タスクの状態を変更しています。
update
では、let task_array = $(element).sortable("toArray");
で要素の並びを取得して、rc = ipcRenderer.sendSync("sortTask", {project_id: project_id, key: "array", task_array: task_array});
でメインプロセスに順序の更新要求を出しています。updateイベントは、ドラッグ&ドロップ元、ドラッグ&ドロップ先の両方で発火するイベントです。したがって、if (task_array.includes(task_id)) {}
にて、取得した要素の並びがドラッグ&ドロップ元か、先かを判定して、ドラッグ&ドロップ先なら更新します。(ドラッグ&ドロップ元の要素は並び替えが発生しません。)ソース
index.js
index.js"use strct"; // Electronのモジュール const electron = require("electron"); // data_controlモジュール const data_control = require("./js/data_control") data_control.init(`${__dirname}/data`) // アプリケーションをコントロールするモジュール const app = electron.app; // ウィンドウを作成するモジュール const BrowserWindow = electron.BrowserWindow; // ダイアログを作成するモジュール const dialog = electron.dialog; // 通信用 const ipcMain = electron.ipcMain // メインウィンドウはGCされないようにグローバル宣言 let mainWindow = null; // project作成用 function makeProject(project_id) { let project = { id: project_id, name:"プロジェクト名", tasks: [] } return project; } // task作成用 function makeTask(task_id) { let task = { id: task_id, name: "タスク名", status: "Wait", start_date: "", due_date: "", end_date: "", detail: "", subtasks: [] } return task; } // subtask作成用 function makeSubtask(subtask_id) { let subtask = { id: subtask_id, name: "サブタスク", checked: false, } return subtask; } // 日付取得用 function today(){ var dt = new Date(); var y = dt.getFullYear(); var m = ("00" + (dt.getMonth()+1)).slice(-2); var d = ("00" + dt.getDate()).slice(-2); var result = y + "/" + m + "/" + d; return result; } // データ取得 let projects = data_control.get("projects"); if(!projects) { projects = []; } if (!projects.length) { let project = makeProject("project1"); projects = [project]; data_control.set("projects", projects); } // 全てのウィンドウが閉じたら終了 app.on("window-all-closed", () => { if (process.platform != "darwin") { app.quit(); } }); // Electronの初期化完了後に実行 app.on("ready", () => { // ウィンドウサイズを1000*800(フレームサイズを含まない)に設定する // HTML側でNode.js使用可能とする(レンダラープロセスで使用可能) mainWindow = new BrowserWindow({ width: 1000, height: 800, minWidth: 700, minHeight: 700, webPreferences: { nodeIntegration: true } }); //使用するhtmlファイルを指定する mainWindow.loadURL(`file://${__dirname}/index.html`); // ウィンドウが閉じられたらアプリも終了 mainWindow.on("closed", () => { mainWindow = null; }); }); ////////////// IPC ////////////// // on data. // return data to renderer. ipcMain.on('data', (event, arg) => { let data = data_control.get(arg); event.returnValue = data; return }) // on message. // console.log(arg) ipcMain.on('message', (event, arg) => { console.log(arg); }) // on confirm. ipcMain.on('confirm', (event, arg) =>{ // arg = {title: , message: } var options = { type: 'info', buttons: ["OK", "Cancel"], title: arg.title, message: arg.message }; index = dialog.showMessageBoxSync(mainWindow, options); if (index === 0) { // "OK" event.returnValue = true; } else if (index === 1) { // "Cancel" event.returnValue = false; } return }) // on add project. ipcMain.on('addProject', (event) => { // projectId採番 for (let i = 1; true; i++){ if (projects.findIndex((project) => {return project.id === `project${i}`;}) === -1) { PROJECT_ID = i; break; } } // project作成 let project = makeProject(`project${PROJECT_ID}`); // project追加 projects.push(project); // データ保存 data_control.set("projects", projects); // 成功時はtrueをreturn event.returnValue = true; return }) // on delete project. ipcMain.on('deleteProject', (event, arg) => { // "arg" is "project_id" projects = projects.filter((project) => {return project.id !== arg;}); data_control.set("projects", projects); event.returnValue = true; return }) // on change projectName. ipcMain.on('changeProjectName', (event, arg) => { // "arg" : {project_id: , project_name: } var index = -1; index = projects.findIndex((project) => { return project.id === arg.project_id; }) if (index !== -1) { project = projects[index]; project.name = arg.project_name; projects[index] = project; data_control.set("projects", projects); event.returnValue = true; } else { event.returnValue = false; } return }) // on add task. ipcMain.on('addTask', (event, arg) => { // "arg" : project_id var index = -1; index = projects.findIndex((project) => {return project.id === arg;}) if (index !== -1) { project = projects[index]; tasks = project.tasks; // taskId採番 for (let i = 1; true; i++){ if (tasks.findIndex((task) => {return task.id === `task${i}`;}) === -1) { TASK_ID = i; break; } } // task作成 let task = makeTask(`task${TASK_ID}`); task.start_date = today(); // task追加 tasks.push(task); projects[index].tasks = tasks; // データ保存 data_control.set("projects", projects); // 作成したtaskをreturnにセット event.returnValue = true; } else { event.returnValue = false; } return }); // on delete task. ipcMain.on('deleteTask', (event, arg) => { // "arg" is "{project_id: , task_id: }" var index = -1; index = projects.findIndex((project) => {return project.id === arg.project_id;}); if (index !== -1) { projects[index].tasks = projects[index].tasks.filter((task) => {return task.id !== arg.task_id;}); event.returnValue = true; data_control.set("projects", projects); } else { event.returnValue = false; } return }); //on sort task. ipcMain.on('sortTask', (event, arg) => { //"arg" is "{project_id: project_id, key: "array", task_array: task_array}" if (arg.key === "array") { task_array = arg.task_array; var index = -1; var rc = false; index = projects.findIndex((project) => {return project.id === arg.project_id;}); if (index !== -1) { projects[index].tasks.sort((task1, task2) => { var index_task1 = task_array.findIndex((task_id) => {return task_id === task1.id}); var index_task2 = task_array.findIndex((task_id) => {return task_id === task2.id}); return index_task1 - index_task2; }); data_control.set("projects", projects); rc = true; } else { rc = false; } } event.returnValue = rc; return }); // on change taskName. ipcMain.on('changeTaskName', (event, arg) => { // "arg" is "{project_id: , task_id: , task_name:}" [index_project, index_task] = searchTask(arg.project_id, arg.task_id); if (index_project !== -1 && index_task !== -1) { projects[index_project].tasks[index_task].name = arg.task_name; event.returnValue = true; // if successfull, return true. data_control.set("projects", projects); } else { event.returnValue = false; // if failed, return false. } return }); // on change taskStatus. ipcMain.on('changeTaskStatus', (event, arg) => { // "arg" is "{project_id: , task_id: , task_status:}" [index_project, index_task] = searchTask(arg.project_id, arg.task_id); if (index_project !== -1 && index_task !== -1) { projects[index_project].tasks[index_task].status = arg.task_status; if (arg.task_status === "Done") { projects[index_project].tasks[index_task].end_date = today(); } event.returnValue = true; // if successfull, return true. data_control.set("projects", projects); } else { event.returnValue = false; // if failed, return false. } return }); // on change taskDetail. ipcMain.on('changeTaskDetail', (event, arg) => { // "arg" is "{project_id: , task_id: , task_detail:}" [index_project, index_task] = searchTask(arg.project_id, arg.task_id); if (index_project !== -1 && index_task !== -1) { projects[index_project].tasks[index_task].detail = arg.task_detail; event.returnValue = true; // if successfull, return true. data_control.set("projects", projects); } else { event.returnValue = false; // if failed, return false. } return }); // on change TaskStartDate. ipcMain.on('changeTaskDate', (event, arg) => { // "arg" is "{project_id: , task_id: , task_(start|due|end)_date:}" var rc = true; [index_project, index_task] = searchTask(arg.project_id, arg.task_id); if (index_project !== -1 && index_task !== -1) { if ("task_start_date" in arg) { projects[index_project].tasks[index_task].start_date = arg.task_start_date; } else if ("task_due_date" in arg) { projects[index_project].tasks[index_task].due_date = arg.task_due_date; } else if ("task_end_date" in arg) { projects[index_project].tasks[index_task].end_date = arg.task_end_date; } else { rc = false; // if failed, return false. } if (rc) { data_control.set("projects", projects); } } else { rc = false; // if failed, return false. } event.returnValue = rc; return }); // on add subtask. ipcMain.on('addSubtask', (event, arg) => { // "arg" is "{project_id: , task_id:}" [index_project, index_task] = searchTask(arg.project_id, arg.task_id); if (index_project !== -1 && index_task !== -1) { subtasks = projects[index_project].tasks[index_task].subtasks; // subtaskId採番 for (let i = 1; true; i++){ if (subtasks.findIndex((subtask) => {return subtask.id === `subtask${i}`;}) === -1) { SUBTASK_ID = i; break; } } // subtask作成 let subtask = makeSubtask(`subtask${SUBTASK_ID}`); // task追加 subtasks.push(subtask); projects[index_project].tasks[index_task].subtasks = subtasks; // データ保存 data_control.set("projects", projects); // 成否をreturnにセット event.returnValue = true; } else { event.returnValue = false; } return }); // on delete subtask. ipcMain.on('deleteSubtask', (event, arg) => { // "arg" is "{project_id: , task_id: , subtask_id:}" [index_project, index_task] = searchTask(arg.project_id, arg.task_id); if (index_project !== -1 && index_task !== -1) { subtasks = projects[index_project].tasks[index_task].subtasks; projects[index_project].tasks[index_task].subtasks = subtasks.filter((subtask) => {return subtask.id !== arg.subtask_id;}); data_control.set("projects", projects); event.returnValue = true; } else { event.returnValue = false; } return }); // on change subtask_name. ipcMain.on('changeSubtaskName', (event, arg) => { // "arg" is "{project_id: , task_id: , subtask_id: , subtask_name:}" [index_project, index_task, index_subtask] = searchSubtask(arg.project_id, arg.task_id, arg.subtask_id); if (index_project !== -1 && index_task !== -1 && index_subtask !== -1) { projects[index_project].tasks[index_task].subtasks[index_subtask].name = arg.subtask_name; data_control.set("projects", projects); event.returnValue = true; } else { event.returnValue = false; } return }); // on change subtask_checked. ipcMain.on('changeSubtaskChecked', (event, arg) => { // "arg" is "{project_id: , task_id: , subtask_id: , subtask_checked:}" [index_project, index_task, index_subtask] = searchSubtask(arg.project_id, arg.task_id, arg.subtask_id); if (index_project !== -1 && index_task !== -1 && index_subtask !== -1) { projects[index_project].tasks[index_task].subtasks[index_subtask].checked = arg.subtask_checked; data_control.set("projects", projects); event.returnValue = true; } else { event.returnValue = false; } return }); ///////////////// function ///////////////// // Search the index of task from project_id and task_id. function searchTask(project_id, task_id) { var index_project = -1; var index_task = -1; index_project = projects.findIndex((project) => {return project.id === project_id;}); if (index_project !== -1) { index_task = projects[index_project].tasks.findIndex((task) => {return task.id === task_id;}); } return [index_project, index_task]; } // Search the index of subtask from project_id, task_id and subtask_id. function searchSubtask(project_id, task_id, subtask_id) { var index_project = -1; var index_task = -1; var index_subtask = -1; index_project = projects.findIndex((project) => {return project.id === project_id;}); if (index_project !== -1) { index_task = projects[index_project].tasks.findIndex((task) => {return task.id === task_id;}); if (index_task !== -1) { index_subtask = projects[index_project].tasks[index_task].subtasks.findIndex((subtask) => {return subtask.id === subtask_id;}); } } return [index_project, index_task, index_subtask]; }
data_control.js
data_control.js// data_control // require. const fs = require('fs'); // vers. let data_json = {} let file_path = "" exports.init = function (data_dir) { file_path = data_dir + "/data.json" // load data file as json. let data_string = ""; if (fs.existsSync(file_path)) { data_string = fs.readFileSync(file_path, 'utf8'); if (data_string) { data_json = JSON.parse(data_string) } } } // set as key-value. value can be json, string or list. exports.set = function (key, value) { // set. data_json[key] = value; // rewrite into file. const data_string = JSON.stringify(data_json); fs.writeFile(file_path, data_string, (err) => { if (err) throw err; }); } exports.get = function (key) { // get. const value = data_json[key] if (value) { return value; }else{ return undefined; } }
index.html
index.html<!doctype html> <html lang="ja"> <head> <!-- Required meta tags --> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <!-- Bootstrap CSS --> <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css" integrity="sha384-9aIt2nRpC12Uk9gS9baDl411NQApFmC26EwAOH8WgZl5MYYxFfc+NcPb1dKGj7Sk" crossorigin="anonymous"> <!-- Jquery UI theme CSS --> <link rel="stylesheet" href="http://ajax.googleapis.com/ajax/libs/jqueryui/1/themes/smoothness/jquery-ui.css" > <!-- My CSS --> <link rel="stylesheet" href="css/common.css"> <link rel="stylesheet" href="css/left_menu.css"> <link rel="stylesheet" href="css/right_body.css"> <title>タスク管理</title> </head> <body> <header > <h1 id="title">Task Board</h1> </header> <div class="container-fluid" id="contents"> <!----------------------- モーダルエリアここから -----------------------> <section id="modalArea" class="modalArea fixed-top"> <div id="modalBg" class="modalBg"></div> <div class="modalWrapper"> <div class="modalContents"> <h1>削除しますか?</h1> <button id="delete_project">削除</button> </div> <div id="closeModal" class="closeModal"> × </div> </div> </section> <!----------------------- モーダルエリアここまで -----------------------> <div class="row" id="main_content"> <!----------------------- サイドバーここから -----------------------> <div class="col-3" id="left_menu"> <h3>Projects</h3> <hr size=1 color="white"> <div class="rounded-pill shadow" id="add_project">+</div> <ul id="menu_list"> <template id="project_template"> <li><div class="project rounded" id="project" contenteditable="false">project</div></li> </template> </ul> </div> <!----------------------- サイドバーここまで -----------------------> <!----------------------- コンテンツここから -----------------------> <div class="col-9" id="right_body"> <!-- カード一覧ここから --> <!-- カード追加ここから --> <div class="rounded-pill shadow" id="add_task">+ タスク追加</div> <!-- カード追加ここまで --> <div class="cards_list row"> <div class="board col-4"> <div class="board_name rounded">Wait</div> <div class="cards rounded" id="cards_wait"> <!-- コンテンツテンプレート挿入位置1 --> </div> </div> <div class="board col-4"> <div class="board_name rounded">Doing</div> <div class="cards rounded" id="cards_doing"> <!-- コンテンツテンプレート挿入位置2 --> </div> </div> <div class="board col-4"> <div class="board_name rounded">Done</div> <div class="cards rounded" id="cards_done"> <!-- コンテンツテンプレート挿入位置3 --> </div> </div> </div> <!-- カード一覧ここまで --> </div> <!----------------------- コンテンツここまで -----------------------> </div> <!----------------------- テンプレートここから -----------------------> <!-- タスクテンプレートここから --> <template id="task_template"> <!-- カードここから --> <div class="card shadow" id="task"> <!-- カードヘッダーここから --> <div class="card-header"> <div class="row"> <!-- カードタイトルここから --> <div class="card_title col-8"> <div class="wrap_task_name" > <input type="text" class="task_name"> </div> <div class="task_name_toolchip"></div> </div> <!-- カードタイトルここまで --> <!-- statusトグルここから --> <div class="col-4" id="status"> <input type="radio" class="status_input Wait" name="status_radio" id="Wait" value="Wait" checked> <label class = "status_label" for="Wait">Wait</label> <input type="radio" class="status_input Doing" name="status_radio" id="Doing" value="Doing"> <label class = "status_label" for="Doing">Doing</label> <input type="radio" class="status_input Done" name="status_radio" id="Done" value="Done"> <label class = "status_label" for="Done">Done</label> </div> <!-- statusトグルここまで --> </div> </div> <!-- カードヘッダーここまで --> <div class="card-body"> <!-- 日付表示ここから --> <div class="row"> <div class="col" id="start_date"> <label class="date_label" for="Start">開始日:</label> <input type="text" id="Start" class="date_input Start form-control" placeholder="日付を選択" readonly> </div> <div class="col" id="due_date"> <label class="date_label" for="Due">期日:</label> <input type="text" id="Due" class="date_input Due form-control" placeholder="日付を選択" readonly> </div> <div class="col" id="end_date"> <label class="date_label" for="End">完了日:</label> <input type="text" id="End" class="date_input End form-control" placeholder="日付を選択" readonly> </div> </div> <!-- 日付表示ここまで --> <!-- 詳細ここから --> <textarea type="textarea" class="form-control rounded task_detail" id="detail_textarea" placeholder="詳細入力"></textarea> <!-- 詳細ここまで --> <!-- チェックリストここから --> <!-- サブタスク追加ここから --> <div class="rounded-pill" id="add_subtask">+ サブタスク追加</div> <!-- サブタスク追加ここまで --> <div class="checklist" id="test"> <!-- チェックボックステンプレート挿入位置 --> </div> <!-- チェックリストここまで --> <!-- カード削除ボタンここから --> <div class="wrap_delete_task col"><div class="rounded-pill float-right" id="delete_task">✕</div></div> <!-- カード削除ボタンここまで --> </div> </div> <!-- カードここまで --> </template> <!-- タスクテンプレートここまで --> <!-- チェックボックステンプレートここから --> <template id="subtask_template"> <div class="checkbox row"> <div class="wrap_checkbox_input"> <input type="checkbox" class="checkbox_input" id="checkbox_input"> <label class="checkbox_label" for="checkbox_input"></label> </div> <div class="wrap_subtask_name"> <input type="text" class="subtask_name" placeholder="サブタスク"> </div> <div class="subtask_name_toolchip"></div> <div id="delete_subtask">削除</div> </div> </template> <!-- チェックボックステンプレートここまで --> <!----------------------- テンプレートここまで -----------------------> </div> <!-- Optional JavaScript --> <script>if (typeof module === 'object') {window.module = module; module = undefined;}</script> <!-- jQuery first, then Popper.js, then Bootstrap JS --> <script src="https://code.jquery.com/jquery-3.5.1.js" integrity="sha256-QWo7LDvxbWT2tbbQ97B53yJnYU3WhH/C8ycbRAkjPDc=" crossorigin="anonymous"></script> <!-- minified jquery --> <!-- <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script> --> <script src="https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script> <script src="https://ajax.googleapis.com/ajax/libs/jqueryui/1/i18n/jquery.ui.datepicker-ja.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script> <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js" integrity="sha384-OgVRvuATP1z7JjHLkuOU7Xw704+h835Lr+6QL9UvYjZE3Ipu6Tp75j7Bh/kR0JKI" crossorigin="anonymous"></script> <script>if (window.module) {module = window.module;}</script> <!-- My JavaScript --> <script src="js/renderer.js"></script> <!-- rendere.js first. --> <script src="js/left_menu.js"></script> <script src="js/right_body.js"></script> </body> </html>
common.css
common.csshtml { font-size: 62.5%; } header { background: whitesmoke; margin: 0; } #title { margin: 0 1rem; height: 5vh; } body { background: whitesmoke; overflow: hidden; } #contents { height: 95vh; } #main_content { background: linear-gradient(-120deg, #584a3d, #453650); } /*スクロールバー*/ ::-webkit-scrollbar { width: 1rem; height: 1rem; } ::-webkit-scrollbar-track { border-radius: 1rem; background-color:rgba(245, 245, 245, 0.1); } ::-webkit-scrollbar-thumb { border-radius: 1rem; background-color: rgba(128, 128, 128, 0.5); } input[type="text"]:focus { border: 0.2rem solid lightskyblue; outline: 0; }
left_menu.css
left_menu.css#left_menu { background-color: rgba(30,30,30,0.5); color: white; height: 95vh; overflow: auto; max-width: 30rem; } #left_menu ul { display: flex; flex-flow: column; padding-left: 0; margin: 0; list-style: none; } #left_menu li { text-align: center; } #left_menu div { font-size: 1.5rem; transform : scale(0.8, 0.8); margin: 0.1rem; } #left_menu div:hover { background-color: rgb(56, 56, 56); cursor : pointer; transition : .2s; transform: scale(0.9, 0.9); } #left_menu div[class*="active"] { background-color: rgba(109, 131, 68); transition : .2s; transform: scale(1, 1); } .project:focus { border: 0.2rem solid lightskyblue; outline: 0; } #add_project { width: 5rem; margin: 0.3rem auto !important; display: block; text-align: center; border: 0.15rem solid white; color: white; background-color: grey !important; font-weight: bold; transform: scale(0.8, 0.8); user-select: none; } #add_project:hover { background-color: #754775 !important; } /* モーダルCSS */ .modalArea { display: none; position: fixed; z-index: 10; /*サイトによってここの数値は調整 */ top: 0; left: 0; width: 100%; height: 100%; } .modalBg { width: 100%; height: 100%; background-color: rgba(30,30,30,0.9); } .modalWrapper { position: absolute; top: 50%; left: 50%; transform:translate(-50%,-50%); width: 70%; padding: 5vh 10vw; background-color: #fff; } .closeModal { position: absolute; top: 1rem; right: 2rem; font-size: 2rem; cursor: pointer; }
right_body.css
right_body.css#right_body { height: 95vh; margin: 0; padding: 0; scroll-behavior: smooth; overflow-x: auto; overflow-y: hidden; } /* カード */ .card { min-width: 35rem; max-width: 35rem; margin: 1rem auto 1rem auto; } .card.Done { opacity: 0.5 !important; } .board { min-height: 97vh; margin: 0; } .board_name { min-width: 40rem; max-width: 40rem; font-size: 3vh; margin: 0 auto; text-align: center; color: white; } .cards { border: 0.2rem solid grey; min-width: 40rem; max-width: 40rem; height: 88vh; margin-bottom: 3rem; padding-bottom: 3rem; align-items: center; overflow: auto; margin: 0 auto; } .cards_list { margin: 0; min-width: 130rem; max-width: 150rem; } #add_task { padding: 0.5rem; font-size: 1.5rem; width: 11rem; display: block; text-align: center; border: 0.15rem solid white; color: white; background-color: grey; font-weight: bold; transform: scale(0.9, 0.9); position:fixed; right:2rem; bottom:3rem; z-index: 1; user-select: none; } #add_task:hover { background-color: #754775; cursor : pointer; transition : .2s; transform: scale(1, 1); } #delete_task { font-size: 1rem; width: 2rem; height: 2rem; display: flex; justify-content: center; align-items: center; margin: 0; padding: 0; color: grey; font-weight: bold; transform: scale(1, 1); z-index: 1; border: 0.1rem solid grey; } #delete_task:hover { cursor : pointer; transition : .2s; transform: scale(1.2, 1.2); color: red; border-color: red; } .wrap_delete_task { margin: 1rem; } /* タスク名 */ .task_name { font-size: 1.5rem; margin: auto auto; width: 100%; background-color: whitesmoke; border-radius: 0.3rem; border: hidden; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .wrap_task_name { display: inline-block; overflow: hidden } .card_title { position:relative; /* for toolchip */ } .task_name_toolchip { max-width: 26rem; display: none; position: absolute; top: 3.5em; left: 5rem; z-index: 9999; padding: 0.3em 0.5em; color: #FFFFFF; background: rgb(124, 124, 124); border-radius: 0.5em; } .task_name_toolchip:after { width: 100%; content: ""; display: block; position: absolute; left: 0.5em; top: -0.8rem; border-top:0.8rem solid transparent; border-left:0.8rem solid rgb(124, 124, 124); } /* statusトグル */ #status { display: flex; justify-content: flex-end; } .status_input { display : none; border: 0.1rem solid grey; } /* statusトグル 選択なし */ .status_input + label { display : inline-block; height : 2.5rem; opacity : 0.7; cursor : pointer; transition : .2s; transform : scale(0.9, 0.9); border: 0.1rem solid grey; border-radius: 0.5rem; padding: 0.3rem 0.6rem; } /* statusトグル 選択あり */ .status_input:checked + label { opacity : 1; transform : scale(1, 1); } .status_input + label:hover { transform : scale(1, 1); } .Wait + label { color: orange; border: 0.1rem solid orange; } .Doing + label { color: rgb(0, 156, 0); border: 0.1rem solid rgb(0, 156, 0); } .Done + label { color: rgb(37, 21, 21); border: 0.1rem solid rgb(37, 21, 21); } .Wait:checked + label { background: orange !important; color: white !important; } .Doing:checked + label { background: rgb(0, 156, 0) !important; color: white !important; } .Done:checked + label { background: rgb(37, 21, 21) !important; color: white !important; } /* タスク詳細説明 */ #detail_textarea { max-height: 20rem; margin: 1rem auto; } /* チェックリスト */ .checkbox { padding-top: 1rem; position:relative; /* for toolchip */ } .wrap_checkbox_input { display: inline-block; padding-left: 2rem; padding-right: 1rem; height: 1.5rem; } input[type="checkbox"] { display: none; } input[type="checkbox"] + label { position: relative; padding-left: 1.5rem; padding-bottom: 1.5rem; cursor: pointer; } input[type="checkbox"] + label:before { content: ''; width: 1.5rem; height: 1.5rem; border: 0.1rem solid grey; border-radius: 1rem; position: absolute; left: 0; top: 0; transition: all 0.2s; } input[type="checkbox"]:checked + label:before { width: 0.75rem; height: 1.5rem; top: -0.5rem; left: 0.5rem; border-radius: 0; border-color: green; border-top-color: transparent; border-left-color: transparent; transform: rotate(60deg); } .subtask_name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border: hidden; width: 100%; height: 99%; } .wrap_subtask_name { display: inline-block; height: 2rem; text-align: center; width: 26rem; border: 0.1rem solid #ddd; border-radius: 0.3rem; background-color: white; overflow: hidden } .subtask_name_toolchip { max-width: 26rem; display: none; position: absolute; top: 3.5em; left: 5rem; z-index: 9999; padding: 0.3em 0.5em; color: #FFFFFF; background: rgb(124, 124, 124); border-radius: 0.5em; } .subtask_name_toolchip:after { width: 100%; content: ""; display: block; position: absolute; left: 0.5em; top: -0.8rem; border-top:0.8rem solid transparent; border-left:0.8rem solid rgb(124, 124, 124); } #add_subtask { width: 10rem; margin: 0.3rem auto; display: block; text-align: center; border: 0.15rem solid grey; color: grey; font-weight: bold; transform: scale(0.9, 0.9); user-select: none; } #add_subtask:hover { border-color: #bb6dbb; color: #bb6dbb; cursor : pointer; transition : .2s; transform: scale(1, 1); } #delete_subtask { width: 3rem; height: 1.5rem; color: grey; display: flex; justify-content: center; align-items: center; padding-left: 1rem; transform: scale(0.9, 0.9); } #delete_subtask:hover { transform: scale(1, 1) !important; color: red !important; cursor: pointer; font-weight: bold; }
renderer.js
renderer.js// Vars let PROJECT_TEXT; let PROJECT_ID = 0; let PROJECT_ELEMENT = ""; let project = {}; let TASK_ID = 0; let CARDS_SCROLL_TOP = 0; let CARDS_SCROLL_LEFT = 0; let top_wait; let top_doing; let top_done; // Electronのモジュール const electron = require("electron"); const ipcRenderer = electron.ipcRenderer; // Rem取得 function convertRemToPx(rem) { const fontSize = getComputedStyle(document.documentElement).fontSize; return rem * parseFloat(fontSize); } // subtask function. function ADD_SUBTASK(clone, task, subtask) { var clone_subtask = $($('#subtask_template').html()); clone_subtask.attr("id", subtask.id); // checkbox input 設定 clone_subtask.find("input[type=checkbox]").each((index, element) => { element.id = `${element.id}_${task.id}_${subtask.id}`; if (subtask.checked) { $(element).prop('checked', true); } }); // checkbox label 設定 clone_subtask.find("label.checkbox_label").each((index, element) => { $(element).attr('for', `${$(element).attr('for')}_${task.id}_${subtask.id}`); }); // checkbox content 設定 clone_subtask.find(".subtask_name").val(subtask.name); clone_subtask.find(".checkbox_input").prop('checked', subtask.checked); if (clone_subtask.find(".checkbox_input").prop('checked')) { // 取り消し線追加 clone_subtask.find(".checkbox_input").closest(".checkbox").find(".subtask_name").css("text-decoration", "line-through"); } // サブタスク追加 clone.find(".checklist").append(clone_subtask); } function LOAD_SUBTASKS(clone, task) { var subtasks = task.subtasks; // クリア clone.find(".checkbox").each((index, element) => { $(element).remove(); }); // 再描画 for (let i = 0; i < subtasks.length; i++) { ADD_SUBTASK(clone, task, subtasks[i]); } } // task function. function ADD_TASK(task) { var clone = $($('#task_template').html()); // task_id 設定 clone.attr("id", task.id); // task_name 設定 clone.find(".task_name").val(task.name); // task_detail 設定 clone.find(".task_detail").val(task.detail); // status input 設定 clone.find("input[type=radio]").each((index, element) => { element.name = `${element.name}_${task.id}`; element.id = `${element.id}_${task.id}`; if ($(element).attr("value") == task.status) { $(element).prop('checked', true); } if (task.status === "Done") { clone.addClass("Done"); } }); // status label 設定 clone.find("label.status_label").each((index, element) => { $(element).attr('for', `${$(element).attr('for')}_${task.id}`); }); // date input 設定 clone.find("input.date_input").each((index, element) => { element.id = `${element.id}_${task.id}`; if (element.id == `Start_${task.id}`) { $(element).attr("value", task.start_date); } if (element.id == `Due_${task.id}`) { $(element).attr("value", task.due_date); } if (element.id == `End_${task.id}`) { $(element).attr("value", task.end_date); } }); // date label 設定 clone.find("label.date_label").each((index, element) => { $(element).attr('for', `${$(element).attr('for')}_${task.id}`); }); // subtask 設定 LOAD_SUBTASKS(clone, task); // タスク追加 if (task.status === "Wait") { $('#cards_wait').append(clone); }else if (task.status === "Doing") { $('#cards_doing').append(clone); }else if (task.status === "Done") { $('#cards_done').append(clone); } // Callender. 追加時に設定が必要 $(function() { $.datepicker.setDefaults($.datepicker.regional["ja"]); $(".date_input").datepicker(); }); } function SHOW_TASKS(tasks) { // スクロール位置一時保存 $('.cards').each((index, element) => { switch ($(element).attr('id')) { case "cards_wait": top_wait = $(element).scrollTop(); break; case "cards_doing": top_doing = $(element).scrollTop(); break; case "cards_done": top_done = $(element).scrollTop(); break; } }); // クリア $('.card').each((index, element) => { $(element).remove(); }); // 再描画 for (let i = 0; i < tasks.length; i++) { ADD_TASK(tasks[i]); } } function LOAD_TASKS() { // projects再取得 projects = ipcRenderer.sendSync('data', 'projects'); // activeなproject取得 project_id = $('#left_menu div.project.active').attr("id"); // tasks取得 var index = -1; index = projects.findIndex((project) => { return project.id === project_id; }) var tasks; if (index !== -1){ tasks = projects[index].tasks; } else { tasks = []; } SHOW_TASKS(tasks); // タスク詳細の高さ調節。読み込み後でないとscrollHeightが使えない。 $(".task_detail").each((index, element) => { const minHeight = convertRemToPx(5); // 5rem $(element).height(minHeight); if (element.scrollHeight > element.clientHeight){ $(element).height(element.scrollHeight); } }); // タスク詳細の高さ調節後にスクロールをリロード RELOAD_SCROLL() } // スクロール位置リストア function RELOAD_SCROLL() { $('.cards').each((index, element) => { switch ($(element).attr('id')) { case "cards_wait": $(element).scrollTop(top_wait); break; case "cards_doing": $(element).scrollTop(top_doing); break; case "cards_done": $(element).scrollTop(top_done); break; } }); } // project function. function ADD_PROJECT(project) { // テンプレートからclone作成 let clone = $($('#project_template').html()); // cloneにid, textを設定 clone.find("#project").each((index, element) => { element.id = project.id; element.textContent = project.name; }); // cloneを一覧に追加 $('#menu_list').append(clone); } function SHOW_PROJECTS(projects) { // クリア active_project_id = ""; $('#left_menu div.project').each((index, element) => { if (element.classList.contains("active")){ active_project_id = element.id; } $(element).parent().remove(); }); // 再描画 for (let i = 0; i < projects.length; i++) { ADD_PROJECT(projects[i]); if (i==0){ $('#left_menu div.project').addClass("active"); } if (projects[i].id == active_project_id){ $('#left_menu div.project').removeClass("active") $(`#left_menu div#${active_project_id}`).addClass("active"); } } } function LOAD_PROJECTS() { // projects取得 projects = ipcRenderer.sendSync('data', 'projects'); SHOW_PROJECTS(projects); LOAD_TASKS(); } // テキスト全選択 jQuery.fn.selectText = function(){ var doc = document; var element = this[0]; if (window.getSelection) { var selection = window.getSelection(); var range = document.createRange(); range.selectNodeContents(element); selection.removeAllRanges(); selection.addRange(range); } }; // ソート可能にする。ロード時に読み込みが必要なため、$(function(){})で定義。 $(function() { $('.cards').each((index, element) => { $(element).sortable({ // オプション connectWith: '.cards', revert: 100, cursor: 'move', delay: 100, // ドロップした時のイベント(e.targetに受け取り側が、ui.itemにドラッグした要素がはいる) receive: (e, ui) => { // ステータスを受け取り側に合わせて変更 var task_status; switch ($(e.target).attr("id")) { case "cards_wait": task_status = "Wait"; break; case "cards_doing": task_status = "Doing"; break; case "cards_done": task_status = "Done"; break; default: return false; } project_id = $('#left_menu div.project.active').attr("id"); task_id = $(ui.item).attr("id"); rc = ipcRenderer.sendSync("changeTaskStatus", {project_id: project_id, task_id: task_id, task_status:task_status}); if (rc) { // ステータストグルの変更 $(e.target).find(`input[type=radio].${task_status}`).prop('checked', true); // Doneクラスのリセット後、ステータスがDoneなら追加(ユーザ入力でないためchangeで発火されない) $(ui.item).removeClass("Done"); if (task_status === "Done") { $(ui.item).addClass("Done"); } } return rc }, update: (e, ui) => { let task_id = ui.item.attr("id"); let task_array = $(element).sortable("toArray"); let rc = true; if (task_array.includes(task_id)) { project_id = $('#left_menu div.project.active').attr("id"); rc = ipcRenderer.sendSync("sortTask", {project_id: project_id, key: "array", task_array: task_array}); } return rc } }); }); });
left_menu.js
left_menu.js'user strict' // 読み込み操作 if (! $('#left_menu div.project').length) { LOAD_PROJECTS(); } // イベント操作 // 対象:プロジェクト追加ボタン // 動作:シングルクリック // 内容:直前にプロジェクトを追加する。 $(document).on('click', '#add_project', function(){ // project追加要求、returnされるproject取得 rc = ipcRenderer.sendSync('addProject'); // 画面に表示 LOAD_PROJECTS(); }); // 対象:プロジェクト名 // 動作:シングルクリック // 内容:プロジェクトを選択する。 $(document).on('click', '#left_menu div.project', function(){ $('#left_menu div.project').removeClass("active") $('#left_menu div.project').each(function () { $(this).attr('contenteditable', 'false'); if (! $(this).text()) { $(this).text(PROJECT_TEXT); } }) $(this).addClass("active"); }); // 対象:プロジェクト名 // 動作:ダブルクリック // 内容:プロジェクト名を編集可能にする。エンターキーまたはダブルクリックで終了する。 $(document).on('dblclick', '#left_menu div.project', function(){ let contenteditable = $(this).attr('contenteditable'); if (contenteditable == 'false') { PROJECT_TEXT = $(this).text(); // すべて編集不可に $('#left_menu div.project').attr('contenteditable', 'false'); // 対象だけ編集可に $(this).attr('contenteditable', 'true') $(this).focus(); $(this).selectText(); // エンター押下時にフォーカスアウト(Shift+EnterはOK) $(this).keypress(function(e){ if(! event.shiftKey){ if (e.keyCode == 13) { $(this).focusout(); } } }); // フォーカスアウト時に編集不可にしてMainプロセスに変更要求 $(this).focusout(()=>{ $(this).attr('contenteditable', 'false'); if (! $(this).text()) { $(this).text(PROJECT_TEXT); }else{ project_id = $(this).attr('id'); project_name = $(this).text(); rc = ipcRenderer.sendSync('changeProjectName', {project_id: project_id, project_name: project_name}); if (!rc) { $(this).text(PROJECT_TEXT); } } }) } }); // 対象:プロジェクト名 // 動作:右クリック // 内容:削除ポップアップを出す。 $(document).on('contextmenu', '#left_menu div.project', function(){ // reset $('.delete').removeClass('delete'); $('p.delete_target').remove(); // 対象追加 $(this).addClass('delete'); $('<p class="delete_target">削除対象:' + $(this).text() + '</p>').insertBefore($('#delete_project')); $('#modalArea').fadeIn(); return false }); // 対象:モーダル // 動作:右クリック // 内容:モーダルを閉じる $('#closeModal , #modalBg').click(function(){ $('#modalArea').fadeOut(); $('p.delete_target').remove(); }); // 対象:削除ボタン(モーダル) // 動作:クリック // 内容:プロジェクトを削除 $('#delete_project').click(function(){ project_id = $('#left_menu div.project.delete').attr("id"); rc = ipcRenderer.sendSync('deleteProject', project_id); $('#modalArea').fadeOut(); LOAD_PROJECTS(); $('p.delete_target').remove(); });
right_body.js
right_body.js'user strict' // 読み込み操作 // LOAD_PROJECTS中で読み込むため不要 //if (! $('#cards.card').length) { // LOAD_TASKS(); //} // イベント操作 // 対象:タスク削除ボタン // 動作:シングルクリック // 内容:選択したタスクを削除する。 $(document).on('click', '#delete_task', (e) => { project_id = $('#left_menu div.project.active').attr("id"); task_id = $(e.target).parents(".card").attr("id"); rc = ipcRenderer.sendSync("confirm", {title: "タスク削除", message: "削除しますか?"}); if (rc) { res = ipcRenderer.sendSync("deleteTask", {project_id: project_id, task_id: task_id}); LOAD_TASKS(); } }); // イベント操作 // 対象:タスク追加ボタン // 動作:シングルクリック // 内容:最後尾に新規タスクを追加する。 $(document).on('click', '#add_task', () => { project_id = $('#left_menu div.project.active').attr("id"); rc = ipcRenderer.sendSync('addTask', project_id); LOAD_TASKS(); $('#cards_wait').animate({scrollTop: $('#cards_wait').get(0).scrollHeight}, 2000); }); // イベント操作 // 対象:タスク状態ラジオボタン // 動作:シングルクリック // 内容:タスク状態を変更する。 $(document).on('click', '.status_input', (e) => { project_id = $('#left_menu div.project.active').attr("id"); task_id = $(e.target).parents(".card").attr("id"); if ($(e.target).prop('checked')) { task_status = $(e.target).attr("value"); rc = ipcRenderer.sendSync("changeTaskStatus", {project_id: project_id, task_id: task_id, task_status:task_status}); if (rc) { if (task_status !== "Done") { rc = ipcRenderer.sendSync('changeTaskDate', {project_id: project_id, task_id: task_id, task_end_date: ""}); } if (rc) { LOAD_TASKS(); } } } }) // イベント操作 // 対象:タスク名 // 動作:フォーカス // 内容:編集があった場合にタスク名を変更する。 $(document).on('focus', '.task_name', (e) => { $(e.target).select(); // エンター押下時にフォーカスアウト(Shift+EnterはOK) $(e.target).keypress(function(e){ if(! event.shiftKey){ if (e.keyCode == 13) { $(e.target).blur(); } } }); }); // フォーカスアウト時に変更反映 $(document).on('blur', '.task_name', (e) => { // 空なら変更せず再ロード。空でなければ変更を反映する。 if ($(e.target).val()) { project_id = $('#left_menu div.project.active').attr("id"); task_id = $(e.target).parents(".card").attr("id"); task_name = $(e.target).val(); rc = ipcRenderer.sendSync('changeTaskName', {project_id: project_id, task_id: task_id, task_name: task_name}); if (rc) { $(e.target).scrollLeft(0); // はみ出た分表示がずれるので最初の位置に戻す } } LOAD_TASKS(); }); // イベント操作 // 対象:タスク詳細 // 動作:入力 // 内容:入力に合わせてサイズを調整する。 $(document).on('input', 'textarea', (e) => { const minHeight = convertRemToPx(5); // 5rem $(e.target).height(minHeight) if (e.target.scrollHeight > e.target.clientHeight){ $(e.target).height(e.target.scrollHeight); } }); // イベント操作 // 対象:タスク詳細 // 動作:内容が修正されたとき // 内容:タスク詳細を変更する。 $(document).on('change', 'textarea', (e) => { project_id = $('#left_menu div.project.active').attr("id"); task_id = $(e.target).parents(".card").attr("id"); var task_detail = $(e.target).val(); rc = ipcRenderer.sendSync('changeTaskDetail', {project_id: project_id, task_id: task_id, task_detail: task_detail}); LOAD_TASKS(); }); // イベント操作 // 対象:タスク開始日 // 動作:内容が修正されたとき // 内容:タスク開始日を変更する。 $(document).on('change', '.date_input.Start', (e) => { project_id = $('#left_menu div.project.active').attr("id"); task_id = $(e.target).parents(".card").attr("id"); var task_start_date = $(e.target).val(); rc = ipcRenderer.sendSync('changeTaskDate', {project_id: project_id, task_id: task_id, task_start_date: task_start_date}); }); // イベント操作 // 対象:タスク期日 // 動作:内容が修正されたとき // 内容:タスク期日を変更する。 $(document).on('change', '.date_input.Due', (e) => { project_id = $('#left_menu div.project.active').attr("id"); task_id = $(e.target).parents(".card").attr("id"); var task_due_date = $(e.target).val(); rc = ipcRenderer.sendSync('changeTaskDate', {project_id: project_id, task_id: task_id, task_due_date: task_due_date}); }); // イベント操作 // 対象:タスク完了日 // 動作:内容が修正されたとき // 内容:タスク完了日を変更する。 $(document).on('change', '.date_input.End', (e) => { project_id = $('#left_menu div.project.active').attr("id"); task_id = $(e.target).parents(".card").attr("id"); var task_end_date = $(e.target).val(); rc = ipcRenderer.sendSync('changeTaskDate', {project_id: project_id, task_id: task_id, task_end_date: task_end_date}); }); // イベント操作 // 対象:サブタスク追加ボタン // 動作:シングルクリック // 内容:最後尾に新規サブタスクを追加する。 $(document).on('click', '#add_subtask', (e) => { project_id = $('#left_menu div.project.active').attr("id"); task_id = $(e.target).parents(".card").attr("id"); rc = ipcRenderer.sendSync('addSubtask', {project_id: project_id, task_id: task_id}); LOAD_TASKS(); }); // イベント操作 // 対象:サブタスク削除ボタン // 動作:シングルクリック // 内容:選択したタスクを削除する。 $(document).on('click', '#delete_subtask', (e) => { project_id = $('#left_menu div.project.active').attr("id"); task_id = $(e.target).parents(".card").attr("id"); subtask_id = $(e.target).parents(".checkbox").attr("id"); rc = ipcRenderer.sendSync("confirm", {title: "サブタスク削除", message: "削除しますか?"}); if (rc) { res = ipcRenderer.sendSync("deleteSubtask", {project_id: project_id, task_id: task_id, subtask_id:subtask_id}); LOAD_TASKS(); } }); // イベント操作 // 対象:サブタスク名 // 動作:フォーカス // 内容:編集があった場合にタスク名を変更する。 $(document).on('focus', '.subtask_name', (e) => { $(e.target).select(); // エンター押下時にフォーカスアウト(Shift+EnterはOK) $(e.target).keypress(function(e){ if(! event.shiftKey){ if (e.keyCode == 13) { $(e.target).blur(); } } }); }); // フォーカスアウト時に変更反映 $(document).on('blur', '.subtask_name', (e) => { // 空なら変更せず再ロード。空でなければ変更を反映する。 if ($(e.target).val()) { project_id = $('#left_menu div.project.active').attr("id"); task_id = $(e.target).parents(".card").attr("id"); subtask_id = $(e.target).parents(".checkbox").attr("id"); subtask_name = $(e.target).val(); rc = ipcRenderer.sendSync('changeSubtaskName', {project_id: project_id, task_id: task_id, subtask_id: subtask_id, subtask_name: subtask_name}); if (rc) { $(e.target).scrollLeft(0); // はみ出た分表示がずれるので最初の位置に戻す } } LOAD_TASKS(); }); // イベント操作 // 対象:サブタスク状態チェックボックス // 動作:シングルクリック // 内容:タスク状態を変更する。 $(document).on('click', '.checkbox_input', (e) => { project_id = $('#left_menu div.project.active').attr("id"); task_id = $(e.target).parents(".card").attr("id"); subtask_id = $(e.target).parents(".checkbox").attr("id"); subtask_checked = $(e.target).prop('checked'); rc = ipcRenderer.sendSync("changeSubtaskChecked", {project_id: project_id, task_id: task_id, subtask_id: subtask_id, subtask_checked:subtask_checked}); if (!rc) { $(e.target).prop('checked', !subtask_checked); } else { LOAD_TASKS(); } }) // イベント操作 // 対象:タスク名 // 動作:マウスオーバー // 内容:ツールチップを表示する。 $(document).on('mouseover', '.task_name', (e) => { if ($(e.target).parent().width() < e.target.scrollWidth) { var task_name = $(e.target).val(); $(e.target).closest(".card_title").find(".task_name_toolchip").text(task_name); $(e.target).closest(".card_title").find(".task_name_toolchip").css("display", "block"); $(e.target).on('mouseleave', () => { $(e.target).closest(".card_title").find(".task_name_toolchip").css("display", "none"); }) } }); // イベント操作 // 対象:サブタスク名 // 動作:マウスオーバー // 内容:ツールチップを表示する。 $(document).on('mouseover', '.subtask_name', (e) => { if ($(e.target).parent().width() < e.target.scrollWidth) { var subtask_name = $(e.target).val(); $(e.target).closest(".checkbox").find(".subtask_name_toolchip").text(subtask_name); $(e.target).closest(".checkbox").find(".subtask_name_toolchip").css("display", "block"); $(e.target).on('mouseleave', () => { $(e.target).closest(".checkbox").find(".subtask_name_toolchip").css("display", "none"); }) } }); // イベント操作 // 対象:プロジェクト名 // 動作:シングルクリック // 内容:タスク一覧を再読み込みする。 $(document).on('click', '#left_menu div.project', () => { LOAD_TASKS(); });
- 投稿日:2020-11-14T01:11:09+09:00
Alexaでアカウントリンクをせずに自前のWebサイトとアカウント連携する ※特定のケースのみ
概要
Alexa で アカウントリンク という機能を使うことで、「Alexaに紐付いているAmazonアカウント」と「自前で運用しているサイトの会員アカウント」を紐付け、Alexaからそのサイトに対して注文処理をかけたりすることができます。
しかしアカウントリンクをするためには、Alexaとの会話の途中で一回スキルを終了して、スマホ等からアカウントリンクのための
OAuthログイン
をしてもらわなければならず、ユーザーの離脱につながってしまいます。しかし、ある条件を満たせば、アカウントリンクをしなくてもAlexaのAmazonアカウントと、サイトの会員アカウントを紐付けて処理をすることができます。
条件
- 自前のサイトで既に
Amazonログイン
・Amazon Pay
を導入していること。- Alexaに話しかけたユーザーが既にそのサイトで
Amazonログイン
などを一度でも実施済み。会員情報に Amazon の
buyerID
という値を紐付けて保存していれば条件を満たします。アカウントリンクをせずに会員を特定する方法
Alexaはスキル起動時に端末固有の
apiAccessToken
という値を生成します。
まず、このトークンをAPIなどでサイトに渡します。
サイト側では受け取ったapiAccesToken
の値を元に、
Amazon Pay Buyer ID APIに問い合わせるとBuyerID
が返ってきます。
あとは会員情報に保存されているbuyerID
と照合すれば会員を特定できます。Alexaのフローチャート例
すぐに離脱されないよう、アカウント判定処理はできるだけ注文処理などの直前に持ってきた方が良いでしょう。
- Amazon Payを端末が許可しているか確認し、許可されてなければ設定を促してスキルを終了する。
- 既にそのスキルでアカウントリンク済みか確認する。済みなら6、済みでなければ3へ。
- サイトにapiAccessTokenを投げ、会員が特定できるか問い合わせる
- サイト側でapiAccessTokenからBuyerIDを特定し、そのBuyerIDの会員がいるか調べ、結果を返す。
- 会員が特定できない場合はアカウントリンクを促してスキルを終了する。
- 特定した会員で注文処理を行う。
実装例
Alexa側の処理 (Node.js)
// Amazon Pay の Setup 処理のハンドラー const ConnectionsSetupResponseHandler = { canHandle(handlerInput) { return handlerInput.requestEnvelope.request.type === 'Connections.Response' && handlerInput.requestEnvelope.request.name === 'Setup'; }, async handle(handlerInput) { const actionResponsePayload = handlerInput.requestEnvelope.request.payload; const actionResponseStatusCode = handlerInput.requestEnvelope.request.status.code; if (actionResponseStatusCode != 200) { const result = errorHandler.handleErrors(handlerInput); // Amazon Payが許可されていない場合は設定を促して終了 if (result.permissionsError) { return sendAmazonPayPermissionCard(handlerInput); // それ以外の理由で Setup 処理がうまくいかなかった場合 } else { return handlerInput.responseBuilder .speak(result.errorMessage) .withShouldEndSession(true) .getResponse(); } } // Amazon Payのパーミッション情報を取得 const permissions = handlerInput.requestEnvelope.context.System.user.permissions; // 注文処理を行うための、アカウントに紐付いたアクセストークンを取得 let accessToken = handlerInput.requestEnvelope.context.System.user.accessToken; // Amazon Payでの自動支払いに同意していない場合 if (permissions.scopes['payments:autopay_consent'].status === 'DENIED') { return handlerInput.responseBuilder .speak('寄付を続行するためには、Amazon Payの使用権限を許可する必要があります。Alexaアプリのホーム画面に表示されている「許可リクエスト」をタップして、' + 'Amazon Payの使用権限を許可してください。また、Alexaアプリの「設定」から「Alexaアカウント」をタップして、' + '音声ショッピングの権限がオンになっていることをご確認ください。完了したら、「アレクサ、W WEBSITEを開いて」と話しかけてください。') .withAskForPermissionsConsentCard(['payments:autopay_consent']) .withShouldEndSession(true) .getResponse(); // アカウントリンクの設定を行っていない場合 } else if (!accessToken) { // アカウントリンク無しで注文できるか確認 const response = await axios.post('https://~~~~/link_account', querystring.stringify({ api_access_token: handlerInput.requestEnvelope.context.System.apiAccessToken }), { validateStatus: function (status) { return true; } }); // アクセストークンが取得できなければアカウントリンクを促して終了 if (!response.data || !response.data.access_token) { return handlerInput.responseBuilder .speak('注文を続行するためには、W WEBSITEのログインアイディーでアカウント連携を行う必要があります。' + 'Alexaアプリのホーム画面に表示されている「アカウントのリンク」をタップして、W WEBSITE' + 'に一度ログインしてください。完了したら、「アレクサ、W WEBSITEを開いて」と話しかけてください。' + 'W WEBSITEのアカウントをお持ちでない場合は、' + 'W WEBSITEのホームページでアカウントを作成いただけます。') .withLinkAccountCard() .withShouldEndSession(true) .getResponse(); } // 会員が特定できた場合は、その会員のアクセストークンを受け取る accessToken = response.data.access_token; } const billingAgreementDetails = actionResponsePayload.billingAgreementDetails; const billingAgreementID = billingAgreementDetails.billingAgreementId; // 注文を行う const response = await axios.post('https://~~/order', handlerInput.requestEnvelope.request.token, { headers: { Authorization: 'Bearer ' + accessToken }, validateStatus: function (status) { return true; } }); if (response.status !== 200) { return handlerInput.responseBuilder .speak('申し訳ありません。注文を完了することができませんでした。しばらく時間を置いてお試しください。') .withShouldEndSession(true) .getResponse(); } let directiveObject = { type: "Connections.SendRequest", name: "Charge", payload: ~~~, token: ~~~ }; return handlerInput.responseBuilder .addDirective(directiveObject) .speak('では、決済処理を始めます') .withShouldEndSession(true) .getResponse(); } };サイト側の処理 (PHP)
$token = $_POST['apiAccessToken']; $ch = curl_init('https://pay-api.amazon.jp/live/v1/buyer/id'); curl_setopt($ch, CURLOPT_HTTPHEADER, ['Authorization' => "Bearer $token"]); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $response = json_decode(curl_exec($ch), true); curl_close($ch); $buyerID = $response === null ? null : $response['buyerID']; // 以下 buyerID から会員を特定し、アクセストークンなどを生成して返す処理