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:
1 parent
6f39343e07
commit
01c53d982d
13 files changed
+1093
-102
No files matched your search
@@ -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 ||
|
||||
|
||||
Reference in new issue
Block a user