Articles tagged with: kintone advent calendar

Cybozu Days 2022のブース出展でkintoneにメディアプレーヤーを設置しました。


kintone Advent Calendar 2022の20日目の記事です。

  Topへ↓

今年もこの季節がやってまいりました。Advent Calendarです。
11月のkintone月間が終わると、間髪を入れずに師走のAdvent Calendarです。
そのkintone月間に行われたのがCybozu Days 2022でした。弊社は3年連続のブース出展をしました。

弊社メンバーが書いた振り返りブログはこちら。
Cybozu Days 2022の今年のテーマはDX(デジタルトランスフォーメーション)でした。
Cybozu Days 2022を終えて[ブースで交流]
Cybozu Days 2022を終えて[出展までの準備]
Cybozu Days 2022を終えて[まとめ]

今年の弊社ブースは、昨年に続いてIoTとkintoneの連携をテーマに打ち出しました。
しかも今年はSORACOMさんのデバイスを中心に据え、さらに業種を絞ってみました。農業!

今年の弊社ブースは全部で五つの出展物を出していました。他の四つは、SORACOM UGではおなじみの前嶋さん(@anysonica)にご協力をお願いしました。
前嶋さんはSORACOM UGの今年のMVPにも選ばれたすごい方です。前嶋さんにお手伝いいただいた四つの出展内容については前嶋さんのブログをご覧いただくのが良さげです。

本稿ではソラカメの動画をkintoneの画面に出す。つまり、メディアプレーヤーの出し方に絞っています。
その実装はシンボ技研の山崎さん(@ryoyamazakiwork)にお願いしました!


これはCybozu Days 2022の弊社ブースの一コマです。動くソラカメの動画がkintoneに映る様子を興味深げに見ているのは瀧村さん(@kinbozuu)のお子さんたちです。とてもかわいらしかった。


2.完成したコード

  Topへ↑

実は、この出展で苦労したのは、ソラカメの動画を連携させる部分ではなく、動画をkintone内でストリーミング再生を行う実装です。そっちのほうが苦労しました。
本稿ではその部分を取り上げてみようと思います。
ただし、苦労したといっても、結果として落ち着いたコードのステップ数はとてもシンプルです。

たったこれだけ。

ただし、以下のライブラリをCDNから呼んでいます。
https://cdn.dashjs.org/latest/dash.all.min.js


3.コードの説明

  Topへ↑

上に挙げたコードを要約すると、
スペースフィールドに対してinnerHTMLでhtmlとして描画し、videoタグで動画を表示する領域を確保しています。あとはdashjsに備わっているオブジェクトやメソッドを呼び出します。
たったこれだけです。

    kintone.app.record.getSpaceElement('dash').innerHTML =
     '<video data-dashjs-player="" autoplay="" width="800" height="600" src=" + url + " controls="true"> ';

続いて読み込んであるdashjsのMediaPlayerオブジェクトのcreateメソッドを呼びます。

 const player = dashjs.MediaPlayer().create();

すると、ストリーミングプレーヤーが使えるように内部で設定されます。
あとはinitializeメソッドで描画するvideoタグや対象となるurlを呼び出せば、ストリーミングが呼び出せます。

 player.initialize(video, url, false);

この時、video要素はdocument.querySelectorを使って変数に格納しておきましょう。

 const video = document.querySelector('video');

すると、こんな感じでkintone上でメディアプレーヤーが使えるようになるのです。

4.dashjsの説明

  Topへ↑

このdashjsはメディアプレーヤーとしての機能をほぼ備えています。たとえば早送りや巻き戻し、テキストの埋め込みなど。
このdashjsを使えば、簡単にkintoneの各項目の値を使ったさまざまな動画へのアプローチが可能になると思われます。
例えば、IoTデバイスで得た動画を基に、動的にkintoneでアクションをつなげることができます。
また、オンラインミーティングをしながら、画面上でkintoneの画面から動的にアクションを起こすこともできるはずです。

dashjsのライブラリの中身やドキュメントは
https://github.com/Dash-Industry-Forum/dash.js
をご覧いただくことをお勧めします。

また、dashjsはBSDライセンスなので、そのライセンスに従っていればほぼ自由に使えます。
kintone内に組み込むことも自由。

5.kintoneでメディアを再生できればさらなる可能性が!

  Topへ↑

動画を起点としたアクション。それはkintoneに限らず、まだ他のシステムやプラットフォームでもそれほど盛んではないようです。(長井意見)
ということは、kintone上で動画を使った業務改善の手法が確立できれば、新たな業務改善の手法として脚光を浴びるかもしれません。
例えば作業マニュアルの動画をkintoneにアップし、その動画内のテキストテロップでコメントを入れる機能をkintone内で行ってしまうなど。
それは、kintoneの業務改善ツールとしての優位性を示す機会になりそうです。

Cybozu Days 2022では弊社にさまざまな可能性を開いてくれました。
IoTとkintoneの可能性。さらには、動画とkintoneのコラボレーションにおいても、
この記事では動画を撮影するためのデバイスにSORACOM社のソラカメを使っています。
ソラカメで撮った動画をkintoneに表示する実装。実はこの実装はすでに案件として進んでおり、来年度に着手することになりそうです。

そのソラカメから動画を取得し、kintoneにUrlとして連携する部分。
その実装については、明日アップする予定のSORACOM Advent Calendar 2022の記事内で書く予定です。
あわせてご覧いただければと思います。


ワーキングツリーにはkintoneとboxをお飾り!


kintone Advent Calendar 2019の24日目の記事です。

  Topへ↓

ん?この著者、この間もAdvent Calendarでみたで? はい。二度目の登場です。

kintoneは優れたツールですが完全ではない?
なーんてディスられても動じず、欠点を正直に認めるのもkintoneの憎めないところ。そう思ってやまない著者です。

kintoneの欠点のいくつかはすぐに挙げられます。
例えばブラウザーベースで動いているので、ファイルアップロードの作業が面倒、とか。
添付ファイルフィールドに画像データを放り込みまくると、一ユーザーあたり5GBの容量の制限が足かせになってくる、とか。

そんな限界を解消するためのささやかなクリスマスプレゼントを皆様にお届けしたいと思います。
なに、ちょっとした贈り物です。クリスマスツリーによくぶら下がっている箱のような。
箱・・・つまりboxです。
今回の記事では私の2019年の失敗事例も公開しているので、ひょっとしたら皆さまのご参考になるかもしれません。

box for kintoneのご紹介

  Topへ↑

世の中にオンラインストレージ製品はたくさんありますよね。その中でもboxが存在感を出しているのはご存じでしょうか。

kintoneとboxの連携はbox for kintoneというプラグインとして公開されています。
それを使えばブラウザー上でアップロードなどせず、ドラッグ&ドロップでブラウザー上から操作できちゃうのです。

クライアントツールのbox Driveをインストールすれば、Windowsのエクスプローラと同じ操作でbox上にファイルをアップロードできてしまう。なんて優れもの。

boxを使うと無尽蔵(契約プランによる)を誇る容量にファイルを置きまくり。
それをkintoneの画面上からに自由に呼び出せる。素晴らしい!
上に書いたkintoneの弱点を周囲のツールが補ってくれるいい例です。
本稿もそうしたkintoneを補ってくれる一つの例としてお役に立てれば幸いです。お日柄もよいので。

box for kintoneの使い方は、Cybozu developer networkにも出ています。
https://developer.cybozu.io/hc/ja/articles/205070124-Box-for-kintone その記事に従えば、簡単にbox for kintoneを導入できるはずです。

ところが、この記事で書かれているのは、一つのレコードに一つのフォルダーを対応させるところまで。
kintoneでちょっとしたシステムを作ろうとすれば複数アプリにまたがった構成が必要です。それに応じてboxのフォルダー構成も複数の階層にまたがってしまいます。

合同会社アクアビットダム設計なる会社

  Topへ↑

たとえば、大阪と東京に支店がある合同会社アクアビットダム設計があったとします。
この会社はダムを独自の技術で製造し、お客様にお納めしている設定です。

受注システムをkintoneで構築するにあたり、
大阪支店[組織]の
長井何某[個人]が担当する
担当案件[案件]の
施行状況[施工]と
湛水状況[湛水]を管理すると仮にしましょう(工程はしょりすぎ。ちなみに最後の工程は水を貯める工程です)。

他に顧客マスタがあるでしょうがここは割愛。また、[組織]と[個人]はアプリではなく、kintoneのアカウントを使用する想定です。
この場合3アプリですね。

ここでご注文からの流れをkintoneで管理したとしましょう。
各アプリの連動はkintoneのアクション機能を使ったとします。

ダム完成までにはさまざまな状況を報告していかねばなりません。するとダムの進捗に合わせて写真が大量に溜まっていきます。
kintoneの添付ファイルフィールドにファイルをアップしていると、すぐに容量が危うくなりかねません。
ここでboxの出番です。

ここでboxで写真を管理しようとした場合、box内のフォルダー構成はこのようになると思います。

さて、先ほどご紹介したbox for kintoneを思い出してみましょう。
プラグイン設定画面にルートフォルダーのIDを設定していましたね。

つまり、アプリ自体にルートフォルダーのみを作る仕様。
それって、どのレコードであろうと共通で1つのフォルダーだけ、、、
いやいや多層boxと多層アプリでは対応できないのはちょっと、、、
結論! box for kintoneだとちょっとキツイかも。

合同会社アクアビットダム設計にboxを

  Topへ↑

ということで、本稿では多段階にわたるboxの連動例をお伝えしたいと思います。また、その時にしでかしてしまった失敗と、そのリカバリ例もお伝えしたいと思います。

まず、話を簡単にするため、合同会社アクアビットダム設計としてのルートフォルダーを設定しておきましょう。

さらに、支店ごとにフォルダーを設定し、支店の配下に担当ごとのフォルダーも生成しておくと話が早いですね。

実際のboxのフォルダー構成はこんな感じ。

ここでルートフォルダーのフォルダーIDを取得しておきます。boxの画面から取れます。

その状態で、案件アプリに新規レコードを登録します。
案件アプリの項目には案件の主管支店と、案件の主担当を指定するフィールドも忘れずに。もちろん必須項目として。

なぜ必須項目にするのでしょう。
その理由は、レコードが保存成功後、案件フォルダーを作る際にどこのフォルダーの配下に作成するか決めなければならないためです。

boxのフォルダー生成APIについて

  Topへ↑

ここでboxの仕様を押さえておきましょうか。
サービスの仕様を確認するには、APIから逆引きしたほうが理解しやすい。いわゆる技術者あるあるです。
boxのAPIはこちらのサイトをご覧になると良いでしょう。
https://ja.developer.box.com ・・developerサイトトップ
https://ja.developer.box.com/reference ・・APIレファレンス

boxのフォルダー作成の項を読むと、親であるフォルダーのIDがパラメーターとして必須のようです。
編集時には親フォルダーのIDは必須ではなくなりますが、もしフォルダーの場所を移動する際は親フォルダーのIDは指定しなければなりません。

案件フォルダーを作る際は、親となる担当者フォルダーのIDを把握しておかねばなりません。
そしてその上の支店フォルダーも。

つまり、案件レコードの保存のタイミングで行うべきことは、まず、そのレコードの支店フィールドの値に等しい支店フォルダーを検索することです。
その際、基準となるのはルートフォルダーです。

ルートフォルダーの下にある支店フォルダーを検索し、そのIDを特定します。
間髪入れずに支店フォルダーの配下にある担当者フォルダーを検索します。
これは同一担当者が複数支店にフォルダーを持っている場合など、運用も考慮していますが、支店フォルダーのIDを内部で保持できるのであれば、いきなり担当者フォルダーから検索してもよいです。
重要なのは案件フォルダーを作成するにはその親フォルダーのIDを事前に必ず保持しておくことです。

先ほど、kintoneの案件アプリの支店と担当者の両フィールドは必須でなければならないとしたのには、そういう理由があったのです。
これら二つのフィールドの値がないと、案件を保存する際に生成されるべき案件フォルダーの保存先が迷子になってしまうので。

JavaScriptで実装してみた

  Topへ↑

続いてはいよいよboxの操作を行います。

その前に本稿ではboxの権限周りには踏み込まないことを言っておきます。
OAuthについては、もともとbox  for  kintoneで用意されていたclient IDを使用します。本当はbox内でアプリを作成し、そのアプリ内で設定した権限を認証しなければならないのですが。
box for kintoneに甘えてしまいましょう。

ついでにpromise処理が考慮されたAPI実行部分もbox for kintoneの処理を流用させていただきましょう。

処理の大まかな順序としては以下の通りです。
ただ、のちに述べますが、このコードは動きません。なのでコードは画像として参考程度に載せます。

まず、イベントが動くタイミングはapp.record.create.submit.successです。新規作成処理成功後ですね。

処理の都合上、この中で別のアプリに更新を行い、その結果が成功した場合にboxフォルダー生成処理を呼び出しています。

boxフォルダー生成処理では、まずルートフォルダーから支店フォルダーを検索します。
続いて支店フォルダーから担当者フォルダーを検索します。

ここでboxの検索の仕様が立ちふさがってきます。
boxの検索仕様として、対象の種類、生成時刻、オーナーIDや親フォルダーIDなどは指定できるのですが、肝心の文字列を完全一致で検索できないのです。queryというパラメータがあるにもかかわらず、そこに指定した文字列は曖昧検索として処理されてしまうのです。
APIレファレンス

つまり、親フォルダーに属する検索対象が複数ありうる場合、検索文字列に工夫が必要です。例えば姓名の間にスペースが入る場合など。
「長井 権兵衛」と「長井 主水」が対象のフォルダー配下にあって「長井 権兵衛」を検索したい場合、queryに「長井 権兵衛」を設定してもマッチしません。
ではどうやればよいか。
スペースの前後の文字列で検索するのです。
この場合、「長井」または「権兵衛」で検索します。すると前者は二件がヒットし、後者は一件がヒットします。
その結果を再度ループして回し、一件ごとにname属性の値が検索文字列に一致するかを確認する。
そのような面倒な処理がboxの検索には必要です。

このコードも実際は使っていませんが、軽く提示します。

これで、担当者フォルダーIDまで求められました。

boxのフォルダー生成と検索にまつわる問題

  Topへ↑

続いてはフォルダーの生成に移りましょう!
APIレファレンス

生成にあたっては、名前と親フォルダーのIDを指定するだけです。

これで、案件レコードが保存されたら案件フォルダーを作成するところまでができました。

ここで当初想定していたboxの構成を見てみましょう。
案件フォルダーの配下に「提案状況」「施工状況」「湛水状況」の三フォルダーがあります。
このフォルダーの生成にも実は厄介な問題が潜んでいます。

例えば、案件のレコードが保存されました。そして案件フォルダーが生成されました。
そしたら、アクション機能によって施工状況アプリにレコードをコピーし、施工状況アプリでもレコードが保存された瞬間、案件レコードの時と同じように施工状況フォルダーを生成すればええんちゃうの?と思ったでしょう。

ところが、フォルダーを生成するには親のフォルダーの指定が必須です。
親フォルダー、つまり案件フォルダーをフォルダー生成処理の直前で検索してフォルダーIDを取得しなければなりません。
ところがこの親フォルダーの検索取得にはひとつハードルが控えています。そのハードルとは、コンテンツが生成されてから検索可能となるまでに時間がかかる、というものです。
boxはなんらかのコンテンツが作成されてから、それが検索可能となるまでにbox内部でindexを構築しており、それに時間が掛かるのです。
boxの APIレファレンスには以下のように書かれています。

つまり、案件フォルダーの生成からすぐ、施工状況アプリのレコードを保存した場合、親となるべきフォルダーが検索できないため、親フォルダーの指定ができないのです。

boxを多層構造でkintoneと連動させる場合、この仕様上の制限は現状では避けられません。

この制限を回避するため、発想を切り替えました。
つまり、案件フォルダーが生成された後、同時に配下のフォルダーも作ってしまうのです。

box APIでは、フォルダー生成が成功した時点で返り値として生成されたフォルダーのIDが得られます。このIDを使えば配下のフォルダーも即時に生成できます。

このコードも実際は使っていませんが、軽く提示します。

この下の処理ではさらに生成した案件フォルダーのURLを取得し、そのURLやフォルダーIDをkintoneの2アプリに更新して設定しています。

ここまででkintoneのapp.record.create.submit.successイベントを見てきました。その結果、実装ができそうです。
テストでもフォルダーが意図通りに生成されました。開発用のPCでも、お客様のご担当者様のPCでも。

バグ大魔王降臨!

  Topへ↑

ところが! やったと思った安心のかげに潜むのが落とし穴。バグが出てしまったのです。
テストではうまく動いていたのに、いざ本番になるとうまくいかない。なんということでしょう!

実は、その根本的な原因は今もなお究明できていません。
事象としてはboxにAPIリクエストを投げた後、何も戻ってこないのにプログラムが終了してしまいます。httpレスポンスすら帰ってこずに。
それも終了する場所がまちまちなのが始末が悪い。複雑なPromiseの構造に加え、referredを混在させたことにも問題があったのかもしれません。
この不具合がやっかいなのは、boxからのレスポンスを待つ間、app.record.create.submit.successの結果が完了できないことにあります。その間、ブラウザーは固まってしまい、kintoneを利用されている皆様にはただ困惑が。

そして、この不具合の原因がブラウザーにあるのか、box側にあるのか、kintone内部にあるのか、それともPCのスペックにあるのか。はたまたネットワーク環境によるものなのか。いまだに分っていません。
ただ、ブラウザー上でレスポンスを待つ運用はまずい、という悔いだけは骨身に沁みました。
私はその原因を追究するよりもお客様の運用を円滑に進めることを優先しました。
その決断として、ブラウザーに依存する実装を止めました。

AWSへ処理を移管

  Topへ↑

では、どうすればよいか。

幸いなことにkintoneにはWebhookという機能が備わっています。Webhookには、レコード保存時にWebhookのリクエストを受け付けてくれるWebhook URLを設定できます。

私がWebhook URLとして設定したのはAWSのAPI Gatewayで設定したURLでした。
API Gatewayについての説明は割愛しますが、kintoneから受け取ったWebhookのリクエストに含まれるJSONを読み取り、それを後続の処理に渡すことができます。
後続の処理にはAWS Lambdaを選びましたので、同じAWS上で処理が連携できます。

AWS LambdaではNode.jsを使い、ほぼkintoneのkintone.app.create.submit.successで実装したのに近いコーディングを行いました。
box Node SDKがAWS Lambdaから簡単に使用できるので、それを使えば似たような実装ができるのです。
ただし、boxのアプリは一から作る必要があります。設定もあれこれ行う必要が生じました。
最初、こちらのブログの力も借りました。ありがとうございました。
Lambda関数のコードを以下に掲示します。なお、このコードは動いているものを基にいろいろといじっているので参考になると思います。

/**
 * This sample demonstrates how to call Box APIs from a Lambda function using the Box Node SDK.
 *
 * For step-by-step instructions on how to create and authorize a Box application,
 * see https://github.com/box/samples/tree/master/box-node-lambda-sample.
 */
const BoxSDK = require('box-node-sdk');                                                // Node.jsのbox-node-sdkモジュールを呼び出す
const request = require('request');                                                    // Node.jsのrequestモジュールを呼び出す
const boxConfig = JSON.parse(process.env.BOX_CONFIG);                                  // AWS Lambdaの環境変数のBOX_CONFIGの値をJSONで扱えるように

boxConfig.boxAppSettings.appAuth.keyID = boxConfig.boxAppSettings.appAuth.publicKeyID; // 9行目で取り出したkeyIDにpublicKeyIDを代入

const sdk = new BoxSDK(boxConfig.boxAppSettings);                                      // 9行目で取り出したboxAppSettingsをsdkに代入

/**
 * Create a service account client that performs actions in the context of the specified
 * enterprise.  The app has a unique service account in each enterprise that authorizes the app.
 * The service account contains any app-specific content for that enterprise.
 * Depending on the scopes selected, it can also create and manage app users or managed users
 * in that enterprise.
 *
 * The client will automatically create and refresh the service account access token, as needed.
 */
const client = sdk.getAppAuthClient('enterprise', boxConfig.enterpriseID);             // boxアプリが適用できるアカウントのグローバル設定を管理

var DOMAIN = '*********.cybozu.com'; //kintone環境のドメイン                            // *****はご使用のkintoneのサブドメインを
var APP_ID_1287 = 1287;   //案件管理アプリのアプリID
var BASE_URL = "https://" + DOMAIN + '/k/v1/';
var APITOKEN_1287 =  "kintoonkaramottekitatookunwokokoniiretene";                      // kintoneの案件アプリのAPIトークン
var headers_1287 = {'X-Cybozu-API-Token': APITOKEN_1287};                              // リクエストで使用するヘッダ
var FolderId;
var updaterecordid_1287;

exports.handler = (event, context, callback) => {                                      // eventはkintoneのWebhookからAPI Gatewayを経由したレコード情報
                                                                                       // contextはLambda関数に関する情報
    const API_BASE_PATH = 'https://api.box.com/2.0';                                   // box Node SDKの文法に準拠

    // targetnameはコンテンツの文字列
    // typeはコンテンツの対象。本稿の場合はfolder
    // content_typesは検索対象とするプロパティ。本稿の場合はname
    // limitは検索結果として戻す件数。
    // idsは親フォルダーのフォルダーID
    // methodは本稿では全てGETなので使用していない
    // dataは本稿の場合検索対象(支店,担当者,案件No)のうち、担当者の場合["担当名"]で渡ってくる。
    function searchFolder(targetname, type, content_types, limit, ids, method, data, success, error) {  //157,159,161行目から呼び出されて検索処理を実施
        if (data !== undefined) {                                 // dataが指定されている場合
            if (data[0] === "担当名") {                            // dataの配列の最初の要素が"担当名"の場合
                targetname = targetname.split(' ')[0];           // 受け取るtargetnameは「長井 権兵衛」の様に全角スペースで区切られた姓名なので姓を取得
            }
        }

        return new Promise(function (resolve, reject) {           // Promiseを設定
            client.search.query(                                  // 24行目でclientとして承認されたbox Node SDKのsearchクラスのquery関数を呼び出し
                "\"" + targetname + "\"",                         // 最初のパラメーターは検索対象文字列。文字列なのでエスケープした""で囲む。でも曖昧検索
                {                                                 
                    fields: 'id,name,modified_at,extension,permissions,collections',  //検索結果として返すコンテンツのプロパティ
                    type: type,                                                       //folder
                    content_types: content_types,                                     //検索対象はnameプロパティ
                    limit: limit,                                                     //結果として返す件数
                    ancestor_folder_ids: ids,                                         //親フォルダーID 
                    offset: 0                                                         //オフセットしないので0
                })
                .then(function(results){                                              //結果が取得されたのでこのPromiseチェーンへ
                    if (data !== undefined) {                                         //dataが指定されている場合
                        if (data[0] === "担当名") {                                   //dataの配列の最初の要素が"担当名"の場合
                            for (var i = 0; i < results.entries.length; i++) {        //戻り値の件数分(limitで指定した件数分)
                                if (results.entries[i].name === data[1]) {            //戻り値のnameプロパティがdataの2番目の要素(担当名)か
                                    resolve(results.entries[i].id);                   //Promiseは完了したと戻り値のidプロパティ(フォルダーID)を返す
                                }
                            }
                        }
                    } else {
                        resolve(results.entries[0].id);                               //Promiseは完了したと戻り値のidプロパティ(フォルダーID)を返す
                    }
                })
                .catch(function(error){ // エラーの場合
                    reject(error);
                });
        });
    }

    // createParamは生成フォルダー名と親フォルダーIDが含まれたJSONオブジェクト
    function postFolder(createParam) {                     //176行目から呼び出されてフォルダー生成処理を実施
        return new Promise(function (resolve, reject) {    // Promiseを設定
            client.folders.create(createParam.parent.id, createParam.name)  //24行目でclientとして承認されたbox Node SDKのfolderクラスのcreate関数を呼出
                                                                            //1つ目は親フォルダーID、2つ目は生成するフォルダーの名称 
                .then(function(results){                                    //85行目の処理が成功したのでこのPromiseチェーンへ
                    var ankenid = results.id;                               //生成したフォルダーIDを以下のforeach内で使うためにankenidに代入 
                    var subfolders = [                                      //生成した案件フォルダーの配下に作成する三つのフォルダー名を配列にしています
                        "提案資料",
                        "施工状況",
                        "湛水状況"
                    ];

                    var promiseset = [];                                    //三つのフォルダーの生成が終わるまで待つPromiseを三つ作るので配列を設定
                    subfolders.forEach(function(val,index,ar){              //89行目で生成した配列の各要素をループします
                        promiseset[index] = new Promise( function( resolve, reject ) {  //95行目で生成した配列にPromiseを設定します。
                            client.folders.create(ankenid, val)             //24行目でclientとして承認されたbox Node SDKのfolderクラスのcreate関数を呼出 
                                                                            //1つ目は親フォルダーID(案件フォルダー)、2つ目は生成するサブフォルダーの名称
                                .then(function(results){                    //98行目の処理が成功したのでこのPromiseチェーンへ
                                    resolve(results.id);                    //97行目のPromiseは完了したと戻り値のidプロパティ(フォルダーID)を返す
                                }).catch(function(error){                   //98行目の処理が失敗したのでこのPromiseチェーンへ
                                    reject(error);                          //97行目のPromiseは失敗したとエラーオブジェクトを返す
                                });
                        });
                    });
                    Promise.all( promiseset )                               //97行目で設定した三つのPromiseが全て完了したらここに来る
                        .then( function ( message ) {
                        resolve(ankenid);                                   //84行目のPromiseは完了したと戻り値のidプロパティ(フォルダーID)を返す
                    })
                        .catch( function ( reason ) {                       //97行目で設定した三つのPromiseのどれかが失敗したらここに来る
                            console.log( reason ) ; // "失敗!!"
                        reject(false);                                      //84行目のPromiseは失敗したとエラーオブジェクトを返す
                    });
                })
                .catch(function(error){                                     //85行目の処理は失敗したらここに来る
                    reject(error);                                          //84行目のPromiseは失敗したとエラーオブジェクトを返す
                });
        });
    }

    // boxフォルダーIDは更新対象となるフォルダーID
    // createParamは更新フォルダー名と親フォルダーIDが含まれたJSONオブジェクト
    function putFolder(boxフォルダーID, updateParam) {             //165行目から呼び出されてフォルダー更新処理を実施
        return new Promise(function (resolve, reject) {         // Promiseを設定
            client.folders.update(boxフォルダーID, updateParam)    //24行目でclientとして承認されたbox Node SDKのfolderクラスのupdate関数を呼出
                                                                //1つ目は対象となるフォルダーID、2つ目は更新するフォルダー情報の含まれたJSONオブジェクト 
                .then(function(results){                        //126行目の処理が成功したのでこのPromiseチェーンへ
                    resolve(results.id);                        //125行目のPromiseは完了したと戻り値のidプロパティ(フォルダーID)を返す
                })
                .catch(function(error){                         //126行目の処理が失敗したのでこのPromiseチェーンへ
                    reject(error);                              //125行目のPromiseは失敗したとエラーオブジェクトを返す
                });
        });
    }

    function getFolderURL(createdid) {                          //178行目から呼び出されてフォルダーの共有処理を実施
        return new Promise(function (resolve, reject) {         // Promiseを設定
            client.folders.update(createdid, {shared_link: client.accessLevels.OPEN}) 
                                                                //24行目でclientとして承認されたbox Node SDKのfolderクラスのupdate関数を呼出
                                                                //1つ目は対象となるフォルダーID、2つ目は更新するフォルダーのプロパティ(共有設定)
                .then(function(results){                        //139行目の処理が成功したのでこのPromiseチェーンへ
                    resolve(results.shared_link.url);           //138行目のPromiseは完了したと戻り値の共有URLプロパティ(リンクURL)を返す
                })
                .catch(function(error){                         //139行目の処理が失敗したのでこのPromiseチェーンへ
                    reject(error);                              //138行目のPromiseは失敗したとエラーオブジェクトを返す
                });
        });
    }
    function createBoxFolder(支店, 担当者, 案件No, boxフォルダーID) {  //212行目から呼び出されてフォルダーの共有処理を実施
        return new Promise(function (resolve,reject) {             // Promiseを設定
            var rootfolder = "12345678910";                        //boxのフォルダー制御のルートとなるフォルダーのフォルダーIDを静的に代入
            var ownerbranchfolder;                                 //支店フォルダーのフォルダーID
            var personinchargefolder;                              //担当者フォルダーのフォルダーID
            var createParam;                                       //searchFolder関数へはダミーオブジェクト。putfolderとpostfolderへはJSONオブジェクト

            searchFolder(支店, "folder", "name", 10, rootfolder, 'GET', createParam).then(function (branchfolderid) {  //45行目へ
                ownerbranchfolder = branchfolderid;                           //searchFolderからの返り値を上位スコープのownerbranchfolderへ代入
                searchFolder(担当者, "folder", "name", 10, ownerbranchfolder, 'GET', ["担当名",担当者]).then(function (personfolderid) {  //45行目へ
                    personinchargefolder = personfolderid;                    //searchFolderからの返り値を上位スコープのpersoninchargefolderへ代入
                    searchFolder(案件No, "folder", "name", 10, personinchargefolder, 'GET', createParam).then(function (projectfolderid) {  //45行目へ
                        var name = "案件No" + " " + 案件No;                    //生成/更新する案件フォルダーの名称を設定する
                        createParam = {name: name, parent: {id: personinchargefolder}};  //案件フォルダーの設定情報をJSONオブジェクトに組み立てる
                        if (projectfolderid.length > 0) {                     //161行目で案件フォルダーが存在した場合(フォルダー情報更新)
                            putFolder(projectfolderid, createParam).then(function (updatedid) {  //124行目へ
                                FolderId = updatedid;                         //161行目の処理で得た更新したフォルダーIDをスコープ外の168行で使うため
                                getFolderURL(updatedid).then(function (updatedurl) {             //137行目へ
                                    resolve(FolderId+"****"+updatedurl);      //151行目のPromise完了をフォルダーIDと共有URLプロパティ(リンクURL)で返す
                                }).catch(function(error){                     //167行目の処理が失敗したのでこのPromiseチェーンへ
                                    reject(error);                            //151行目のPromiseは失敗したとエラーオブジェクトを返す
                                });
                            }).catch(function(error){                         //165行目の処理が失敗したのでこのPromiseチェーンへ
                                reject(error);                                //151行目のPromiseは失敗したとエラーオブジェクトを返す
                            });
                        } else {
                            postFolder(createParam).then(function (createdid) {       //83行目へ
                                FolderId = createdid;                                 //生成したフォルダーIDを以下の179行目で使うためにFolderIdに代入
                                getFolderURL(createdid).then(function (createdurl) {  //137行目へ
                                    resolve(FolderId+"****"+createdurl);      //151行目のPromise完了をフォルダーIDと共有URLプロパティ(リンクURL)で返す
                                }).catch(function(error){                             //178行目の処理が失敗したのでこのPromiseチェーンへ
                                    reject(error);                                    //151行目のPromiseは失敗したとエラーオブジェクトを返す
                                });
                            }, function(res) {                                        //176行目のフォルダー生成処理でrejectレスポンスが返った場合
                                if (res.status && res.status === 409) {               //176行目のフォルダー生成処理でrejectレスポンスが409返った場合
                                    if (res.context_info                              //176行目のフォルダー生成処理でrejectレスポンスが競合を示した場合
                                        && res.context_info.conflicts
                                        && res.context_info.conflicts.length > 0) {
                                        return;                                       //150行目のcreateBoxFolder関数を終える
                                    }
                                }
                            }).catch(function(error){                                 //176行目のフォルダー生成処理でエラーが帰った場合
                                reject(error);                                        //151行目のPromiseは失敗したとエラーオブジェクトを返す
                            });
                        }
                    }).catch(function (error) {                                       //161行目のフォルダー検索処理でエラーが帰った場合
                        // 非同期処理失敗。呼ばれない
                        console.log(error);
                    });
                }).catch(function (error) {                                           //159行目のフォルダー検索処理でエラーが帰った場合
                    // 非同期処理失敗。呼ばれない
                    console.log(error);
                });
            }).catch(function (error) {                                               //157行目のフォルダー検索処理でエラーが帰った場合
                // 非同期処理失敗。呼ばれない
                console.log(error);
            });
        });
    }

    var recordjson = JSON.parse(event.body);                                 //34行目で受け取ったkintoneのWebhookのレコード情報をJSON形式で扱えるように
    updaterecordid_1287 = recordjson.record.レコード番号.value;               //210行目のレコードデータの「レコード番号」フィールドの値を代入
    createBoxFolder(recordjson.record.支店.value[0].code,                    //150行目へ
                    recordjson.record.担当者.value, 
                    recordjson.record.案件No.value, 
                    recordjson.record.boxフォルダーID.value).then(function(idurl) {
        if (idurl) {                               //212行目のcreateBoxFolderの戻り値(168、179行目で値設定)
            var targetrecordids = [updaterecordid_1287+"**"+APP_ID_1287];   //211行目で設定したレコード番号と27行目で設定したアプリID
            var kintonepromiseset = [];                                     //kintoneのレコードアップデートが終わるまで待つPromiseの配列を設定
            targetrecordids.forEach(function(val,index,ar){                 //217行目で生成した配列の各要素(本稿では1つ)をループします
                kintonepromiseset[index] = new Promise( function( resolve, reject ) {  //218行目で生成した配列にPromiseを設定します。
                    var body_post = {                                                  //kintoneの既存案件アプリを更新するレコードを組み立てます。
                        app: val.split("**")[1],                                       //217行目で設定した配列の**で区切られた右側(アプリID)
                        id: val.split("**")[0],                                        //217行目で設定した配列の**で区切られた左側(レコード番号)
                        record: {
                            boxフォルダーID: {
                                value: idurl.split("****")[0]                       //212行目のcreateBoxFolderの戻り値の****で区切られた左のフォルダーID
                            },
                            表示: {
                                value: idurl.split("****")[1].replace("*******.box.com","app.box.com")
                                         //212行目のcreateBoxFolderの戻り値の****で区切られた右のURL(契約のboxのサブドメインをapp.box.comに置換の必要あり)
                            }
                        }
                    };
                    var options_getsalesamount = {                            //リクエストのbody部分を組み立てます。
                        url: BASE_URL + 'record.json',                        //28行目で設定したURLのルートと一行レコードの更新なのでrecord.jsonを連結
                        method: 'PUT',                                        //更新なのでPUT
                        headers: headers_1287,                                //30行目で設定したAPIトークン
                        'Content-Type': 'application/json',                   //リクエストのボディ部分のタイプ
                        json: body_post                                       //221行目で設定したボディ部分
                    }
                    //レコードを取得
                    request(options_getsalesamount, function (error, response, body) {    //Node.jsのrequestモジュールで234行のリクエストを送信
                        if (error) {                                                      //242行目の値がerrorだったら
                            console.log('Error: ' + error.message);
                            reject();                                                     //220行目のPromiseは失敗したとエラーオブジェクトを返す
                        }
                        console.log("kintone recordput:succcess"+val);
                        resolve();                                                        //220行目のPromise完了を返す
                    });
                });
            });
            Promise.all( kintonepromiseset )                                              //220行目で設定したPromiseが全て完了したらここに来る(本稿は1つ)
                .then( function ( message ) {                                             //252行目の処理が成功したのでこのPromiseチェーンへ
                    context.done(null, {text: "kintone POST and Box Folder Create success!"});  //Lambdaの処理結果をログとして残す
            })
                .catch( function ( reason ) {
                    context.done(null, {text: "Box Folder Create failed!"});              //Lambdaの処理結果としてエラーログ
                return;
            });
        } else {
            context.done(null, {text: "Box Folder Create failed!"});                      //Lambdaの処理結果としてエラーログ
        }
    }, function(res) {                                                                    //212行目の返り値がrejectで戻ってきた場合
        context.done(null, {text: "Box Folder Create failed!"});                          //Lambdaの処理結果としてエラーログ
        return false;                                                                     //212行目の結果としてfalseを返す
    });
};

 

なんとか実装

  Topへ↑

いずれにせよ、私が2019年に出した唯一の大きなバグがこれでした。
結局、バグが出てから実運用にこぎつけるまでにさらに二カ月ほどの時間をいただきました。お客様にも多大なご迷惑をおかけしてしまいました。

これが実装できたことで、案件アプリにレコードを登録した時点で、Webhookが発動し、AWS API GatewayからAWS Lambdaを介してboxへのフォルダー生成と、レコードに対応するboxのURLとフォルダー番号をkintoneの該当レコードに登録することができました。

kintoneの画面上にboxのフォルダーを出す部分はbox for kintoneの内部にも書かれている通りです。
実際それを使わせていただいています。ありがとうございます。
以下にコードを載せていますが、疲れてきたのでコード内のコメントは割愛します。ごめんなさい。

(function() {
    'use strict';

    var BOX_CLIENT_ID = 'wkgp4k64whsha8mwvg7k5k63cim82mmv';   //sample_plugin_default
    // localStorage
    var LOCAL_STORAGE_PREFIX = 'kintone.plugin.' + BOX_CLIENT_ID;
    var LOCAL_STORAGE_JUDGED_ALLOW_ACCESS = LOCAL_STORAGE_PREFIX + '.judgedAllowAccess';

    var config = [];

    var BOX_EMBED_WIDTH = 840;
    var BOX_EMBED_HEIGHT = 420;

    var getUrl = function(path) {
        var matchedGuestSpacePath = location.pathname.match(/^\/k\/(guest\/\d+\/)/);
        var guestSpacePath = '';
        if (matchedGuestSpacePath !== null && matchedGuestSpacePath.length === 2) {
            guestSpacePath = matchedGuestSpacePath[1]; // "guest//"
        }
        var apiPath = '/k/' + guestSpacePath + path;
        return apiPath;
    };

    var boxApi = {
        clientInfo: {'provider': 'box', 'client': BOX_CLIENT_ID},

        getAccessToken: function() {
            // add a hash parameter for distinguishing OAuth redirect
            var delimiter = (location.hash.indexOf('#') === 0) ? '&' : '#';
            location.hash += delimiter + BOX_CLIENT_ID + '.oauth_redirect=true';
            kintone.oauth.redirectToAuthenticate(this.clientInfo, location.href);
        },
        hasAccessToken: function() {
            return kintone.oauth.hasAccessToken(boxApi.clientInfo);
        }
    };

    var validateConfig = function(record) {
        config['folderId'] = '0';//Box親フォルダーID
        config['keyFld'] = '顧客名';//kintoneキーフィールド
        config['boxUrl'] = '表示';//Box共有リンクの格納先
        config['boxFolderId'] = "boxフォルダーID";
        config['access'] = 'Open';//Box共有リンクのアクセス権[Collaborator/Company/Open]
        config['prohibitToDownload'] = 'false';//コラボレータにのみダウンロードを許可する

        if (!config) {return false; }
        return true;
    };

    var decorateBoxLinkField = function(boxUrl) {

        var boxLinkPattern = /^https:\/\/([a-zA-Z0-9]+).box.(com|net)(\/s\/[a-z0-9]+)$/;
        var match = boxUrl.match(boxLinkPattern);
        if (!match) {
            return;
        }
        var iframeSrc =
            'https://app.box.com/embed_widget/000000000000' +
            match[3] +
            '?theme=gray' +
            '&show_parent_path=no' +
            '&show_item_feed_actions=no' +
            '&partner_id=233';

        var elEmbed = kintone.app.record.getFieldElement(config.boxUrl);
        if (elEmbed === null) {return; }
        $(elEmbed).empty();

        var width = BOX_EMBED_WIDTH;
        var height = BOX_EMBED_HEIGHT;

        $(elEmbed).parent().css({
            'width': (width + 100) + 'px',
            'height': 'auto',
            'background-color': 'rgba( 255, 255, 255, 0 )'
        });
        var embedIframe = $('', {
            src: iframeSrc,
            width: width,
            height: height,
            frameborder: '0',
            allowfullscreen: 'true',
            allowscriptaccess: 'always'
        });
        $(elEmbed).append(embedIframe);
    };

    var judgedAllowAccessFlag = {
        isSet: function() {
            return (localStorage.getItem(LOCAL_STORAGE_JUDGED_ALLOW_ACCESS) !== null);
        },

        set: function() {
            localStorage.setItem(LOCAL_STORAGE_JUDGED_ALLOW_ACCESS, 'true');
        },

        remove: function() {
            localStorage.removeItem(LOCAL_STORAGE_JUDGED_ALLOW_ACCESS);
        }
    };

    kintone.events.on('app.record.detail.show', function(e) {
        if (validateConfig(e.record)) {
            var boxUrl = e.record[config.boxUrl].value;
            if (!e.record[config.boxUrl].value) {

                var elEmbed = kintone.app.record.getFieldElement(config.boxUrl);
                if (elEmbed === null) {return null; }
                $(elEmbed).empty();

            } else {
                decorateBoxLinkField(boxUrl);
            }
        }

        return e;
    });

    var checkAccessToken = function() {
        var oauth_redirect_param = BOX_CLIENT_ID + '.oauth_redirect=true';
        if (location.hash.indexOf(oauth_redirect_param) !== -1) {
            judgedAllowAccessFlag.set();

            // remove a hash parameter
            location.hash = location.hash.replace(oauth_redirect_param, '');

            var t = setInterval(function() {
                if (location.hash.indexOf(oauth_redirect_param) !== -1) {
                    // cancel button was clicked
                    clearInterval(t);
                    location.href = getUrl(kintone.app.getId() + '/');
                }
            }, 500);
        } else if (!judgedAllowAccessFlag.isSet() || !boxApi.hasAccessToken()) {
            kintone.oauth.clearAccessToken(boxApi.clientInfo, function(body, status, headers) {
                boxApi.getAccessToken();
                return null;
            });
        }
    };

    kintone.events.on('app.record.create.show', function(e) {
        if (validateConfig(e.record)) {
            checkAccessToken();
            e.record[config.boxUrl]['disabled'] = true;
            e.record[config.boxFolderId]['disabled'] = true;
        }

        return e;
    });

    kintone.events.on('app.record.edit.show', function(e) {
        if (validateConfig(e.record)) {
            if (!e.record[config.boxUrl].value) {
                checkAccessToken();
            } else {
//                e.record[config.keyFld]['disabled'] = true;
                e.record[config.boxFolderId]['disabled'] = true;
            }
            e.record[config.boxUrl]['disabled'] = true;
        }
        return e;
    });

    kintone.events.on('app.record.index.edit.show', function(e) {
        if (validateConfig(e.record)) {
            e.record[config.boxUrl]['disabled'] = true;
            e.record[config.keyFld]['disabled'] = true;
            e.record[config.boxFolderId]['disabled'] = true;
        }
        return e;
    });
})();

 

 

まとめ

  Topへ↑

実案件ではさらに凝った実装(フォルダー数も階層も本稿の例よりさらに多い)が施されています。
そして、古くboxが設定されていないレコードには手作業がたまに発生しているものの、実運用に乗っています。
この記事ではそれ以上の情報を出すことはお客様の業務に関わるのでここまでにしとうございます。

本稿がkintoneを運用している皆様にとって少しの手助けになれば幸せです。

kintone上で大量の添付ファイルに困っていらっしゃる方や、社内ファイルサーバーからの移行でお困りの方。他のPaaSからkintoneへ移行する作業があって、添付ファイルの扱いにお困りの方。
弊社では本稿のようなboxとkintoneの連動事例を何例も手掛けております。お困りの際はおっしゃってくださいませ。

最後に蛇足ですが、boxの案件で例に挙げた三つのダムは、私が実際に訪れてダムカードを入手した場所です。


当エントリーの参考にさせていただいたブログ

  Topへ↑

最後になりましたが、このエントリー作成にあたり、以下の2サイトからの情報を参考にさせていただきました。ありがとうございました。

 box APIレファレンス
 AWS Lambda上でBox Node SDKを利用する-九龍堂雑録


コーチングのグラフってkintoneで出せるんやって!


kintone2 Advent Calendar 2019の5日目の記事です。

  Topへ↓

突然ですが皆さん、コーチングって聞いたことがありますか?
あっ! そこのあなた、ページはそのままに!
これは間違いなくkintone Advent Calendarの記事ですから。
ほら!

きとみちゃん楽しいですよね!
https://kintone.cybozu.co.jp/jp/kitomi/

日々、お仕事に励むきとみちゃん。
きとみちゃんとお仕事をする仲間はとっても個性が豊か。

ちょっぴりあわてんぼうでドジっ子のきとみちゃんがkintoneに救われる姿は微笑ましいです。
ちなみに私は巻物で見積書を出してくださる麻呂な方が好きです。この方のお名前はなんでおじゃる?

さて、きとみちゃんがお仕事をする上で助けになる手法はkintoneの他にもさまざまなものがあります。

その中の一つが冒頭に書いたコーチングなのです。

コーチングを一言で言い表すなら、
・相手の学習や成長、変化を促し、相手の潜在能力を解放させ、最大限に力を発揮させる。
でしょうか。

詳しくはWikipediaの「コーチング」
をご覧くださいませ。

ビジネスにフォーカスを当てたコーチングの歴史はまだまだ浅いです。
ここでお伝えしておかなければならないのは、自己啓発セミナーとは違う、ということです。

と、偉そうにウンチクを述べる私ですが、コーチングを受けた経験は人生で1,2回だけ。
では、そんな私がkintone Advent Calendarで何を語るというのでしょうか。

結論を先に書いちゃうと、kintoneでこんなグラフを作ってみましょう!
ということなんですね。

グラフとデータのご説明

  Topへ↑

上に登場したのは四つの傾向を円グラフにしたものです。
それぞれの傾向の文字列にマウスを合わせると、事前に登録しておいたキーワードが出てくる。
これ、実は以前、お客様に依頼されて作ったkintoneにChart.jsを組み込んだグラフ生成の仕掛けです。

私はコーチングには無知です。
ですから、kintoneに入力画面を作り、その結果を集計することで、設問に応じた四つの傾向が算出できる、ということを知ったときは新鮮でした。

お客様によれば、
相手をほめる場合の、個人に響くキーワードは4つの傾向に分けられる
だそうです。

それに合わせて、こんな入力画面を作ってみました。

仮に20問の設問としています。それぞれの4つの傾向ごとに5問を設問しました。
それぞれの問いごとに
・よく当てはまる
・当てはまる
・当てはまらない
・まったく当てはまらない
の4種類の答えをラジオボタンで設定しています。

もちろん、さらに設問数を増やすことも可能ですし、設問数を自在に増減させたい、というご要望もあるでしょうね。
その場合はサブテーブルを使えばよさそうです。
この記事ではサブテーブルではなく、20問に固定したバージョンでお届けしてみます!

実際の内容

  Topへ↑

はい。ではアプリの設定画面です。フォームはこんな感じ。

一番左の文字列フィールドは設問の文字列を入力します。
フィールドコードは上から順にquestion_1からquestion_20としています。

真ん中のドロップダウンフィールドは4つの傾向を選びます。
フィールドコードは上から順にtrend_1からtrend_20としています。

右のラジオボタンフィールドはそれぞれの答えを入力する欄です。
フィールドコードは上から順にanswer_1からanswer_20としています。

で、続いてはグラフを表示するカスタマイズビューを設定してみましょう。

こんな感じですね。

続いてはロジックです。
実は、このグラフを作るには以下の二つのJavaScriptファイルを設定するだけ。

上に設定したのは、Chart.jsです。
Cybozu Developer Network
からCDNのページに移動してもらえれば。

そこのChart.jsに書かれているURLをコピーし、上の画面の
から

に貼って保存するだけ! きとみちゃんでもできますよね?
htttps://がダブらないようにだけ気を付けて!

続いてグラフ表示のロジックです

  Topへ↑

では続いてきとみちゃんとグラフ.jsの内容を。
ここからはVisual Studio Codeの画面にコメントを入れています。




ちょっと見にくいので、直に貼ったコードも提供します。右にスクロールしてくださいね。

(function () {
  "use strict";

  // 一覧ページ
  kintone.events.on('app.record.index.show', function(event) {                       //一覧画面表示時の定型文です
    var record = event.records[0];
    var itemcount = 20;
    var 1_Score = 0;
    var 2_Score = 0;
    var 3_Score = 0;
    var 4_Score = 0;
    var selectedScore = 0;
    var dataLabelPlugin = {                                                          //ここは以下の162行目で呼び出されるチャートのプラグインコンフィグで呼び出される部分です。
      afterDatasetsDraw: function (Chart, easing) {                                  //afterDatasetsDrawとはプラグインコアAPIとして呼び出されるChart.js内部のフックです。要は描画後です。
        var ctx = Chart.ctx;                                                         //チャートが描画されている対象のDOM要素です。157行目で定義され、159行目でChartオブジェクトに渡されます。
        Chart.data.datasets.forEach(function (dataset, i) {                          //対象チャートをループしています。データは77行目で一種類で指定していますのでループは一回のみです。               
          var meta = Chart.getDatasetMeta(i);                                        //チャートのメタデータを取得しています。データやラベルも含まれています。
          if (!meta.hidden) {                                                        //チャートのhiddenプロパティがTrueの場合そもそもチャートが描画されません。
            meta.data.forEach(function (element, index) {                            //メタ要素のデータをループします。今回は4種類ですね。
              ctx.fillStyle = 'rgb(0, 0, 0)';                                        //円グラフの中の文字の色です。rgb(0, 0, 0)は黒を表しています。
              var fontSize = 16;                                                    //36-37行目で文字の場所を設定するためのフォントサイズを16pxで設定しています。表示フォントのサイズとは別に。
              ctx.font = "24px \"Helvetica Neue\", Helvetica, Arial, sans-serif";    //これが実際に描画される文字のフォント情報です

              var sum  = function(arr) {                                             //ここでは対象となるデータの合計値を返します。4種類のデータの合計です。
                  return arr.reduce(function(prev, current, i, arr) {
                      return prev+current;
                  });
              };
              var percentString = ((dataset.data[index] / sum(dataset.data))*100).toFixed(1) + "%";  //それぞれのデータの値を全体の合計で割り、パーセントの文字列を構築します。
              var dataString = Chart.data.labels[index];                                             //それぞれのデータのラベルです。79行目で定義した4つの傾向のラベルですね。 
              ctx.textAlign = 'center';
              ctx.textBaseline = 'middle';

              var padding = 5;
              var position = element.tooltipPosition();
              ctx.fillText(dataString, position.x, position.y - (fontSize / 2) - padding);           //30行目で設定したラベルの値を計算した位置に表示します。
              ctx.fillText(percentString, position.x, position.y - (fontSize / 2) - padding + 35);   //29行目で設定した値のパーセントの文字列を計算した位置に表示します。
            });
          }
        });
      }
    };
    for (var i=1 ; i<=itemcount ; i++){                                                              //ここから76行目までは大人の事情でいろいろとあいまいですがお許しを
      switch( record['answer_' + i]['value'] ) {                                                     //要するに20レコードの設問の答えを基に四つの傾向に加算しているのです
        case 'よく当てはまる':
          selectedScore = 係数は内緒よ♪;
          break;
        case '当てはまる':
          selectedScore = 係数は内緒よ♪;
          break;
        case '当てはまらない':
          selectedScore = 係数は内緒よ♪;
          break;
        case 'まったく当てはまらない':
          selectedScore = 係数は内緒よ♪;
          break;
      }
      switch( record['trend_' + i]['value'] ) {
        case '一つ目の傾向':
          1_Score = 1_Score + selectedScore + 山藤ゆりさんに教えてもらった魔法の値を加えるの♪;           //要するに20レコードの設問の答えを基に四つの傾向に重みづけしているのです
          break;
        case '二つ目の傾向':
          2_Score = 2_Score + selectedScore + 山藤ゆりさんに教えてもらった魔法の値を加えるの♪;
          break;
        case '三つ目の傾向':
          3_Score = 3_Score + selectedScore + 山藤ゆりさんに教えてもらった魔法の値を加えるの♪;
          break;
        case '四つ目の傾向':
          4_Score = 4_Score + selectedScore + 山藤ゆりさんに教えてもらった魔法の値を加えるの♪;
          break;
      }
    }
    1_Score = ロジック関数は内緒よ♪(1_Score);
    2_Score = ロジック関数は内緒よ♪(2_Score);
    3_Score = ロジック関数は内緒よ♪(3_Score);
    4_Score = ロジック関数は内緒よ♪(4_Score);                                          //さらに四つの傾向に値を秘密ロジックで精緻化しています。この辺も大人の事情が絡んでいます。
    var pieChartData = {                                                             //161行目でChartオブジェクトに渡されるデータとラベルと背景色のホバー色や枠の組み合わせです。四要素です。   
      labels : ["リーダー合理系","アイディア活動系","ヘルプ支援系","クール分析系"],       //ラベルですね。四つの要素に分かれています。
      datasets : [                                                                   //四つの要素のそれぞれの色の指定です。
        {
          backgroundColor: [
            '#ff6384',
            '#36a2eb',
            '#cc65fe',
            '#ffce56'
          ],
          hoverBackgroundColor: [
              "#FF2384",
              "#3662EB",
              "#cc25fe",
              "#FF8E56"
          ],
          hoverBorderColor: [
              "#000000",
              "#000000",
              "#000000",
              "#000000"
          ],
          hoverBorderWidth: [
              2,
              2,
              2,
              2
          ],
          data : [1_Score,2_Score,3_Score,4_Score]                                   //四つの要素の値です。大人の事情で実際の回答から複雑に計算された結果が格納されます。
        }
      ]
    }
    var tooltipkeyword = {                                                           //ここは四つの傾向ごとに176行目で乱数を設定し、任意のキーワードを表示するようにしています。
      type : [
        {
          word : [
            '同業者もあの人を噂している',
            '他の部署でも話題になっている',
            '○○さんしかできない',
            '自分で判断し、動ける人',
            '部署のメンバーに信頼されている',
            'あのひとには任せられる'
          ],
          title : "任せる、難題、未知の分野、他に頼めない、誰にもできない"
        },
        {
          word : [
            '発想がおもしろい!!',
            '一緒にいるだけで楽しい!!',
            'さすがアイデアマン!!',
            'すばらしいサービス精神!!',
            'うちの部署のムードメーカー!!',
            'その自由な発想がうらやましい!!'
          ],
          title : "自由にして、思いっきり、楽しく、面白く、みんなでいっしょ"
        },
        {
          word : [
            'みんなが働きに感謝している',
            '縁の下の力持ち',
            '一緒にいて落ち着く',
            '丁寧で親切で信頼できる',
            '細かいところによく気が付く',
            '相手の気持ちを分かってくれる'
          ],
          title : "感謝、ありがとう、仲良く、話し合い、相手の気持ち"
        },
        {
          word : [
            '詳しく業務を理解している',
            '商品のことをよく知っている',
            'わが社のことになんでも詳しい',
            'うちの課の歩く辞書',
            'あの人に聞けば間違いない',
            'このデータ量は大したもの'
          ],
          title : "情報、正確、正しく分析、予定通り、計画通り"
        }
      ]
    }
    var canvas = document.getElementById('canvas').getContext("2d");                 //Chartが描画されるDOM要素を指定するChart.jsの定型文です。id="canvas"はカスタマイズビューで指定しました。
    canvas.canvas.height = 256;                                                      //描画される領域の高さを指定しています。
    var test_chart = new Chart(canvas, {                                             //ここでChartオブジェクトをインスタンスとして実体化させています。
      type: 'pie',                                                                   //type: 'pie'はグラフの種類ですね。円グラフです。
      data: pieChartData,                                                            //77行目で定義したデータの実態です。
      plugins: [dataLabelPlugin],                                                    //プラグインコンフィグで関数を呼び出すことができます。その関数は13行目をご参照ください。
      options: {                                                                     //ここからはオプション情報です。
        animation: {
          animateRotate: true,
          animateScale: true
        },
        tooltips: {
          titleFontSize: 48,
          bodyFontSize: 36,
          callbacks: {
            label: function (tooltipItem, data){                                     //ここは描画後にマウスカーソルが乗った時の事前に内部でtooltipItemに定義された情報を基に値を返します。
                return pieChartData["datasets"][0]['data'][tooltipItem['index']] + "ポイント"       //77行目で定義されたデータから該当するデータを表示し
                  + "  キーワード → " + tooltipkeyword["type"][tooltipItem['index']]["title"];      //さらにキーワードとして109行目で定義された四つの傾向のタイトルを表示します。
            },
            afterLabel: function (tooltipItem, data){                                //172行目のラベルの後に別の情報を表示させるにはafterLabelツールチップコールバックが呼び出せます。
                  return "「" + tooltipkeyword["type"][tooltipItem['index']]["word"][Math.floor(Math.random()*(6-0)+0)] + "」";
            }                                                                        //さらにテキストとして109行目で定義された四つの傾向の文言のオブジェクトから乱数で選ばれた文言を表示します。
          }
        },
      }
    });
  });
})();

あとはこのJavaScriptファイルを

にのようにアップロードしていただければ。

どうでしょう。kintoneのデータにChart.jsを組み合わせるだけで、
kintoneのデータを分析することができてしまうのです。

Chart.jsにはさまざまなグラフが用意されているので、
kintoneの標準グラフでは表現できないことも可能です。

コーチング用の分析ツールとしても使えてしまうkintoneの奥深さを楽しんでいただけたらきとみちゃんも喜ぶはずです!
よかったら以下にChart.jsの公式サイトのリンクも貼っているのでご参考になさってくださいね。

当エントリの参考にさせていただいたブログ

  Topへ↑

最後になりましたが、このエントリ作成にあたり、以下の2サイトおよび、コーチングについて教えて頂いたお客様からの情報を参考にさせていただきました。ありがとうございました。

 Chart.jsドキュメント翻訳
 Chart.js公式サイト


ライフログのkintone盛り alasql仕込みのGoogle Chart添え


1.読まなくてもいい献立の前書き

  目次へ↓

唐突ですが皆さん、ライフログって言葉覚えてますか? 何!忘れた? そんな言葉あったっけ?

それならば説明しましょう!システムが行った作業の結果がログ。ウェブ上にログを残すからウェブログ。略してブログ。そして、人生のイベントを記録するのがライフログ。今やこの言葉は、数多くの新語とともにハードディスクの肥やしと成り果てています。

ところが、まだ忘れるのは早い!という訳で、kintoneでライフログです。

このライフログという概念は2000年代の初め頃に産声をあげていたといいます。ITジャーナリストの佐々木俊尚氏は、2010年の秋に発表した『キュレーションの時代』の中でライフログの概念は2010年代の間にはまだ根付かないだろう、と的確に予言されています。なぜなら、日本にはプライバシーの公開に気持ち悪さを持つ人が大多数だから、と。つまり、ライフログとはまだこれからの概念なのです。佐々木氏はその本の中で、ライフログを集めるツールとしてFourSquare_logoをプッシュしています。このFourSquare_logoですが、ある時期に仕様が迷走したこともあって日本ではすっかり影を潜めてしまいました。ですが、海外では位置とモノを関連付けたデータをがっちり握った企業として存在感を保っているようです。FourSquare_logoとは、一言でいうと訪問場所でチェックインすることで自分がいつどこにいたかを後世の自分、またはどこかの物好きのために記録しておくツールです。それはまさにライフログ。なお『キュレーションの時代』はかつて弊社のブログでも取り上げています。

kintoneは言うまでもなく優れたプラットホームサービスですし、仕事の改善にはテキメンに効きます。それはもう間違いない。でも、プライベートな使い方だってできるのですよ。ワークライフバランスを充実させるにはプライベートでもkintoneを使い倒したほうがいいと思いませんかみなさん!?

私にとって幸いなことにkintoneFourSquare_logoの連携事例はウェブにほぼ皆無です。そこは青い大海原、ブルーオーシャン!というわけでささやかですが、連携事例を公開したいと思います。あわせて、これもkintoneとの連携事例が極めて少ないGoogle Chartと絡めて。題して「ライフログのkintone盛り alasql仕込みのGoogle Chart添え」

2.ご用意いただく材料

  目次へ↑

ちなみにこのレシピの内容を再現せんと志す奇特な方は、以下のものをご用意ください。

  • FourSquare_logoのアカウント(チェックイン用のアプリSwarm_logoも忘れずに)
  • kintoneのスタンダードプラン
  • phpが動き、cronが実行できるサーバー(レンタルサーバーでも可)
  • Google Maps Platform上で動くライブラリを呼び出すためのAPI KEYの入手。こちらをご参考に

このレシピは2018年12月時点の「さくらのレンタルサーバー スタンダード」(phpは7.2.10(CGI版))で動作することを確認しております。
また、Google Maps Platformで動くAPIについては、月あたり40000リクエストまでが無料枠内だそうです。このレシピはプライベート利用なので、そこまでいかないことを念頭においています。
ちなみに、みんな大好き「zapier」でも、Swarm_logoでチェックインする度にkintoneに新たなレコードを追加できます。が、それをいっちゃぁおしめえよ、ということでお付き合いいただければ幸いです。
このレシピは一日単位でkintoneにライフログを一括で登録することに意義を持たせています。そして激動の一日を振り返りつつ、自分をねぎらいたい、そして昨日を忘れ去りたい「あ・な・た」の味方になることも!

なお、いうまでもなく、ライフログはみだりに公開するものではありませんぞ。このレシピのせいであなたのデイリーライフに何か問題が起こっても責任は取れませんのであしからず〜

3.献立の出来上がりイメージとレシピ

  目次へ↑

FourSquare_logoはAPIがOAUTH付きで公開されています。なので私の策略はこうです。まず、午前0時を過ぎたあたりでFourSquare_logoの前日分のチェックインデータをkintoneにシュっと放り込んでやろう。そして溜まったデータをウヒャヒャと一覧で眺めてみよう。ついでに今年、どんぐらい国や県を訪れたんやろう、と地図に出して一人ニヤけて悦にいる。うーん、ダークロースト。

まずは着地点をお見せします。上が世界の長井。下が日本の長井です。

世界の中心で長井を晒す

日本の中心で長井を晒す

ともにkintoneのカスタマイズビューではなく通常の一覧を使って長井を晒しています。世界地図は日本と台湾に色がついており、日本地図は東京が最も濃く、次に神奈川。データはFourSquare_logoSwarm_logo)でチェックインしたデータをphpでkintoneに取り込みます。地図を書き込むのは一覧画面のヘッダスペースです。そのため、世界地図版と日本地図版で一覧を分けています。

kintoneのアプリの構造はこんなんです。

フィールド名 フィールドコード
場所 場所 文字列(1行)
文字列(1行)
都道府県 都道府県 文字列(1行)
コメント コメント 文字列(1行)
日時 日時 日時

あと、アプリの設定画面で下図の3つのファイルを登録してください。
https://www.gstatic.com/charts/loader.js
とhttps://cdnjs.cloudflare.com/ajax/libs/alasql/0.4.11/alasql.min.js
はCDNから使います。最後のjsファイルはこの後説明します。
kintone_app

ここであらためて全体の構成図をご覧いただきましょう。一目瞭然とはこのことですね。

構成図

4.FourSquareの下ごしらえ

  目次へ↑

日々のチェックイン履歴をFourSquare_logoに溜める方法は、簡単に書くとこんな感じです。

  • Swarm_logoのアプリを立ち上げ、チェックインしたい場所でチェックインボタンを押す。
  • Swarm_logoの候補の一覧にその場所が登場します。
  • 登場しなくても検索すれば場所が指定できます。
  • 指定した場所でチェックイン(構成図の①)を行えば時間とともに自動的に記録(構成図の②)されます。気が向けばコメントも入れられます。

どうです?モンスターボールを投げなくてもよいし、ポータルを三角形で囲わなくてもよいのです。簡単ですよねFourSquare_logo

さて、今回の記事ではAccessTokenをFourSquare_logoから取得する処理(構成図の③)から説明します。AccessTokenはこのレシピの中で一度取得するだけです。なぜ一度だけでよいかというと、AccessTokenはユーザーを特定するのに使われるからです。つまり、一度AccessTokenを取得しておけば、何度でも使いまわしができるのです。FourSquare_logoには他にもたくさんのAPIが用意されています。処理によってはその都度AccessTokenを取らねばならず、ソースも複雑な下ごしらえが求められることもあります。ですが、このレシピではユーザーのチェックイン情報だけをとるのが目的なので、一度きりでよいのです。

5.FourSquareのAPI KeyとAPI Secretを取り寄せる

  目次へ↑

まず、https://ja.foursquare.com/にアクセスしてください。

FourSquarePage

そして、右上の開発者をクリックします。

すると、https://developer.foursquare.com/にページが遷移します。

FourSquareDeveloperPage

がでたら、右上のCreate Accountからアカウントを作成してください。

多分、FourSquare_logoで作ったアカウントがそのまま開発者アカウントとして引き継がれるはずです。

そしたら下図のようにMy Appsを選びましょう。アプリの作成のリンクがあるはずです。

FourSquareMyApp

こんな風に入れてください。

FourSquareMyAppRegist

この中で重要なのは3点。

  • Client ID、Client Secretは次の作業で使うのでちゃんとメモっておきましょう。
  • Client ID、Client Secretは墓まで持っていきましょう。人に教えちゃダメ!
  • Redirect URI (s)もちゃんと考えておきましょう。これは次の作業でこさえるスクリプトのURIです。

Application Urlという項目もありますが、ここは適当でよいです。他の項目も商用で使わなければ空白でよいはず。

6.FourSquareのアクセストークンの取り寄せとソースの仕込み方

  目次へ↑

続いて、アクセストークンを取得するためのphpを示しましょう。98行。38ステップです。

コメントに内容は記載しているので、まずはソースを味わってみてください。

設定A ⇒ 条件分岐B ⇒ 条件分岐C ⇒ 処理D ⇒ 処理E ⇒ 処理F ⇒ 条件分岐G ⇒ 処理H ⇒ 条件分岐I ⇒ 処理Jの順に進みます。

  // 設定A //
  // マイアプリで表示されたClient ID
  $client_id = 'KOKONIHAHONMAYATTARARANDAMUNAMOJIRETSUGAHAITTORUNEN';
  // マイアプリで表示されたClient Secret
  $client_secret = 'SEYAKEDOKOKONISOREDASHITARAORENOKOUDOUGABARERUKARADASAHENNEN';
  // リダイレクトURL (このスクリプト自身のアドレスです)
  $redirect_uri = 'https://*****.*****.ne.jp/*****/****_*********.php';
  // アクセストークン取得URLのベース
  $access_token_baseurl = 'https://foursquare.com/oauth2/access_token';
  // 認証URLのベース
  $authenticate_baseurl = 'https://foursquare.com/oauth2/authenticate';

  // 結果表示HTML用
  $html = '';
  // 結果表示見出し
  $html .= '<h2>実行結果</h2>';

  // 条件分岐B //
  //初回は$_GET['code']がなく中の処理は実行されない。「許可」され、リダイレクトされた場合に実行
  if( isset( $_GET['code'] ) 
    && !empty( $_GET['code'] ) 
    && is_string( $_GET['code'] ) ) {
    // 処理E //
    // 認証画面でFourSquareの実行が許可されると$_GET['code']付きでこのスクリプトが呼び出される。
    // アクセストークンの取得に利用するコード
    $code = $_GET['code'];
    // 処理F //
    // アクセストークン取得のためのパラメータを設定したUrlを組み立てる。
    // リクエストURL
    $request_url = 
      $access_token_baseurl . 
        '?client_id=' . $client_id . 
        '&client_secret=' . $client_secret . 
        '&grant_type=authorization_code' . 
        '&redirect_uri=' . rawurlencode( $redirect_uri ) . 
        '&code=' . $_GET['code'] . 
        '&state=users/self';
    // curlを初期化する
    $curl = curl_init();
    curl_setopt( $curl , CURLOPT_URL , $request_url );
    curl_setopt( $curl , CURLOPT_HEADER, 1 );
    // 証明書の検証を行わない
    curl_setopt( $curl , CURLOPT_SSL_VERIFYPEER , false );
    // curl_execの結果を文字列で返す
    curl_setopt( $curl , CURLOPT_RETURNTRANSFER , true );
    // タイムアウトの秒数
    curl_setopt( $curl , CURLOPT_TIMEOUT , 5 );
    // 実行し、結果を$jsonに代入
    $res1 = curl_exec( $curl );
    $res2 = curl_getinfo( $curl );
    curl_close( $curl );
    // 取得したJSONデータ(ヘッダーサイズでTrimしないとFourSquareはエラーになる)
    $json = substr( $res1, $res2['header_size'] );

    // JSONをオブジェクト型に変換する
    $obj = json_decode( $json );

    // 条件分岐G //
    if( !isset( $obj->access_token ) ) {
      // アクセストークンを取得できなかった場合
      $error = 'アクセストークンを上手く取得することができませんでした。';
    } else {
      // 処理H //
      // アクセストークンを[$access_token]に代入する
      $access_token = $obj->access_token;
      // アクセストークンをブラウザーに出力する
      $html .= '<p>取得したアクセストークンは <b><mark>' . 
        $access_token . '</mark></b>です。</p>';
    }
  } elseif( isset( $_GET['error'] ) ) {
    // 「拒否」して返された場合怒る。
    $error = 'なんで「許可」してくれへんの!?';
  } else {
    // 条件分岐C //
    // 初回はこの処理が行われるはず。
    header( 'Location: ' . $authenticate_baseurl . 
      '?client_id=' . $client_id . 
      '&response_type=code' . 
      '&redirect_uri=' . rawurlencode( $redirect_uri ) );
    // 処理D //
    // headerの後はexit()
    exit;
  }

  // 条件分岐I //
  // エラー判定
  if( isset( $error ) && !empty( $error ) ) {
    $html .= '<p><mark>' . $error . '</mark>' . 
      'もう一度、認証をするには、' . 
      '<a href="' . explode( '?' , $_SERVER['REQUEST_URI'] )[0] . '">こちら</a>' . 
      'をクリックして下さい。</p>' ;
  } else {
    // 処理J //
    // ブラウザーに[$html]を出力 (結果としてアクセストークンの文字列が表示されます)
    echo $html;
  }

もう一度上の処理をおさらいします。
・最初にこのスクリプトが呼ばれた際、URLにcodeパラメーターはついていません。
・だからBの条件でEFGHの処理は行われず、条件分岐Cの処理が実行されます。
・その中ではLocationヘッダを送信しています。なので、その下の処理Dでただちに認証画面にリダイレクトされます(認証画面は割愛します)。
・そこで認証が終われば、再びこのスクリプトがcodeパラメーター付きで呼ばれるよう、処理DのLocationヘッダの中にredirect_uriパラメーターを指定しています。
・再びこのスクリプトが実行されると、今度は条件分岐Bで処理E、Fが実行されます。
・FではFourSquare_logoのアクセストークンを返すAPIが呼ばれます。
・なので、無事にアクセストークンが返ってくるという流れです。お判りでしょうか?

そしたらこのスクリプトファイルをどこかのウェブサーバーに送ってください。phpファイルの実行権限付きで。先ほどFourSquare_logoのマイアプリの設定でRedirect Url (s)に入力していただきましたが、その内容と合わせておいてください。

たとえばhttps://hanamogera.com/mokeke/foursqaure_tokun_yokose.phpに送るとします。そしたらブラウザーのアドレスバーに直接上のアドレスを指定し、実行します。

すると結果が以下のように表示されるはずです。このマーカー部分をコピーしておきましょう。

アクセストークン

おめでとうございます。アクセストークンはこれであなたのものです。ついに調理人として存分に腕を振るう時が来たのです!!

7.FourSquareからCheckinデータの取り寄せとソースの仕込み方

  目次へ↑

続いては、いよいよAccessTokenを使ってFourSquare_logoにデータくれ!とおねだり (構成図の④) してみましょう!もらったデータをkintoneにシュッと投げ込むまで(構成図の⑤)のphpも説明します。まずはphpを以下に示します。115行。64ステップです。

こちらもコメントに内容は記載しているので、まずはソースを味わってみてください。
先のスクリプトはソースに大分シェフの手を加えましたが、このスクリプトは素材の味を生かすような作りにしています。

設定A ⇒ 設定B ⇒ 設定C ⇒ 設定D ⇒ 設定E ⇒ 条件分岐F ⇒ 条件分岐G ⇒ 処理H ⇒ 処理I ⇒ 処理J ⇒ 処理Kの順に進みます。

  // 設定A //
  // アクセストークン (前の処理で取ってきた文字列です)
  $access_token = 'YATTAYOAKUSESUTOKUNTORETAYOKOREDESIAWASENINARERUNE';

  // 設定項目 (ここはアクセストークン以外はデフォルト)
  $params = array(
    'oauth_token' => $access_token ,// アクセストークン (これでユーザーが認識される)
    'locale' => 'ja' ,              // ローカライズ (jaは日本)
    'm' => 'swarm' ,                // モード (foursquare OR swarm)
    'v' => '20150801' ,             // バージョン (APIのバージョン。今のところ左の年月日)
    'limit' => '250'                // 取得件数 (250が上限)
  ) ;
  // 設定B //
  // GETメソッドで指定した場合 (設定項目のパラメーターを差し替える)
  foreach( array( 'locale' , 'm' , 'limit' , 'sort' , 'afterTimestamp' , 'beforeTimestamp' ) as $val ) {
    if( isset( $_GET[ $val ] ) && $_GET[ $val ] != '' ) {
      $params[ $val ] = $_GET[ $val ] ;
    }
  }

  // 設定C //
  // 設定項目 (日付や並び替えなどの条件を追加します。例えば2018/11/30の00:15時点で実行されたとします。
             するとstrtotime("today")は2018-11-30 00:00:00が返されます。
              strtotime("yesterday")は2018-11-29 00:00:00が返されます。
              それをもとにした$params["afterTimestamp"]は2018-11-29 00:00:00より後のチェックインデータを、
              それをもとにした$params["beforeTimestamp"]は2018-11-30 00:00:00より前のチェックインデータを取得します。
              $params["sort"]はoldestfirstを指定すると上記の日付範囲のチェックインデータのうち古いものを最初に取得します。
              ここ重要です。kintoneライフログ調理師試験に出ますので、要おさらい!!) //
  $today = strtotime("today");
  $yesterday = strtotime("yesterday");
  $params["afterTimestamp"]=$yesterday;
  $params["beforeTimestamp"]=$today;
  $params["sort"]="oldestfirst";

  // リクエストURL (usersの次のselfは固定文字)
  $request_url = 'https://api.foursquare.com/v2/users/self/checkins?' . http_build_query( $params );

  // 設定D //
  // cURLでリクエスト
  $curl = curl_init();
  curl_setopt( $curl , CURLOPT_URL , $request_url );
  curl_setopt( $curl , CURLOPT_HEADER, 1 );
  curl_setopt( $curl , CURLOPT_SSL_VERIFYPEER , false );
  curl_setopt( $curl , CURLOPT_RETURNTRANSFER , true );
  curl_setopt( $curl , CURLOPT_TIMEOUT , 5 );
  $res1 = curl_exec( $curl );
  $res2 = curl_getinfo( $curl );
  curl_close( $curl );

  // 設定E //
  // 取得したJSONデータをオブジェクト形式に変換する (ヘッダーサイズでTrimしないとFourSquareはエラーになる)
  $json = substr( $res1, $res2['header_size'] );
  $obj = json_decode( $json );

  // 条件分岐F //
  // エラー判定 (metaのcodeの値が200だと正常に取得されている)
  if( !$obj || !isset($obj->meta->code) || $obj->meta->code != 200 ) {
    //ログ出力して調査!
  } else {
    // 説明
    $data = array();
    $count = 0;
    // 条件分岐G //
    //取得したデータオブジェクトの -> response -> checkins -> itemsの中をループする //
    foreach( $obj->response->checkins->items as $item ) {
      // 処理H //
      //$itemの中にはチェックインの場所についての栄養が豊富に含まれています。緯度経度やコメントや市長など。詳しくはhttps://developer.foursquare.com/docs/api/users/checkins //
      // チェックインID
      $id = $item->id ;
      // ベニューのID
      $venue_id = $item->venue->id ;
      // ベニューの国 (国名は日本語で取得できる)
      $venue_country = $item->venue->location->country;
      // ベニューの都道府県 (取得した後、Google Chartの都合で末尾の「都」「府」「県」は除去する)
      $venue_prefecture = $item->venue->location->state;
      switch ($venue_prefecture){
        case '北海道':
          break;
        default:
          $venue_prefecture = mb_substr($venue_prefecture,0,-1, "UTF-8") ;
          break;
      }
      // ベニューの名前 (改行は除去しておく)
      $venue_name = str_replace(array("\r\n", "\r", "\n"), '', $item->venue->name);
      // チェックイン日時(オフセットと合わせる。要は日本の時間に合わせる)
      $createdAt = $item->createdAt + 54000 - 86400;
      // 日時の整形 (kintoneにあった日付データ形式に変更する)
      $createdAt = date( 'Y-m-d' , $createdAt )."T".date( 'H:i:s' , $createdAt ) ;
      // コメント (改行は除去しておく)
      $shout = ( isset($item->shout) ) ? str_replace(array("\r\n", "\r", "\n"), '', $item->shout) : '';
      // kintoneに投げるデータです。フィールドコードとデータ型を合わせることを忘れずに。
      // 処理I //
      //kintoneに投げるデータをここで指定します。文字列データは文字列型にキャストしておくとふっくら仕上がります //
      $data[$count] = array(
        "場所" => array("value" => (string)$venue_name),
        "コメント" => array("value" => (string)$shout),
        "国" => array("value" => (string)$venue_country),
        "都道府県" => array("value" => (string)$venue_prefecture),
        "日時" => array("value" => $createdAt)
      );
      $count++;
    }
  }

  // 処理J //
  // kintoneの対象のアプリIDを指定する。(1111というのはダミーです)。FourSquareからのデータもあわせて。
  $postdata = array("app" => 1111,records=>$data);
  // 処理K //
  // kintoneのアクセストークンを指定する。ベーシック認証がある場合はそちらもあわせて指定。
  $headers = array(
    "X-Cybozu-API-Token:" . "kintoneNOAPURISETTEIDESHUTOKUDEKIRUTOKUN",
    "Authorization:" . "Basic " . base64_encode("YUUZAAID:PASUWAADO"),
    "Content-Type:" . "application/json"
  );
  // 以下の******はお使いのkintoneのサブドメインを入れてください。
  $curl = curl_init('https://******.cybozu.com/k/v1/records.json');
  curl_setopt($curl, CURLOPT_POST, true);
  curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
  curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
  curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($postdata,JSON_UNESCAPED_UNICODE));
  curl_setopt($curl, CURLOPT_HEADER, true);
  // curlで出たエラーを補足するためのものです。
  $fp = fopen('curl.log', 'a');
  // 詳細な情報を出力する
  curl_setopt($curl, CURLOPT_VERBOSE, true);
  // STDERR の代わりにエラーを出力するファイルポインタ
  curl_setopt($curl, CURLOPT_STDERR, $fp);
  if(curl_exec($curl) === false){
    //ログ出力して調査!
  } else {
    return true;
  }
  curl_close($curl);

このスクリプトもウェブサーバーに送ってください。ファイルの実行権限付きで。あと、ファイルのUrlはきちんとメモしておいてくださいね(^_^)

次にcronの設定を行います。cron実行のための構文はサーバーによって違います。ここに載せているcron設定はさくらインターネットの例ですが、phpファイルの実行パスと、スクリプトのファイル名はどのサーバーでも求められるはずです。このレシピは毎日00:15に自動で実行するように設定しています。

Cron設定1

cron2-1

8.kintone上でデータの盛り付け

  目次へ↑

さて、7までのレシピ(2018/12時点の)に忠実に行うとチェックインデータは毎日順調にkintoneに流れ込むはず。あとはデータを盛り付けるだけ。「ライフログのkintone盛り alasql仕込みのGoogle Chart添え」とうたっている以上、最後の仕上げにGoogle Chartを添えるのを忘れるなかれ。それぞれの一覧ごとに違うマップを表示するJavaScriptを以下に示します。この中でkintoneのデータをalasqlで集計し、その結果をGoogle Chartで地図に表示しています。

世界地図の場合、処理A ⇒ 条件分岐B ⇒ 処理C ⇒ 処理D ⇒ 処理E ⇒ 処理V ⇒ 処理F ⇒ 処理W ⇒ 処理G ⇒ 処理H ⇒ 処理I ⇒ 処理J ⇒ 処理Kの順に進みます。
日本地図の場合、処理A ⇒ 条件分岐L ⇒ 処理M ⇒ 処理N ⇒ 処理O ⇒ 処理V ⇒ 処理P ⇒ 処理W ⇒ 処理Q ⇒ 処理R ⇒ 処理S ⇒ 処理T ⇒ 処理Uの順に進みます。

(function () {
  "use strict";
  // 処理V //
  // 再帰処理による一回のリクエスト制限を超えた全レコードを取得 //
  function fetchRecords(appId, opt_query, opt_fields, opt_offset, opt_limit, opt_records) {
    var query = opt_query || '';
    var offset = opt_offset || 0;
    var limit = opt_limit || 500;
    var allRecords = opt_records || [];
    var params = {app: appId, query: query + ' limit ' + limit + ' offset ' + offset };
    if (opt_fields) params.fields = opt_fields;
    return kintone.api(kintone.api.url('/k/v1/records', true), 'GET', params).then(function(resp) {
      allRecords = allRecords.concat(resp.records);
      if (resp.records.length === limit) {
        return fetchRecords(appId, query, opt_fields, offset + limit, limit, allRecords);
      }
      return allRecords;
    });
  }

  // 処理W //
  // json形式で取得したkintoneのレコードをalasqlで扱えるようレコードセット形式に変換 //
  function convertToRows(records) {
    var rows = records.map(function(record){
      var keys = Object.keys(record);
      var row = {};
      keys.map(function(key){
        row[key] = record[key].type === 'NUMBER' ? Number(record[key].value) : record[key].value;
      });
      return row;
    });
    return rows;
  }

  // 一覧ページ
  kintone.events.on('app.record.index.show', function(event) {
    // 処理A //
    //Google Cloud Platformで発行したAPI KEYです。
    //API KEYがない場合、以下の処理Cでgoogle.charts.load('upcoming', {'packages':['geochart']});としても動きますが、世界地図の色塗りができません。また、コンソールでエラーが表示されます。
    //作成したプロジェクトの認証はリファラーを限定するとよいです。https://subdomain.cybozu.com/* のように指定すると、対象のサブドメインに対して動作します。https://akvabit.cybozu.com/だと動きませんので注意が必要です。
    //あと、許可するAPIですが、「Maps JavaScript API」「Geolocation API」「Geocoding API」の三つを有効にしています。
    const ApiKey = 'k54u6 jkrawyeie-wkjykethiudarwhyeiu_rekyjur';
    // 条件分岐B //
    //世界地図ビューの場合 //
    if (event.viewId === 5351051) {
      // 処理C //
      //google chartを読み込む。種類は地図 //
      google.charts.load('upcoming', {'packages':['geochart'],'mapsApiKey':ApiKey});
      // 処理D //
      //google chartの読み込みが完了したらコールバックでdrawWorldMapを呼び出す //
      google.charts.setOnLoadCallback(drawWorldMap);
      function drawWorldMap() {
        var obj = {};
        // 処理E //
        //このファイルの3-17行のfetchRecords関数を呼び出す。対象アプリは自分で、フィールドは[場所][国] //
        fetchRecords(event.appId, '', ['場所', '国']).then(function(records) {
          // 処理F //
          //取得したレコードをSqlで扱えるようなレコードセットの形式に変換するconvertToRows関数(このファイルの20-30行)をご参照 //
          obj.rs1 = convertToRows(records);
          // 処理G //
          //alasqlでsql文のソースを組み上げ、バインドパラメータに処理Fで得たレコードセットをセットする //
          var result = 
            alasql(
              "SELECT t.[国], COUNT(t.[場所]) as [回数] \
              FROM ? AS t \
              GROUP BY t.[国] \
              ORDER BY t.[国]", [obj.rs1]);
          // 処理H //
          //google chartのデータテーブルのインスタンスを新たに確保する //
          var data = new google.visualization.DataTable();
          // 処理I //
          //google chartのデータテーブルの列と行を追加します。alasqlでグループ集計された国ごとの訪問回数です。 //
          data.addColumn('string', '国');
          data.addColumn('number', '訪問場所数');
          result.forEach(value => {
            data.addRow([value["国"], value["回数"]]);
          });
          // 処理J //
          //google chartの世界地図の色塗りの書式を設定する //
          var options = {
            datalessRegionColor: '#ffffff',
            colorAxis:{
                maxValue:500,
                colors:['#D8F6CE','#21610B']
            }
          };
          // 処理K //
          //google chartの世界地図のデータに処理Iで格納した内容を代入し、kintoneのヘッダスペースに描画する //
          var chart = new google.visualization.GeoChart(kintone.app.getHeaderSpaceElement());
          chart.draw(data, options);
        });
      }
    // 条件分岐L //
    //日本地図ビューの場合 //
    } else if (event.viewId === 5351053) {
      // 処理M //
      //google chartを読み込む。種類は地図 //
      google.charts.load('upcoming', {'packages':['geochart'],'mapsApiKey':ApiKey});
      // 処理N //
      //google chartの読み込みが完了したらコールバックでdrawJapanMapを呼び出す //
      google.charts.setOnLoadCallback(drawJapanMap);
      function drawJapanMap() {
        var obj = {};
        // 処理O //
        //このファイルの3-17行のfetchRecords関数を呼び出す。対象アプリは自分で、フィールドは[場所][都道府県] //
        fetchRecords(event.appId, '', ['場所', '都道府県']).then(function(records) {
          // 処理P //
          //取得したレコードをSqlで扱えるようなレコードセットの形式に変換するconvertToRows関数(このファイルの20-30行)をご参照 //
          obj.rs1 = convertToRows(records);
          // 処理Q //
          //alasqlでsql文のソースを組み上げ、バインドパラメータに処理Pで得たレコードセットをセットする //
          var result = 
            alasql(
              "SELECT a.[都道府県], COUNT(a.[場所]) as [回数] \
              FROM ? AS a \
              GROUP BY a.[都道府県] \
              ORDER BY a.[都道府県]", [obj.rs1]);
          // 処理R //
          //google chartのデータテーブルのインスタンスを新たに確保する //
          var data = new google.visualization.DataTable();
          // 処理S //
          //google chartのデータテーブルの列と行を追加します。alasqlでグループ集計された都道府県ごとの訪問回数です。 //
          data.addColumn('string', '都道府県');
          data.addColumn('number', '訪問場所数');
          result.forEach(value => {
            data.addRow([value["都道府県"], value["回数"]]);
          });
          // 処理T //
          //google chartの地図を日本地図の都道府県とし、色塗りの書式を設定する //
          var options = {
            region: 'JP',
            resolution: 'provinces',
            datalessRegionColor: '#ffffff',
            colorAxis:{
                maxValue:600,
                colors:['#F2FBEF','#21610B']
            }
          };
          // 処理U //
          //google chartの世界地図のデータに処理Sで格納した内容を代入し、kintoneのヘッダスペースに描画する //
          var chart = new google.visualization.GeoChart(kintone.app.getHeaderSpaceElement());
          chart.draw(data, options);
        });
      }
    }
  });
})();

いかがでしょうか? ここはカスタマイズビューでリッチにデータを表現しても良いですし、条件に応じて絞り込めば、さらに面白いこともできます。チェックインデータにはカテゴリーもありますので、例えば訪れた酒場だけを抜き出し都道府県で図示したり、訪れたラーメン屋だけを都道府県で抜き出すことだってできます。例えば私の趣味ですが、今までに訪れた滝を色分けすればこうなります。城とすればこう。駅だとこう。他にも登った山や訪れた蒸留所や酒蔵、ブルワリー。各地の日本酒や世界の酒、料理などで色分けしても面白そうです。
残念なことにGoogle chartはまだ市町村には対応していません。もしそうなればもっと面白いライフログが作れそうです。もちろん、地図で塗り分けるほかにもGoogle chartはかなりのグラフの種類を用意しています。そこは皆様の自由です。
なお、ライフログとは、別に人様に見せて放浪癖を誇るものでも、旅行経験を自慢するものでもありません。あくまでもプライベートな利用がよろしいかと思います。ご自身の今までの人生とこれから残された人生に何を成すかを定める助けになればそれで十分です。そのあたり、ライフログについての私の考えは、このレシピ同時に書いたこちらで世に問うてみました。またご覧頂ければ幸いです。
このレシピをまとめるなら、要するにkintoneをプライベート用途に使おうよ、との主旨です。そういう使い方がもっと増えればkintoneはより身近なものになるのですから!

9.当レシピの参考にさせていただいたブログ

  目次へ↑

最後になりましたが、このレシピを作るのに、以下の5サイトを参考にさせていただきました。ありがとうございました。

 Foursquare(Swarm) APIの使い方まとめ (サンプルコード付き)
 Get Check-Ins for a User
 kintone でSQLを使う
 GoogleのGeochartを使ってみた
Google Maps Platform