Mint.com tags display

Show tags in the transactions listing on Mint.com.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name        Mint.com tags display
// @match       https://mint.intuit.com/transactions
// @connect     mint.intuit.com
// @description Show tags in the transactions listing on Mint.com.
// @namespace   com.warkmilson.mint.js
// @author      Mark Wilson (update by Shaun Williams)
// @version     2.0.1
// @homepage    https://github.com/mddub/mint-tags-display
// @grant       none
// @noframes
// ==/UserScript==
//

(function() {

  //------------------------------------------------------------------------------
  // Logging
  //------------------------------------------------------------------------------

  const logging = false
  function log(...args) {
    if (logging) console.info('MINT_TAGS', ...args)
  }

  //------------------------------------------------------------------------------
  // Track state by watching XHR
  //------------------------------------------------------------------------------

  // State

  const state = {
    txnTags: {}, // txn id -> [tag name]
    tagOrder: [], // [tag name]
    tagName: {}, // tag id -> tag name
  }
  window._MINT_TAGS = state

  // Update state

  const apiUrl = path => `https://mint.intuit.com/pfm/v1${path}`

  const apiHooks = {
    // when transactions are fetched, save tags belonging to each transaction
    [apiUrl('/transactions/search')]: data => {
      for (const {id,tagData} of data.Transaction) {
        state.txnTags[id] = tagData?.tags.map(tag => tag.name)
      }
    },
    // when the master tag list is fetched, save it
    [apiUrl('/tags')]: data => {
      state.tagOrder = data.Tag.map(tag => tag.name)
      state.tagName = Object.fromEntries(data.Tag.map(tag => [tag.id, tag.name]))
    },
  }

  // when transactions are edited, update our tag records
  function handleTxnEdits(edits) {
    const idsToUpdate = []
    for (const {id,tagData} of edits) {
      if (tagData) {
        state.txnTags[id] = tagData.tags.map(tag => state.tagName[tag.id])
        idsToUpdate.push(id)
      }
    }
    setTimeout(() => idsToUpdate.forEach(updateRowTags), 500)
  }

  // hook XHR to intercept api calls
  function watchXHR() {
    const origOpen = XMLHttpRequest.prototype.open

    XMLHttpRequest.prototype.open = function(method, url) {
      const self = this

      // save XHR responses when needed
      self.addEventListener("readystatechange", function() {
        const hook = apiHooks[url]
        if (self.readyState === 4 && hook) {
          const data = JSON.parse(self.responseText)
          log('HOOKING', url, data)
          hook(data)
        }
      }, false)

      // intercept edits to transactions
      const txnsUrl = apiUrl('/transactions')
      if (method == 'PUT' && url.startsWith(txnsUrl)) {
        const origSend = self.send
        self.send = function(body) {
          const data = JSON.parse(body)
          const edits = url==txnsUrl ? data.Transaction : [{...data, id:url.slice(txnsUrl.length+1)}]
          log('HOOKING EDITS', edits)
          handleTxnEdits(edits)
          origSend.apply(self, arguments)
        }
      }

      origOpen.apply(self, arguments)
    }
  }

  //------------------------------------------------------------------------------
  // Render DOM
  //------------------------------------------------------------------------------

  var TAG_COLORS = [
    // source: http://colorbrewer2.org/#type=qualitative&scheme=Paired&n=12
    // background, foreground
    ['#a6cee3', '#000'],
    ['#b2df8a', '#000'],
    ['#fb9a99', '#000'],
    ['#fdbf6f', '#000'],
    ['#cab2d6', '#000'],
    ['#ffff99', '#000'],
    ['#1f78b4', '#fff'],
    ['#33a02c', '#fff'],
    ['#e31a1c', '#fff'],
    ['#ff7f00', '#fff'],
    ['#6a3d9a', '#fff'],
    ['#b15928', '#fff']
  ];

  function getTagStyle(tag) {
    const i = state.tagOrder.indexOf(tag)
    const [bg,fg] = TAG_COLORS[i]
    return `background:${bg}; color:${fg}`
  }

  // re-render our custom tag annotations in this row
  function updateRowTags(id) {
    log('UPDATING ROW', id)
    const td = document.querySelector(`tr[data-automation-id$=_${id}] td:nth-child(4)`)
    if (!td) return

    const tags = state.txnTags[id]
    const tagsDiv = () => td.querySelector('.gm-tags')
    if (tags?.length) {
      if (!tagsDiv()) td.innerHTML += '<div class="gm-tags" style="font-size:10px; display:inline-block"></div>'
      tagsDiv().innerHTML = tags.map(tag => `<span class="gm-tag" style="${getTagStyle(tag)}; margin-left:4px; padding:0 2px">${tag}</span>`).join('')
    } else {
      tagsDiv()?.remove()
    }
  }

  const rowId = row => row?.dataset?.automationId?.match(/TRANSACTION_TABLE_ROW_(READ|EDIT)_(.*)/)?.[2]

  function initRender() {
    if (!document.querySelector('tr[data-automation-id]')) {
      return setTimeout(initRender, 500)
    }
    log('FOUND TABLE')
    for (const row of document.querySelectorAll('tr[data-automation-id]')) {
      updateRowTags(rowId(row))
    }
    renderWhenDomChanges()
  }

  function renderWhenDomChanges() {
    log('WATCHING FOR CHANGES')
    const observer = new MutationObserver(() => {
      observer.disconnect()
      document.querySelectorAll('tr[data-automation-id]').forEach(row => updateRowTags(rowId(row)))
      log('RELAUNCHING WATCH')
      renderWhenDomChanges()
    })
    observer.observe(document.body, {subtree: true, childList: true, characterData: true})
  }

  //------------------------------------------------------------------------------
  // Main
  //------------------------------------------------------------------------------

  watchXHR()
  initRender()

})();