NeoFinancial export transactions as CSV

Adds a button to transactions page that exports all transactions into a CSV file. Developed for use with "Actual" budgeting tool, will probably work fine with any other importer.

< 脚本NeoFinancial export transactions as CSV的反馈

评价:一般 - 脚本能用,但还有一些问题

§
发表于:2025-01-20

Great script. I was using using up until it stopped working, not the scripts fault but looks like NEO updated their website so now when the script tries to find the filters element in the query selector it returns nothing and just stops working.

I've modified addDownloadButtons and keepButtonShown to get it to draw the download buttons on top of the existing page in the bottom left. In addition to removing the other references to the transactionFiltersQuery.

Current downside is that the buttons will appear no matter what. I think maybe we can check the url to see if we are on the */transactions page but other than that here's what I got.

Removing the transactionFiltersQuery from detectPageType function and PageInfo object

/**
 * Identifies if current page is a transactions page and returns apropriate button callback
 *
 * @typedef {Object} PageInfo
 * @property {boolean} isTransactionsPage
 * @property {AccountInfo?} accountInfo
 */

/**
 * @typedef {Object} AccountInfo
 * @property {string} id
 * @property {string} name
 * @property {"credit"|"savings"} type
 * @property {TransactionCallback} transactionsCallback
 */

/**
 * @returns {Promise<PageInfo>}
 */
async function detectPageType() {
    /**
     * @type {PageInfo}
     */
    let pageInfo = {
        isTransactionsPage: false,
        accountInfo: null,
    };

    let pathParts = window.location.pathname.split("/");
    if (pathParts[pathParts.length - 1] !== "transactions") {
        return pageInfo;
    } else {
        pageInfo.isTransactionsPage = true;
    }

    let accountsIdx = pathParts.findIndex((v) => v === "accounts");

    let accountType = pathParts[accountsIdx + 1];
    let accountId = pathParts[accountsIdx + 2];

    // Handling different types of accounts
    if (accountType === "credit") {
        pageInfo.accountInfo = {
            id: accountId,
            type: accountType,
            // Looks like credit account cannot have custom name, hardcoding it
            name: "Credit",
            transactionsCallback: creditTransactions,
        };
    } else if (accountType === "savings") {
        pageInfo.accountInfo = {
            id: accountId,
            type: accountType,
            name: await savingsAccountName(accountId),
            transactionsCallback: savingsTransactions,
        };
    }

    if (pageInfo.accountInfo !== null) {
        console.log(`[csv-export] Transactions history page of type '${accountType}' detected`);
    }

    return pageInfo;
}

Updates to addDownloadButtons

/**
 * Inserts a CSV export button in a box on the bottom left of the screen
 *
 * @param {AccountInfo?} accountInfo
 */
function addDownloadButtons(accountInfo) {
    if (!accountInfo) {
        return;
    }

    // Create a container for the buttons
    const buttonContainer = document.createElement('div');
    buttonContainer.style.position = 'fixed';
    buttonContainer.style.bottom = '10px';
    buttonContainer.style.left = '10px';
    buttonContainer.style.zIndex = '1000'; // Ensure it is on top of other elements
    buttonContainer.style.backgroundColor = 'rgba(255, 255, 255, 0.8)'; // Optional: Add a background for better visibility
    buttonContainer.style.padding = '10px'; // Optional: Add some padding
    buttonContainer.style.borderRadius = '5px'; // Optional: Add rounded corners

    let csvExportRow = document.createElement("div");
    csvExportRow.id = exportCsvId;
    csvExportRow.style.display = "flex";
    csvExportRow.style.alignItems = "center";
    csvExportRow.style.gap = "1em";

    let csvExportText = document.createElement("div");
    csvExportText.innerText = "Export as CSV:";
    csvExportText.style.fontFamily = buttonStyle.fontFamily;
    csvExportText.style.fontWeight = "400";
    csvExportRow.appendChild(csvExportText);

    const now = new Date();
    const buttons = [
        {
            text: "This Month",
            fromDate: new Date(now.getFullYear(), now.getMonth(), 1),
        },
        {
            text: "Last 3 Months",
            fromDate: new Date(now.getFullYear(), now.getMonth() - 3, 1),
        },
        {
            text: "All",
            fromDate: null,
        },
    ];

    for (const button of buttons) {
        let filters = [];
        if (button.fromDate) {
            filters = [
                {
                    field: "authorizationProcessedAt",
                    operator: "GTE",
                    type: "DATE",
                    value: button.fromDate.toISOString(),
                },
            ];
        }

        let exportButton = document.createElement("button");
        exportButton.innerText = button.text;
        exportButton.onclick = saveBlobToFileCallback(
            accountInfo,
            filters,
            button.fromDate,
        );
        Object.assign(exportButton.style, buttonStyle);
        let exportButtonBox = document.createElement("div");
        exportButtonBox.className = "MuiBox-root";
        exportButtonBox.appendChild(exportButton);

        csvExportRow.appendChild(exportButtonBox);
    }

    buttonContainer.appendChild(csvExportRow);
    document.body.appendChild(buttonContainer);
}

Updates to keepButtonShown

/**
 * Keeps button shown after rerenders and href changes
 * @returns {Promise<void>}
 */
async function keepButtonShown() {
    // Early exit, to avoid unnecessary requests if already injected
    if (document.querySelector(`div#${exportCsvId}`)) {
        return;
    }

    const pageInfo = await detectPageType();
    if (!pageInfo.isTransactionsPage) {
        return;
    }

    // Intentional duplicate, avoiding race condidion on detectPageType call
    if (document.querySelector(`div#${exportCsvId}`)) {
        return;
    }
    addDownloadButtons(pageInfo.accountInfo);
}
§
发表于:2025-02-12

Hey, thanks for this. Unfortunately, I don't really have time to properly review this now, so for now I just tweaked my existing setup to start working again. As my github repo for this states - all of my userscripts are under MIT license: https://github.com/eaglesemanation/userscripts. So feel free to redistribute your version, or maybe just open a PR on that repo and at some point I'll have time to check it out.

发表回复

登录以发表回复。