2026-03-12 10:59:44 +08:00
|
|
|
|
import { Capacitor } from '@capacitor/core'
|
2025-11-25 17:12:09 +08:00
|
|
|
|
import { CapacitorSQLite, SQLiteConnection } from '@capacitor-community/sqlite'
|
|
|
|
|
|
import { defineCustomElements as defineJeep } from 'jeep-sqlite/loader'
|
|
|
|
|
|
|
|
|
|
|
|
const DB_NAME = 'echo_local'
|
|
|
|
|
|
const DB_VERSION = 1
|
|
|
|
|
|
const TRANSACTION_TABLE_SQL = `
|
|
|
|
|
|
CREATE TABLE IF NOT EXISTS transactions (
|
|
|
|
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
|
|
|
|
amount REAL NOT NULL,
|
|
|
|
|
|
merchant TEXT NOT NULL,
|
|
|
|
|
|
category TEXT DEFAULT 'Uncategorized',
|
|
|
|
|
|
date TEXT NOT NULL,
|
|
|
|
|
|
note TEXT,
|
2026-03-12 10:59:44 +08:00
|
|
|
|
sync_status INTEGER DEFAULT 0,
|
|
|
|
|
|
ai_category TEXT,
|
|
|
|
|
|
ai_tags TEXT,
|
|
|
|
|
|
ai_confidence REAL,
|
|
|
|
|
|
ai_reason TEXT,
|
|
|
|
|
|
ai_status TEXT DEFAULT 'idle',
|
|
|
|
|
|
ai_model TEXT,
|
|
|
|
|
|
ai_normalized_merchant TEXT
|
2025-11-25 17:12:09 +08:00
|
|
|
|
);
|
|
|
|
|
|
`
|
2026-03-12 10:59:44 +08:00
|
|
|
|
const MERCHANT_PROFILE_TABLE_SQL = `
|
|
|
|
|
|
CREATE TABLE IF NOT EXISTS merchant_profiles (
|
|
|
|
|
|
merchant_key TEXT PRIMARY KEY NOT NULL,
|
|
|
|
|
|
normalized_merchant TEXT NOT NULL,
|
|
|
|
|
|
category TEXT,
|
|
|
|
|
|
tags TEXT,
|
|
|
|
|
|
hit_count INTEGER DEFAULT 0,
|
|
|
|
|
|
updated_at TEXT NOT NULL
|
|
|
|
|
|
);
|
|
|
|
|
|
`
|
|
|
|
|
|
const TRANSACTION_REQUIRED_COLUMNS = {
|
|
|
|
|
|
ai_category: 'TEXT',
|
|
|
|
|
|
ai_tags: 'TEXT',
|
|
|
|
|
|
ai_confidence: 'REAL',
|
|
|
|
|
|
ai_reason: 'TEXT',
|
|
|
|
|
|
ai_status: "TEXT DEFAULT 'idle'",
|
|
|
|
|
|
ai_model: 'TEXT',
|
|
|
|
|
|
ai_normalized_merchant: 'TEXT',
|
|
|
|
|
|
}
|
2025-11-25 17:12:09 +08:00
|
|
|
|
|
|
|
|
|
|
let sqliteConnection
|
|
|
|
|
|
let db
|
|
|
|
|
|
let initialized = false
|
2025-11-27 10:44:27 +08:00
|
|
|
|
let jeepLoaderPromise
|
|
|
|
|
|
let jeepElementReadyPromise
|
|
|
|
|
|
|
|
|
|
|
|
const waitFor = (ms = 100) =>
|
|
|
|
|
|
new Promise((resolve) => {
|
|
|
|
|
|
setTimeout(resolve, ms)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const ensureStoreReady = async (jeepEl, retries = 5) => {
|
|
|
|
|
|
for (let attempt = 0; attempt < retries; attempt += 1) {
|
|
|
|
|
|
const isOpen = (await jeepEl.isStoreOpen?.()) === true
|
|
|
|
|
|
if (isOpen) return
|
|
|
|
|
|
if (typeof jeepEl.openStore === 'function') {
|
|
|
|
|
|
const opened = await jeepEl.openStore('jeepSqliteStore', 'databases')
|
|
|
|
|
|
if (opened) return
|
|
|
|
|
|
}
|
|
|
|
|
|
await waitFor(200 * (attempt + 1))
|
|
|
|
|
|
}
|
2026-03-12 10:59:44 +08:00
|
|
|
|
throw new Error('jeep-sqlite IndexedDB store failed to initialize')
|
2025-11-27 10:44:27 +08:00
|
|
|
|
}
|
2025-11-25 17:12:09 +08:00
|
|
|
|
|
|
|
|
|
|
const ensureJeepElement = async () => {
|
2025-11-27 10:44:27 +08:00
|
|
|
|
if (!jeepLoaderPromise) {
|
|
|
|
|
|
jeepLoaderPromise = defineJeep(window)
|
2025-11-25 17:12:09 +08:00
|
|
|
|
}
|
2025-11-27 10:44:27 +08:00
|
|
|
|
await jeepLoaderPromise
|
|
|
|
|
|
|
|
|
|
|
|
if (!jeepElementReadyPromise) {
|
|
|
|
|
|
jeepElementReadyPromise = (async () => {
|
|
|
|
|
|
let jeepEl = document.querySelector('jeep-sqlite')
|
|
|
|
|
|
if (!jeepEl) {
|
|
|
|
|
|
jeepEl = document.createElement('jeep-sqlite')
|
|
|
|
|
|
jeepEl.setAttribute('auto-save', 'true')
|
|
|
|
|
|
document.body.appendChild(jeepEl)
|
|
|
|
|
|
}
|
|
|
|
|
|
jeepEl.setAttribute('wasm-path', '/assets')
|
|
|
|
|
|
await customElements.whenDefined('jeep-sqlite')
|
|
|
|
|
|
await jeepEl.componentOnReady?.()
|
|
|
|
|
|
return jeepEl
|
|
|
|
|
|
})()
|
2025-11-25 17:12:09 +08:00
|
|
|
|
}
|
2025-11-27 10:44:27 +08:00
|
|
|
|
|
|
|
|
|
|
const element = await jeepElementReadyPromise
|
|
|
|
|
|
await ensureStoreReady(element)
|
2025-11-25 17:12:09 +08:00
|
|
|
|
await CapacitorSQLite.initWebStore()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 10:59:44 +08:00
|
|
|
|
const ensureTableColumns = async (tableName, requiredColumns) => {
|
|
|
|
|
|
const result = await db.query(`PRAGMA table_info(${tableName});`)
|
|
|
|
|
|
const existingColumns = new Set((result?.values || []).map((row) => row.name))
|
|
|
|
|
|
|
|
|
|
|
|
for (const [columnName, definition] of Object.entries(requiredColumns)) {
|
|
|
|
|
|
if (!existingColumns.has(columnName)) {
|
|
|
|
|
|
await db.execute(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${definition};`)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-25 17:12:09 +08:00
|
|
|
|
const prepareConnection = async () => {
|
|
|
|
|
|
if (!sqliteConnection) {
|
|
|
|
|
|
sqliteConnection = new SQLiteConnection(CapacitorSQLite)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (Capacitor.getPlatform() === 'web') {
|
|
|
|
|
|
await ensureJeepElement()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const consistency = await sqliteConnection.checkConnectionsConsistency()
|
|
|
|
|
|
if (!consistency.result) {
|
|
|
|
|
|
await sqliteConnection.closeAllConnections()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const isConn = (await sqliteConnection.isConnection(DB_NAME, false)).result
|
|
|
|
|
|
if (isConn) {
|
|
|
|
|
|
db = await sqliteConnection.retrieveConnection(DB_NAME, false)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
db = await sqliteConnection.createConnection(DB_NAME, false, 'no-encryption', DB_VERSION, false)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await db.open()
|
|
|
|
|
|
await db.execute(TRANSACTION_TABLE_SQL)
|
2026-03-12 10:59:44 +08:00
|
|
|
|
await db.execute(MERCHANT_PROFILE_TABLE_SQL)
|
|
|
|
|
|
await ensureTableColumns('transactions', TRANSACTION_REQUIRED_COLUMNS)
|
2025-11-25 17:12:09 +08:00
|
|
|
|
initialized = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export const initSQLite = async () => {
|
|
|
|
|
|
if (!initialized) {
|
|
|
|
|
|
await prepareConnection()
|
|
|
|
|
|
}
|
|
|
|
|
|
return db
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export const getDb = async () => {
|
|
|
|
|
|
if (!initialized) {
|
|
|
|
|
|
await initSQLite()
|
|
|
|
|
|
}
|
|
|
|
|
|
return db
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-27 10:44:27 +08:00
|
|
|
|
export const saveDbToStore = async () => {
|
|
|
|
|
|
await initSQLite()
|
|
|
|
|
|
if (!sqliteConnection?.saveToStore) return
|
|
|
|
|
|
try {
|
|
|
|
|
|
await sqliteConnection.saveToStore(DB_NAME)
|
|
|
|
|
|
} catch (error) {
|
2026-03-12 10:59:44 +08:00
|
|
|
|
console.warn('[sqlite] saveToStore failed, data remains in memory until next sync', error)
|
2025-11-27 10:44:27 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-25 17:12:09 +08:00
|
|
|
|
export const closeSQLite = async () => {
|
|
|
|
|
|
if (db) {
|
|
|
|
|
|
await db.close()
|
|
|
|
|
|
db = null
|
|
|
|
|
|
}
|
|
|
|
|
|
initialized = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export const databaseName = DB_NAME
|