Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bulk importer for Fava #1944

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions frontend/css/bulk-importer.css
Original file line number Diff line number Diff line change
@@ -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;
}
12 changes: 8 additions & 4 deletions frontend/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,8 @@
}
}

.journal .balance {
.journal .balance,
.bulk-importer .balance {
--entry-background: hsl(120deg 100% 90%);
}

Expand Down Expand Up @@ -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%);
Expand All @@ -242,7 +244,8 @@

@media (prefers-color-scheme: dark) {
:root {
.journal .balance {
.journal .balance,
.bulk-importer .balance {
--entry-background: hsl(120deg 50% 15%);
}

Expand Down Expand Up @@ -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%);
}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/api/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand Down
1 change: 1 addition & 0 deletions frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
222 changes: 222 additions & 0 deletions frontend/src/reports/bulkimporter/BulkExtract.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
<script lang="ts">
import type { Entry as EntryType } from "../../entries";
import { _ } from "../../i18n";
import ModalBase from "../../modals/ModalBase.svelte";
import SimpleTransactionRow from "./SimpleTransactionRow.svelte";
import { accounts } from "../../stores";
import { slide } from "svelte/transition";

import { SimpleTransaction } from ".";
import { Transaction } from "../../entries";
import AutocompleteInput from "../../AutocompleteInput.svelte";
import OtherEntryRow from "./OtherEntryRow.svelte";
import IndividualEntryEdit from "./IndividualEntryEdit.svelte";

export let entries: EntryType[];
export let save: () => void;
export let close: () => void;
export let account: string;

// The new target account (when moving simple transactions in bulk).
let new_target: string;
// The entry that we want to manually edit. Starts off undefined, is set when
// the user clicks the vertical elipses to edit, and unset when they close the
// editor.
let entry_to_edit: EntryType | undefined = undefined;

// Simple transactions grouped by their target account.
let transactions_by_target: Map<string, SimpleTransaction[]>;
let simple_transactions: SimpleTransaction[];
let other_entries: EntryType[];
$: {
// When `entries` changes, we recompute simple_transactions, other_entries,
// and transactions_by_target.
simple_transactions = [];
other_entries = [];
entries.forEach((entry) => {
try {
if (entry instanceof Transaction) {
simple_transactions.push(
new SimpleTransaction(entry, account, simple_transactions.length),
);
return;
}
} finally {

Check failure on line 44 in frontend/src/reports/bulkimporter/BulkExtract.svelte

View workflow job for this annotation

GitHub Actions / lint-js

Empty block statement
}
other_entries.push(entry);
});
transactions_by_target = new Map();
simple_transactions.forEach((st) => {
let target = st.getTargetAccount();
let transactions = transactions_by_target.get(target);
if (transactions) {
transactions.push(st);
} else {
transactions_by_target.set(target, [st]);
}
});
}
// `selected` shouldn't always be regenerated after changes to `entries`
// because Svelte sometimes recomputes when there isn't a real change to
// `entries` and the user might still be selecting transactions. Only recreate
// `selected` if there is a length mismatch.
let selected: boolean[] = [];
$: if (selected.length !== simple_transactions.length) {
selected = new Array(simple_transactions.length).fill(false);

Check failure on line 65 in frontend/src/reports/bulkimporter/BulkExtract.svelte

View workflow job for this annotation

GitHub Actions / lint-js

Unsafe assignment of type `any[]` to a variable of type `boolean[]`
}
$: shown = entries.length > 0;
$: num_selected = selected.filter((v) => v).length;

function selectNone() {
selected.fill(false);
selected = selected;
}

function selectAll() {
selected.fill(true);
selected = selected;
}

function toggleSelectAll() {
if (num_selected == 0) {

Check failure on line 81 in frontend/src/reports/bulkimporter/BulkExtract.svelte

View workflow job for this annotation

GitHub Actions / lint-js

Expected '===' and instead saw '=='
selectAll();
} else {
selectNone();
}
}

function forEachSelectedTransaction(
callbackFn: (transaction: SimpleTransaction, index: number) => void,
) {
selected.forEach((selected, index) => {
if (selected) {
callbackFn(simple_transactions[index]!, index);

Check failure on line 93 in frontend/src/reports/bulkimporter/BulkExtract.svelte

View workflow job for this annotation

GitHub Actions / lint-js

Forbidden non-null assertion
}
});
}

function moveSelected() {
if (!new_target) {
return;
}
forEachSelectedTransaction((transaction, index) => {

Check failure on line 102 in frontend/src/reports/bulkimporter/BulkExtract.svelte

View workflow job for this annotation

GitHub Actions / lint-js

'index' is defined but never used
transaction.transaction.postings[

Check failure on line 103 in frontend/src/reports/bulkimporter/BulkExtract.svelte

View workflow job for this annotation

GitHub Actions / lint-js

Forbidden non-null assertion
transaction.target_posting_index
]!.account = new_target;
});
// Force reactivity to everything that follows from `entries`
entries = entries;
new_target = "";
selectNone();
}

function markSelectedDuplicate() {
forEachSelectedTransaction((transaction, index) => {

Check failure on line 114 in frontend/src/reports/bulkimporter/BulkExtract.svelte

View workflow job for this annotation

GitHub Actions / lint-js

'index' is defined but never used
transaction.transaction.meta.__duplicate__ = true;
});
// Force reactivity to everything that follows from `entries`
entries = entries;
selectNone();
}

function markSelectedNotDuplicate() {
forEachSelectedTransaction((transaction, index) => {

Check failure on line 123 in frontend/src/reports/bulkimporter/BulkExtract.svelte

View workflow job for this annotation

GitHub Actions / lint-js

'index' is defined but never used
transaction.transaction.meta.__duplicate__ = false;
});
// Force reactivity to everything that follows from `entries`
entries = entries;
selectNone();
}
</script>

<ModalBase {shown} closeHandler={close}>
<div>
<h3>{_("Import")}: {account}</h3>
<div class="toolbar" transition:slide|global>
<label>
<input type="checkbox" on:click|preventDefault={toggleSelectAll} />
{num_selected} selected.
</label>
{#if num_selected > 0}
<AutocompleteInput
bind:value={new_target}
placeholder={_("Move to account")}
suggestions={$accounts}
className="account-selector"
/>
<button on:click={moveSelected}>Move</button>

Check failure on line 147 in frontend/src/reports/bulkimporter/BulkExtract.svelte

View workflow job for this annotation

GitHub Actions / lint-js

Missing an explicit type attribute for button
<button on:click={markSelectedDuplicate}>Mark Duplicate</button>

Check failure on line 148 in frontend/src/reports/bulkimporter/BulkExtract.svelte

View workflow job for this annotation

GitHub Actions / lint-js

Missing an explicit type attribute for button
<button on:click={markSelectedNotDuplicate}>Mark Not Duplicate</button>
{/if}
</div>
<div>
{#each transactions_by_target as [target, transactions], i}
<h4>{target}</h4>
<ul class="flex-table bulk-importer">
<li class="head">
<p>
<span class="select"></span>
<span class="datecell">Date</span>
<span class="flag">F</span>
<span class="description">Description</span>
<span class="num">Amount</span>
<span class="edit"></span>
</p>
</li>
{#each transactions as transaction}
<SimpleTransactionRow
bind:entry={transaction}
bind:selected={selected[transaction.index]}
manual_edit={() => {
entry_to_edit = transaction.transaction;
}}
/>
{/each}
</ul>
{/each}
<strong>{_("Other entries")}</strong>
<ul class="flex-table bulk-importer">
<li class="head">
<p>
<span class="select"></span>
<span class="datecell">Date</span>
<span class="flag">F</span>
<span class="description">Description</span>
<span class="num">Amount</span>
<span class="edit"></span>
</p>
</li>
{#each other_entries as entry}
<OtherEntryRow
bind:entry
manual_edit={() => {
entry_to_edit = entry;
}}
/>
{/each}
</ul>
</div>
<div class="flex-row">
<form on:submit|preventDefault={save}>
<button>{_("Save")}</button>
</form>
</div>
</div>
<IndividualEntryEdit
bind:entry={entry_to_edit}
close={() => {
entry_to_edit = undefined;
entries = entries;
}}
/>
</ModalBase>

<style>
.toolbar {
min-height: 3em;
}

.toolbar label {
vertical-align: middle;
}
</style>
58 changes: 58 additions & 0 deletions frontend/src/reports/bulkimporter/IndividualEntryEdit.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<script lang="ts">
// This is very similar to the Extract.svelte component in the traditional importer.
import type { Entry as EntryType } from "../../entries";
import { isDuplicate, Transaction } from "../../entries";
import Entry from "../../entry-forms/Entry.svelte";
import { _ } from "../../i18n";
import ModalBase from "../../modals/ModalBase.svelte";

export let entry: EntryType | undefined;
export let close: () => void;
$: shown = !!entry; // Truthiness check (if entry is undefined then shown === false)

$: duplicate = entry && isDuplicate(entry);

function toggleDuplicate() {
if (entry) {
entry.meta.__duplicate__ = !isDuplicate(entry);
}
}

function cleanup_and_close() {
if (entry instanceof Transaction) {
// The editor can add an extra posting to allow for editing. Clean it up
// before closing.
entry.postings = entry.postings.filter((p) => !p.is_empty());
}
close();
}
</script>

<ModalBase {shown} closeHandler={cleanup_and_close}>
<form novalidate={duplicate} on:submit|preventDefault={() => {}}>
<h3>{_("Import")}</h3>
{#if entry}
<div class="flex-row">
<h3>{_("Edit Entry")}</h3>
<span class="spacer"></span>
<label class="button muted">
<input
type="checkbox"
checked={duplicate}
on:click={toggleDuplicate}
/>
ignore duplicate
</label>
</div>
<div class:duplicate>
<Entry bind:entry />
</div>
{/if}
</form>
</ModalBase>

<style>
.duplicate {
opacity: 0.5;
}
</style>
Loading
Loading