SlickGrid で遊んでみる

SlickGrid で遊んでみる:


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"/> 
--> 
  <!--  form 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) 
 


結果

Excelより軽くて便利


課題

  • 必要なJSがどれか全く分かっていない
  • テーブルのメタデータからフィールド名を自動生成&自動処理可能にする(汎用化)
  • 動作環境が変わった時に設定ファイルをいじらなくても動くようにする
  • JavaScriptのコードをHTMLから分離
  • 新規追加に少しトリッキーな操作を要求する(データベースでエラーが出た場合の処理が甘い)
  • 一括更新

コメント

このブログの人気の投稿

投稿時間:2021-06-17 22:08:45 RSSフィード2021-06-17 22:00 分まとめ(2089件)

投稿時間:2021-06-20 02:06:12 RSSフィード2021-06-20 02:00 分まとめ(3871件)

投稿時間:2021-06-17 05:05:34 RSSフィード2021-06-17 05:00 分まとめ(1274件)