Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save wukuan405/7bdd77f1bad486e1e60aa4681e98d766 to your computer and use it in GitHub Desktop.

Select an option

Save wukuan405/7bdd77f1bad486e1e60aa4681e98d766 to your computer and use it in GitHub Desktop.
Freeeの経費精算に為替換算機能を追加(インストール方法はこのページの一番下を参照 / The installation instruction is at the foot of the page)
// ==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