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}

+
+ + {#if num_selected > 0} + + + + + {/if} +
+
+ {#each transactions_by_target as [target, transactions], i} +

{target}

+
    +
  • +

    + + Date + F + Description + Amount + +

    +
  • + {#each transactions as transaction} + { + entry_to_edit = transaction.transaction; + }} + /> + {/each} +
+ {/each} + {_("Other entries")} +
    +
  • +

    + + Date + F + Description + Amount + +

    +
  • + {#each other_entries as entry} + { + entry_to_edit = entry; + }} + /> + {/each} +
+
+
+
+ +
+
+
+ { + 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 @@ + + + +
{}}> +

{_("Import")}

+ {#if entry} +
+

{_("Edit Entry")}

+ + +
+
+ +
+ {/if} +
+
+ + 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 || ""} + + +

    + +
  • +{: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 @@