feat(editor,quick-entry): page editor with splits, links, attachments and one-line quick entry

This commit is contained in:
hh committed 2026-10-16 03:25:02 +00:00
1 parent 135a2d9328
commit 7e19684cff
22 files changed
+4067 -10

No files matched your search

+219
View File
@@ -0,0 +1,219 @@
import type { Split } from "#lib/api/types.js";
import { evalAmount, parseDate } from "#lib/components/inputs/parse.js";
import { today } from "#lib/dates.js";
import type { Catalog } from "./catalog.svelte.js";
import {
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 Quick {
amount: number | null;
amountText: string;
description: string;
segments: Segment[];
type: TxType;
}
export function parseQuick(input: string): Quick {
const segments: Segment[] = [];
const words: string[] = [];
let amount: number | null = null;
let amountText = "";
let deposit = false;
let current: Segment | null = null;
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;
if (!word) {
continue;
}
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 === ">");
let type: TxType = "withdrawal";
if (hasTarget) {
type = "transfer";
} else if (deposit) {
type = "deposit";
}
return { amount, amountText, description: words.join(" "), 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 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);
split.tags = tagsOf(quick);
split.amount = quick.amountText;
split.currency = split[own].currency;
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 };
}