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.
379 lines
9.7 KiB
TypeScript
379 lines
9.7 KiB
TypeScript
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,
|
|
type Side,
|
|
type SplitForm,
|
|
type TxType,
|
|
TYPE_LABEL,
|
|
toAccountRef,
|
|
} from "./model.js";
|
|
|
|
export type Marker = "@" | "^" | ">" | "!" | "$" | "%" | "#" | "~";
|
|
|
|
const MULTI = new Set<string>(["@", "^", ">", "!", "$", "%"]);
|
|
const MARKERS = new Set<string>([...MULTI, "#", "~"]);
|
|
const WORD = /\S+/g;
|
|
const NUMERIC = /^[\d(]/;
|
|
|
|
export interface Segment {
|
|
end: number;
|
|
marker: Marker;
|
|
start: number;
|
|
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;
|
|
}
|
|
|
|
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 first = true;
|
|
for (const match of input.matchAll(WORD)) {
|
|
let [word] = match;
|
|
let start = match.index;
|
|
if (first && word.startsWith("+")) {
|
|
deposit = true;
|
|
word = word.slice(1);
|
|
start += 1;
|
|
}
|
|
first = false;
|
|
const taken =
|
|
!word ||
|
|
takeCode(state, word, codes) ||
|
|
takeEqual(state, word) ||
|
|
takeMarker(state, word, start) ||
|
|
takeAmount(state, word);
|
|
if (!taken) {
|
|
state.words.push(word);
|
|
}
|
|
}
|
|
const hasTarget = state.segments.some((segment) => segment.marker === ">");
|
|
let type: TxType = "withdrawal";
|
|
if (hasTarget) {
|
|
type = "transfer";
|
|
} else if (deposit) {
|
|
type = "deposit";
|
|
}
|
|
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) =>
|
|
quick.segments
|
|
.filter((segment) => segment.marker === marker)
|
|
.at(-1)
|
|
?.value.trim() ?? "";
|
|
|
|
export const tagsOf = (quick: Quick) =>
|
|
quick.segments
|
|
.filter((segment) => segment.marker === "#" && segment.value)
|
|
.map((segment) => segment.value);
|
|
|
|
export function segmentAt(
|
|
quick: Quick,
|
|
input: string,
|
|
caret: number
|
|
): Segment | null {
|
|
const last = quick.segments.at(-1);
|
|
for (const segment of quick.segments) {
|
|
const open =
|
|
segment === last && MULTI.has(segment.marker) && caret === input.length;
|
|
if (caret > segment.start && (caret <= segment.end || open)) {
|
|
return segment;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function complete(
|
|
input: string,
|
|
segment: Segment,
|
|
label: string
|
|
): { caret: number; text: string } {
|
|
const before = input.slice(0, segment.start + 1) + label;
|
|
const rest = input.slice(segment.end).trimStart();
|
|
return { caret: before.length + 1, text: `${before} ${rest}` };
|
|
}
|
|
|
|
export const sidesOf = (type: TxType): { other: Side; own: Side } => {
|
|
const own = OWN_SIDE[type];
|
|
return { other: own === "source" ? "destination" : "source", own };
|
|
};
|
|
|
|
export const counterpartyKinds = (type: TxType) =>
|
|
type === "transfer"
|
|
? KINDS.transfer.destination
|
|
: KINDS[type][sidesOf(type).other];
|
|
|
|
function resolveAccounts(quick: Quick, catalog: Catalog, split: SplitForm) {
|
|
const { own, other } = sidesOf(quick.type);
|
|
const ownName = markerValue(quick, "^");
|
|
const ownAccount = ownName
|
|
? catalog.findAccount(KINDS[quick.type][own], ownName)
|
|
: catalog.defaultAccount();
|
|
if (ownAccount) {
|
|
split[own] = toAccountRef(ownAccount);
|
|
}
|
|
const otherName = markerValue(quick, quick.type === "transfer" ? ">" : "@");
|
|
const otherAccount = otherName
|
|
? catalog.findAccount(counterpartyKinds(quick.type), otherName)
|
|
: undefined;
|
|
split[other] = otherAccount
|
|
? toAccountRef(otherAccount)
|
|
: { ...split[other], name: otherName };
|
|
return ownAccount || !ownName
|
|
? []
|
|
: [`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,
|
|
split: SplitForm,
|
|
past: Split | null | undefined
|
|
) {
|
|
const categoryName = markerValue(quick, "!");
|
|
const budgetName = markerValue(quick, "$");
|
|
const learn = quick.type !== "transfer" && past;
|
|
split.category = categoryName || (learn ? (past.category_name ?? "") : "");
|
|
const budget = budgetName ? catalog.findBudget(budgetName) : undefined;
|
|
if (budget) {
|
|
split.budget = { id: budget.id, name: budget.attributes.name };
|
|
} else if (!budgetName && learn && past.budget_id) {
|
|
split.budget = { id: past.budget_id, name: past.budget_name ?? "" };
|
|
}
|
|
return {
|
|
fromPast: {
|
|
budget: !budgetName && Boolean(split.budget.id),
|
|
category: !categoryName && Boolean(split.category),
|
|
},
|
|
problems: budgetName && !budget ? [`No budget called "${budgetName}"`] : [],
|
|
};
|
|
}
|
|
|
|
export function resolveQuick(
|
|
quick: Quick,
|
|
catalog: Catalog,
|
|
recent: Record<string, Split | null>
|
|
) {
|
|
const form = emptyForm(quick.type);
|
|
const [split] = form.splits;
|
|
const { own, other } = sidesOf(quick.type);
|
|
const problems = resolveAccounts(quick, catalog, split);
|
|
const counterparty = split[other].id;
|
|
const links = resolveLinks(
|
|
quick,
|
|
catalog,
|
|
split,
|
|
counterparty ? recent[counterparty] : null
|
|
);
|
|
problems.push(
|
|
...links.problems,
|
|
...resolveBill(quick, catalog, split),
|
|
...resolveMoney(
|
|
quick,
|
|
split,
|
|
split[own],
|
|
quick.type === "transfer" ? split[other] : null
|
|
)
|
|
);
|
|
split.tags = tagsOf(quick);
|
|
split.description =
|
|
quick.description ||
|
|
split[other].name ||
|
|
split.category ||
|
|
TYPE_LABEL[quick.type];
|
|
const dateText = markerValue(quick, "~");
|
|
const date = dateText ? parseDate(dateText) : today();
|
|
if (date) {
|
|
form.date = date;
|
|
} else {
|
|
problems.push(`"${dateText}" is not a date`);
|
|
}
|
|
return { form, fromPast: links.fromPast, problems };
|
|
}
|