From b89047d0fbb68ac37e25172edecf02ef44180103 Mon Sep 17 00:00:00 2001 From: Viktor Oreshkin Date: Sun, 8 Jun 2025 11:21:03 +0300 Subject: [PATCH] feat: Add simple JSON export Adds a simple JSON export for transactions, easier for programmatic use. Includes all tags (unlike CSV) and is not raw backup data. --- src/3-widgets/Navigation/SettingsMenu.tsx | 29 +++++++-- .../export/{exportCSV => }/exportCSV.ts | 28 +-------- src/4-features/export/exportCSV/index.ts | 1 - src/4-features/export/exportSimpleJSON.ts | 62 +++++++++++++++++++ src/4-features/export/index.ts | 3 + .../{exportCSV => }/populateTransaction.ts | 25 +++++++- src/6-shared/localization/translations/en.ts | 1 + .../localization/translations/ru.json | 1 + 8 files changed, 118 insertions(+), 32 deletions(-) rename src/4-features/export/{exportCSV => }/exportCSV.ts (79%) delete mode 100644 src/4-features/export/exportCSV/index.ts create mode 100644 src/4-features/export/exportSimpleJSON.ts create mode 100644 src/4-features/export/index.ts rename src/4-features/export/{exportCSV => }/populateTransaction.ts (64%) diff --git a/src/3-widgets/Navigation/SettingsMenu.tsx b/src/3-widgets/Navigation/SettingsMenu.tsx index efb64b19..a8858286 100644 --- a/src/3-widgets/Navigation/SettingsMenu.tsx +++ b/src/3-widgets/Navigation/SettingsMenu.tsx @@ -39,8 +39,11 @@ import { resetData } from 'store/data' import { userSettingsModel } from '5-entities/userSettings' import { useRegularSync } from '3-widgets/RegularSyncHandler' import { logOut } from '4-features/authorization' -import { exportCSV } from '4-features/export/exportCSV' -import { exportJSON } from '4-features/export/exportJSON' +import { + exportCSV, + exportJSON, + exportSimpleJSON, +} from '4-features/export' import { clearLocalData } from '4-features/localData' import { convertZmBudgetsToZerro } from '4-features/budget/convertZmBudgetsToZerro' import { registerPopover } from '6-shared/historyPopovers' @@ -96,6 +99,7 @@ const Settings = (props: { onClose: () => void; showLinks?: boolean }) => { {t('export')} + @@ -127,12 +131,12 @@ function ExportCsvItem() { function ExportJsonItem() { const { t } = useTranslation('settings') const dispatch = useAppDispatch() - const handleExportCSV = () => { + const handleExportJSON = () => { sendEvent('Settings: export json') dispatch(exportJSON) } return ( - + @@ -141,6 +145,23 @@ function ExportJsonItem() { ) } +function ExportSimpleJsonItem() { + const { t } = useTranslation('settings') + const dispatch = useAppDispatch() + const handleExportSimpleJSON = () => { + sendEvent('Settings: export simple json') + dispatch(exportSimpleJSON) + } + return ( + + + + + {t('downloadJSON')} + + ) +} + function ThemeItem({ onClose }: ItemProps) { const { t } = useTranslation('settings') const theme = useColorScheme() diff --git a/src/4-features/export/exportCSV/exportCSV.ts b/src/4-features/export/exportCSV.ts similarity index 79% rename from src/4-features/export/exportCSV/exportCSV.ts rename to src/4-features/export/exportCSV.ts index c9f65189..dc00aae5 100644 --- a/src/4-features/export/exportCSV/exportCSV.ts +++ b/src/4-features/export/exportCSV.ts @@ -1,35 +1,11 @@ -import { createSelector } from '@reduxjs/toolkit' import { PopulatedTransaction, - populateTransaction, + getPopulatedTransactions, } from './populateTransaction' import { formatDate } from '6-shared/helpers/date' import { ById } from '6-shared/types' import { AppThunk } from 'store' -import { trModel, TrType } from '5-entities/transaction' -import { instrumentModel } from '5-entities/currency/instrument' -import { accountModel } from '5-entities/account' -import { tagModel } from '5-entities/tag' - -// Only for CSV -const getPopulatedTransactions = createSelector( - [ - instrumentModel.getInstruments, - accountModel.getAccounts, - tagModel.getPopulatedTags, - trModel.getTransactions, - ], - (instruments, accounts, tags, transactions) => { - const result: { [id: string]: PopulatedTransaction } = {} - for (const id in transactions) { - result[id] = populateTransaction( - { instruments, accounts, tags }, - transactions[id] - ) - } - return result - } -) +import { TrType } from '5-entities/transaction' export const exportCSV: AppThunk = (_, getState) => { const tr = getPopulatedTransactions(getState()) diff --git a/src/4-features/export/exportCSV/index.ts b/src/4-features/export/exportCSV/index.ts deleted file mode 100644 index b1e5bcab..00000000 --- a/src/4-features/export/exportCSV/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { exportCSV } from './exportCSV' diff --git a/src/4-features/export/exportSimpleJSON.ts b/src/4-features/export/exportSimpleJSON.ts new file mode 100644 index 00000000..2350b87f --- /dev/null +++ b/src/4-features/export/exportSimpleJSON.ts @@ -0,0 +1,62 @@ +import { + PopulatedTransaction, + getPopulatedTransactions, +} from './populateTransaction' +import { formatDate } from '6-shared/helpers/date' +import { AppThunk } from 'store' +import { TrType } from '5-entities/transaction' + +// TODO: i18n +const transactionTypes = { + [TrType.Income]: 'Income', + [TrType.Outcome]: 'Expense', + [TrType.Transfer]: 'Transfer', + [TrType.OutcomeDebt]: 'Debt Payment', + [TrType.IncomeDebt]: 'Debt Repayment', +} + +function transactionToJsonObj(t: PopulatedTransaction) { + return { + id: t.id, + date: formatDate(t.created, 'yyyy-MM-dd'), + created: formatDate(t.created, 'yyyy-MM-dd HH:mm'), + type: transactionTypes[t.type as TrType] || t.type, + + fromAccount: t.outcomeAccount ? t.outcomeAccount.title : null, + toAccount: t.incomeAccount ? t.incomeAccount.title : null, + outcome: t.outcome || 0, + outcomeCurrency: t.outcomeInstrument ? t.outcomeInstrument.shortTitle : null, + income: t.income || 0, + incomeCurrency: t.incomeInstrument ? t.incomeInstrument.shortTitle : null, + + payee: t.payee || null, + comment: t.comment || null, + tags: t.tag ? t.tag.map(tag => tag.title) : [], + } +} + +export const exportSimpleJSON: AppThunk = (_, getState) => { + const transactions = getPopulatedTransactions(getState()) + + const jsonData = Object.values(transactions) + .filter(tr => !tr.deleted) + .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) + .map(transactionToJsonObj) + + const content = JSON.stringify(jsonData, null, 2) + const blob = new Blob([content], { type: 'application/json' }) + const href = window.URL.createObjectURL(blob) + const fileName = `transactions-${formatDate(Date.now(), 'yyyyMMdd-HHmm')}.json` + + const link = document.createElement('a') + link.setAttribute('href', href) + link.setAttribute('download', fileName) + document.body.appendChild(link) // Required for FF + link.click() + + // Clean up + setTimeout(() => { + URL.revokeObjectURL(href) + document.body.removeChild(link) + }, 100) +} diff --git a/src/4-features/export/index.ts b/src/4-features/export/index.ts new file mode 100644 index 00000000..ae0f7bee --- /dev/null +++ b/src/4-features/export/index.ts @@ -0,0 +1,3 @@ +export { exportCSV } from './exportCSV' +export { exportJSON } from './exportJSON' +export { exportSimpleJSON } from './exportSimpleJSON' diff --git a/src/4-features/export/exportCSV/populateTransaction.ts b/src/4-features/export/populateTransaction.ts similarity index 64% rename from src/4-features/export/exportCSV/populateTransaction.ts rename to src/4-features/export/populateTransaction.ts index b3fc90ce..bb446ed4 100644 --- a/src/4-features/export/exportCSV/populateTransaction.ts +++ b/src/4-features/export/populateTransaction.ts @@ -1,4 +1,4 @@ -import { TrType } from '5-entities/transaction' +import { createSelector } from '@reduxjs/toolkit' import { getType } from '5-entities/transaction/helpers' import { ByIdOld, @@ -9,6 +9,10 @@ import { TTagId, TTransaction, } from '6-shared/types' +import { trModel, TrType } from '5-entities/transaction' +import { instrumentModel } from '5-entities/currency/instrument' +import { accountModel } from '5-entities/account' +import { tagModel } from '5-entities/tag' interface DataSources { instruments: { [id: number]: TInstrument } @@ -50,3 +54,22 @@ function mapTags(ids: TTagId[] | null, tags: ByIdOld) { // TODO: Надо что-то придумать с null тегом 🤔 ⤵ return ids && ids.length ? ids.map(id => tags[id + '']) : null } + +export const getPopulatedTransactions = createSelector( + [ + instrumentModel.getInstruments, + accountModel.getAccounts, + tagModel.getPopulatedTags, + trModel.getTransactions, + ], + (instruments, accounts, tags, transactions) => { + const result: { [id: string]: PopulatedTransaction } = {} + for (const id in transactions) { + result[id] = populateTransaction( + { instruments, accounts, tags }, + transactions[id] + ) + } + return result + } +) diff --git a/src/6-shared/localization/translations/en.ts b/src/6-shared/localization/translations/en.ts index 0c7fb29f..adc20315 100644 --- a/src/6-shared/localization/translations/en.ts +++ b/src/6-shared/localization/translations/en.ts @@ -445,6 +445,7 @@ export const en: typeof ru = { export: 'Export', downloadCSV: 'Download CSV', fullBackup: 'Full backup', + downloadJSON: 'Download JSON', darkMode: 'Dark Mode', lightMode: 'Light Mode', reloadData: 'Reload data', diff --git a/src/6-shared/localization/translations/ru.json b/src/6-shared/localization/translations/ru.json index 0f26ab0e..188f5b54 100644 --- a/src/6-shared/localization/translations/ru.json +++ b/src/6-shared/localization/translations/ru.json @@ -425,6 +425,7 @@ "export": "Экспорт", "downloadCSV": "Скачать CSV", "fullBackup": "Полный бэкап", + "downloadJSON": "Скачать JSON", "darkMode": "Тёмная тема", "lightMode": "Светлая тема", "reloadData": "Перезагрузить данные",