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.
+