86
65

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

kintoneAdvent Calendar 2020

Day 25

kintoneのスレッド投稿がスマートになるChrome拡張をVue.jsで作る

Last updated at Posted at 2020-12-25

🎄こちらは kintone Advent Calendar 2020 25日目の記事です🎅

今年もたくさんの記事ありがとうございました!!
おかげさまでPart2まで広がっています!!

今年はPart2は埋まらなかったけど・・・
これはアドベントカレンダーのオーナーとしてあとで全部埋めておくべきか・・・
怒涛のQiitaラッシュが始まる・・・

はじめに

2年前に投稿した Chrome拡張の作り方 (超概要) が、今でも定期的にいいねもらえている状況でして、ふと、

またChrome拡張関連の記事書けばいいね貰えるのでは・・・

という不純な動機でこの記事書きますw
しかもVue.jsというこちらも人気のやつで、「いいね物乞いだ!」と言われても否定できません( ̄ー ̄)ニヤリ

でも真面目に記事は書きますよ!

つくったもの(はいけい)

kintoneのスレッドは標準でいろいろと文字のデザインとか見た目を整えることができますが、
上部のメニューにあるもの以外は使えません。
スクリーンショット 2020-12-25 2.21.40.png

例えば、文字色とかは意外と選択肢が少なかったりします。
スクリーンショット 2020-12-25 2.22.20.png

でも実はコンソール上でCSS書き換えたりどこかで色付きの文字をコピペすれば、好きな色に変えることが可能です。
スクリーンショット 2020-12-25 2.28.28.png

つまり スレッドは実は自由度が高いけど、標準では機能がないから本領発揮できていない ってことです。
ということで、Chrome拡張で本領発揮させてあげたいと思います!

つくったもの(じつぶつ)

日々の日報をスマートに挿入するChrome拡張です。
schedulePicker.gif

Chrome拡張のpopupメニューも使って、文字色を自由に変更できるようにしてみました。
スクリーンショット 2020-12-25 3.2238.38.png
本来であれば、

  • Garoonの今日の予定をコピー
    • スレッドにペースト
  • Garoonの次の日の予定をコピペ
    • スレッドにペースト
  • kintoneのレコード一覧をコピペ
    • スレッドにペースト
  • 基本きれいに貼り付けできないからめっちゃ頑張って見た目整える

といった感じにあっちこっちコピーしてスレッドに貼り付け、コピーしては貼り付けを繰り返す必要があります。
超絶めんどくさい!スマートにやりたい!!

なぜVue.jsでつくったのか

最近ずっとVue.jsを触っているから

まぁこれに尽きるわけですが、kintoneのスレッド挿入の場合、見た目の装飾は全てHTMLに直書きする必要があります。
→ CSSで見た目を整えても、スレッド投稿したときに結局意味なくなるので、HTMLのタグ属性として記述してあげて、そのままスレッドに埋め込む、みたいな感じです。

なのでJavaScript側からHTML要素に対してstyle属性をゴニョゴニョしないといけないわけですが、当然生のJSではやりたくないです。

で、よく使われるのがjQueryです。今回はAPIも叩くのでajax使えば楽・・・と流れそうになりましたが、
ふと 「Vue.jsでも:styleでCSSを直接HTMLにバインドできたな」 というのを思い出し、Vue.jsでチャレンジしてみました!
(こうやって脱jQueryが浸透されていく・・・jQuery嫌いじゃないけどねw)

しくみ

chrome.contextMenus.create というのを使うと、右クリック時にアイコン/テキストを表示させることができます。

background.js
chrome.contextMenus.create({
	title : 'DRMaker',
	type : 'normal',
	contexts : ['all'],
	id: 'parent_id',
	onclick : () => alert('hello')
});

そしてそのアイコンクリック時にもろもろ動かすようにしています。

  • 右クリック
  • 埋め込んだアイコン/テキストをクリック
    • クリックイベントでメッセージ送信
  • メインスクリプトの方でメッセージ受信
    • メインの処理が動く
  • Garoon REST APIで今日の予定を取得
    • データを整形する
  • Garoon REST APIで次の日の予定を取得
    • データを整形する
  • kintone REST APIでタスク管理アプリのデータを取得
    • データを整形する
  • 全体を日報のフォーマットに整える
  • ついでに アニメ名言API を使ってネタ要素を追加

みたいな感じです。詳しいコード解説は下に書きますが、ChromeはSendMessageonMessageでなにやら連携しているらしいです。まぁあまり良くわからないのですが、自分の解釈としては、

右クリックでの操作とメイン処理を紐付けるためにメッセージ送信/受信という形で紐付けている

って思っていますw (とりあえず動けばOK!)

じゅんび

まぁ普通のものしか使っていません。

  • Chrome
  • Visual Studio Code
  • vue-cli

以上! vue-cliの使い方は他の記事とかで丁寧に書かれているのでそれ見てください。

こーどかいせつ

では、本命のコード解説をします。なるべく丁寧に書きますが、自分の知識が適当なところは適当に書きますw

ファイル構成

ざっくりファイルの構成を書いてみます。

├── babel.config.js
├── dist
├── manifest.json          #Chrome拡張用(設定ファイル)
├── node_modules
├── package-lock.json
├── package.json
├── popup                  #Chrome拡張ポップアップ画面用(ブラウザ右上のアイコンクリック時の処理)
|    ├── popup.html        #ポップアップ画面
|    └── popup.js          #ポップアップ画面の制御
├── public
├── src                 
|    ├── assets
|    ├── components        
|    |     ├── Garoon.vue  #Garoonからスケジュールを取得する処理 and HTML生成
|    |     ├── Kintone.vue #kintoneからタスクデータを取得する処理 and HTML生成
|    |     └── Anime.vue   #アニメの名言を取得する処理 and HTML生成
|    ├── App.vue           #componentsの中のものを使って全体としてHTMLを生成
|    ├── main.js           #Chrome拡張とVue.jsを紐付ける処理
|    └── background.js     #Chrome拡張で右クリックをトリガーとする処理
└── yarn.lock

まぁこんな感じですかね。コメントがない部分のやつに関しては基本気にしなくてよい or デフォルトの動きのままです。

manifest.json

先にmanifest.jsonを見たほうが早いかもなので載せます。

manifest.json
{
  "name": "BBPicker",
  "version": "1.0.0",
  "manifest_version": 2,
  "description": "日報用",
  "permissions": [
    "storage",
    "background",
    "contextMenus",
  ],
  "content_scripts": [{
    "matches": [
    ],
    "js": [
      "dist/js/app.js",
      "dist/js/chunk-vendors.js"
    ]
  }],
  "background": {
    "matches": [
    ],
    "scripts": [
      "src/background.js"
    ]
  },
  "browser_action": {
    "default_title": "BBPicker",
    "default_popup": "popup/popup.html"
  }
}

permissions の3つは今回は必須になります。

  • storage
    • ブラウザ右上のアイコンクリック時に変数設定をする場合、それをブラウザに保存するために必要
    • Chromeのstorageに保存して、メインファイルから呼び出す動きになる
  • background
    • 右クリックで動かすために必要
  • contextMenus
    • 右クリックの一覧にアイコン/テキストを表示させるために必要

まぁ消したら動かなくなったものだけ残したらこれになりましたw

background.js

右クリック時の処理です。

background.js
const onClick = () => (_, tab) => chrome.tabs.sendMessage(tab.id, '');

chrome.contextMenus.create({
	title : 'DRMaker',
	type : 'normal',
	contexts : ['all'],
	id: 'parent_id',
	onclick : onClick()
});

右クリックでアイコンを選択したらメインの処理を実行したいので chrome.tabs.sendMessage で連携させています。

今回は1階層だけですが、2階層以降も作れるみたいです。
細かい部分は公式リファレンスを!
https://developer.chrome.com/docs/extensions/reference/contextMenus/

popup

ブラウザ右上のアイコンクリック時に行いたい処理を記述します。
今回は文字色をカラーピッカーで選択して、保存させるシンプルな仕組みなのでこんな感じに。

popup.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
</head>
<body>
  <label>文字色</label>
  <input id="fontcolor" type="color" value="#ffffff">
  <p id="message"></p>
  <button id="submit">保存</button>
  <script src="popup.js"></script>
</body>
</html>
popup.js
window.onload = ()  => {
  const fontColorEl = document.getElementById('fontcolor');
  const messageEl = document.getElementById('message');
  const submitBtnEl = document.getElementById('submit');

  // すでに保存されている情報があればそれを設定する処理
  chrome.storage.sync.get('selected_fontcolor', items => {
    fontColorEl.value = items.selected_fontcolor;
  });

  // Option画面で保存されたときの処理
  submitBtnEl.onclick = () => {
    chrome.storage.sync.set({
      selected_fontcolor: fontColorEl.value,
    }, () => {
      messageEl.textContent = 'Saved';
      setTimeout(() => messageEl.textContent = '', 750);
    });
  }
};

普通のHTML/JSを使ったよくあるフォーム入力画面です。
Chrome拡張特有の部分は chrome.storage.sync.getchrome.storage.sync.setですが、
いわゆるwebstorageと同じ動きなので直感的に使えると思います。

Vue.js部分

ではメインのVue.js部分に! (ちょっと説明が疲れてきた)

main.js

Chrome拡張と紐付けするためにこんな感じにしています。
直感で**「こう書けばいけるっしょ!」**って軽い気持ちで書いたら本当にいけました 笑

main.js
import Vue from "vue";
import App from "./App.vue";

Vue.config.productionTip = false;

chrome.extension.onMessage.addListener(() => {
  chrome.storage.sync.get(null, items => {
    // フォーカスしている部分を取得
    console.log(items);
    Vue.prototype.$items = items;
    const target = document.activeElement;
    const target_area = target.id;
    // フォーカスしていなかったら処理をやめる
    if (!target_area) return;
    new Vue({
      render: h => h(App)
    }).$mount(target.children[0]);
  });
});

chrome.extension.onMessage.addListenerはメッセージを受信したときの処理です。
background.jsの方で右クリックしたらメッセージ送信としているので、つまりは 右クリックしたとき というイベントになります。

chrome.storage.sync.getはChromeのstorageから値を取ってくる処理です。
文字色が格納されているはずなので、それをitemsとして取得してVue.prototype.$items = items;でグローバル変数に入れています。

あとの処理はお好きに。
今回はkintoneスレッド投稿なので、まずスレッドをフォーカスしてもらって、

  • アクティブなら処理を実行
  • そうでなければ何もしない

と条件分岐しています。

最初に躓いたポイント

Vue.jsのマウント部分で、今は .$mount(target.children[0]) と書いていますが、
最初は愚直に「target部分に挿入したいから .$mount(target)」 と書いていました。

ただ、これをすると実はtarget部分を 置き換える となるので、スレッド入力部分(inputタグ)がただのテキストに置き換わってしまい、inputタグじゃないからスレッド保存ができなくなる、というトラブルが!!!
picker2.gif

じゃあ中の要素を置き換えるかってことで children を指定しています。
(JSをある程度やっていると、ここらへんの勘が働くようになりましたね〜)

App.vue

スレッドに挿入するメイン処理の部分です。

App.vue
<template>
  <div id="app" style="margin: 0 10px">
    <div :style="css.todayText"> {{ today.toFormat("MM/dd")}}</div>
    <br/>
    <span :style="css.bold">{{ today.toFormat("MM/dd") }}】の予定</span>
    <GrSchedule :date="this.today" />
    <br/>
    <span :style="css.bold">{{ tomorrow.toFormat("MM/dd") }}】の予定</span>
    <GrSchedule :date="tomorrow" />
    <br/>
    <span :style="css.bold">【タスク一覧】</span>
    <KinTask />
    <br/>
    <span :style="css.bold">【今日の一言】</span>
    <br/><br/>
    <span :style="css.bold">【今日のアニメ名台詞】</span>
    <AnimeChan />
  </div>
</template>

<script>
import GrSchedule from "./components/GaroonSchedule";
import KinTask from "./components/KintoneTask";
import AnimeChan from "./components/AnimeChan";

import { DateTime } from "luxon";

export default {
  name: "App",
  components: {
    GrSchedule,
    KinTask,
    AnimeChan
  },
  data() {
    return {
      today: DateTime.local(),
      tomorrow: DateTime.local().toFormat("E") === "5" ? DateTime.local().plus({days: 3}): DateTime.local().plus({days: 1}),
      css: {
        bold: "font-weight: 700",
        todayText: {
          "font-family": "cursive",
          "font-size": "x-large",
          "font-weight": "700",
          "color": this.$items.selected_fontcolor
        }
      }
    }
  },
};
</script>

<style></style>

それぞれコンポーネント化しているので、全体を整える役割ですね。
地味に頑張っているのが tomorrow の処理で

DateTime.local().toFormat("E") === "5" ? DateTime.local().plus({days: 3}): DateTime.local().plus({days: 1})

と書くことで 今日が金曜日なら次の日は3日後の月曜日 と設定しています。
何も考慮しないと金曜日の日報に「次の日の予定」ってことで土曜日の予定が挿入されてしまいます。土曜出勤なら良いですが^^

ここらへんはVue.jsの普通の書き方です。むしろChrome拡張を作っていたと忘れるぐらいですw

components

・・・

説明疲れてきたからGitHubのリンク載せておきます。

こちらにアップしているので、細かい部分はこちらでw
https://github.com/RyBB/kintoneThreadPostChromeEx/tree/main/src/components
(Garoonのスケジュール整えるのはこれだけで1記事書けそうだからなぁ(-_-;))

Garoonの処理

軽く説明はしておきます 笑

何が一番大変だったかというと、予定メニューに合わせて色を変更する部分でした(地味)
→ なるべくGaroon自体の色と合わせるために、switch文で予定メニューに合わせて背景色を選んでいたりします。

※ ちなみに、認証については同一ドメイン環境を前提として、セッション認証でやっています。

Garoon.vue
<template>
  <div>
    <div style="line-height: 1.2; white-space: nowrap;">
      <div v-for="(data, index) in scheduleData" :key="index">
        <span> {{ data.time }} </span>
        <span :style="'background-color:' + colorConf[data.color]">
          <span v-if="data.menu" :style="menucss">{{ data.menu }}</span>
        </span>
        <a target="_brank" :href="'/g/schedule/view.csp?event=' + data.id"> {{ data.subject }} </a>
      </div>
    </div>
  </div>
</template>

<script>
import axios from "axios";
import { DateTime } from "luxon";

export default {
  name: "GrSche",
  props: ["date"],
  data() {
    return {
      html: "",
      scheduleData: [],
      colorConf: {
        normal: "rgb(49, 130, 220)",
        blue: "rgb(49, 130, 200)",
        wblue: "rgb(87, 179, 237)",
        orange: "rgb(239, 146, 1)",
        red: "rgb(244, 72, 72)",
        pink: "rgb(241, 148, 167)",
        purple: "rgb(181, 146, 216)",
        brown:"rgb(185, 153, 118)",
        gray: "rgb(153, 153, 153)",
        ygreen: "rgb(50, 205, 50)"
      },
      menucss: {
        "display": "inline-block",
        "margin-right": "3px",
        "padding": "2px 2px 1px",
        "color": "rgb(255, 255, 255)",
        "font-size": "11.628px",
        "border-radius": "2px",
        "line-height": "1.1",
      }
    };
  },
  async mounted() {
    const data = await this.getGaroonSchedule(this.date.toFormat("yyyy-MM-dd"));
    this.html = this.formatSchedule(data);
  },
  methods: {
    getGaroonSchedule(d) {
      const url = `/g/api/v1/schedule/events?rangeStart=${d}T00:00:00%2b09:00&rangeEnd=${d}T23:59:59%2b09:00`;
      return axios.get(url, {
        headers: {
          "X-Requested-With": "XMLHttpRequest"
        }
      });
    },
    setMenuColor(plan) {
      switch (plan) {
        // 青
        case "打合":
        case "会議":
          return "blue";
        // 水色
        case "来訪":
          return "wblue";
        // オレンジ
        case "出張":
        case "ウルトラワーク":
          return "orange";
        // 赤
        case "複業":
        case "休み":
          return "red";
        // ピンク
        case "往訪":
          return "pink";
        // 紫
        case "面接":
        case "フェア":
          return "purple";
        // 茶色
        case "勉強会":
        case "タスク":
          return "brown";
        // グレー
        case "説明会":
        case "セミナー":
        case "その他":
          return "gray";
        // 黄緑
        case "終日":
          return "ygreen";
        // デフォルト
        default:
          return "normal";
      }
    },
    formatSchedule(schedule) {
      this.scheduleData = schedule.data.events
        .filter(val => val.eventType !== "ALL_DAY")
        .sort((a, b) => {
          if (a.start.dateTime > b.start.dateTime) return 1;
          else if (a.start.dateTime < b.start.dateTime) return -1;
          return 0;
        })
        .map(ele => {
          return {
            time: `${DateTime.fromISO(ele.start.dateTime).toFormat("HH:mm")}-${DateTime.fromISO(ele.end.dateTime).toFormat("HH:mm")}`,
            menu: ele.isAllDay ? "終日" : ele.eventMenu !== "" ?  ele.eventMenu: "",
            color: this.setMenuColor(ele.isAllDay ? "終日" : ele.eventMenu !== "" ?  ele.eventMenu: ""),
            id: ele.id,
            subject: ele.subject
          }
        });
    }
  }
};
</script>

<style></style>

App.vueからdateを受け取るようにしていて、そのdateでAPIで取得する日付を変えています
(このGaroon.vue自体はコンポーネントとして再利用したいので)

1日の予定がごそっと取得できるので、

  • いらない予定を消す(期間予定とか)
  • 開始時刻順にソート
  • 必要な情報を揃えて配列に追加
    • 開始時刻-終了時刻(hh:mm-hh:mm)
    • 予定メニュー名
    • 予定メニューの色
    • スケジュールID(リンク用)
    • スケジュールタイトル

といった感じに配列のメソッドを駆使してやってます。
あとは整えた配列をVue.jsの v-for を使っていい感じにループで表示させたりしています。

kintoneの処理

取得するレコードの条件を決めるのが一番めんどくさかったですね・・・

  • ステータスが「完了/中止/延期」以外のもの
  • ただし、「完了/中止/延期」に今日なったものは日報に書きたいので、「更新日時が今日のもの」は含める

が自分の中でしっくりくる条件でした。

Kintone.vue
<template>
  <div>
    <table style="text-align:center; border-collapse:collapse; border: 1px solid #333333;">
      <thead>
        <tr>
          <th :style="css">種類</th>
          <th :style="css">タスク名</th>
          <th :style="css">優先度</th>
          <th :style="css">ステータス</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="(val, index) in task" :key="index">
          <td :style="val.css">{{ val.type }}</td>
          <td :style="val.css" style="text-align: left">{{ val.title }}</td>
          <td :style="val.css">{{ val.triage }}</td>
          <td :style="val.css">{{ val.status }}</td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<script>
import axios from "axios";
export default {
  name: "KinTask",
  data() {
    return {
      task: [],
      css: {
        border: "1px solid #333333",
        padding: "3px"
      }
    }
  },
  async mounted() {
    const url = "/k/v1/records.json";
    const {data} = await axios.get(url, {
      app: XXXXX,
      query: 'status not in ("完了", "中止", "延期") or 更新日時 = TODAY() order by deadline asc, status asc, triage asc, maintitle asc limit 100'
    }, {headers: {
      "X-Requested-With": "XMLHttpRequest"
    }});
    this.task = data.records.map(ele => {
      const obj = {
        type: ele.type.value,
        title: ele.title.value,
        triage: ele.triage.value,
        status: ele.status.value,
        css: {
          border: "1px solid #333333",
          padding: "0 20px"
        }
      };
      obj.css['background-color'] = ele.status.value === '未処理' ? '#ffe391' : '';
      obj.css.color = (ele.status.value ===  "完了" || ele.status.value ===  "保留") ? '#ccc' : '#000';
      return obj
    });
  }
}
</script>

<style></style>

タスク一覧はテーブル形式で表示したいので、HTML側でテーブルを作成してGaroonと同じくv-forでタスクの数分ループしています。
まぁあとはコード見ればわかると思います(そこまで難しいことはしていないですw)

Animeの名台詞処理

なんか面白いAPIないかなーと調べていたら、アニメの名台詞が取得できるAPIがあったので組み込んでみました。

▼ The Anime chan API
https://animechanapi.xyz/

めっちゃシンプルな設計になっているので誰でも簡単に使えると思います!
(取得できる内容が英語なのが難点。。ただでさえ名台詞ってなんのやつかわからないのあるのにそれが英語とか・・・)

名探偵コナンは Detective Conan でした笑 Meitantei Conanじゃないのかー

AnimeChan.vue
<template>
  <div>
    <span>{{ text }}</span>
  </div>
</template>

<script>
import axios from 'axios';
export default {
  name: 'AnimeChan',
  data() {
    return {
      anime: '',
      text: '',
      chara: '',
    }
  },
  async mounted() {
    const url = 'https://animechanapi.xyz/api/quotes/random';
    const resp = await axios.get(url);
    this.anime = resp.data.data[0].anime;
    this.text = resp.data.data[0].quote;
    this.chara = resp.data.data[0].character;
  }
}
</script>

<style></style>

めっちゃシンプル!!

てきよう

あとは npm run build してVue.jsファイルをビルドして、
Chromeの拡張機能から**「パッケージ化されていない拡張機能を読み込む」** でディレクトリごと指定すればOKです!
manifest.json がちゃんと書けていれば問題なく読み込めると思います。

詳しくはこちらを↓
https://qiita.com/RyBB/items/32b2a7b879f21b3edefc#chrome%E3%81%B8%E3%81%AE%E9%81%A9%E7%94%A8%E6%96%B9%E6%B3%95

せんでん

急に宣伝しますが、「俺の自由研究-Vue.jsで始めるポータルカスタマイズ-」 ってタイトルの同人誌を作りました!

Vue.js初めて使うよって方でもわかりやすく、1から説明しているのでぜひぜひ!
(たぶん、今後のサイボウズのイベントで他の同人誌同様に売られると思います)

今なら技術書典10で電子版を無料配布もしているのでぜひ!
https://techbookfest.org/organization/16490002

スクリーンショット 2020-12-25 14.29.24.png

おわりに

ふぅ。。。めっちゃボリューミーな記事になってしまいました!!
必殺技の 「コードはGitHubにアップしているのであとはそちらを」 戦略をさっさと使えばよかったw

まぁ年末だしいいか。みんなこの 「おわりに」 までちゃんと読んでくれているでしょう。。

Vue.jsは手軽でいいぞ〜〜

では、みなさん良いお年を!! ≧(+・` ཀ・´)≦

86
65
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
86
65

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?