[Feature] Migrate from Quicken (import everything and recreate accounts) #495

Closed
opened 2026-02-28 19:06:22 -06:00 by GiteaMirror · 3 comments
Owner

Originally created by @tam481 on GitHub (Jul 9, 2023).

Verified feature request does not already exist?

  • I have searched and found no existing issue

💻

  • Would you like to implement this feature?

Pitch: what problem are you trying to solve?

Please provide the ability to import everything from Quicken to help with migration.

The existing import feature is limited to individual accounts and even then I could not get it to work at all.

Describe your ideal solution to this problem

A Quicken migration feature or at least the ability to import from QIF that includes all dates, memos, accounts etc.

Teaching and learning

No response

Originally created by @tam481 on GitHub (Jul 9, 2023). ### Verified feature request does not already exist? - [X] I have searched and found no existing issue ### 💻 - [ ] Would you like to implement this feature? ### Pitch: what problem are you trying to solve? Please provide the ability to import everything from Quicken to help with migration. The existing import feature is limited to individual accounts and even then I could not get it to work at all. ### Describe your ideal solution to this problem A Quicken migration feature or at least the ability to import from QIF that includes all dates, memos, accounts etc. ### Teaching and learning _No response_
GiteaMirror added the featureneeds votesimporters labels 2026-02-28 19:06:22 -06:00
Author
Owner

@github-actions[bot] commented on GitHub (Jul 9, 2023):

Thanks for sharing your idea!

This repository is now using lodash style issue management for enhancements. This means enhancement issues will now be closed instead of leaving them open. This doesn’t mean we don’t accept feature requests, though! We will consider implementing ones that receive many upvotes, and we welcome contributions for any feature requests marked as needing votes (just post a comment first so we can help you make a successful contribution).

The enhancement backlog can be found here: https://github.com/actualbudget/actual/issues?q=label%3A%22needs+votes%22+sort%3Areactions-%2B1-desc+

Don’t forget to upvote the top comment with 👍!

@github-actions[bot] commented on GitHub (Jul 9, 2023): :sparkles: Thanks for sharing your idea! :sparkles: This repository is now using lodash style issue management for enhancements. This means enhancement issues will now be closed instead of leaving them open. This doesn’t mean we don’t accept feature requests, though! We will consider implementing ones that receive many upvotes, and we welcome contributions for any feature requests marked as needing votes (just post a comment first so we can help you make a successful contribution). The enhancement backlog can be found here: https://github.com/actualbudget/actual/issues?q=label%3A%22needs+votes%22+sort%3Areactions-%2B1-desc+ Don’t forget to upvote the top comment with 👍! <!-- feature-auto-close-comment -->
Author
Owner

@mbrevda commented on GitHub (Apr 21, 2025):

Here is some code that I used to migrate from quicken. It would be great to see one-click migration in Actual!
As per https://github.com/actualbudget/actual/issues/4326, must be run like: ACTUAL_DATA_DIR=./actual node import.mts
tested on node v23+

Supports:

  • accounts
  • payees
  • categories
  • transactions
  • slipts
  • transfers

Does not support:

  • rules
  • budgets
  • investments
  • reports
  • scheduled transactions

Also, transaction order is lost due to a lack of support in Actual

import.mts
import { DatabaseSync } from "node:sqlite";
import { randomUUID } from "node:crypto";
import api, { utils } from "@actual-app/api";
import { Agent, setGlobalDispatcher } from "undici";

// ignore self-signed cert errors
const agent = new Agent({ connect: { rejectUnauthorized: false } });
setGlobalDispatcher(agent);

/** ---- begin quicken setup ---- */
const dbPath =  "/path/to/Quicken File.quicken/data";

const db = new DatabaseSync(dbPath, { readOnly: true });
const getQuickenCategories = () => {
  type CategoryType = {
    id: number;
    parentName: string | null;
    name: string;
    type: number;
  };
  const categories: Record<number, { name: string; type: number }> = {};

  const q = db.prepare(/* sql */ `
        SELECT
            t.z_pk AS id,
            pt.zname AS parentName,
            t.zname AS name,
            t.ztype AS type
        FROM
            ZTAG AS t
        LEFT JOIN
            ZTAG AS pt ON t.zparentcategory = pt.z_pk
        WHERE t.z_pk IN (
            SELECT ZCATEGORYTAG FROM ZCASHFLOWTRANSACTIONENTRY
        ) 
        ORDER BY pt.zname, t.zname;
    `);
  for (const row of q.all() as CategoryType[]) {
    let name = row.name;
    if (row.parentName) name = row.parentName + ":" + row.name;
    categories[row.id] = { name, type: row.type };
  }

  return categories;
};

const getQuickenAccounts = () => {
  type AccountType = {
    Z_PK: number;
    ZACTIVE: number;
    ZNAME: string;
    ZTYPENAME: string;
  };
  const accounts: Record<
    number,
    { name: string; type: string; closed: boolean }
  > = {};
  const typeMap = {
    CASH: "other",
    CHECKING: "checking",
    CREDITCARD: "credit",
  };
  const q = db.prepare(/* sql */ `
    SELECT
      Z_PK,
      ZACTIVE,
      ZNAME,
      ZTYPENAME
    FROM
      ZACCOUNT
  `);

  for (const row of q.all() as AccountType[]) {
    if (!typeMap[row.ZTYPENAME])
      throw new Error(`Account type ${row.ZTYPENAME} not found`);
    const type = typeMap[row.ZTYPENAME];
    accounts[row.Z_PK] = {
      name: row.ZNAME,
      type,
      closed: row.ZACTIVE === 0,
    };
  }
  return accounts;
};

const getQuickenPayees = () => {
  type PayeeType = {
    Z_PK: number;
    ZNAME: string;
  };
  const payees: Record<number, string> = {};
  const q = db.prepare(/* sql */ `
      SELECT
        Z_PK,
        ZNAME
      FROM
        zuserpayee
    `);
  for (const row of q.all() as PayeeType[]) {
    payees[row.Z_PK] = row.ZNAME;
  }
  return payees;
};

const getQuickenTransactions = () => {
  const transactions: Record<
    number,
    {
      id: string;
      transferAccount: number;
      date: Date;
      notes: string;
      payee: string;
      amount: number;
      category: number | null;
      account: number;
      transfer_id?: string;
      subtransactions?: {
        amount: number;
        category?: number | null;
        notes?: string;
      }[];
    }
  > = {};

  type TransactionType = {
    transactionKey: number;
    date: string;
    accountId: number;
    accountName: string;
    amount: number;
    note: string;
    payeeId: number;
    parentPayeeName: string;
    categoryId: number;
    transactionId: number;
    splitParentId: number;
    splitAmount: number;
    splitNote: string;
    transferId: number;
    transferAccount: number;
    isSplit: boolean;
  };

  const q = db.prepare(/* sql */ `
    SELECT
  ztransaction.z_pk AS transactionKey,
  ztransaction.zentereddate AS date,
  ZTRANSACTION.zorderid as orderId,
  ztransaction.zaccount AS accountId,
  zaccount.zname AS accountName,
  ztransaction.zamount AS amount,
  ztransaction.znote AS note,
  ztransaction.zuserpayee as payeeId,
  zuserpayee.zname AS parentPayeeName,
  zcashflowtransactionentry.z_pk AS transactionId,
  COUNT(*) OVER (PARTITION BY zcashflowtransactionentry.zparent) > 1 AS isSplit,
  zcashflowtransactionentry.zparent AS splitParentId,
  zcashflowtransactionentry.zcategorytag AS categoryId,
  zcashflowtransactionentry.zamount AS splitAmount,
  zcashflowtransactionentry.znote AS splitNote,
  transferPair.zparent as transferId,
  transferParent.zaccount AS transferAccount
FROM
  ztransaction
  LEFT JOIN zcashflowtransactionentry ON ztransaction.z_pk = zcashflowtransactionentry.zparent
  LEFT JOIN zaccount ON zaccount.z_pk = ztransaction.zaccount
  LEFT JOIN zuserpayee ON zuserpayee.z_pk = ztransaction.zuserpayee
  LEFT JOIN zcashflowtransactionentry as transferPair ON zcashflowtransactionentry.ztransfer = transferPair.ZQUICKENID
  LEFT JOIN ztransaction AS transferParent ON transferPair.zparent = transferParent.z_pk
ORDER BY
  date ASC, orderId;
    `);

  for (const row of q.all() as TransactionType[]) {
    const id = String(row.transactionKey);
    const isSplit = row.isSplit;
    transactions[id] =
      isSplit && transactions[id]
        ? transactions[id]
        : {
            id,
            // convert from quicken date to js date
            date: new Date((Number(row.date) + 978282000) * 1000),
            account: row.accountId,
            notes: row.note,
            payee: row.payeeId,
            // don't set the category id if this is a split transaction or a transfer
            category: isSplit || row.transferAccount ? null : row.categoryId,
            amount: utils.amountToInteger(row.amount),
            transferAccount: row.transferAccount,
            // bellow we set a transfer id for both sides when we reach the first side of a transfer
            // if this transaction is the "other side" and already has a transfer id set, ensure that we keep it
            ...(transactions[id]?.transfer_id
              ? { transfer_id: transactions[id].transfer_id }
              : {}),
          };

    // if this row is a transfer but we didn't set a transfer id yet
    // set the id in this row, and on the other side of the transfer
    if (row.transferId && !transactions[id].transfer_id) {
      const otherSide = row.transferId;

      // if the overside exists and has a transfer id, use it. Otherwise, generate a new one
      const transfer_id = transactions[otherSide]?.transfer_id || randomUUID();
      transactions[id].transfer_id = transfer_id;

      // set the transfer id on the other side of the transfer if it's not already set
      transactions[otherSide] = { ...transactions[otherSide], transfer_id };
    }

    // if this row is a split transaction, add the split details to the parent transaction
    if (row.isSplit) {
      transactions[id].subtransactions = [
        {
          amount: utils.amountToInteger(row.splitAmount),
          category: row.categoryId,
          notes: row.splitNote,
        },
        ...(transactions[id].subtransactions || []),
      ];
    }
  }

  // clean up transactions
  // remove transactions with no amount
  Object.entries(transactions).forEach(([id, txn]) => {
    if (!transactions[id].amount) delete transactions[id];
  });
  return transactions;
};

const transactions = getQuickenTransactions();
const quicken = {
  categories: getQuickenCategories(),
  accounts: getQuickenAccounts(),
  payees: getQuickenPayees(),
};
// console.log("Transactions", transactions);
// console.log("Quicken data", quicken);
/** ------ end quicken setup ---- */

async function getApi() {
  await api.init({
    // Budget data will be cached locally here, in subdirectories for each file.
    dataDir: "./actual",
    // This is the URL of your running server
    serverURL: "IP ADDRESS HERE",
    // This is the password you use to log into the server
    password: "PASSWORD",
  });

  return api;
}

const actual = { payees: {}, categories: {}, accounts: {} };

async function main() {
  const api = await getApi();

  await api.runImport("My Finances " + new Date(), async () => {
    const categoryGroups = await api.getCategoryGroups();
    const incomeCategory = categoryGroups.find((c) => c.name === "Income")?.id;
    console.log("Income category", incomeCategory);
    const expenseCategory = await api.createCategoryGroup({ name: "Expenses" });
    console.log("Expense category", expenseCategory);

    console.log("Creating categories...");
    await Promise.all(
      Object.entries(quicken.categories)
        // ignore internal categories
        .filter(([id, category]) => category.type !== 0)
        .map(async ([id, category]) => {
          const categoryId = await api.createCategory({
            name: category.name,
            type: category.type === 1 ? "expense" : "income",
            group_id: category.type === 1 ? expenseCategory : incomeCategory,
          });
          actual.categories[id] = categoryId;
          // console.log(`Created category ${id}:`, category.name, categoryId);
        })
    );

    // create accounts
    await Promise.all(
      Object.entries(quicken.accounts).map(async ([id, account]) => {
        const accountId = await api.createAccount({
          name: account.name,
          type: account.type,
          closed: account.closed,
        });
        actual.accounts[id] = accountId;
        console.log("Created account", account.name, accountId);
      })
    );

    // create payees
    console.log("Creating payees...");
    await Promise.all(
      Object.entries(quicken.payees).map(async ([id, name]) => {
        const payeeId = await api.createPayee({ name });
        actual.payees[id] = payeeId;
        //  console.log("Created payee", name, payeeId);
      })
    );

    // find transfer payees
    console.log("Loading transfer payees...");
    for (const payee of await api.getPayees()) {
      for (const [quickenId, actualId] of Object.entries(actual.accounts)) {
        if (payee.transfer_acct === actualId) {
          quicken.accounts[quickenId].payeeId = payee.id;
          break;
        }
      }
    }

    // create transactions
    // map transaction quicken id's to Actual ids
    console.log("Creating transactions...");
    for (const transaction of Object.values(transactions)) {
      // for transfers, use the transfer payee
      transaction.payee = transaction.transferAccount
        ? quicken.accounts[transaction.transferAccount].payeeId
        : actual.payees[transaction.payee];

      // category id's
      transaction.category = transaction.category
        ? actual.categories[transaction.category]
        : null;

      // account id's
      transaction.account = actual.accounts[transaction.account];

      // update subtransactions with Actual category ids
      if (transaction?.subtransactions?.length) {
        transaction.subtransactions = transaction.subtransactions.map((t) => {
          const category = t.category ? actual.categories[t.category] : null;
          return { ...t, category };
        });
      }

      // remove fields that are not needed
      if ("transferAccount" in transaction) {
        delete (transaction as any).transferAccount;
      }
    }

    // group transactions by account
    // this is needed because Actual API doesn't support importing multiple accounts' transactions at once
    const groupedTransactions = Object.groupBy(
      Object.values(transactions),
      ({ account }) => account
    );

    console.log("Importing transactions...");
    await Promise.all(
      Object.entries(groupedTransactions).map(([account, txns]) => {
        return api.addTransactions(account, txns, { learnCategories: true });
      })
    );
  });
  console.log("Import complete, syncing to server...");

  await api.shutdown();
  console.log("Done!");
}

main().catch((err) => {
  console.error("Error:", err);
  process.exit(1);
});

@mbrevda commented on GitHub (Apr 21, 2025): Here is some code that I used to migrate from quicken. It would be great to see one-click migration in Actual! As per https://github.com/actualbudget/actual/issues/4326, must be run like: ` ACTUAL_DATA_DIR=./actual node import.mts` tested on node v23+ Supports: - accounts - payees - categories - transactions - slipts - transfers Does not support: - rules - budgets - investments - reports - scheduled transactions Also, transaction order is lost due to a lack of support in Actual <details><summary>import.mts</summary> ```typescript import { DatabaseSync } from "node:sqlite"; import { randomUUID } from "node:crypto"; import api, { utils } from "@actual-app/api"; import { Agent, setGlobalDispatcher } from "undici"; // ignore self-signed cert errors const agent = new Agent({ connect: { rejectUnauthorized: false } }); setGlobalDispatcher(agent); /** ---- begin quicken setup ---- */ const dbPath = "/path/to/Quicken File.quicken/data"; const db = new DatabaseSync(dbPath, { readOnly: true }); const getQuickenCategories = () => { type CategoryType = { id: number; parentName: string | null; name: string; type: number; }; const categories: Record<number, { name: string; type: number }> = {}; const q = db.prepare(/* sql */ ` SELECT t.z_pk AS id, pt.zname AS parentName, t.zname AS name, t.ztype AS type FROM ZTAG AS t LEFT JOIN ZTAG AS pt ON t.zparentcategory = pt.z_pk WHERE t.z_pk IN ( SELECT ZCATEGORYTAG FROM ZCASHFLOWTRANSACTIONENTRY ) ORDER BY pt.zname, t.zname; `); for (const row of q.all() as CategoryType[]) { let name = row.name; if (row.parentName) name = row.parentName + ":" + row.name; categories[row.id] = { name, type: row.type }; } return categories; }; const getQuickenAccounts = () => { type AccountType = { Z_PK: number; ZACTIVE: number; ZNAME: string; ZTYPENAME: string; }; const accounts: Record< number, { name: string; type: string; closed: boolean } > = {}; const typeMap = { CASH: "other", CHECKING: "checking", CREDITCARD: "credit", }; const q = db.prepare(/* sql */ ` SELECT Z_PK, ZACTIVE, ZNAME, ZTYPENAME FROM ZACCOUNT `); for (const row of q.all() as AccountType[]) { if (!typeMap[row.ZTYPENAME]) throw new Error(`Account type ${row.ZTYPENAME} not found`); const type = typeMap[row.ZTYPENAME]; accounts[row.Z_PK] = { name: row.ZNAME, type, closed: row.ZACTIVE === 0, }; } return accounts; }; const getQuickenPayees = () => { type PayeeType = { Z_PK: number; ZNAME: string; }; const payees: Record<number, string> = {}; const q = db.prepare(/* sql */ ` SELECT Z_PK, ZNAME FROM zuserpayee `); for (const row of q.all() as PayeeType[]) { payees[row.Z_PK] = row.ZNAME; } return payees; }; const getQuickenTransactions = () => { const transactions: Record< number, { id: string; transferAccount: number; date: Date; notes: string; payee: string; amount: number; category: number | null; account: number; transfer_id?: string; subtransactions?: { amount: number; category?: number | null; notes?: string; }[]; } > = {}; type TransactionType = { transactionKey: number; date: string; accountId: number; accountName: string; amount: number; note: string; payeeId: number; parentPayeeName: string; categoryId: number; transactionId: number; splitParentId: number; splitAmount: number; splitNote: string; transferId: number; transferAccount: number; isSplit: boolean; }; const q = db.prepare(/* sql */ ` SELECT ztransaction.z_pk AS transactionKey, ztransaction.zentereddate AS date, ZTRANSACTION.zorderid as orderId, ztransaction.zaccount AS accountId, zaccount.zname AS accountName, ztransaction.zamount AS amount, ztransaction.znote AS note, ztransaction.zuserpayee as payeeId, zuserpayee.zname AS parentPayeeName, zcashflowtransactionentry.z_pk AS transactionId, COUNT(*) OVER (PARTITION BY zcashflowtransactionentry.zparent) > 1 AS isSplit, zcashflowtransactionentry.zparent AS splitParentId, zcashflowtransactionentry.zcategorytag AS categoryId, zcashflowtransactionentry.zamount AS splitAmount, zcashflowtransactionentry.znote AS splitNote, transferPair.zparent as transferId, transferParent.zaccount AS transferAccount FROM ztransaction LEFT JOIN zcashflowtransactionentry ON ztransaction.z_pk = zcashflowtransactionentry.zparent LEFT JOIN zaccount ON zaccount.z_pk = ztransaction.zaccount LEFT JOIN zuserpayee ON zuserpayee.z_pk = ztransaction.zuserpayee LEFT JOIN zcashflowtransactionentry as transferPair ON zcashflowtransactionentry.ztransfer = transferPair.ZQUICKENID LEFT JOIN ztransaction AS transferParent ON transferPair.zparent = transferParent.z_pk ORDER BY date ASC, orderId; `); for (const row of q.all() as TransactionType[]) { const id = String(row.transactionKey); const isSplit = row.isSplit; transactions[id] = isSplit && transactions[id] ? transactions[id] : { id, // convert from quicken date to js date date: new Date((Number(row.date) + 978282000) * 1000), account: row.accountId, notes: row.note, payee: row.payeeId, // don't set the category id if this is a split transaction or a transfer category: isSplit || row.transferAccount ? null : row.categoryId, amount: utils.amountToInteger(row.amount), transferAccount: row.transferAccount, // bellow we set a transfer id for both sides when we reach the first side of a transfer // if this transaction is the "other side" and already has a transfer id set, ensure that we keep it ...(transactions[id]?.transfer_id ? { transfer_id: transactions[id].transfer_id } : {}), }; // if this row is a transfer but we didn't set a transfer id yet // set the id in this row, and on the other side of the transfer if (row.transferId && !transactions[id].transfer_id) { const otherSide = row.transferId; // if the overside exists and has a transfer id, use it. Otherwise, generate a new one const transfer_id = transactions[otherSide]?.transfer_id || randomUUID(); transactions[id].transfer_id = transfer_id; // set the transfer id on the other side of the transfer if it's not already set transactions[otherSide] = { ...transactions[otherSide], transfer_id }; } // if this row is a split transaction, add the split details to the parent transaction if (row.isSplit) { transactions[id].subtransactions = [ { amount: utils.amountToInteger(row.splitAmount), category: row.categoryId, notes: row.splitNote, }, ...(transactions[id].subtransactions || []), ]; } } // clean up transactions // remove transactions with no amount Object.entries(transactions).forEach(([id, txn]) => { if (!transactions[id].amount) delete transactions[id]; }); return transactions; }; const transactions = getQuickenTransactions(); const quicken = { categories: getQuickenCategories(), accounts: getQuickenAccounts(), payees: getQuickenPayees(), }; // console.log("Transactions", transactions); // console.log("Quicken data", quicken); /** ------ end quicken setup ---- */ async function getApi() { await api.init({ // Budget data will be cached locally here, in subdirectories for each file. dataDir: "./actual", // This is the URL of your running server serverURL: "IP ADDRESS HERE", // This is the password you use to log into the server password: "PASSWORD", }); return api; } const actual = { payees: {}, categories: {}, accounts: {} }; async function main() { const api = await getApi(); await api.runImport("My Finances " + new Date(), async () => { const categoryGroups = await api.getCategoryGroups(); const incomeCategory = categoryGroups.find((c) => c.name === "Income")?.id; console.log("Income category", incomeCategory); const expenseCategory = await api.createCategoryGroup({ name: "Expenses" }); console.log("Expense category", expenseCategory); console.log("Creating categories..."); await Promise.all( Object.entries(quicken.categories) // ignore internal categories .filter(([id, category]) => category.type !== 0) .map(async ([id, category]) => { const categoryId = await api.createCategory({ name: category.name, type: category.type === 1 ? "expense" : "income", group_id: category.type === 1 ? expenseCategory : incomeCategory, }); actual.categories[id] = categoryId; // console.log(`Created category ${id}:`, category.name, categoryId); }) ); // create accounts await Promise.all( Object.entries(quicken.accounts).map(async ([id, account]) => { const accountId = await api.createAccount({ name: account.name, type: account.type, closed: account.closed, }); actual.accounts[id] = accountId; console.log("Created account", account.name, accountId); }) ); // create payees console.log("Creating payees..."); await Promise.all( Object.entries(quicken.payees).map(async ([id, name]) => { const payeeId = await api.createPayee({ name }); actual.payees[id] = payeeId; // console.log("Created payee", name, payeeId); }) ); // find transfer payees console.log("Loading transfer payees..."); for (const payee of await api.getPayees()) { for (const [quickenId, actualId] of Object.entries(actual.accounts)) { if (payee.transfer_acct === actualId) { quicken.accounts[quickenId].payeeId = payee.id; break; } } } // create transactions // map transaction quicken id's to Actual ids console.log("Creating transactions..."); for (const transaction of Object.values(transactions)) { // for transfers, use the transfer payee transaction.payee = transaction.transferAccount ? quicken.accounts[transaction.transferAccount].payeeId : actual.payees[transaction.payee]; // category id's transaction.category = transaction.category ? actual.categories[transaction.category] : null; // account id's transaction.account = actual.accounts[transaction.account]; // update subtransactions with Actual category ids if (transaction?.subtransactions?.length) { transaction.subtransactions = transaction.subtransactions.map((t) => { const category = t.category ? actual.categories[t.category] : null; return { ...t, category }; }); } // remove fields that are not needed if ("transferAccount" in transaction) { delete (transaction as any).transferAccount; } } // group transactions by account // this is needed because Actual API doesn't support importing multiple accounts' transactions at once const groupedTransactions = Object.groupBy( Object.values(transactions), ({ account }) => account ); console.log("Importing transactions..."); await Promise.all( Object.entries(groupedTransactions).map(([account, txns]) => { return api.addTransactions(account, txns, { learnCategories: true }); }) ); }); console.log("Import complete, syncing to server..."); await api.shutdown(); console.log("Done!"); } main().catch((err) => { console.error("Error:", err); process.exit(1); }); ``` </details>
Author
Owner

@orrd commented on GitHub (May 14, 2025):

mbrevda's script looks interesting, I may try that.

Aside from that, is there currently any way to import transactions from Quicken into Actual at all? I haven't found information on a way to export individual accounts from Quicken in a format that Actual can import. The Actual docs have a solution for Quicken on Mac, but not for Quicken on Windows.

@orrd commented on GitHub (May 14, 2025): mbrevda's script looks interesting, I may try that. Aside from that, is there currently any way to import transactions from Quicken into Actual at all? I haven't found information on a way to export individual accounts from Quicken in a format that Actual can import. The Actual docs have a solution for Quicken on Mac, but not for Quicken on Windows.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/actual#495