339 lines
11 KiB
JavaScript
339 lines
11 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()
|
|
);
|
|
`);
|
|
|
|
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();
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
module.exports = { createStore, defaultSettings };
|