diff --git a/frontend/css/bulk-importer.css b/frontend/css/bulk-importer.css
new file mode 100644
index 000000000..fe8bdfb3b
--- /dev/null
+++ b/frontend/css/bulk-importer.css
@@ -0,0 +1,42 @@
+.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;
+}
diff --git a/frontend/css/style.css b/frontend/css/style.css
index 5aac33bd4..a124d4a3f 100644
--- a/frontend/css/style.css
+++ b/frontend/css/style.css
@@ -186,7 +186,8 @@
}
}
-.journal .balance {
+.journal .balance,
+.bulk-importer .balance {
--entry-background: hsl(120deg 100% 90%);
}
@@ -230,7 +231,8 @@
--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 +244,8 @@
@media (prefers-color-scheme: dark) {
:root {
- .journal .balance {
+ .journal .balance,
+ .bulk-importer .balance {
--entry-background: hsl(120deg 50% 15%);
}
@@ -286,7 +289,8 @@
--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..dbc668f16
--- /dev/null
+++ b/frontend/src/reports/bulkimporter/BulkExtract.svelte
@@ -0,0 +1,222 @@
+
+
+
+
+
{_("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..1f7276665
--- /dev/null
+++ b/frontend/src/reports/bulkimporter/IndividualEntryEdit.svelte
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
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..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) => unknown;
+ export let extract: (
+ name: string,
+ importer: string,
+ account: string,
+ ) => unknown;
{#each files as file}
@@ -45,7 +49,7 @@