Skip to content

Commit

Permalink
modify README.md and add source codes
Browse files Browse the repository at this point in the history
  • Loading branch information
hidetak committed Feb 27, 2020
1 parent 281d087 commit 0a0820d
Show file tree
Hide file tree
Showing 4 changed files with 393 additions and 0 deletions.
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,30 @@

[Trello](https://trello.com/)で保存した情報をCSV形式で出力するコマンドラインツールです。

カードがどのくらいの時間、リストに存在していたがを集計するために利用することを想定しています。例えば、作業中はDoingという名前のリストにCardを移動し、終了したり、別の作業を開始するときはDoingリストの外に出すというルールでTrelloを運用することで、各カードに要した時間を計測できます。

[Node.js](https://nodejs.org/)上で動作します。

## CSVで出力するデータ

本ツールでは以下のデータをTrelloから抜き出し、リストの出し入れば発生する毎を一つの行として出力します。

|名前||説明|
| --- | --- | --- |
|cardId|string|Cardを一意に識別するためのIDです。|
|number|string|Cardのタイトルが`#123 名前`のような形式であった場合、123が入ります。対応する文字列を検出できなかった場合は`-`が入ります。|
|title|string|CardのTitleが入ります。上記numberが抽出された場合は、number以外の部分が入ります。|
|point|string|Cardの説明に`Point: 3`のような文字列があった場合、3の部分を抜き出します。Agileのストーリーポイントにより見積もりを行う場合、見積もり結果を集計することを想定します。|
|listName|string|リストの名前です。|
|inDate|Date|リストにCardが入った時刻です。|
|outDate|Date|リストからCardが出た時刻です。|
|time|string|リストに入ってから出るまでにかかった時間です。単位はhoursです。|
|labelPink|string|Cardに付与したPinkラベルに記載した文字列です。|
|labelGreen|string|Cardに付与したGreenまたはLimeのラベルに記載した文字列です。|
|member|string|Cardのメンバーを出力します。複数メンバーがいる場合はカンマ区切りで表示します。|

上記に加え、集計結果の抽出した全行のtimeの合計と全カードのpointの合計も出力します。

## 使い方

本ツールを利用するPCにNode.js(v10以上)をインストールします(説明は省略します)。
Expand Down Expand Up @@ -52,3 +74,43 @@ package.jsonをテキストエディタで開くと以下のような記載を
> npm start
```

実行すると、最初にBoardのリストが表示されますので、CSVを作成したいボードの左側に表示された番号を入力します。

```
1: Trelloへようこそ!
2: hoge1
3: hoge2
select board number: 2
```

しばらく待つと次に以下が表示されます。

```
0: Show only header
1: All
2: Specify condition by js
Select output data:
```

出力したいデータの種類を左側の番号で選択します。

0を入力すると、CSVのヘッダのみを標準出力に出力します。
1を入力すると、全データをCSV形式で標準出力に出力します。
2を入力すると、JavaScriptの条件文により出力するデータを選択できるようになります。

```
0: Show only header
1: All
2: Specify condition by js
Select output data: 2
input condition by js: listName === "Doing"
```

条件文の例を示します。

* Doingという名前のリストに滞在した時間のみ集計したい場合
`listName === "Doing"`
* さらにメンバーの名前がtrellouserのみ集計したい場合
`listName === "Doing && member === "trellouser"`
* さらに特定の日付の範囲のCSVのみ抽出したい場合
`listName === "Doing && member === "trellouser" && inDate > new Date("2020/2/22") && outDate < new Date("2020/2/29")`
288 changes: 288 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
const readLine = require('readline')
const fetch = require('node-fetch')
const moment = require('moment-timezone')

if (process.argv.length !== 5) {
console.error(
'usage: node index.js <Trello KEY> <Trello TOKEN> <Trello USERNAME>'
)
process.exit()
}

const KEY = process.argv[2]
const TOKEN = process.argv[3]
const USERNAME = process.argv[4]

const BOARDS_URL = `https://trello.com/1/members/${USERNAME}/boards?key=${KEY}&token=${TOKEN}&fields=name,memberships`
const MEMBERS_URL = `https://trello.com/1/members/$$MEMBER_ID$$/?fields=fullName,username&key=${KEY}&token=${TOKEN}`
const LISTS_URL = `https://trello.com/1/boards/$$BOARD_ID$$/lists?fields=name,url&key=${KEY}&token=${TOKEN}`
const CARDS_URL = `https://trello.com/1/lists/$$LIST_ID$$/cards?key=${KEY}&token=${TOKEN}&fields=name,labels,idMembers,desc`
const ACTIONS_URL = `https://trello.com/1/cards/$$CARD_ID$$/actions?key=${KEY}&token=${TOKEN}&filter=all`

// Debugの出力を抑止
console.debug = () => {}

const readUserInput = (question, initialInput) => {
const rl = readLine.createInterface({
input: process.stdin,
output: process.stdout
})

return new Promise((resolve, reject) => {
rl.question(question, answer => {
resolve(answer)
rl.close()
})
if (initialInput) {
rl.write(initialInput)
}
})
}

const inputText = async (questionText, re, initialInput) => {
while (true) {
let a = await readUserInput(questionText, initialInput)
if (a && a.match(re)) {
return a
} else {
console.log('error: invalid input')
}
}
}

const getDataFromTrello = async url => {
let response = await fetch(url)
return await response.json()
}

const writeLine = d => {
let inDate = moment(d.inDate)
.tz('Asia/Tokyo')
.format('YYYY/MM/DD HH:mm:ss')
let outDate = moment(d.outDate)
.tz('Asia/Tokyo')
.format('YYYY/MM/DD HH:mm:ss')
console.log(
`"${d.cardId}","${d.number}","${d.title}","${d.point}","${d.listName}","${inDate}","${outDate}","${d.time}","${d.labelPink}","${d.labelGreen}","${d.member}"`
)
}

const parseData = (actionMap, cardsMap) => {
let list = []
for (const key in actionMap) {
const actions = actionMap[key]
let cardId = cardsMap[key].id
let labels = cardsMap[key].labelsMap
let cardName = cardsMap[key].name
let matched = cardName.match('^#([0-9]+)[ |:](.+)')
let number = matched && matched[1] ? matched[1].trim() : '-'
let title = matched && matched[2] ? matched[2].trim() : cardName
let member = cardsMap[key].membersText ? cardsMap[key].membersText : '-'
let desc = cardsMap[key].desc
let point = 0
if (desc && desc.match('(Point|point|POINT) *: *([0-9]+)')) {
point = Number(desc.match('(Point|point|POINT) *: *([0-9]+)')[2])
}
let current
for (let i = actions.length - 1; i >= 0; i--) {
const a = actions[i]
let listName = a.data.list ? a.data.list.name : '-'
let date = new Date(a.date)
if (!current) {
current = {
cardId,
number,
title,
point,
listName,
inDate: date,
labelPink: labels['pink'] ? labels['pink'] : '-',
labelGreen: labels['green']
? labels['green']
: labels['lime']
? labels['lime']
: '-',
member
}
}
if (a.data.listBefore) {
current['listName'] = a.data.listBefore.name
current['outDate'] = new Date(a.date)
current['time'] =
(current['outDate'].getTime() - current['inDate'].getTime()) /
1000 /
60 /
60
list.push(JSON.parse(JSON.stringify(current)))
}
if (a.data.listAfter) {
current = {
cardId,
number,
point,
title,
listName: a.data.listAfter.name,
inDate: new Date(a.date),
labelPink: labels['pink'] ? labels['pink'] : '-',
labelGreen: labels['green']
? labels['green']
: labels['lime']
? labels['lime']
: '-',
member
}
}
}
if (current) {
current['outDate'] = new Date()
current['time'] =
(current['outDate'].getTime() - current['inDate'].getTime()) /
1000 /
60 /
60
list.push(JSON.parse(JSON.stringify(current)))
}
}
return list
}

const writeList = list => {
console.log(
'cardId,number,title,point,listName,inDate,outDate,time,labelPink,labelGreen,member'
)
let total = 0
let cardPointMap = {}
for (let d of list) {
writeLine(d)
total += new Date(d.outDate).getTime() - new Date(d.inDate).getTime()
cardPointMap[d.cardId] = d.point
}
let totalPoint = 0
for (let k in cardPointMap) {
totalPoint += cardPointMap[k]
}
console.log(`total: ${total / 1000 / 60 / 60} hrs`)
console.log(`total point: ${totalPoint}`)
}

const main = async () => {
try {
// Board一覧取得
let boardList = await getDataFromTrello(BOARDS_URL)
console.debug('boardList:', JSON.stringify(boardList, true, ' '))
// Board一覧表示
let num = 1
for (const item of boardList) {
console.log(`${num++}: ${item.name}`)
}
// Board選択
let i = await inputText('select board number: ', `^[1-${num - 1}]$`)
const selectedBoard = boardList[Number(i - 1)]
// User取得
let memberMap = {}
const memberships = selectedBoard.memberships
for (const m of memberships) {
memberMap[m.idMember] = await getDataFromTrello(
MEMBERS_URL.replace('$$MEMBER_ID$$', m.idMember)
)
}
console.debug('members:', memberMap)
// List
let lists = await getDataFromTrello(
LISTS_URL.replace('$$BOARD_ID$$', selectedBoard.id)
)
// 全カード取得
let cards = []
for (const item of lists) {
let list = await getDataFromTrello(
CARDS_URL.replace('$$LIST_ID$$', item.id)
)
cards = cards.concat(list)
}
console.debug('cards:', JSON.stringify(cards, true, ' '))
// カードに対するActionを取得
let actionMap = {}
let cardsMap = {}
for (const c of cards) {
actionMap[c.id] = await getDataFromTrello(
ACTIONS_URL.replace('$$CARD_ID$$', c.id)
)
if (c.idMembers) {
let members = []
for (const m of c.idMembers) {
members.push(memberMap[m].fullName)
}
c['membersText'] = members.join(',')
}
labelsMap = {}
if (c.labels) {
for (const l of c.labels) {
labelsMap[l.color] = l.name
}
}
c['labelsMap'] = labelsMap
cardsMap[c.id] = c
}
console.debug('actionMap:', JSON.stringify(actionMap, true, ' '))
// Parse
let list = parseData(actionMap, cardsMap)
// 出力内容選択
let initialCondition = ''
while (true) {
console.log('----------')
console.log('0: Show only header')
console.log('1: All')
console.log('2: Specify condition by js')
const type = await inputText('Select output data: ', `^[0-2]$`)
if (type === '0') {
console.log('----------')
console.log(
'cardId,number,title,point,listName,inDate,outDate,time,labelPink,labelGreen,member'
)
} else if (type === '1') {
console.log('----------')
writeList(list)
} else if (type === '2') {
const condition = await inputText(
'input condition by js: ',
`.+`,
initialCondition
)
console.log('----------')
let newList = []
let total = 0
for (let d of list) {
let {
cardId,
number,
title,
point,
listName,
inDate,
outDate,
time,
labelPink,
labelGreen,
member
} = d
inDate = new Date(inDate)
outDate = new Date(outDate)
try {
if (eval(condition)) {
newList.push(d)
}
} catch (err) {
console.error(err)
break
}
}
writeList(newList)
initialCondition = condition
}
}
} catch (err) {
console.error(err)
}
}

main()
26 changes: 26 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 0a0820d

Please sign in to comment.