-
-
Save wukuan405/7bdd77f1bad486e1e60aa4681e98d766 to your computer and use it in GitHub Desktop.
Freeeの経費精算に為替換算機能を追加(インストール方法はこのページの一番下を参照 / The installation instruction is at the foot of the page)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name freee Echange Rate Converter | |
| // @namespace http://takram.com/ | |
| // @version 2018.09.14 | |
| // @match https://secure.freee.co.jp/expense_applications_v2 | |
| // @match https://secure.freee.co.jp/expense_applications_v2/* | |
| // @grant GM_xmlhttpRequest | |
| // @require https://unpkg.com/vue | |
| // ==/UserScript== | |
| (() => { | |
| function main() { | |
| observeExpenseTable() | |
| // Hide spin-button of input[type=number] | |
| addStyle(` | |
| input[type="number"]::-webkit-outer-spin-button, | |
| input[type="number"]::-webkit-inner-spin-button { | |
| -webkit-appearance: none; | |
| } | |
| input[type="number"] { | |
| -moz-appearance:textfield; | |
| } | |
| `) | |
| } | |
| // ----------------------------------------------------------------------------- | |
| // 経費精算テーブル表示・行追加を検知 | |
| // | |
| function observeExpenseTable() { | |
| let exist = false | |
| setInterval(() => { | |
| const expenseTable = document.querySelector('table.expense-application-line-table') | |
| if (expenseTable) { | |
| if (!exist) { | |
| if (document.querySelector('button.apply-button')) { | |
| // 経費精算 編集画面表示時 | |
| console.log('Expense table appears') | |
| exist = true | |
| setupTableHeaderAndFooter(expenseTable) | |
| } else { | |
| // 経費精算 閲覧画面 | |
| console.log('Expense table appears, but not editable') | |
| } | |
| } | |
| if (exist) { | |
| setupTableBody(expenseTable) | |
| } | |
| } else { | |
| if (exist) { | |
| // 経費精算詳細から一覧に戻った場合 | |
| console.log('Expense table disappears') | |
| exist = false | |
| } else { | |
| console.log('Wait for expense table to appear...') | |
| } | |
| } | |
| }, 1000) | |
| } | |
| // ----------------------------------------------------------------------------- | |
| // ヘッダー・フッターのカラムを追加 | |
| // | |
| function setupTableHeaderAndFooter(expenseTable) { | |
| // thead | |
| const theadRow = expenseTable.querySelector('thead > tr') | |
| const amountTh = theadRow.querySelectorAll('th')[5] | |
| amountTh.querySelector('span').innerText = '金額(円)' | |
| amountTh.insertAdjacentHTML( | |
| 'afterend', | |
| '<th class="sw-col-number"><span class="sw-basic-table-label">外貨</span></th>' | |
| ) | |
| // tfoot | |
| const tfootRow = expenseTable.querySelector('tfoot > tr') | |
| tfootRow.querySelectorAll('td')[1].setAttribute('colspan', 6) | |
| } | |
| function setupTableBody(expenseTable) { | |
| const rows = expenseTable.querySelectorAll('tbody > tr') | |
| if (!rows) return | |
| for (const row of rows) { | |
| // 行に為替機能を追加済みかどうかを確認 | |
| if (!row.querySelector('td.exchange-rate-column')) { | |
| console.log('New row detected') | |
| setupTableRow(row) | |
| } | |
| } | |
| } | |
| // ----------------------------------------------------------------------------- | |
| // 各行に為替機能を追加 | |
| // | |
| function setupTableRow(row) { | |
| const dateInput = row.querySelector('input.date-input') | |
| const descriptionTextArea = row.querySelector('textarea') | |
| // Add td | |
| console.log('Add td') | |
| const jpyAmountCell = row.querySelector('td.amount-column') | |
| const foreignCurrencyContainer = document.createElement('td') | |
| foreignCurrencyContainer.classList.add('exchange-rate-column') | |
| foreignCurrencyContainer.style.width = '120px' | |
| row.insertBefore(foreignCurrencyContainer, jpyAmountCell.nextSibling) | |
| // Append ForeignCurrency component | |
| console.log('Add ForeignCurrency vm') | |
| const jpyInput = row.querySelector('input.sw-number-input') | |
| const foreignCurrencyVM = new ForeignCurrency().$mount() | |
| foreignCurrencyVM.date = parseDateString(dateInput.value) | |
| // 内容欄に外貨決済額が書かれていればフォームもその値に更新 | |
| const memo = getDescriptionMemo(descriptionTextArea) | |
| if (memo) { | |
| foreignCurrencyVM.currencyCode = memo.currencyCode | |
| foreignCurrencyVM.localAmount = memo.localAmount | |
| } | |
| foreignCurrencyVM.$on('update', (jpyAmount, currencyCode, localAmount) => { | |
| NativeTextBox.setValue(jpyInput, jpyAmount) | |
| updateDescriptionMemo(descriptionTextArea, currencyCode, localAmount) | |
| }) | |
| foreignCurrencyContainer.appendChild(foreignCurrencyVM.$el) | |
| // Watch date input | |
| watchDateInput(dateInput, (value) => { | |
| const date = parseDateString(value) | |
| if (date) { | |
| foreignCurrencyVM.date = date | |
| } | |
| }) | |
| } | |
| function updateDescriptionMemo(textArea, code, amount) { | |
| const newMemo = `外貨決済: ${amount} ${code}` | |
| const regex = /^外貨決済:\s*[\d\.\,\-]+\s*[A-Z]{3}/ | |
| const lines = NativeTextBox.getValue(textArea).split(/\r\n|\n|\r/) | |
| let memoExist = false | |
| let lineCount = 0 | |
| // すでにメモが書かれていたら更新 | |
| for (const line of lines) { | |
| if (line.match(regex)) { | |
| lines[lineCount] = newMemo | |
| memoExist = true | |
| break | |
| } | |
| lineCount++ | |
| } | |
| // 何も書かれていなければメモを追加 | |
| if (!memoExist && code != 'JPY') { | |
| lines.push(newMemo) | |
| } | |
| // 金額が空または日本円の場合はメモを削除 | |
| if (memoExist && (amount == '' || code == 'JPY')) { | |
| lines.splice(lineCount, 1) | |
| } | |
| NativeTextBox.setValue(textArea, lines.join('\n')) | |
| } | |
| function getDescriptionMemo(textArea) { | |
| const lines = NativeTextBox.getValue(textArea).split(/\r\n|\n|\r/) | |
| let memoExist = false | |
| let lineCount = 0 | |
| for (const line of lines) { | |
| const matchData = line.match(/^外貨決済:\s*([\d\.\,\-]+)\s*([A-Z]{3})/) | |
| if (matchData) { | |
| return { | |
| currencyCode: matchData[2], | |
| localAmount: parseFloat(matchData[1]), | |
| } | |
| } | |
| } | |
| return null | |
| } | |
| // ----------------------------------------------------------------------------- | |
| // Utilities | |
| // | |
| function watchDateInput(dateInput, onChange) { | |
| let previousValue = dateInput.value | |
| let timer = null | |
| dateInput.addEventListener('focus', () => { | |
| clearInterval(timer) | |
| timer = window.setInterval(() => { | |
| const newValue = dateInput.value | |
| if (newValue != previousValue) { | |
| previousValue = newValue | |
| onChange(newValue) | |
| } | |
| }, 100) | |
| }, false) | |
| dateInput.addEventListener('blur', () => { | |
| clearInterval(timer) | |
| }, false) | |
| } | |
| class NativeTextBox { | |
| static setValue(element, value) { | |
| Object.getOwnPropertyDescriptor( | |
| NativeTextBox.getElementObj(element), | |
| "value" | |
| ).set.call(element, value) | |
| element.dispatchEvent(new Event('input', {bubbles: true})) | |
| } | |
| static getValue(element) { | |
| return Object.getOwnPropertyDescriptor( | |
| NativeTextBox.getElementObj(element), | |
| "value" | |
| ).get.call(element) | |
| } | |
| static getElementObj(element) { | |
| if (element.localName == 'textarea') { | |
| return window.HTMLTextAreaElement.prototype | |
| } | |
| return window.HTMLInputElement.prototype | |
| } | |
| } | |
| function parseDateString(dateStr) { | |
| const buf = dateStr.split('-') | |
| if (buf.length != 3) return null | |
| const year = parseInt(buf[0]) | |
| const month = parseInt(buf[1]) | |
| const date = parseInt(buf[2]) | |
| if (isNaN(year) || isNaN(month) || isNaN(date)) return null | |
| return new Date(year, month-1, date) | |
| } | |
| function formatDateStr(date) { | |
| const y = date.getFullYear().toString() | |
| const m = (date.getMonth() + 1).toString().padStart(2, '0') | |
| const d = date.getDate().toString().padStart(2, '0') | |
| return `${y}-${m}-${d}` | |
| } | |
| function addStyle (cssStr) { | |
| var D = document; | |
| var newNode = D.createElement ('style'); | |
| newNode.textContent = cssStr; | |
| var targ = D.getElementsByTagName ('head')[0] || D.body || D.documentElement; | |
| targ.appendChild (newNode); | |
| } | |
| // ----------------------------------------------------------------------------- | |
| // ForeignCurrency Component | |
| // | |
| const ForeignCurrency = Vue.extend({ | |
| template: ` | |
| <div class="sw-basic-table-col-detail"> | |
| <div v-if="currencyCode != 'JPY'"> | |
| <input type="number" | |
| v-model="localAmount" | |
| ref="localAmount" | |
| class="sw-number-input" | |
| step="0.001" | |
| style="margin-bottom: 6px;" | |
| /> | |
| </div> | |
| <select v-model="currencyCode" style="width: 110px; margin-bottom: 6px;"> | |
| <option v-for="code in codes" :value="code"> | |
| {{ currencyCodeToUnit(code) }} | |
| </option> | |
| </select> | |
| <div v-if="currencyCode != 'JPY'"> | |
| <div style="color: #666;"> | |
| {{ currencyCodeToUnit(currencyCode) }}/円: | |
| {{ exchangeRate }} | |
| </div> | |
| <div> | |
| <a v-bind:href="url" target="_blank"> | |
| {{ exchangeRateDate }} | |
| </a> | |
| </div> | |
| </div> | |
| </div> | |
| `, | |
| data: () => { | |
| return { | |
| currencyCode: 'JPY', | |
| exchangeRate: 1, | |
| localAmount: null, | |
| date: null, | |
| exchangeRateDate: null, | |
| url: null, | |
| currencyCodeMap: { | |
| 'JPY': '円', | |
| 'USD': 'ドル', | |
| 'EUR': 'ユーロ', | |
| 'GBP': 'ポンド', | |
| 'CHF': 'スイス・フラン', | |
| 'DKK': 'デンマーク・クローネ', | |
| 'NOK': 'ノルウェー・クローネ', | |
| 'SEK': 'スウェーデン・クローネ', | |
| 'RUB': 'ロシア・ルーブル', | |
| 'CZK': 'チェコ・コルナ', | |
| 'HUF': 'ハンガリー・フォリント', | |
| 'PLN': 'ポーランド・ズロチ', | |
| 'CAD': 'カナダ・ドル', | |
| 'AUD': 'オーストラリア・ドル', | |
| 'NZD': 'ニュージーランド・ドル', | |
| 'CNY': '中国・人民元', | |
| 'HKD': '香港・ドル', | |
| 'KRW': '韓国ウォン', | |
| 'SGD': 'シンガポール・ドル', | |
| 'MYR': 'マレーシア・リンギット', | |
| 'SAR': 'サウジ・リアル', | |
| 'AED': 'UAEディルハム', | |
| 'THB': 'タイ・バーツ', | |
| 'INR': 'インド・ルピー', | |
| 'PKR': 'パキスタン・ルピー', | |
| 'KWD': 'クウェート・ディナール', | |
| 'QAR': 'カタール・リアル', | |
| 'IDR': 'インドネシア・ルピア', | |
| 'MXN': 'メキシコ・ペソ', | |
| 'PHP': 'フィリピン・ペソ', | |
| 'TRY': 'トルコ・リラ', | |
| 'ZAR': '南アフリカ・ランド', | |
| }, | |
| codes: [ | |
| 'JPY', 'USD', 'EUR', 'GBP', 'CHF', 'DKK', 'NOK', 'SEK', 'RUB', 'CZK', | |
| 'HUF', 'PLN', 'CAD', 'AUD', 'NZD', 'CNY', 'HKD', 'KRW', 'SGD', 'MYR', | |
| 'SAR', 'AED', 'THB', 'INR', 'PKR', 'KWD', 'QAR', 'IDR', 'MXN', 'PHP', | |
| 'TRY', 'ZAR', | |
| ], | |
| } | |
| }, | |
| computed: { | |
| jpyAmount: function() { | |
| return Math.ceil(this.localAmount * this.exchangeRate) | |
| }, | |
| }, | |
| watch: { | |
| jpyAmount: function() { | |
| this.$emit('update', this.jpyAmount, this.currencyCode, this.localAmount) | |
| }, | |
| date: function() { | |
| if (this.date) { | |
| this.updateExchangeRate() | |
| } | |
| }, | |
| currencyCode: function() { | |
| this.updateExchangeRate() | |
| if (this.currencyCode != 'JPY') { | |
| setTimeout(() => { | |
| this.$refs.localAmount.focus() | |
| }, 200) | |
| } | |
| }, | |
| }, | |
| methods: { | |
| updateExchangeRate: function() { | |
| if (this.currencyCode == 'JPY') { | |
| this.exchangeRate = 1 | |
| return | |
| } | |
| const mufg = new MUFGExchangeRate() | |
| mufg.find(this.date, this.currencyCode, (tts, date, url) => { | |
| console.log(tts, date) | |
| this.exchangeRate = tts | |
| this.exchangeRateDate = formatDateStr(date) | |
| this.url = url | |
| }) | |
| }, | |
| currencyCodeToUnit: function(code) { | |
| return this.currencyCodeMap[code] | |
| }, | |
| } | |
| }) | |
| // ----------------------------------------------------------------------------- | |
| // MUFGExchangeRate | |
| // | |
| class MUFGExchangeRate { | |
| find(date, code, onLoad) { | |
| this.date = date | |
| this.code = code | |
| this.onLoad = onLoad | |
| this.fetch() | |
| } | |
| fetch() { | |
| console.log(`Fetch ${this.getUrl()}`) | |
| GM_xmlhttpRequest({ | |
| url: this.getUrl(), | |
| overrideMimeType:"text/plain; charset=Shift_JIS", | |
| onload: (res) => { | |
| this.parse(res) | |
| }, | |
| onerror: (res) => { | |
| console.error('Fetch error') | |
| } | |
| }) | |
| } | |
| getUrl() { | |
| const y = (this.date.getFullYear() - 2000).toString().padStart(2, '0') | |
| const m = (this.date.getMonth() + 1).toString().padStart(2, '0') | |
| const d = this.date.getDate().toString().padStart(2, '0') | |
| return `http://www.murc-kawasesouba.jp/fx/past/index.php?id=${y}${m}${d}` | |
| } | |
| fetchPreviousDate() { | |
| console.log('Fetch previous date') | |
| this.date.setDate(this.date.getDate() - 1) | |
| this.fetch() | |
| } | |
| parse(res) { | |
| const doc = new DOMParser().parseFromString(res.responseText, 'text/html') | |
| const document = doc.documentElement | |
| const table = document.querySelector('table.data-table7') | |
| if (!table) { | |
| return this.fetchPreviousDate() | |
| } | |
| for (const row of table.querySelectorAll('tr')) { | |
| const cells = row.querySelectorAll('td') | |
| if (cells.length == 0) continue | |
| // 3列目がCode | |
| const code = cells[2].textContent | |
| if (code == this.code) { | |
| // 4列目がTTS | |
| const tts = parseFloat(cells[3].textContent) | |
| return this.onLoad(tts, this.date, this.getUrl()) | |
| } | |
| } | |
| console.error(`Can't find ${this.code} in the table`) | |
| } | |
| } | |
| main() | |
| })() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment