feat(editor,quick-entry): page editor with splits, links, attachments and one-line quick entry
This commit is contained in:
1 parent
135a2d9328
commit
7e19684cff
22 files changed
+4067
-10
No files matched your search
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user