Database lock (the reported 0.10.0 regression): - tauri-plugin-sql loads SQLite through a default multi-connection sqlx pool (Pool::connect => max_connections = 10) and exposes no JS transaction primitive. BEGIN and COMMIT issued as separate db.execute calls could land on different pooled connections, stranding an open write transaction on an idle connection — a permanent write lock until app restart. Triggered when the Bilan page's concurrent reads interleaved with an in-flight snapshot save. - Fix: funnel every db operation through one FIFO lock in db.ts; withTransaction holds it across the whole BEGIN..COMMIT so a transaction's statements never span connections (and the saves become genuinely atomic). A reentrancy guard lets nested getDb() calls run directly instead of deadlocking. Applied to all 5 transaction sites (saveSnapshotAtomic, upsertSnapshotLines, proposeStarterAccounts, applyKeywordWithReassignment, applyMigration) via in-place helper extraction. New db.test.ts covers serialization, cross-transaction non-interleaving, reentrancy, and lock release on error. Log console (Settings -> Systems -> Journaux): - getLogs returned the same mutated array reference, so useSyncExternalStore (identity comparison) never re-rendered on a new log -> the console never updated live. getLogs now returns an immutable snapshot rebuilt on each mutation (stable between mutations, new identity after each). - Add logInfo/logWarn/logError app-logging API and instrument the snapshot save (info on success, error on failure) so DB issues surface in the console. tsc + 635 vitest + vite build green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
283 lines
8.9 KiB
TypeScript
283 lines
8.9 KiB
TypeScript
import { getDb, withTransaction } from "./db";
|
|
import type Database from "@tauri-apps/plugin-sql";
|
|
import type { Keyword, RecentTransaction } from "../shared/types";
|
|
|
|
/**
|
|
* Normalize a description for keyword matching:
|
|
* - lowercase
|
|
* - strip accents via NFD decomposition
|
|
* - collapse whitespace
|
|
*/
|
|
export function normalizeDescription(desc: string): string {
|
|
return desc
|
|
.normalize("NFD")
|
|
.replace(/[\u0300-\u036f]/g, "")
|
|
.toLowerCase()
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
const WORD_CHAR = /\w/;
|
|
|
|
/**
|
|
* Build a regex pattern for a keyword with smart boundaries.
|
|
* Uses \b when the keyword edge is a word character (a-z, 0-9, _),
|
|
* and uses (?<=\s|^) / (?=\s|$) when the edge is a non-word character
|
|
* (e.g., brackets, parentheses, dashes). This ensures keywords like
|
|
* "[VIREMENT]" or "(INTERAC)" can match correctly.
|
|
*/
|
|
export function buildKeywordRegex(normalizedKeyword: string): RegExp {
|
|
const escaped = normalizedKeyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const left = WORD_CHAR.test(normalizedKeyword[0])
|
|
? "\\b"
|
|
: "(?<=\\s|^)";
|
|
const right = WORD_CHAR.test(normalizedKeyword[normalizedKeyword.length - 1])
|
|
? "\\b"
|
|
: "(?=\\s|$)";
|
|
return new RegExp(`${left}${escaped}${right}`);
|
|
}
|
|
|
|
interface CategorizationResult {
|
|
category_id: number | null;
|
|
supplier_id: number | null;
|
|
}
|
|
|
|
interface CompiledKeyword {
|
|
regex: RegExp;
|
|
category_id: number;
|
|
supplier_id: number | null;
|
|
}
|
|
|
|
/**
|
|
* Compile keywords into regex patterns once for reuse across multiple matches.
|
|
*/
|
|
export function compileKeywords(keywords: Keyword[]): CompiledKeyword[] {
|
|
return keywords.map((kw) => ({
|
|
regex: buildKeywordRegex(normalizeDescription(kw.keyword)),
|
|
category_id: kw.category_id,
|
|
supplier_id: kw.supplier_id ?? null,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Match a normalized description against compiled keywords.
|
|
*/
|
|
function matchDescription(
|
|
normalized: string,
|
|
compiled: CompiledKeyword[]
|
|
): CategorizationResult {
|
|
for (const kw of compiled) {
|
|
if (kw.regex.test(normalized)) {
|
|
return {
|
|
category_id: kw.category_id,
|
|
supplier_id: kw.supplier_id,
|
|
};
|
|
}
|
|
}
|
|
return { category_id: null, supplier_id: null };
|
|
}
|
|
|
|
/**
|
|
* Auto-categorize a single transaction description.
|
|
* Returns matching category_id and supplier_id, or nulls if no match.
|
|
*/
|
|
export async function categorizeDescription(
|
|
description: string
|
|
): Promise<CategorizationResult> {
|
|
const db = await getDb();
|
|
const keywords = await db.select<Keyword[]>(
|
|
"SELECT * FROM keywords WHERE is_active = 1 ORDER BY priority DESC"
|
|
);
|
|
|
|
const compiled = compileKeywords(keywords);
|
|
const normalized = normalizeDescription(description);
|
|
return matchDescription(normalized, compiled);
|
|
}
|
|
|
|
/**
|
|
* Auto-categorize a batch of transactions (by their descriptions).
|
|
* Returns an array of results in the same order.
|
|
*/
|
|
export async function categorizeBatch(
|
|
descriptions: string[]
|
|
): Promise<CategorizationResult[]> {
|
|
const db = await getDb();
|
|
const keywords = await db.select<Keyword[]>(
|
|
"SELECT * FROM keywords WHERE is_active = 1 ORDER BY priority DESC"
|
|
);
|
|
|
|
const compiled = compileKeywords(keywords);
|
|
|
|
return descriptions.map((desc) => {
|
|
const normalized = normalizeDescription(desc);
|
|
return matchDescription(normalized, compiled);
|
|
});
|
|
}
|
|
|
|
// --- AddKeywordDialog support (Issue #74) ---
|
|
|
|
export const KEYWORD_MIN_LENGTH = 2;
|
|
export const KEYWORD_MAX_LENGTH = 64;
|
|
export const KEYWORD_PREVIEW_LIMIT = 50;
|
|
|
|
/**
|
|
* Validate a keyword before it hits the regex engine.
|
|
*
|
|
* Rejects whitespace-only input and caps length at 64 chars to prevent
|
|
* ReDoS (CWE-1333) when the compiled regex is replayed across many
|
|
* transactions later.
|
|
*/
|
|
export function validateKeyword(raw: string): { ok: true; value: string } | { ok: false; reason: "tooShort" | "tooLong" } {
|
|
const trimmed = raw.trim();
|
|
if (trimmed.length < KEYWORD_MIN_LENGTH) return { ok: false, reason: "tooShort" };
|
|
if (trimmed.length > KEYWORD_MAX_LENGTH) return { ok: false, reason: "tooLong" };
|
|
return { ok: true, value: trimmed };
|
|
}
|
|
|
|
/**
|
|
* Preview the transactions that would be recategorised if the user commits
|
|
* the given keyword. Uses a parameterised `LIKE ?1` to scope the candidates,
|
|
* then re-filters in memory with `buildKeywordRegex` for exact word-boundary
|
|
* matching. Results are capped at `limit` visible rows — callers decide what
|
|
* to do with the `totalMatches` (which may be greater than the returned list).
|
|
*
|
|
* SECURITY: the keyword is never interpolated into the SQL string. `LIKE ?1`
|
|
* is the only parameterised binding, and the `%...%` wrapping happens inside
|
|
* the bound parameter value.
|
|
*/
|
|
export async function previewKeywordMatches(
|
|
keyword: string,
|
|
limit: number = KEYWORD_PREVIEW_LIMIT,
|
|
): Promise<{ visible: RecentTransaction[]; totalMatches: number }> {
|
|
const validation = validateKeyword(keyword);
|
|
if (!validation.ok) {
|
|
return { visible: [], totalMatches: 0 };
|
|
}
|
|
const normalized = normalizeDescription(validation.value);
|
|
const regex = buildKeywordRegex(normalized);
|
|
const db = await getDb();
|
|
|
|
// Coarse pre-filter via parameterised LIKE (case-insensitive thanks to
|
|
// normalize on the JS side). A small cap protects against catastrophic
|
|
// backtracking across a huge candidate set — hard-capped to 1000 rows
|
|
// before the in-memory filter.
|
|
const likePattern = `%${normalized}%`;
|
|
const candidates = await db.select<RecentTransaction[]>(
|
|
`SELECT t.id, t.date, t.description, t.amount,
|
|
c.name AS category_name,
|
|
c.color AS category_color
|
|
FROM transactions t
|
|
LEFT JOIN categories c ON t.category_id = c.id
|
|
WHERE LOWER(t.description) LIKE $1
|
|
ORDER BY t.date DESC
|
|
LIMIT 1000`,
|
|
[likePattern],
|
|
);
|
|
|
|
const matched: RecentTransaction[] = [];
|
|
for (const tx of candidates) {
|
|
const normDesc = normalizeDescription(tx.description);
|
|
if (regex.test(normDesc)) matched.push(tx);
|
|
}
|
|
|
|
return {
|
|
visible: matched.slice(0, limit),
|
|
totalMatches: matched.length,
|
|
};
|
|
}
|
|
|
|
export interface ApplyKeywordInput {
|
|
keyword: string;
|
|
categoryId: number;
|
|
/** ids of transactions to recategorise (only those the user checked). */
|
|
transactionIds: number[];
|
|
/**
|
|
* When true, and a keyword with the same spelling already exists for a
|
|
* different category, that existing keyword is **reassigned** to the new
|
|
* category rather than creating a duplicate. Matches the spec decision
|
|
* that history is never touched — only the visible transactions are
|
|
* recategorised.
|
|
*/
|
|
allowReplaceExisting: boolean;
|
|
}
|
|
|
|
export interface ApplyKeywordResult {
|
|
keywordId: number;
|
|
updatedTransactions: number;
|
|
replacedExisting: boolean;
|
|
}
|
|
|
|
/**
|
|
* INSERTs (or reassigns) a keyword and recategorises the given transaction
|
|
* ids in a single SQL transaction. Either all writes commit or none do.
|
|
*
|
|
* SECURITY: every query is parameterised. The caller is expected to have
|
|
* vetted `transactionIds` from a preview window that the user confirmed.
|
|
*/
|
|
export async function applyKeywordWithReassignment(
|
|
input: ApplyKeywordInput,
|
|
): Promise<ApplyKeywordResult> {
|
|
const validation = validateKeyword(input.keyword);
|
|
if (!validation.ok) {
|
|
throw new Error(`invalid_keyword:${validation.reason}`);
|
|
}
|
|
const keyword = validation.value;
|
|
|
|
return withTransaction((db) =>
|
|
applyKeywordInTransaction(db, input, keyword),
|
|
);
|
|
}
|
|
|
|
async function applyKeywordInTransaction(
|
|
db: Database,
|
|
input: ApplyKeywordInput,
|
|
keyword: string,
|
|
): Promise<ApplyKeywordResult> {
|
|
await db.execute("BEGIN");
|
|
try {
|
|
// Is there already a row for this keyword spelling?
|
|
const existing = await db.select<Array<{ id: number; category_id: number }>>(
|
|
`SELECT id, category_id FROM keywords WHERE keyword = $1 LIMIT 1`,
|
|
[keyword],
|
|
);
|
|
|
|
let keywordId: number;
|
|
let replacedExisting = false;
|
|
if (existing.length > 0) {
|
|
if (!input.allowReplaceExisting && existing[0].category_id !== input.categoryId) {
|
|
throw new Error("keyword_already_exists");
|
|
}
|
|
await db.execute(
|
|
`UPDATE keywords SET category_id = $1, is_active = 1 WHERE id = $2`,
|
|
[input.categoryId, existing[0].id],
|
|
);
|
|
keywordId = existing[0].id;
|
|
replacedExisting = existing[0].category_id !== input.categoryId;
|
|
} else {
|
|
const result = await db.execute(
|
|
`INSERT INTO keywords (keyword, category_id, priority) VALUES ($1, $2, $3)`,
|
|
[keyword, input.categoryId, 100],
|
|
);
|
|
keywordId = Number(result.lastInsertId ?? 0);
|
|
}
|
|
|
|
let updatedTransactions = 0;
|
|
for (const txId of input.transactionIds) {
|
|
await db.execute(
|
|
`UPDATE transactions
|
|
SET category_id = $1,
|
|
is_manually_categorized = 1,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $2`,
|
|
[input.categoryId, txId],
|
|
);
|
|
updatedTransactions++;
|
|
}
|
|
|
|
await db.execute("COMMIT");
|
|
return { keywordId, updatedTransactions, replacedExisting };
|
|
} catch (e) {
|
|
await db.execute("ROLLBACK");
|
|
throw e;
|
|
}
|
|
}
|