feat: tag clean-up converters, saved searches, quick entry subscriptions and foreign amounts

Tag clean-up lists tags named like a category, account or subscription,
tags used once and unused ones; s c and s s move a tag's transactions
to a category or subscription, Enter again deletes the tag. P pins a
search, pins and built-ins show in the palette. Quick entry takes
%subscription and =520 UAH. The transaction page compares the amount
with the usual one at the same place.
This commit is contained in:
hh committed 2026-10-16 15:10:47 +00:00
1 parent 6f39343e07
commit 01c53d982d
13 files changed
+1093 -102

No files matched your search

+198 -39
View File
@@ -1,8 +1,11 @@
import type { Split } from "#lib/api/types.js";
import { evalAmount, parseDate } from "#lib/components/inputs/parse.js";
import { today } from "#lib/dates.js";
import { session } from "#lib/session.svelte.js";
import type { Catalog } from "./catalog.svelte.js";
import {
type AccountRef,
type Cur,
emptyForm,
KINDS,
OWN_SIDE,
@@ -13,9 +16,9 @@ import {
toAccountRef,
} from "./model.js";
export type Marker = "@" | "^" | ">" | "!" | "$" | "#" | "~";
export type Marker = "@" | "^" | ">" | "!" | "$" | "%" | "#" | "~";
const MULTI = new Set<string>(["@", "^", ">", "!", "$"]);
const MULTI = new Set<string>(["@", "^", ">", "!", "$", "%"]);
const MARKERS = new Set<string>([...MULTI, "#", "~"]);
const WORD = /\S+/g;
const NUMERIC = /^[\d(]/;
@@ -27,21 +30,109 @@ export interface Segment {
value: string;
}
export interface Equal {
code: string;
text: string;
}
export interface Quick {
amount: number | null;
amountText: string;
currency: string;
description: string;
equal: Equal | null;
segments: Segment[];
type: TxType;
}
export function parseQuick(input: string): Quick {
const segments: Segment[] = [];
const words: string[] = [];
let amount: number | null = null;
let amountText = "";
const enabledCodes = () =>
new Set(
session.currencies
.filter((item) => item.attributes.enabled)
.map((item) => item.attributes.code)
);
interface Parse {
amount: number | null;
amountText: string;
coded: "amount" | "equal" | null;
currency: string;
current: Segment | null;
equal: Equal | null;
segments: Segment[];
words: string[];
}
function takeCode(state: Parse, word: string, codes: Set<string>) {
const { coded } = state;
const code = word.toUpperCase();
state.coded = null;
if (!(coded && codes.has(code))) {
return false;
}
if (coded === "amount") {
state.currency = code;
} else if (state.equal) {
state.equal.code = code;
}
return true;
}
function takeEqual(state: Parse, word: string) {
if (!word.startsWith("=") || state.equal) {
return false;
}
state.equal = { code: "", text: word.slice(1) };
state.coded = "equal";
state.current = null;
return true;
}
function takeMarker(state: Parse, word: string, start: number) {
const { current } = state;
if (MARKERS.has(word[0])) {
const segment = {
end: start + word.length,
marker: word[0] as Marker,
start,
value: word.slice(1),
};
state.segments.push(segment);
state.current = MULTI.has(segment.marker) ? segment : null;
return true;
}
if (current) {
current.value = `${current.value} ${word}`;
current.end = start + word.length;
return true;
}
return false;
}
function takeAmount(state: Parse, word: string) {
const value =
state.amount === null && NUMERIC.test(word) ? evalAmount(word) : null;
if (value === null) {
return false;
}
state.amount = value;
state.amountText = word;
state.coded = "amount";
return true;
}
export function parseQuick(input: string, codes = enabledCodes()): Quick {
const state: Parse = {
amount: null,
amountText: "",
coded: null,
currency: "",
current: null,
equal: null,
segments: [],
words: [],
};
let deposit = false;
let current: Segment | null = null;
let first = true;
for (const match of input.matchAll(WORD)) {
let [word] = match;
@@ -52,42 +143,32 @@ export function parseQuick(input: string): Quick {
start += 1;
}
first = false;
if (!word) {
continue;
const taken =
!word ||
takeCode(state, word, codes) ||
takeEqual(state, word) ||
takeMarker(state, word, start) ||
takeAmount(state, word);
if (!taken) {
state.words.push(word);
}
if (MARKERS.has(word[0])) {
const segment = {
end: start + word.length,
marker: word[0] as Marker,
start,
value: word.slice(1),
};
segments.push(segment);
current = MULTI.has(segment.marker) ? segment : null;
continue;
}
if (current) {
current.value = `${current.value} ${word}`;
current.end = start + word.length;
continue;
}
const value: number | null =
amount === null && NUMERIC.test(word) ? evalAmount(word) : null;
if (value !== null) {
amount = value;
amountText = word;
continue;
}
words.push(word);
}
const hasTarget = segments.some((segment) => segment.marker === ">");
const hasTarget = state.segments.some((segment) => segment.marker === ">");
let type: TxType = "withdrawal";
if (hasTarget) {
type = "transfer";
} else if (deposit) {
type = "deposit";
}
return { amount, amountText, description: words.join(" "), segments, type };
return {
amount: state.amount,
amountText: state.amountText,
currency: state.currency,
description: state.words.join(" "),
equal: state.equal,
segments: state.segments,
type,
};
}
export const markerValue = (quick: Quick, marker: Marker) =>
@@ -158,6 +239,77 @@ function resolveAccounts(quick: Quick, catalog: Catalog, split: SplitForm) {
: [`No asset account or liability called "${ownName}"`];
}
function curOf(code: string | undefined): Cur | null {
const found = session.currencies.find(
(item) => item.attributes.code === code
);
return found
? {
code: found.attributes.code,
decimals: found.attributes.decimal_places ?? 2,
symbol: found.attributes.symbol,
}
: null;
}
function resolveMoney(
quick: Quick,
split: SplitForm,
own: AccountRef,
target: AccountRef | null
): string[] {
const native = own.currency?.code;
const { equal } = quick;
split.currency = own.currency;
split.amount = quick.amountText;
const first = quick.currency || native;
if (!native) {
return [];
}
if (!equal) {
return first === native
? []
: [`${own.name} is in ${native}: add =amount ${native}`];
}
const second =
equal.code || (first === native ? target?.currency?.code : native);
if (first === native && second && second !== native) {
split.foreignAmount = equal.text;
split.foreignCurrency = curOf(second);
return [];
}
if (second === native && first !== native) {
split.amount = equal.text;
split.foreignAmount = quick.amountText;
split.foreignCurrency = curOf(first);
return [];
}
const example =
session.currencies.find(
(item) => item.attributes.enabled && item.attributes.code !== native
)?.attributes.code ?? "USD";
return [`Name the other currency, like =${equal.text || "520"} ${example}`];
}
function resolveBill(quick: Quick, catalog: Catalog, split: SplitForm) {
const name = markerValue(quick, "%");
if (!name) {
return [];
}
if (quick.type !== "withdrawal") {
return ["Only a withdrawal can pay a subscription"];
}
const wanted = name.toLowerCase();
const bill = (catalog.bills.data ?? []).find(
(item) => item.attributes.name?.toLowerCase() === wanted
);
if (!bill) {
return [`No subscription called "${name}"`];
}
split.bill = { id: bill.id, name: bill.attributes.name ?? name };
return [];
}
function resolveLinks(
quick: Quick,
catalog: Catalog,
@@ -199,10 +351,17 @@ export function resolveQuick(
split,
counterparty ? recent[counterparty] : null
);
problems.push(...links.problems);
problems.push(
...links.problems,
...resolveBill(quick, catalog, split),
...resolveMoney(
quick,
split,
split[own],
quick.type === "transfer" ? split[other] : null
)
);
split.tags = tagsOf(quick);
split.amount = quick.amountText;
split.currency = split[own].currency;
split.description =
quick.description ||
split[other].name ||