From d17d8776a6bdfc7f7345b0d3d6c824d164486836 Mon Sep 17 00:00:00 2001
From: Umang Varma
Date: Mon, 6 Jan 2025 20:54:06 -0800
Subject: [PATCH 1/2] Bulk importer for Fava
This is my first pass at making a bulk importer for Fava.
Known issues that I hope to address:
* Checkboxes don't honor shift-clicks to select a range
* Doesn't use Svelte v5 runes.
Nice to haves that I also hope to work on in the future:
* A drag and drop interface for moving transactions (either
individually, or all selected transactions).
---
frontend/css/bulk-importer.css | 41 ++++
frontend/css/style.css | 8 +-
frontend/src/api/validators.ts | 1 +
frontend/src/main.ts | 1 +
.../reports/bulkimporter/BulkExtract.svelte | 221 ++++++++++++++++++
.../bulkimporter/IndividualEntryEdit.svelte | 59 +++++
.../reports/bulkimporter/OtherEntryRow.svelte | 60 +++++
.../bulkimporter/SimpleTransactionRow.svelte | 37 +++
frontend/src/reports/bulkimporter/index.ts | 58 +++++
frontend/src/reports/import/FileList.svelte | 4 +-
frontend/src/reports/import/Import.svelte | 32 ++-
src/fava/core/fava_options.py | 1 +
12 files changed, 509 insertions(+), 14 deletions(-)
create mode 100644 frontend/css/bulk-importer.css
create mode 100644 frontend/src/reports/bulkimporter/BulkExtract.svelte
create mode 100644 frontend/src/reports/bulkimporter/IndividualEntryEdit.svelte
create mode 100644 frontend/src/reports/bulkimporter/OtherEntryRow.svelte
create mode 100644 frontend/src/reports/bulkimporter/SimpleTransactionRow.svelte
create mode 100644 frontend/src/reports/bulkimporter/index.ts
diff --git a/frontend/css/bulk-importer.css b/frontend/css/bulk-importer.css
new file mode 100644
index 000000000..8fd22c93a
--- /dev/null
+++ b/frontend/css/bulk-importer.css
@@ -0,0 +1,41 @@
+.bulk-importer span.select,
+.bulk-importer span.edit {
+ width: 2rem;
+}
+
+.bulk-importer span.flag {
+ width: 3rem;
+}
+
+.bulk-importer span.description {
+ flex: 1;
+}
+
+.bulk-importer span.datecell {
+ width: 6rem;
+}
+
+.bulk-importer span.datecell,
+.bulk-importer span.flag {
+ text-align: center;
+ background-color: var(--entry-background);
+}
+
+.bulk-importer span.edit button {
+ padding: 2px 4px;
+ margin: 0;
+ font-weight: bold;
+ background: var(--background);
+}
+
+.bulk-importer span.edit button:hover {
+ background: var(--background-darker);
+}
+
+.bulk-importer .postings {
+ font-size: 0.9em;
+ background-color: var(--journal-postings);
+}
+.bulk-importer .duplicate {
+ text-decoration: line-through;
+ }
\ No newline at end of file
diff --git a/frontend/css/style.css b/frontend/css/style.css
index 5aac33bd4..048e52bb5 100644
--- a/frontend/css/style.css
+++ b/frontend/css/style.css
@@ -186,7 +186,7 @@
}
}
-.journal .balance {
+.journal .balance, .bulk-importer .balance {
--entry-background: hsl(120deg 100% 90%);
}
@@ -230,7 +230,7 @@
--entry-background: hsl(35deg 100% 80%);
}
-.journal {
+.journal, .bulk-importer {
--journal-postings: hsl(0deg 0% 92%);
--journal-metadata: hsl(210deg 44% 67%);
--journal-tag: hsl(210deg 61% 64%);
@@ -242,7 +242,7 @@
@media (prefers-color-scheme: dark) {
:root {
- .journal .balance {
+ .journal .balance, .bulk-importer .balance {
--entry-background: hsl(120deg 50% 15%);
}
@@ -286,7 +286,7 @@
--entry-background: hsl(35deg 100% 20%);
}
- .journal {
+ .journal, .bulk-importer {
--journal-postings: hsl(0deg 0% 10%);
--journal-hover-highlight: hsl(0deg 0% 20% / 60%);
}
diff --git a/frontend/src/api/validators.ts b/frontend/src/api/validators.ts
index 77d44a8c2..70043bd3c 100644
--- a/frontend/src/api/validators.ts
+++ b/frontend/src/api/validators.ts
@@ -66,6 +66,7 @@ const fava_options = object({
insert_entry: array(
object({ date: string, filename: string, lineno: number, re: string }),
),
+ use_bulk_importer: boolean,
use_external_editor: boolean,
});
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index 0344f5fff..44080fc93 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -19,6 +19,7 @@ import "../css/help.css";
import "../css/journal-table.css";
import "../css/notifications.css";
import "../css/tree-table.css";
+import "../css/bulk-importer.css";
// Polyfill for customised builtin elements in Webkit
import "@ungap/custom-elements";
diff --git a/frontend/src/reports/bulkimporter/BulkExtract.svelte b/frontend/src/reports/bulkimporter/BulkExtract.svelte
new file mode 100644
index 000000000..f2bf62bcd
--- /dev/null
+++ b/frontend/src/reports/bulkimporter/BulkExtract.svelte
@@ -0,0 +1,221 @@
+
+
+
+
+
{_("Import")}: {account}
+
+
+ {#each transactions_by_target as [target, transactions], i}
+
{target}
+
+ {/each}
+
{_("Other entries")}
+
+
+
+
+
+
+ {
+ entry_to_edit = undefined;
+ entries = entries;
+ }}
+ />
+
+
+
diff --git a/frontend/src/reports/bulkimporter/IndividualEntryEdit.svelte b/frontend/src/reports/bulkimporter/IndividualEntryEdit.svelte
new file mode 100644
index 000000000..5de8eedf9
--- /dev/null
+++ b/frontend/src/reports/bulkimporter/IndividualEntryEdit.svelte
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/reports/bulkimporter/OtherEntryRow.svelte b/frontend/src/reports/bulkimporter/OtherEntryRow.svelte
new file mode 100644
index 000000000..8e08ec36c
--- /dev/null
+++ b/frontend/src/reports/bulkimporter/OtherEntryRow.svelte
@@ -0,0 +1,60 @@
+
+
+{#if entry instanceof Transaction}
+
+
+
+ {entry.date}
+ *
+ {entry.payee || ""}{#if entry.payee && entry.narration}{/if}{entry.narration || ""}
+
+
+
+
+ {#each entry.postings as posting}
+ -
+
+
+
+
+ {posting.account}
+ {posting.amount}
+
+
+
+ {/each}
+
+
+{:else if entry instanceof Balance}
+
+
+
+ {entry.date}
+ Bal
+ {entry.account}
+ {entry.amount.number} {entry.amount.currency}
+
+
+
+{/if}
+
+
diff --git a/frontend/src/reports/bulkimporter/SimpleTransactionRow.svelte b/frontend/src/reports/bulkimporter/SimpleTransactionRow.svelte
new file mode 100644
index 000000000..76bf20dd9
--- /dev/null
+++ b/frontend/src/reports/bulkimporter/SimpleTransactionRow.svelte
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/reports/bulkimporter/index.ts b/frontend/src/reports/bulkimporter/index.ts
new file mode 100644
index 000000000..bec271693
--- /dev/null
+++ b/frontend/src/reports/bulkimporter/index.ts
@@ -0,0 +1,58 @@
+import { Transaction } from "../../entries";
+import type { Entry } from "../../entries";
+
+function negate_amount(amount: string) {
+ if (amount.trimStart().startsWith("-")) {
+ return amount.trimStart().slice(1);
+ } else {
+ return "-" + amount;
+ }
+}
+
+// Wrapper around Transaction.
+export class SimpleTransaction {
+ // The original transaction. This is guaranteed to have exactly 2
+ // postings (checked in the constructor).
+ transaction: Transaction;
+ readonly origin_account: string;
+ readonly target_posting_index: number;
+ readonly origin_posting_index: number;
+ // Remember the index in the upstream list of `SimpleTransaction`s. Because
+ // of Svelte's reactivity, we have to maintain paralell data structures to
+ // track local edits that are not purely functional (as otherwise, the changes
+ // get overwritten any time Svelte recomputes derived values).
+ readonly index: number;
+
+ constructor(transaction: Transaction, origin_account: string, index: number) {
+ this.transaction = transaction;
+ this.origin_account = origin_account;
+ this.index = index;
+
+ let target_postings = this.transaction.postings
+ .map((posting, index) => ({ posting: posting, index: index }))
+ .filter(({ posting, }) => !posting.is_empty() && posting.account !== this.origin_account);
+ if (this.transaction.postings.length !== 2) {
+ throw new Error("A transaction with more than 2 postings is not a 'simple' transaction.")
+ }
+ if (target_postings.length !== 1) {
+ throw new Error("Expected exactly one posting whose account was not" +
+ this.origin_account + ", but found " +
+ JSON.stringify(target_postings))
+ } else {
+ this.target_posting_index = target_postings[0]!.index;
+ this.origin_posting_index = 1 - this.target_posting_index;
+ }
+ }
+
+ getTargetAccount() {
+ return this.transaction.postings[this.target_posting_index]!.account;
+ }
+
+ setTargetAccount(target: string) {
+ this.transaction.postings[this.target_posting_index]!.account = target;
+ }
+
+ getAmount() {
+ return this.transaction.postings[this.origin_posting_index]!.amount || negate_amount(this.transaction.postings[this.target_posting_index]!.amount)
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/reports/import/FileList.svelte b/frontend/src/reports/import/FileList.svelte
index 6cd97e893..04de96803 100644
--- a/frontend/src/reports/import/FileList.svelte
+++ b/frontend/src/reports/import/FileList.svelte
@@ -9,7 +9,7 @@
export let selected: string | null;
export let remove: (name: string) => unknown;
export let move: (name: string, a: string, newName: string) => unknown;
- export let extract: (name: string, importer: string) => unknown;
+ export let extract: (name: string, importer: string, account:string) => unknown;
{#each files as file}
@@ -45,7 +45,7 @@
{:else}
- {
- entries = [];
- }}
- {save}
- />
+ {#if $fava_options.use_bulk_importer}
+ {
+ entries = [];
+ }}
+ {save}
+ account={bulk_importer_account}
+ />
+ {:else}
+ {
+ entries = [];
+ }}
+ {save}
+ />
+ {/if}
{#if files.length === 0}
diff --git a/src/fava/core/fava_options.py b/src/fava/core/fava_options.py
index 95a02527b..35c89c4cd 100644
--- a/src/fava/core/fava_options.py
+++ b/src/fava/core/fava_options.py
@@ -113,6 +113,7 @@ class FavaOptions:
upcoming_events: int = 7
uptodate_indicator_grey_lookback_days: int = 60
use_external_editor: bool = False
+ use_bulk_importer: bool = False
def set_collapse_pattern(self, value: str) -> None:
"""Set the collapse_pattern option."""
From d06ee98825cd0acc7ce882e02c0479ba45a5a8fd Mon Sep 17 00:00:00 2001
From: "pre-commit-ci[bot]"
<66853113+pre-commit-ci[bot]@users.noreply.github.com>
Date: Tue, 7 Jan 2025 05:10:48 +0000
Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
---
frontend/css/bulk-importer.css | 31 ++++++++++---------
frontend/css/style.css | 12 ++++---
.../reports/bulkimporter/BulkExtract.svelte | 1 +
.../bulkimporter/IndividualEntryEdit.svelte | 13 ++++----
frontend/src/reports/import/FileList.svelte | 6 +++-
5 files changed, 36 insertions(+), 27 deletions(-)
diff --git a/frontend/css/bulk-importer.css b/frontend/css/bulk-importer.css
index 8fd22c93a..fe8bdfb3b 100644
--- a/frontend/css/bulk-importer.css
+++ b/frontend/css/bulk-importer.css
@@ -1,41 +1,42 @@
.bulk-importer span.select,
.bulk-importer span.edit {
- width: 2rem;
+ width: 2rem;
}
.bulk-importer span.flag {
- width: 3rem;
+ width: 3rem;
}
.bulk-importer span.description {
- flex: 1;
+ flex: 1;
}
.bulk-importer span.datecell {
- width: 6rem;
+ width: 6rem;
}
.bulk-importer span.datecell,
.bulk-importer span.flag {
- text-align: center;
- background-color: var(--entry-background);
+ text-align: center;
+ background-color: var(--entry-background);
}
.bulk-importer span.edit button {
- padding: 2px 4px;
- margin: 0;
- font-weight: bold;
- background: var(--background);
+ padding: 2px 4px;
+ margin: 0;
+ font-weight: bold;
+ background: var(--background);
}
.bulk-importer span.edit button:hover {
- background: var(--background-darker);
+ background: var(--background-darker);
}
.bulk-importer .postings {
- font-size: 0.9em;
- background-color: var(--journal-postings);
+ font-size: 0.9em;
+ background-color: var(--journal-postings);
}
+
.bulk-importer .duplicate {
- text-decoration: line-through;
- }
\ No newline at end of file
+ text-decoration: line-through;
+}
diff --git a/frontend/css/style.css b/frontend/css/style.css
index 048e52bb5..a124d4a3f 100644
--- a/frontend/css/style.css
+++ b/frontend/css/style.css
@@ -186,7 +186,8 @@
}
}
-.journal .balance, .bulk-importer .balance {
+.journal .balance,
+.bulk-importer .balance {
--entry-background: hsl(120deg 100% 90%);
}
@@ -230,7 +231,8 @@
--entry-background: hsl(35deg 100% 80%);
}
-.journal, .bulk-importer {
+.journal,
+.bulk-importer {
--journal-postings: hsl(0deg 0% 92%);
--journal-metadata: hsl(210deg 44% 67%);
--journal-tag: hsl(210deg 61% 64%);
@@ -242,7 +244,8 @@
@media (prefers-color-scheme: dark) {
:root {
- .journal .balance, .bulk-importer .balance {
+ .journal .balance,
+ .bulk-importer .balance {
--entry-background: hsl(120deg 50% 15%);
}
@@ -286,7 +289,8 @@
--entry-background: hsl(35deg 100% 20%);
}
- .journal, .bulk-importer {
+ .journal,
+ .bulk-importer {
--journal-postings: hsl(0deg 0% 10%);
--journal-hover-highlight: hsl(0deg 0% 20% / 60%);
}
diff --git a/frontend/src/reports/bulkimporter/BulkExtract.svelte b/frontend/src/reports/bulkimporter/BulkExtract.svelte
index f2bf62bcd..dbc668f16 100644
--- a/frontend/src/reports/bulkimporter/BulkExtract.svelte
+++ b/frontend/src/reports/bulkimporter/BulkExtract.svelte
@@ -215,6 +215,7 @@
.toolbar {
min-height: 3em;
}
+
.toolbar label {
vertical-align: middle;
}
diff --git a/frontend/src/reports/bulkimporter/IndividualEntryEdit.svelte b/frontend/src/reports/bulkimporter/IndividualEntryEdit.svelte
index 5de8eedf9..1f7276665 100644
--- a/frontend/src/reports/bulkimporter/IndividualEntryEdit.svelte
+++ b/frontend/src/reports/bulkimporter/IndividualEntryEdit.svelte
@@ -1,5 +1,5 @@
diff --git a/frontend/src/reports/import/FileList.svelte b/frontend/src/reports/import/FileList.svelte
index 04de96803..e71f3b9c4 100644
--- a/frontend/src/reports/import/FileList.svelte
+++ b/frontend/src/reports/import/FileList.svelte
@@ -9,7 +9,11 @@
export let selected: string | null;
export let remove: (name: string) => unknown;
export let move: (name: string, a: string, newName: string) => unknown;
- export let extract: (name: string, importer: string, account:string) => unknown;
+ export let extract: (
+ name: string,
+ importer: string,
+ account: string,
+ ) => unknown;
{#each files as file}