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

add auto-completion for narration #1888

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions frontend/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ interface GetAPIParams {
move: { filename: string; account: string; new_name: string };
payee_accounts: { payee: string };
payee_transaction: { payee: string };
narration_transaction: { narration: string };
query: Filters & { query_string: string };
source: { filename: string };
}
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/api/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export const ledgerDataValidator = object({
options,
other_ledgers: array(tuple(string, string)),
payees: array(string),
narrations: array(string),
precisions: record(number),
sidebar_links: array(tuple(string, string)),
tags: array(string),
Expand Down Expand Up @@ -193,6 +194,7 @@ export const getAPIValidators = {
move: string,
payee_accounts: array(string),
payee_transaction: Transaction.validator,
narration_transaction: Transaction.validator,
query: query_validator,
source,
trial_balance: tree_report,
Expand Down
48 changes: 27 additions & 21 deletions frontend/src/entry-forms/Transaction.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
import { Posting } from "../entries";
import { _ } from "../i18n";
import { notify_err } from "../notifications";
import { payees } from "../stores";
import { valueExtractor, valueSelector } from "../sidebar/FilterForm.svelte";
import { narrations, payees } from "../stores";
import AddMetadataButton from "./AddMetadataButton.svelte";
import EntryMetadata from "./EntryMetadata.svelte";
import PostingSvelte from "./Posting.svelte";
Expand Down Expand Up @@ -40,22 +41,17 @@
}
}

/// Extract tags and links that can be provided in the narration <input>.
function onNarrationChange({
currentTarget,
}: {
currentTarget: HTMLInputElement;
}) {
const { value } = currentTarget;
/// Extract tags and links that can be provided in the narration.
function onNarrationBlur() {
const value = narration;
entry.tags = [...value.matchAll(TAGS_RE)].map((a) => a[1] ?? "");
entry.links = [...value.matchAll(LINKS_RE)].map((a) => a[1] ?? "");
entry.narration = value
.replaceAll(TAGS_RE, "")
.replaceAll(LINKS_RE, "")
.trim();
}

/// Output tags and links in the narration <input>
/// Output tags and links in the narration
function combineNarrationTagsLinks(e: Transaction): string {
let val = e.narration;
if (e.tags.length) {
Expand All @@ -66,7 +62,7 @@
}
return val;
}
$: narration = combineNarrationTagsLinks(entry);
let narration = "";

// Autofill complete transactions.
async function autocompleteSelectPayee() {
Expand All @@ -77,6 +73,17 @@
data.date = entry.date;
entry = data;
}
async function autocompleteSelectNarration() {
if (entry.payee || !entry.postings.every((p) => !p.account)) {
return;
}
const data = await get("narration_transaction", {
narration: narration,
});
data.date = entry.date;
entry = data;
narration = combineNarrationTagsLinks(entry);
}

function movePosting({ from, to }: { from: number; to: number }) {
const moved = entry.postings[from];
Expand Down Expand Up @@ -108,14 +115,18 @@
on:select={autocompleteSelectPayee}
/>
</label>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label>
<span>{_("Narration")}:</span>
<input
type="text"
name="narration"
<AutocompleteInput
className="narration"
placeholder={_("Narration")}
value={narration}
on:change={onNarrationChange}
bind:value={narration}
suggestions={$narrations}
{valueExtractor}
{valueSelector}
on:blur={onNarrationBlur}
on:select={autocompleteSelectNarration}
/>
<AddMetadataButton bind:meta={entry.meta} />
</label>
Expand Down Expand Up @@ -151,11 +162,6 @@
flex-basis: 100px;
}

input[name="narration"] {
flex-grow: 1;
flex-basis: 200px;
}

label > span:first-child,
.label > span:first-child {
display: none;
Expand Down
36 changes: 22 additions & 14 deletions frontend/src/sidebar/FilterForm.svelte
Original file line number Diff line number Diff line change
@@ -1,22 +1,17 @@
<script lang="ts">
import AutocompleteInput from "../AutocompleteInput.svelte";
import { _ } from "../i18n";
import { accounts, links, payees, tags, years } from "../stores";
import { account_filter, fql_filter, time_filter } from "../stores/filters";

$: fql_filter_suggestions = [
...$tags.map((tag) => `#${tag}`),
...$links.map((link) => `^${link}`),
...$payees.map((payee) => `payee:"${payee}"`),
];

function valueExtractor(value: string, input: HTMLInputElement) {
<script lang="ts" context="module">
export function valueExtractor(
value: string,
input: HTMLInputElement,
): string {
const match = /\S*$/.exec(
value.slice(0, input.selectionStart ?? undefined),
);
return match?.[0] ?? value;
}
function valueSelector(value: string, input: HTMLInputElement) {
export function valueSelector(
value: string,
input: HTMLInputElement,
): string {
const selectionStart = input.selectionStart ?? 0;
const match = /\S*$/.exec(input.value.slice(0, selectionStart));
const matchLength = match?.[0]?.length;
Expand All @@ -27,6 +22,19 @@
)}${value}${input.value.slice(selectionStart)}`
: value;
}
</script>

<script lang="ts">
import AutocompleteInput from "../AutocompleteInput.svelte";
import { _ } from "../i18n";
import { accounts, links, payees, tags, years } from "../stores";
import { account_filter, fql_filter, time_filter } from "../stores/filters";

$: fql_filter_suggestions = [
...$tags.map((tag) => `#${tag}`),
...$links.map((link) => `^${link}`),
...$payees.map((payee) => `payee:"${payee}"`),
];

let account_filter_value = "";
let fql_filter_value = "";
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/stores/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ export const currencies = derived_array(ledgerData, (v) => v.currencies);
export const links = derived_array(ledgerData, (v) => v.links);
/** The ranked array of all payees. */
export const payees = derived_array(ledgerData, (v) => v.payees);
/** The ranked array of all narrations. */
export const narrations = derived_array(ledgerData, (v) => v.narrations);
/** The ranked array of all tags. */
export const tags = derived_array(ledgerData, (v) => v.tags);
/** The array of all years. */
Expand Down
13 changes: 13 additions & 0 deletions src/fava/core/attributes.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ def __init__(self, ledger: FavaLedger) -> None:
self.accounts: Sequence[str] = []
self.currencies: Sequence[str] = []
self.payees: Sequence[str] = []
self.narrations: Sequence[str] = []
self.links: Sequence[str] = []
self.tags: Sequence[str] = []
self.years: Sequence[str] = []
Expand Down Expand Up @@ -93,10 +94,13 @@ def load_file(self) -> None: # noqa: D102
)
currency_ranker = ExponentialDecayRanker()
payee_ranker = ExponentialDecayRanker()
narration_ranker = ExponentialDecayRanker()

for txn in self.ledger.all_entries_by_type.Transaction:
if txn.payee:
payee_ranker.update(txn.payee, txn.date)
if txn.narration:
narration_ranker.update(txn.narration, txn.date)
for posting in txn.postings:
account_ranker.update(posting.account, txn.date)
currency_ranker.update(posting.units.currency, txn.date)
Expand All @@ -106,6 +110,7 @@ def load_file(self) -> None: # noqa: D102
self.accounts = account_ranker.sort()
self.currencies = currency_ranker.sort()
self.payees = payee_ranker.sort()
self.narrations = narration_ranker.sort()

def payee_accounts(self, payee: str) -> Sequence[str]:
"""Rank accounts for the given payee."""
Expand All @@ -124,3 +129,11 @@ def payee_transaction(self, payee: str) -> Transaction | None:
if txn.payee == payee:
return txn
return None

def narration_transaction(self, narration: str) -> Transaction | None:
"""Get the last transaction for a narration."""
transactions = self.ledger.all_entries_by_type.Transaction
for txn in reversed(transactions):
if txn.narration == narration:
return txn
return None
2 changes: 2 additions & 0 deletions src/fava/internal_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ class LedgerData:
links: Sequence[str]
options: dict[str, str | Sequence[str]]
payees: Sequence[str]
narrations: Sequence[str]
precisions: dict[str, int]
tags: Sequence[str]
years: Sequence[str]
Expand Down Expand Up @@ -116,6 +117,7 @@ def get_ledger_data() -> LedgerData:
ledger.attributes.links,
_get_options(),
ledger.attributes.payees,
ledger.attributes.narrations,
ledger.format_decimal.precisions,
ledger.attributes.tags,
ledger.attributes.years,
Expand Down
7 changes: 7 additions & 0 deletions src/fava/json_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,13 @@ def get_payee_transaction(payee: str) -> Any:
return serialise(entry) if entry else None


@api_endpoint
def get_narration_transaction(narration: str) -> Any:
"""Last transaction for the given narration."""
entry = g.ledger.attributes.narration_transaction(narration)
return serialise(entry) if entry else None


@api_endpoint
def get_source(filename: str) -> Mapping[str, str]:
"""Load one of the source files."""
Expand Down
37 changes: 37 additions & 0 deletions tests/__snapshots__/test_application-test_client_side_reports
Original file line number Diff line number Diff line change
Expand Up @@ -941,6 +941,43 @@
"links": [
"test-link"
],
"narrations": [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This part is problematic. In my case, all these narrations would e.g. double the size of the balance sheet report. I think we should switch to lazily loading them only when the dialog is used.

"Investing 40% of cash in VBMPX",
"Investing 60% of cash in RGAGX",
"Payroll",
"Buying groceries",
"Eating out alone",
"Employer match for contribution",
"Eating out with Julie",
"Eating out ",
"Eating out after work",
"Eating out with Bill",
"Eating out with Natasha",
"Monthly bank fee",
"Paying off credit card",
"Eating out with Joe",
"Paying the rent",
"Tram tickets",
"Eating out with work buddies",
"Buy shares of VEA",
"Buy shares of GLD",
"Dividends on portfolio",
"Transfering accumulated savings to other account",
"Buy shares of ITOT",
"Buy shares of VHT",
"Consume vacation days",
"Sell shares of GLD",
"STATE TAX \u0026 FINANC PYMT",
"FEDERAL TAXPYMT",
"Allowed contributions for one year",
"Sell shares of VEA",
"Filing taxes for 2015",
"Sell shares of VHT",
"Sell shares of ITOT",
"Filing taxes for 2014",
"Opening Balance for checking account",
"\u00c1rv\u00edzt\u0171r\u0151 t\u00fck\u00f6rf\u00far\u00f3g\u00e9p"
],
"options": {
"documents": [],
"filename": "TEST_DATA_DIR/long-example.beancount",
Expand Down
37 changes: 37 additions & 0 deletions tests/__snapshots__/test_internal_api-test_get_ledger_data.json
Original file line number Diff line number Diff line change
Expand Up @@ -929,6 +929,43 @@
"links": [
"test-link"
],
"narrations": [
"Investing 40% of cash in VBMPX",
"Investing 60% of cash in RGAGX",
"Payroll",
"Buying groceries",
"Eating out alone",
"Employer match for contribution",
"Eating out with Julie",
"Eating out ",
"Eating out after work",
"Eating out with Bill",
"Eating out with Natasha",
"Monthly bank fee",
"Paying off credit card",
"Eating out with Joe",
"Paying the rent",
"Tram tickets",
"Eating out with work buddies",
"Buy shares of VEA",
"Buy shares of GLD",
"Dividends on portfolio",
"Transfering accumulated savings to other account",
"Buy shares of ITOT",
"Buy shares of VHT",
"Consume vacation days",
"Sell shares of GLD",
"STATE TAX & FINANC PYMT",
"FEDERAL TAXPYMT",
"Allowed contributions for one year",
"Sell shares of VEA",
"Filing taxes for 2015",
"Sell shares of VHT",
"Sell shares of ITOT",
"Filing taxes for 2014",
"Opening Balance for checking account",
"\u00c1rv\u00edzt\u0171r\u0151 t\u00fck\u00f6rf\u00far\u00f3g\u00e9p"
],
"options": {
"documents": [],
"filename": "TEST_DATA_DIR/long-example.beancount",
Expand Down
9 changes: 9 additions & 0 deletions tests/test_core_attributes.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,12 @@ def test_payee_transaction(example_ledger: FavaLedger) -> None:
txn = attr.payee_transaction("BayBook")
assert txn
assert str(txn.date) == "2016-05-05"


def test_narration_transaction(example_ledger: FavaLedger) -> None:
attr = example_ledger.attributes
assert attr.narration_transaction("NOTANARRATION") is None

txn = attr.narration_transaction("Monthly bank fee")
assert txn
assert str(txn.date) == "2016-05-04"