SlickGridとは
Excelみたいなのを作るのに便利なJavaScriptライブラリ
https://github.com/mleibman/SlickGrid/wiki
詳細はこの辺
https://qiita.com/icoxfog417/items/98e34c0555991033afec
何をしたかったのか
Excelで設定データを入力していたら1000超えたあたりで重い上に面倒になったのでいっそのこと自作してしまおうと思った。
GUIベースだとMacとWindowsを行き来する今の環境だと面倒なのでブラウザベースにした。
仕様
- データの更新を検出したらデータベースを更新(データをJSONにしてAPIを叩く)
- データの更新は編集行が変わった時だけに行う(更新頻度が高いと負荷がかかるため)
- データの更新はセルを選択すれば可能
- UpdateかInsertかはサーバー側で処理する(update APIで行う)
- データベースはsqlite3を選択(検索APIを考慮するとcsvよりデータベースを使った方が良いがこの程度でサーバーを起動するのが面倒)
- エラーメッセージはコンソールログに出し、アラートではださない
- 強制更新ボタンは今回は付けていない(関数呼び出すだけなので実装自体は楽)
- 絞り込みが可能になっている(search APIで行う)
- サーバーにはPythonの簡易サーバー(CGIHTTPServer)を使用(クライアントPC1台でも完結させたいため)
- 見栄えはcssで後からどうにかする
- データもコードもクラウドストレージと同期させる(データの管理を楽にするため)
実装
SAMPLEを利用する
一から書くと大変なのであちこちからサンプルを参考(コピペしてカスタマイズ)して使っていますが、どこから持ってきたのか覚えて居ない……。
完成したのがこんなコード(cssはフォームの表示を整える為にあります)
※元のライセンスがMITライセンスなのでMITライセンスになると思います。
HTML
wordedit.html
<!DOCTYPE HTML>
<meta http-equiv="Content-Type" content="text/html;charset=UTF-8">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
<title>編集</title>
<link rel="stylesheet" href="./lib/slickgird/slick.grid.css" type="text/css"/>
<link rel="stylesheet" href="./lib/slickgird/css/smoothness/jquery-ui-1.8.16.custom.css" type="text/css"/>
<link rel="stylesheet" href="./serch.css" type="text/css">
<!---
<link rel="stylesheet" href="examples.css" type="text/css"/>
-->
<!-- from SlickGrid example Spread Sheet -->
<style>
.slick-cell.copied {
background: blue;
background: rgba(0, 0, 255, 0.2);
}
</style>
</head>
<body>
<div id = "header">
<div class = "header_text">絞り込み:<input type="text" id="word" class="textbox" size="10" onkeydown="CreateSpreadSheet(document.getElementById('word').value);"></div>
</div>
<div style="position:relative 1;top: 200px;">
<div style="width:100%;">
<div id="myGrid" style="height:800px;"></div>
</div>
<script src="./lib/slickgird/lib/firebugx.js"></script>
<script src="./lib/slickgird/lib/jquery-1.7.min.js"></script>
<script src="./lib/slickgird/lib/jquery-ui-1.8.16.custom.min.js"></script>
<script src="./lib/slickgird/lib/jquery.event.drag-2.2.js"></script>
<script src="./lib/slickgird/slick.core.js"></script>
<script src="./lib/slickgird/plugins/slick.autotooltips.js"></script>
<script src="./lib/slickgird/plugins/slick.cellrangedecorator.js"></script>
<script src="./lib/slickgird/plugins/slick.cellrangeselector.js"></script>
<script src="./lib/slickgird/plugins/slick.cellcopymanager.js"></script>
<script src="./lib/slickgird/plugins/slick.cellselectionmodel.js"></script>
<script src="./lib/slickgird/slick.editors.js"></script>
<script src="./lib/slickgird/slick.grid.js"></script>
<script>
//検索APIの指定
const searchapi = './api/v1/search';
//更新APIの指定
const updateapi = './api/v1/update';
var grid;
var data = [];
var options = {
editable: true,
enableAddRow: true,
enableCellNavigation: true,
asyncEditorLoading: true,
autoEdit: true
};
var columns = [
{
id: "selector",
name: "",
field: "num",
width: 60
}
];
fieldnames = ['単語','読み','コメント']; //フィールド名を指定
fieldids = ['word','read','comment']; //フィールドIDを指定
//上二つの数は一致すること
for (var i = 0; i < fieldnames.length; i++) {
columns.push({
id: fieldids[i],
name: fieldnames[i],
field: fieldids[i],
width: 120,
editor: FormulaEditor
});
}
/***
* A proof-of-concept cell editor with Excel-like range selection and insertion.
*/
function FormulaEditor(args) {
var _self = this;
var _editor = new Slick.Editors.Text(args);
var _selector;
$.extend(this, _editor);
function init() {
// register a plugin to select a range and append it to the textbox
// since events are fired in reverse order (most recently added are executed first),
// this will override other plugins like moverows or selection model and will
// not require the grid to not be in the edit mode
_selector = new Slick.CellRangeSelector();
_selector.onCellRangeSelected.subscribe(_self.handleCellRangeSelected);
args.grid.registerPlugin(_selector);
}
this.destroy = function () {
_selector.onCellRangeSelected.unsubscribe(_self.handleCellRangeSelected);
grid.unregisterPlugin(_selector);
_editor.destroy();
};
this.handleCellRangeSelected = function (e, args) {
_editor.setValue(
_editor.getValue() +
grid.getColumns()[args.range.fromCell].name +
args.range.fromRow +
":" +
grid.getColumns()[args.range.toCell].name +
args.range.toRow
);
};
init();
}
$(function () {
CreateSpreadSheet('all');
});
function CreateSpreadSheet(opt){
let datacount = 0;
if(opt == 'all' || opt == '') {
opt = 'all=1';
} else {
opt = 'word=' + opt;
}
$.getJSON(searchapi,opt,function(data, status, xhr) {
datacount = data.length;
for( let i = 1 ;i <= data.length; i++){
let d = (data[i-1]);
d["num"] = i;
}
grid = new Slick.Grid("#myGrid", data, columns, options);
grid.setSelectionModel(new Slick.CellSelectionModel());
grid.registerPlugin(new Slick.AutoTooltips());
// set keyboard focus on the grid
grid.getCanvasNode().focus();
var copyManager = new Slick.CellCopyManager();
grid.registerPlugin(copyManager);
copyManager.onPasteCells.subscribe(function (e, args) {
if (args.from.length !== 1 || args.to.length !== 1) {
throw "This implementation only supports single range copy and paste operations";
}
var from = args.from[0];
var to = args.to[0];
var val;
for (var i = 0; i <= from.toRow - from.fromRow; i++) {
for (var j = 0; j <= from.toCell - from.fromCell; j++) {
if (i <= to.toRow - to.fromRow && j <= to.toCell - to.fromCell) {
val = data[from.fromRow + i][columns[from.fromCell + j].field];
data[to.fromRow + i][columns[to.fromCell + j].field] = val;
grid.invalidateRow(to.fromRow + i);
}
}
}
grid.render();
});
grid.onAddNewRow.subscribe(function (e, args) {
var item = args.item;
var column = args.column;
grid.invalidateRow(data.length);
data.push(item);
grid.updateRowCount();
grid.render();
});
let beforeRow = -1;
let beforeRowdata = {};
grid.onSelectedRowsChanged.subscribe(function (e, args) {
rownum = args.rows[0];
if(beforeRow >= 0 && beforeRow != rownum){ //編集行が変わったチェック
console.log(beforeRow);
rowdata = grid.getDataItem(beforeRow);
let a = JSON.stringify(beforeRowdata); // 編集前データ
let b = JSON.stringify(rowdata); // 編集後データ
if(a !== b ){ // 変更があるかチェック
console.log('edited!');
// 更新APIを呼び出す
//サンプルではJOINEDキーを使っているので更新前のデータを二つ送っている
rowdata["oldword"] = beforeRowdata["word"];
rowdata["oldread"] = beforeRowdata["read"];
let params = JSON.stringify(rowdata);
console.log(params);
//Application/JSONで送りたかったのですがPythonのCGIServerだと上手く取得できる方法が分からなかったのでのでx-www-form-urlencodedのdata='jsondata'でPOSTしています。
$.ajax({
type: 'POST', // method = "POST"
url: updateapi, // POST送信先のURL
data: "data=" + params, // JSONデータ本体
contentType: 'application/x-www-form-urlencoded', // リクエストの Content-Type
dataType: 'text',
success: function(json_data) { // 200 OK時
console.log(json_data);
},
error: function(json_data) {
console.log(json_data);
console.log('server error');
},
complete: function() { // 成功・失敗に関わらず通信が終了した際の処理
}
});
}
$.extend(true,beforeRowdata,grid.getDataItem(rownum));
} else if(beforeRow < 0){
$.extend(true,beforeRowdata,grid.getDataItem(rownum));
}
beforeRow = rownum;
});
});
}
</script>
</body>
</html>
Update APIとSearch APIを作成
コードがあまりに汚い。
テーブルスキーマ
drop table WordDictionary;
create table WordDictionary(
WORD text not null,
READ text not null,
comment text,
UNIQUE (WORD,READ)
);
Search API(JSONで検索結果を返す)
search
#!/usr/local/bin/python3
import urllib
import cgitb
import sqlite3
import json
from contextlib import closing
import sys
import configparser
import os
import os.path as path
#config.iniにはデータベースファイルの位置が入っている
#実行環境によって相対パスでは取得できないのでsearch APIのフォルダの場所を自動取得
#config.ini
#[setting]
#database=/path/too
inipath = path.join(path.dirname(sys.argv[0]),'config.ini')
inifile = configparser.ConfigParser()
inifile.read( inipath , 'UTF-8')
dbname = inifile.get('settings', 'database')
#cgitb.enable()
def query4Yomi(conn,yomi):
cur = conn.cursor()
yomi = '%' + yomi + '%'
return cur.execute('select * from WordDictionary where READ like ?', (yomi,))
def query4Word(conn,word):
cur = conn.cursor()
return cur.execute('select * from WordDictionary where WORD = ?', (word,))
def queryall(conn):
cur = conn.cursor()
return cur.execute('select * from WordDictionary order by READ')
def query4Any(conn,word):
cur = conn.cursor()
word = '%' + word + '%'
return cur.execute('select * from WordDictionary where READ like ? or WORD like ? or comment like ? order by READ', (word,word,word,))
with closing(sqlite3.connect(dbname)) as conn:
if 'QUERY_STRING' in os.environ:
query = urllib.parse.parse_qs(os.environ['QUERY_STRING'])
else:
query = {}
if ( 'word' in query ):
# results = query4Yomi(conn,query['word'][0])
results = query4Any(conn,query['word'][0])
elif ( 'all' in query ):
results = queryall(conn)
else:
results = {}
print ("Content-Type: application/json")
print ("")
print ('[')
line = 0
for row in results:
if (line !=0):
print (',')
print ('{')
print ('"word": "' + row[0] + '"')
print (',"read": "' + row[1] + '"')
print (',"comment": "' + row[2] + '"')
print ('}')
line += 1
print (']')
Update API(data='JSONDATA'でJSONDATAの内容をデータベースにアップ
update
#!/usr/local/bin/python3
import cgi
import cgitb
import sqlite3
import sys
import json
import configparser
import os
import os.path as path
#config.iniにはデータベースファイルの位置が入っている
#実行環境によって相対パスでは取得できないのでupdate APIのフォルダの場所を自動取得
#config.ini
#[setting]
#database=/path/too
inipath = path.join(path.dirname(sys.argv[0]),'config.ini')
inifile = configparser.ConfigParser()
inifile.read( inipath , 'UTF-8')
dbname = inifile.get('settings', 'database')
from contextlib import closing
def update(conn,param):
cur = conn.cursor()
cur.execute('update WordDictionary ' +
'SET WORD = ?, READ = ?, comment = ? WHERE WORD = ? and READ = ?',
(param['word'],param['read'],param['comment'],param['oldword'],param['oldread']))
conn.commit()
def query(conn,word,read):
cur = conn.cursor()
cur.execute('select WORD,READ from WordDictionary where WORD = ? and READ = ?', (word,read))
s = cur.fetchone()
try:
if (str(s[0]) == word and str(s[1]) == read):
return (1,s)
else:
return (0,None)
except:
pass
return (0,None)
def insert(conn,param):
cur = conn.cursor()
p ={'comment': ''}
for key in p.keys():
if(key in param):
p[key] = param[key]
cur.execute('insert INTO WordDictionary ' +
'VALUES (?, ?, ?)',
(param['word'],param['read'],p['comment'])
conn.commit()
with closing(sqlite3.connect(dbname)) as conn:
print ("Content-Type: application/json")
print ("")
# if (os.environ['REQUEST_METHOD'] != "POST"):
# print (str(os.environ['REQUEST_METHOD']))
form = cgi.FieldStorage()
q = json.loads(form['data'].value)
try:
(r,s0) = query(conn,q['oldword'],q['oldread'])
if(r != 1) :
insert(conn,q)
m = 'insert'
else:
update(conn,q)
m = 'update'
(r,s1) = query(conn,q['word'],q['read'])
results = {"result" : "200" , "method" : m}
print (results)
except:
import traceback as tr
s = tr.format_exc()
results = {"result" : "403" ,"trace" : s}
print (results)
簡易サーバー
cgiserver
#!/usr/local/bin/python3
# -*- coding: utf-8 -*-
import http.server
import threading
import webbrowser
import time
handler = http.server.CGIHTTPRequestHandler
handler.cgi_directories = ['/cgi-bin','/api']
http.server.test(HandlerClass=handler)
フォームも起動させようとするやつ
cgiserver.py
#!/usr/local/bin/python3
# -*- coding: utf-8 -*-
import http.server
import threading
import webbrowser
import time
url = 'http://localhost:8000/wordedit.html'
def openBrowser(url):
time.sleep(1)
print ("OPEN URL {}".format(url))
webbrowser.open(url)
#1秒待ってからブラウザを起動
try:
if __name__ == "__main__":
thread = threading.Thread(target=openBrowser,args=(url,))
thread.start()
except:
print ("Error: unable to start thread")
handler = http.server.CGIHTTPRequestHandler
handler.cgi_directories = ['/cgi-bin','/api']
http.server.test(HandlerClass=handler)
補足:Windowsでは起動前に以下の環境変数を設定しておかないと挙動不審になるので注意
set PYTHONIOENCODING=utf-8
結果
Excelより軽くて便利
課題
- 必要なJSがどれか全く分かっていない
- テーブルのメタデータからフィールド名を自動生成&自動処理可能にする(汎用化)
- 動作環境が変わった時に設定ファイルをいじらなくても動くようにする
- JavaScriptのコードをHTMLから分離
- 新規追加に少しトリッキーな操作を要求する(データベースでエラーが出た場合の処理が甘い)
- 一括更新