437 lines
15 KiB
JavaScript
437 lines
15 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const { Pool } = require('pg');
|
|
|
|
const defaultSettings = {
|
|
visionModel: '',
|
|
taskModel: '',
|
|
sex: '',
|
|
age: '',
|
|
heightCm: '',
|
|
weightKg: '',
|
|
targetWeightKg: '',
|
|
goal: 'Lose weight',
|
|
calorieTarget: '',
|
|
activityLevel: 'Moderate',
|
|
pace: 'Steady',
|
|
motivation: '',
|
|
challenge: '',
|
|
trackingStyle: '',
|
|
calorieCountingExperience: '',
|
|
fastingInterest: '',
|
|
};
|
|
|
|
function readJsonFile(file, fallback) {
|
|
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
|
|
}
|
|
|
|
function nowIso() {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
function delay(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function maskDatabaseUrl(databaseUrl) {
|
|
try {
|
|
const url = new URL(databaseUrl);
|
|
if (url.password) url.password = '***';
|
|
return url.toString();
|
|
} catch {
|
|
return 'PostgreSQL';
|
|
}
|
|
}
|
|
|
|
function normalizePlan(plan) {
|
|
return {
|
|
id: String(plan.id || ''),
|
|
title: String(plan.title || 'Weight-loss plan'),
|
|
version: Number(plan.version || 1),
|
|
content: String(plan.content || ''),
|
|
previousPlanId: plan.previousPlanId || '',
|
|
createdAt: plan.createdAt || nowIso(),
|
|
};
|
|
}
|
|
|
|
function normalizeEntry(entry, trashedAt = '') {
|
|
const payload = { ...entry };
|
|
if (trashedAt) payload.trashedAt = trashedAt;
|
|
else delete payload.trashedAt;
|
|
return payload;
|
|
}
|
|
|
|
function readSqliteLegacy(sqliteFile) {
|
|
if (!fs.existsSync(sqliteFile)) return {};
|
|
try {
|
|
const { DatabaseSync } = require('node:sqlite');
|
|
const db = new DatabaseSync(sqliteFile, { readOnly: true });
|
|
const readKey = key => {
|
|
try {
|
|
const row = db.prepare('SELECT value FROM app_kv WHERE key = ?').get(key);
|
|
return row ? JSON.parse(row.value) : undefined;
|
|
} catch { return undefined; }
|
|
};
|
|
const plans = (() => {
|
|
try {
|
|
return db.prepare('SELECT id, title, version, content, previous_plan_id AS previousPlanId, created_at AS createdAt FROM plans ORDER BY version DESC, created_at DESC').all();
|
|
} catch { return []; }
|
|
})();
|
|
const entries = [];
|
|
const trash = [];
|
|
try {
|
|
for (const row of db.prepare('SELECT payload, trashed_at AS trashedAt FROM meal_entries ORDER BY updated_at DESC').all()) {
|
|
const entry = JSON.parse(row.payload);
|
|
if (row.trashedAt) trash.push(normalizeEntry(entry, row.trashedAt));
|
|
else entries.push(normalizeEntry(entry, ''));
|
|
}
|
|
} catch {}
|
|
db.close();
|
|
return {
|
|
authConfig: readKey('auth_config'),
|
|
modelConfig: readKey('model_config'),
|
|
settings: readKey('settings'),
|
|
selectedPlanId: readKey('selected_plan_id'),
|
|
plans,
|
|
entries,
|
|
trash,
|
|
};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
async function connectWithRetry(pool, attempts = 40) {
|
|
let lastError;
|
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
try {
|
|
await pool.query('SELECT 1');
|
|
return;
|
|
} catch (error) {
|
|
lastError = error;
|
|
await delay(Math.min(3000, attempt * 250));
|
|
}
|
|
}
|
|
throw lastError;
|
|
}
|
|
|
|
async function createStore({
|
|
dataDir,
|
|
databaseUrl = process.env.CALORIE_AI_DATABASE_URL || process.env.DATABASE_URL || 'postgresql://calorie_ai@postgres:5432/calorie_ai',
|
|
legacyFiles = {},
|
|
sqliteFile = path.join(dataDir, 'calorie-ai.sqlite'),
|
|
}) {
|
|
fs.mkdirSync(dataDir, { recursive: true });
|
|
const pool = new Pool({ connectionString: databaseUrl });
|
|
await connectWithRetry(pool);
|
|
|
|
await pool.query(`
|
|
CREATE TABLE IF NOT EXISTS app_kv (
|
|
key TEXT PRIMARY KEY,
|
|
value JSONB NOT NULL,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
CREATE TABLE IF NOT EXISTS plans (
|
|
id TEXT PRIMARY KEY,
|
|
title TEXT NOT NULL,
|
|
version INTEGER NOT NULL,
|
|
content TEXT NOT NULL,
|
|
previous_plan_id TEXT,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
CREATE TABLE IF NOT EXISTS meal_entries (
|
|
id TEXT PRIMARY KEY,
|
|
payload JSONB NOT NULL,
|
|
trashed_at TEXT,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
CREATE TABLE IF NOT EXISTS activities (
|
|
id TEXT PRIMARY KEY,
|
|
source TEXT NOT NULL,
|
|
source_name TEXT NOT NULL DEFAULT '',
|
|
type TEXT NOT NULL DEFAULT '',
|
|
title TEXT NOT NULL DEFAULT '',
|
|
start_at TEXT NOT NULL,
|
|
end_at TEXT NOT NULL DEFAULT '',
|
|
calories DOUBLE PRECISION NOT NULL DEFAULT 0,
|
|
steps BIGINT NOT NULL DEFAULT 0,
|
|
distance_meters DOUBLE PRECISION NOT NULL DEFAULT 0,
|
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
synced_at TEXT NOT NULL,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
`);
|
|
|
|
async function getKey(key, fallback) {
|
|
const result = await pool.query('SELECT value FROM app_kv WHERE key = $1', [key]);
|
|
return result.rows[0]?.value ?? fallback;
|
|
}
|
|
|
|
async function setKey(key, value) {
|
|
await pool.query(`
|
|
INSERT INTO app_kv (key, value, updated_at) VALUES ($1, $2::jsonb, now())
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
|
`, [key, JSON.stringify(value)]);
|
|
}
|
|
|
|
async function hasKey(key) {
|
|
const result = await pool.query('SELECT 1 FROM app_kv WHERE key = $1', [key]);
|
|
return result.rowCount > 0;
|
|
}
|
|
|
|
async function savePlanRow(plan) {
|
|
const normalized = normalizePlan(plan);
|
|
await pool.query(`
|
|
INSERT INTO plans (id, title, version, content, previous_plan_id, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, now())
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
title = excluded.title,
|
|
version = excluded.version,
|
|
content = excluded.content,
|
|
previous_plan_id = excluded.previous_plan_id,
|
|
created_at = excluded.created_at,
|
|
updated_at = excluded.updated_at
|
|
`, [
|
|
normalized.id,
|
|
normalized.title,
|
|
normalized.version,
|
|
normalized.content,
|
|
normalized.previousPlanId || null,
|
|
normalized.createdAt,
|
|
]);
|
|
return normalized.id;
|
|
}
|
|
|
|
async function saveEntryRow(entry, trashedAt = '') {
|
|
const payload = normalizeEntry(entry, trashedAt);
|
|
await pool.query(`
|
|
INSERT INTO meal_entries (id, payload, trashed_at, updated_at) VALUES ($1, $2::jsonb, $3, now())
|
|
ON CONFLICT(id) DO UPDATE SET payload = excluded.payload, trashed_at = excluded.trashed_at, updated_at = excluded.updated_at
|
|
`, [String(payload.id), JSON.stringify(payload), trashedAt || null]);
|
|
}
|
|
|
|
async function migrateLegacy() {
|
|
if (await getKey('legacy_migration_done', false)) return;
|
|
|
|
const authMissing = !await hasKey('auth_config');
|
|
const modelsMissing = !await hasKey('model_config');
|
|
const settingsMissing = !await hasKey('settings');
|
|
const planCount = await pool.query('SELECT COUNT(*)::int AS count FROM plans');
|
|
const entryCount = await pool.query('SELECT COUNT(*)::int AS count FROM meal_entries');
|
|
if (!authMissing && !modelsMissing && !settingsMissing && planCount.rows[0].count > 0 && entryCount.rows[0].count > 0) {
|
|
await setKey('legacy_migration_done', true);
|
|
return;
|
|
}
|
|
|
|
const sqlite = readSqliteLegacy(sqliteFile);
|
|
|
|
if (authMissing) await setKey('auth_config', sqlite.authConfig || readJsonFile(legacyFiles.authFile, {}));
|
|
if (modelsMissing) await setKey('model_config', sqlite.modelConfig || readJsonFile(legacyFiles.modelsFile, {}));
|
|
if (settingsMissing) await setKey('settings', sqlite.settings || readJsonFile(legacyFiles.settingsFile, defaultSettings));
|
|
|
|
if (planCount.rows[0].count === 0) {
|
|
const jsonPlans = readJsonFile(legacyFiles.plansFile, {});
|
|
const plans = sqlite.plans?.length ? sqlite.plans : Array.isArray(jsonPlans.plans) ? jsonPlans.plans : [];
|
|
for (const plan of plans) if (plan && plan.id && plan.content) await savePlanRow(plan);
|
|
const selectedPlanId = sqlite.selectedPlanId || jsonPlans.selectedPlanId || '';
|
|
if (selectedPlanId) await setKey('selected_plan_id', selectedPlanId);
|
|
}
|
|
|
|
if (entryCount.rows[0].count === 0) {
|
|
const jsonEntries = readJsonFile(legacyFiles.entriesFile, {});
|
|
const entries = sqlite.entries?.length ? sqlite.entries : Array.isArray(jsonEntries.entries) ? jsonEntries.entries : [];
|
|
const trash = sqlite.trash?.length ? sqlite.trash : Array.isArray(jsonEntries.trash) ? jsonEntries.trash : [];
|
|
for (const entry of entries) if (entry && entry.id) await saveEntryRow(entry, '');
|
|
for (const entry of trash) if (entry && entry.id) await saveEntryRow(entry, entry.trashedAt || nowIso());
|
|
}
|
|
|
|
await setKey('legacy_migration_done', true);
|
|
}
|
|
|
|
async function getPlans() {
|
|
const result = await pool.query(`
|
|
SELECT id, title, version, content, previous_plan_id, created_at
|
|
FROM plans
|
|
ORDER BY version DESC, created_at DESC
|
|
`);
|
|
const plans = result.rows.map(row => {
|
|
const plan = {
|
|
id: row.id,
|
|
title: row.title,
|
|
version: row.version,
|
|
content: row.content,
|
|
createdAt: row.created_at,
|
|
};
|
|
if (row.previous_plan_id) plan.previousPlanId = row.previous_plan_id;
|
|
return plan;
|
|
});
|
|
const selectedPlanId = await getKey('selected_plan_id', '') || plans[0]?.id || '';
|
|
return { plans, selectedPlanId };
|
|
}
|
|
|
|
async function savePlan(plan) {
|
|
const id = await savePlanRow(plan);
|
|
await setKey('selected_plan_id', id);
|
|
return getPlans();
|
|
}
|
|
|
|
async function selectPlan(id) {
|
|
await setKey('selected_plan_id', id || '');
|
|
return getPlans();
|
|
}
|
|
|
|
async function planExists(id) {
|
|
const result = await pool.query('SELECT 1 FROM plans WHERE id = $1', [id]);
|
|
return result.rowCount > 0;
|
|
}
|
|
|
|
async function deletePlan(id) {
|
|
await pool.query('DELETE FROM plans WHERE id = $1', [id]);
|
|
const state = await getPlans();
|
|
if (state.selectedPlanId === id) await setKey('selected_plan_id', state.plans[0]?.id || '');
|
|
return getPlans();
|
|
}
|
|
|
|
async function getEntries() {
|
|
const result = await pool.query('SELECT payload, trashed_at FROM meal_entries ORDER BY updated_at DESC');
|
|
const entries = [];
|
|
const trash = [];
|
|
for (const row of result.rows) {
|
|
if (row.trashed_at) trash.push(normalizeEntry(row.payload, row.trashed_at));
|
|
else entries.push(normalizeEntry(row.payload, ''));
|
|
}
|
|
return { entries, trash };
|
|
}
|
|
|
|
async function upsertMealEntry(entry) {
|
|
await saveEntryRow(entry, '');
|
|
return getEntries();
|
|
}
|
|
|
|
async function moveEntriesToTrash(ids) {
|
|
const result = await pool.query('SELECT payload FROM meal_entries WHERE trashed_at IS NULL AND id = ANY($1::text[])', [ids]);
|
|
const trashedAt = nowIso();
|
|
for (const row of result.rows) await saveEntryRow(row.payload, trashedAt);
|
|
return getEntries();
|
|
}
|
|
|
|
async function restoreEntry(id) {
|
|
const result = await pool.query('SELECT payload FROM meal_entries WHERE id = $1 AND trashed_at IS NOT NULL', [id]);
|
|
if (!result.rows[0]) return null;
|
|
await saveEntryRow(result.rows[0].payload, '');
|
|
return getEntries();
|
|
}
|
|
|
|
async function clearTrash() {
|
|
await pool.query('DELETE FROM meal_entries WHERE trashed_at IS NOT NULL');
|
|
return getEntries();
|
|
}
|
|
|
|
function normalizeActivity(activity, fallbackSource = '') {
|
|
const startAt = activity.startAt || activity.date || nowIso();
|
|
const source = String(activity.source || fallbackSource || 'external');
|
|
return {
|
|
id: String(activity.id || `${source}-${startAt}`),
|
|
source,
|
|
sourceName: String(activity.sourceName || activity.source_name || source),
|
|
type: String(activity.type || 'Activity'),
|
|
title: String(activity.title || activity.type || 'Activity'),
|
|
startAt: String(startAt),
|
|
endAt: String(activity.endAt || activity.end_at || startAt),
|
|
calories: Number(activity.calories || 0),
|
|
steps: Number(activity.steps || 0),
|
|
distanceMeters: Number(activity.distanceMeters || activity.distance_meters || 0),
|
|
metadata: activity.metadata && typeof activity.metadata === 'object' ? activity.metadata : {},
|
|
syncedAt: String(activity.syncedAt || nowIso()),
|
|
};
|
|
}
|
|
|
|
async function getActivities(limit = 500) {
|
|
const result = await pool.query(`
|
|
SELECT id, source, source_name, type, title, start_at, end_at, calories, steps, distance_meters, metadata, synced_at
|
|
FROM activities
|
|
ORDER BY start_at DESC
|
|
LIMIT $1
|
|
`, [limit]);
|
|
return {
|
|
activities: result.rows.map(row => ({
|
|
id: row.id,
|
|
source: row.source,
|
|
sourceName: row.source_name,
|
|
type: row.type,
|
|
title: row.title,
|
|
startAt: row.start_at,
|
|
endAt: row.end_at,
|
|
calories: Number(row.calories || 0),
|
|
steps: Number(row.steps || 0),
|
|
distanceMeters: Number(row.distance_meters || 0),
|
|
metadata: row.metadata || {},
|
|
syncedAt: row.synced_at,
|
|
})),
|
|
};
|
|
}
|
|
|
|
async function upsertActivities(activities, source = '') {
|
|
for (const item of activities) {
|
|
const activity = normalizeActivity(item, source);
|
|
await pool.query(`
|
|
INSERT INTO activities (id, source, source_name, type, title, start_at, end_at, calories, steps, distance_meters, metadata, synced_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, now())
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
source = excluded.source,
|
|
source_name = excluded.source_name,
|
|
type = excluded.type,
|
|
title = excluded.title,
|
|
start_at = excluded.start_at,
|
|
end_at = excluded.end_at,
|
|
calories = excluded.calories,
|
|
steps = excluded.steps,
|
|
distance_meters = excluded.distance_meters,
|
|
metadata = excluded.metadata,
|
|
synced_at = excluded.synced_at,
|
|
updated_at = excluded.updated_at
|
|
`, [
|
|
activity.id,
|
|
activity.source,
|
|
activity.sourceName,
|
|
activity.type,
|
|
activity.title,
|
|
activity.startAt,
|
|
activity.endAt,
|
|
activity.calories,
|
|
activity.steps,
|
|
activity.distanceMeters,
|
|
JSON.stringify(activity.metadata),
|
|
activity.syncedAt,
|
|
]);
|
|
}
|
|
return getActivities();
|
|
}
|
|
|
|
await migrateLegacy();
|
|
|
|
return {
|
|
databaseLabel: maskDatabaseUrl(databaseUrl),
|
|
getAuthConfig: () => getKey('auth_config', {}),
|
|
saveAuthConfig: config => setKey('auth_config', config),
|
|
getModelConfig: () => getKey('model_config', {}),
|
|
saveModelConfig: config => setKey('model_config', config),
|
|
getSettings: async () => ({ ...defaultSettings, ...await getKey('settings', defaultSettings) }),
|
|
saveSettings: settings => setKey('settings', settings),
|
|
getPlans,
|
|
savePlan,
|
|
selectPlan,
|
|
planExists,
|
|
deletePlan,
|
|
getEntries,
|
|
upsertMealEntry,
|
|
moveEntriesToTrash,
|
|
restoreEntry,
|
|
clearTrash,
|
|
getActivities,
|
|
upsertActivities,
|
|
};
|
|
}
|
|
|
|
module.exports = { createStore, defaultSettings };
|