diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index b7b59a7..1b9f65a 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -1,5 +1,8 @@ name: Android Release +permissions: + contents: write + on: push: tags: @@ -31,6 +34,25 @@ jobs: mkdir -p dist cp app/build/outputs/apk/debug/app-debug.apk "dist/calorie-ai-${GITHUB_REF_NAME}.apk" + - name: Publish Forgejo release asset + env: + FORGEJO_API: http://forgejo:3000/api/v1 + TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + apk="dist/calorie-ai-${GITHUB_REF_NAME}.apk" + auth_header="Authorization: token ${TOKEN}" + release_json="$(curl -fsS -H "$auth_header" "$FORGEJO_API/repos/${GITHUB_REPOSITORY}/releases/tags/${GITHUB_REF_NAME}" || true)" + if [ -z "$release_json" ]; then + release_json="$(node -e 'console.log(JSON.stringify({ tag_name: process.env.GITHUB_REF_NAME, name: `Calorie AI ${process.env.GITHUB_REF_NAME}`, body: "Installable Android debug APK for Calorie AI.", draft: false, prerelease: false }))' | curl -fsS -X POST -H "$auth_header" -H 'Content-Type: application/json' --data-binary @- "$FORGEJO_API/repos/${GITHUB_REPOSITORY}/releases")" + fi + release_id="$(printf '%s' "$release_json" | node -e 'let data=""; process.stdin.on("data", c => data += c); process.stdin.on("end", () => console.log(JSON.parse(data).id));')" + asset_id="$(printf '%s' "$release_json" | node -e 'let data=""; process.stdin.on("data", c => data += c); process.stdin.on("end", () => { const release = JSON.parse(data); const asset = (release.assets || []).find(item => item.name === `calorie-ai-${process.env.GITHUB_REF_NAME}.apk`); if (asset) console.log(asset.id); });')" + if [ -n "$asset_id" ]; then + curl -fsS -X DELETE -H "$auth_header" "$FORGEJO_API/repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets/${asset_id}" + fi + curl -fsS -X POST -H "$auth_header" -F "attachment=@${apk}" "$FORGEJO_API/repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets?name=calorie-ai-${GITHUB_REF_NAME}.apk" + - name: Upload APK artifact uses: actions/upload-artifact@v3 with: diff --git a/README.md b/README.md index cb9dfa9..0a18616 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,17 @@ # Calorie AI -Native Android and Dockerized web calorie tracker with uploaded meal images and admin-configurable AI models. +Native Android and Dockerized web calorie tracker with meal images, AI nutrition estimates, server-backed plans, and admin-configurable AI models. ## Features - Add meals by description, portion estimate, and uploaded image. - Analyze meals through OpenAI-compatible chat completions. - Uses a vision model for food image calorie and portion estimates. -- Uses a tasking model to normalize calories, protein, carbs, fat, fruit servings, vegetable servings, food groups, and notes. +- Uses an advice model to normalize calories, protein, carbs, fat, fruit servings, vegetable servings, food groups, and notes. - Stores daily meal entries locally on Android or in browser local storage. +- Stores generated web weight-loss plans on the server in `web/data/plans.json`. - Shows daily totals plus charts for macros, 7-day calories, and fruit/vegetable intake. -- Admin settings control API base URL, API key, vision model, and tasking model. The two model fields can contain the same model name. +- Admin settings control API base URL, API key, image model, and nutrition/advice model. The two model fields can contain the same model name. ## Android Build @@ -22,7 +23,30 @@ gradle :app:assembleDebug Forgejo Actions builds the debug APK on every push to `main` and uploads it as the `calorie-ai-debug-apk` artifact. -Tagged versions also build an installable APK named `calorie-ai-vX.Y.Z.apk` through the Android Release workflow. +Tagged versions also build an installable APK named `calorie-ai-vX.Y.Z.apk` and attach it to a Forgejo release through the Android Release workflow. + +## Install With Obtainium + +Use the Forgejo releases page as the source: + +```text +https://git.danvics.com/danvics/calorie-ai-android/releases +``` + +If Obtainium asks for a direct APK URL, use the latest release asset URL pattern: + +```text +https://git.danvics.com/danvics/calorie-ai-android/releases/download/v0.1.1/calorie-ai-v0.1.1.apk +``` + +Recommended Obtainium setup: + +- App source: `HTML` or `Gitea/Forgejo` if your Obtainium version offers it. +- URL: `https://git.danvics.com/danvics/calorie-ai-android/releases` +- APK link filter: `calorie-ai-.*\.apk` +- Version extraction: release tag, for example `v0.1.1`. + +The APK is a debug build, so Android may require allowing installs from Obtainium and accepting the debug signing certificate. ## Web Frontend @@ -58,4 +82,4 @@ Example base URLs: Default admin PIN is `admin`. Change it in the Admin AI Settings panel after first launch. -The web frontend stores AI settings in browser local storage after web login. The web login is separate from the Android admin PIN. +The web frontend stores AI settings in browser local storage after web login, while generated plan versions are stored server-side. The web login is separate from the Android admin PIN. diff --git a/web/server.js b/web/server.js index fa95b38..4e168a5 100644 --- a/web/server.js +++ b/web/server.js @@ -11,6 +11,7 @@ const publicRoot = publicDir + path.sep; const dataDir = process.env.CALORIE_AI_DATA_DIR || path.join(__dirname, 'data'); const authFile = path.join(dataDir, 'auth.json'); const modelsFile = path.join(dataDir, 'models.json'); +const plansFile = path.join(dataDir, 'plans.json'); const sessionCookieName = 'calorie_ai_session'; const sessionSeconds = 60 * 60 * 24 * 7; let auth; @@ -136,7 +137,66 @@ function tokenHash(token) { return crypto.createHash('sha256').update(token).digest('hex'); } -async function sendMail(to, subject, text) { +function escHtml(value) { + return String(value || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function emailWrapper(body) { + const siteName = 'Calorie AI'; + const font = '-apple-system,BlinkMacSystemFont,\'Segoe UI\',Helvetica,Arial,sans-serif'; + return ` + + + + + ${escHtml(siteName)} + + + + + +
+ + + + +
+

${escHtml(siteName)}

+
${body}
+

+ ${escHtml(siteName)} — meal tracking and weight-loss planning
+ If you didn’t request this, you can safely ignore it. +

+
+
+ +`; +} + +function buttonHtml(href, label) { + return ` + +
+ ${escHtml(label)} +
`; +} + +function linkFallback(url) { + return `

If the button doesn’t work, copy this link:

+

${escHtml(url)}

`; +} + +function actionEmailHtml(title, body, link, label) { + return emailWrapper(`

${escHtml(title)}

+

${escHtml(body).replace(/\n/g, '
')}

+ ${buttonHtml(link, label)} + ${linkFallback(link)}`); +} + +async function sendMail(to, subject, text, html = '') { const config = mailConfig(); if (!config.configured) throw new Error('SMTP is not configured'); const transporter = nodemailer.createTransport({ @@ -145,7 +205,7 @@ async function sendMail(to, subject, text) { secure: config.secure, auth: config.user || config.pass ? { user: config.user, pass: config.pass } : undefined, }); - await transporter.sendMail({ from: config.from, to, subject, text }); + await transporter.sendMail({ from: config.from, to, subject, text, html: html || undefined }); } function loadModelConfig() { @@ -163,6 +223,58 @@ function saveModelConfig(config = modelConfig) { writePrivateJson(modelsFile, { ...config, updatedAt: new Date().toISOString() }); } +function loadPlanConfig() { + const stored = readJsonFile(plansFile, {}); + return { + plans: Array.isArray(stored.plans) ? stored.plans : [], + selectedPlanId: stored.selectedPlanId || '', + }; +} + +function savePlanConfig(config) { + writePrivateJson(plansFile, { ...config, updatedAt: new Date().toISOString() }); +} + +async function getPlans(req, res) { + send(res, 200, JSON.stringify(loadPlanConfig())); +} + +async function createPlan(req, res) { + const payload = JSON.parse(await readBody(req) || '{}'); + if (!payload.content) return send(res, 400, JSON.stringify({ error: 'Plan content required' })); + const config = loadPlanConfig(); + const plan = { + id: payload.id || crypto.randomUUID(), + title: String(payload.title || `Weight-loss plan ${config.plans.length + 1}`), + version: Number(payload.version || Math.max(0, ...config.plans.map(item => Number(item.version || 0))) + 1), + content: String(payload.content), + previousPlanId: payload.previousPlanId || undefined, + createdAt: payload.createdAt || new Date().toISOString(), + }; + config.plans = [plan, ...config.plans]; + config.selectedPlanId = plan.id; + savePlanConfig(config); + send(res, 200, JSON.stringify(config)); +} + +async function selectPlanRoute(req, res) { + const payload = JSON.parse(await readBody(req) || '{}'); + const config = loadPlanConfig(); + if (payload.id && !config.plans.some(plan => plan.id === payload.id)) return send(res, 404, JSON.stringify({ error: 'Plan not found' })); + config.selectedPlanId = payload.id || ''; + savePlanConfig(config); + send(res, 200, JSON.stringify(config)); +} + +async function deletePlanRoute(req, res) { + const payload = JSON.parse(await readBody(req) || '{}'); + const config = loadPlanConfig(); + config.plans = config.plans.filter(plan => plan.id !== payload.id); + if (config.selectedPlanId === payload.id) config.selectedPlanId = config.plans[0]?.id || ''; + savePlanConfig(config); + send(res, 200, JSON.stringify(config)); +} + function dedupeModels(models) { return Array.from(new Map((models || []).filter(model => model && model.id).map(model => [model.id, { id: String(model.id), @@ -380,7 +492,7 @@ async function changePassword(req, res) { try { const payload = JSON.parse(await readBody(req)); if (!await argon2.verify(auth.passwordHash, payload.currentPassword || '')) return send(res, 401, JSON.stringify({ error: 'Current password is incorrect' })); - if (!payload.newPassword || payload.newPassword.length < 12) return send(res, 400, JSON.stringify({ error: 'Use at least 12 characters for the new password' })); + if (!payload.newPassword || payload.newPassword.length < 8) return send(res, 400, JSON.stringify({ error: 'Use at least 8 characters for the new password' })); auth.passwordHash = await hashPassword(payload.newPassword); saveAuthConfig({ passwordHash: auth.passwordHash, resetTokenHash: undefined, resetExpiresAt: undefined }); send(res, 200, JSON.stringify({ ok: true })); @@ -395,7 +507,9 @@ async function resetPassword(req, res) { const token = crypto.randomBytes(32).toString('base64url'); saveAuthConfig({ resetTokenHash: tokenHash(token), resetExpiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() }); const link = `${appOrigin(req)}/reset?token=${encodeURIComponent(token)}`; - await sendMail(auth.email, 'Calorie AI password reset', `Use this link to reset your Calorie AI password. It expires in 1 hour.\n\n${link}`); + const text = `Use this link to reset your Calorie AI password. It expires in 1 hour.\n\n${link}`; + const html = actionEmailHtml('Password reset request', 'Someone requested a password reset for your Calorie AI account. If that was you, click the button below to choose a new password. This link expires in 1 hour. If you did not request this, no action is needed.', link, 'Reset My Password'); + await sendMail(auth.email, 'Reset your Calorie AI password', text, html); send(res, 200, JSON.stringify({ ok: true, message: 'Reset email sent.' })); } catch (error) { send(res, 400, JSON.stringify({ error: error.message })); @@ -413,7 +527,9 @@ async function requestPasswordReset(req, res) { const token = crypto.randomBytes(32).toString('base64url'); saveAuthConfig({ resetTokenHash: tokenHash(token), resetExpiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() }); const link = `${appOrigin(req)}/reset?token=${encodeURIComponent(token)}`; - await sendMail(auth.email, 'Calorie AI password reset', `Use this link to reset your Calorie AI password. It expires in 1 hour.\n\n${link}`); + const text = `Use this link to reset your Calorie AI password. It expires in 1 hour.\n\n${link}`; + const html = actionEmailHtml('Password reset request', 'Someone requested a password reset for your Calorie AI account. If that was you, click the button below to choose a new password. This link expires in 1 hour. If you did not request this, no action is needed.', link, 'Reset My Password'); + await sendMail(auth.email, 'Reset your Calorie AI password', text, html); send(res, 200, JSON.stringify({ ok: true, message: 'If the account exists, a reset email was sent.' })); } catch (error) { send(res, 400, JSON.stringify({ error: error.message })); @@ -423,7 +539,7 @@ async function requestPasswordReset(req, res) { async function confirmPasswordReset(req, res) { try { const payload = JSON.parse(await readBody(req) || '{}'); - if (!payload.newPassword || payload.newPassword.length < 12) return send(res, 400, JSON.stringify({ error: 'Use at least 12 characters for the new password' })); + if (!payload.newPassword || payload.newPassword.length < 8) return send(res, 400, JSON.stringify({ error: 'Use at least 8 characters for the new password' })); const valid = auth.resetTokenHash && auth.resetExpiresAt && new Date(auth.resetExpiresAt).getTime() > Date.now() && safeEqual(auth.resetTokenHash, tokenHash(payload.token || '')); if (!valid) return send(res, 400, JSON.stringify({ error: 'Reset link is invalid or expired' })); const passwordHash = await hashPassword(payload.newPassword); @@ -452,7 +568,9 @@ async function sendVerification(req, res) { const token = crypto.randomBytes(32).toString('base64url'); saveAuthConfig({ verificationTokenHash: tokenHash(token), verificationExpiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() }); const link = `${appOrigin(req)}/verify?token=${encodeURIComponent(token)}`; - await sendMail(auth.email, 'Verify your Calorie AI account', `Use this link to verify your Calorie AI account email. It expires in 24 hours.\n\n${link}`); + const text = `Use this link to verify your Calorie AI account email. It expires in 24 hours.\n\n${link}`; + const html = actionEmailHtml('Verify your email', 'Please verify your Calorie AI account email by clicking the button below. This link expires in 24 hours.', link, 'Verify My Email'); + await sendMail(auth.email, 'Verify your Calorie AI account', text, html); send(res, 200, JSON.stringify({ ok: true, message: 'Verification email sent.' })); } catch (error) { send(res, 400, JSON.stringify({ error: error.message })); @@ -536,6 +654,10 @@ async function start() { app.post('/api/models/search', wrap(searchModels)); app.post('/api/models/add', wrap(addModel)); app.post('/api/models/remove', wrap(removeModel)); + app.get('/api/plans', wrap(getPlans)); + app.post('/api/plans', wrap(createPlan)); + app.post('/api/plans/select', wrap(selectPlanRoute)); + app.post('/api/plans/delete', wrap(deletePlanRoute)); app.post('/api/chat', wrap(proxyChat)); app.use((req, res) => { if (req.method === 'GET' || req.method === 'HEAD') return serveStatic(req, res); diff --git a/web/src/App.svelte b/web/src/App.svelte index ce61c29..84e1c83 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -10,8 +10,6 @@ const entriesKey = 'calorie-ai-web-entries'; const trashKey = 'calorie-ai-web-trash'; const settingsKey = 'calorie-ai-web-settings'; - const plansKey = 'calorie-ai-web-plans'; - const selectedPlanKey = 'calorie-ai-web-selected-plan'; const pages = [ { id: 'dashboard', label: 'Dashboard', icon: '⌂', group: 'Tracker' }, { id: 'log', label: 'Log meal', icon: '+', group: 'Tracker' }, @@ -115,14 +113,13 @@ onMount(async () => { entries = readJson(entriesKey, []); trash = readJson(trashKey, []); - plans = readJson(plansKey, []); - selectedPlanId = localStorage.getItem(selectedPlanKey) || plans[0]?.id || ''; settings = { ...settings, ...readJson(settingsKey, settings) }; await loadSession(); if (location.pathname === '/verify' && resetToken) await verifyAccountToken(); page = localStorage.getItem('calorie_ai_last_tab') || 'dashboard'; setDefaultMealDateTime(); await loadModels(); + await loadPlans(); await drawCharts(); }); @@ -190,11 +187,6 @@ localStorage.setItem('calorie_ai_last_tab', id); } - function toggleMenu() { - if (window.matchMedia('(min-width: 1024px)').matches) sidebarCollapsed = !sidebarCollapsed; - else menuOpen = true; - } - async function login() { loginStatus = 'Checking credentials...'; loginError = false; @@ -296,12 +288,26 @@ saveJson(settingsKey, settings); } - function savePlans(nextPlans = plans, nextSelectedPlanId = selectedPlanId) { - plans = nextPlans; - selectedPlanId = nextSelectedPlanId || plans[0]?.id || ''; - saveJson(plansKey, plans); - if (selectedPlanId) localStorage.setItem(selectedPlanKey, selectedPlanId); - else localStorage.removeItem(selectedPlanKey); + function applyPlanConfig(config) { + plans = config.plans || []; + selectedPlanId = config.selectedPlanId || plans[0]?.id || ''; + } + + async function loadPlans() { + try { + const response = await fetch('/api/plans'); + if (response.ok) applyPlanConfig(await response.json()); + } catch {} + } + + async function savePlan(plan) { + const response = await fetch('/api/plans', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(plan), + }); + if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error || 'Plan save failed.'); + applyPlanConfig(await response.json()); } function emptyEstimate() { @@ -668,7 +674,7 @@ Image estimate: ${imageEstimate}` }, ], }); const plan = { id: entryId(), title: `Weight-loss plan ${plans.length + 1}`, version: plans.length + 1, content, createdAt: new Date().toISOString() }; - savePlans([plan, ...plans], plan.id); + await savePlan(plan); planStatus = 'Plan generated.'; saveSettings(); } catch (error) { @@ -701,7 +707,7 @@ Image estimate: ${imageEstimate}` }, }); const version = Math.max(0, ...plans.map(plan => Number(plan.version || 0))) + 1; const updatedPlan = { id: entryId(), title: `Updated plan ${version}`, version, content, createdAt: new Date().toISOString(), previousPlanId: currentPlan.id }; - savePlans([updatedPlan, ...plans], updatedPlan.id); + await savePlan(updatedPlan); tuneNote = ''; planStatus = 'Plan updated.'; } catch (error) { @@ -712,14 +718,23 @@ Image estimate: ${imageEstimate}` }, } } - function selectPlan(planId) { + async function selectPlan(planId) { selectedPlanId = planId; - localStorage.setItem(selectedPlanKey, planId); + const response = await fetch('/api/plans/select', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: planId }), + }); + if (response.ok) applyPlanConfig(await response.json()); } - function deletePlan(planId) { - const nextPlans = plans.filter(plan => plan.id !== planId); - savePlans(nextPlans, selectedPlanId === planId ? nextPlans[0]?.id || '' : selectedPlanId); + async function deletePlan(planId) { + const response = await fetch('/api/plans/delete', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: planId }), + }); + if (response.ok) applyPlanConfig(await response.json()); } async function drawCharts() { @@ -808,20 +823,21 @@ Image estimate: ${imageEstimate}` },
CA

Reset password

{#if resetStatus}

{resetStatus}

{/if} + {#if resetStatus && !resetError}Go to sign in{/if} {:else if location.pathname === '/verify'}
CA

Verify email

{#if verifyStatus}

{verifyStatus}

{/if} - Go to sign in + Go to sign in
{:else}
@@ -848,28 +864,18 @@ Image estimate: ${imageEstimate}` }, {:else} -
-
-
-
- -
CA
-
-

Calorie AI

-

Food diary

+
+
-
- -
- - {#if menuOpen}{/if} + + {#if menuOpen}{/if} + +
+
+
+
+ +
+
{pages.find(item => item.id === page)?.label || 'Dashboard'}
+
+
+ +
+
{#if page === 'dashboard'} @@ -1002,6 +1021,8 @@ Image estimate: ${imageEstimate}` }, .active-tab { border-left-color: #2563eb; background: #dbeafe; color: #2563eb; } .tab-icon { display: grid; width: 1.35rem; height: 1.35rem; flex: 0 0 1.35rem; place-items: center; font-weight: 900; } :global(.collapsed-sidebar) { width: 4.75rem !important; } - :global(.collapsed-sidebar .tab-label), :global(.collapsed-sidebar .sidebar-section-label) { display: none; } + :global(.collapsed-sidebar .sidebar-header) { justify-content: center; padding-left: 0; padding-right: 0; } + :global(.collapsed-sidebar .tab-label), :global(.collapsed-sidebar .sidebar-section-label), :global(.collapsed-sidebar .sidebar-brand), :global(.collapsed-sidebar .sidebar-logo) { display: none; } :global(.collapsed-sidebar .tab-button) { justify-content: center; padding-left: 0.75rem; padding-right: 0.75rem; } + :global(.collapsed-sidebar .collapse-button) { height: 2.5rem; width: 2.5rem; border-radius: 0.75rem; } diff --git a/web/src/MealForm.svelte b/web/src/MealForm.svelte index a2e91cc..cdcb3e5 100644 --- a/web/src/MealForm.svelte +++ b/web/src/MealForm.svelte @@ -22,7 +22,6 @@

{editingEntryId ? 'Edit meal' : 'Log meal'}

-

Backfill meals for any date and time. After analysis, edit the values or run analysis again.

Meal details

@@ -32,7 +31,7 @@ - +
{#if selectedImageDataUrl}Selected meal preview{/if} {#if editingEntryId} diff --git a/web/src/SettingsPage.svelte b/web/src/SettingsPage.svelte index de71b86..6964045 100644 --- a/web/src/SettingsPage.svelte +++ b/web/src/SettingsPage.svelte @@ -46,8 +46,8 @@

Password

- - + +
{#if passwordStatus}

{passwordStatus}

{/if} {#if resetPasswordValue}
{resetPasswordValue}
{/if} @@ -73,7 +73,7 @@
event.key === 'Enter' && searchModels()}>
- {#if discoveredModels.length}{#each discoveredModels as model}
{model.name} {model.id}
{/each}{:else}

Search to query the current provider. Results stay in this scroll panel.

{/if} + {#each discoveredModels as model}
{model.name} {model.id}
{/each}
diff --git a/web/tests/app.spec.js b/web/tests/app.spec.js index 6b3bad6..00938ef 100644 --- a/web/tests/app.spec.js +++ b/web/tests/app.spec.js @@ -50,8 +50,33 @@ async function mockModels(page) { }); } +async function mockPlans(page) { + let plans = []; + let selectedPlanId = ''; + await page.route('**/api/plans', async (route) => { + if (route.request().method() === 'GET') { + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ plans, selectedPlanId }) }); + } + const plan = route.request().postDataJSON(); + plans = [plan, ...plans]; + selectedPlanId = plan.id; + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ plans, selectedPlanId }) }); + }); + await page.route('**/api/plans/select', async (route) => { + selectedPlanId = route.request().postDataJSON().id || ''; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ plans, selectedPlanId }) }); + }); + await page.route('**/api/plans/delete', async (route) => { + const id = route.request().postDataJSON().id; + plans = plans.filter(plan => plan.id !== id); + if (selectedPlanId === id) selectedPlanId = plans[0]?.id || ''; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ plans, selectedPlanId }) }); + }); +} + async function login(page) { await mockModels(page); + await mockPlans(page); await page.goto('/'); await expect(page).toHaveURL(/\/login$/); await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();