feat: major platform expansion — Brain service, RSS reader, iOS app, AI assistants, Firefox extension
Brain Service: - Playwright stealth crawler replacing browserless (og:image, Readability, Reddit JSON API) - AI classification with tag definitions and folder assignment - YouTube video download via yt-dlp - Karakeep migration complete (96 items) - Taxonomy management (folders with icons/colors, tags) - Discovery shuffle, sort options, search (Meilisearch + pgvector) - Item tag/folder editing, card color accents RSS Reader Service: - Custom FastAPI reader replacing Miniflux - Feed management (add/delete/refresh), category support - Full article extraction via Readability - Background content fetching for new entries - Mark all read with confirmation - Infinite scroll, retention cleanup (30/60 day) - 17 feeds migrated from Miniflux iOS App (SwiftUI): - Native iOS 17+ app with @Observable architecture - Cookie-based auth, configurable gateway URL - Dashboard with custom background photo + frosted glass widgets - Full fitness module (today/templates/goals/food library) - AI assistant chat (fitness + brain, raw JSON state management) - 120fps ProMotion support AI Assistants (Gateway): - Unified dispatcher with fitness/brain domain detection - Fitness: natural language food logging, photo analysis, multi-item splitting - Brain: save/append/update/delete notes, search & answer, undo support - Madiha user gets fitness-only (brain disabled) Firefox Extension: - One-click save to Brain from any page - Login with platform credentials - Right-click context menu (save page/link/image) - Notes field for URL saves - Signed and published on AMO Other: - Reader bookmark button routes to Brain (was Karakeep) - Fitness food library with "Add" button + add-to-meal popup - Kindle send file size check (25MB SMTP2GO limit) - Atelier UI as default (useAtelierShell=true) - Mobile upload box in nav drawer Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
25
.gitignore
vendored
25
.gitignore
vendored
@@ -37,3 +37,28 @@ gateway/data/
|
||||
|
||||
# Test artifacts
|
||||
test-results/
|
||||
|
||||
# Brain storage (user data, not code)
|
||||
services/brain/storage/
|
||||
services/brain/data/
|
||||
|
||||
# Reader data
|
||||
services/reader/data/
|
||||
|
||||
# Screenshots (uploaded images, not code)
|
||||
screenshots/*.png
|
||||
screenshots/*.jpg
|
||||
screenshots/*.jpeg
|
||||
|
||||
# iOS build artifacts
|
||||
ios/Platform/Platform.xcodeproj/xcuserdata/
|
||||
ios/Platform/Platform.xcodeproj/project.xcworkspace/xcuserdata/
|
||||
ios/Platform.bak/
|
||||
|
||||
# Node modules
|
||||
**/node_modules/
|
||||
|
||||
# Temp files
|
||||
*.pyc
|
||||
__pycache__/
|
||||
.DS_Store
|
||||
|
||||
@@ -15,6 +15,8 @@ services:
|
||||
- IMMICH_API_KEY=${IMMICH_API_KEY}
|
||||
- KARAKEEP_URL=${KARAKEEP_URL:-http://192.168.1.42:3005}
|
||||
- KARAKEEP_API_KEY=${KARAKEEP_API_KEY}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
- OPENAI_MODEL=${OPENAI_MODEL:-gpt-5.2}
|
||||
- BODY_SIZE_LIMIT=52428800
|
||||
- TZ=${TZ:-America/Chicago}
|
||||
networks:
|
||||
@@ -64,6 +66,7 @@ services:
|
||||
- TASKS_BACKEND_URL=http://tasks-service:8098
|
||||
- TASKS_SERVICE_API_KEY=${TASKS_SERVICE_API_KEY}
|
||||
- BRAIN_BACKEND_URL=http://brain-api:8200
|
||||
- READER_BACKEND_URL=http://reader-api:8300
|
||||
- QBITTORRENT_HOST=${QBITTORRENT_HOST:-192.168.1.42}
|
||||
- QBITTORRENT_PORT=${QBITTORRENT_PORT:-8080}
|
||||
- QBITTORRENT_USERNAME=${QBITTORRENT_USERNAME:-admin}
|
||||
|
||||
250
extensions/brain-firefox/background.js
Normal file
250
extensions/brain-firefox/background.js
Normal file
@@ -0,0 +1,250 @@
|
||||
"use strict";
|
||||
|
||||
var API_BASE = "https://dash.quadjourney.com";
|
||||
|
||||
console.log("[Brain] Background script loaded");
|
||||
|
||||
// ── Auth helpers ──
|
||||
|
||||
function getSession() {
|
||||
return browser.storage.local.get("brainSession").then(function(data) {
|
||||
return data.brainSession || null;
|
||||
});
|
||||
}
|
||||
|
||||
function apiRequest(path, opts) {
|
||||
return getSession().then(function(session) {
|
||||
if (!session) throw new Error("Not logged in");
|
||||
|
||||
opts = opts || {};
|
||||
opts.headers = opts.headers || {};
|
||||
opts.headers["Cookie"] = "platform_session=" + session;
|
||||
opts.credentials = "include";
|
||||
|
||||
return fetch(API_BASE + path, opts).then(function(resp) {
|
||||
if (resp.status === 401 || resp.status === 403) {
|
||||
browser.storage.local.remove("brainSession");
|
||||
throw new Error("Session expired");
|
||||
}
|
||||
if (!resp.ok) throw new Error("API error: " + resp.status);
|
||||
return resp.json();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Save functions ──
|
||||
|
||||
function saveLink(url, title, note) {
|
||||
var body = { type: "link", url: url };
|
||||
if (title) body.title = title;
|
||||
if (note && note.trim()) body.raw_content = note.trim();
|
||||
return apiRequest("/api/brain/items", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function saveNote(text) {
|
||||
return apiRequest("/api/brain/items", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "note", raw_content: text }),
|
||||
});
|
||||
}
|
||||
|
||||
function saveImage(imageUrl) {
|
||||
return fetch(imageUrl).then(function(resp) {
|
||||
if (!resp.ok) throw new Error("Failed to download image");
|
||||
return resp.blob();
|
||||
}).then(function(blob) {
|
||||
var parts = imageUrl.split("/");
|
||||
var filename = (parts[parts.length - 1] || "image.jpg").split("?")[0];
|
||||
var formData = new FormData();
|
||||
formData.append("file", blob, filename);
|
||||
|
||||
return getSession().then(function(session) {
|
||||
if (!session) throw new Error("Not logged in");
|
||||
return fetch(API_BASE + "/api/brain/items/upload", {
|
||||
method: "POST",
|
||||
headers: { "Cookie": "platform_session=" + session },
|
||||
credentials: "include",
|
||||
body: formData,
|
||||
});
|
||||
});
|
||||
}).then(function(resp) {
|
||||
if (!resp.ok) throw new Error("Upload failed: " + resp.status);
|
||||
return resp.json();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Badge helpers ──
|
||||
|
||||
function showBadge(tabId, text, color, duration) {
|
||||
browser.action.setBadgeText({ text: text, tabId: tabId });
|
||||
browser.action.setBadgeBackgroundColor({ color: color, tabId: tabId });
|
||||
setTimeout(function() {
|
||||
browser.action.setBadgeText({ text: "", tabId: tabId });
|
||||
}, duration || 2000);
|
||||
}
|
||||
|
||||
// ── Context menus ──
|
||||
|
||||
browser.runtime.onInstalled.addListener(function() {
|
||||
console.log("[Brain] Creating context menus");
|
||||
browser.contextMenus.create({
|
||||
id: "save-page",
|
||||
title: "Save page to Brain",
|
||||
contexts: ["page"],
|
||||
});
|
||||
browser.contextMenus.create({
|
||||
id: "save-link",
|
||||
title: "Save link to Brain",
|
||||
contexts: ["link"],
|
||||
});
|
||||
browser.contextMenus.create({
|
||||
id: "save-image",
|
||||
title: "Save image to Brain",
|
||||
contexts: ["image"],
|
||||
});
|
||||
});
|
||||
|
||||
browser.contextMenus.onClicked.addListener(function(info, tab) {
|
||||
getSession().then(function(session) {
|
||||
if (!session) {
|
||||
browser.action.openPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
var promise;
|
||||
if (info.menuItemId === "save-page") {
|
||||
promise = saveLink(tab.url, tab.title);
|
||||
} else if (info.menuItemId === "save-link") {
|
||||
promise = saveLink(info.linkUrl, info.linkText);
|
||||
} else if (info.menuItemId === "save-image") {
|
||||
promise = saveImage(info.srcUrl);
|
||||
}
|
||||
|
||||
if (promise) {
|
||||
promise.then(function() {
|
||||
showBadge(tab.id, "OK", "#059669", 2000);
|
||||
}).catch(function(e) {
|
||||
showBadge(tab.id, "ERR", "#DC2626", 3000);
|
||||
console.error("[Brain] Save failed:", e);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Keyboard shortcut ──
|
||||
|
||||
browser.commands.onCommand.addListener(function(command) {
|
||||
if (command === "save-page") {
|
||||
browser.tabs.query({ active: true, currentWindow: true }).then(function(tabs) {
|
||||
var tab = tabs[0];
|
||||
if (!tab || !tab.url) return;
|
||||
|
||||
getSession().then(function(session) {
|
||||
if (!session) {
|
||||
browser.action.openPopup();
|
||||
return;
|
||||
}
|
||||
saveLink(tab.url, tab.title).then(function() {
|
||||
showBadge(tab.id, "OK", "#059669", 2000);
|
||||
}).catch(function(e) {
|
||||
showBadge(tab.id, "ERR", "#DC2626", 3000);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── Message handler (from popup) ──
|
||||
|
||||
browser.runtime.onMessage.addListener(function(msg, sender, sendResponse) {
|
||||
console.log("[Brain] Got message:", msg.action);
|
||||
|
||||
if (msg.action === "login") {
|
||||
fetch(API_BASE + "/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username: msg.username,
|
||||
password: msg.password,
|
||||
}),
|
||||
credentials: "include",
|
||||
}).then(function(resp) {
|
||||
if (!resp.ok) {
|
||||
sendResponse({ success: false, error: "Invalid credentials" });
|
||||
return;
|
||||
}
|
||||
// Wait for cookie to be set
|
||||
return new Promise(function(r) { setTimeout(r, 300); }).then(function() {
|
||||
return browser.cookies.get({
|
||||
url: API_BASE,
|
||||
name: "platform_session",
|
||||
});
|
||||
}).then(function(cookie) {
|
||||
if (cookie && cookie.value) {
|
||||
return browser.storage.local.set({ brainSession: cookie.value }).then(function() {
|
||||
sendResponse({ success: true });
|
||||
});
|
||||
}
|
||||
sendResponse({ success: false, error: "Login OK but could not capture session" });
|
||||
});
|
||||
}).catch(function(e) {
|
||||
console.error("[Brain] Login error:", e);
|
||||
sendResponse({ success: false, error: e.message || "Connection failed" });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (msg.action === "logout") {
|
||||
browser.storage.local.remove("brainSession").then(function() {
|
||||
sendResponse({ success: true });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (msg.action === "check-auth") {
|
||||
getSession().then(function(session) {
|
||||
if (!session) {
|
||||
sendResponse({ authenticated: false });
|
||||
return;
|
||||
}
|
||||
return apiRequest("/api/auth/me").then(function(data) {
|
||||
sendResponse({ authenticated: true, user: data.user || data });
|
||||
}).catch(function() {
|
||||
sendResponse({ authenticated: false });
|
||||
});
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (msg.action === "save-link") {
|
||||
saveLink(msg.url, msg.title, msg.note).then(function(result) {
|
||||
sendResponse(result);
|
||||
}).catch(function(e) {
|
||||
sendResponse({ error: e.message });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (msg.action === "save-note") {
|
||||
saveNote(msg.text).then(function(result) {
|
||||
sendResponse(result);
|
||||
}).catch(function(e) {
|
||||
sendResponse({ error: e.message });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (msg.action === "save-image") {
|
||||
saveImage(msg.url).then(function(result) {
|
||||
sendResponse(result);
|
||||
}).catch(function(e) {
|
||||
sendResponse({ error: e.message });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
});
|
||||
BIN
extensions/brain-firefox/brain-firefox.xpi
Normal file
BIN
extensions/brain-firefox/brain-firefox.xpi
Normal file
Binary file not shown.
49
extensions/brain-firefox/manifest.json
Normal file
49
extensions/brain-firefox/manifest.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Brain - Save to Second Brain",
|
||||
"version": "1.0.0",
|
||||
"description": "One-click save pages, notes, and images to your Second Brain",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
"contextMenus",
|
||||
"storage",
|
||||
"cookies"
|
||||
],
|
||||
"host_permissions": [
|
||||
"https://dash.quadjourney.com/*"
|
||||
],
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "icons/brain-16.png",
|
||||
"32": "icons/brain-32.png",
|
||||
"48": "icons/brain-48.png"
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
"scripts": ["background.js"]
|
||||
},
|
||||
"commands": {
|
||||
"save-page": {
|
||||
"suggested_key": {
|
||||
"default": "Alt+Shift+S"
|
||||
},
|
||||
"description": "Save current page to Brain"
|
||||
}
|
||||
},
|
||||
"icons": {
|
||||
"16": "icons/brain-16.png",
|
||||
"32": "icons/brain-32.png",
|
||||
"48": "icons/brain-48.png",
|
||||
"128": "icons/brain-128.png"
|
||||
},
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "brain@quadjourney.com",
|
||||
"data_collection_permissions": {
|
||||
"required": ["none"],
|
||||
"optional": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
259
extensions/brain-firefox/popup.html
Normal file
259
extensions/brain-firefox/popup.html
Normal file
@@ -0,0 +1,259 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
width: 340px;
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
background: #f5efe6;
|
||||
color: #1e1812;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid rgba(35,26,17,0.1);
|
||||
}
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.header-mark {
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #211912, #5d4c3f);
|
||||
color: white;
|
||||
display: grid; place-items: center;
|
||||
font-size: 12px; font-weight: 700;
|
||||
}
|
||||
.header-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.header-user {
|
||||
font-size: 11px;
|
||||
color: #8c7b69;
|
||||
}
|
||||
.logout-btn {
|
||||
background: none; border: none;
|
||||
color: #8c7b69; font-size: 11px;
|
||||
cursor: pointer; text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ── Login ── */
|
||||
.login-view { padding: 24px 16px; }
|
||||
.login-title {
|
||||
font-size: 16px; font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.login-sub {
|
||||
font-size: 12px; color: #8c7b69;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.field { margin-bottom: 12px; }
|
||||
.field label {
|
||||
display: block; font-size: 11px; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
color: #6b6256; margin-bottom: 4px;
|
||||
}
|
||||
.field input {
|
||||
width: 100%; padding: 10px 12px;
|
||||
border: 1px solid rgba(35,26,17,0.15);
|
||||
border-radius: 10px; font-size: 14px;
|
||||
background: rgba(255,255,255,0.7);
|
||||
color: #1e1812; outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
.field input:focus {
|
||||
border-color: rgba(35,26,17,0.35);
|
||||
}
|
||||
.login-btn, .save-btn {
|
||||
width: 100%; padding: 12px;
|
||||
border: none; border-radius: 12px;
|
||||
background: linear-gradient(135deg, #211912, #5d4c3f);
|
||||
color: white; font-size: 14px; font-weight: 600;
|
||||
cursor: pointer; font-family: inherit;
|
||||
transition: opacity 150ms;
|
||||
}
|
||||
.login-btn:hover, .save-btn:hover { opacity: 0.9; }
|
||||
.login-btn:disabled, .save-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.login-error {
|
||||
color: #8f3928; font-size: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* ── Save view ── */
|
||||
.save-view { padding: 12px 16px 16px; }
|
||||
|
||||
.mode-tabs {
|
||||
display: flex; gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.mode-tab {
|
||||
flex: 1; padding: 8px;
|
||||
border: 1px solid rgba(35,26,17,0.12);
|
||||
border-radius: 10px;
|
||||
background: rgba(255,255,255,0.4);
|
||||
color: #6b6256; font-size: 12px; font-weight: 600;
|
||||
cursor: pointer; text-align: center;
|
||||
font-family: inherit;
|
||||
transition: all 150ms;
|
||||
}
|
||||
.mode-tab.active {
|
||||
background: rgba(255,255,255,0.9);
|
||||
color: #1e1812;
|
||||
border-color: rgba(35,26,17,0.25);
|
||||
}
|
||||
|
||||
.url-preview {
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255,255,255,0.6);
|
||||
border: 1px solid rgba(35,26,17,0.08);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.url-title {
|
||||
font-size: 13px; font-weight: 600;
|
||||
white-space: nowrap; overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.url-domain {
|
||||
font-size: 11px; color: #8c7b69;
|
||||
white-space: nowrap; overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.note-toggle {
|
||||
background: none; border: none;
|
||||
color: #8c7b69; font-size: 12px;
|
||||
cursor: pointer; padding: 0;
|
||||
margin-bottom: 8px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.note-toggle:hover { color: #1e1812; }
|
||||
|
||||
.note-area {
|
||||
width: 100%; min-height: 80px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(35,26,17,0.15);
|
||||
border-radius: 10px; font-size: 13px;
|
||||
background: rgba(255,255,255,0.7);
|
||||
color: #1e1812; outline: none;
|
||||
font-family: inherit; resize: vertical;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.note-area:focus {
|
||||
border-color: rgba(35,26,17,0.35);
|
||||
}
|
||||
|
||||
/* ── Success state ── */
|
||||
.success-view {
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.success-check {
|
||||
width: 48px; height: 48px;
|
||||
border-radius: 50%;
|
||||
background: #059669;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.success-check svg { color: white; }
|
||||
.success-text {
|
||||
font-size: 16px; font-weight: 600;
|
||||
}
|
||||
.success-sub {
|
||||
font-size: 12px; color: #8c7b69;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ── Error ── */
|
||||
.error-msg {
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: rgba(143,57,40,0.08);
|
||||
color: #8f3928;
|
||||
font-size: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.hidden { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Login View -->
|
||||
<div id="login-view" class="login-view hidden">
|
||||
<div class="login-title">Sign in to Brain</div>
|
||||
<div class="login-sub">Use your Platform credentials</div>
|
||||
<div id="login-error" class="login-error hidden"></div>
|
||||
<div class="field">
|
||||
<label>Username</label>
|
||||
<input id="login-user" type="text" placeholder="admin" autocomplete="username">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Password</label>
|
||||
<input id="login-pass" type="password" placeholder="" autocomplete="current-password">
|
||||
</div>
|
||||
<button id="login-btn" class="login-btn">Sign in</button>
|
||||
</div>
|
||||
|
||||
<!-- Save View -->
|
||||
<div id="save-view" class="hidden">
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<div class="header-mark">B</div>
|
||||
<div>
|
||||
<div class="header-title">Brain</div>
|
||||
<div class="header-user" id="user-name"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="logout-btn" id="logout-btn">Logout</button>
|
||||
</div>
|
||||
|
||||
<div class="save-view">
|
||||
<div class="mode-tabs">
|
||||
<button class="mode-tab active" id="tab-link" data-mode="link">Save page</button>
|
||||
<button class="mode-tab" id="tab-note" data-mode="note">Quick note</button>
|
||||
</div>
|
||||
|
||||
<!-- Link mode -->
|
||||
<div id="link-mode">
|
||||
<div class="url-preview">
|
||||
<div class="url-title" id="page-title"></div>
|
||||
<div class="url-domain" id="page-url"></div>
|
||||
</div>
|
||||
<button class="note-toggle" id="note-toggle-btn">+ Add a note</button>
|
||||
<textarea id="link-note" class="note-area hidden" placeholder="Optional note about this page..."></textarea>
|
||||
<button id="save-link-btn" class="save-btn">Save page</button>
|
||||
</div>
|
||||
|
||||
<!-- Note mode -->
|
||||
<div id="note-mode" class="hidden">
|
||||
<textarea id="note-text" class="note-area" placeholder="Write a note..." style="min-height:120px;"></textarea>
|
||||
<button id="save-note-btn" class="save-btn">Save note</button>
|
||||
</div>
|
||||
|
||||
<div id="save-error" class="error-msg hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success View -->
|
||||
<div id="success-view" class="success-view hidden">
|
||||
<div class="success-check">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
</div>
|
||||
<div class="success-text">Saved!</div>
|
||||
<div class="success-sub">AI is classifying in the background</div>
|
||||
</div>
|
||||
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
202
extensions/brain-firefox/popup.js
Normal file
202
extensions/brain-firefox/popup.js
Normal file
@@ -0,0 +1,202 @@
|
||||
const loginView = document.getElementById("login-view");
|
||||
const saveView = document.getElementById("save-view");
|
||||
const successView = document.getElementById("success-view");
|
||||
|
||||
const loginBtn = document.getElementById("login-btn");
|
||||
const loginUser = document.getElementById("login-user");
|
||||
const loginPass = document.getElementById("login-pass");
|
||||
const loginError = document.getElementById("login-error");
|
||||
const logoutBtn = document.getElementById("logout-btn");
|
||||
const userName = document.getElementById("user-name");
|
||||
|
||||
const tabLink = document.getElementById("tab-link");
|
||||
const tabNote = document.getElementById("tab-note");
|
||||
const linkMode = document.getElementById("link-mode");
|
||||
const noteMode = document.getElementById("note-mode");
|
||||
|
||||
const pageTitle = document.getElementById("page-title");
|
||||
const pageUrl = document.getElementById("page-url");
|
||||
const noteToggleBtn = document.getElementById("note-toggle-btn");
|
||||
const linkNote = document.getElementById("link-note");
|
||||
const saveLinkBtn = document.getElementById("save-link-btn");
|
||||
|
||||
const noteText = document.getElementById("note-text");
|
||||
const saveNoteBtn = document.getElementById("save-note-btn");
|
||||
const saveError = document.getElementById("save-error");
|
||||
|
||||
let currentTab = null;
|
||||
|
||||
// ── View management ──
|
||||
|
||||
function showView(view) {
|
||||
loginView.classList.add("hidden");
|
||||
saveView.classList.add("hidden");
|
||||
successView.classList.add("hidden");
|
||||
view.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function showError(el, msg) {
|
||||
el.textContent = msg;
|
||||
el.classList.remove("hidden");
|
||||
}
|
||||
function hideError(el) {
|
||||
el.classList.add("hidden");
|
||||
}
|
||||
|
||||
// ── Init ──
|
||||
|
||||
async function init() {
|
||||
const resp = await browser.runtime.sendMessage({ action: "check-auth" });
|
||||
if (resp.authenticated) {
|
||||
userName.textContent = resp.user?.display_name || resp.user?.username || "";
|
||||
showView(saveView);
|
||||
await loadCurrentTab();
|
||||
} else {
|
||||
showView(loginView);
|
||||
loginUser.focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCurrentTab() {
|
||||
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
|
||||
if (tab) {
|
||||
currentTab = tab;
|
||||
pageTitle.textContent = tab.title || "Untitled";
|
||||
try {
|
||||
pageUrl.textContent = new URL(tab.url).hostname;
|
||||
} catch {
|
||||
pageUrl.textContent = tab.url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Login ──
|
||||
|
||||
loginBtn.addEventListener("click", async () => {
|
||||
const user = loginUser.value.trim();
|
||||
const pass = loginPass.value;
|
||||
if (!user || !pass) return;
|
||||
|
||||
loginBtn.disabled = true;
|
||||
loginBtn.textContent = "Signing in...";
|
||||
hideError(loginError);
|
||||
|
||||
try {
|
||||
const resp = await browser.runtime.sendMessage({
|
||||
action: "login",
|
||||
username: user,
|
||||
password: pass,
|
||||
});
|
||||
|
||||
if (resp && resp.success) {
|
||||
const auth = await browser.runtime.sendMessage({ action: "check-auth" });
|
||||
userName.textContent = auth.user?.display_name || auth.user?.username || "";
|
||||
showView(saveView);
|
||||
await loadCurrentTab();
|
||||
} else {
|
||||
showError(loginError, (resp && resp.error) || "Login failed");
|
||||
}
|
||||
} catch (e) {
|
||||
showError(loginError, "Error: " + (e.message || e));
|
||||
}
|
||||
|
||||
loginBtn.disabled = false;
|
||||
loginBtn.textContent = "Sign in";
|
||||
});
|
||||
|
||||
loginPass.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") loginBtn.click();
|
||||
});
|
||||
|
||||
// ── Logout ──
|
||||
|
||||
logoutBtn.addEventListener("click", async () => {
|
||||
await browser.runtime.sendMessage({ action: "logout" });
|
||||
showView(loginView);
|
||||
loginUser.value = "";
|
||||
loginPass.value = "";
|
||||
});
|
||||
|
||||
// ── Mode tabs ──
|
||||
|
||||
tabLink.addEventListener("click", () => {
|
||||
tabLink.classList.add("active");
|
||||
tabNote.classList.remove("active");
|
||||
linkMode.classList.remove("hidden");
|
||||
noteMode.classList.add("hidden");
|
||||
hideError(saveError);
|
||||
});
|
||||
|
||||
tabNote.addEventListener("click", () => {
|
||||
tabNote.classList.add("active");
|
||||
tabLink.classList.remove("active");
|
||||
noteMode.classList.remove("hidden");
|
||||
linkMode.classList.add("hidden");
|
||||
hideError(saveError);
|
||||
noteText.focus();
|
||||
});
|
||||
|
||||
// ── Note toggle on link mode ──
|
||||
|
||||
noteToggleBtn.addEventListener("click", () => {
|
||||
linkNote.classList.toggle("hidden");
|
||||
if (!linkNote.classList.contains("hidden")) {
|
||||
noteToggleBtn.textContent = "- Hide note";
|
||||
linkNote.focus();
|
||||
} else {
|
||||
noteToggleBtn.textContent = "+ Add a note";
|
||||
}
|
||||
});
|
||||
|
||||
// ── Save link ──
|
||||
|
||||
saveLinkBtn.addEventListener("click", async () => {
|
||||
if (!currentTab?.url) return;
|
||||
saveLinkBtn.disabled = true;
|
||||
saveLinkBtn.textContent = "Saving...";
|
||||
hideError(saveError);
|
||||
|
||||
try {
|
||||
await browser.runtime.sendMessage({
|
||||
action: "save-link",
|
||||
url: currentTab.url,
|
||||
title: currentTab.title,
|
||||
note: linkNote.value.trim() || undefined,
|
||||
});
|
||||
|
||||
showView(successView);
|
||||
setTimeout(() => window.close(), 1500);
|
||||
} catch (e) {
|
||||
showError(saveError, e.message || "Save failed");
|
||||
saveLinkBtn.disabled = false;
|
||||
saveLinkBtn.textContent = "Save page";
|
||||
}
|
||||
});
|
||||
|
||||
// ── Save note ──
|
||||
|
||||
saveNoteBtn.addEventListener("click", async () => {
|
||||
const text = noteText.value.trim();
|
||||
if (!text) return;
|
||||
saveNoteBtn.disabled = true;
|
||||
saveNoteBtn.textContent = "Saving...";
|
||||
hideError(saveError);
|
||||
|
||||
try {
|
||||
await browser.runtime.sendMessage({ action: "save-note", text });
|
||||
showView(successView);
|
||||
setTimeout(() => window.close(), 1500);
|
||||
} catch (e) {
|
||||
showError(saveError, e.message || "Save failed");
|
||||
saveNoteBtn.disabled = false;
|
||||
saveNoteBtn.textContent = "Save note";
|
||||
}
|
||||
});
|
||||
|
||||
// ── Start ──
|
||||
init().catch((e) => {
|
||||
console.error("[Brain popup] Init failed:", e);
|
||||
// Show login view as fallback
|
||||
showView(loginView);
|
||||
loginUser.focus();
|
||||
});
|
||||
8
extensions/brain-firefox/test-popup.html
Normal file
8
extensions/brain-firefox/test-popup.html
Normal file
@@ -0,0 +1,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="width:300px;padding:20px;font-family:sans-serif;">
|
||||
<h2>Brain Extension</h2>
|
||||
<p>If you see this, the popup works.</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -12,8 +12,9 @@
|
||||
═══════════════════════════════════════════════ */
|
||||
|
||||
@layer base {
|
||||
html, body { overflow-x: hidden; }
|
||||
body { padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); }
|
||||
html { background-color: #f5efe6; }
|
||||
body { overflow-x: clip; }
|
||||
body { padding-bottom: env(safe-area-inset-bottom); }
|
||||
:root {
|
||||
/* ── Fonts ── */
|
||||
--font: 'Outfit', -apple-system, system-ui, sans-serif;
|
||||
@@ -106,7 +107,7 @@
|
||||
|
||||
/* ── LIGHT MODE — Zinc + Emerald ── */
|
||||
:root {
|
||||
--canvas: #FAFAFA;
|
||||
--canvas: #f5efe6;
|
||||
--surface: #FFFFFF;
|
||||
--surface-secondary: #F4F4F5;
|
||||
--card: #FFFFFF;
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#f5efe6" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { Bot, Brain, SendHorizonal, X } from '@lucide/svelte';
|
||||
|
||||
type ChatRole = 'user' | 'assistant';
|
||||
|
||||
type SourceLink = {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
type Message = {
|
||||
role: ChatRole;
|
||||
content: string;
|
||||
sources?: SourceLink[];
|
||||
};
|
||||
|
||||
type AssistantState = {
|
||||
lastMutation?: {
|
||||
type: 'append' | 'create';
|
||||
itemId: string;
|
||||
itemTitle: string;
|
||||
additionId?: string;
|
||||
content: string;
|
||||
createdItemId?: string;
|
||||
};
|
||||
pendingDelete?: {
|
||||
itemId: string;
|
||||
itemTitle: string;
|
||||
};
|
||||
};
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(), onclose }: Props = $props();
|
||||
|
||||
const intro =
|
||||
'Ask Brain to save a note, add to an existing note, answer from your notes, or delete something. I will reuse the existing Brain workflow when a new item should be created.';
|
||||
|
||||
let messages = $state<Message[]>([{ role: 'assistant', content: intro }]);
|
||||
let input = $state('');
|
||||
let sending = $state(false);
|
||||
let error = $state('');
|
||||
let composerEl: HTMLInputElement | null = $state(null);
|
||||
let threadEndEl: HTMLDivElement | null = $state(null);
|
||||
let assistantState = $state<AssistantState>({});
|
||||
|
||||
async function scrollThreadToBottom(behavior: ScrollBehavior = 'smooth') {
|
||||
await tick();
|
||||
requestAnimationFrame(() => {
|
||||
threadEndEl?.scrollIntoView({ block: 'end', behavior });
|
||||
});
|
||||
}
|
||||
|
||||
function resetThread() {
|
||||
messages = [{ role: 'assistant', content: intro }];
|
||||
input = '';
|
||||
error = '';
|
||||
assistantState = {};
|
||||
}
|
||||
|
||||
async function sendMessage(content: string) {
|
||||
const clean = content.trim();
|
||||
if (!clean) return;
|
||||
|
||||
error = '';
|
||||
sending = true;
|
||||
const nextMessages = [...messages, { role: 'user' as const, content: clean }];
|
||||
messages = nextMessages;
|
||||
input = '';
|
||||
void scrollThreadToBottom('auto');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/assistant/brain', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
messages: nextMessages,
|
||||
state: assistantState
|
||||
})
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.error || data?.reply || 'Brain assistant request failed');
|
||||
}
|
||||
|
||||
assistantState = data?.state || {};
|
||||
messages = [
|
||||
...nextMessages,
|
||||
{
|
||||
role: 'assistant',
|
||||
content: data?.reply || 'Done.',
|
||||
sources: Array.isArray(data?.sources) ? data.sources : []
|
||||
}
|
||||
];
|
||||
await scrollThreadToBottom();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Brain assistant request failed';
|
||||
} finally {
|
||||
sending = false;
|
||||
requestAnimationFrame(() => composerEl?.focus());
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
void sendMessage(input);
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
onclose();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
requestAnimationFrame(() => composerEl?.focus());
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
document.body.classList.add('assistant-open');
|
||||
return () => {
|
||||
document.body.classList.remove('assistant-open');
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<button class="assistant-backdrop" aria-label="Close assistant" onclick={onclose}></button>
|
||||
|
||||
<div class="assistant-drawer" role="dialog" aria-label="Brain assistant">
|
||||
<header class="assistant-head">
|
||||
<div class="assistant-title-wrap">
|
||||
<div class="assistant-mark">
|
||||
<Brain size={16} strokeWidth={1.9} />
|
||||
</div>
|
||||
<div>
|
||||
<div class="assistant-kicker">Assistant</div>
|
||||
<div class="assistant-title">Brain chat</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="assistant-head-actions">
|
||||
<button class="assistant-ghost" onclick={resetThread}>Reset</button>
|
||||
<button class="assistant-close" onclick={onclose} aria-label="Close assistant">
|
||||
<X size={17} strokeWidth={1.9} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="assistant-body">
|
||||
<section class="thread">
|
||||
<div class="thread-spacer" aria-hidden="true"></div>
|
||||
{#each messages as message}
|
||||
<div class="bubble-row" class:user={message.role === 'user'}>
|
||||
{#if message.role === 'assistant'}
|
||||
<div class="bubble-icon">
|
||||
<Bot size={14} strokeWidth={1.8} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="bubble-stack" class:user={message.role === 'user'}>
|
||||
<div class="bubble" class:user={message.role === 'user'}>
|
||||
{message.content}
|
||||
</div>
|
||||
|
||||
{#if message.sources?.length}
|
||||
<div class="source-list">
|
||||
{#each message.sources as source}
|
||||
<a class="source-chip" href={source.href}>
|
||||
<span>{source.title}</span>
|
||||
<small>{source.type}</small>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
<div bind:this={threadEndEl}></div>
|
||||
</section>
|
||||
|
||||
{#if error}
|
||||
<div class="assistant-error">{error}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<footer class="assistant-compose">
|
||||
<div class="compose-box">
|
||||
<input
|
||||
bind:this={composerEl}
|
||||
bind:value={input}
|
||||
class="compose-input"
|
||||
type="text"
|
||||
placeholder="Ask Brain to save, find, answer, or delete..."
|
||||
onkeydown={handleKeydown}
|
||||
disabled={sending}
|
||||
/>
|
||||
<button class="send-btn" onclick={handleSubmit} disabled={sending || !input.trim()}>
|
||||
<SendHorizonal size={15} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.assistant-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(33, 23, 14, 0.18);
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 80;
|
||||
border: none;
|
||||
}
|
||||
|
||||
:global(body.assistant-open) {
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.assistant-drawer {
|
||||
position: fixed;
|
||||
top: 18px;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
width: min(460px, calc(100vw - 24px));
|
||||
background: linear-gradient(180deg, rgba(249, 244, 238, 0.98), rgba(244, 236, 226, 0.98));
|
||||
border: 1px solid rgba(35, 26, 17, 0.08);
|
||||
border-radius: 28px;
|
||||
box-shadow: 0 28px 80px rgba(35, 26, 17, 0.18);
|
||||
backdrop-filter: blur(18px);
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
z-index: 90;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.assistant-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 18px 14px;
|
||||
border-bottom: 1px solid rgba(35, 26, 17, 0.08);
|
||||
}
|
||||
|
||||
.assistant-title-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.assistant-mark {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(34, 24, 14, 0.08);
|
||||
color: #231a11;
|
||||
}
|
||||
|
||||
.assistant-kicker {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: #8a7866;
|
||||
}
|
||||
|
||||
.assistant-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.04em;
|
||||
color: #18120c;
|
||||
}
|
||||
|
||||
.assistant-head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.assistant-ghost,
|
||||
.assistant-close,
|
||||
.send-btn {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.assistant-ghost {
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(35, 26, 17, 0.06);
|
||||
color: #5d5248;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.assistant-close {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 999px;
|
||||
background: rgba(35, 26, 17, 0.06);
|
||||
color: #2c241d;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.assistant-body {
|
||||
min-height: 0;
|
||||
padding: 0 10px 10px;
|
||||
}
|
||||
|
||||
.thread {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 10px 8px 12px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.thread-spacer {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.bubble-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.bubble-row.user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.bubble-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(35, 26, 17, 0.08);
|
||||
color: #2d241b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bubble-stack {
|
||||
max-width: 78%;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bubble-stack.user {
|
||||
justify-items: end;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
padding: 11px 14px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 252, 248, 0.92);
|
||||
border: 1px solid rgba(35, 26, 17, 0.07);
|
||||
color: #221a13;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.bubble.user {
|
||||
background: #1f1812;
|
||||
color: #fff8f1;
|
||||
border-color: #1f1812;
|
||||
}
|
||||
|
||||
.source-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.source-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
text-decoration: none;
|
||||
background: rgba(255, 252, 248, 0.78);
|
||||
border: 1px solid rgba(35, 26, 17, 0.07);
|
||||
color: #2c241d;
|
||||
}
|
||||
|
||||
.source-chip small {
|
||||
color: #8a7866;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.assistant-error {
|
||||
margin: 0 8px 6px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
background: rgba(150, 34, 22, 0.08);
|
||||
color: #8f3428;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.assistant-compose {
|
||||
padding: 12px 14px 14px;
|
||||
border-top: 1px solid rgba(35, 26, 17, 0.08);
|
||||
background: rgba(248, 242, 235, 0.84);
|
||||
}
|
||||
|
||||
.compose-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 10px 10px 14px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 252, 248, 0.88);
|
||||
border: 1px solid rgba(35, 26, 17, 0.08);
|
||||
}
|
||||
|
||||
.compose-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #1f1812;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.compose-input::placeholder {
|
||||
color: #8a7866;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #1f1812;
|
||||
color: #fff8f1;
|
||||
}
|
||||
|
||||
.send-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.assistant-drawer {
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
left: 12px;
|
||||
bottom: 12px;
|
||||
width: auto;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.bubble-stack {
|
||||
max-width: 88%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,16 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { tick } from 'svelte';
|
||||
import { Bot, Dumbbell, ImagePlus, SendHorizonal, Sparkles, X } from '@lucide/svelte';
|
||||
import { Bot, Brain, Dumbbell, ImagePlus, SendHorizonal, Sparkles, X } from '@lucide/svelte';
|
||||
|
||||
type ChatRole = 'user' | 'assistant';
|
||||
type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
type Domain = 'fitness' | 'brain';
|
||||
|
||||
type SourceLink = {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
type Message = {
|
||||
role: ChatRole;
|
||||
content: string;
|
||||
image?: string;
|
||||
imageName?: string;
|
||||
sources?: SourceLink[];
|
||||
domain?: Domain;
|
||||
};
|
||||
|
||||
type Draft = {
|
||||
@@ -30,21 +40,34 @@
|
||||
|
||||
type DraftBundle = Draft[];
|
||||
|
||||
type UnifiedState = {
|
||||
activeDomain?: Domain;
|
||||
fitnessState?: {
|
||||
draft?: Draft | null;
|
||||
drafts?: DraftBundle;
|
||||
};
|
||||
brainState?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onclose: () => void;
|
||||
entryDate?: string | null;
|
||||
allowBrain?: boolean;
|
||||
}
|
||||
|
||||
let { open = $bindable(), onclose, entryDate = null }: Props = $props();
|
||||
let { open = $bindable(), onclose, entryDate = null, allowBrain = true }: Props = $props();
|
||||
|
||||
const intro = `Tell me what you ate, including multiple items or a nutrition label photo, then correct me naturally until it looks right.`;
|
||||
const intro = $derived(
|
||||
allowBrain
|
||||
? 'Log food, ask about calories, save a note, answer from Brain, or update/delete something. Photos currently route to fitness.'
|
||||
: 'Log food, ask about calories, or add from a photo. Photos currently route to fitness.'
|
||||
);
|
||||
|
||||
let messages = $state<Message[]>([
|
||||
{ role: 'assistant', content: intro }
|
||||
]);
|
||||
let draft = $state<Draft | null>(null);
|
||||
let drafts = $state<DraftBundle>([]);
|
||||
let unifiedState = $state<UnifiedState>({});
|
||||
let input = $state('');
|
||||
let sending = $state(false);
|
||||
let error = $state('');
|
||||
@@ -66,8 +89,7 @@
|
||||
|
||||
function resetThread() {
|
||||
messages = [{ role: 'assistant', content: intro }];
|
||||
draft = null;
|
||||
drafts = [];
|
||||
unifiedState = {};
|
||||
input = '';
|
||||
error = '';
|
||||
photoPreview = null;
|
||||
@@ -124,16 +146,16 @@
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/assistant/fitness', {
|
||||
const response = await fetch('/api/assistant', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action,
|
||||
messages: nextMessages,
|
||||
draft,
|
||||
drafts,
|
||||
state: unifiedState,
|
||||
entryDate,
|
||||
imageDataUrl: action === 'chat' ? outgoingPhoto || attachedPhoto : null
|
||||
imageDataUrl: action === 'chat' ? outgoingPhoto || attachedPhoto : null,
|
||||
allowBrain
|
||||
})
|
||||
});
|
||||
|
||||
@@ -142,21 +164,18 @@
|
||||
throw new Error(data?.error || 'Assistant request failed');
|
||||
}
|
||||
|
||||
if (data.draft) {
|
||||
draft = data.draft;
|
||||
}
|
||||
|
||||
if (Array.isArray(data.drafts)) {
|
||||
drafts = data.drafts;
|
||||
if (data.drafts.length > 0) {
|
||||
draft = null;
|
||||
}
|
||||
} else if (data.draft) {
|
||||
drafts = [];
|
||||
}
|
||||
unifiedState = data?.state || {};
|
||||
|
||||
if (data.reply) {
|
||||
messages = [...nextMessages, { role: 'assistant', content: data.reply }];
|
||||
messages = [
|
||||
...nextMessages,
|
||||
{
|
||||
role: 'assistant',
|
||||
content: data.reply,
|
||||
sources: Array.isArray(data?.sources) ? data.sources : [],
|
||||
domain: data?.domain === 'fitness' || data?.domain === 'brain' ? data.domain : undefined
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
if (data.applied) {
|
||||
@@ -165,8 +184,6 @@
|
||||
attachedPhoto = null;
|
||||
attachedPhotoName = '';
|
||||
if (fileInputEl) fileInputEl.value = '';
|
||||
draft = null;
|
||||
drafts = [];
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('fitnessassistantapplied', {
|
||||
detail: { entryDate: data.draft?.entry_date || data.drafts?.[0]?.entry_date || entryDate || null }
|
||||
@@ -236,20 +253,36 @@
|
||||
document.body.classList.remove('assistant-open');
|
||||
};
|
||||
});
|
||||
|
||||
function activeDraft(): Draft | null {
|
||||
return unifiedState.fitnessState?.draft || null;
|
||||
}
|
||||
|
||||
function activeDrafts(): DraftBundle {
|
||||
return unifiedState.fitnessState?.drafts || [];
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<button class="assistant-backdrop" aria-label="Close assistant" onclick={onclose}></button>
|
||||
|
||||
<div class="assistant-drawer" role="dialog" aria-label="Fitness assistant">
|
||||
<div class="assistant-drawer" role="dialog" aria-label="Assistant">
|
||||
<header class="assistant-head">
|
||||
<div class="assistant-title-wrap">
|
||||
<div class="assistant-mark">
|
||||
<Dumbbell size={16} strokeWidth={1.9} />
|
||||
{#if unifiedState.activeDomain === 'brain'}
|
||||
{#if allowBrain}
|
||||
<Brain size={16} strokeWidth={1.9} />
|
||||
{:else}
|
||||
<Dumbbell size={16} strokeWidth={1.9} />
|
||||
{/if}
|
||||
{:else}
|
||||
<Dumbbell size={16} strokeWidth={1.9} />
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<div class="assistant-kicker">Assistant</div>
|
||||
<div class="assistant-title">Fitness chat</div>
|
||||
<div class="assistant-title">{allowBrain ? 'One chat' : 'Food chat'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -285,11 +318,21 @@
|
||||
{message.content}
|
||||
</div>
|
||||
{/if}
|
||||
{#if message.sources?.length}
|
||||
<div class="source-list">
|
||||
{#each message.sources as source}
|
||||
<a class="source-chip" href={source.href}>
|
||||
<span>{source.title}</span>
|
||||
<small>{source.type}</small>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if drafts.length > 1}
|
||||
{#if activeDrafts().length > 1}
|
||||
<div class="bubble-row draft-row">
|
||||
<div class="bubble-icon">
|
||||
<Sparkles size={14} strokeWidth={1.8} />
|
||||
@@ -298,12 +341,12 @@
|
||||
<div class="draft-card-top">
|
||||
<div>
|
||||
<div class="draft-card-kicker">Ready to add</div>
|
||||
<div class="draft-card-title">{drafts.length} items</div>
|
||||
<div class="draft-card-title">{activeDrafts().length} items</div>
|
||||
</div>
|
||||
<div class="draft-meal">{drafts[0]?.meal_type || 'meal'}</div>
|
||||
<div class="draft-meal">{activeDrafts()[0]?.meal_type || 'meal'}</div>
|
||||
</div>
|
||||
<div class="bundle-list">
|
||||
{#each drafts as item}
|
||||
{#each activeDrafts() as item}
|
||||
<div class="bundle-row">
|
||||
<div class="bundle-name">{item.food_name}</div>
|
||||
<div class="bundle-calories">{Math.round(item.calories || 0)} cal</div>
|
||||
@@ -311,7 +354,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
<div class="draft-inline-metrics">
|
||||
<span>{drafts.reduce((sum, item) => sum + Math.round(item.calories || 0), 0)} cal total</span>
|
||||
<span>{activeDrafts().reduce((sum, item) => sum + Math.round(item.calories || 0), 0)} cal total</span>
|
||||
</div>
|
||||
<div class="draft-card-actions">
|
||||
<button class="draft-apply subtle" onclick={beginRevision} disabled={sending}>
|
||||
@@ -323,7 +366,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if draft?.food_name}
|
||||
{:else if activeDraft()?.food_name}
|
||||
<div class="bubble-row draft-row">
|
||||
<div class="bubble-icon">
|
||||
<Sparkles size={14} strokeWidth={1.8} />
|
||||
@@ -332,19 +375,19 @@
|
||||
<div class="draft-card-top">
|
||||
<div>
|
||||
<div class="draft-card-kicker">Ready to add</div>
|
||||
<div class="draft-card-title">{draft.food_name}</div>
|
||||
<div class="draft-card-title">{activeDraft()?.food_name}</div>
|
||||
</div>
|
||||
{#if draft.meal_type}
|
||||
<div class="draft-meal">{draft.meal_type}</div>
|
||||
{#if activeDraft()?.meal_type}
|
||||
<div class="draft-meal">{activeDraft()?.meal_type}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="draft-inline-metrics">
|
||||
<span>{Math.round(draft.calories || 0)} cal</span>
|
||||
<span>{Math.round(draft.protein || 0)}p</span>
|
||||
<span>{Math.round(draft.carbs || 0)}c</span>
|
||||
<span>{Math.round(draft.fat || 0)}f</span>
|
||||
<span>{Math.round(draft.sugar || 0)} sugar</span>
|
||||
<span>{Math.round(draft.fiber || 0)} fiber</span>
|
||||
<span>{Math.round(activeDraft()?.calories || 0)} cal</span>
|
||||
<span>{Math.round(activeDraft()?.protein || 0)}p</span>
|
||||
<span>{Math.round(activeDraft()?.carbs || 0)}c</span>
|
||||
<span>{Math.round(activeDraft()?.fat || 0)}f</span>
|
||||
<span>{Math.round(activeDraft()?.sugar || 0)} sugar</span>
|
||||
<span>{Math.round(activeDraft()?.fiber || 0)} fiber</span>
|
||||
</div>
|
||||
<div class="draft-card-actions">
|
||||
<button class="draft-apply subtle" onclick={beginRevision} disabled={sending}>
|
||||
@@ -390,7 +433,7 @@
|
||||
<img src={photoPreview} alt="Selected food" />
|
||||
<div class="photo-staged-copy">
|
||||
<div class="photo-staged-title">{photoName || 'Food photo'}</div>
|
||||
<div class="photo-staged-note">I’ll draft the entry from this photo.</div>
|
||||
<div class="photo-staged-note">Photos currently route to fitness.</div>
|
||||
</div>
|
||||
<button class="photo-staged-clear" type="button" onclick={clearPhoto}>Remove</button>
|
||||
</div>
|
||||
@@ -410,7 +453,7 @@
|
||||
class="compose-input"
|
||||
bind:value={input}
|
||||
bind:this={composerEl}
|
||||
placeholder="Add 2 boiled eggs for breakfast..."
|
||||
placeholder={allowBrain ? 'Log food, save a note, ask Brain...' : 'Log food or ask about calories...'}
|
||||
onkeydown={handleKeydown}
|
||||
type="text"
|
||||
/>
|
||||
@@ -537,9 +580,12 @@
|
||||
.assistant-body {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.thread {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
@@ -549,6 +595,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.bundle-card {
|
||||
@@ -642,6 +689,30 @@
|
||||
color: #f8f4ee;
|
||||
}
|
||||
|
||||
.source-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.source-chip {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 14px;
|
||||
text-decoration: none;
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
border: 1px solid rgba(36, 26, 18, 0.08);
|
||||
color: #241c14;
|
||||
}
|
||||
|
||||
.source-chip small {
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #7a6a5b;
|
||||
}
|
||||
|
||||
.image-bubble {
|
||||
width: min(240px, 100%);
|
||||
padding: 8px;
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
LibraryBig,
|
||||
Menu,
|
||||
Package2,
|
||||
MessageSquare,
|
||||
Search,
|
||||
Settings2,
|
||||
SquareCheckBig,
|
||||
@@ -193,8 +194,9 @@
|
||||
<div class="brand-sub">ops workspace</div>
|
||||
</div>
|
||||
</a>
|
||||
<button class="command-trigger mobile" onclick={onOpenCommand}>
|
||||
<Search size={15} strokeWidth={1.8} />
|
||||
<button class="command-trigger mobile" onclick={onOpenCommand} aria-label="Open assistant">
|
||||
<MessageSquare size={15} strokeWidth={1.8} />
|
||||
<span>AI</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@@ -220,7 +222,8 @@
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="mobile-nav-bottom">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="mobile-nav-upload" ondragover={(e) => e.preventDefault()} ondrop={onRailDrop}>
|
||||
<div class="rail-upload-row">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="paste-box" contenteditable="true" onpaste={onPasteBox} role="textbox" tabindex="0">
|
||||
@@ -230,10 +233,6 @@
|
||||
<Upload size={13} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
<div class="rail-date">
|
||||
<CalendarDays size={14} strokeWidth={1.8} />
|
||||
<span>{new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' }).format(new Date())}</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
@@ -250,13 +249,14 @@
|
||||
radial-gradient(circle at top left, rgba(214, 120, 58, 0.08), transparent 22%),
|
||||
radial-gradient(circle at 85% 10%, rgba(65, 91, 82, 0.12), transparent 20%),
|
||||
linear-gradient(180deg, #f5efe6 0%, #efe6da 48%, #ede7de 100%);
|
||||
background-color: #f5efe6;
|
||||
}
|
||||
|
||||
.shell {
|
||||
--shell-ink: #1e1812;
|
||||
--shell-muted: #6b6256;
|
||||
--shell-line: rgba(35, 26, 17, 0.11);
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
grid-template-columns: 270px minmax(0, 1fr);
|
||||
position: relative;
|
||||
@@ -525,10 +525,17 @@
|
||||
}
|
||||
|
||||
.command-trigger.mobile {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
padding: 0 12px;
|
||||
gap: 6px;
|
||||
font-weight: 700;
|
||||
color: var(--shell-ink);
|
||||
}
|
||||
|
||||
.command-trigger.mobile span {
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.mobile-menu-btn {
|
||||
@@ -557,33 +564,15 @@
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: min(300px, 82vw);
|
||||
padding: 16px 14px;
|
||||
padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px));
|
||||
width: min(320px, 86vw);
|
||||
padding: 18px 16px;
|
||||
background: linear-gradient(180deg, rgba(250, 246, 239, 0.96), rgba(244, 237, 228, 0.94));
|
||||
border-right: 1px solid var(--shell-line);
|
||||
backdrop-filter: blur(20px);
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.mobile-nav-list {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.mobile-nav-list a {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.mobile-nav-bottom {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--shell-line);
|
||||
flex-shrink: 0;
|
||||
align-content: start;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.mobile-nav-head {
|
||||
@@ -634,5 +623,11 @@
|
||||
.mobile-menu-btn {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.mobile-nav-upload {
|
||||
margin-top: auto;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--shell-line);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onDestroy, tick } from 'svelte';
|
||||
import {
|
||||
Home, Heart, Briefcase, Plane, Moon, Server, Truck, Printer, FileText,
|
||||
Folder as FolderIcon, BookOpen, Tag as TagIcon, Star, Globe, Layers, Archive
|
||||
} from '@lucide/svelte';
|
||||
|
||||
import { onDestroy } from 'svelte';
|
||||
import PdfInlinePreview from '$lib/components/trips/PdfInlinePreview.svelte';
|
||||
const ICON_MAP: Record<string, any> = {
|
||||
'home': Home, 'heart': Heart, 'briefcase': Briefcase, 'plane': Plane,
|
||||
'moon': Moon, 'server': Server, 'truck': Truck, 'printer': Printer,
|
||||
'file-text': FileText, 'folder': FolderIcon, 'book-open': BookOpen,
|
||||
'tag': TagIcon, 'star': Star, 'globe': Globe, 'layers': Layers, 'archive': Archive,
|
||||
};
|
||||
|
||||
function getFolderColor(folderName: string | null): string | null {
|
||||
if (!folderName) return null;
|
||||
const f = sidebarFolders.find(f => f.name === folderName);
|
||||
return f?.color || null;
|
||||
}
|
||||
|
||||
interface BrainItem {
|
||||
id: string;
|
||||
@@ -23,6 +38,17 @@
|
||||
assets: { id: string; asset_type: string; filename: string; content_type: string | null }[];
|
||||
}
|
||||
|
||||
interface BrainAddition {
|
||||
id: string;
|
||||
item_id: string;
|
||||
source: string;
|
||||
kind: string;
|
||||
content: string;
|
||||
metadata_json?: any;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface SidebarFolder { id: string; name: string; slug: string; color?: string; icon?: string; is_active: boolean; item_count: number; }
|
||||
interface SidebarTag { id: string; name: string; slug: string; color?: string; icon?: string; is_active: boolean; item_count: number; }
|
||||
|
||||
@@ -35,6 +61,8 @@
|
||||
let activeTagId = $state<string | null>(null);
|
||||
let searchQuery = $state('');
|
||||
let searching = $state(false);
|
||||
type SortMode = 'discover' | 'newest' | 'oldest';
|
||||
let sortMode = $state<SortMode>('discover');
|
||||
|
||||
// Sidebar
|
||||
let sidebarFolders = $state<SidebarFolder[]>([]);
|
||||
@@ -52,8 +80,116 @@
|
||||
|
||||
// Detail
|
||||
let selectedItem = $state<BrainItem | null>(null);
|
||||
let selectedAdditions = $state<BrainAddition[]>([]);
|
||||
let editingNote = $state(false);
|
||||
let editNoteContent = $state('');
|
||||
let editingTags = $state(false);
|
||||
let editingFolder = $state(false);
|
||||
let tagInput = $state('');
|
||||
|
||||
async function addTagToItem(tag: string) {
|
||||
if (!selectedItem || !tag.trim()) return;
|
||||
const newTags = [...(selectedItem.tags || []), tag.trim()];
|
||||
if (selectedItem.tags?.includes(tag.trim())) return;
|
||||
try {
|
||||
const updated = await api(`/items/${selectedItem.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tags: newTags }),
|
||||
});
|
||||
selectedItem = { ...selectedItem, tags: updated.tags };
|
||||
tagInput = '';
|
||||
await loadSidebar();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function removeTagFromItem(tag: string) {
|
||||
if (!selectedItem) return;
|
||||
const newTags = (selectedItem.tags || []).filter(t => t !== tag);
|
||||
try {
|
||||
const updated = await api(`/items/${selectedItem.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tags: newTags }),
|
||||
});
|
||||
selectedItem = { ...selectedItem, tags: updated.tags };
|
||||
await loadSidebar();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function changeItemFolder(folderName: string) {
|
||||
if (!selectedItem) return;
|
||||
try {
|
||||
const updated = await api(`/items/${selectedItem.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ folder: folderName }),
|
||||
});
|
||||
selectedItem = { ...selectedItem, folder: updated.folder };
|
||||
editingFolder = false;
|
||||
await loadSidebar();
|
||||
await loadItems();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadAdditions(itemId: string) {
|
||||
try {
|
||||
selectedAdditions = await api(`/items/${itemId}/additions`);
|
||||
} catch {
|
||||
selectedAdditions = [];
|
||||
}
|
||||
}
|
||||
|
||||
// PDF viewer
|
||||
let pdfViewerHost = $state<HTMLDivElement | null>(null);
|
||||
let pdfViewerLoading = $state(false);
|
||||
|
||||
async function renderPdfViewer(url: string) {
|
||||
pdfViewerLoading = true;
|
||||
await tick();
|
||||
if (!pdfViewerHost) { pdfViewerLoading = false; return; }
|
||||
pdfViewerHost.replaceChildren();
|
||||
try {
|
||||
const mod = await import('pdfjs-dist');
|
||||
const pdfjs = mod.default ?? mod;
|
||||
if (!pdfjs.GlobalWorkerOptions.workerSrc) {
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
|
||||
}
|
||||
const pdf = await pdfjs.getDocument({ url, withCredentials: true }).promise;
|
||||
const containerWidth = Math.max(280, pdfViewerHost.clientWidth - 32);
|
||||
const pixelRatio = Math.max(window.devicePixelRatio || 1, 1);
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
const page = await pdf.getPage(i);
|
||||
const baseVp = page.getViewport({ scale: 1 });
|
||||
const scale = containerWidth / baseVp.width;
|
||||
const vp = page.getViewport({ scale });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.floor(vp.width * pixelRatio);
|
||||
canvas.height = Math.floor(vp.height * pixelRatio);
|
||||
canvas.style.width = `${vp.width}px`;
|
||||
canvas.style.height = `${vp.height}px`;
|
||||
canvas.style.borderRadius = '8px';
|
||||
canvas.style.background = 'white';
|
||||
canvas.style.boxShadow = '0 2px 8px rgba(0,0,0,0.08)';
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
await page.render({ canvas, canvasContext: ctx, viewport: vp }).promise;
|
||||
}
|
||||
pdfViewerHost.appendChild(canvas);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('PDF render failed', err);
|
||||
if (pdfViewerHost) pdfViewerHost.innerHTML = '<div style="padding:24px;text-align:center;color:#8c3c2d;">Failed to load PDF</div>';
|
||||
} finally {
|
||||
pdfViewerLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function pdfAutoRender(node: HTMLDivElement, url: string) {
|
||||
renderPdfViewer(url);
|
||||
return { destroy() {} };
|
||||
}
|
||||
|
||||
async function api(path: string, opts: RequestInit = {}) {
|
||||
const res = await fetch(`/api/brain${path}`, { credentials: 'include', ...opts });
|
||||
@@ -61,6 +197,80 @@
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Discovery shuffle (weighted random, refreshes every 10 min) ──
|
||||
const SHUFFLE_TTL = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
function getShuffleSeed(): number {
|
||||
const STORAGE_KEY = 'brain_shuffle';
|
||||
try {
|
||||
const stored = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const { seed, ts } = JSON.parse(stored);
|
||||
if (Date.now() - ts < SHUFFLE_TTL) return seed;
|
||||
}
|
||||
} catch {}
|
||||
const seed = Math.floor(Math.random() * 2147483647);
|
||||
try { sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ seed, ts: Date.now() })); } catch {}
|
||||
return seed;
|
||||
}
|
||||
|
||||
function seededRandom(seed: number) {
|
||||
let s = seed;
|
||||
return () => {
|
||||
s = (s * 16807 + 0) % 2147483647;
|
||||
return s / 2147483647;
|
||||
};
|
||||
}
|
||||
|
||||
function weightedShuffle(allItems: BrainItem[]): BrainItem[] {
|
||||
if (allItems.length <= 1) return allItems;
|
||||
|
||||
const now = Date.now();
|
||||
const twoWeeksAgo = now - 14 * 24 * 60 * 60 * 1000;
|
||||
const recent: BrainItem[] = [];
|
||||
const older: BrainItem[] = [];
|
||||
|
||||
for (const item of allItems) {
|
||||
const created = new Date(item.created_at).getTime();
|
||||
if (created >= twoWeeksAgo) recent.push(item);
|
||||
else older.push(item);
|
||||
}
|
||||
|
||||
const rng = seededRandom(getShuffleSeed());
|
||||
|
||||
// Fisher-Yates with seeded RNG
|
||||
const shuffle = (arr: BrainItem[]) => {
|
||||
const a = [...arr];
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rng() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
};
|
||||
|
||||
const shuffledRecent = shuffle(recent);
|
||||
const shuffledOlder = shuffle(older);
|
||||
|
||||
// Interleave: ~30% recent, ~70% older (by ratio)
|
||||
const result: BrainItem[] = [];
|
||||
let ri = 0, oi = 0;
|
||||
while (ri < shuffledRecent.length || oi < shuffledOlder.length) {
|
||||
// Decide: pick recent or older based on target ratio
|
||||
const recentRatio = shuffledRecent.length / (shuffledRecent.length + shuffledOlder.length || 1);
|
||||
if (ri < shuffledRecent.length && (oi >= shuffledOlder.length || rng() < recentRatio)) {
|
||||
result.push(shuffledRecent[ri++]);
|
||||
} else if (oi < shuffledOlder.length) {
|
||||
result.push(shuffledOlder[oi++]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function isDefaultView(): boolean {
|
||||
return !activeFolder && !activeTag && !searchQuery.trim();
|
||||
}
|
||||
|
||||
async function loadSidebar() {
|
||||
try {
|
||||
const data = await api('/taxonomy/sidebar');
|
||||
@@ -73,12 +283,21 @@
|
||||
async function loadItems() {
|
||||
loading = true;
|
||||
try {
|
||||
const params = new URLSearchParams({ limit: '50' });
|
||||
const fetchLimit = sortMode === 'discover' ? '100' : '50';
|
||||
const params = new URLSearchParams({ limit: fetchLimit });
|
||||
if (activeFolder) params.set('folder', activeFolder);
|
||||
if (activeTag) params.set('tag', activeTag);
|
||||
const data = await api(`/items?${params}`);
|
||||
items = data.items || [];
|
||||
let fetched = data.items || [];
|
||||
total = data.total || 0;
|
||||
|
||||
if (sortMode === 'discover' && isDefaultView() && fetched.length > 1) {
|
||||
items = weightedShuffle(fetched);
|
||||
} else if (sortMode === 'oldest') {
|
||||
items = [...fetched].reverse();
|
||||
} else {
|
||||
items = fetched;
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
loading = false;
|
||||
}
|
||||
@@ -210,10 +429,37 @@
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
function setSelectedItem(item: BrainItem | null) {
|
||||
selectedItem = item;
|
||||
editingNote = false;
|
||||
editingFolder = false;
|
||||
tagInput = '';
|
||||
selectedAdditions = [];
|
||||
|
||||
if (typeof window === 'undefined') return;
|
||||
const url = new URL(window.location.href);
|
||||
if (item?.id) {
|
||||
url.searchParams.set('item', item.id);
|
||||
void loadAdditions(item.id);
|
||||
} else {
|
||||
url.searchParams.delete('item');
|
||||
}
|
||||
window.history.replaceState({}, '', url);
|
||||
}
|
||||
|
||||
function combinedNoteContent(item: BrainItem | null, additions: BrainAddition[]): string {
|
||||
if (!item) return '';
|
||||
const parts = [
|
||||
item.raw_content?.trim() || '',
|
||||
...additions.map((addition) => addition.content.trim()).filter(Boolean)
|
||||
].filter(Boolean);
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
async function deleteItem(id: string) {
|
||||
try {
|
||||
await api(`/items/${id}`, { method: 'DELETE' });
|
||||
selectedItem = null;
|
||||
setSelectedItem(null);
|
||||
await loadItems();
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
@@ -320,6 +566,14 @@
|
||||
|
||||
await loadSidebar();
|
||||
await loadItems();
|
||||
|
||||
const itemId = new URL(window.location.href).searchParams.get('item');
|
||||
if (itemId) {
|
||||
try {
|
||||
const item = await api(`/items/${itemId}`);
|
||||
setSelectedItem(item);
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(stopPolling);
|
||||
@@ -365,8 +619,16 @@
|
||||
{/if}
|
||||
<nav class="sidebar-nav">
|
||||
{#each sidebarFolders.filter(f => f.is_active) as folder}
|
||||
<button class="nav-item" class:active={activeFolder === folder.name} onclick={() => { activeFolder = folder.name; activeFolderId = folder.id; activeTag = null; activeTagId = null; mobileSidebarOpen = false; loadItems(); }}>
|
||||
{#if folder.color}<span class="nav-dot" style="background: {folder.color}"></span>{/if}
|
||||
<button class="nav-item folder-nav-item" class:active={activeFolder === folder.name}
|
||||
style={folder.color ? `--folder-color: ${folder.color}` : ''}
|
||||
onclick={() => { activeFolder = folder.name; activeFolderId = folder.id; activeTag = null; activeTagId = null; mobileSidebarOpen = false; loadItems(); }}>
|
||||
<span class="nav-icon" style={folder.color ? `color: ${folder.color}` : ''}>
|
||||
{#if folder.icon && ICON_MAP[folder.icon]}
|
||||
<svelte:component this={ICON_MAP[folder.icon]} size={15} strokeWidth={2} />
|
||||
{:else}
|
||||
<FolderIcon size={15} strokeWidth={2} />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="nav-label">{folder.name}</span>
|
||||
{#if showManage}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions --><span class="nav-delete" onclick={(e) => { e.stopPropagation(); if (confirm(`Delete "${folder.name}"? Items will be moved.`)) deleteTaxonomy(folder.id); }}>×</span>
|
||||
@@ -482,6 +744,20 @@
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="sort-bar">
|
||||
<button class="sort-btn" class:active={sortMode === 'discover'} onclick={() => { sortMode = 'discover'; loadItems(); }}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M2 18h6l4-12 4 12h6"/></svg>
|
||||
Discover
|
||||
</button>
|
||||
<button class="sort-btn" class:active={sortMode === 'newest'} onclick={() => { sortMode = 'newest'; loadItems(); }}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M12 5v14"/><path d="M19 12l-7 7-7-7"/></svg>
|
||||
Newest
|
||||
</button>
|
||||
<button class="sort-btn" class:active={sortMode === 'oldest'} onclick={() => { sortMode = 'oldest'; loadItems(); }}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M12 19V5"/><path d="M5 12l7-7 7 7"/></svg>
|
||||
Oldest
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Masonry card grid -->
|
||||
@@ -502,11 +778,15 @@
|
||||
{:else}
|
||||
<div class="masonry">
|
||||
{#each items as item (item.id)}
|
||||
<div class="card" class:is-note={item.type === 'note'} class:is-processing={item.processing_status !== 'ready'}>
|
||||
<div class="card" class:is-note={item.type === 'note'} class:is-processing={item.processing_status !== 'ready'}
|
||||
style={getFolderColor(item.folder) ? `--card-accent: ${getFolderColor(item.folder)}` : ''}>
|
||||
<!-- Thumbnail: screenshot for links/PDFs, original image for images -->
|
||||
{#if item.type === 'link' && item.assets?.some(a => a.asset_type === 'screenshot')}
|
||||
{#if item.type === 'link' && item.assets?.some(a => a.asset_type === 'screenshot' || a.asset_type === 'og_image')}
|
||||
{@const ogAsset = item.assets?.find(a => a.asset_type === 'og_image')}
|
||||
{@const ssAsset = item.assets?.find(a => a.asset_type === 'screenshot')}
|
||||
{@const thumbAsset = ogAsset || ssAsset}
|
||||
<a class="card-thumb" href={item.url} target="_blank" rel="noopener">
|
||||
<img src="/api/brain/storage/{item.id}/screenshot/screenshot.png" alt="" loading="lazy" />
|
||||
<img src="/api/brain/storage/{item.id}/{thumbAsset?.asset_type}/{thumbAsset?.filename}" alt="" loading="lazy" />
|
||||
{#if item.processing_status !== 'ready'}
|
||||
<div class="card-processing-overlay">
|
||||
<span class="processing-dot"></span>
|
||||
@@ -515,17 +795,18 @@
|
||||
{/if}
|
||||
</a>
|
||||
{:else if item.type === 'pdf' && item.assets?.some(a => a.asset_type === 'screenshot')}
|
||||
<button class="card-thumb" onclick={() => { selectedItem = item; editingNote = false; }}>
|
||||
<img src="/api/brain/storage/{item.id}/screenshot/screenshot.png" alt="" loading="lazy" />
|
||||
{@const pdfSs = item.assets.find(a => a.asset_type === 'screenshot')}
|
||||
<button class="card-thumb" onclick={() => setSelectedItem(item)}>
|
||||
<img src="/api/brain/storage/{item.id}/screenshot/{pdfSs?.filename}" alt="" loading="lazy" />
|
||||
<div class="card-type-badge">PDF</div>
|
||||
</button>
|
||||
{:else if item.type === 'image' && item.assets?.some(a => a.asset_type === 'original_upload')}
|
||||
{@const imgAsset = item.assets.find(a => a.asset_type === 'original_upload')}
|
||||
<button class="card-thumb" onclick={() => { selectedItem = item; editingNote = false; }}>
|
||||
<button class="card-thumb" onclick={() => setSelectedItem(item)}>
|
||||
<img src="/api/brain/storage/{item.id}/original_upload/{imgAsset?.filename}" alt="" loading="lazy" />
|
||||
</button>
|
||||
{:else if item.type === 'note'}
|
||||
<button class="card-note-body" onclick={() => { selectedItem = item; editingNote = false; }}>
|
||||
<button class="card-note-body" onclick={() => setSelectedItem(item)}>
|
||||
{(item.raw_content || '').slice(0, 200)}{(item.raw_content || '').length > 200 ? '...' : ''}
|
||||
</button>
|
||||
{:else if item.processing_status !== 'ready'}
|
||||
@@ -536,7 +817,7 @@
|
||||
{/if}
|
||||
|
||||
<!-- Card content — click opens detail -->
|
||||
<button class="card-content" onclick={() => { selectedItem = item; editingNote = false; }}>
|
||||
<button class="card-content" onclick={() => setSelectedItem(item)}>
|
||||
<div class="card-title">{item.title || 'Untitled'}</div>
|
||||
{#if item.url}
|
||||
<div class="card-domain">{(() => { try { return new URL(item.url).hostname; } catch { return ''; } })()}</div>
|
||||
@@ -559,7 +840,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
<div class="card-meta">
|
||||
{#if item.folder}<span class="card-folder">{item.folder}</span>{/if}
|
||||
{#if item.folder}<span class="card-folder" style={getFolderColor(item.folder) ? `color: ${getFolderColor(item.folder)}` : ''}>{item.folder}</span>{/if}
|
||||
<span class="card-date">{formatDate(item.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -575,18 +856,19 @@
|
||||
<!-- ═══ PDF/Image full-screen viewer ═══ -->
|
||||
{#if selectedItem && (selectedItem.type === 'pdf' || selectedItem.type === 'image')}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="viewer-overlay" onclick={(e) => { if (e.target === e.currentTarget) selectedItem = null; }} onkeydown={(e) => { if (e.key === 'Escape') selectedItem = null; }}>
|
||||
<div class="viewer-overlay" onclick={(e) => { if (e.target === e.currentTarget) setSelectedItem(null); }} onkeydown={(e) => { if (e.key === 'Escape') setSelectedItem(null); }}>
|
||||
<div class="viewer-layout">
|
||||
<!-- Main viewer area -->
|
||||
<div class="viewer-main">
|
||||
{#if selectedItem.type === 'pdf'}
|
||||
{@const pdfAsset = selectedItem.assets?.find(a => a.asset_type === 'original_upload')}
|
||||
{#if pdfAsset}
|
||||
<iframe
|
||||
src="/api/brain/storage/{selectedItem.id}/original_upload/{pdfAsset.filename}#zoom=page-fit"
|
||||
title={selectedItem.title || 'PDF'}
|
||||
class="viewer-iframe"
|
||||
></iframe>
|
||||
<div class="viewer-pdf-wrap" bind:this={pdfViewerHost}
|
||||
use:pdfAutoRender={`/api/brain/storage/${selectedItem.id}/original_upload/${pdfAsset.filename}`}>
|
||||
{#if pdfViewerLoading}
|
||||
<div class="viewer-pdf-loading">Rendering PDF...</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if selectedItem.type === 'image'}
|
||||
{@const imgAsset = selectedItem.assets?.find(a => a.asset_type === 'original_upload')}
|
||||
@@ -605,7 +887,7 @@
|
||||
<div class="detail-type">{selectedItem.type === 'pdf' ? 'PDF Document' : 'Image'}</div>
|
||||
<h2 class="detail-title">{selectedItem.title || 'Untitled'}</h2>
|
||||
</div>
|
||||
<button class="close-btn" onclick={() => selectedItem = null}>
|
||||
<button class="close-btn" onclick={() => setSelectedItem(null)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -614,16 +896,41 @@
|
||||
<div class="detail-summary">{selectedItem.summary}</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedItem.tags && selectedItem.tags.length > 0}
|
||||
<div class="detail-tags">
|
||||
{#each selectedItem.tags as tag}
|
||||
<button class="detail-tag" onclick={() => { selectedItem = null; activeTag = tag; activeFolder = null; loadItems(); }}>{tag}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="detail-tags">
|
||||
{#each (selectedItem.tags || []) as tag}
|
||||
<span class="detail-tag editable">
|
||||
{tag}
|
||||
<button class="tag-remove" onclick={() => removeTagFromItem(tag)}>×</button>
|
||||
</span>
|
||||
{/each}
|
||||
<span class="tag-add-wrap">
|
||||
<input class="tag-add-input" placeholder="+ tag" bind:value={tagInput}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' && tagInput.trim()) addTagToItem(tagInput); }}
|
||||
list="available-tags" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-meta-line">
|
||||
{#if selectedItem.folder}<span class="meta-folder-pill">{selectedItem.folder}</span>{/if}
|
||||
{#if editingFolder}
|
||||
<div class="folder-picker">
|
||||
{#each sidebarFolders.filter(f => f.is_active) as folder}
|
||||
<button class="folder-pick-btn" class:current={selectedItem.folder === folder.name}
|
||||
style={folder.color ? `border-color: ${folder.color}` : ''}
|
||||
onclick={() => changeItemFolder(folder.name)}>
|
||||
{#if folder.icon && ICON_MAP[folder.icon]}
|
||||
<svelte:component this={ICON_MAP[folder.icon]} size={13} strokeWidth={2} />
|
||||
{/if}
|
||||
{folder.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
{#if selectedItem.folder}
|
||||
<button class="meta-folder-pill clickable" style={getFolderColor(selectedItem.folder) ? `background: color-mix(in srgb, ${getFolderColor(selectedItem.folder)} 14%, transparent); color: ${getFolderColor(selectedItem.folder)}` : ''} onclick={() => editingFolder = true}>{selectedItem.folder}</button>
|
||||
{:else}
|
||||
<button class="meta-folder-pill clickable" onclick={() => editingFolder = true}>Set folder</button>
|
||||
{/if}
|
||||
{/if}
|
||||
<span>{formatDate(selectedItem.created_at)}</span>
|
||||
{#if selectedItem.metadata_json?.page_count}
|
||||
<span>{selectedItem.metadata_json.page_count} page{selectedItem.metadata_json.page_count !== 1 ? 's' : ''}</span>
|
||||
@@ -641,8 +948,8 @@
|
||||
{#if selectedItem.assets?.some(a => a.asset_type === 'original_upload')}
|
||||
<a class="action-btn" href="/api/brain/storage/{selectedItem.id}/original_upload/{selectedItem.assets.find(a => a.asset_type === 'original_upload')?.filename}" target="_blank" rel="noopener">Download</a>
|
||||
{/if}
|
||||
<button class="action-btn ghost" onclick={() => reprocessItem(selectedItem.id)}>Reclassify</button>
|
||||
<button class="action-btn ghost" onclick={() => { if (confirm('Delete this item?')) deleteItem(selectedItem.id); }}>Delete</button>
|
||||
<button class="action-btn ghost" onclick={() => selectedItem && reprocessItem(selectedItem.id)}>Reclassify</button>
|
||||
<button class="action-btn ghost" onclick={() => { if (selectedItem && confirm('Delete this item?')) deleteItem(selectedItem.id); }}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -651,21 +958,22 @@
|
||||
<!-- ═══ Detail sheet for links/notes ═══ -->
|
||||
{:else if selectedItem}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="detail-overlay" onclick={(e) => { if (e.target === e.currentTarget) selectedItem = null; }} onkeydown={(e) => { if (e.key === 'Escape') selectedItem = null; }}>
|
||||
<div class="detail-overlay" onclick={(e) => { if (e.target === e.currentTarget) setSelectedItem(null); }} onkeydown={(e) => { if (e.key === 'Escape') setSelectedItem(null); }}>
|
||||
<div class="detail-sheet">
|
||||
<div class="detail-header">
|
||||
<div>
|
||||
<div class="detail-type">{selectedItem.type}</div>
|
||||
<h2 class="detail-title">{selectedItem.title || 'Untitled'}</h2>
|
||||
</div>
|
||||
<button class="close-btn" onclick={() => selectedItem = null}>
|
||||
<button class="close-btn" onclick={() => setSelectedItem(null)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if selectedItem.type === 'link' && selectedItem.assets?.some(a => a.asset_type === 'screenshot')}
|
||||
{#if selectedItem.type === 'link' && selectedItem.assets?.some(a => a.asset_type === 'screenshot' || a.asset_type === 'og_image')}
|
||||
{@const detailImg = selectedItem.assets?.find(a => a.asset_type === 'og_image') || selectedItem.assets?.find(a => a.asset_type === 'screenshot')}
|
||||
<a class="detail-screenshot" href={selectedItem.url} target="_blank" rel="noopener">
|
||||
<img src="/api/brain/storage/{selectedItem.id}/screenshot/screenshot.png" alt="" />
|
||||
<img src="/api/brain/storage/{selectedItem.id}/{detailImg?.asset_type}/{detailImg?.filename}" alt="" />
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
@@ -691,22 +999,61 @@
|
||||
</div>
|
||||
{:else}
|
||||
<button class="content-body clickable" onclick={startEditNote}>
|
||||
{selectedItem.raw_content || 'Empty note — click to edit'}
|
||||
{combinedNoteContent(selectedItem, selectedAdditions) || 'Empty note — click to edit'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedItem.tags && selectedItem.tags.length > 0}
|
||||
<div class="detail-tags">
|
||||
{#each selectedItem.tags as tag}
|
||||
<button class="detail-tag" onclick={() => { selectedItem = null; activeTag = tag; activeFolder = null; loadItems(); }}>{tag}</button>
|
||||
{/each}
|
||||
{#if selectedItem.type !== 'note' && selectedAdditions.length > 0}
|
||||
<div class="detail-content">
|
||||
<div class="extracted-label">Notes</div>
|
||||
<div class="addition-list">
|
||||
{#each selectedAdditions as addition}
|
||||
<div class="addition-row">
|
||||
<div class="addition-body">{addition.content}</div>
|
||||
<div class="addition-meta">{formatDate(addition.created_at)}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="detail-tags">
|
||||
{#each (selectedItem.tags || []) as tag}
|
||||
<span class="detail-tag editable">
|
||||
{tag}
|
||||
<button class="tag-remove" onclick={() => removeTagFromItem(tag)}>×</button>
|
||||
</span>
|
||||
{/each}
|
||||
<span class="tag-add-wrap">
|
||||
<input class="tag-add-input" placeholder="+ tag" bind:value={tagInput}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' && tagInput.trim()) addTagToItem(tagInput); }}
|
||||
list="available-tags" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-meta-line">
|
||||
{#if selectedItem.folder}<span class="meta-folder-pill">{selectedItem.folder}</span>{/if}
|
||||
{#if editingFolder}
|
||||
<div class="folder-picker">
|
||||
{#each sidebarFolders.filter(f => f.is_active) as folder}
|
||||
<button class="folder-pick-btn" class:current={selectedItem.folder === folder.name}
|
||||
style={folder.color ? `border-color: ${folder.color}` : ''}
|
||||
onclick={() => changeItemFolder(folder.name)}>
|
||||
{#if folder.icon && ICON_MAP[folder.icon]}
|
||||
<svelte:component this={ICON_MAP[folder.icon]} size={13} strokeWidth={2} />
|
||||
{/if}
|
||||
{folder.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
{#if selectedItem.folder}
|
||||
<button class="meta-folder-pill clickable" style={getFolderColor(selectedItem.folder) ? `background: color-mix(in srgb, ${getFolderColor(selectedItem.folder)} 14%, transparent); color: ${getFolderColor(selectedItem.folder)}` : ''} onclick={() => editingFolder = true}>{selectedItem.folder}</button>
|
||||
{:else}
|
||||
<button class="meta-folder-pill clickable" onclick={() => editingFolder = true}>Set folder</button>
|
||||
{/if}
|
||||
{/if}
|
||||
<span>{formatDate(selectedItem.created_at)}</span>
|
||||
</div>
|
||||
|
||||
@@ -714,13 +1061,19 @@
|
||||
{#if selectedItem.url}
|
||||
<a class="action-btn" href={selectedItem.url} target="_blank" rel="noopener">Open original</a>
|
||||
{/if}
|
||||
<button class="action-btn ghost" onclick={() => reprocessItem(selectedItem.id)}>Reclassify</button>
|
||||
<button class="action-btn ghost" onclick={() => { if (confirm('Delete this item?')) deleteItem(selectedItem.id); }}>Delete</button>
|
||||
<button class="action-btn ghost" onclick={() => selectedItem && reprocessItem(selectedItem.id)}>Reclassify</button>
|
||||
<button class="action-btn ghost" onclick={() => { if (selectedItem && confirm('Delete this item?')) deleteItem(selectedItem.id); }}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<datalist id="available-tags">
|
||||
{#each sidebarTags.filter(t => t.is_active) as tag}
|
||||
<option value={tag.name}></option>
|
||||
{/each}
|
||||
</datalist>
|
||||
|
||||
<style>
|
||||
/* No .page wrapper — brain-layout is the root */
|
||||
|
||||
@@ -935,6 +1288,17 @@
|
||||
.nav-dot {
|
||||
width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
|
||||
}
|
||||
.nav-icon {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 22px; height: 22px; flex-shrink: 0;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--folder-color, #8c7b69) 12%, transparent);
|
||||
}
|
||||
.folder-nav-item.active {
|
||||
background: color-mix(in srgb, var(--folder-color, #8c7b69) 10%, rgba(255,248,241,0.9));
|
||||
border-left: 2px solid var(--folder-color, transparent);
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mobile-pills { display: flex; }
|
||||
@@ -963,6 +1327,37 @@
|
||||
|
||||
/* ═══ Search ═══ */
|
||||
.search-section { margin-bottom: 18px; }
|
||||
|
||||
.sort-bar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.sort-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(35,26,17,0.1);
|
||||
background: transparent;
|
||||
color: #8c7b69;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
font-family: var(--font);
|
||||
cursor: pointer;
|
||||
transition: all 160ms;
|
||||
}
|
||||
.sort-btn:hover {
|
||||
background: rgba(255,248,241,0.8);
|
||||
color: #5d5248;
|
||||
}
|
||||
.sort-btn.active {
|
||||
background: rgba(255,248,241,0.95);
|
||||
color: #1e1812;
|
||||
font-weight: 600;
|
||||
border-color: rgba(35,26,17,0.2);
|
||||
}
|
||||
.search-wrap { position: relative; }
|
||||
.search-icon { position: absolute; left: 16px; top: 50%; transform: translateY(-50%); width: 18px; height: 18px; color: #7f7365; pointer-events: none; }
|
||||
.search-input {
|
||||
@@ -996,10 +1391,17 @@
|
||||
text-align: left;
|
||||
transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease;
|
||||
}
|
||||
.card::before {
|
||||
content: '';
|
||||
display: block;
|
||||
height: 3px;
|
||||
background: var(--card-accent, transparent);
|
||||
border-radius: 20px 20px 0 0;
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 12px 32px rgba(42,30,19,0.08);
|
||||
border-color: rgba(35,26,17,0.14);
|
||||
border-color: var(--card-accent, rgba(35,26,17,0.14));
|
||||
}
|
||||
.card:active { transform: scale(0.985); }
|
||||
|
||||
@@ -1195,11 +1597,22 @@
|
||||
background: #f5f0ea;
|
||||
}
|
||||
|
||||
.viewer-iframe {
|
||||
.viewer-pdf-wrap {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: white;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
background: rgba(245,240,234,0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.viewer-pdf-loading {
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
color: rgba(90, 71, 54, 0.7);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.viewer-image-wrap {
|
||||
@@ -1326,6 +1739,34 @@
|
||||
white-space: pre-wrap; word-break: break-word;
|
||||
font-family: var(--mono);
|
||||
}
|
||||
.addition-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
background: rgba(255,255,255,0.5);
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(35,26,17,0.06);
|
||||
}
|
||||
.addition-row {
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid rgba(35,26,17,0.08);
|
||||
}
|
||||
.addition-row:last-child {
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
.addition-body {
|
||||
font-size: 0.94rem;
|
||||
color: #2c241d;
|
||||
line-height: 1.65;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.addition-meta {
|
||||
margin-top: 6px;
|
||||
font-size: 0.74rem;
|
||||
color: #8c7b69;
|
||||
}
|
||||
|
||||
.detail-meta-line {
|
||||
display: flex; gap: 12px; font-size: 0.8rem; color: #8c7b69;
|
||||
@@ -1334,7 +1775,7 @@
|
||||
|
||||
/* meta grid removed — folder/date shown inline */
|
||||
|
||||
.detail-tags { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px; }
|
||||
.detail-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 20px; align-items: center; }
|
||||
.detail-tag {
|
||||
background: rgba(35,26,17,0.06); padding: 4px 12px;
|
||||
border-radius: 999px; font-size: 0.85rem; color: #5d5248;
|
||||
@@ -1342,6 +1783,48 @@
|
||||
transition: background 160ms;
|
||||
}
|
||||
.detail-tag:hover { background: rgba(179,92,50,0.12); color: #1e1812; }
|
||||
.detail-tag.editable {
|
||||
display: inline-flex; align-items: center; gap: 4px; cursor: default;
|
||||
}
|
||||
.tag-remove {
|
||||
background: none; border: none; color: rgba(93,82,72,0.5);
|
||||
font-size: 0.9rem; cursor: pointer; padding: 0 2px; line-height: 1;
|
||||
transition: color 160ms;
|
||||
}
|
||||
.tag-remove:hover { color: #8f3928; }
|
||||
.tag-add-wrap { display: inline-flex; }
|
||||
.tag-add-input {
|
||||
width: 80px; padding: 4px 10px; border-radius: 999px;
|
||||
border: 1px dashed rgba(35,26,17,0.15); background: none;
|
||||
font-size: 0.82rem; color: #5d5248; font-family: var(--font);
|
||||
outline: none; transition: border-color 160ms, width 160ms;
|
||||
}
|
||||
.tag-add-input:focus { border-color: rgba(179,92,50,0.4); width: 120px; border-style: solid; }
|
||||
.tag-add-input::placeholder { color: rgba(93,82,72,0.4); }
|
||||
|
||||
.meta-folder-pill.clickable {
|
||||
cursor: pointer; border: none; font-family: var(--font); font-size: inherit;
|
||||
transition: opacity 160ms;
|
||||
}
|
||||
.meta-folder-pill.clickable:hover { opacity: 0.7; }
|
||||
|
||||
.folder-picker {
|
||||
display: flex; flex-wrap: wrap; gap: 6px;
|
||||
}
|
||||
.folder-pick-btn {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
padding: 4px 10px; border-radius: 999px;
|
||||
border: 1.5px solid rgba(35,26,17,0.12);
|
||||
background: rgba(255,252,248,0.8); color: #5d5248;
|
||||
font-size: 0.8rem; font-family: var(--font); font-weight: 500;
|
||||
cursor: pointer; transition: all 160ms;
|
||||
}
|
||||
.folder-pick-btn:hover { background: rgba(255,248,241,0.95); }
|
||||
.folder-pick-btn.current {
|
||||
font-weight: 700; color: #1e1812;
|
||||
background: rgba(255,248,241,0.95);
|
||||
box-shadow: inset 0 0 0 1px currentColor;
|
||||
}
|
||||
|
||||
.detail-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.action-btn {
|
||||
@@ -1385,8 +1868,8 @@
|
||||
.brain-layout { margin: 0; }
|
||||
.masonry { columns: 1; }
|
||||
.detail-sheet { width: 100%; padding: 20px; }
|
||||
.viewer-overlay { padding: 0; }
|
||||
.viewer-layout { grid-template-columns: 1fr; grid-template-rows: 55vh 1fr; width: 100%; height: 100vh; border-radius: 0; max-width: 100%; }
|
||||
.viewer-sidebar { max-height: none; border-left: none; border-top: 1px solid rgba(35,26,17,0.08); overflow-y: auto; }
|
||||
.viewer-overlay { padding: 10px; }
|
||||
.viewer-layout { grid-template-columns: 1fr; grid-template-rows: 1fr auto; width: 100%; height: 90vh; border-radius: 18px; }
|
||||
.viewer-sidebar { max-height: 40vh; border-left: none; border-top: 1px solid rgba(35,26,17,0.08); overflow-y: auto; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -531,6 +531,65 @@
|
||||
resolveError = '';
|
||||
}
|
||||
|
||||
// ── Add Food to Meal ──
|
||||
let addFoodItem = $state<FoodItem | null>(null);
|
||||
let addFoodDetail = $state<any>(null);
|
||||
let addQty = $state('1');
|
||||
let addUnit = $state('serving');
|
||||
let addMeal = $state<MealType>(guessCurrentMeal());
|
||||
let addingToMeal = $state(false);
|
||||
|
||||
function openAddToMeal(food: FoodItem) {
|
||||
addFoodItem = food;
|
||||
addQty = '1';
|
||||
addMeal = guessCurrentMeal();
|
||||
addUnit = 'serving';
|
||||
addFoodDetail = null;
|
||||
// Fetch full food data
|
||||
fetch(`/api/fitness/foods/${food.id}`, { credentials: 'include' })
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (data) {
|
||||
addFoodDetail = data;
|
||||
addUnit = data.base_unit || 'serving';
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function closeAddToMeal() {
|
||||
addFoodItem = null;
|
||||
addFoodDetail = null;
|
||||
}
|
||||
|
||||
async function confirmAddToMeal() {
|
||||
if (!addFoodItem) return;
|
||||
addingToMeal = true;
|
||||
try {
|
||||
const res = await fetch('/api/fitness/entries', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
food_id: addFoodItem.id,
|
||||
quantity: parseFloat(addQty) || 1,
|
||||
unit: addUnit,
|
||||
meal_type: addMeal,
|
||||
entry_date: selectedDate,
|
||||
entry_method: 'search',
|
||||
source: 'web'
|
||||
})
|
||||
});
|
||||
if (res.ok) {
|
||||
addFoodItem = null;
|
||||
addFoodDetail = null;
|
||||
activeTab = 'log';
|
||||
await loadDayData();
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
addingToMeal = false;
|
||||
}
|
||||
|
||||
// ── Food Edit/Delete ──
|
||||
let editingFood = $state<any>(null);
|
||||
let editFoodName = $state('');
|
||||
@@ -1038,34 +1097,34 @@
|
||||
<div class="macro-row">
|
||||
<div class="macro-item">
|
||||
<div class="macro-card-head">
|
||||
<span class="macro-name">Protein</span>
|
||||
<span class="macro-left">{macroLeft(totals.protein, goal.protein)}</span>
|
||||
<span class="macro-name">Fiber</span>
|
||||
<span class="macro-left">{macroLeft(totals.fiber, goal.fiber)}</span>
|
||||
</div>
|
||||
<div class="macro-bar-wrap">
|
||||
<div class="macro-bar-fill protein" style="width:{Math.min(totals.protein / goal.protein * 100, 100)}%"></div>
|
||||
<div class="macro-bar-fill fiber" style="width:{nutrientPercent(totals.fiber, goal.fiber)}%"></div>
|
||||
</div>
|
||||
<div class="macro-label">
|
||||
<span class="macro-current">{totals.protein}g</span>
|
||||
<span class="macro-target">/ {goal.protein}g</span>
|
||||
<span class="macro-current">{totals.fiber}g</span>
|
||||
<span class="macro-target">{goal.fiber > 0 ? `/ ${goal.fiber}g` : '/ tracking only'}</span>
|
||||
</div>
|
||||
<div class="macro-guidance">
|
||||
<div class="macro-instruction">{macroInstruction('protein', totals.protein, goal.protein, caloriesRemaining)}</div>
|
||||
<div class="macro-instruction">{extraInstruction('fiber', totals.fiber, goal.fiber)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="macro-item">
|
||||
<div class="macro-card-head">
|
||||
<span class="macro-name">Carbs</span>
|
||||
<span class="macro-left">{macroLeft(totals.carbs, goal.carbs)}</span>
|
||||
<span class="macro-name">Sugar</span>
|
||||
<span class="macro-left">{macroLeft(totals.sugar, goal.sugar)}</span>
|
||||
</div>
|
||||
<div class="macro-bar-wrap">
|
||||
<div class="macro-bar-fill carbs" style="width:{Math.min(totals.carbs / goal.carbs * 100, 100)}%"></div>
|
||||
<div class="macro-bar-fill sugar" style="width:{nutrientPercent(totals.sugar, goal.sugar)}%"></div>
|
||||
</div>
|
||||
<div class="macro-label">
|
||||
<span class="macro-current">{totals.carbs}g</span>
|
||||
<span class="macro-target">/ {goal.carbs}g</span>
|
||||
<span class="macro-current">{totals.sugar}g</span>
|
||||
<span class="macro-target">{goal.sugar > 0 ? `/ ${goal.sugar}g` : '/ tracking only'}</span>
|
||||
</div>
|
||||
<div class="macro-guidance">
|
||||
<div class="macro-instruction">{macroInstruction('carbs', totals.carbs, goal.carbs, caloriesRemaining)}</div>
|
||||
<div class="macro-instruction">{extraInstruction('sugar', totals.sugar, goal.sugar)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="macro-item">
|
||||
@@ -1091,34 +1150,34 @@
|
||||
<div class="extra-nutrients-row">
|
||||
<div class="macro-item extra-item sugar-item">
|
||||
<div class="macro-card-head">
|
||||
<span class="macro-name">Sugar</span>
|
||||
<span class="macro-left">{macroLeft(totals.sugar, goal.sugar)}</span>
|
||||
<span class="macro-name">Carbs</span>
|
||||
<span class="macro-left">{macroLeft(totals.carbs, goal.carbs)}</span>
|
||||
</div>
|
||||
<div class="macro-bar-wrap">
|
||||
<div class="macro-bar-fill sugar" style="width:{nutrientPercent(totals.sugar, goal.sugar)}%"></div>
|
||||
<div class="macro-bar-fill carbs" style="width:{Math.min(totals.carbs / goal.carbs * 100, 100)}%"></div>
|
||||
</div>
|
||||
<div class="macro-label">
|
||||
<span class="macro-current">{totals.sugar}g</span>
|
||||
<span class="macro-target">{goal.sugar > 0 ? `/ ${goal.sugar}g` : '/ tracking only'}</span>
|
||||
<span class="macro-current">{totals.carbs}g</span>
|
||||
<span class="macro-target">/ {goal.carbs}g</span>
|
||||
</div>
|
||||
<div class="macro-guidance">
|
||||
<div class="macro-instruction">{extraInstruction('sugar', totals.sugar, goal.sugar)}</div>
|
||||
<div class="macro-instruction">{macroInstruction('carbs', totals.carbs, goal.carbs, caloriesRemaining)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="macro-item extra-item fiber-item">
|
||||
<div class="macro-card-head">
|
||||
<span class="macro-name">Fiber</span>
|
||||
<span class="macro-left">{macroLeft(totals.fiber, goal.fiber)}</span>
|
||||
<span class="macro-name">Protein</span>
|
||||
<span class="macro-left">{macroLeft(totals.protein, goal.protein)}</span>
|
||||
</div>
|
||||
<div class="macro-bar-wrap">
|
||||
<div class="macro-bar-fill fiber" style="width:{nutrientPercent(totals.fiber, goal.fiber)}%"></div>
|
||||
<div class="macro-bar-fill protein" style="width:{Math.min(totals.protein / goal.protein * 100, 100)}%"></div>
|
||||
</div>
|
||||
<div class="macro-label">
|
||||
<span class="macro-current">{totals.fiber}g</span>
|
||||
<span class="macro-target">{goal.fiber > 0 ? `/ ${goal.fiber}g` : '/ tracking only'}</span>
|
||||
<span class="macro-current">{totals.protein}g</span>
|
||||
<span class="macro-target">/ {goal.protein}g</span>
|
||||
</div>
|
||||
<div class="macro-guidance">
|
||||
<div class="macro-instruction">{extraInstruction('fiber', totals.fiber, goal.fiber)}</div>
|
||||
<div class="macro-instruction">{macroInstruction('protein', totals.protein, goal.protein, caloriesRemaining)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1217,6 +1276,28 @@
|
||||
</div>
|
||||
{#if expandedEntry === entry.id}
|
||||
<div class="entry-actions">
|
||||
<div class="entry-nutrition-grid">
|
||||
<div class="entry-nutrient">
|
||||
<span class="entry-nutrient-label">Protein</span>
|
||||
<span class="entry-nutrient-value">{entry.protein}g</span>
|
||||
</div>
|
||||
<div class="entry-nutrient">
|
||||
<span class="entry-nutrient-label">Carbs</span>
|
||||
<span class="entry-nutrient-value">{entry.carbs}g</span>
|
||||
</div>
|
||||
<div class="entry-nutrient">
|
||||
<span class="entry-nutrient-label">Fat</span>
|
||||
<span class="entry-nutrient-value">{entry.fat}g</span>
|
||||
</div>
|
||||
<div class="entry-nutrient">
|
||||
<span class="entry-nutrient-label">Sugar</span>
|
||||
<span class="entry-nutrient-value">{entry.sugar}g</span>
|
||||
</div>
|
||||
<div class="entry-nutrient">
|
||||
<span class="entry-nutrient-label">Fiber</span>
|
||||
<span class="entry-nutrient-value">{entry.fiber}g</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="entry-qty-control">
|
||||
<input
|
||||
type="number"
|
||||
@@ -1276,19 +1357,22 @@
|
||||
<div class="list-card">
|
||||
{#each filteredFoods as food (food.id || `${food.name}-${food.info}-${food.calories}`)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="list-row" onclick={() => openFoodEdit(food)}>
|
||||
<div class="list-row-info">
|
||||
<div class="list-row">
|
||||
<div class="list-row-info" onclick={() => openFoodEdit(food)} style="cursor:pointer;flex:1;min-width:0;">
|
||||
<div class="list-row-name">
|
||||
{food.name}
|
||||
{#if food.favorite}
|
||||
<svg class="fav-icon" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="list-row-meta">{food.info}</div>
|
||||
<div class="list-row-meta">{food.info} · {food.calories} cal</div>
|
||||
</div>
|
||||
<div class="list-row-right">
|
||||
<span class="list-row-value">{food.calories} cal</span>
|
||||
<svg class="list-row-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
|
||||
<button class="log-btn" onclick={(e) => { e.stopPropagation(); openAddToMeal(food); }}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" width="14" height="14" style="display:inline;vertical-align:-2px;margin-right:4px;"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
Add
|
||||
</button>
|
||||
<svg class="list-row-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" onclick={() => openFoodEdit(food)} style="cursor:pointer;"><path d="M9 18l6-6-6-6"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -1419,6 +1503,61 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Add food to meal modal -->
|
||||
{#if addFoodItem}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="resolve-overlay" onclick={closeAddToMeal}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="resolve-modal" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="resolve-modal-header">
|
||||
<div class="resolve-modal-title">Add to log</div>
|
||||
<button class="resolve-modal-close" onclick={closeAddToMeal}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="resolve-modal-body">
|
||||
<div class="add-food-name">{addFoodItem.name}</div>
|
||||
<div class="add-food-base">{addFoodItem.calories} cal per 1 {addUnit}</div>
|
||||
|
||||
<div class="food-edit-row" style="margin-top:16px;">
|
||||
<div class="food-edit-field">
|
||||
<label class="food-edit-label">Quantity</label>
|
||||
<input class="food-edit-input" type="number" step="0.25" min="0.25" bind:value={addQty} />
|
||||
</div>
|
||||
<div class="food-edit-field">
|
||||
<label class="food-edit-label">Unit</label>
|
||||
<input class="food-edit-input" type="text" bind:value={addUnit} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="food-edit-field" style="margin-top:12px;">
|
||||
<label class="food-edit-label">Meal</label>
|
||||
<div class="meal-picker">
|
||||
{#each mealTypes as meal}
|
||||
<button class="meal-pick-btn" class:active={addMeal === meal} onclick={() => addMeal = meal}>
|
||||
{meal}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="add-to-meal-preview" style="margin-top:14px;">
|
||||
{Math.round((parseFloat(addQty) || 1) * (addFoodDetail?.calories_per_base ?? addFoodItem.calories))} cal
|
||||
· {Math.round((parseFloat(addQty) || 1) * (addFoodDetail?.protein_per_base ?? addFoodItem.protein ?? 0))}g protein
|
||||
· {Math.round((parseFloat(addQty) || 1) * (addFoodDetail?.carbs_per_base ?? addFoodItem.carbs ?? 0))}g carbs
|
||||
· {Math.round((parseFloat(addQty) || 1) * (addFoodDetail?.fat_per_base ?? addFoodItem.fat ?? 0))}g fat
|
||||
</div>
|
||||
</div>
|
||||
<div class="resolve-modal-footer">
|
||||
<button class="btn-secondary" onclick={closeAddToMeal}>Cancel</button>
|
||||
<button class="btn-primary" onclick={confirmAddToMeal} disabled={addingToMeal}>
|
||||
{addingToMeal ? 'Adding...' : 'Add to ' + addMeal}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Resolve confirmation modal -->
|
||||
{#if resolvedItems.length > 0}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
@@ -2424,18 +2563,48 @@
|
||||
}
|
||||
|
||||
.entry-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 12px 16px 0;
|
||||
}
|
||||
|
||||
.entry-nutrition-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.entry-nutrient {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 10px 10px 9px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255,255,255,0.68);
|
||||
border: 1px solid rgba(44,31,19,0.07);
|
||||
}
|
||||
|
||||
.entry-nutrient-label {
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--fitness-muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.entry-nutrient-value {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--fitness-text);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.entry-qty-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.entry-qty-input {
|
||||
@@ -2910,8 +3079,12 @@
|
||||
|
||||
.resolve-modal {
|
||||
width: min(520px, calc(100vw - 24px));
|
||||
max-height: calc(100vh - 48px);
|
||||
max-height: calc(100dvh - 48px);
|
||||
border-radius: 28px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: modalUp 180ms ease;
|
||||
}
|
||||
|
||||
@@ -2963,6 +3136,9 @@
|
||||
|
||||
.resolve-modal-body {
|
||||
padding: 20px 22px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.resolve-item + .resolve-item {
|
||||
@@ -3071,6 +3247,46 @@
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.meal-picker {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.meal-pick-btn {
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--fitness-line);
|
||||
background: var(--fitness-surface);
|
||||
font-size: 0.82rem;
|
||||
font-family: var(--font);
|
||||
color: var(--fitness-muted);
|
||||
cursor: pointer;
|
||||
text-transform: capitalize;
|
||||
transition: all 160ms;
|
||||
}
|
||||
.meal-pick-btn.active {
|
||||
background: var(--fitness-ink);
|
||||
color: white;
|
||||
border-color: var(--fitness-ink);
|
||||
}
|
||||
.add-food-name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--fitness-ink);
|
||||
}
|
||||
.add-food-base {
|
||||
font-size: 0.85rem;
|
||||
color: var(--fitness-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.add-to-meal-preview {
|
||||
font-size: 0.85rem;
|
||||
font-family: var(--mono);
|
||||
color: var(--fitness-muted);
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
background: var(--fitness-surface);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@@ -3302,6 +3518,14 @@
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.entry-nutrition-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.entry-qty-control {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.food-edit-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -29,16 +29,12 @@
|
||||
let scrollRAF: number | null = null;
|
||||
let scrollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let lastScrollTs = 0;
|
||||
let scrollCarry = 0;
|
||||
let lastAutoCheckTs = 0;
|
||||
let mobileAutoStartScrollY = 0;
|
||||
let loading = $state(true);
|
||||
let loadingMore = $state(false);
|
||||
let hasMore = $state(true);
|
||||
let totalUnread = $state(0);
|
||||
const LIMIT = 50;
|
||||
let feedCounters: Record<string, number> = {};
|
||||
let stagedAutoReadIds = new Set<number>();
|
||||
|
||||
// ── Helpers ──
|
||||
function timeAgo(dateStr: string): string {
|
||||
@@ -173,65 +169,113 @@
|
||||
}
|
||||
|
||||
async function markAllReadAPI() {
|
||||
const ids = articles.filter(a => !a.read).map(a => a.id);
|
||||
if (!ids.length) return;
|
||||
try {
|
||||
await api('/entries', {
|
||||
const body: any = {};
|
||||
if (activeFeedId) body.feed_id = activeFeedId;
|
||||
await api('/entries/mark-all-read', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ entry_ids: ids, status: 'read' })
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
// ── Karakeep ──
|
||||
let karakeepIds = $state<Record<number, string>>({});
|
||||
let savingToKarakeep = $state<Set<number>>(new Set());
|
||||
// ── Feed management ──
|
||||
let showFeedManager = $state(false);
|
||||
let addFeedUrl = $state('');
|
||||
let addFeedCategoryId = $state<number | null>(null);
|
||||
let addingFeed = $state(false);
|
||||
|
||||
async function toggleKarakeep(article: Article, e?: Event) {
|
||||
async function addFeed() {
|
||||
if (!addFeedUrl.trim()) return;
|
||||
addingFeed = true;
|
||||
try {
|
||||
const body: any = { feed_url: addFeedUrl.trim() };
|
||||
if (addFeedCategoryId) body.category_id = addFeedCategoryId;
|
||||
await api('/feeds', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
addFeedUrl = '';
|
||||
await loadSidebar();
|
||||
} catch (e) {
|
||||
alert('Failed to add feed. Check the URL.');
|
||||
}
|
||||
addingFeed = false;
|
||||
}
|
||||
|
||||
async function deleteFeed(feedId: number, feedName: string) {
|
||||
if (!confirm(`Delete "${feedName}" and all its articles?`)) return;
|
||||
try {
|
||||
await api(`/feeds/${feedId}`, { method: 'DELETE' });
|
||||
if (activeFeedId === feedId) { activeFeedId = null; activeNav = 'Today'; }
|
||||
await loadSidebar();
|
||||
await loadEntries();
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
let refreshing = $state(false);
|
||||
|
||||
async function refreshFeed(feedId: number) {
|
||||
refreshing = true;
|
||||
try {
|
||||
await api(`/feeds/${feedId}/refresh`, { method: 'POST' });
|
||||
await loadSidebar();
|
||||
await loadEntries();
|
||||
} catch { /* silent */ }
|
||||
refreshing = false;
|
||||
}
|
||||
|
||||
async function refreshAllFeeds() {
|
||||
refreshing = true;
|
||||
try {
|
||||
await api('/feeds/refresh-all', { method: 'POST' });
|
||||
await loadSidebar();
|
||||
await loadEntries();
|
||||
} catch { /* silent */ }
|
||||
refreshing = false;
|
||||
}
|
||||
|
||||
// ── Save to Brain ──
|
||||
let brainSavedIds = $state<Record<number, string>>({});
|
||||
let savingToBrain = $state<Set<number>>(new Set());
|
||||
|
||||
async function toggleBrainSave(article: Article, e?: Event) {
|
||||
e?.stopPropagation();
|
||||
e?.preventDefault();
|
||||
if (savingToKarakeep.has(article.id)) return;
|
||||
if (savingToBrain.has(article.id)) return;
|
||||
const articleUrl = article.url || '';
|
||||
console.log('Karakeep: saving', article.id, articleUrl);
|
||||
if (!articleUrl && !karakeepIds[article.id]) {
|
||||
console.log('Karakeep: no URL, skipping');
|
||||
return;
|
||||
}
|
||||
savingToKarakeep = new Set([...savingToKarakeep, article.id]);
|
||||
if (!articleUrl && !brainSavedIds[article.id]) return;
|
||||
|
||||
savingToBrain = new Set([...savingToBrain, article.id]);
|
||||
try {
|
||||
if (karakeepIds[article.id]) {
|
||||
const res = await fetch('/api/karakeep/delete', {
|
||||
method: 'POST', credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: karakeepIds[article.id] })
|
||||
if (brainSavedIds[article.id]) {
|
||||
// Un-save: delete from Brain
|
||||
await fetch(`/api/brain/items/${brainSavedIds[article.id]}`, {
|
||||
method: 'DELETE', credentials: 'include',
|
||||
});
|
||||
console.log('Karakeep delete:', res.status);
|
||||
const next = { ...karakeepIds };
|
||||
const next = { ...brainSavedIds };
|
||||
delete next[article.id];
|
||||
karakeepIds = next;
|
||||
brainSavedIds = next;
|
||||
} else {
|
||||
const res = await fetch('/api/karakeep/save', {
|
||||
// Save to Brain
|
||||
const res = await fetch('/api/brain/items', {
|
||||
method: 'POST', credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: articleUrl })
|
||||
body: JSON.stringify({ type: 'link', url: articleUrl, title: article.title || undefined })
|
||||
});
|
||||
const text = await res.text();
|
||||
console.log('Karakeep save:', res.status, text);
|
||||
try {
|
||||
const data = JSON.parse(text);
|
||||
if (res.ok && data.ok) {
|
||||
karakeepIds = { ...karakeepIds, [article.id]: data.id };
|
||||
console.log('Karakeep: saved as', data.id);
|
||||
}
|
||||
} catch { console.error('Karakeep: bad JSON response'); }
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
brainSavedIds = { ...brainSavedIds, [article.id]: data.id };
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Karakeep error:', err);
|
||||
console.error('Brain save error:', err);
|
||||
} finally {
|
||||
const next = new Set(savingToKarakeep);
|
||||
const next = new Set(savingToBrain);
|
||||
next.delete(article.id);
|
||||
savingToKarakeep = next;
|
||||
savingToBrain = next;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,6 +305,26 @@
|
||||
markEntryRead(article.id);
|
||||
decrementUnread();
|
||||
}
|
||||
// Auto-fetch full article content if we only have RSS summary
|
||||
if (article.content && article.content.length < 1500) {
|
||||
fetchFullContent(article);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFullContent(article: Article) {
|
||||
try {
|
||||
const data = await api(`/entries/${article.id}/fetch-full-content`, { method: 'POST' });
|
||||
if (data.full_content || data.content) {
|
||||
const fullHtml = data.full_content || data.content;
|
||||
if (fullHtml.length > (article.content?.length || 0)) {
|
||||
article.content = fullHtml;
|
||||
if (selectedArticle?.id === article.id) {
|
||||
selectedArticle = { ...article };
|
||||
}
|
||||
articles = [...articles];
|
||||
}
|
||||
}
|
||||
} catch { /* silent — keep RSS summary */ }
|
||||
}
|
||||
|
||||
function closeArticle() { selectedArticle = null; }
|
||||
@@ -280,11 +344,18 @@
|
||||
else { markEntryUnread(article.id); totalUnread++; navItems[0].count = totalUnread; navItems = [...navItems]; }
|
||||
}
|
||||
|
||||
function markAllRead() {
|
||||
const unreadCount = articles.filter(a => !a.read).length;
|
||||
markAllReadAPI();
|
||||
async function markAllRead() {
|
||||
const label = activeFeedId
|
||||
? feedCategories.flatMap(c => c.feeds).find(f => f.id === activeFeedId)?.name || 'this feed'
|
||||
: 'all feeds';
|
||||
if (!confirm(`Mark all unread in ${label} as read?`)) return;
|
||||
await markAllReadAPI();
|
||||
articles = articles.map(a => ({ ...a, read: true }));
|
||||
decrementUnread(unreadCount);
|
||||
totalUnread = 0;
|
||||
navItems[0].count = 0;
|
||||
navItems = [...navItems];
|
||||
// Refresh counters
|
||||
await loadSidebar();
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
@@ -314,7 +385,7 @@
|
||||
|
||||
// ── Auto-scroll (requestAnimationFrame for smoothness) ──
|
||||
function usesPageScroll(): boolean {
|
||||
return window.matchMedia('(max-width: 1024px)').matches && !autoScrollActive;
|
||||
return window.matchMedia('(max-width: 1024px)').matches;
|
||||
}
|
||||
|
||||
function startAutoScroll() {
|
||||
@@ -323,20 +394,15 @@
|
||||
if (!usesPageScroll() && !articleListEl) return;
|
||||
autoScrollActive = true;
|
||||
lastScrollTs = 0;
|
||||
scrollCarry = 0;
|
||||
lastAutoCheckTs = 0;
|
||||
if (usesPageScroll()) {
|
||||
mobileAutoStartScrollY = window.scrollY;
|
||||
requestAnimationFrame(() => {
|
||||
articleListEl?.scrollTo({ top: mobileAutoStartScrollY, behavior: 'auto' });
|
||||
});
|
||||
scrollInterval = setInterval(() => {
|
||||
if (!autoScrollActive) return;
|
||||
if (!articleListEl) return;
|
||||
const scroller = document.scrollingElement;
|
||||
if (!scroller) return;
|
||||
const delta = Math.max(1, Math.round(4 * autoScrollSpeed));
|
||||
const nextY = articleListEl.scrollTop + delta;
|
||||
const maxY = Math.max(0, articleListEl.scrollHeight - articleListEl.clientHeight);
|
||||
articleListEl.scrollTop = Math.min(nextY, maxY);
|
||||
const nextY = scroller.scrollTop + delta;
|
||||
const maxY = Math.max(0, scroller.scrollHeight - window.innerHeight);
|
||||
scroller.scrollTop = Math.min(nextY, maxY);
|
||||
checkScrolledCards();
|
||||
if (nextY >= maxY - 1) {
|
||||
stopAutoScroll();
|
||||
@@ -349,17 +415,12 @@
|
||||
const dt = lastScrollTs ? Math.min(34, timestamp - lastScrollTs) : 16;
|
||||
lastScrollTs = timestamp;
|
||||
const pxPerSecond = 72 * autoScrollSpeed;
|
||||
scrollCarry += pxPerSecond * (dt / 1000);
|
||||
const delta = Math.max(1, Math.round(scrollCarry));
|
||||
scrollCarry -= delta;
|
||||
const delta = pxPerSecond * (dt / 1000);
|
||||
|
||||
if (!articleListEl) return;
|
||||
articleListEl.scrollTop += delta;
|
||||
const maxScroll = articleListEl.scrollHeight - articleListEl.clientHeight;
|
||||
if (!lastAutoCheckTs || timestamp - lastAutoCheckTs > 220) {
|
||||
lastAutoCheckTs = timestamp;
|
||||
checkScrolledCards();
|
||||
}
|
||||
checkScrolledCards();
|
||||
if (articleListEl.scrollTop >= maxScroll - 1) {
|
||||
stopAutoScroll();
|
||||
return;
|
||||
@@ -370,21 +431,10 @@
|
||||
scrollRAF = requestAnimationFrame(step);
|
||||
}
|
||||
function stopAutoScroll() {
|
||||
const restorePageScroll = window.matchMedia('(max-width: 1024px)').matches && articleListEl
|
||||
? articleListEl.scrollTop
|
||||
: null;
|
||||
autoScrollActive = false;
|
||||
lastScrollTs = 0;
|
||||
scrollCarry = 0;
|
||||
lastAutoCheckTs = 0;
|
||||
if (scrollRAF) { cancelAnimationFrame(scrollRAF); scrollRAF = null; }
|
||||
if (scrollInterval) { clearInterval(scrollInterval); scrollInterval = null; }
|
||||
commitStagedAutoReads();
|
||||
if (restorePageScroll !== null) {
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollTo({ top: restorePageScroll, behavior: 'auto' });
|
||||
});
|
||||
}
|
||||
}
|
||||
function toggleAutoScroll() {
|
||||
if (autoScrollActive) stopAutoScroll();
|
||||
@@ -431,9 +481,12 @@
|
||||
|
||||
const cards = articleListEl.querySelectorAll('[data-entry-id]');
|
||||
if (usesPageScroll()) {
|
||||
const pageBottom = window.scrollY + window.innerHeight;
|
||||
const loadThreshold = document.documentElement.scrollHeight - 500;
|
||||
if (hasMore && !loadingMore && pageBottom >= loadThreshold) {
|
||||
// Check if the last card is near the viewport bottom
|
||||
const lastCard = cards.length ? cards[cards.length - 1] : null;
|
||||
const nearBottom = lastCard
|
||||
? lastCard.getBoundingClientRect().top < window.innerHeight + 800
|
||||
: (window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 500);
|
||||
if (hasMore && !loadingMore && nearBottom) {
|
||||
loadEntries(true);
|
||||
}
|
||||
} else {
|
||||
@@ -455,45 +508,16 @@
|
||||
if (!id) return;
|
||||
const article = articles.find(a => a.id === id);
|
||||
if (article && !article.read) {
|
||||
if (autoScrollActive) {
|
||||
if (!stagedAutoReadIds.has(id)) {
|
||||
stagedAutoReadIds.add(id);
|
||||
newlyRead++;
|
||||
}
|
||||
} else {
|
||||
article.read = true;
|
||||
pendingReadIds.push(id);
|
||||
newlyRead++;
|
||||
}
|
||||
article.read = true;
|
||||
pendingReadIds.push(id);
|
||||
newlyRead++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (newlyRead > 0) {
|
||||
if (!autoScrollActive) {
|
||||
articles = [...articles];
|
||||
decrementUnread(newlyRead);
|
||||
if (flushTimer) clearTimeout(flushTimer);
|
||||
flushTimer = setTimeout(flushPendingReads, 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function commitStagedAutoReads() {
|
||||
if (!stagedAutoReadIds.size) return;
|
||||
const ids = [...stagedAutoReadIds];
|
||||
stagedAutoReadIds = new Set();
|
||||
let newlyRead = 0;
|
||||
articles = articles.map((article) => {
|
||||
if (ids.includes(article.id) && !article.read) {
|
||||
newlyRead++;
|
||||
return { ...article, read: true };
|
||||
}
|
||||
return article;
|
||||
});
|
||||
if (newlyRead > 0) {
|
||||
articles = [...articles];
|
||||
decrementUnread(newlyRead);
|
||||
pendingReadIds.push(...ids);
|
||||
if (flushTimer) clearTimeout(flushTimer);
|
||||
flushTimer = setTimeout(flushPendingReads, 1000);
|
||||
}
|
||||
@@ -520,7 +544,6 @@
|
||||
if (flushTimer) clearTimeout(flushTimer);
|
||||
if (scrollCheckTimer) clearTimeout(scrollCheckTimer);
|
||||
if (scrollInterval) clearInterval(scrollInterval);
|
||||
commitStagedAutoReads();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
@@ -566,7 +589,32 @@
|
||||
<div class="sidebar-separator"></div>
|
||||
|
||||
<div class="feeds-section">
|
||||
<div class="feeds-header">Feeds</div>
|
||||
<div class="feeds-header">
|
||||
<span>Feeds</span>
|
||||
<div class="feeds-header-actions">
|
||||
<button class="feeds-action-btn" title="Refresh all feeds" onclick={refreshAllFeeds}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/><path d="M16 16h5v5"/></svg>
|
||||
</button>
|
||||
<button class="feeds-action-btn" title={showFeedManager ? 'Done' : 'Manage feeds'} onclick={() => showFeedManager = !showFeedManager}>
|
||||
{#if showFeedManager}
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" width="13" height="13"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
{:else}
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showFeedManager}
|
||||
<div class="add-feed-row">
|
||||
<input class="add-feed-input" type="text" placeholder="Paste feed URL..." bind:value={addFeedUrl}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') addFeed(); }} />
|
||||
<button class="add-feed-btn" onclick={addFeed} disabled={addingFeed || !addFeedUrl.trim()}>
|
||||
{addingFeed ? '...' : '+'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each feedCategories as cat, i}
|
||||
<div class="feed-category">
|
||||
<button class="category-toggle" onclick={() => toggleCategory(i)}>
|
||||
@@ -577,12 +625,22 @@
|
||||
{#if cat.expanded}
|
||||
<div class="feed-list">
|
||||
{#each cat.feeds as feed}
|
||||
<button class="feed-item" onclick={() => selectFeed(feed.id || 0)}>
|
||||
<span class="feed-name">{feed.name}</span>
|
||||
{#if feed.count > 0}
|
||||
<span class="feed-count">{feed.count}</span>
|
||||
<div class="feed-item-row">
|
||||
<button class="feed-item" onclick={() => selectFeed(feed.id || 0)}>
|
||||
<span class="feed-name">{feed.name}</span>
|
||||
{#if feed.count > 0}
|
||||
<span class="feed-count">{feed.count}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if showFeedManager}
|
||||
<button class="feed-manage-btn" title="Refresh" onclick={(e) => { e.stopPropagation(); refreshFeed(feed.id || 0); }}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="11" height="11"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
|
||||
</button>
|
||||
<button class="feed-manage-btn feed-delete-btn" title="Delete" onclick={(e) => { e.stopPropagation(); deleteFeed(feed.id || 0, feed.name); }}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="11" height="11"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>
|
||||
</button>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -598,7 +656,7 @@
|
||||
{/if}
|
||||
|
||||
<!-- Middle Panel: Article List -->
|
||||
<div class="reader-list" class:auto-scrolling={autoScrollActive}>
|
||||
<div class="reader-list">
|
||||
<div class="list-header">
|
||||
<div class="list-header-top">
|
||||
<button class="mobile-menu" onclick={() => sidebarOpen = !sidebarOpen} aria-label="Toggle sidebar">
|
||||
@@ -656,7 +714,7 @@
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="article-list" class:auto-scrolling={autoScrollActive} bind:this={articleListEl} ontouchstart={handleScrollInterrupt} onwheel={handleScrollInterrupt} onscroll={handleListScroll}>
|
||||
<div class="article-list" bind:this={articleListEl} ontouchstart={handleScrollInterrupt} onwheel={handleScrollInterrupt} onscroll={handleListScroll}>
|
||||
{#each filteredArticles as article, index (article.id)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
@@ -677,11 +735,11 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="card-header-right">
|
||||
<button class="bookmark-btn" class:saved={!!karakeepIds[article.id]} onclick={(e) => toggleKarakeep(article, e)} title={karakeepIds[article.id] ? 'Saved to Karakeep' : 'Save to Karakeep'}>
|
||||
{#if savingToKarakeep.has(article.id)}
|
||||
<button class="bookmark-btn" class:saved={!!brainSavedIds[article.id]} onclick={(e) => toggleBrainSave(article, e)} title={brainSavedIds[article.id] ? 'Saved to Brain' : 'Save to Brain'}>
|
||||
{#if savingToBrain.has(article.id)}
|
||||
<svg class="spinning" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
|
||||
{:else}
|
||||
<svg viewBox="0 0 24 24" fill={karakeepIds[article.id] ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
|
||||
<svg viewBox="0 0 24 24" fill={brainSavedIds[article.id] ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
<span class="card-time">{article.timeAgo}</span>
|
||||
@@ -713,6 +771,11 @@
|
||||
{#if filteredArticles.length === 0}
|
||||
<div class="list-empty">No articles to show</div>
|
||||
{/if}
|
||||
{#if hasMore && !loading}
|
||||
<button class="load-more-btn" onclick={() => loadEntries(true)} disabled={loadingMore}>
|
||||
{loadingMore ? 'Loading...' : 'Load more articles'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -737,11 +800,11 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="pane-actions">
|
||||
<button class="pane-action-btn" class:saved-karakeep={!!karakeepIds[selectedArticle.id]} onclick={() => toggleKarakeep(selectedArticle!)} title={karakeepIds[selectedArticle.id] ? 'Saved to Karakeep' : 'Save to Karakeep'}>
|
||||
{#if savingToKarakeep.has(selectedArticle.id)}
|
||||
<button class="pane-action-btn" class:saved-brain={!!brainSavedIds[selectedArticle.id]} onclick={() => toggleBrainSave(selectedArticle!)} title={brainSavedIds[selectedArticle.id] ? 'Saved to Brain' : 'Save to Brain'}>
|
||||
{#if savingToBrain.has(selectedArticle.id)}
|
||||
<svg class="spinning" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
|
||||
{:else}
|
||||
<svg viewBox="0 0 24 24" fill={karakeepIds[selectedArticle.id] ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
|
||||
<svg viewBox="0 0 24 24" fill={brainSavedIds[selectedArticle.id] ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
<button class="pane-action-btn" onclick={() => toggleRead(selectedArticle!)} title="Toggle read (m)">
|
||||
@@ -842,7 +905,49 @@
|
||||
.sidebar-separator { height: 1px; background: rgba(35,26,17,0.08); margin: 12px 18px; }
|
||||
|
||||
.feeds-section { padding: 0 12px; flex: 1; }
|
||||
.feeds-header { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; color: #8a7a68; padding: 6px 12px 8px; }
|
||||
.feeds-header {
|
||||
font-size: 11px; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: 0.12em; color: #8a7a68; padding: 6px 12px 8px;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.feeds-header-actions { display: flex; gap: 2px; }
|
||||
.feeds-action-btn {
|
||||
width: 24px; height: 24px; border-radius: 6px; border: none;
|
||||
background: none; color: #8a7a68; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: all 150ms;
|
||||
}
|
||||
.feeds-action-btn:hover { background: rgba(255,248,241,0.8); color: #1e1812; }
|
||||
.add-feed-row {
|
||||
display: flex; gap: 4px; padding: 4px 12px 8px;
|
||||
}
|
||||
.add-feed-input {
|
||||
flex: 1; padding: 6px 10px; border-radius: 8px;
|
||||
border: 1px solid rgba(35,26,17,0.12); background: rgba(255,255,255,0.5);
|
||||
font-size: 12px; font-family: var(--font); color: #1e1812; outline: none;
|
||||
}
|
||||
.add-feed-input:focus { border-color: rgba(35,26,17,0.3); }
|
||||
.add-feed-input::placeholder { color: #8a7a68; }
|
||||
.add-feed-btn {
|
||||
width: 28px; height: 28px; border-radius: 8px; border: none;
|
||||
background: #1e1812; color: white; font-size: 14px; font-weight: 600;
|
||||
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||
transition: opacity 150ms;
|
||||
}
|
||||
.add-feed-btn:hover { opacity: 0.8; }
|
||||
.add-feed-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
.feed-item-row {
|
||||
display: flex; align-items: center;
|
||||
}
|
||||
.feed-item-row .feed-item { flex: 1; min-width: 0; }
|
||||
.feed-manage-btn {
|
||||
width: 22px; height: 22px; border-radius: 5px; border: none;
|
||||
background: none; color: #8a7a68; cursor: pointer; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: all 150ms;
|
||||
}
|
||||
.feed-manage-btn:hover { background: rgba(255,248,241,0.8); color: #1e1812; }
|
||||
.feed-delete-btn:hover { color: #8f3928; }
|
||||
.feed-category { margin-bottom: 1px; }
|
||||
.category-toggle {
|
||||
display: flex; align-items: center; gap: 5px; width: 100%;
|
||||
@@ -926,6 +1031,23 @@
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.load-more-btn {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
margin: 8px 0;
|
||||
border-radius: 14px;
|
||||
border: 1px dashed rgba(35,26,17,0.15);
|
||||
background: transparent;
|
||||
color: #8a7a68;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
font-family: var(--font);
|
||||
cursor: pointer;
|
||||
transition: all 160ms;
|
||||
}
|
||||
.load-more-btn:hover { background: rgba(255,248,241,0.7); color: #1e1812; }
|
||||
.load-more-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.mobile-scroll-fab {
|
||||
display: none;
|
||||
}
|
||||
@@ -1073,10 +1195,10 @@
|
||||
}
|
||||
.pane-action-btn:hover { color: #1f1811; background: rgba(255,250,244,0.92); }
|
||||
.pane-action-btn.active { color: var(--accent); }
|
||||
.pane-action-btn.saved-karakeep { color: #F59E0B; }
|
||||
.pane-action-btn.saved-brain { color: #F59E0B; }
|
||||
.pane-action-btn svg { width: 15px; height: 15px; }
|
||||
|
||||
.pane-content { max-width: 680px; margin: 0 auto; padding: 30px 36px 88px; }
|
||||
.pane-content { max-width: 680px; margin: 0 auto; padding: 30px 36px 88px; overflow-x: hidden; }
|
||||
.pane-hero { margin-bottom: 18px; }
|
||||
.pane-hero-image {
|
||||
width: 100%;
|
||||
@@ -1094,9 +1216,12 @@
|
||||
.pane-source { font-weight: 600; color: #1f1811; }
|
||||
.pane-author { font-style: italic; }
|
||||
.pane-dot { width: 3px; height: 3px; border-radius: 50%; background: #8a7a68; }
|
||||
.pane-body { font-size: 1.02rem; line-height: 1.9; color: #42372d; }
|
||||
.pane-body { font-size: 1.02rem; line-height: 1.9; color: #42372d; overflow-wrap: break-word; word-break: break-word; }
|
||||
.pane-body :global(p) { margin-bottom: 18px; }
|
||||
.pane-body :global(img) { max-width: 100%; height: auto; border-radius: var(--radius-md); margin: var(--sp-3) 0; }
|
||||
.pane-body :global(table) { max-width: 100%; overflow-x: auto; display: block; }
|
||||
.pane-body :global(iframe) { max-width: 100%; }
|
||||
.pane-body :global(*) { max-width: 100%; }
|
||||
.pane-body :global(a) { color: var(--accent); text-decoration: underline; }
|
||||
.pane-body :global(a:hover) { opacity: 0.8; }
|
||||
.pane-body :global(blockquote) { border-left: 3px solid var(--border); padding-left: var(--sp-4); margin: var(--sp-4) 0; color: var(--text-3); font-style: italic; }
|
||||
@@ -1142,7 +1267,7 @@
|
||||
position: relative;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
height: auto;
|
||||
min-height: calc(100vh - 56px);
|
||||
min-height: 100vh;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
@@ -1157,53 +1282,43 @@
|
||||
}
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
:global(.mobile-bar) {
|
||||
position: fixed;
|
||||
inset: 0 0 auto 0;
|
||||
background: transparent;
|
||||
border-bottom: none;
|
||||
backdrop-filter: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:global(.mobile-bar .mobile-menu-btn),
|
||||
:global(.mobile-bar .command-trigger.mobile),
|
||||
:global(.mobile-bar .mobile-brand) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.reader-sidebar { display: none; }
|
||||
.reader-sidebar.open { display: flex; position: fixed; left: 0; top: 56px; bottom: 0; z-index: 40; box-shadow: 8px 0 24px rgba(0,0,0,0.08); width: 260px; }
|
||||
.mobile-menu { display: flex; }
|
||||
.reader-list {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(245, 237, 227, 0.96) 0%, rgba(239, 230, 219, 0.94) 100%);
|
||||
background: transparent;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
}
|
||||
.list-header {
|
||||
padding: 14px 14px 10px;
|
||||
padding: 8px 14px 8px;
|
||||
position: relative;
|
||||
background: rgba(244, 236, 226, 0.92);
|
||||
backdrop-filter: blur(14px);
|
||||
border-bottom: 1px solid rgba(35,26,17,0.08);
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
.list-header-top {
|
||||
gap: 12px;
|
||||
}
|
||||
.reader-list.auto-scrolling {
|
||||
position: fixed;
|
||||
top: 56px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 18;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(245, 237, 227, 0.98) 0%, rgba(239, 230, 219, 0.98) 100%);
|
||||
}
|
||||
.reader-list.auto-scrolling .list-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 28;
|
||||
}
|
||||
.article-list.auto-scrolling {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-top: 6px;
|
||||
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 18px);
|
||||
}
|
||||
.list-view-name { font-size: 1.44rem; }
|
||||
.list-subtitle { font-size: 0.84rem; line-height: 1.4; }
|
||||
.article-list {
|
||||
padding: 6px 14px calc(env(safe-area-inset-bottom, 0px) + 18px);
|
||||
padding: 2px 14px calc(env(safe-area-inset-bottom, 0px) + 18px);
|
||||
gap: 8px;
|
||||
background: transparent;
|
||||
overflow: visible;
|
||||
|
||||
@@ -6,7 +6,7 @@ const gatewayUrl = env.GATEWAY_URL || 'http://localhost:8100';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ cookies, url }) => {
|
||||
const host = url.host.toLowerCase();
|
||||
const useAtelierShell = host.includes(':4174') || host.startsWith('test.');
|
||||
const useAtelierShell = true;
|
||||
const session = cookies.get('platform_session');
|
||||
if (!session) {
|
||||
throw redirect(302, `/login?redirect=${encodeURIComponent(url.pathname)}`);
|
||||
@@ -26,7 +26,7 @@ export const load: LayoutServerLoad = async ({ cookies, url }) => {
|
||||
// Hiding reduces clutter for users who don't need certain apps day-to-day.
|
||||
const allApps = ['tasks', 'trips', 'fitness', 'inventory', 'budget', 'reader', 'media', 'brain'];
|
||||
const hiddenByUser: Record<string, string[]> = {
|
||||
'madiha': ['inventory', 'reader'],
|
||||
'madiha': ['inventory', 'reader', 'brain'],
|
||||
};
|
||||
const hidden = hiddenByUser[data.user.username] || [];
|
||||
const visibleApps = allApps.filter(a => !hidden.includes(a));
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
let assistantEntryDate = $state<string | null>(null);
|
||||
const visibleApps = data?.visibleApps || ['trips', 'fitness', 'inventory', 'budget', 'reader', 'media'];
|
||||
const userName = data?.user?.display_name || '';
|
||||
const assistantBrainEnabled = data?.user?.username !== 'madiha';
|
||||
const useAtelierShell = data?.useAtelierShell || false;
|
||||
|
||||
function openCommand() {
|
||||
@@ -40,7 +41,7 @@
|
||||
<AppShell onOpenCommand={openCommand} {visibleApps} {userName}>
|
||||
{@render children()}
|
||||
</AppShell>
|
||||
<FitnessAssistantDrawer bind:open={commandOpen} onclose={closeCommand} entryDate={assistantEntryDate} />
|
||||
<FitnessAssistantDrawer bind:open={commandOpen} onclose={closeCommand} entryDate={assistantEntryDate} allowBrain={assistantBrainEnabled} />
|
||||
{:else}
|
||||
<div class="app">
|
||||
<Navbar onOpenCommand={openCommand} {visibleApps} />
|
||||
|
||||
140
frontend-v2/src/routes/assistant/+server.ts
Normal file
140
frontend-v2/src/routes/assistant/+server.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
type ChatRole = 'user' | 'assistant';
|
||||
|
||||
type ChatMessage = {
|
||||
role?: ChatRole;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
type UnifiedState = {
|
||||
activeDomain?: 'fitness' | 'brain';
|
||||
fitnessState?: Record<string, unknown>;
|
||||
brainState?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function recentMessages(messages: unknown): Array<{ role: ChatRole; content: string }> {
|
||||
if (!Array.isArray(messages)) return [];
|
||||
return messages
|
||||
.filter((message) => !!message && typeof message === 'object')
|
||||
.map((message) => {
|
||||
const item = message as ChatMessage;
|
||||
return {
|
||||
role: item.role === 'assistant' ? 'assistant' : 'user',
|
||||
content: typeof item.content === 'string' ? item.content : ''
|
||||
};
|
||||
})
|
||||
.filter((message) => message.content.trim())
|
||||
.slice(-16);
|
||||
}
|
||||
|
||||
function lastUserMessage(messages: Array<{ role: ChatRole; content: string }>): string {
|
||||
return [...messages].reverse().find((message) => message.role === 'user')?.content?.trim() || '';
|
||||
}
|
||||
|
||||
function isFitnessIntent(text: string): boolean {
|
||||
return /\b(calories?|protein|carbs?|fat|sugar|fiber|breakfast|lunch|dinner|snack|meal|food|ate|eaten|log|track|entries|macros?)\b/i.test(text)
|
||||
|| /\bfor (breakfast|lunch|dinner|snack)\b/i.test(text)
|
||||
|| /\bhow many calories do i have left\b/i.test(text);
|
||||
}
|
||||
|
||||
function isBrainIntent(text: string): boolean {
|
||||
return /\b(note|notes|brain|remember|save this|save that|what do i have|what have i saved|find my|delete (?:that|this|note|item)|update (?:that|this|note)|from my notes)\b/i.test(text);
|
||||
}
|
||||
|
||||
function detectDomain(
|
||||
messages: Array<{ role: ChatRole; content: string }>,
|
||||
state: UnifiedState,
|
||||
imageDataUrl?: string | null
|
||||
): 'fitness' | 'brain' {
|
||||
const text = lastUserMessage(messages);
|
||||
if (imageDataUrl) return 'fitness';
|
||||
if (!text) return state.activeDomain || 'brain';
|
||||
|
||||
const fitness = isFitnessIntent(text);
|
||||
const brain = isBrainIntent(text);
|
||||
|
||||
if (fitness && !brain) return 'fitness';
|
||||
if (brain && !fitness) return 'brain';
|
||||
|
||||
if (state.activeDomain === 'fitness' && !brain) return 'fitness';
|
||||
if (state.activeDomain === 'brain' && !fitness) return 'brain';
|
||||
|
||||
return fitness ? 'fitness' : 'brain';
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async ({ request, fetch, cookies }) => {
|
||||
if (!cookies.get('platform_session')) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { messages = [], state = {}, imageDataUrl = null, entryDate = null, action = 'chat', allowBrain = true } = await request
|
||||
.json()
|
||||
.catch(() => ({}));
|
||||
|
||||
let brainEnabled = !!allowBrain;
|
||||
try {
|
||||
const gatewayUrl = env.GATEWAY_URL || 'http://localhost:8100';
|
||||
const session = cookies.get('platform_session');
|
||||
if (session) {
|
||||
const auth = await fetch(`${gatewayUrl}/api/auth/me`, {
|
||||
headers: { Cookie: `platform_session=${session}` }
|
||||
});
|
||||
if (auth.ok) {
|
||||
const data = await auth.json().catch(() => null);
|
||||
if (data?.authenticated && data?.user?.username === 'madiha') {
|
||||
brainEnabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// keep requested allowBrain fallback
|
||||
}
|
||||
|
||||
const chat = recentMessages(messages);
|
||||
const unifiedState: UnifiedState = state && typeof state === 'object' ? state : {};
|
||||
const domain = brainEnabled ? detectDomain(chat, unifiedState, imageDataUrl) : 'fitness';
|
||||
|
||||
const response = await fetch(domain === 'fitness' ? '/assistant/fitness' : '/assistant/brain', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(
|
||||
domain === 'fitness'
|
||||
? {
|
||||
action,
|
||||
messages,
|
||||
draft: unifiedState.fitnessState && 'draft' in unifiedState.fitnessState ? unifiedState.fitnessState.draft : null,
|
||||
drafts: unifiedState.fitnessState && 'drafts' in unifiedState.fitnessState ? unifiedState.fitnessState.drafts : [],
|
||||
entryDate,
|
||||
imageDataUrl
|
||||
}
|
||||
: {
|
||||
messages,
|
||||
state: unifiedState.brainState || {}
|
||||
}
|
||||
)
|
||||
});
|
||||
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
return json(body, { status: response.status });
|
||||
}
|
||||
|
||||
return json({
|
||||
...body,
|
||||
domain,
|
||||
state: {
|
||||
activeDomain: domain,
|
||||
fitnessState:
|
||||
domain === 'fitness'
|
||||
? {
|
||||
draft: body?.draft ?? null,
|
||||
drafts: Array.isArray(body?.drafts) ? body.drafts : []
|
||||
}
|
||||
: unifiedState.fitnessState || {},
|
||||
brainState: domain === 'brain' ? body?.state || {} : unifiedState.brainState || {}
|
||||
}
|
||||
});
|
||||
};
|
||||
799
frontend-v2/src/routes/assistant/brain/+server.ts
Normal file
799
frontend-v2/src/routes/assistant/brain/+server.ts
Normal file
@@ -0,0 +1,799 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
type ChatRole = 'user' | 'assistant';
|
||||
|
||||
type ChatMessage = {
|
||||
role?: ChatRole;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
type BrainItem = {
|
||||
id: string;
|
||||
type: string;
|
||||
title?: string | null;
|
||||
url?: string | null;
|
||||
raw_content?: string | null;
|
||||
extracted_text?: string | null;
|
||||
folder?: string | null;
|
||||
tags?: string[] | null;
|
||||
summary?: string | null;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
type BrainAddition = {
|
||||
id: string;
|
||||
item_id: string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
type AssistantState = {
|
||||
lastMutation?: {
|
||||
type: 'append' | 'create' | 'update';
|
||||
itemId: string;
|
||||
itemTitle: string;
|
||||
additionId?: string;
|
||||
content: string;
|
||||
createdItemId?: string;
|
||||
previousRawContent?: string;
|
||||
};
|
||||
pendingDelete?: {
|
||||
itemId: string;
|
||||
itemTitle: string;
|
||||
};
|
||||
};
|
||||
|
||||
type SourceLink = {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
type SearchQueryDecision = {
|
||||
queries?: string[];
|
||||
};
|
||||
|
||||
function recentMessages(messages: unknown): Array<{ role: ChatRole; content: string }> {
|
||||
if (!Array.isArray(messages)) return [];
|
||||
return messages
|
||||
.filter((m) => !!m && typeof m === 'object')
|
||||
.map((m) => {
|
||||
const message = m as ChatMessage;
|
||||
return {
|
||||
role: (message.role === 'assistant' ? 'assistant' : 'user') as ChatRole,
|
||||
content: typeof message.content === 'string' ? message.content.slice(0, 4000) : ''
|
||||
};
|
||||
})
|
||||
.filter((m) => m.content.trim())
|
||||
.slice(-12);
|
||||
}
|
||||
|
||||
function lastUserMessage(messages: Array<{ role: ChatRole; content: string }>): string {
|
||||
return [...messages].reverse().find((message) => message.role === 'user')?.content?.trim() || '';
|
||||
}
|
||||
|
||||
function toSource(item: BrainItem): SourceLink {
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title || 'Untitled',
|
||||
type: item.type,
|
||||
href: `/brain?item=${item.id}`
|
||||
};
|
||||
}
|
||||
|
||||
function isConfirmation(text: string): boolean {
|
||||
const clean = text.trim().toLowerCase();
|
||||
return [
|
||||
'yes',
|
||||
'yes delete it',
|
||||
'delete it',
|
||||
'confirm',
|
||||
'yes do it',
|
||||
'do it',
|
||||
'go ahead'
|
||||
].includes(clean);
|
||||
}
|
||||
|
||||
function isUndo(text: string): boolean {
|
||||
return /^(undo|undo last change|undo that|revert that)$/i.test(text.trim());
|
||||
}
|
||||
|
||||
function wantsNewNoteInstead(text: string): boolean {
|
||||
return /create (?:a )?new note/i.test(text) || /make (?:that|it) a new note/i.test(text);
|
||||
}
|
||||
|
||||
function moveTargetFromText(text: string): string | null {
|
||||
const match = text.match(/(?:add|move)\s+(?:that|it)\s+to\s+(.+)$/i);
|
||||
return match?.[1]?.trim() || null;
|
||||
}
|
||||
|
||||
function wantsDelete(text: string): boolean {
|
||||
return /\bdelete\b/i.test(text) && /\b(note|item|that)\b/i.test(text);
|
||||
}
|
||||
|
||||
function isExplicitUpdateRequest(text: string): boolean {
|
||||
return /\b(update|edit|change|replace|correct|set)\b/i.test(text);
|
||||
}
|
||||
|
||||
function isListingIntent(text: string): boolean {
|
||||
return /\b(what|which|show|list)\b/i.test(text)
|
||||
&& /\b(do i have|have i saved|saved|notes|items|books?|pdfs?|links?|documents?|files?)\b/i.test(text);
|
||||
}
|
||||
|
||||
function buildCandidateSummary(items: BrainItem[]): string {
|
||||
if (!items.length) return 'No candidates found.';
|
||||
return items
|
||||
.map((item, index) => {
|
||||
const snippet = (item.raw_content || item.extracted_text || item.summary || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.slice(0, 220);
|
||||
return `${index + 1}. id=${item.id}
|
||||
title=${item.title || 'Untitled'}
|
||||
type=${item.type}
|
||||
folder=${item.folder || ''}
|
||||
tags=${(item.tags || []).join(', ')}
|
||||
snippet=${snippet}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
async function brainSearch(fetchFn: typeof fetch, q: string, limit = 5): Promise<BrainItem[]> {
|
||||
const response = await fetchFn('/api/brain/search/hybrid', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ q, limit })
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const body = await response.json().catch(() => ({}));
|
||||
return Array.isArray(body?.items) ? body.items : [];
|
||||
}
|
||||
|
||||
function normalizeSearchSeed(text: string): string {
|
||||
return text
|
||||
.trim()
|
||||
.replace(/^what\s+(books?|notes?|pdfs?|links?|documents?|files?)\s+do i have saved\s*/i, '$1 ')
|
||||
.replace(/^show me\s+(all\s+)?(books?|notes?|pdfs?|links?|documents?|files?)\s*/i, '$2 ')
|
||||
.replace(/^list\s+(my\s+)?(books?|notes?|pdfs?|links?|documents?|files?)\s*/i, '$2 ')
|
||||
.replace(/^add note\s+/i, '')
|
||||
.replace(/^add\s+/i, '')
|
||||
.replace(/^save\s+(?:this|that)\s+/i, '')
|
||||
.replace(/^what do i have about\s+/i, '')
|
||||
.replace(/^what have i saved about\s+/i, '')
|
||||
.replace(/^find\s+/i, '')
|
||||
.replace(/^search for\s+/i, '')
|
||||
.replace(/^answer\s+/i, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
async function deriveSearchQueries(
|
||||
messages: Array<{ role: ChatRole; content: string }>,
|
||||
userText: string
|
||||
): Promise<string[]> {
|
||||
const normalized = normalizeSearchSeed(userText);
|
||||
const fallback = [normalized || userText.trim()].filter(Boolean);
|
||||
if (!env.OPENAI_API_KEY) return fallback;
|
||||
|
||||
const systemPrompt = `You extract concise retrieval queries for a personal knowledge base.
|
||||
|
||||
Return ONLY JSON:
|
||||
{
|
||||
"queries": ["query one", "query two"]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Return 1 to 3 short search queries.
|
||||
- Focus on the underlying topic, not chat filler.
|
||||
- For "what do I have about X", include just X.
|
||||
- For advice/tips, you may infer a likely broader topic if strongly implied.
|
||||
- Keep each query short, usually 1 to 5 words.
|
||||
- Do not include punctuation-heavy sentences.
|
||||
- Do not include explanatory text.`;
|
||||
|
||||
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${env.OPENAI_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: env.OPENAI_MODEL || 'gpt-5.2',
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.1,
|
||||
max_completion_tokens: 200,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...messages.slice(-6),
|
||||
{ role: 'user', content: `Search intent: ${userText}` }
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) return fallback;
|
||||
const raw = await response.json().catch(() => null);
|
||||
const content = raw?.choices?.[0]?.message?.content;
|
||||
if (typeof content !== 'string') return fallback;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(content) as SearchQueryDecision;
|
||||
const queries = Array.isArray(parsed.queries)
|
||||
? parsed.queries.map((query) => String(query || '').trim()).filter(Boolean).slice(0, 3)
|
||||
: [];
|
||||
return queries.length ? queries : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async function collectCandidates(
|
||||
fetchFn: typeof fetch,
|
||||
messages: Array<{ role: ChatRole; content: string }>,
|
||||
userText: string,
|
||||
limit = 8
|
||||
): Promise<BrainItem[]> {
|
||||
const queries = await deriveSearchQueries(messages, userText);
|
||||
const merged = new Map<string, BrainItem>();
|
||||
|
||||
for (const query of queries) {
|
||||
const items = await brainSearch(fetchFn, query, limit);
|
||||
for (const item of items) {
|
||||
if (!merged.has(item.id)) merged.set(item.id, item);
|
||||
}
|
||||
}
|
||||
|
||||
if (!merged.size) {
|
||||
for (const item of await brainSearch(fetchFn, userText, limit)) {
|
||||
if (!merged.has(item.id)) merged.set(item.id, item);
|
||||
}
|
||||
}
|
||||
|
||||
return [...merged.values()].slice(0, limit);
|
||||
}
|
||||
|
||||
async function createBrainNote(fetchFn: typeof fetch, content: string, title?: string) {
|
||||
const response = await fetchFn('/api/brain/items', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'note',
|
||||
raw_content: content,
|
||||
title: title?.trim() || undefined
|
||||
})
|
||||
});
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
body: await response.json().catch(() => ({}))
|
||||
};
|
||||
}
|
||||
|
||||
async function updateBrainItem(fetchFn: typeof fetch, itemId: string, body: { raw_content?: string; title?: string }) {
|
||||
const response = await fetchFn(`/api/brain/items/${itemId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
body: await response.json().catch(() => ({}))
|
||||
};
|
||||
}
|
||||
|
||||
async function appendToItem(fetchFn: typeof fetch, itemId: string, content: string) {
|
||||
const response = await fetchFn(`/api/brain/items/${itemId}/additions`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ content, source: 'assistant', kind: 'append' })
|
||||
});
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
body: await response.json().catch(() => ({}))
|
||||
};
|
||||
}
|
||||
|
||||
async function deleteAddition(fetchFn: typeof fetch, itemId: string, additionId: string) {
|
||||
const response = await fetchFn(`/api/brain/items/${itemId}/additions/${additionId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
return response.ok;
|
||||
}
|
||||
|
||||
async function deleteItem(fetchFn: typeof fetch, itemId: string) {
|
||||
const response = await fetchFn(`/api/brain/items/${itemId}`, { method: 'DELETE' });
|
||||
return response.ok;
|
||||
}
|
||||
|
||||
async function getItem(fetchFn: typeof fetch, itemId: string): Promise<BrainItem | null> {
|
||||
const response = await fetchFn(`/api/brain/items/${itemId}`);
|
||||
if (!response.ok) return null;
|
||||
return response.json().catch(() => null);
|
||||
}
|
||||
|
||||
async function decideAction(
|
||||
messages: Array<{ role: ChatRole; content: string }>,
|
||||
candidates: BrainItem[],
|
||||
state: AssistantState,
|
||||
listingIntent = false
|
||||
) {
|
||||
const systemPrompt = `You are the Brain assistant inside a personal app.
|
||||
|
||||
You help the user save notes naturally, append thoughts to existing items, answer questions from saved notes, and choose whether to create a new note.
|
||||
|
||||
Return ONLY JSON with this shape:
|
||||
{
|
||||
"action": "append_existing" | "create_new_note" | "update_existing" | "answer" | "list_items" | "delete_target",
|
||||
"reply": "short reply preview",
|
||||
"target_item_id": "optional item id",
|
||||
"formatted_content": "text to append or create",
|
||||
"create_title": "short AI-generated title when creating a new note",
|
||||
"answer": "short answer when action=answer",
|
||||
"source_item_ids": ["id1", "id2"],
|
||||
"match_confidence": "high" | "low"
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Use existing items only when the topical match is strong.
|
||||
- If the match is weak or ambiguous, create a new note.
|
||||
- The user prefers speed: do not ask which note to use.
|
||||
- If the user explicitly says update, change, edit, replace, set, or correct, prefer update_existing when one strong existing note clearly matches.
|
||||
- If the user is asking to list what they have saved, prefer list_items instead of answer.
|
||||
- For short tips/advice, format as bullets when natural.
|
||||
- Only fix spelling and grammar. Do not rewrite the meaning.
|
||||
- For questions, answer briefly and cite up to 3 source ids.
|
||||
- For list_items, return a concise list-style reply and cite up to 12 source ids.
|
||||
- For delete requests, choose the most likely target and set action=delete_target.
|
||||
- Never choose append_existing unless target_item_id is one of the candidate ids.
|
||||
- Never choose update_existing unless target_item_id is one of the candidate ids.
|
||||
- If all candidates are weak, set action=create_new_note and match_confidence=low.
|
||||
- The current assistant state is only for context:
|
||||
${JSON.stringify(state, null, 2)}
|
||||
- listing_intent=${listingIntent}
|
||||
`;
|
||||
|
||||
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${env.OPENAI_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: env.OPENAI_MODEL || 'gpt-5.2',
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.2,
|
||||
max_completion_tokens: 1100,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...messages,
|
||||
{
|
||||
role: 'user',
|
||||
content: `Candidate items:\n${buildCandidateSummary(candidates)}`
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
|
||||
const raw = await response.json();
|
||||
const content = raw?.choices?.[0]?.message?.content;
|
||||
if (typeof content !== 'string') {
|
||||
throw new Error('Assistant response was empty.');
|
||||
}
|
||||
|
||||
return JSON.parse(content) as {
|
||||
action?: 'append_existing' | 'create_new_note' | 'update_existing' | 'answer' | 'list_items' | 'delete_target';
|
||||
reply?: string;
|
||||
target_item_id?: string;
|
||||
formatted_content?: string;
|
||||
create_title?: string;
|
||||
answer?: string;
|
||||
source_item_ids?: string[];
|
||||
match_confidence?: 'high' | 'low';
|
||||
};
|
||||
}
|
||||
|
||||
async function rewriteExistingItemContent(
|
||||
messages: Array<{ role: ChatRole; content: string }>,
|
||||
target: BrainItem,
|
||||
userInstruction: string
|
||||
): Promise<string> {
|
||||
const existing = (target.raw_content || target.extracted_text || '').trim();
|
||||
if (!existing) {
|
||||
throw new Error('Target item has no editable content.');
|
||||
}
|
||||
|
||||
const systemPrompt = `You edit an existing personal note in place.
|
||||
|
||||
Return ONLY JSON:
|
||||
{
|
||||
"updated_content": "full updated note body"
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Apply the user's requested update to the existing note.
|
||||
- Preserve the note's structure and wording as much as possible.
|
||||
- Make the smallest correct change needed.
|
||||
- Do not append a new line when the user clearly wants an existing value updated.
|
||||
- Only fix spelling and grammar where needed for the changed text.
|
||||
- Return the full updated note body, not a diff.`;
|
||||
|
||||
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${env.OPENAI_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: env.OPENAI_MODEL || 'gpt-5.2',
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.1,
|
||||
max_completion_tokens: 900,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...messages.slice(-8),
|
||||
{
|
||||
role: 'user',
|
||||
content: `Target title: ${target.title || 'Untitled'}\n\nExisting note:\n${existing}\n\nInstruction: ${userInstruction}`
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
|
||||
const raw = await response.json();
|
||||
const content = raw?.choices?.[0]?.message?.content;
|
||||
if (typeof content !== 'string') {
|
||||
throw new Error('Update response was empty.');
|
||||
}
|
||||
const parsed = JSON.parse(content) as { updated_content?: string };
|
||||
const updated = parsed.updated_content?.trim();
|
||||
if (!updated) {
|
||||
throw new Error('Updated content was empty.');
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
function sourceLinksFromIds(items: BrainItem[], ids: string[] | undefined): SourceLink[] {
|
||||
if (!Array.isArray(ids) || !ids.length) return [];
|
||||
const map = new Map(items.map((item) => [item.id, item]));
|
||||
return ids.map((id) => map.get(id)).filter(Boolean).map((item) => toSource(item as BrainItem));
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async ({ request, fetch, cookies }) => {
|
||||
if (!cookies.get('platform_session')) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
if (!env.OPENAI_API_KEY) {
|
||||
return json({ error: 'Assistant is not configured.' }, { status: 500 });
|
||||
}
|
||||
|
||||
const { messages = [], state = {} } = await request.json().catch(() => ({}));
|
||||
const chat = recentMessages(messages);
|
||||
const userText = lastUserMessage(chat);
|
||||
const currentState: AssistantState = state && typeof state === 'object' ? state : {};
|
||||
|
||||
if (!userText) {
|
||||
return json({
|
||||
reply: 'Tell me what to save, find, answer, or delete.',
|
||||
state: currentState,
|
||||
sources: []
|
||||
});
|
||||
}
|
||||
|
||||
if (currentState.pendingDelete && isConfirmation(userText)) {
|
||||
const target = currentState.pendingDelete;
|
||||
const ok = await deleteItem(fetch, target.itemId);
|
||||
if (!ok) {
|
||||
return json({ reply: `I couldn't delete "${target.itemTitle}" yet.`, state: currentState, sources: [] }, { status: 500 });
|
||||
}
|
||||
return json({
|
||||
reply: `Deleted "${target.itemTitle}".`,
|
||||
state: {},
|
||||
sources: []
|
||||
});
|
||||
}
|
||||
|
||||
if (currentState.lastMutation && isUndo(userText)) {
|
||||
if (currentState.lastMutation.type === 'append' && currentState.lastMutation.additionId) {
|
||||
const ok = await deleteAddition(fetch, currentState.lastMutation.itemId, currentState.lastMutation.additionId);
|
||||
return json({
|
||||
reply: ok ? `Undid the change in "${currentState.lastMutation.itemTitle}".` : 'I could not undo that change yet.',
|
||||
state: ok ? {} : currentState,
|
||||
sources: ok ? [{ id: currentState.lastMutation.itemId, title: currentState.lastMutation.itemTitle, type: 'note', href: `/brain?item=${currentState.lastMutation.itemId}` }] : []
|
||||
});
|
||||
}
|
||||
if (currentState.lastMutation.type === 'create' && currentState.lastMutation.createdItemId) {
|
||||
const ok = await deleteItem(fetch, currentState.lastMutation.createdItemId);
|
||||
return json({
|
||||
reply: ok ? `Removed "${currentState.lastMutation.itemTitle}".` : 'I could not undo that note creation yet.',
|
||||
state: ok ? {} : currentState,
|
||||
sources: []
|
||||
});
|
||||
}
|
||||
if (currentState.lastMutation.type === 'update' && currentState.lastMutation.previousRawContent !== undefined) {
|
||||
const restored = await updateBrainItem(fetch, currentState.lastMutation.itemId, {
|
||||
raw_content: currentState.lastMutation.previousRawContent
|
||||
});
|
||||
return json({
|
||||
reply: restored.ok ? `Undid the update in "${currentState.lastMutation.itemTitle}".` : 'I could not undo that update yet.',
|
||||
state: restored.ok ? {} : currentState,
|
||||
sources: restored.ok ? [{ id: currentState.lastMutation.itemId, title: currentState.lastMutation.itemTitle, type: 'note', href: `/brain?item=${currentState.lastMutation.itemId}` }] : []
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (currentState.lastMutation && wantsNewNoteInstead(userText)) {
|
||||
const content = currentState.lastMutation.content;
|
||||
if (currentState.lastMutation.type === 'append' && currentState.lastMutation.additionId) {
|
||||
await deleteAddition(fetch, currentState.lastMutation.itemId, currentState.lastMutation.additionId);
|
||||
}
|
||||
const titleDecision = await decideAction(
|
||||
[{ role: 'user', content: `Create a concise title for this note:\n${content}` }],
|
||||
[],
|
||||
currentState
|
||||
).catch(() => ({ create_title: 'New Note' }));
|
||||
const created = await createBrainNote(fetch, content, titleDecision.create_title);
|
||||
if (!created.ok) {
|
||||
return json({ reply: 'I could not create the new note yet.', state: currentState, sources: [] }, { status: 500 });
|
||||
}
|
||||
const createdItem = created.body as BrainItem;
|
||||
return json({
|
||||
reply: `Created "${createdItem.title || titleDecision.create_title || 'New note'}".`,
|
||||
state: {
|
||||
lastMutation: {
|
||||
type: 'create',
|
||||
itemId: createdItem.id,
|
||||
createdItemId: createdItem.id,
|
||||
itemTitle: createdItem.title || titleDecision.create_title || 'New note',
|
||||
content
|
||||
}
|
||||
},
|
||||
sources: [toSource(createdItem)]
|
||||
});
|
||||
}
|
||||
|
||||
const retarget = currentState.lastMutation ? moveTargetFromText(userText) : null;
|
||||
if (currentState.lastMutation && retarget) {
|
||||
if (currentState.lastMutation.type === 'append' && currentState.lastMutation.additionId) {
|
||||
await deleteAddition(fetch, currentState.lastMutation.itemId, currentState.lastMutation.additionId);
|
||||
} else if (currentState.lastMutation.type === 'create' && currentState.lastMutation.createdItemId) {
|
||||
await deleteItem(fetch, currentState.lastMutation.createdItemId);
|
||||
}
|
||||
|
||||
const candidates = await collectCandidates(fetch, chat, retarget, 8);
|
||||
const target = candidates[0];
|
||||
if (!target) {
|
||||
const created = await createBrainNote(fetch, currentState.lastMutation.content, retarget);
|
||||
if (!created.ok) {
|
||||
return json({ reply: 'I could not move that into a new note yet.', state: currentState, sources: [] }, { status: 500 });
|
||||
}
|
||||
const createdItem = created.body as BrainItem;
|
||||
return json({
|
||||
reply: `Created "${createdItem.title || retarget}".`,
|
||||
state: {
|
||||
lastMutation: {
|
||||
type: 'create',
|
||||
itemId: createdItem.id,
|
||||
createdItemId: createdItem.id,
|
||||
itemTitle: createdItem.title || retarget,
|
||||
content: currentState.lastMutation.content
|
||||
}
|
||||
},
|
||||
sources: [toSource(createdItem)]
|
||||
});
|
||||
}
|
||||
|
||||
const appended = await appendToItem(fetch, target.id, currentState.lastMutation.content);
|
||||
if (!appended.ok) {
|
||||
return json({ reply: `I couldn't move that to "${target.title || 'that item'}" yet.`, state: currentState, sources: [] }, { status: 500 });
|
||||
}
|
||||
return json({
|
||||
reply: `Moved it to "${target.title || 'Untitled'}".`,
|
||||
state: {
|
||||
lastMutation: {
|
||||
type: 'append',
|
||||
itemId: target.id,
|
||||
itemTitle: target.title || 'Untitled',
|
||||
additionId: appended.body?.id,
|
||||
content: currentState.lastMutation.content
|
||||
}
|
||||
},
|
||||
sources: [toSource(target)]
|
||||
});
|
||||
}
|
||||
|
||||
const listingIntent = isListingIntent(userText);
|
||||
const candidates = await collectCandidates(fetch, chat, userText, listingIntent ? 24 : 8);
|
||||
|
||||
if (isExplicitUpdateRequest(userText) && candidates[0] && (candidates[0].raw_content || candidates[0].extracted_text)) {
|
||||
const target = candidates[0];
|
||||
try {
|
||||
const updatedContent = await rewriteExistingItemContent(chat, target, userText);
|
||||
const updated = await updateBrainItem(fetch, target.id, { raw_content: updatedContent });
|
||||
if (!updated.ok) {
|
||||
return json({ reply: `I couldn't update "${target.title || 'Untitled'}" yet.`, state: currentState, sources: [] }, { status: 500 });
|
||||
}
|
||||
return json({
|
||||
reply: `Updated "${target.title || 'Untitled'}".`,
|
||||
state: {
|
||||
lastMutation: {
|
||||
type: 'update',
|
||||
itemId: target.id,
|
||||
itemTitle: target.title || 'Untitled',
|
||||
content: updatedContent,
|
||||
previousRawContent: target.raw_content || target.extracted_text || ''
|
||||
}
|
||||
},
|
||||
sources: [toSource(target)]
|
||||
});
|
||||
} catch (error) {
|
||||
return json(
|
||||
{
|
||||
reply: `I couldn't update "${target.title || 'Untitled'}" yet.`,
|
||||
state: currentState,
|
||||
sources: [toSource(target)],
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (wantsDelete(userText) && !currentState.pendingDelete) {
|
||||
const target = candidates[0];
|
||||
if (!target) {
|
||||
return json({ reply: "I couldn't find a note to delete.", state: currentState, sources: [] });
|
||||
}
|
||||
return json({
|
||||
reply: `Delete "${target.title || 'Untitled'}"?`,
|
||||
state: {
|
||||
...currentState,
|
||||
pendingDelete: {
|
||||
itemId: target.id,
|
||||
itemTitle: target.title || 'Untitled'
|
||||
}
|
||||
},
|
||||
sources: [toSource(target)]
|
||||
});
|
||||
}
|
||||
|
||||
let decision;
|
||||
try {
|
||||
decision = await decideAction(chat, candidates, currentState, listingIntent);
|
||||
} catch (error) {
|
||||
return json(
|
||||
{
|
||||
reply: 'The Brain assistant did not respond cleanly.',
|
||||
state: currentState,
|
||||
sources: [],
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
if (decision.action === 'answer') {
|
||||
return json({
|
||||
reply: decision.answer || decision.reply || 'I found a few relevant notes.',
|
||||
state: currentState,
|
||||
sources: sourceLinksFromIds(candidates, decision.source_item_ids)
|
||||
});
|
||||
}
|
||||
|
||||
if (decision.action === 'list_items') {
|
||||
return json({
|
||||
reply: decision.answer || decision.reply || 'Here are the matching items I found.',
|
||||
state: currentState,
|
||||
sources: sourceLinksFromIds(candidates, decision.source_item_ids)
|
||||
});
|
||||
}
|
||||
|
||||
if (decision.action === 'delete_target' && decision.target_item_id) {
|
||||
const target = candidates.find((item) => item.id === decision.target_item_id) || (await getItem(fetch, decision.target_item_id));
|
||||
if (!target) {
|
||||
return json({ reply: "I couldn't find that note to delete.", state: currentState, sources: [] });
|
||||
}
|
||||
return json({
|
||||
reply: `Delete "${target.title || 'Untitled'}"?`,
|
||||
state: {
|
||||
...currentState,
|
||||
pendingDelete: {
|
||||
itemId: target.id,
|
||||
itemTitle: target.title || 'Untitled'
|
||||
}
|
||||
},
|
||||
sources: [toSource(target)]
|
||||
});
|
||||
}
|
||||
|
||||
if (decision.action === 'update_existing' && decision.target_item_id && decision.match_confidence === 'high') {
|
||||
const target = candidates.find((item) => item.id === decision.target_item_id) || (await getItem(fetch, decision.target_item_id));
|
||||
if (target && (target.raw_content || target.extracted_text)) {
|
||||
try {
|
||||
const updatedContent = await rewriteExistingItemContent(chat, target, userText);
|
||||
const updated = await updateBrainItem(fetch, target.id, { raw_content: updatedContent });
|
||||
if (!updated.ok) {
|
||||
return json({ reply: `I couldn't update "${target.title || 'Untitled'}" yet.`, state: currentState, sources: [] }, { status: 500 });
|
||||
}
|
||||
return json({
|
||||
reply: `Updated "${target.title || 'Untitled'}".`,
|
||||
state: {
|
||||
lastMutation: {
|
||||
type: 'update',
|
||||
itemId: target.id,
|
||||
itemTitle: target.title || 'Untitled',
|
||||
content: updatedContent,
|
||||
previousRawContent: target.raw_content || target.extracted_text || ''
|
||||
}
|
||||
},
|
||||
sources: [toSource(target)]
|
||||
});
|
||||
} catch (error) {
|
||||
return json(
|
||||
{
|
||||
reply: `I couldn't update "${target.title || 'Untitled'}" yet.`,
|
||||
state: currentState,
|
||||
sources: [toSource(target)],
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const content = decision.formatted_content?.trim() || userText;
|
||||
|
||||
if (decision.action === 'append_existing' && decision.target_item_id && decision.match_confidence === 'high') {
|
||||
const target = candidates.find((item) => item.id === decision.target_item_id) || (await getItem(fetch, decision.target_item_id));
|
||||
if (!target) {
|
||||
// fall through to create below
|
||||
} else {
|
||||
const appended = await appendToItem(fetch, target.id, content);
|
||||
if (!appended.ok) {
|
||||
return json({ reply: `I couldn't add that to "${target.title || 'Untitled'}" yet.`, state: currentState, sources: [] }, { status: 500 });
|
||||
}
|
||||
return json({
|
||||
reply: `Added to "${target.title || 'Untitled'}".`,
|
||||
state: {
|
||||
lastMutation: {
|
||||
type: 'append',
|
||||
itemId: target.id,
|
||||
itemTitle: target.title || 'Untitled',
|
||||
additionId: appended.body?.id,
|
||||
content
|
||||
}
|
||||
},
|
||||
sources: [toSource(target)]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const created = await createBrainNote(fetch, content, decision.create_title);
|
||||
if (!created.ok) {
|
||||
return json({ reply: 'I could not create the note yet.', state: currentState, sources: [] }, { status: 500 });
|
||||
}
|
||||
const createdItem = created.body as BrainItem;
|
||||
return json({
|
||||
reply: `Created "${createdItem.title || decision.create_title || 'New note'}".`,
|
||||
state: {
|
||||
lastMutation: {
|
||||
type: 'create',
|
||||
itemId: createdItem.id,
|
||||
createdItemId: createdItem.id,
|
||||
itemTitle: createdItem.title || decision.create_title || 'New note',
|
||||
content
|
||||
}
|
||||
},
|
||||
sources: [toSource(createdItem)]
|
||||
});
|
||||
};
|
||||
21
frontend-v2/static/manifest.json
Normal file
21
frontend-v2/static/manifest.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "Platform",
|
||||
"short_name": "Platform",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f5efe6",
|
||||
"theme_color": "#f5efe6",
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,7 +3,7 @@ WORKDIR /app
|
||||
RUN pip install --no-cache-dir bcrypt
|
||||
RUN adduser --disabled-password --no-create-home appuser
|
||||
RUN mkdir -p /app/data && chown -R appuser /app/data
|
||||
COPY --chown=appuser server.py config.py database.py sessions.py proxy.py responses.py auth.py dashboard.py command.py ./
|
||||
COPY --chown=appuser server.py config.py database.py sessions.py proxy.py responses.py auth.py dashboard.py command.py assistant.py ./
|
||||
COPY --chown=appuser integrations/ ./integrations/
|
||||
EXPOSE 8100
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
1580
gateway/assistant.py
Normal file
1580
gateway/assistant.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ INVENTORY_URL = os.environ.get("INVENTORY_BACKEND_URL", "http://localhost:4499")
|
||||
NOCODB_API_TOKEN = os.environ.get("NOCODB_API_TOKEN", "")
|
||||
MINIFLUX_URL = os.environ.get("MINIFLUX_URL", "http://localhost:8767")
|
||||
MINIFLUX_API_KEY = os.environ.get("MINIFLUX_API_KEY", "")
|
||||
READER_URL = os.environ.get("READER_BACKEND_URL", "http://reader-api:8300")
|
||||
TRIPS_API_TOKEN = os.environ.get("TRIPS_API_TOKEN", "")
|
||||
SHELFMARK_URL = os.environ.get("SHELFMARK_URL", "http://shelfmark:8084")
|
||||
SPOTIZERR_URL = os.environ.get("SPOTIZERR_URL", "http://spotizerr-app:7171")
|
||||
|
||||
@@ -8,7 +8,7 @@ import bcrypt
|
||||
|
||||
from config import (
|
||||
DB_PATH, TRIPS_URL, FITNESS_URL, INVENTORY_URL,
|
||||
MINIFLUX_URL, SHELFMARK_URL, SPOTIZERR_URL, BUDGET_URL, TASKS_URL, BRAIN_URL,
|
||||
MINIFLUX_URL, READER_URL, SHELFMARK_URL, SPOTIZERR_URL, BUDGET_URL, TASKS_URL, BRAIN_URL,
|
||||
)
|
||||
|
||||
|
||||
@@ -94,9 +94,13 @@ def init_db():
|
||||
# Ensure reader app exists
|
||||
rdr = c.execute("SELECT id FROM apps WHERE id = 'reader'").fetchone()
|
||||
if not rdr:
|
||||
c.execute("INSERT INTO apps VALUES ('reader', 'Reader', 'rss', '/reader', ?, 4, 1, 'unread_count')", (MINIFLUX_URL,))
|
||||
c.execute("INSERT INTO apps VALUES ('reader', 'Reader', 'rss', '/reader', ?, 4, 1, 'unread_count')", (READER_URL,))
|
||||
conn.commit()
|
||||
print("[Gateway] Added reader app")
|
||||
else:
|
||||
# Update existing reader app to point to new service
|
||||
c.execute("UPDATE apps SET proxy_target = ? WHERE id = 'reader'", (READER_URL,))
|
||||
conn.commit()
|
||||
|
||||
# Ensure books app exists (now media)
|
||||
books = c.execute("SELECT id FROM apps WHERE id = 'books'").fetchone()
|
||||
|
||||
@@ -90,8 +90,15 @@ def handle_send_to_kindle(handler, book_id: str, body: bytes):
|
||||
title = meta.get("title", "Book") if meta else "Book"
|
||||
author = ", ".join(meta.get("authors", [])) if meta else ""
|
||||
|
||||
# Read file and encode as base64
|
||||
# Read file and check size
|
||||
file_data = file_path.read_bytes()
|
||||
size_mb = len(file_data) / (1024 * 1024)
|
||||
if size_mb > 25:
|
||||
handler._send_json({
|
||||
"error": f"File too large for email ({size_mb:.1f} MB). SMTP2GO limit is 25 MB. Use the Kindle app or USB instead.",
|
||||
"size_mb": round(size_mb, 1),
|
||||
}, 413)
|
||||
return
|
||||
file_b64 = base64.b64encode(file_data).decode("ascii")
|
||||
filename = file_path.name
|
||||
|
||||
@@ -133,7 +140,9 @@ def handle_send_to_kindle(handler, book_id: str, body: bytes):
|
||||
"size": len(file_data),
|
||||
})
|
||||
else:
|
||||
handler._send_json({"error": "Email send failed", "detail": result}, 500)
|
||||
failures = result.get("data", {}).get("failures", [])
|
||||
detail = failures[0] if failures else str(result)
|
||||
handler._send_json({"error": f"Email send failed: {detail}"}, 500)
|
||||
except Exception as e:
|
||||
handler._send_json({"error": f"SMTP2GO error: {str(e)}"}, 500)
|
||||
|
||||
@@ -175,6 +184,13 @@ def handle_send_file_to_kindle(handler, body: bytes):
|
||||
return
|
||||
|
||||
file_data = file_path.read_bytes()
|
||||
size_mb = len(file_data) / (1024 * 1024)
|
||||
if size_mb > 25:
|
||||
handler._send_json({
|
||||
"error": f"File too large for email ({size_mb:.1f} MB). SMTP2GO limit is 25 MB. Use the Kindle app or USB instead.",
|
||||
"size_mb": round(size_mb, 1),
|
||||
}, 413)
|
||||
return
|
||||
file_b64 = base64.b64encode(file_data).decode("ascii")
|
||||
|
||||
ext = file_path.suffix.lower()
|
||||
|
||||
@@ -56,7 +56,7 @@ def resolve_service(path):
|
||||
remainder = "/" + "/".join(parts[3:]) if len(parts) > 3 else "/"
|
||||
# Services that don't use /api prefix (Express apps, etc.)
|
||||
NO_API_PREFIX_SERVICES = {"inventory", "music", "budget"}
|
||||
SERVICE_PATH_PREFIX = {"reader": "/v1"}
|
||||
SERVICE_PATH_PREFIX = {}
|
||||
if service_id in SERVICE_PATH_PREFIX:
|
||||
backend_path = f"{SERVICE_PATH_PREFIX[service_id]}{remainder}"
|
||||
elif service_id in NO_API_PREFIX_SERVICES:
|
||||
|
||||
@@ -25,6 +25,7 @@ from dashboard import (
|
||||
handle_set_connection, handle_pin, handle_unpin, handle_get_pinned,
|
||||
)
|
||||
from command import handle_command
|
||||
from assistant import handle_assistant, handle_fitness_assistant, handle_brain_assistant
|
||||
from integrations.booklore import (
|
||||
handle_booklore_libraries, handle_booklore_import,
|
||||
handle_booklore_books, handle_booklore_cover,
|
||||
@@ -242,6 +243,24 @@ class GatewayHandler(ResponseMixin, BaseHTTPRequestHandler):
|
||||
handle_command(self, user, body)
|
||||
return
|
||||
|
||||
if path == "/api/assistant":
|
||||
user = self._require_auth()
|
||||
if user:
|
||||
handle_assistant(self, body, user)
|
||||
return
|
||||
|
||||
if path == "/api/assistant/fitness":
|
||||
user = self._require_auth()
|
||||
if user:
|
||||
handle_fitness_assistant(self, body, user)
|
||||
return
|
||||
|
||||
if path == "/api/assistant/brain":
|
||||
user = self._require_auth()
|
||||
if user:
|
||||
handle_brain_assistant(self, body, user)
|
||||
return
|
||||
|
||||
if path.startswith("/api/"):
|
||||
self._proxy("POST", path, body)
|
||||
return
|
||||
@@ -290,8 +309,10 @@ class GatewayHandler(ResponseMixin, BaseHTTPRequestHandler):
|
||||
headers["Content-Type"] = ct
|
||||
|
||||
# Inject service-level auth
|
||||
if service_id == "reader" and MINIFLUX_API_KEY:
|
||||
headers["X-Auth-Token"] = MINIFLUX_API_KEY
|
||||
if service_id == "reader":
|
||||
if user:
|
||||
headers["X-Gateway-User-Id"] = str(user["id"])
|
||||
headers["X-Gateway-User-Name"] = user.get("display_name", user.get("username", ""))
|
||||
elif service_id == "trips" and TRIPS_API_TOKEN:
|
||||
headers["Authorization"] = f"Bearer {TRIPS_API_TOKEN}"
|
||||
elif service_id == "inventory" and INVENTORY_SERVICE_API_KEY:
|
||||
|
||||
467
ios/Platform/Platform.xcodeproj/project.pbxproj
Normal file
467
ios/Platform/Platform.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,467 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 56;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
F0819A9915F94DECBA67FA89 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF8A4F2EEC3A4EB6B78AA13E /* Config.swift */; };
|
||||
DD3F166E894F43848CE426C7 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42807C65E9754543B46DBF62 /* ContentView.swift */; };
|
||||
C9B59C679DF04512BF9F5C69 /* PlatformApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47F2CEB00333491ABEE288C5 /* PlatformApp.swift */; };
|
||||
F35DB630BE0A46799AEC15A5 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63F8C700FA4E43818AA54E03 /* APIClient.swift */; };
|
||||
B20540C8E87F42C2AF4AB477 /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4094EAAB413433FA0D70AB9 /* AuthManager.swift */; };
|
||||
779FB7AAB0244CB68E5C69C8 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 879AEC4095F64FE7B851B6F9 /* LoginView.swift */; };
|
||||
FB0A7F362F5944C0BF0365C5 /* AssistantChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F36ACA2BA1243B0B8954E8F /* AssistantChatView.swift */; };
|
||||
45E92CD7F85A49BFA5C05F20 /* AssistantViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4073DF36CE74BB4A5B4F22A /* AssistantViewModel.swift */; };
|
||||
2BC13A589E944CE3A4C6B708 /* FitnessAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = E149FC023D964ADA86C96EB5 /* FitnessAPI.swift */; };
|
||||
F7F65E02FB974FACA76AFAF4 /* FitnessModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4DF9C7493C2402A8B8571DB /* FitnessModels.swift */; };
|
||||
CF63000DFF4F4F728281A31D /* FitnessRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D82B05FA8D84CA98E18C0E2 /* FitnessRepository.swift */; };
|
||||
400DCD1DE0534E9E856319F8 /* FoodSearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1822D8E4AFBF4B14A5E79050 /* FoodSearchViewModel.swift */; };
|
||||
96AFE69ACFB54D97A7C3A4A0 /* GoalsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCDED359C0EE4CC2986AE602 /* GoalsViewModel.swift */; };
|
||||
84CFFF27A99940B5875A65DA /* TemplatesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FED646258BA4DFA8D6078EC /* TemplatesViewModel.swift */; };
|
||||
03EBDF20EDF547EDB4B177CF /* TodayViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4F05CCCEE9A4E0FA6BC6795 /* TodayViewModel.swift */; };
|
||||
92E276F3CA8D4400827B2F99 /* AddFoodSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C60F0E61463489689BC84E3 /* AddFoodSheet.swift */; };
|
||||
C1D87BF5F61847BD952F5C65 /* EntryDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C3C34A418B5428A8B2A62B7 /* EntryDetailView.swift */; };
|
||||
E48F9E44708544D781FC8ACD /* FitnessTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D994E5BA694983BA293390 /* FitnessTabView.swift */; };
|
||||
2F5AF62DD13D4B0EB8E338A0 /* FoodLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27103840483E431EB0275752 /* FoodLibraryView.swift */; };
|
||||
6FE7AC0C375242398A5118B4 /* FoodSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A98379942DAA4FB992CE1A33 /* FoodSearchView.swift */; };
|
||||
8064B019D0B742749713A35E /* GoalsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A511884CFF6D40198C9A326B /* GoalsView.swift */; };
|
||||
060E0115D90647A593BCF8B7 /* MealSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EA9E7B0D02A4E13ADD734A4 /* MealSectionView.swift */; };
|
||||
7902B9F4789C4C6FB080AF0D /* TemplatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E59243273F494E9C1F63CB /* TemplatesView.swift */; };
|
||||
A74F6C2CDEF4487383684BB9 /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CE2113036D74BBA9D3DA571 /* TodayView.swift */; };
|
||||
75DB623876D54D0BAF32B59F /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE0067210C0C4833BEF98835 /* HomeView.swift */; };
|
||||
340D12BB6F234169953639F6 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F60B0BE1ED854A1F859C8B6F /* HomeViewModel.swift */; };
|
||||
D2CF33B6FE4142CA9995E125 /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D88C79EBC3A4E3791482B07 /* LoadingView.swift */; };
|
||||
CF9C74396EA449D2BDEBAA09 /* MacroBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16A32CB0269E4AF79A96B241 /* MacroBar.swift */; };
|
||||
C6D6C2D5882245A895C8B606 /* MacroRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF6CAF2179B48C6B338233C /* MacroRing.swift */; };
|
||||
86D08C3CC8B34AEDB1254EC1 /* Color+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C04E1FF020544D08EDD3CCA /* Color+Extensions.swift */; };
|
||||
C367EA266AED4AB7842B8A6F /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 929DC19B5C81454EB58087AA /* Date+Extensions.swift */; };
|
||||
F1AA651AF622471EA697E2CA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4C75844F44B444F4A8228158 /* Assets.xcassets */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
AF8A4F2EEC3A4EB6B78AA13E /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Config.swift"; sourceTree = "<group>"; };
|
||||
42807C65E9754543B46DBF62 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView.swift"; sourceTree = "<group>"; };
|
||||
47F2CEB00333491ABEE288C5 /* PlatformApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PlatformApp.swift"; sourceTree = "<group>"; };
|
||||
63F8C700FA4E43818AA54E03 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIClient.swift"; sourceTree = "<group>"; };
|
||||
E4094EAAB413433FA0D70AB9 /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AuthManager.swift"; sourceTree = "<group>"; };
|
||||
879AEC4095F64FE7B851B6F9 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LoginView.swift"; sourceTree = "<group>"; };
|
||||
5F36ACA2BA1243B0B8954E8F /* AssistantChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AssistantChatView.swift"; sourceTree = "<group>"; };
|
||||
F4073DF36CE74BB4A5B4F22A /* AssistantViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AssistantViewModel.swift"; sourceTree = "<group>"; };
|
||||
E149FC023D964ADA86C96EB5 /* FitnessAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FitnessAPI.swift"; sourceTree = "<group>"; };
|
||||
B4DF9C7493C2402A8B8571DB /* FitnessModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FitnessModels.swift"; sourceTree = "<group>"; };
|
||||
6D82B05FA8D84CA98E18C0E2 /* FitnessRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FitnessRepository.swift"; sourceTree = "<group>"; };
|
||||
1822D8E4AFBF4B14A5E79050 /* FoodSearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FoodSearchViewModel.swift"; sourceTree = "<group>"; };
|
||||
DCDED359C0EE4CC2986AE602 /* GoalsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GoalsViewModel.swift"; sourceTree = "<group>"; };
|
||||
4FED646258BA4DFA8D6078EC /* TemplatesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TemplatesViewModel.swift"; sourceTree = "<group>"; };
|
||||
B4F05CCCEE9A4E0FA6BC6795 /* TodayViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TodayViewModel.swift"; sourceTree = "<group>"; };
|
||||
8C60F0E61463489689BC84E3 /* AddFoodSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AddFoodSheet.swift"; sourceTree = "<group>"; };
|
||||
3C3C34A418B5428A8B2A62B7 /* EntryDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EntryDetailView.swift"; sourceTree = "<group>"; };
|
||||
A5D994E5BA694983BA293390 /* FitnessTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FitnessTabView.swift"; sourceTree = "<group>"; };
|
||||
27103840483E431EB0275752 /* FoodLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FoodLibraryView.swift"; sourceTree = "<group>"; };
|
||||
A98379942DAA4FB992CE1A33 /* FoodSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FoodSearchView.swift"; sourceTree = "<group>"; };
|
||||
A511884CFF6D40198C9A326B /* GoalsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GoalsView.swift"; sourceTree = "<group>"; };
|
||||
6EA9E7B0D02A4E13ADD734A4 /* MealSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MealSectionView.swift"; sourceTree = "<group>"; };
|
||||
D0E59243273F494E9C1F63CB /* TemplatesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TemplatesView.swift"; sourceTree = "<group>"; };
|
||||
3CE2113036D74BBA9D3DA571 /* TodayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TodayView.swift"; sourceTree = "<group>"; };
|
||||
FE0067210C0C4833BEF98835 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeView.swift"; sourceTree = "<group>"; };
|
||||
F60B0BE1ED854A1F859C8B6F /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeViewModel.swift"; sourceTree = "<group>"; };
|
||||
1D88C79EBC3A4E3791482B07 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LoadingView.swift"; sourceTree = "<group>"; };
|
||||
16A32CB0269E4AF79A96B241 /* MacroBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MacroBar.swift"; sourceTree = "<group>"; };
|
||||
FDF6CAF2179B48C6B338233C /* MacroRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MacroRing.swift"; sourceTree = "<group>"; };
|
||||
1C04E1FF020544D08EDD3CCA /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = "<group>"; };
|
||||
929DC19B5C81454EB58087AA /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = "<group>"; };
|
||||
4C75844F44B444F4A8228158 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
0FDDDCE767CF4BF6B6D41677 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
4B7D1D629553482DA83FE35D /* Platform.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Platform.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
037C8FC2A4954FCE91D25A60 /* Platform */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
029D94F090324446B082BA63 /* Platform */,
|
||||
B5E96950287B4399909152DA /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B5E96950287B4399909152DA /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4B7D1D629553482DA83FE35D /* Platform.app */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
029D94F090324446B082BA63 /* Platform */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AF8A4F2EEC3A4EB6B78AA13E /* Config.swift */,
|
||||
42807C65E9754543B46DBF62 /* ContentView.swift */,
|
||||
47F2CEB00333491ABEE288C5 /* PlatformApp.swift */,
|
||||
0FDDDCE767CF4BF6B6D41677 /* Info.plist */,
|
||||
4C75844F44B444F4A8228158 /* Assets.xcassets */,
|
||||
0DA26F997DC3429889C0B23A /* Core */,
|
||||
8CF6CD4493114827807F5F6D /* Features */,
|
||||
047E80495324497B8522ACEC /* Shared */,
|
||||
);
|
||||
path = "Platform"; sourceTree = "<group>";
|
||||
};
|
||||
0DA26F997DC3429889C0B23A /* Core */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
,
|
||||
);
|
||||
path = "Core"; sourceTree = "<group>";
|
||||
};
|
||||
8CF6CD4493114827807F5F6D /* Features */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
824CFF8CF00F41C590FB148C /* Auth */,
|
||||
DAD6984656494252A7E8A5DC /* Home */,
|
||||
C94148B12F3443238D763D27 /* Fitness */,
|
||||
64DDC35730F64FAFA4F2962C /* Assistant */,
|
||||
);
|
||||
path = "Features"; sourceTree = "<group>";
|
||||
};
|
||||
824CFF8CF00F41C590FB148C /* Auth */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
,
|
||||
);
|
||||
path = "Auth"; sourceTree = "<group>";
|
||||
};
|
||||
DAD6984656494252A7E8A5DC /* Home */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
,
|
||||
);
|
||||
path = "Home"; sourceTree = "<group>";
|
||||
};
|
||||
64DDC35730F64FAFA4F2962C /* Assistant */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
,
|
||||
);
|
||||
path = "Assistant"; sourceTree = "<group>";
|
||||
};
|
||||
C94148B12F3443238D763D27 /* Fitness */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
822A533A33DF4047882688E2 /* Models */,
|
||||
969F179A1EB645CCBAECE591 /* API */,
|
||||
A5B64A87024F4F66B4A5D8B4 /* Repository */,
|
||||
4B89164541C1493A80664F6D /* ViewModels */,
|
||||
BB4E0BAFB7DA45A68F0480A4 /* Views */,
|
||||
);
|
||||
path = "Fitness"; sourceTree = "<group>";
|
||||
};
|
||||
969F179A1EB645CCBAECE591 /* API */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
,
|
||||
);
|
||||
path = "API"; sourceTree = "<group>";
|
||||
};
|
||||
822A533A33DF4047882688E2 /* Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
,
|
||||
);
|
||||
path = "Models"; sourceTree = "<group>";
|
||||
};
|
||||
A5B64A87024F4F66B4A5D8B4 /* Repository */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
,
|
||||
);
|
||||
path = "Repository"; sourceTree = "<group>";
|
||||
};
|
||||
4B89164541C1493A80664F6D /* ViewModels */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
,
|
||||
);
|
||||
path = "ViewModels"; sourceTree = "<group>";
|
||||
};
|
||||
BB4E0BAFB7DA45A68F0480A4 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
,
|
||||
);
|
||||
path = "Views"; sourceTree = "<group>";
|
||||
};
|
||||
047E80495324497B8522ACEC /* Shared */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7F73AF13180C459B8275CD39 /* Components */,
|
||||
D3B81404D3A24B66B6848BB6 /* Extensions */,
|
||||
);
|
||||
path = "Shared"; sourceTree = "<group>";
|
||||
};
|
||||
7F73AF13180C459B8275CD39 /* Components */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
,
|
||||
);
|
||||
path = "Components"; sourceTree = "<group>";
|
||||
};
|
||||
D3B81404D3A24B66B6848BB6 /* Extensions */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
,
|
||||
);
|
||||
path = "Extensions"; sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
B66E7E9630EA415E95CE3A85 /* Platform */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 0025AB77304B486EB7AE3B2B /* Build configuration list for PBXNativeTarget "Platform" */;
|
||||
buildPhases = (
|
||||
F8ADC26469734B15B38E123A /* Sources */,
|
||||
078D546291EB4BBFA91F6661 /* Frameworks */,
|
||||
841189B510034E9A81A14A8C /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = Platform;
|
||||
productName = Platform;
|
||||
productReference = 4B7D1D629553482DA83FE35D /* Platform.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
1C4E1290ED4B4E0D832C6DD0 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1540;
|
||||
LastUpgradeCheck = 1540;
|
||||
};
|
||||
buildConfigurationList = E9E082A339DE4D0DAF491E2B /* Build configuration list for PBXProject "Platform" */;
|
||||
compatibilityVersion = "Xcode 14.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 037C8FC2A4954FCE91D25A60 /* Platform */;
|
||||
productRefGroup = B5E96950287B4399909152DA /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
B66E7E9630EA415E95CE3A85 /* Platform */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
841189B510034E9A81A14A8C /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
F1AA651AF622471EA697E2CA /* Assets.xcassets in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
F8ADC26469734B15B38E123A /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
F0819A9915F94DECBA67FA89 /* Config.swift in Sources */,
|
||||
DD3F166E894F43848CE426C7 /* ContentView.swift in Sources */,
|
||||
C9B59C679DF04512BF9F5C69 /* PlatformApp.swift in Sources */,
|
||||
F35DB630BE0A46799AEC15A5 /* APIClient.swift in Sources */,
|
||||
B20540C8E87F42C2AF4AB477 /* AuthManager.swift in Sources */,
|
||||
779FB7AAB0244CB68E5C69C8 /* LoginView.swift in Sources */,
|
||||
FB0A7F362F5944C0BF0365C5 /* AssistantChatView.swift in Sources */,
|
||||
45E92CD7F85A49BFA5C05F20 /* AssistantViewModel.swift in Sources */,
|
||||
2BC13A589E944CE3A4C6B708 /* FitnessAPI.swift in Sources */,
|
||||
F7F65E02FB974FACA76AFAF4 /* FitnessModels.swift in Sources */,
|
||||
CF63000DFF4F4F728281A31D /* FitnessRepository.swift in Sources */,
|
||||
400DCD1DE0534E9E856319F8 /* FoodSearchViewModel.swift in Sources */,
|
||||
96AFE69ACFB54D97A7C3A4A0 /* GoalsViewModel.swift in Sources */,
|
||||
84CFFF27A99940B5875A65DA /* TemplatesViewModel.swift in Sources */,
|
||||
03EBDF20EDF547EDB4B177CF /* TodayViewModel.swift in Sources */,
|
||||
92E276F3CA8D4400827B2F99 /* AddFoodSheet.swift in Sources */,
|
||||
C1D87BF5F61847BD952F5C65 /* EntryDetailView.swift in Sources */,
|
||||
E48F9E44708544D781FC8ACD /* FitnessTabView.swift in Sources */,
|
||||
2F5AF62DD13D4B0EB8E338A0 /* FoodLibraryView.swift in Sources */,
|
||||
6FE7AC0C375242398A5118B4 /* FoodSearchView.swift in Sources */,
|
||||
8064B019D0B742749713A35E /* GoalsView.swift in Sources */,
|
||||
060E0115D90647A593BCF8B7 /* MealSectionView.swift in Sources */,
|
||||
7902B9F4789C4C6FB080AF0D /* TemplatesView.swift in Sources */,
|
||||
A74F6C2CDEF4487383684BB9 /* TodayView.swift in Sources */,
|
||||
75DB623876D54D0BAF32B59F /* HomeView.swift in Sources */,
|
||||
340D12BB6F234169953639F6 /* HomeViewModel.swift in Sources */,
|
||||
D2CF33B6FE4142CA9995E125 /* LoadingView.swift in Sources */,
|
||||
CF9C74396EA449D2BDEBAA09 /* MacroBar.swift in Sources */,
|
||||
C6D6C2D5882245A895C8B606 /* MacroRing.swift in Sources */,
|
||||
86D08C3CC8B34AEDB1254EC1 /* Color+Extensions.swift in Sources */,
|
||||
C367EA266AED4AB7842B8A6F /* Date+Extensions.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
078D546291EB4BBFA91F6661 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
B7446285A53C413C9BA0229C /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASCETECHNOLOGIES_AWARE = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
D697F1D80CFA4E629D58BE0A /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
C29C67A4DC4943FB8112E677 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Platform/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Platform;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.quadjourney.platform;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
5289CC82B720470DA5F3181B /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Platform/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Platform;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.quadjourney.platform;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
E9E082A339DE4D0DAF491E2B /* Build configuration list for PBXProject "Platform" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
B7446285A53C413C9BA0229C /* Debug */,
|
||||
D697F1D80CFA4E629D58BE0A /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
0025AB77304B486EB7AE3B2B /* Build configuration list for PBXNativeTarget "Platform" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
C29C67A4DC4943FB8112E677 /* Debug */,
|
||||
5289CC82B720470DA5F3181B /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
};
|
||||
rootObject = 1C4E1290ED4B4E0D832C6DD0 /* Project object */;
|
||||
}
|
||||
7
ios/Platform/Platform.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
ios/Platform/Platform.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.078",
|
||||
"green" : "0.412",
|
||||
"red" : "0.545"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
6
ios/Platform/Platform/Assets.xcassets/Contents.json
Normal file
6
ios/Platform/Platform/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
5
ios/Platform/Platform/Config.swift
Normal file
5
ios/Platform/Platform/Config.swift
Normal file
@@ -0,0 +1,5 @@
|
||||
import Foundation
|
||||
|
||||
enum Config {
|
||||
static let gatewayURL = "https://dash.quadjourney.com"
|
||||
}
|
||||
43
ios/Platform/Platform/ContentView.swift
Normal file
43
ios/Platform/Platform/ContentView.swift
Normal file
@@ -0,0 +1,43 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(AuthManager.self) private var authManager
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if authManager.isCheckingAuth {
|
||||
ProgressView("Loading...")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.canvas)
|
||||
} else if authManager.isLoggedIn {
|
||||
MainTabView()
|
||||
} else {
|
||||
LoginView()
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await authManager.checkAuth()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MainTabView: View {
|
||||
@State private var selectedTab = 0
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
HomeView()
|
||||
.tabItem {
|
||||
Label("Home", systemImage: "house.fill")
|
||||
}
|
||||
.tag(0)
|
||||
|
||||
FitnessTabView()
|
||||
.tabItem {
|
||||
Label("Fitness", systemImage: "figure.run")
|
||||
}
|
||||
.tag(1)
|
||||
}
|
||||
.tint(.accent)
|
||||
}
|
||||
}
|
||||
145
ios/Platform/Platform/Core/APIClient.swift
Normal file
145
ios/Platform/Platform/Core/APIClient.swift
Normal file
@@ -0,0 +1,145 @@
|
||||
import Foundation
|
||||
|
||||
enum APIError: LocalizedError {
|
||||
case invalidURL
|
||||
case httpError(Int, String?)
|
||||
case decodingError(Error)
|
||||
case networkError(Error)
|
||||
case unknown(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL: return "Invalid URL"
|
||||
case .httpError(let code, let msg): return msg ?? "HTTP error \(code)"
|
||||
case .decodingError(let err): return "Decoding error: \(err.localizedDescription)"
|
||||
case .networkError(let err): return err.localizedDescription
|
||||
case .unknown(let msg): return msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class APIClient {
|
||||
static let shared = APIClient()
|
||||
|
||||
private let session: URLSession
|
||||
private let decoder: JSONDecoder
|
||||
|
||||
private init() {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.httpCookieAcceptPolicy = .always
|
||||
config.httpShouldSetCookies = true
|
||||
config.httpCookieStorage = .shared
|
||||
session = URLSession(configuration: config)
|
||||
decoder = JSONDecoder()
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
}
|
||||
|
||||
private func buildURL(_ path: String) throws -> URL {
|
||||
guard let url = URL(string: "\(Config.gatewayURL)\(path)") else {
|
||||
throw APIError.invalidURL
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
func request<T: Decodable>(_ method: String, _ path: String, body: Encodable? = nil) async throws -> T {
|
||||
let url = try buildURL(path)
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = method
|
||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
if let body = body {
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
let encoder = JSONEncoder()
|
||||
encoder.keyEncodingStrategy = .convertToSnakeCase
|
||||
req.httpBody = try encoder.encode(body)
|
||||
}
|
||||
|
||||
let (data, response): (Data, URLResponse)
|
||||
do {
|
||||
(data, response) = try await session.data(for: req)
|
||||
} catch {
|
||||
throw APIError.networkError(error)
|
||||
}
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse,
|
||||
!(200...299).contains(httpResponse.statusCode) {
|
||||
let bodyStr = String(data: data, encoding: .utf8)
|
||||
throw APIError.httpError(httpResponse.statusCode, bodyStr)
|
||||
}
|
||||
|
||||
do {
|
||||
return try decoder.decode(T.self, from: data)
|
||||
} catch {
|
||||
throw APIError.decodingError(error)
|
||||
}
|
||||
}
|
||||
|
||||
func get<T: Decodable>(_ path: String) async throws -> T {
|
||||
try await request("GET", path)
|
||||
}
|
||||
|
||||
func post<T: Decodable>(_ path: String, body: Encodable? = nil) async throws -> T {
|
||||
try await request("POST", path, body: body)
|
||||
}
|
||||
|
||||
func patch<T: Decodable>(_ path: String, body: Encodable? = nil) async throws -> T {
|
||||
try await request("PATCH", path, body: body)
|
||||
}
|
||||
|
||||
func put<T: Decodable>(_ path: String, body: Encodable? = nil) async throws -> T {
|
||||
try await request("PUT", path, body: body)
|
||||
}
|
||||
|
||||
func delete(_ path: String) async throws {
|
||||
let url = try buildURL(path)
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "DELETE"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
let (data, response): (Data, URLResponse)
|
||||
do {
|
||||
(data, response) = try await session.data(for: req)
|
||||
} catch {
|
||||
throw APIError.networkError(error)
|
||||
}
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse,
|
||||
!(200...299).contains(httpResponse.statusCode) {
|
||||
let bodyStr = String(data: data, encoding: .utf8)
|
||||
throw APIError.httpError(httpResponse.statusCode, bodyStr)
|
||||
}
|
||||
}
|
||||
|
||||
func rawPost(_ path: String, body: Data) async throws -> (Data, URLResponse) {
|
||||
let url = try buildURL(path)
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
req.httpBody = body
|
||||
|
||||
let (data, response): (Data, URLResponse)
|
||||
do {
|
||||
(data, response) = try await session.data(for: req)
|
||||
} catch {
|
||||
throw APIError.networkError(error)
|
||||
}
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse,
|
||||
!(200...299).contains(httpResponse.statusCode) {
|
||||
let bodyStr = String(data: data, encoding: .utf8)
|
||||
throw APIError.httpError(httpResponse.statusCode, bodyStr)
|
||||
}
|
||||
|
||||
return (data, response)
|
||||
}
|
||||
|
||||
func clearCookies() {
|
||||
if let cookies = HTTPCookieStorage.shared.cookies {
|
||||
for cookie in cookies {
|
||||
HTTPCookieStorage.shared.deleteCookie(cookie)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
104
ios/Platform/Platform/Core/AuthManager.swift
Normal file
104
ios/Platform/Platform/Core/AuthManager.swift
Normal file
@@ -0,0 +1,104 @@
|
||||
import Foundation
|
||||
|
||||
struct AuthUser: Codable {
|
||||
let id: Int
|
||||
let username: String
|
||||
let displayName: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, username
|
||||
case displayName = "display_name"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
// Handle id as Int or String
|
||||
if let intId = try? container.decode(Int.self, forKey: .id) {
|
||||
id = intId
|
||||
} else if let strId = try? container.decode(String.self, forKey: .id),
|
||||
let parsed = Int(strId) {
|
||||
id = parsed
|
||||
} else {
|
||||
throw DecodingError.typeMismatch(Int.self, .init(codingPath: [CodingKeys.id], debugDescription: "Expected Int or String for id"))
|
||||
}
|
||||
username = try container.decode(String.self, forKey: .username)
|
||||
displayName = try container.decodeIfPresent(String.self, forKey: .displayName)
|
||||
}
|
||||
}
|
||||
|
||||
struct LoginRequest: Encodable {
|
||||
let username: String
|
||||
let password: String
|
||||
}
|
||||
|
||||
struct LoginResponse: Decodable {
|
||||
let success: Bool
|
||||
let user: AuthUser?
|
||||
}
|
||||
|
||||
struct MeResponse: Decodable {
|
||||
let authenticated: Bool
|
||||
let user: AuthUser?
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class AuthManager {
|
||||
var isLoggedIn = false
|
||||
var isCheckingAuth = true
|
||||
var user: AuthUser?
|
||||
var loginError: String?
|
||||
|
||||
private let api = APIClient.shared
|
||||
private let loggedInKey = "isLoggedIn"
|
||||
|
||||
init() {
|
||||
isLoggedIn = UserDefaults.standard.bool(forKey: loggedInKey)
|
||||
}
|
||||
|
||||
func checkAuth() async {
|
||||
guard UserDefaults.standard.bool(forKey: loggedInKey) else {
|
||||
isCheckingAuth = false
|
||||
isLoggedIn = false
|
||||
return
|
||||
}
|
||||
do {
|
||||
let response: MeResponse = try await api.get("/api/auth/me")
|
||||
if response.authenticated {
|
||||
user = response.user
|
||||
isLoggedIn = true
|
||||
} else {
|
||||
isLoggedIn = false
|
||||
UserDefaults.standard.set(false, forKey: loggedInKey)
|
||||
}
|
||||
} catch {
|
||||
isLoggedIn = false
|
||||
UserDefaults.standard.set(false, forKey: loggedInKey)
|
||||
}
|
||||
isCheckingAuth = false
|
||||
}
|
||||
|
||||
func login(username: String, password: String) async {
|
||||
loginError = nil
|
||||
do {
|
||||
let response: LoginResponse = try await api.post("/api/auth/login", body: LoginRequest(username: username, password: password))
|
||||
if response.success {
|
||||
user = response.user
|
||||
isLoggedIn = true
|
||||
UserDefaults.standard.set(true, forKey: loggedInKey)
|
||||
} else {
|
||||
loginError = "Invalid credentials"
|
||||
}
|
||||
} catch let error as APIError {
|
||||
loginError = error.localizedDescription
|
||||
} catch {
|
||||
loginError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func logout() {
|
||||
api.clearCookies()
|
||||
isLoggedIn = false
|
||||
user = nil
|
||||
UserDefaults.standard.set(false, forKey: loggedInKey)
|
||||
}
|
||||
}
|
||||
285
ios/Platform/Platform/Features/Assistant/AssistantChatView.swift
Normal file
285
ios/Platform/Platform/Features/Assistant/AssistantChatView.swift
Normal file
@@ -0,0 +1,285 @@
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
|
||||
enum AssistantTab: String, CaseIterable {
|
||||
case chat = "AI Chat"
|
||||
case quickAdd = "Quick Add"
|
||||
}
|
||||
|
||||
struct AssistantChatView: View {
|
||||
let entryDate: String
|
||||
let onDismiss: () -> Void
|
||||
|
||||
@Environment(AuthManager.self) private var authManager
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var vm: AssistantViewModel
|
||||
@State private var selectedTab: AssistantTab = .chat
|
||||
@State private var scrollProxy: ScrollViewProxy?
|
||||
|
||||
init(entryDate: String, onDismiss: @escaping () -> Void) {
|
||||
self.entryDate = entryDate
|
||||
self.onDismiss = onDismiss
|
||||
_vm = State(initialValue: AssistantViewModel(entryDate: entryDate, username: nil))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
// Tab switcher
|
||||
HStack(spacing: 0) {
|
||||
ForEach(AssistantTab.allCases, id: \.self) { tab in
|
||||
Button {
|
||||
selectedTab = tab
|
||||
} label: {
|
||||
Text(tab.rawValue)
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(selectedTab == tab ? Color.accent : Color.textSecondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.background(selectedTab == tab ? Color.accent.opacity(0.1) : Color.clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(4)
|
||||
.background(Color.surfaceSecondary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
|
||||
if selectedTab == .chat {
|
||||
chatContent
|
||||
} else {
|
||||
FoodSearchView(mealType: .snack, dateString: entryDate)
|
||||
}
|
||||
}
|
||||
.background(Color.canvas)
|
||||
.navigationTitle("Assistant")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Done") {
|
||||
dismiss()
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
vm = AssistantViewModel(entryDate: entryDate, username: authManager.user?.username)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var chatContent: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Messages
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(vm.messages) { message in
|
||||
messageBubble(message)
|
||||
.id(message.id)
|
||||
}
|
||||
if vm.isLoading {
|
||||
HStack {
|
||||
ProgressView()
|
||||
.tint(Color.accent)
|
||||
Text("Thinking...")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.id("loading")
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.onAppear { scrollProxy = proxy }
|
||||
.onChange(of: vm.messages.count) { _, _ in
|
||||
withAnimation {
|
||||
proxy.scrollTo(vm.messages.last?.id, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let error = vm.error {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
// Input bar
|
||||
HStack(spacing: 8) {
|
||||
PhotosPicker(selection: Binding(
|
||||
get: { vm.selectedPhoto },
|
||||
set: { newVal in
|
||||
vm.selectedPhoto = newVal
|
||||
Task { await vm.loadPhoto(newVal) }
|
||||
}
|
||||
), matching: .images) {
|
||||
Image(systemName: vm.photoData != nil ? "photo.fill" : "photo")
|
||||
.font(.title3)
|
||||
.foregroundStyle(vm.photoData != nil ? Color.accent : Color.textSecondary)
|
||||
}
|
||||
|
||||
TextField("Ask anything...", text: $vm.inputText)
|
||||
.textFieldStyle(.plain)
|
||||
.padding(10)
|
||||
.background(Color.surfaceSecondary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
|
||||
Button {
|
||||
Task {
|
||||
await vm.send()
|
||||
withAnimation {
|
||||
scrollProxy?.scrollTo(vm.messages.last?.id, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(vm.inputText.isEmpty && vm.photoData == nil ? Color.textTertiary : Color.accent)
|
||||
}
|
||||
.disabled(vm.inputText.isEmpty && vm.photoData == nil)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.surface)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func messageBubble(_ message: ChatMessage) -> some View {
|
||||
if message.role == "user" {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text(message.content)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
.padding(12)
|
||||
.background(Color(hex: "8B6914").opacity(0.15))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.frame(maxWidth: 280, alignment: .trailing)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if !message.content.isEmpty {
|
||||
Text(message.content)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
.padding(12)
|
||||
.background(Color.assistantBubble)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.frame(maxWidth: 300, alignment: .leading)
|
||||
}
|
||||
|
||||
// Draft cards
|
||||
ForEach(Array(message.drafts.enumerated()), id: \.offset) { _, draft in
|
||||
draftCard(draft, applied: message.applied)
|
||||
}
|
||||
|
||||
// Source links
|
||||
if !message.sources.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(message.sources) { source in
|
||||
if let url = URL(string: source.href), !source.href.isEmpty {
|
||||
Link(destination: url) {
|
||||
sourceChip(source)
|
||||
}
|
||||
} else {
|
||||
sourceChip(source)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
|
||||
private func draftCard(_ draft: FitnessDraft, applied: Bool) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text(draft.foodName)
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
Spacer()
|
||||
Text(draft.mealType.capitalized)
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(Color.accent)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.accent.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
macroChip("Cal", Int(draft.calories))
|
||||
macroChip("P", Int(draft.protein))
|
||||
macroChip("C", Int(draft.carbs))
|
||||
macroChip("F", Int(draft.fat))
|
||||
}
|
||||
|
||||
if !applied {
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
Task { await vm.applyDraft() }
|
||||
} label: {
|
||||
Text("Add it")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.emerald)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Color.emerald)
|
||||
Text("Added")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(Color.emerald)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.shadow(color: .black.opacity(0.05), radius: 4, y: 2)
|
||||
.frame(maxWidth: 300, alignment: .leading)
|
||||
}
|
||||
|
||||
private func macroChip(_ label: String, _ value: Int) -> some View {
|
||||
VStack(spacing: 2) {
|
||||
Text("\(value)")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
Text(label)
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func sourceChip(_ source: SourceLink) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: source.type == "brain" ? "brain" : "link")
|
||||
.font(.system(size: 10))
|
||||
Text(source.title)
|
||||
.font(.caption2)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.foregroundStyle(Color.accent)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.accent.opacity(0.08))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
|
||||
struct ChatMessage: Identifiable {
|
||||
let id = UUID()
|
||||
let role: String // "user" or "assistant"
|
||||
let content: String
|
||||
var drafts: [FitnessDraft] = []
|
||||
var sources: [SourceLink] = []
|
||||
var applied: Bool = false
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class AssistantViewModel {
|
||||
var messages: [ChatMessage] = []
|
||||
var inputText = ""
|
||||
var isLoading = false
|
||||
var error: String?
|
||||
var selectedPhoto: PhotosPickerItem?
|
||||
var photoData: Data?
|
||||
|
||||
// Raw JSON state from server — never decode this
|
||||
private var serverState: Any?
|
||||
private let api = APIClient.shared
|
||||
private var entryDate: String
|
||||
private var allowBrain: Bool
|
||||
|
||||
init(entryDate: String, username: String?) {
|
||||
self.entryDate = entryDate
|
||||
self.allowBrain = (username ?? "") != "madiha"
|
||||
}
|
||||
|
||||
func send(action: String = "chat") async {
|
||||
let text = inputText.trimmingCharacters(in: .whitespaces)
|
||||
guard !text.isEmpty || action == "apply" else { return }
|
||||
|
||||
if action == "chat" && !text.isEmpty {
|
||||
messages.append(ChatMessage(role: "user", content: text))
|
||||
inputText = ""
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
error = nil
|
||||
|
||||
do {
|
||||
// Build request as raw JSON
|
||||
var requestDict: [String: Any] = [
|
||||
"entryDate": entryDate,
|
||||
"action": action,
|
||||
"allowBrain": allowBrain
|
||||
]
|
||||
|
||||
// Messages array
|
||||
let msgArray = messages.filter { $0.role == "user" }.map { msg -> [String: String] in
|
||||
["role": "user", "content": msg.content]
|
||||
}
|
||||
requestDict["messages"] = msgArray
|
||||
|
||||
// State pass-through
|
||||
if let state = serverState {
|
||||
requestDict["state"] = state
|
||||
} else {
|
||||
requestDict["state"] = NSNull()
|
||||
}
|
||||
|
||||
// Photo
|
||||
if let data = photoData {
|
||||
let base64 = data.base64EncodedString()
|
||||
requestDict["imageDataUrl"] = "data:image/jpeg;base64,\(base64)"
|
||||
photoData = nil
|
||||
} else {
|
||||
requestDict["imageDataUrl"] = NSNull()
|
||||
}
|
||||
|
||||
let bodyData = try JSONSerialization.data(withJSONObject: requestDict)
|
||||
let (responseData, _) = try await api.rawPost("/api/assistant/chat", body: bodyData)
|
||||
|
||||
// Parse response as raw JSON
|
||||
guard let json = try JSONSerialization.jsonObject(with: responseData) as? [String: Any] else {
|
||||
error = "Invalid response"
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
// Store raw state
|
||||
serverState = json["state"]
|
||||
|
||||
// Extract display fields
|
||||
let reply = json["reply"] as? String ?? ""
|
||||
let applied = json["applied"] as? Bool ?? false
|
||||
|
||||
// Parse drafts
|
||||
var drafts: [FitnessDraft] = []
|
||||
if let draft = json["draft"] as? [String: Any], let d = FitnessDraft(from: draft) {
|
||||
drafts.append(d)
|
||||
}
|
||||
if let draftsArray = json["drafts"] as? [[String: Any]] {
|
||||
for dict in draftsArray {
|
||||
if let d = FitnessDraft(from: dict) {
|
||||
drafts.append(d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse sources
|
||||
var sources: [SourceLink] = []
|
||||
if let sourcesArray = json["sources"] as? [[String: Any]] {
|
||||
for dict in sourcesArray {
|
||||
if let s = SourceLink(from: dict) {
|
||||
sources.append(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for error
|
||||
if let errStr = json["error"] as? String, !errStr.isEmpty {
|
||||
error = errStr
|
||||
}
|
||||
|
||||
if !reply.isEmpty || !drafts.isEmpty {
|
||||
messages.append(ChatMessage(
|
||||
role: "assistant",
|
||||
content: reply,
|
||||
drafts: drafts,
|
||||
sources: sources,
|
||||
applied: applied
|
||||
))
|
||||
}
|
||||
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func applyDraft() async {
|
||||
await send(action: "apply")
|
||||
}
|
||||
|
||||
func loadPhoto(_ item: PhotosPickerItem?) async {
|
||||
guard let item else { return }
|
||||
if let data = try? await item.loadTransferable(type: Data.self) {
|
||||
// Compress as JPEG
|
||||
if let img = UIImage(data: data), let jpeg = img.jpegData(compressionQuality: 0.7) {
|
||||
photoData = jpeg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
80
ios/Platform/Platform/Features/Auth/LoginView.swift
Normal file
80
ios/Platform/Platform/Features/Auth/LoginView.swift
Normal file
@@ -0,0 +1,80 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LoginView: View {
|
||||
@Environment(AuthManager.self) private var authManager
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
@State private var isLoading = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "square.grid.2x2.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(Color.accent)
|
||||
Text("Platform")
|
||||
.font(.system(size: 32, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
Text("Sign in to continue")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
}
|
||||
|
||||
VStack(spacing: 16) {
|
||||
TextField("Username", text: $username)
|
||||
.textFieldStyle(.plain)
|
||||
.padding(14)
|
||||
.background(Color.surfaceSecondary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.textContentType(.username)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
|
||||
SecureField("Password", text: $password)
|
||||
.textFieldStyle(.plain)
|
||||
.padding(14)
|
||||
.background(Color.surfaceSecondary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.textContentType(.password)
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
|
||||
if let error = authManager.loginError {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
|
||||
Button {
|
||||
isLoading = true
|
||||
Task {
|
||||
await authManager.login(username: username, password: password)
|
||||
isLoading = false
|
||||
}
|
||||
} label: {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
} else {
|
||||
Text("Sign In")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Color.accent)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal, 32)
|
||||
.disabled(username.isEmpty || password.isEmpty || isLoading)
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
}
|
||||
.background(Color.canvas.ignoresSafeArea())
|
||||
}
|
||||
}
|
||||
56
ios/Platform/Platform/Features/Fitness/API/FitnessAPI.swift
Normal file
56
ios/Platform/Platform/Features/Fitness/API/FitnessAPI.swift
Normal file
@@ -0,0 +1,56 @@
|
||||
import Foundation
|
||||
|
||||
struct FitnessAPI {
|
||||
private let api = APIClient.shared
|
||||
|
||||
func getEntries(date: String) async throws -> [FoodEntry] {
|
||||
try await api.get("/api/fitness/entries?date=\(date)")
|
||||
}
|
||||
|
||||
func createEntry(_ req: CreateEntryRequest) async throws -> FoodEntry {
|
||||
try await api.post("/api/fitness/entries", body: req)
|
||||
}
|
||||
|
||||
func updateEntry(id: Int, quantity: Double) async throws -> FoodEntry {
|
||||
struct Body: Encodable { let quantity: Double }
|
||||
return try await api.patch("/api/fitness/entries/\(id)", body: Body(quantity: quantity))
|
||||
}
|
||||
|
||||
func deleteEntry(id: Int) async throws {
|
||||
try await api.delete("/api/fitness/entries/\(id)")
|
||||
}
|
||||
|
||||
func getFoods(limit: Int = 100) async throws -> [FoodItem] {
|
||||
try await api.get("/api/fitness/foods?limit=\(limit)")
|
||||
}
|
||||
|
||||
func searchFoods(query: String, limit: Int = 20) async throws -> [FoodItem] {
|
||||
let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query
|
||||
return try await api.get("/api/fitness/foods/search?q=\(encoded)&limit=\(limit)")
|
||||
}
|
||||
|
||||
func getRecentFoods(limit: Int = 8) async throws -> [FoodItem] {
|
||||
try await api.get("/api/fitness/foods/recent?limit=\(limit)")
|
||||
}
|
||||
|
||||
func getFood(id: Int) async throws -> FoodItem {
|
||||
try await api.get("/api/fitness/foods/\(id)")
|
||||
}
|
||||
|
||||
func getGoals(date: String) async throws -> DailyGoal {
|
||||
try await api.get("/api/fitness/goals/for-date?date=\(date)")
|
||||
}
|
||||
|
||||
func updateGoals(_ req: UpdateGoalsRequest) async throws -> DailyGoal {
|
||||
try await api.put("/api/fitness/goals", body: req)
|
||||
}
|
||||
|
||||
func getTemplates() async throws -> [MealTemplate] {
|
||||
try await api.get("/api/fitness/templates")
|
||||
}
|
||||
|
||||
func logTemplate(id: Int, date: String) async throws {
|
||||
struct Empty: Decodable {}
|
||||
let _: Empty = try await api.post("/api/fitness/templates/\(id)/log?date=\(date)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Meal Type
|
||||
|
||||
enum MealType: String, Codable, CaseIterable, Identifiable {
|
||||
case breakfast, lunch, dinner, snack
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
rawValue.capitalized
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .breakfast: return "sunrise.fill"
|
||||
case .lunch: return "sun.max.fill"
|
||||
case .dinner: return "moon.fill"
|
||||
case .snack: return "leaf.fill"
|
||||
}
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .breakfast: return .breakfastColor
|
||||
case .lunch: return .lunchColor
|
||||
case .dinner: return .dinnerColor
|
||||
case .snack: return .snackColor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Food Entry
|
||||
|
||||
struct FoodEntry: Identifiable, Codable {
|
||||
let id: Int
|
||||
let userId: Int?
|
||||
let foodId: Int?
|
||||
let mealType: MealType
|
||||
let quantity: Double
|
||||
let entryDate: String
|
||||
let foodName: String
|
||||
let calories: Double
|
||||
let protein: Double
|
||||
let carbs: Double
|
||||
let fat: Double
|
||||
let sugar: Double?
|
||||
let fiber: Double?
|
||||
let servingSize: String?
|
||||
let imageFilename: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case userId = "user_id"
|
||||
case foodId = "food_id"
|
||||
case mealType = "meal_type"
|
||||
case quantity
|
||||
case entryDate = "entry_date"
|
||||
case foodName = "food_name"
|
||||
case snapshotFoodName = "snapshot_food_name"
|
||||
case calories, protein, carbs, fat, sugar, fiber
|
||||
case servingSize = "serving_size"
|
||||
case imageFilename = "image_filename"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try Self.decodeIntFlex(c, .id)
|
||||
userId = try? Self.decodeIntFlex(c, .userId)
|
||||
foodId = try? Self.decodeIntFlex(c, .foodId)
|
||||
mealType = try c.decode(MealType.self, forKey: .mealType)
|
||||
quantity = try Self.decodeDoubleFlex(c, .quantity)
|
||||
entryDate = try c.decode(String.self, forKey: .entryDate)
|
||||
// Handle food_name or snapshot_food_name
|
||||
if let name = try? c.decode(String.self, forKey: .foodName) {
|
||||
foodName = name
|
||||
} else if let name = try? c.decode(String.self, forKey: .snapshotFoodName) {
|
||||
foodName = name
|
||||
} else {
|
||||
foodName = "Unknown"
|
||||
}
|
||||
calories = try Self.decodeDoubleFlex(c, .calories)
|
||||
protein = try Self.decodeDoubleFlex(c, .protein)
|
||||
carbs = try Self.decodeDoubleFlex(c, .carbs)
|
||||
fat = try Self.decodeDoubleFlex(c, .fat)
|
||||
sugar = try? Self.decodeDoubleFlex(c, .sugar)
|
||||
fiber = try? Self.decodeDoubleFlex(c, .fiber)
|
||||
servingSize = try? c.decode(String.self, forKey: .servingSize)
|
||||
imageFilename = try? c.decode(String.self, forKey: .imageFilename)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||
try c.encode(id, forKey: .id)
|
||||
try c.encodeIfPresent(userId, forKey: .userId)
|
||||
try c.encodeIfPresent(foodId, forKey: .foodId)
|
||||
try c.encode(mealType, forKey: .mealType)
|
||||
try c.encode(quantity, forKey: .quantity)
|
||||
try c.encode(entryDate, forKey: .entryDate)
|
||||
try c.encode(foodName, forKey: .foodName)
|
||||
try c.encode(calories, forKey: .calories)
|
||||
try c.encode(protein, forKey: .protein)
|
||||
try c.encode(carbs, forKey: .carbs)
|
||||
try c.encode(fat, forKey: .fat)
|
||||
try c.encodeIfPresent(sugar, forKey: .sugar)
|
||||
try c.encodeIfPresent(fiber, forKey: .fiber)
|
||||
try c.encodeIfPresent(servingSize, forKey: .servingSize)
|
||||
try c.encodeIfPresent(imageFilename, forKey: .imageFilename)
|
||||
}
|
||||
|
||||
// Flexible Int decoding
|
||||
private static func decodeIntFlex(_ c: KeyedDecodingContainer<CodingKeys>, _ key: CodingKeys) throws -> Int {
|
||||
if let v = try? c.decode(Int.self, forKey: key) { return v }
|
||||
if let v = try? c.decode(Double.self, forKey: key) { return Int(v) }
|
||||
if let v = try? c.decode(String.self, forKey: key), let i = Int(v) { return i }
|
||||
throw DecodingError.typeMismatch(Int.self, .init(codingPath: [key], debugDescription: "Expected numeric"))
|
||||
}
|
||||
|
||||
// Flexible Double decoding
|
||||
private static func decodeDoubleFlex(_ c: KeyedDecodingContainer<CodingKeys>, _ key: CodingKeys) throws -> Double {
|
||||
if let v = try? c.decode(Double.self, forKey: key) { return v }
|
||||
if let v = try? c.decode(Int.self, forKey: key) { return Double(v) }
|
||||
if let v = try? c.decode(String.self, forKey: key), let d = Double(v) { return d }
|
||||
throw DecodingError.typeMismatch(Double.self, .init(codingPath: [key], debugDescription: "Expected numeric"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Food Item
|
||||
|
||||
struct FoodItem: Identifiable, Codable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let calories: Double
|
||||
let protein: Double
|
||||
let carbs: Double
|
||||
let fat: Double
|
||||
let sugar: Double?
|
||||
let fiber: Double?
|
||||
let servingSize: String?
|
||||
let imageFilename: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, calories, protein, carbs, fat, sugar, fiber
|
||||
case servingSize = "serving_size"
|
||||
case imageFilename = "image_filename"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
if let v = try? c.decode(Int.self, forKey: .id) { id = v }
|
||||
else if let v = try? c.decode(Double.self, forKey: .id) { id = Int(v) }
|
||||
else { id = 0 }
|
||||
name = try c.decode(String.self, forKey: .name)
|
||||
calories = (try? c.decode(Double.self, forKey: .calories)) ?? Double((try? c.decode(Int.self, forKey: .calories)) ?? 0)
|
||||
protein = (try? c.decode(Double.self, forKey: .protein)) ?? Double((try? c.decode(Int.self, forKey: .protein)) ?? 0)
|
||||
carbs = (try? c.decode(Double.self, forKey: .carbs)) ?? Double((try? c.decode(Int.self, forKey: .carbs)) ?? 0)
|
||||
fat = (try? c.decode(Double.self, forKey: .fat)) ?? Double((try? c.decode(Int.self, forKey: .fat)) ?? 0)
|
||||
sugar = (try? c.decode(Double.self, forKey: .sugar)) ?? (try? c.decode(Int.self, forKey: .sugar)).map { Double($0) }
|
||||
fiber = (try? c.decode(Double.self, forKey: .fiber)) ?? (try? c.decode(Int.self, forKey: .fiber)).map { Double($0) }
|
||||
servingSize = try? c.decode(String.self, forKey: .servingSize)
|
||||
imageFilename = try? c.decode(String.self, forKey: .imageFilename)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Daily Goal
|
||||
|
||||
struct DailyGoal: Codable {
|
||||
let calories: Double
|
||||
let protein: Double
|
||||
let carbs: Double
|
||||
let fat: Double
|
||||
let sugar: Double
|
||||
let fiber: Double
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case calories, protein, carbs, fat, sugar, fiber
|
||||
}
|
||||
|
||||
init(calories: Double = 2000, protein: Double = 150, carbs: Double = 250, fat: Double = 65, sugar: Double = 50, fiber: Double = 30) {
|
||||
self.calories = calories
|
||||
self.protein = protein
|
||||
self.carbs = carbs
|
||||
self.fat = fat
|
||||
self.sugar = sugar
|
||||
self.fiber = fiber
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
calories = (try? c.decode(Double.self, forKey: .calories)) ?? Double((try? c.decode(Int.self, forKey: .calories)) ?? 2000)
|
||||
protein = (try? c.decode(Double.self, forKey: .protein)) ?? Double((try? c.decode(Int.self, forKey: .protein)) ?? 150)
|
||||
carbs = (try? c.decode(Double.self, forKey: .carbs)) ?? Double((try? c.decode(Int.self, forKey: .carbs)) ?? 250)
|
||||
fat = (try? c.decode(Double.self, forKey: .fat)) ?? Double((try? c.decode(Int.self, forKey: .fat)) ?? 65)
|
||||
sugar = (try? c.decode(Double.self, forKey: .sugar)) ?? Double((try? c.decode(Int.self, forKey: .sugar)) ?? 50)
|
||||
fiber = (try? c.decode(Double.self, forKey: .fiber)) ?? Double((try? c.decode(Int.self, forKey: .fiber)) ?? 30)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Meal Template
|
||||
|
||||
struct MealTemplate: Identifiable, Codable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let mealType: MealType
|
||||
let totalCalories: Double?
|
||||
let totalProtein: Double?
|
||||
let totalCarbs: Double?
|
||||
let totalFat: Double?
|
||||
let itemCount: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name
|
||||
case mealType = "meal_type"
|
||||
case totalCalories = "total_calories"
|
||||
case totalProtein = "total_protein"
|
||||
case totalCarbs = "total_carbs"
|
||||
case totalFat = "total_fat"
|
||||
case itemCount = "item_count"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
if let v = try? c.decode(Int.self, forKey: .id) { id = v }
|
||||
else if let v = try? c.decode(Double.self, forKey: .id) { id = Int(v) }
|
||||
else { id = 0 }
|
||||
name = try c.decode(String.self, forKey: .name)
|
||||
mealType = try c.decode(MealType.self, forKey: .mealType)
|
||||
totalCalories = (try? c.decode(Double.self, forKey: .totalCalories)) ?? (try? c.decode(Int.self, forKey: .totalCalories)).map { Double($0) }
|
||||
totalProtein = (try? c.decode(Double.self, forKey: .totalProtein)) ?? (try? c.decode(Int.self, forKey: .totalProtein)).map { Double($0) }
|
||||
totalCarbs = (try? c.decode(Double.self, forKey: .totalCarbs)) ?? (try? c.decode(Int.self, forKey: .totalCarbs)).map { Double($0) }
|
||||
totalFat = (try? c.decode(Double.self, forKey: .totalFat)) ?? (try? c.decode(Int.self, forKey: .totalFat)).map { Double($0) }
|
||||
itemCount = (try? c.decode(Int.self, forKey: .itemCount)) ?? (try? c.decode(Double.self, forKey: .itemCount)).map { Int($0) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Requests
|
||||
|
||||
struct CreateEntryRequest: Encodable {
|
||||
let foodId: Int?
|
||||
let foodName: String
|
||||
let mealType: String
|
||||
let quantity: Double
|
||||
let entryDate: String
|
||||
let calories: Double
|
||||
let protein: Double
|
||||
let carbs: Double
|
||||
let fat: Double
|
||||
let sugar: Double?
|
||||
let fiber: Double?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case foodId = "food_id"
|
||||
case foodName = "food_name"
|
||||
case mealType = "meal_type"
|
||||
case quantity
|
||||
case entryDate = "entry_date"
|
||||
case calories, protein, carbs, fat, sugar, fiber
|
||||
}
|
||||
}
|
||||
|
||||
struct UpdateGoalsRequest: Encodable {
|
||||
let calories: Double
|
||||
let protein: Double
|
||||
let carbs: Double
|
||||
let fat: Double
|
||||
let sugar: Double
|
||||
let fiber: Double
|
||||
}
|
||||
|
||||
// MARK: - Fitness Draft (AI Chat)
|
||||
|
||||
struct FitnessDraft {
|
||||
let foodName: String
|
||||
let mealType: String
|
||||
let calories: Double
|
||||
let protein: Double
|
||||
let carbs: Double
|
||||
let fat: Double
|
||||
let sugar: Double?
|
||||
let fiber: Double?
|
||||
let quantity: Double
|
||||
|
||||
init?(from dict: [String: Any]) {
|
||||
guard let name = dict["food_name"] as? String else { return nil }
|
||||
foodName = name
|
||||
mealType = (dict["meal_type"] as? String) ?? "snack"
|
||||
calories = Self.flexDouble(dict["calories"])
|
||||
protein = Self.flexDouble(dict["protein"])
|
||||
carbs = Self.flexDouble(dict["carbs"])
|
||||
fat = Self.flexDouble(dict["fat"])
|
||||
sugar = dict["sugar"].flatMap { Self.flexDoubleOpt($0) }
|
||||
fiber = dict["fiber"].flatMap { Self.flexDoubleOpt($0) }
|
||||
quantity = Self.flexDouble(dict["quantity"], default: 1)
|
||||
}
|
||||
|
||||
private static func flexDouble(_ val: Any?, default def: Double = 0) -> Double {
|
||||
if let v = val as? Double { return v }
|
||||
if let v = val as? Int { return Double(v) }
|
||||
if let v = val as? NSNumber { return v.doubleValue }
|
||||
return def
|
||||
}
|
||||
|
||||
private static func flexDoubleOpt(_ val: Any) -> Double? {
|
||||
if let v = val as? Double { return v }
|
||||
if let v = val as? Int { return Double(v) }
|
||||
if let v = val as? NSNumber { return v.doubleValue }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Source Link
|
||||
|
||||
struct SourceLink: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let type: String
|
||||
let href: String
|
||||
|
||||
init?(from dict: [String: Any]) {
|
||||
guard let id = dict["id"] as? String,
|
||||
let title = dict["title"] as? String else { return nil }
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.type = (dict["type"] as? String) ?? ""
|
||||
self.href = (dict["href"] as? String) ?? ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
final class FitnessRepository {
|
||||
static let shared = FitnessRepository()
|
||||
|
||||
var entries: [FoodEntry] = []
|
||||
var goals: DailyGoal = DailyGoal()
|
||||
var isLoading = false
|
||||
var error: String?
|
||||
|
||||
private let api = FitnessAPI()
|
||||
|
||||
func loadDay(date: String) async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
async let e = api.getEntries(date: date)
|
||||
async let g = api.getGoals(date: date)
|
||||
entries = try await e
|
||||
goals = try await g
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func deleteEntry(id: Int) async {
|
||||
do {
|
||||
try await api.deleteEntry(id: id)
|
||||
entries.removeAll { $0.id == id }
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func addEntry(_ req: CreateEntryRequest) async {
|
||||
do {
|
||||
let entry = try await api.createEntry(req)
|
||||
entries.append(entry)
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func updateEntry(id: Int, quantity: Double) async -> FoodEntry? {
|
||||
do {
|
||||
let updated = try await api.updateEntry(id: id, quantity: quantity)
|
||||
if let idx = entries.firstIndex(where: { $0.id == id }) {
|
||||
entries[idx] = updated
|
||||
}
|
||||
return updated
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Computed helpers
|
||||
var totalCalories: Double { entries.reduce(0) { $0 + $1.calories * $1.quantity } }
|
||||
var totalProtein: Double { entries.reduce(0) { $0 + $1.protein * $1.quantity } }
|
||||
var totalCarbs: Double { entries.reduce(0) { $0 + $1.carbs * $1.quantity } }
|
||||
var totalFat: Double { entries.reduce(0) { $0 + $1.fat * $1.quantity } }
|
||||
|
||||
func entriesForMeal(_ meal: MealType) -> [FoodEntry] {
|
||||
entries.filter { $0.mealType == meal }
|
||||
}
|
||||
|
||||
func mealCalories(_ meal: MealType) -> Double {
|
||||
entriesForMeal(meal).reduce(0) { $0 + $1.calories * $1.quantity }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
final class FoodSearchViewModel {
|
||||
var query = ""
|
||||
var results: [FoodItem] = []
|
||||
var recentFoods: [FoodItem] = []
|
||||
var allFoods: [FoodItem] = []
|
||||
var isSearching = false
|
||||
var isLoadingInitial = false
|
||||
|
||||
private let api = FitnessAPI()
|
||||
private var searchTask: Task<Void, Never>?
|
||||
|
||||
func loadInitial() async {
|
||||
isLoadingInitial = true
|
||||
do {
|
||||
async let r = api.getRecentFoods()
|
||||
async let a = api.getFoods()
|
||||
recentFoods = try await r
|
||||
allFoods = try await a
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
isLoadingInitial = false
|
||||
}
|
||||
|
||||
func search() {
|
||||
searchTask?.cancel()
|
||||
let q = query.trimmingCharacters(in: .whitespaces)
|
||||
guard q.count >= 2 else {
|
||||
results = []
|
||||
isSearching = false
|
||||
return
|
||||
}
|
||||
isSearching = true
|
||||
searchTask = Task {
|
||||
do {
|
||||
let items = try await api.searchFoods(query: q)
|
||||
if !Task.isCancelled {
|
||||
results = items
|
||||
isSearching = false
|
||||
}
|
||||
} catch {
|
||||
if !Task.isCancelled {
|
||||
isSearching = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
final class GoalsViewModel {
|
||||
var calories: String = ""
|
||||
var protein: String = ""
|
||||
var carbs: String = ""
|
||||
var fat: String = ""
|
||||
var sugar: String = ""
|
||||
var fiber: String = ""
|
||||
var isLoading = false
|
||||
var isSaving = false
|
||||
var error: String?
|
||||
var saved = false
|
||||
|
||||
private let api = FitnessAPI()
|
||||
|
||||
func load(date: String) async {
|
||||
isLoading = true
|
||||
do {
|
||||
let goals = try await api.getGoals(date: date)
|
||||
calories = String(Int(goals.calories))
|
||||
protein = String(Int(goals.protein))
|
||||
carbs = String(Int(goals.carbs))
|
||||
fat = String(Int(goals.fat))
|
||||
sugar = String(Int(goals.sugar))
|
||||
fiber = String(Int(goals.fiber))
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func save() async {
|
||||
isSaving = true
|
||||
error = nil
|
||||
saved = false
|
||||
let req = UpdateGoalsRequest(
|
||||
calories: Double(calories) ?? 2000,
|
||||
protein: Double(protein) ?? 150,
|
||||
carbs: Double(carbs) ?? 250,
|
||||
fat: Double(fat) ?? 65,
|
||||
sugar: Double(sugar) ?? 50,
|
||||
fiber: Double(fiber) ?? 30
|
||||
)
|
||||
do {
|
||||
_ = try await api.updateGoals(req)
|
||||
saved = true
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
final class TemplatesViewModel {
|
||||
var templates: [MealTemplate] = []
|
||||
var isLoading = false
|
||||
var error: String?
|
||||
var logSuccess: String?
|
||||
|
||||
private let api = FitnessAPI()
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
templates = try await api.getTemplates()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func logTemplate(_ template: MealTemplate, date: String) async {
|
||||
do {
|
||||
try await api.logTemplate(id: template.id, date: date)
|
||||
logSuccess = "Logged \(template.name)"
|
||||
// Refresh the repository
|
||||
await FitnessRepository.shared.loadDay(date: date)
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
var groupedByMeal: [MealType: [MealTemplate]] {
|
||||
Dictionary(grouping: templates, by: { $0.mealType })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
final class TodayViewModel {
|
||||
var selectedDate: Date = Date()
|
||||
let repository = FitnessRepository.shared
|
||||
|
||||
var dateString: String {
|
||||
selectedDate.apiDateString
|
||||
}
|
||||
|
||||
var displayDate: String {
|
||||
if selectedDate.isToday {
|
||||
return "Today"
|
||||
}
|
||||
return selectedDate.displayString
|
||||
}
|
||||
|
||||
func load() async {
|
||||
await repository.loadDay(date: dateString)
|
||||
}
|
||||
|
||||
func previousDay() {
|
||||
selectedDate = Calendar.current.date(byAdding: .day, value: -1, to: selectedDate) ?? selectedDate
|
||||
Task { await load() }
|
||||
}
|
||||
|
||||
func nextDay() {
|
||||
selectedDate = Calendar.current.date(byAdding: .day, value: 1, to: selectedDate) ?? selectedDate
|
||||
Task { await load() }
|
||||
}
|
||||
|
||||
func deleteEntry(_ entry: FoodEntry) async {
|
||||
await repository.deleteEntry(id: entry.id)
|
||||
}
|
||||
}
|
||||
171
ios/Platform/Platform/Features/Fitness/Views/AddFoodSheet.swift
Normal file
171
ios/Platform/Platform/Features/Fitness/Views/AddFoodSheet.swift
Normal file
@@ -0,0 +1,171 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AddFoodSheet: View {
|
||||
let food: FoodItem
|
||||
let mealType: MealType
|
||||
let dateString: String
|
||||
let onAdded: () -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var quantity: Double = 1.0
|
||||
@State private var selectedMeal: MealType
|
||||
@State private var isAdding = false
|
||||
|
||||
init(food: FoodItem, mealType: MealType, dateString: String, onAdded: @escaping () -> Void) {
|
||||
self.food = food
|
||||
self.mealType = mealType
|
||||
self.dateString = dateString
|
||||
self.onAdded = onAdded
|
||||
_selectedMeal = State(initialValue: mealType)
|
||||
}
|
||||
|
||||
private var scaledCalories: Double { food.calories * quantity }
|
||||
private var scaledProtein: Double { food.protein * quantity }
|
||||
private var scaledCarbs: Double { food.carbs * quantity }
|
||||
private var scaledFat: Double { food.fat * quantity }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
// Food header
|
||||
VStack(spacing: 8) {
|
||||
if let img = food.imageFilename {
|
||||
AsyncImage(url: URL(string: "\(Config.gatewayURL)/api/fitness/images/\(img)")) { image in
|
||||
image.resizable().aspectRatio(contentMode: .fill)
|
||||
} placeholder: {
|
||||
Color.surfaceSecondary
|
||||
}
|
||||
.frame(width: 80, height: 80)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
Text(food.name)
|
||||
.font(.title3.bold())
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
|
||||
if let serving = food.servingSize {
|
||||
Text(serving)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
// Quantity
|
||||
VStack(spacing: 8) {
|
||||
Text("Quantity")
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
HStack(spacing: 16) {
|
||||
Button { if quantity > 0.5 { quantity -= 0.5 } } label: {
|
||||
Image(systemName: "minus.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(Color.accent)
|
||||
}
|
||||
Text(String(format: "%.1f", quantity))
|
||||
.font(.title2.bold())
|
||||
.frame(minWidth: 60)
|
||||
Button { quantity += 0.5 } label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(Color.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Meal picker
|
||||
VStack(spacing: 8) {
|
||||
Text("Meal")
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
HStack(spacing: 8) {
|
||||
ForEach(MealType.allCases) { meal in
|
||||
Button {
|
||||
selectedMeal = meal
|
||||
} label: {
|
||||
Text(meal.displayName)
|
||||
.font(.caption.bold())
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(selectedMeal == meal ? meal.color.opacity(0.2) : Color.surfaceSecondary)
|
||||
.foregroundStyle(selectedMeal == meal ? meal.color : Color.textSecondary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nutrition preview
|
||||
VStack(spacing: 8) {
|
||||
nutritionRow("Calories", "\(Int(scaledCalories))")
|
||||
nutritionRow("Protein", "\(Int(scaledProtein))g")
|
||||
nutritionRow("Carbs", "\(Int(scaledCarbs))g")
|
||||
nutritionRow("Fat", "\(Int(scaledFat))g")
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.surfaceSecondary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
// Add button
|
||||
Button {
|
||||
isAdding = true
|
||||
Task {
|
||||
let req = CreateEntryRequest(
|
||||
foodId: food.id,
|
||||
foodName: food.name,
|
||||
mealType: selectedMeal.rawValue,
|
||||
quantity: quantity,
|
||||
entryDate: dateString,
|
||||
calories: food.calories,
|
||||
protein: food.protein,
|
||||
carbs: food.carbs,
|
||||
fat: food.fat,
|
||||
sugar: food.sugar,
|
||||
fiber: food.fiber
|
||||
)
|
||||
await FitnessRepository.shared.addEntry(req)
|
||||
isAdding = false
|
||||
dismiss()
|
||||
onAdded()
|
||||
}
|
||||
} label: {
|
||||
Group {
|
||||
if isAdding {
|
||||
ProgressView().tint(.white)
|
||||
} else {
|
||||
Text("Add")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Color.accent)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
.background(Color.canvas)
|
||||
.navigationTitle("Add Food")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func nutritionRow(_ label: String, _ value: String) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import SwiftUI
|
||||
|
||||
struct EntryDetailView: View {
|
||||
let entry: FoodEntry
|
||||
let dateString: String
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var quantity: Double
|
||||
@State private var isDeleting = false
|
||||
@State private var isSaving = false
|
||||
|
||||
init(entry: FoodEntry, dateString: String) {
|
||||
self.entry = entry
|
||||
self.dateString = dateString
|
||||
_quantity = State(initialValue: entry.quantity)
|
||||
}
|
||||
|
||||
private var scaledCalories: Double { entry.calories * quantity }
|
||||
private var scaledProtein: Double { entry.protein * quantity }
|
||||
private var scaledCarbs: Double { entry.carbs * quantity }
|
||||
private var scaledFat: Double { entry.fat * quantity }
|
||||
private var scaledSugar: Double? { entry.sugar.map { $0 * quantity } }
|
||||
private var scaledFiber: Double? { entry.fiber.map { $0 * quantity } }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
// Food name
|
||||
Text(entry.foodName)
|
||||
.font(.title2.bold())
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
|
||||
// Meal badge
|
||||
HStack {
|
||||
Image(systemName: entry.mealType.icon)
|
||||
Text(entry.mealType.displayName)
|
||||
}
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(entry.mealType.color)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(entry.mealType.color.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
// Quantity editor
|
||||
VStack(spacing: 8) {
|
||||
Text("Quantity")
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
HStack(spacing: 16) {
|
||||
Button { if quantity > 0.5 { quantity -= 0.5 } } label: {
|
||||
Image(systemName: "minus.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(Color.accent)
|
||||
}
|
||||
Text(String(format: "%.1f", quantity))
|
||||
.font(.title2.bold())
|
||||
.frame(minWidth: 60)
|
||||
Button { quantity += 0.5 } label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(Color.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if quantity != entry.quantity {
|
||||
Button {
|
||||
isSaving = true
|
||||
Task {
|
||||
_ = await FitnessRepository.shared.updateEntry(id: entry.id, quantity: quantity)
|
||||
isSaving = false
|
||||
dismiss()
|
||||
}
|
||||
} label: {
|
||||
Group {
|
||||
if isSaving {
|
||||
ProgressView().tint(.white)
|
||||
} else {
|
||||
Text("Update Quantity")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 44)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Color.accent)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
// Nutrition grid
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
|
||||
nutritionCell("Calories", "\(Int(scaledCalories))", "kcal")
|
||||
nutritionCell("Protein", "\(Int(scaledProtein))", "g")
|
||||
nutritionCell("Carbs", "\(Int(scaledCarbs))", "g")
|
||||
nutritionCell("Fat", "\(Int(scaledFat))", "g")
|
||||
if let sugar = scaledSugar {
|
||||
nutritionCell("Sugar", "\(Int(sugar))", "g")
|
||||
}
|
||||
if let fiber = scaledFiber {
|
||||
nutritionCell("Fiber", "\(Int(fiber))", "g")
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
|
||||
// Delete
|
||||
Button(role: .destructive) {
|
||||
isDeleting = true
|
||||
Task {
|
||||
await FitnessRepository.shared.deleteEntry(id: entry.id)
|
||||
isDeleting = false
|
||||
dismiss()
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
if isDeleting {
|
||||
ProgressView().tint(.red)
|
||||
} else {
|
||||
Image(systemName: "trash")
|
||||
Text("Delete Entry")
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 44)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.red)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
.background(Color.canvas)
|
||||
.navigationTitle("Entry Detail")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func nutritionCell(_ label: String, _ value: String, _ unit: String) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
Text(value)
|
||||
.font(.title3.bold())
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
Text("\(label) (\(unit))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(12)
|
||||
.background(Color.surfaceSecondary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import SwiftUI
|
||||
|
||||
enum FitnessTab: String, CaseIterable {
|
||||
case today = "Today"
|
||||
case templates = "Templates"
|
||||
case goals = "Goals"
|
||||
case foods = "Foods"
|
||||
}
|
||||
|
||||
struct FitnessTabView: View {
|
||||
@State private var selectedTab: FitnessTab = .today
|
||||
@State private var showAssistant = false
|
||||
@State private var todayVM = TodayViewModel()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
VStack(spacing: 0) {
|
||||
// Tab bar
|
||||
HStack(spacing: 24) {
|
||||
ForEach(FitnessTab.allCases, id: \.self) { tab in
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
selectedTab = tab
|
||||
}
|
||||
} label: {
|
||||
Text(tab.rawValue)
|
||||
.font(.subheadline)
|
||||
.fontWeight(selectedTab == tab ? .bold : .medium)
|
||||
.foregroundStyle(selectedTab == tab ? Color.accent : Color.textSecondary)
|
||||
.padding(.bottom, 8)
|
||||
.overlay(alignment: .bottom) {
|
||||
if selectedTab == tab {
|
||||
Rectangle()
|
||||
.fill(Color.accent)
|
||||
.frame(height: 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 60)
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
// Content
|
||||
TabView(selection: $selectedTab) {
|
||||
TodayView(viewModel: todayVM)
|
||||
.tag(FitnessTab.today)
|
||||
TemplatesView(dateString: todayVM.dateString)
|
||||
.tag(FitnessTab.templates)
|
||||
GoalsView(dateString: todayVM.dateString)
|
||||
.tag(FitnessTab.goals)
|
||||
FoodLibraryView(dateString: todayVM.dateString)
|
||||
.tag(FitnessTab.foods)
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
}
|
||||
|
||||
// Floating + button
|
||||
Button {
|
||||
showAssistant = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.font(.title2.bold())
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 56, height: 56)
|
||||
.background(Color.accent)
|
||||
.clipShape(Circle())
|
||||
.shadow(color: Color.accent.opacity(0.3), radius: 8, y: 4)
|
||||
}
|
||||
.padding(.trailing, 20)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
.background(Color.canvas.ignoresSafeArea())
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
.sheet(isPresented: $showAssistant) {
|
||||
AssistantChatView(entryDate: todayVM.dateString) {
|
||||
Task { await todayVM.load() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FoodLibraryView: View {
|
||||
let dateString: String
|
||||
@State private var vm = FoodSearchViewModel()
|
||||
@State private var selectedFood: FoodItem?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Search bar
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(Color.textTertiary)
|
||||
TextField("Search foods...", text: $vm.query)
|
||||
.textFieldStyle(.plain)
|
||||
.autocorrectionDisabled()
|
||||
.onChange(of: vm.query) { _, _ in
|
||||
vm.search()
|
||||
}
|
||||
if !vm.query.isEmpty {
|
||||
Button { vm.query = ""; vm.results = [] } label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(Color.textTertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.surfaceSecondary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
|
||||
if vm.isLoadingInitial {
|
||||
LoadingView()
|
||||
} else {
|
||||
let foods = vm.query.count >= 2 ? vm.results : vm.allFoods
|
||||
if foods.isEmpty {
|
||||
EmptyStateView(icon: "fork.knife", title: "No foods", subtitle: "Your food library is empty")
|
||||
} else {
|
||||
List(foods) { food in
|
||||
Button {
|
||||
selectedFood = food
|
||||
} label: {
|
||||
HStack {
|
||||
if let img = food.imageFilename {
|
||||
AsyncImage(url: URL(string: "\(Config.gatewayURL)/api/fitness/images/\(img)")) { image in
|
||||
image.resizable().aspectRatio(contentMode: .fill)
|
||||
} placeholder: {
|
||||
Color.surfaceSecondary
|
||||
}
|
||||
.frame(width: 40, height: 40)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(food.name)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
HStack(spacing: 8) {
|
||||
Text("\(Int(food.calories)) cal")
|
||||
Text("P:\(Int(food.protein))g")
|
||||
Text("C:\(Int(food.carbs))g")
|
||||
Text("F:\(Int(food.fat))g")
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await vm.loadInitial()
|
||||
}
|
||||
.sheet(item: $selectedFood) { food in
|
||||
AddFoodSheet(food: food, mealType: .snack, dateString: dateString) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FoodSearchView: View {
|
||||
let mealType: MealType
|
||||
let dateString: String
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var vm = FoodSearchViewModel()
|
||||
@State private var selectedFood: FoodItem?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
// Search bar
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(Color.textTertiary)
|
||||
TextField("Search foods...", text: $vm.query)
|
||||
.textFieldStyle(.plain)
|
||||
.autocorrectionDisabled()
|
||||
.onChange(of: vm.query) { _, _ in
|
||||
vm.search()
|
||||
}
|
||||
if !vm.query.isEmpty {
|
||||
Button { vm.query = ""; vm.results = [] } label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(Color.textTertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.surfaceSecondary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
|
||||
if vm.isSearching || vm.isLoadingInitial {
|
||||
LoadingView()
|
||||
} else if !vm.query.isEmpty && vm.query.count >= 2 {
|
||||
// Search results
|
||||
List {
|
||||
Section {
|
||||
ForEach(vm.results) { food in
|
||||
foodRow(food)
|
||||
}
|
||||
} header: {
|
||||
Text("\(vm.results.count) results")
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
} else {
|
||||
// Default: recent + all
|
||||
List {
|
||||
if !vm.recentFoods.isEmpty {
|
||||
Section("Recent") {
|
||||
ForEach(vm.recentFoods) { food in
|
||||
foodRow(food)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !vm.allFoods.isEmpty {
|
||||
Section("All Foods") {
|
||||
ForEach(vm.allFoods) { food in
|
||||
foodRow(food)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
}
|
||||
.background(Color.canvas)
|
||||
.navigationTitle("Add Food")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await vm.loadInitial()
|
||||
}
|
||||
.sheet(item: $selectedFood) { food in
|
||||
AddFoodSheet(food: food, mealType: mealType, dateString: dateString) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func foodRow(_ food: FoodItem) -> some View {
|
||||
Button {
|
||||
selectedFood = food
|
||||
} label: {
|
||||
HStack {
|
||||
if let img = food.imageFilename {
|
||||
AsyncImage(url: URL(string: "\(Config.gatewayURL)/api/fitness/images/\(img)")) { image in
|
||||
image.resizable().aspectRatio(contentMode: .fill)
|
||||
} placeholder: {
|
||||
Color.surfaceSecondary
|
||||
}
|
||||
.frame(width: 40, height: 40)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(food.name)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
Text("\(Int(food.calories)) cal")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "plus.circle")
|
||||
.foregroundStyle(Color.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
85
ios/Platform/Platform/Features/Fitness/Views/GoalsView.swift
Normal file
85
ios/Platform/Platform/Features/Fitness/Views/GoalsView.swift
Normal file
@@ -0,0 +1,85 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GoalsView: View {
|
||||
let dateString: String
|
||||
@State private var vm = GoalsViewModel()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
if vm.isLoading {
|
||||
LoadingView()
|
||||
} else {
|
||||
VStack(spacing: 12) {
|
||||
goalField("Calories", value: $vm.calories, unit: "kcal")
|
||||
goalField("Protein", value: $vm.protein, unit: "g")
|
||||
goalField("Carbs", value: $vm.carbs, unit: "g")
|
||||
goalField("Fat", value: $vm.fat, unit: "g")
|
||||
goalField("Sugar", value: $vm.sugar, unit: "g")
|
||||
goalField("Fiber", value: $vm.fiber, unit: "g")
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
Button {
|
||||
Task { await vm.save() }
|
||||
} label: {
|
||||
Group {
|
||||
if vm.isSaving {
|
||||
ProgressView().tint(.white)
|
||||
} else {
|
||||
Text("Save Goals")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Color.accent)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal, 16)
|
||||
.disabled(vm.isSaving)
|
||||
|
||||
if vm.saved {
|
||||
Text("Goals saved!")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.emerald)
|
||||
}
|
||||
|
||||
if let err = vm.error {
|
||||
ErrorBanner(message: err)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 80)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.task {
|
||||
await vm.load(date: dateString)
|
||||
}
|
||||
}
|
||||
|
||||
private func goalField(_ label: String, value: Binding<String>, unit: String) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
.frame(width: 80, alignment: .leading)
|
||||
TextField("0", text: value)
|
||||
.keyboardType(.numberPad)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.subheadline.bold())
|
||||
.padding(10)
|
||||
.background(Color.surfaceSecondary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
Text(unit)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.textTertiary)
|
||||
.frame(width: 32)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MealSectionView: View {
|
||||
let meal: MealType
|
||||
let entries: [FoodEntry]
|
||||
let mealCalories: Double
|
||||
let onDelete: (FoodEntry) -> Void
|
||||
let dateString: String
|
||||
|
||||
@State private var isExpanded = true
|
||||
@State private var showFoodSearch = false
|
||||
@State private var selectedEntry: FoodEntry?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isExpanded.toggle()
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 0) {
|
||||
// Colored accent bar
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(meal.color)
|
||||
.frame(width: 4, height: 44)
|
||||
.padding(.trailing, 12)
|
||||
|
||||
Image(systemName: meal.icon)
|
||||
.font(.headline)
|
||||
.foregroundStyle(meal.color)
|
||||
.frame(width: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(meal.displayName)
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
Text("\(entries.count) item\(entries.count == 1 ? "" : "s")")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
}
|
||||
.padding(.leading, 8)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("\(Int(mealCalories)) cal")
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(meal.color)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Color.textTertiary)
|
||||
.rotationEffect(.degrees(isExpanded ? 90 : 0))
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
// Entries
|
||||
if isExpanded && !entries.isEmpty {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(entries) { entry in
|
||||
Button {
|
||||
selectedEntry = entry
|
||||
} label: {
|
||||
entryRow(entry)
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) {
|
||||
onDelete(entry)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.leading, 40)
|
||||
}
|
||||
|
||||
// Add button
|
||||
if isExpanded {
|
||||
Button {
|
||||
showFoodSearch = true
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.foregroundStyle(meal.color.opacity(0.6))
|
||||
Text("Add food")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.leading, 44)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(meal.color.opacity(0.03))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal, 16)
|
||||
.sheet(isPresented: $showFoodSearch) {
|
||||
FoodSearchView(mealType: meal, dateString: dateString)
|
||||
}
|
||||
.sheet(item: $selectedEntry) { entry in
|
||||
EntryDetailView(entry: entry, dateString: dateString)
|
||||
}
|
||||
}
|
||||
|
||||
private func entryRow(_ entry: FoodEntry) -> some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(entry.foodName)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
.lineLimit(1)
|
||||
if entry.quantity != 1 {
|
||||
Text("x\(String(format: "%.1f", entry.quantity))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Text("\(Int(entry.calories * entry.quantity))")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
103
ios/Platform/Platform/Features/Fitness/Views/TemplatesView.swift
Normal file
103
ios/Platform/Platform/Features/Fitness/Views/TemplatesView.swift
Normal file
@@ -0,0 +1,103 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TemplatesView: View {
|
||||
let dateString: String
|
||||
@State private var vm = TemplatesViewModel()
|
||||
@State private var templateToLog: MealTemplate?
|
||||
@State private var showConfirm = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
if vm.isLoading {
|
||||
LoadingView()
|
||||
} else if vm.templates.isEmpty {
|
||||
EmptyStateView(icon: "doc.on.doc", title: "No templates", subtitle: "Create meal templates from the web app")
|
||||
} else {
|
||||
ForEach(MealType.allCases) { meal in
|
||||
let templates = vm.groupedByMeal[meal] ?? []
|
||||
if !templates.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: meal.icon)
|
||||
.foregroundStyle(meal.color)
|
||||
Text(meal.displayName)
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
ForEach(templates) { template in
|
||||
templateCard(template)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let msg = vm.logSuccess {
|
||||
Text(msg)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.emerald)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
if let err = vm.error {
|
||||
ErrorBanner(message: err)
|
||||
}
|
||||
|
||||
Spacer(minLength: 80)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.task {
|
||||
await vm.load()
|
||||
}
|
||||
.alert("Log Meal", isPresented: $showConfirm, presenting: templateToLog) { template in
|
||||
Button("Log") {
|
||||
Task { await vm.logTemplate(template, date: dateString) }
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: { template in
|
||||
Text("Add \(template.name) to today's log?")
|
||||
}
|
||||
}
|
||||
|
||||
private func templateCard(_ template: MealTemplate) -> some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(template.name)
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
HStack(spacing: 8) {
|
||||
if let cal = template.totalCalories {
|
||||
Text("\(Int(cal)) cal")
|
||||
}
|
||||
if let items = template.itemCount {
|
||||
Text("\(items) items")
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
templateToLog = template
|
||||
showConfirm = true
|
||||
} label: {
|
||||
Text("Log meal")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.accent)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.background(Color.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.shadow(color: .black.opacity(0.03), radius: 4, y: 1)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
96
ios/Platform/Platform/Features/Fitness/Views/TodayView.swift
Normal file
96
ios/Platform/Platform/Features/Fitness/Views/TodayView.swift
Normal file
@@ -0,0 +1,96 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TodayView: View {
|
||||
@Bindable var viewModel: TodayViewModel
|
||||
@State private var showFoodSearch = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
// Date selector
|
||||
HStack {
|
||||
Button { viewModel.previousDay() } label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.accent)
|
||||
}
|
||||
Spacer()
|
||||
Text(viewModel.displayDate)
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
Spacer()
|
||||
Button { viewModel.nextDay() } label: {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.accent)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 8)
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 50)
|
||||
.onEnded { value in
|
||||
if value.translation.width > 0 {
|
||||
viewModel.previousDay()
|
||||
} else {
|
||||
viewModel.nextDay()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Macro summary card
|
||||
if !viewModel.repository.isLoading {
|
||||
macroSummaryCard
|
||||
}
|
||||
|
||||
// Error
|
||||
if let error = viewModel.repository.error {
|
||||
ErrorBanner(message: error) { await viewModel.load() }
|
||||
}
|
||||
|
||||
// Meal sections
|
||||
ForEach(MealType.allCases) { meal in
|
||||
MealSectionView(
|
||||
meal: meal,
|
||||
entries: viewModel.repository.entriesForMeal(meal),
|
||||
mealCalories: viewModel.repository.mealCalories(meal),
|
||||
onDelete: { entry in
|
||||
Task { await viewModel.deleteEntry(entry) }
|
||||
},
|
||||
dateString: viewModel.dateString
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(minLength: 80)
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.load()
|
||||
}
|
||||
.task {
|
||||
await viewModel.load()
|
||||
}
|
||||
}
|
||||
|
||||
private var macroSummaryCard: some View {
|
||||
let repo = viewModel.repository
|
||||
let goals = repo.goals
|
||||
|
||||
return VStack(spacing: 12) {
|
||||
HStack(spacing: 20) {
|
||||
LargeCalorieRing(consumed: repo.totalCalories, goal: goals.calories)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
MacroBar(label: "Protein", value: repo.totalProtein, goal: goals.protein, color: .blue)
|
||||
MacroBar(label: "Carbs", value: repo.totalCarbs, goal: goals.carbs, color: .orange)
|
||||
MacroBar(label: "Fat", value: repo.totalFat, goal: goals.fat, color: .purple)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.shadow(color: .black.opacity(0.04), radius: 8, y: 2)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
161
ios/Platform/Platform/Features/Home/HomeView.swift
Normal file
161
ios/Platform/Platform/Features/Home/HomeView.swift
Normal file
@@ -0,0 +1,161 @@
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
|
||||
struct HomeView: View {
|
||||
@Environment(AuthManager.self) private var authManager
|
||||
@State private var vm = HomeViewModel()
|
||||
@State private var showAssistant = false
|
||||
@State private var showProfileMenu = false
|
||||
@State private var selectedPhoto: PhotosPickerItem?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
// Background
|
||||
if let bg = vm.backgroundImage {
|
||||
Image(uiImage: bg)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.ignoresSafeArea()
|
||||
.overlay(Color.black.opacity(0.2).ignoresSafeArea())
|
||||
}
|
||||
else {
|
||||
Color.canvas.ignoresSafeArea()
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
// Top bar
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Dashboard")
|
||||
.font(.title2.bold())
|
||||
.foregroundStyle(vm.backgroundImage != nil ? .white : Color.textPrimary)
|
||||
if let name = authManager.user?.displayName ?? authManager.user?.username {
|
||||
Text("Welcome, \(name)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(vm.backgroundImage != nil ? .white.opacity(0.8) : Color.textSecondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Menu {
|
||||
PhotosPicker(selection: $selectedPhoto, matching: .images) {
|
||||
Label("Change Background", systemImage: "photo")
|
||||
}
|
||||
if vm.backgroundImage != nil {
|
||||
Button(role: .destructive) {
|
||||
vm.removeBackground()
|
||||
} label: {
|
||||
Label("Remove Background", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Button(role: .destructive) {
|
||||
authManager.logout()
|
||||
} label: {
|
||||
Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "person.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(vm.backgroundImage != nil ? .white : Color.accent)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 60)
|
||||
|
||||
// Widget grid
|
||||
LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) {
|
||||
calorieWidget
|
||||
quickStatsWidget
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
Spacer(minLength: 100)
|
||||
}
|
||||
}
|
||||
|
||||
// Floating + button
|
||||
Button {
|
||||
showAssistant = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.font(.title2.bold())
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 56, height: 56)
|
||||
.background(Color.accent)
|
||||
.clipShape(Circle())
|
||||
.shadow(color: Color.accent.opacity(0.3), radius: 8, y: 4)
|
||||
}
|
||||
.padding(.trailing, 20)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
.sheet(isPresented: $showAssistant) {
|
||||
AssistantChatView(entryDate: Date().apiDateString) {
|
||||
Task { await vm.loadData() }
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedPhoto) { _, newValue in
|
||||
guard let item = newValue else { return }
|
||||
Task {
|
||||
if let data = try? await item.loadTransferable(type: Data.self),
|
||||
let image = UIImage(data: data) {
|
||||
vm.setBackground(image)
|
||||
}
|
||||
selectedPhoto = nil
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await vm.loadData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var calorieWidget: some View {
|
||||
let hasBg = vm.backgroundImage != nil
|
||||
return VStack(spacing: 8) {
|
||||
LargeCalorieRing(consumed: vm.caloriesConsumed, goal: vm.caloriesGoal)
|
||||
Text("Calories")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(hasBg ? .white : Color.textSecondary)
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background {
|
||||
if hasBg {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(.ultraThinMaterial)
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color.surface)
|
||||
.shadow(color: .black.opacity(0.04), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var quickStatsWidget: some View {
|
||||
let hasBg = vm.backgroundImage != nil
|
||||
let repo = FitnessRepository.shared
|
||||
return VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Macros")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(hasBg ? .white : Color.textSecondary)
|
||||
MacroBar(label: "Protein", value: repo.totalProtein, goal: repo.goals.protein, color: .blue, compact: true)
|
||||
MacroBar(label: "Carbs", value: repo.totalCarbs, goal: repo.goals.carbs, color: .orange, compact: true)
|
||||
MacroBar(label: "Fat", value: repo.totalFat, goal: repo.goals.fat, color: .purple, compact: true)
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background {
|
||||
if hasBg {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(.ultraThinMaterial)
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color.surface)
|
||||
.shadow(color: .black.opacity(0.04), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
54
ios/Platform/Platform/Features/Home/HomeViewModel.swift
Normal file
54
ios/Platform/Platform/Features/Home/HomeViewModel.swift
Normal file
@@ -0,0 +1,54 @@
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
|
||||
@Observable
|
||||
final class HomeViewModel {
|
||||
var backgroundImage: UIImage?
|
||||
var caloriesConsumed: Double = 0
|
||||
var caloriesGoal: Double = 2000
|
||||
var isLoading = false
|
||||
|
||||
private let bgKey = "homeBackgroundImage"
|
||||
private let repo = FitnessRepository.shared
|
||||
|
||||
init() {
|
||||
loadBackgroundFromDefaults()
|
||||
}
|
||||
|
||||
func loadData() async {
|
||||
isLoading = true
|
||||
let today = Date().apiDateString
|
||||
await repo.loadDay(date: today)
|
||||
caloriesConsumed = repo.totalCalories
|
||||
caloriesGoal = repo.goals.calories
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func setBackground(_ image: UIImage) {
|
||||
// Resize to max 1200px
|
||||
let maxDim: CGFloat = 1200
|
||||
let scale = min(maxDim / image.size.width, maxDim / image.size.height, 1.0)
|
||||
let newSize = CGSize(width: image.size.width * scale, height: image.size.height * scale)
|
||||
UIGraphicsBeginImageContextWithOptions(newSize, false, 1.0)
|
||||
image.draw(in: CGRect(origin: .zero, size: newSize))
|
||||
let resized = UIGraphicsGetImageFromCurrentImageContext()
|
||||
UIGraphicsEndImageContext()
|
||||
|
||||
if let resized, let data = resized.jpegData(compressionQuality: 0.8) {
|
||||
UserDefaults.standard.set(data, forKey: bgKey)
|
||||
backgroundImage = resized
|
||||
}
|
||||
}
|
||||
|
||||
func removeBackground() {
|
||||
UserDefaults.standard.removeObject(forKey: bgKey)
|
||||
backgroundImage = nil
|
||||
}
|
||||
|
||||
private func loadBackgroundFromDefaults() {
|
||||
if let data = UserDefaults.standard.data(forKey: bgKey),
|
||||
let img = UIImage(data: data) {
|
||||
backgroundImage = img
|
||||
}
|
||||
}
|
||||
}
|
||||
10
ios/Platform/Platform/Info.plist
Normal file
10
ios/Platform/Platform/Info.plist
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
13
ios/Platform/Platform/PlatformApp.swift
Normal file
13
ios/Platform/Platform/PlatformApp.swift
Normal file
@@ -0,0 +1,13 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct PlatformApp: App {
|
||||
@State private var authManager = AuthManager()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environment(authManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
66
ios/Platform/Platform/Shared/Components/LoadingView.swift
Normal file
66
ios/Platform/Platform/Shared/Components/LoadingView.swift
Normal file
@@ -0,0 +1,66 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LoadingView: View {
|
||||
var message: String = "Loading..."
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
ProgressView()
|
||||
.tint(.accent)
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
struct ErrorBanner: View {
|
||||
let message: String
|
||||
var retry: (() async -> Void)?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
Spacer()
|
||||
if let retry = retry {
|
||||
Button("Retry") {
|
||||
Task { await retry() }
|
||||
}
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(Color.accent)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.orange.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyStateView: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let subtitle: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(Color.textTertiary)
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(40)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
44
ios/Platform/Platform/Shared/Components/MacroBar.swift
Normal file
44
ios/Platform/Platform/Shared/Components/MacroBar.swift
Normal file
@@ -0,0 +1,44 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MacroBar: View {
|
||||
let label: String
|
||||
let value: Double
|
||||
let goal: Double
|
||||
let color: Color
|
||||
var compact: Bool = false
|
||||
|
||||
private var progress: Double {
|
||||
guard goal > 0 else { return 0 }
|
||||
return min(max(value / goal, 0), 1)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: compact ? 2 : 4) {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(compact ? .caption2 : .caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
Spacer()
|
||||
Text("\(Int(value))/\(Int(goal))g")
|
||||
.font(compact ? .caption2 : .caption)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
}
|
||||
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: compact ? 2 : 3)
|
||||
.fill(color.opacity(0.15))
|
||||
.frame(height: compact ? 4 : 6)
|
||||
|
||||
RoundedRectangle(cornerRadius: compact ? 2 : 3)
|
||||
.fill(color)
|
||||
.frame(width: max(0, geo.size.width * progress), height: compact ? 4 : 6)
|
||||
.animation(.easeInOut(duration: 0.5), value: progress)
|
||||
}
|
||||
}
|
||||
.frame(height: compact ? 4 : 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
83
ios/Platform/Platform/Shared/Components/MacroRing.swift
Normal file
83
ios/Platform/Platform/Shared/Components/MacroRing.swift
Normal file
@@ -0,0 +1,83 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MacroRing: View {
|
||||
let consumed: Double
|
||||
let goal: Double
|
||||
let color: Color
|
||||
var size: CGFloat = 80
|
||||
var lineWidth: CGFloat = 8
|
||||
var showLabel: Bool = true
|
||||
var labelFontSize: CGFloat = 14
|
||||
|
||||
private var progress: Double {
|
||||
guard goal > 0 else { return 0 }
|
||||
return min(max(consumed / goal, 0), 1)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(color.opacity(0.15), lineWidth: lineWidth)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: progress)
|
||||
.stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.easeInOut(duration: 0.6), value: progress)
|
||||
|
||||
if showLabel {
|
||||
VStack(spacing: 0) {
|
||||
Text("\(Int(consumed))")
|
||||
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
if goal > 0 {
|
||||
Text("/ \(Int(goal))")
|
||||
.font(.system(size: labelFontSize * 0.65, weight: .medium, design: .rounded))
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
struct LargeCalorieRing: View {
|
||||
let consumed: Double
|
||||
let goal: Double
|
||||
|
||||
private var remaining: Int {
|
||||
max(0, Int(goal) - Int(consumed))
|
||||
}
|
||||
|
||||
private var progress: Double {
|
||||
guard goal > 0 else { return 0 }
|
||||
return min(max(consumed / goal, 0), 1)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(Color.emerald.opacity(0.15), lineWidth: 14)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: progress)
|
||||
.stroke(
|
||||
Color.emerald,
|
||||
style: StrokeStyle(lineWidth: 14, lineCap: .round)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.easeInOut(duration: 0.8), value: progress)
|
||||
|
||||
VStack(spacing: 2) {
|
||||
Text("\(Int(consumed))")
|
||||
.font(.system(size: 28, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
Text("\(remaining) left")
|
||||
.font(.system(size: 12, weight: .medium, design: .rounded))
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
}
|
||||
}
|
||||
.frame(width: 120, height: 120)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Color {
|
||||
init(hex: String) {
|
||||
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||
var int: UInt64 = 0
|
||||
Scanner(string: hex).scanHexInt64(&int)
|
||||
let a, r, g, b: UInt64
|
||||
switch hex.count {
|
||||
case 6:
|
||||
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
|
||||
case 8:
|
||||
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
|
||||
default:
|
||||
(a, r, g, b) = (255, 0, 0, 0)
|
||||
}
|
||||
self.init(
|
||||
.sRGB,
|
||||
red: Double(r) / 255,
|
||||
green: Double(g) / 255,
|
||||
blue: Double(b) / 255,
|
||||
opacity: Double(a) / 255
|
||||
)
|
||||
}
|
||||
|
||||
// Core palette
|
||||
static let canvas = Color(hex: "F5EFE6")
|
||||
static let surface = Color(hex: "FFFFFF")
|
||||
static let surfaceSecondary = Color(hex: "FAF7F2")
|
||||
static let accent = Color(hex: "8B6914")
|
||||
static let accentLight = Color(hex: "D4A843")
|
||||
static let emerald = Color(hex: "059669")
|
||||
static let textPrimary = Color(hex: "1C1917")
|
||||
static let textSecondary = Color(hex: "78716C")
|
||||
static let textTertiary = Color(hex: "A8A29E")
|
||||
static let border = Color(hex: "E7E5E4")
|
||||
|
||||
// Meal colors
|
||||
static let breakfastColor = Color(hex: "F59E0B")
|
||||
static let lunchColor = Color(hex: "059669")
|
||||
static let dinnerColor = Color(hex: "8B5CF6")
|
||||
static let snackColor = Color(hex: "EC4899")
|
||||
|
||||
// Chat
|
||||
static let userBubble = Color(hex: "8B6914").opacity(0.15)
|
||||
static let assistantBubble = Color(hex: "F5F5F4")
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import Foundation
|
||||
|
||||
extension Date {
|
||||
var apiDateString: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
var displayString: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "EEEE, MMM d"
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
var shortDisplayString: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMM d, yyyy"
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
var isToday: Bool {
|
||||
Calendar.current.isDateInToday(self)
|
||||
}
|
||||
|
||||
static func from(apiString: String) -> Date? {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
return formatter.date(from: apiString)
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@ FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libpq-dev tesseract-ocr tesseract-ocr-eng && rm -rf /var/lib/apt/lists/*
|
||||
RUN pip install --no-cache-dir --upgrade pip
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libpq-dev tesseract-ocr tesseract-ocr-eng ffmpeg && rm -rf /var/lib/apt/lists/*
|
||||
RUN pip install --no-cache-dir --upgrade pip yt-dlp
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
@@ -13,10 +13,10 @@ from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.api.deps import get_user_id, get_db_session
|
||||
from app.config import FOLDERS, TAGS
|
||||
from app.models.item import Item, ItemAsset
|
||||
from app.models.item import Item, ItemAsset, ItemAddition
|
||||
from app.models.schema import (
|
||||
ItemCreate, ItemUpdate, ItemOut, ItemList, SearchQuery, SemanticSearchQuery,
|
||||
HybridSearchQuery, SearchResult, ConfigOut,
|
||||
HybridSearchQuery, SearchResult, ConfigOut, ItemAdditionCreate, ItemAdditionOut,
|
||||
)
|
||||
from app.services.storage import storage
|
||||
from fastapi.responses import Response
|
||||
@@ -25,6 +25,46 @@ from app.worker.tasks import enqueue_process_item
|
||||
router = APIRouter(prefix="/api", tags=["brain"])
|
||||
|
||||
|
||||
async def refresh_item_search_state(db: AsyncSession, item: Item):
|
||||
"""Recompute embedding + Meilisearch doc after assistant additions change."""
|
||||
from app.search.engine import index_item
|
||||
from app.services.embed import generate_embedding
|
||||
|
||||
additions_result = await db.execute(
|
||||
select(ItemAddition)
|
||||
.where(ItemAddition.item_id == item.id, ItemAddition.user_id == item.user_id)
|
||||
.order_by(ItemAddition.created_at.asc())
|
||||
)
|
||||
additions = additions_result.scalars().all()
|
||||
additions_text = "\n\n".join(addition.content for addition in additions if addition.content.strip())
|
||||
|
||||
searchable_text_parts = [item.raw_content or "", item.extracted_text or "", additions_text]
|
||||
searchable_text = "\n\n".join(part.strip() for part in searchable_text_parts if part and part.strip())
|
||||
|
||||
embed_text = f"{item.title or ''}\n{item.summary or ''}\n{searchable_text}".strip()
|
||||
embedding = await generate_embedding(embed_text)
|
||||
if embedding:
|
||||
item.embedding = embedding
|
||||
|
||||
item.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
|
||||
await index_item({
|
||||
"id": item.id,
|
||||
"user_id": item.user_id,
|
||||
"type": item.type,
|
||||
"title": item.title,
|
||||
"url": item.url,
|
||||
"folder": item.folder,
|
||||
"tags": item.tags or [],
|
||||
"summary": item.summary,
|
||||
"extracted_text": searchable_text[:10000],
|
||||
"processing_status": item.processing_status,
|
||||
"created_at": item.created_at.isoformat() if item.created_at else None,
|
||||
})
|
||||
|
||||
|
||||
# ── Health ──
|
||||
|
||||
@router.get("/health")
|
||||
@@ -201,14 +241,31 @@ async def update_item(
|
||||
item.title = body.title
|
||||
if body.folder is not None:
|
||||
item.folder = body.folder
|
||||
# Update folder_id FK
|
||||
from app.models.taxonomy import Folder as FolderModel
|
||||
folder_row = (await db.execute(
|
||||
select(FolderModel).where(FolderModel.user_id == user_id, FolderModel.name == body.folder)
|
||||
)).scalar_one_or_none()
|
||||
item.folder_id = folder_row.id if folder_row else None
|
||||
if body.tags is not None:
|
||||
item.tags = body.tags
|
||||
# Update item_tags relational entries
|
||||
from app.models.taxonomy import Tag as TagModel, ItemTag
|
||||
from sqlalchemy import delete as sa_delete
|
||||
await db.execute(sa_delete(ItemTag).where(ItemTag.item_id == item.id))
|
||||
for tag_name in body.tags:
|
||||
tag_row = (await db.execute(
|
||||
select(TagModel).where(TagModel.user_id == user_id, TagModel.name == tag_name)
|
||||
)).scalar_one_or_none()
|
||||
if tag_row:
|
||||
db.add(ItemTag(item_id=item.id, tag_id=tag_row.id))
|
||||
if body.raw_content is not None:
|
||||
item.raw_content = body.raw_content
|
||||
|
||||
item.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
await refresh_item_search_state(db, item)
|
||||
return item
|
||||
|
||||
|
||||
@@ -238,6 +295,100 @@ async def delete_item(
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.get("/items/{item_id}/additions", response_model=list[ItemAdditionOut])
|
||||
async def list_item_additions(
|
||||
item_id: str,
|
||||
user_id: str = Depends(get_user_id),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
item = (await db.execute(
|
||||
select(Item).where(Item.id == item_id, Item.user_id == user_id)
|
||||
)).scalar_one_or_none()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
additions = (await db.execute(
|
||||
select(ItemAddition)
|
||||
.where(ItemAddition.item_id == item_id, ItemAddition.user_id == user_id)
|
||||
.order_by(ItemAddition.created_at.asc())
|
||||
)).scalars().all()
|
||||
return additions
|
||||
|
||||
|
||||
@router.post("/items/{item_id}/additions", response_model=ItemAdditionOut, status_code=201)
|
||||
async def create_item_addition(
|
||||
item_id: str,
|
||||
body: ItemAdditionCreate,
|
||||
user_id: str = Depends(get_user_id),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
item = (await db.execute(
|
||||
select(Item).where(Item.id == item_id, Item.user_id == user_id)
|
||||
)).scalar_one_or_none()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
content = body.content.strip()
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail="Addition content cannot be empty")
|
||||
|
||||
addition = ItemAddition(
|
||||
id=str(uuid.uuid4()),
|
||||
item_id=item.id,
|
||||
user_id=user_id,
|
||||
source=(body.source or "assistant").strip() or "assistant",
|
||||
kind=(body.kind or "append").strip() or "append",
|
||||
content=content,
|
||||
metadata_json=body.metadata_json or {},
|
||||
)
|
||||
db.add(addition)
|
||||
item.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(addition)
|
||||
|
||||
result = await db.execute(
|
||||
select(Item).where(Item.id == item.id, Item.user_id == user_id)
|
||||
)
|
||||
fresh_item = result.scalar_one()
|
||||
await refresh_item_search_state(db, fresh_item)
|
||||
return addition
|
||||
|
||||
|
||||
@router.delete("/items/{item_id}/additions/{addition_id}")
|
||||
async def delete_item_addition(
|
||||
item_id: str,
|
||||
addition_id: str,
|
||||
user_id: str = Depends(get_user_id),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
item = (await db.execute(
|
||||
select(Item).where(Item.id == item_id, Item.user_id == user_id)
|
||||
)).scalar_one_or_none()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
addition = (await db.execute(
|
||||
select(ItemAddition).where(
|
||||
ItemAddition.id == addition_id,
|
||||
ItemAddition.item_id == item_id,
|
||||
ItemAddition.user_id == user_id,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if not addition:
|
||||
raise HTTPException(status_code=404, detail="Addition not found")
|
||||
|
||||
await db.delete(addition)
|
||||
item.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
result = await db.execute(
|
||||
select(Item).where(Item.id == item.id, Item.user_id == user_id)
|
||||
)
|
||||
fresh_item = result.scalar_one()
|
||||
await refresh_item_search_state(db, fresh_item)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
# ── Reprocess item ──
|
||||
|
||||
@router.post("/items/{item_id}/reprocess", response_model=ItemOut)
|
||||
@@ -335,5 +486,7 @@ async def serve_asset(item_id: str, asset_type: str, filename: str):
|
||||
elif filename.endswith(".jpg") or filename.endswith(".jpeg"): ct = "image/jpeg"
|
||||
elif filename.endswith(".html"): ct = "text/html"
|
||||
elif filename.endswith(".pdf"): ct = "application/pdf"
|
||||
elif filename.endswith(".mp4"): ct = "video/mp4"
|
||||
elif filename.endswith(".webm"): ct = "video/webm"
|
||||
|
||||
return Response(content=data, media_type=ct, headers={"Cache-Control": "public, max-age=3600"})
|
||||
|
||||
@@ -17,8 +17,8 @@ MEILI_URL = os.environ.get("MEILI_URL", "http://brain-meili:7700")
|
||||
MEILI_KEY = os.environ.get("MEILI_MASTER_KEY", "brain-meili-key")
|
||||
MEILI_INDEX = "items"
|
||||
|
||||
# ── Browserless ──
|
||||
BROWSERLESS_URL = os.environ.get("BROWSERLESS_URL", "http://brain-browserless:3000")
|
||||
# ── Crawler ──
|
||||
CRAWLER_URL = os.environ.get("CRAWLER_URL", "http://brain-crawler:3100")
|
||||
|
||||
# ── OpenAI ──
|
||||
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
|
||||
@@ -42,14 +42,14 @@ DEBUG = os.environ.get("DEBUG", "").lower() in ("1", "true")
|
||||
|
||||
# ── Classification rules ──
|
||||
FOLDERS = [
|
||||
"Home", "Family", "Work", "Travel", "Knowledge", "Faith", "Projects"
|
||||
"Home", "Family", "Work", "Travel", "Islam",
|
||||
"Homelab", "Vanlife", "3D Printing", "Documents",
|
||||
]
|
||||
|
||||
TAGS = [
|
||||
"reference", "important", "legal", "financial", "insurance",
|
||||
"research", "idea", "guide", "tutorial", "setup", "how-to",
|
||||
"tools", "dev", "server", "selfhosted", "home-assistant",
|
||||
"shopping", "compare", "buy", "product",
|
||||
"family", "kids", "health", "travel", "faith",
|
||||
"video", "read-later", "books",
|
||||
"diy", "reference", "home-assistant", "shopping", "video",
|
||||
"tutorial", "server", "kids", "books", "travel",
|
||||
"churning", "lawn-garden", "piracy", "work", "3d-printing",
|
||||
"lectures", "vanlife", "yusuf", "madiha", "hafsa", "mustafa",
|
||||
"medical", "legal", "vehicle", "insurance", "financial", "homeschool",
|
||||
]
|
||||
|
||||
@@ -31,7 +31,7 @@ app.include_router(taxonomy_router)
|
||||
async def startup():
|
||||
from sqlalchemy import text as sa_text
|
||||
from app.database import engine, Base
|
||||
from app.models.item import Item, ItemAsset, AppLink # noqa: import to register models
|
||||
from app.models.item import Item, ItemAsset, AppLink, ItemAddition # noqa: import to register models
|
||||
from app.models.taxonomy import Folder, Tag, ItemTag # noqa: register taxonomy tables
|
||||
|
||||
# Enable pgvector extension before creating tables
|
||||
|
||||
@@ -45,6 +45,12 @@ class Item(Base):
|
||||
|
||||
# Relationships
|
||||
assets = relationship("ItemAsset", back_populates="item", cascade="all, delete-orphan")
|
||||
additions = relationship(
|
||||
"ItemAddition",
|
||||
back_populates="item",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="ItemAddition.created_at",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_items_user_status", "user_id", "processing_status"),
|
||||
@@ -79,3 +85,19 @@ class AppLink(Base):
|
||||
app = Column(String(64), nullable=False) # trips|tasks|fitness|inventory
|
||||
app_entity_id = Column(String(128), nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
|
||||
class ItemAddition(Base):
|
||||
__tablename__ = "item_additions"
|
||||
|
||||
id = Column(UUID(as_uuid=False), primary_key=True, default=new_id)
|
||||
item_id = Column(UUID(as_uuid=False), ForeignKey("items.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
user_id = Column(String(64), nullable=False, index=True)
|
||||
source = Column(String(32), nullable=False, default="assistant") # assistant|manual
|
||||
kind = Column(String(32), nullable=False, default="append") # append
|
||||
content = Column(Text, nullable=False)
|
||||
metadata_json = Column(JSONB, nullable=True, default=dict)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
item = relationship("Item", back_populates="additions")
|
||||
|
||||
@@ -26,6 +26,13 @@ class ItemUpdate(BaseModel):
|
||||
raw_content: Optional[str] = None
|
||||
|
||||
|
||||
class ItemAdditionCreate(BaseModel):
|
||||
content: str
|
||||
source: Optional[str] = "assistant"
|
||||
kind: Optional[str] = "append"
|
||||
metadata_json: Optional[dict] = None
|
||||
|
||||
|
||||
class SearchQuery(BaseModel):
|
||||
q: str
|
||||
folder: Optional[str] = None
|
||||
@@ -63,6 +70,19 @@ class AssetOut(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ItemAdditionOut(BaseModel):
|
||||
id: str
|
||||
item_id: str
|
||||
source: str
|
||||
kind: str
|
||||
content: str
|
||||
metadata_json: Optional[dict] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ItemOut(BaseModel):
|
||||
id: str
|
||||
type: str
|
||||
|
||||
@@ -70,23 +70,24 @@ class ItemTag(Base):
|
||||
|
||||
# Default folders with colors and icons
|
||||
DEFAULT_FOLDERS = [
|
||||
{"name": "Home", "color": "#059669", "icon": "home"},
|
||||
{"name": "Family", "color": "#D97706", "icon": "heart"},
|
||||
{"name": "Work", "color": "#4338CA", "icon": "briefcase"},
|
||||
{"name": "Travel", "color": "#0EA5E9", "icon": "plane"},
|
||||
{"name": "Knowledge", "color": "#8B5CF6", "icon": "book-open"},
|
||||
{"name": "Faith", "color": "#10B981", "icon": "moon"},
|
||||
{"name": "Projects", "color": "#F43F5E", "icon": "folder"},
|
||||
{"name": "Home", "color": "#059669", "icon": "home"},
|
||||
{"name": "Family", "color": "#D97706", "icon": "heart"},
|
||||
{"name": "Work", "color": "#4338CA", "icon": "briefcase"},
|
||||
{"name": "Travel", "color": "#0EA5E9", "icon": "plane"},
|
||||
{"name": "Islam", "color": "#10B981", "icon": "moon"},
|
||||
{"name": "Homelab", "color": "#6366F1", "icon": "server"},
|
||||
{"name": "Vanlife", "color": "#F59E0B", "icon": "truck"},
|
||||
{"name": "3D Printing", "color": "#EC4899", "icon": "printer"},
|
||||
{"name": "Documents", "color": "#78716C", "icon": "file-text"},
|
||||
]
|
||||
|
||||
# Default tags to seed for new users
|
||||
DEFAULT_TAGS = [
|
||||
"reference", "important", "legal", "financial", "insurance",
|
||||
"research", "idea", "guide", "tutorial", "setup", "how-to",
|
||||
"tools", "dev", "server", "selfhosted", "home-assistant",
|
||||
"shopping", "compare", "buy", "product",
|
||||
"family", "kids", "health", "travel", "faith",
|
||||
"video", "read-later", "books",
|
||||
"diy", "reference", "home-assistant", "shopping", "video",
|
||||
"tutorial", "server", "kids", "books", "travel",
|
||||
"churning", "lawn-garden", "piracy", "work", "3d-printing",
|
||||
"lectures", "vanlife", "yusuf", "madiha", "hafsa", "mustafa",
|
||||
"medical", "legal", "vehicle", "insurance", "financial", "homeschool",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -9,20 +9,61 @@ from app.config import OPENAI_API_KEY, OPENAI_MODEL
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
TAG_DEFINITIONS = {
|
||||
"home-assistant": "Home Assistant specific content (dashboards, ESPHome, automations, integrations, Lovelace cards)",
|
||||
"server": "Server/infrastructure content (Docker, backups, networking, self-hosted apps, Linux)",
|
||||
"kids": "Anything related to children, parenting, or educational content for kids",
|
||||
"shopping": "A product page, product review, or specific item you might want to buy (Amazon, stores, book reviews with purchase links). NOT general discussion threads or forums comparing many options.",
|
||||
"diy": "Physical hands-on projects around the house, yard, or vehicle — repairs, woodworking, crafts, building things. NOT software, dashboards, or digital projects.",
|
||||
"reference": "Lookup info like contacts, sizes, specs, measurements, settings to remember",
|
||||
"video": "Video content (YouTube, TikTok, etc)",
|
||||
"tutorial": "How-to guides, step-by-step instructions, learning content",
|
||||
"books": "Book recommendations, reviews, or reading lists",
|
||||
"travel": "Destinations, resorts, hotels, trip ideas, reviews, places to visit",
|
||||
"churning": "Credit card points, miles, award travel, hotel loyalty programs, points maximization, sign-up bonuses",
|
||||
"lawn-garden": "Lawn care, gardening, yard work, bug spraying, fertilizer, landscaping, plants, outdoor maintenance",
|
||||
"piracy": "Anything to do with downloading content like Audiobooks, games",
|
||||
"lectures": "Lecture notes, Islamic talks, sermon recordings, religious class notes",
|
||||
"3d-printing": "3D printer files (STL), printer mods, filament, slicer settings, 3D printed objects and projects",
|
||||
"work": "Work-related content",
|
||||
"vanlife": "Van conversion, Promaster van, van build projects, camping in vans, van electrical/solar, van life lifestyle",
|
||||
"yusuf": "Personal document belonging to family member Yusuf (look for name in title or content)",
|
||||
"madiha": "Personal document belonging to family member Madiha (look for name in title or content)",
|
||||
"hafsa": "Personal document belonging to family member Hafsa (look for name in title or content)",
|
||||
"mustafa": "Personal document belonging to family member Mustafa (look for name in title or content)",
|
||||
"medical": "Medical records, allergy results, prescriptions, lab work, vaccination records, doctor notes",
|
||||
"legal": "Birth certificates, passports, IDs, citizenship papers, contracts, legal agreements",
|
||||
"vehicle": "Car registration, license plates, insurance cards, vehicle titles, maintenance records",
|
||||
"insurance": "Insurance policies, insurance cards, coverage documents, claims",
|
||||
"financial": "Tax documents, bank statements, pay stubs, loan papers, credit reports",
|
||||
"homeschool": "Homeschooling resources, curriculum, lesson plans, educational materials for teaching kids at home, school projects, science experiments",
|
||||
}
|
||||
|
||||
|
||||
def build_system_prompt(folders: list[str], tags: list[str]) -> str:
|
||||
tag_defs = "\n".join(
|
||||
f" - '{t}': {TAG_DEFINITIONS[t]}" if t in TAG_DEFINITIONS else f" - '{t}'"
|
||||
for t in tags
|
||||
)
|
||||
return f"""You are a classification engine for a personal "second brain" knowledge management system.
|
||||
|
||||
Given an item (URL, note, document, or file), you must return structured JSON with:
|
||||
- folder: exactly 1 from this list: {json.dumps(folders)}
|
||||
- tags: exactly 2 or 3 from this list: {json.dumps(tags)}
|
||||
- title: a concise, normalized title (max 80 chars)
|
||||
- tags: ONLY from this predefined list. Do NOT create any new tags outside this list. If no tags fit, return an empty array.
|
||||
- title: a concise, normalized title in Title Case with spaces (max 80 chars, e.g. 'Machine Learning', 'Web Development')
|
||||
- summary: a 1-2 sentence summary of the content (for links/documents only)
|
||||
- corrected_text: for NOTES ONLY — return the original note text with spelling/grammar fixed. Keep the original meaning, tone, and structure. Only fix typos and obvious errors. Return empty string for non-notes.
|
||||
- confidence: a float 0.0-1.0 indicating how confident you are
|
||||
|
||||
Tag definitions (only assign tags that STRONGLY match the content):
|
||||
{tag_defs}
|
||||
|
||||
Rules:
|
||||
- NEVER invent folders or tags not in the lists above
|
||||
- Only assign tags that STRONGLY match the content. 1-2 tags is perfectly fine.
|
||||
- Do NOT pad with extra tags just to reach a target number. If only one tag fits, only use one.
|
||||
- If NO tags fit the content, return an empty tags array.
|
||||
- Name tags: 'yusuf', 'madiha', 'hafsa', or 'mustafa' ONLY when the content is a personal document belonging to that family member (look for their name in the title or content)
|
||||
- NEVER skip classification
|
||||
- NEVER return freeform text outside the schema
|
||||
- For notes: do NOT summarize. Keep the original text. Only fix spelling.
|
||||
@@ -43,7 +84,7 @@ def build_response_schema(folders: list[str], tags: list[str]) -> dict:
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "enum": tags},
|
||||
"minItems": 2,
|
||||
"minItems": 0,
|
||||
"maxItems": 3,
|
||||
},
|
||||
"title": {"type": "string"},
|
||||
@@ -88,8 +129,8 @@ async def classify_item(
|
||||
if not OPENAI_API_KEY:
|
||||
log.warning("No OPENAI_API_KEY set, returning defaults")
|
||||
return {
|
||||
"folder": "Knowledge",
|
||||
"tags": ["reference", "read-later"],
|
||||
"folder": "Home",
|
||||
"tags": ["reference"],
|
||||
"title": title or "Untitled",
|
||||
"summary": "No AI classification available",
|
||||
"confidence": 0.0,
|
||||
@@ -122,10 +163,8 @@ async def classify_item(
|
||||
|
||||
# Validate folder and tags are in allowed sets
|
||||
if result["folder"] not in folders:
|
||||
result["folder"] = folders[0] if folders else "Knowledge"
|
||||
result["folder"] = folders[0] if folders else "Home"
|
||||
result["tags"] = [t for t in result["tags"] if t in tags][:3]
|
||||
if len(result["tags"]) < 2:
|
||||
result["tags"] = (result["tags"] + ["reference", "read-later"])[:3]
|
||||
|
||||
return result
|
||||
|
||||
@@ -133,8 +172,8 @@ async def classify_item(
|
||||
log.error(f"Classification attempt {attempt + 1} failed: {e}")
|
||||
if attempt == retries:
|
||||
return {
|
||||
"folder": "Knowledge",
|
||||
"tags": ["reference", "read-later"],
|
||||
"folder": "Home",
|
||||
"tags": ["reference"],
|
||||
"title": title or "Untitled",
|
||||
"summary": f"Classification failed: {e}",
|
||||
"confidence": 0.0,
|
||||
|
||||
@@ -1,162 +1,218 @@
|
||||
"""Content ingestion — fetch, extract, screenshot, archive."""
|
||||
"""Content ingestion — Playwright crawler for HTML, screenshots, og:image."""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from html.parser import HTMLParser
|
||||
from io import StringIO
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import BROWSERLESS_URL
|
||||
from app.config import CRAWLER_URL
|
||||
from app.services.storage import storage
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _HTMLTextExtractor(HTMLParser):
|
||||
"""Simple HTML to text converter."""
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._result = StringIO()
|
||||
self._skip = False
|
||||
self._skip_tags = {"script", "style", "noscript", "svg"}
|
||||
# ── YouTube helpers ──
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag in self._skip_tags:
|
||||
self._skip = True
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag in self._skip_tags:
|
||||
self._skip = False
|
||||
if tag in ("p", "div", "br", "h1", "h2", "h3", "h4", "li", "tr"):
|
||||
self._result.write("\n")
|
||||
|
||||
def handle_data(self, data):
|
||||
if not self._skip:
|
||||
self._result.write(data)
|
||||
|
||||
def get_text(self) -> str:
|
||||
raw = self._result.getvalue()
|
||||
# Collapse whitespace
|
||||
lines = [line.strip() for line in raw.splitlines()]
|
||||
return "\n".join(line for line in lines if line)
|
||||
def _extract_youtube_id(url: str) -> str | None:
|
||||
patterns = [
|
||||
r'(?:youtube\.com/watch\?.*v=|youtu\.be/|youtube\.com/shorts/|youtube\.com/embed/)([a-zA-Z0-9_-]{11})',
|
||||
]
|
||||
for pat in patterns:
|
||||
m = re.search(pat, url)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def html_to_text(html: str) -> str:
|
||||
extractor = _HTMLTextExtractor()
|
||||
extractor.feed(html)
|
||||
return extractor.get_text()
|
||||
def _is_youtube_url(url: str) -> bool:
|
||||
return bool(_extract_youtube_id(url))
|
||||
|
||||
|
||||
def extract_title_from_html(html: str) -> str | None:
|
||||
match = re.search(r"<title[^>]*>(.*?)</title>", html, re.IGNORECASE | re.DOTALL)
|
||||
return match.group(1).strip() if match else None
|
||||
async def fetch_youtube_metadata(url: str) -> dict | None:
|
||||
"""Fetch YouTube video metadata via oEmbed. No API key needed."""
|
||||
video_id = _extract_youtube_id(url)
|
||||
if not video_id:
|
||||
return None
|
||||
|
||||
result = {
|
||||
"title": None,
|
||||
"description": None,
|
||||
"author": None,
|
||||
"thumbnail_url": f"https://img.youtube.com/vi/{video_id}/maxresdefault.jpg",
|
||||
"video_id": video_id,
|
||||
"is_short": "/shorts/" in url,
|
||||
}
|
||||
|
||||
def extract_meta_description(html: str) -> str | None:
|
||||
match = re.search(
|
||||
r'<meta[^>]*name=["\']description["\'][^>]*content=["\'](.*?)["\']',
|
||||
html, re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
return match.group(1).strip() if match else None
|
||||
|
||||
|
||||
async def fetch_url_content(url: str) -> dict:
|
||||
"""Fetch URL content. Returns dict with html, text, title, description, used_browserless."""
|
||||
result = {"html": None, "text": None, "title": None, "description": None, "used_browserless": False}
|
||||
|
||||
# Try HTTP-first extraction
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||
resp = await client.get(url, headers={
|
||||
"User-Agent": "Mozilla/5.0 (compatible; SecondBrain/1.0)"
|
||||
})
|
||||
resp.raise_for_status()
|
||||
html = resp.text
|
||||
result["html"] = html
|
||||
result["text"] = html_to_text(html)
|
||||
result["title"] = extract_title_from_html(html)
|
||||
result["description"] = extract_meta_description(html)
|
||||
|
||||
# If extraction is weak (< 200 chars of text), try browserless
|
||||
if len(result["text"] or "") < 200:
|
||||
log.info(f"Weak extraction ({len(result['text'] or '')} chars), trying browserless")
|
||||
br = await fetch_with_browserless(url)
|
||||
if br and len(br.get("text", "")) > len(result["text"] or ""):
|
||||
result.update(br)
|
||||
result["used_browserless"] = True
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
oembed_url = f"https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v={video_id}&format=json"
|
||||
resp = await client.get(oembed_url)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
result["title"] = data.get("title")
|
||||
result["author"] = data.get("author_name")
|
||||
|
||||
noembed_url = f"https://noembed.com/embed?url=https://www.youtube.com/watch?v={video_id}"
|
||||
resp2 = await client.get(noembed_url)
|
||||
if resp2.status_code == 200:
|
||||
data2 = resp2.json()
|
||||
if not result["title"]:
|
||||
result["title"] = data2.get("title")
|
||||
if not result["author"]:
|
||||
result["author"] = data2.get("author_name")
|
||||
except Exception as e:
|
||||
log.warning(f"HTTP fetch failed for {url}: {e}, trying browserless")
|
||||
try:
|
||||
br = await fetch_with_browserless(url)
|
||||
if br:
|
||||
result.update(br)
|
||||
result["used_browserless"] = True
|
||||
except Exception as e2:
|
||||
log.error(f"Browserless also failed for {url}: {e2}")
|
||||
log.warning(f"YouTube metadata fetch failed: {e}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def fetch_with_browserless(url: str) -> dict | None:
|
||||
"""Use browserless/chrome to render JS-heavy pages."""
|
||||
async def download_youtube_thumbnail(url: str, item_id: str) -> str | None:
|
||||
"""Download YouTube thumbnail and save as screenshot asset."""
|
||||
video_id = _extract_youtube_id(url)
|
||||
if not video_id:
|
||||
return None
|
||||
|
||||
urls_to_try = [
|
||||
f"https://img.youtube.com/vi/{video_id}/maxresdefault.jpg",
|
||||
f"https://img.youtube.com/vi/{video_id}/hqdefault.jpg",
|
||||
]
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
resp = await client.post(
|
||||
f"{BROWSERLESS_URL}/content",
|
||||
json={"url": url, "waitForTimeout": 3000},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
html = resp.text
|
||||
return {
|
||||
"html": html,
|
||||
"text": html_to_text(html),
|
||||
"title": extract_title_from_html(html),
|
||||
"description": extract_meta_description(html),
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
for thumb_url in urls_to_try:
|
||||
resp = await client.get(thumb_url)
|
||||
if resp.status_code == 200 and len(resp.content) > 1000:
|
||||
path = storage.save(
|
||||
item_id=item_id, asset_type="screenshot",
|
||||
filename="thumbnail.jpg", data=resp.content,
|
||||
)
|
||||
return path
|
||||
except Exception as e:
|
||||
log.error(f"Browserless fetch failed: {e}")
|
||||
log.warning(f"YouTube thumbnail download failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def take_screenshot(url: str, item_id: str) -> str | None:
|
||||
"""Take a screenshot of a URL using browserless. Returns storage path or None."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
resp = await client.post(
|
||||
f"{BROWSERLESS_URL}/screenshot",
|
||||
json={
|
||||
"url": url,
|
||||
"options": {"type": "png", "fullPage": False},
|
||||
"waitForTimeout": 3000,
|
||||
},
|
||||
async def download_youtube_video(url: str, item_id: str) -> tuple[str | None, dict]:
|
||||
"""Download YouTube video via yt-dlp."""
|
||||
import asyncio
|
||||
import subprocess
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
video_id = _extract_youtube_id(url)
|
||||
if not video_id:
|
||||
return None, {}
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
outpath = os.path.join(tmpdir, "%(id)s.%(ext)s")
|
||||
cmd = [
|
||||
"yt-dlp", "--no-playlist",
|
||||
"-f", "bestvideo[height<=720][ext=mp4]+bestaudio[ext=m4a]/best[height<=720][ext=mp4]/best[height<=720]",
|
||||
"--merge-output-format", "mp4",
|
||||
"--write-info-json", "--no-write-playlist-metafiles",
|
||||
"-o", outpath, url,
|
||||
]
|
||||
try:
|
||||
proc = await asyncio.to_thread(
|
||||
subprocess.run, cmd, capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
log.warning(f"yt-dlp failed: {proc.stderr[:300]}")
|
||||
return None, {}
|
||||
|
||||
video_file = None
|
||||
info = {}
|
||||
for f in os.listdir(tmpdir):
|
||||
if f.endswith(".mp4"):
|
||||
video_file = os.path.join(tmpdir, f)
|
||||
elif f.endswith(".info.json"):
|
||||
import json as _json
|
||||
with open(os.path.join(tmpdir, f)) as fh:
|
||||
info = _json.load(fh)
|
||||
|
||||
if not video_file:
|
||||
return None, {}
|
||||
|
||||
file_data = open(video_file, "rb").read()
|
||||
path = storage.save(
|
||||
item_id=item_id, asset_type="video",
|
||||
filename=f"{video_id}.mp4", data=file_data,
|
||||
)
|
||||
log.info(f"Downloaded YouTube video: {len(file_data)} bytes -> {path}")
|
||||
return path, info
|
||||
except subprocess.TimeoutExpired:
|
||||
log.warning(f"yt-dlp timed out for {url}")
|
||||
return None, {}
|
||||
except Exception as e:
|
||||
log.error(f"YouTube download failed: {e}")
|
||||
return None, {}
|
||||
|
||||
|
||||
# ── Main crawler (Playwright stealth service) ──
|
||||
|
||||
async def crawl_url(url: str) -> dict:
|
||||
"""Call the Playwright crawler service. Returns dict with html, text, title,
|
||||
description, author, og_image_url, screenshot (base64), status_code, error."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=45) as client:
|
||||
resp = await client.post(f"{CRAWLER_URL}/crawl", json={"url": url})
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
log.warning(f"Crawler returned {resp.status_code} for {url}")
|
||||
except Exception as e:
|
||||
log.error(f"Crawler request failed for {url}: {e}")
|
||||
return {"url": url, "html": None, "text": None, "title": None,
|
||||
"description": None, "og_image_url": None, "screenshot": None, "error": str(e) if 'e' in dir() else "unknown"}
|
||||
|
||||
|
||||
async def save_screenshot_from_base64(b64: str, item_id: str) -> str | None:
|
||||
"""Decode base64 screenshot and save to storage."""
|
||||
try:
|
||||
data = base64.b64decode(b64)
|
||||
if len(data) < 500:
|
||||
return None
|
||||
path = storage.save(
|
||||
item_id=item_id, asset_type="screenshot",
|
||||
filename="screenshot.jpg", data=data,
|
||||
)
|
||||
return path
|
||||
except Exception as e:
|
||||
log.error(f"Screenshot save failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def download_og_image(og_url: str, item_id: str) -> str | None:
|
||||
"""Download an og:image and save as asset."""
|
||||
# Clean HTML entities from URL
|
||||
og_url = og_url.replace("&", "&")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||
resp = await client.get(og_url, headers={
|
||||
"User-Agent": "Mozilla/5.0 (compatible; SecondBrain/1.0)"
|
||||
})
|
||||
if resp.status_code == 200 and len(resp.content) > 1000:
|
||||
ct = resp.headers.get("content-type", "image/jpeg")
|
||||
ext = "png" if "png" in ct else "jpg"
|
||||
path = storage.save(
|
||||
item_id=item_id,
|
||||
asset_type="screenshot",
|
||||
filename="screenshot.png",
|
||||
data=resp.content,
|
||||
item_id=item_id, asset_type="og_image",
|
||||
filename=f"og_image.{ext}", data=resp.content,
|
||||
)
|
||||
log.info(f"Downloaded og:image ({len(resp.content)} bytes) for {item_id}")
|
||||
return path
|
||||
except Exception as e:
|
||||
log.error(f"Screenshot failed for {url}: {e}")
|
||||
log.warning(f"og:image download failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def archive_html(html: str, item_id: str) -> str | None:
|
||||
"""Save the full HTML as an archived asset."""
|
||||
"""Save full HTML as an archived asset."""
|
||||
if not html:
|
||||
return None
|
||||
try:
|
||||
path = storage.save(
|
||||
item_id=item_id,
|
||||
asset_type="archived_html",
|
||||
filename="page.html",
|
||||
data=html.encode("utf-8"),
|
||||
item_id=item_id, asset_type="archived_html",
|
||||
filename="page.html", data=html.encode("utf-8"),
|
||||
)
|
||||
return path
|
||||
except Exception as e:
|
||||
|
||||
@@ -12,6 +12,7 @@ from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import REDIS_URL, DATABASE_URL_SYNC
|
||||
from app.models.item import Item, ItemAsset
|
||||
from app.models.taxonomy import Folder, Tag, ItemTag # noqa: F401 — register FK targets
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -34,7 +35,7 @@ async def _process_item(item_id: str):
|
||||
"""Full processing pipeline for a saved item."""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from app.config import DATABASE_URL
|
||||
from app.services.ingest import fetch_url_content, take_screenshot, archive_html
|
||||
from app.services.ingest import crawl_url, save_screenshot_from_base64, download_og_image, archive_html
|
||||
from app.services.classify import classify_item
|
||||
from app.services.embed import generate_embedding
|
||||
from app.search.engine import index_item, ensure_meili_index
|
||||
@@ -62,42 +63,96 @@ async def _process_item(item_id: str):
|
||||
|
||||
# ── Step 1: Fetch content for URLs ──
|
||||
if item.type == "link" and item.url:
|
||||
log.info(f"Fetching URL: {item.url}")
|
||||
content = await fetch_url_content(item.url)
|
||||
html_content = content.get("html")
|
||||
extracted_text = content.get("text") or extracted_text
|
||||
if not title:
|
||||
title = content.get("title")
|
||||
from app.services.ingest import (
|
||||
_is_youtube_url, download_youtube_thumbnail,
|
||||
download_youtube_video, fetch_youtube_metadata,
|
||||
)
|
||||
|
||||
item.metadata_json = item.metadata_json or {}
|
||||
item.metadata_json["description"] = content.get("description")
|
||||
item.metadata_json["used_browserless"] = content.get("used_browserless", False)
|
||||
is_yt = _is_youtube_url(item.url)
|
||||
|
||||
# Take screenshot
|
||||
screenshot_path = await take_screenshot(item.url, item.id)
|
||||
if screenshot_path:
|
||||
asset = ItemAsset(
|
||||
id=str(uuid.uuid4()),
|
||||
item_id=item.id,
|
||||
asset_type="screenshot",
|
||||
filename="screenshot.png",
|
||||
content_type="image/png",
|
||||
storage_path=screenshot_path,
|
||||
)
|
||||
db.add(asset)
|
||||
if is_yt:
|
||||
# YouTube: use oEmbed + thumbnail + yt-dlp (no crawler needed)
|
||||
log.info(f"Processing YouTube URL: {item.url}")
|
||||
yt_meta = await fetch_youtube_metadata(item.url)
|
||||
if yt_meta:
|
||||
if not title:
|
||||
title = yt_meta.get("title")
|
||||
extracted_text = f"YouTube: {yt_meta.get('title','')}\nBy: {yt_meta.get('author','')}"
|
||||
item.metadata_json["youtube"] = {
|
||||
"video_id": yt_meta.get("video_id"),
|
||||
"author": yt_meta.get("author"),
|
||||
"is_short": yt_meta.get("is_short", False),
|
||||
}
|
||||
item.metadata_json["description"] = f"YouTube video by {yt_meta.get('author','')}"
|
||||
|
||||
# Archive HTML
|
||||
if html_content:
|
||||
html_path = await archive_html(html_content, item.id)
|
||||
if html_path:
|
||||
asset = ItemAsset(
|
||||
id=str(uuid.uuid4()),
|
||||
item_id=item.id,
|
||||
asset_type="archived_html",
|
||||
filename="page.html",
|
||||
content_type="text/html",
|
||||
storage_path=html_path,
|
||||
)
|
||||
db.add(asset)
|
||||
# Download video
|
||||
log.info(f"Downloading YouTube video: {item.url}")
|
||||
video_path, yt_info = await download_youtube_video(item.url, item.id)
|
||||
if video_path:
|
||||
db.add(ItemAsset(
|
||||
id=str(uuid.uuid4()), item_id=item.id,
|
||||
asset_type="video", filename=f"{yt_meta['video_id']}.mp4",
|
||||
content_type="video/mp4", storage_path=video_path,
|
||||
))
|
||||
if yt_info.get("duration"):
|
||||
item.metadata_json["youtube"]["duration"] = yt_info["duration"]
|
||||
if yt_info.get("description"):
|
||||
item.metadata_json["youtube"]["description"] = yt_info["description"][:500]
|
||||
extracted_text = f"YouTube: {title or ''}\nBy: {(yt_meta or {}).get('author','')}\n{yt_info['description'][:2000]}"
|
||||
|
||||
# Thumbnail
|
||||
thumb_path = await download_youtube_thumbnail(item.url, item.id)
|
||||
if thumb_path:
|
||||
db.add(ItemAsset(
|
||||
id=str(uuid.uuid4()), item_id=item.id,
|
||||
asset_type="screenshot", filename="thumbnail.jpg",
|
||||
content_type="image/jpeg", storage_path=thumb_path,
|
||||
))
|
||||
|
||||
else:
|
||||
# Regular URL: use Playwright crawler (stealth)
|
||||
log.info(f"Crawling URL: {item.url}")
|
||||
crawl = await crawl_url(item.url)
|
||||
html_content = crawl.get("html")
|
||||
extracted_text = crawl.get("text") or extracted_text
|
||||
if not title:
|
||||
title = crawl.get("title")
|
||||
item.metadata_json["description"] = crawl.get("description")
|
||||
item.metadata_json["author"] = crawl.get("author")
|
||||
item.metadata_json["status_code"] = crawl.get("status_code")
|
||||
|
||||
# Screenshot (from crawler, base64 JPEG)
|
||||
if crawl.get("screenshot"):
|
||||
ss_path = await save_screenshot_from_base64(crawl["screenshot"], item.id)
|
||||
if ss_path:
|
||||
db.add(ItemAsset(
|
||||
id=str(uuid.uuid4()), item_id=item.id,
|
||||
asset_type="screenshot", filename="screenshot.jpg",
|
||||
content_type="image/jpeg", storage_path=ss_path,
|
||||
))
|
||||
|
||||
# og:image (extracted from rendered DOM by crawler)
|
||||
og_url = crawl.get("og_image_url")
|
||||
if og_url:
|
||||
og_path = await download_og_image(og_url, item.id)
|
||||
if og_path:
|
||||
db.add(ItemAsset(
|
||||
id=str(uuid.uuid4()), item_id=item.id,
|
||||
asset_type="og_image", filename="og_image.jpg",
|
||||
content_type="image/jpeg", storage_path=og_path,
|
||||
))
|
||||
item.metadata_json["og_image_url"] = og_url
|
||||
|
||||
# Archive HTML
|
||||
if html_content:
|
||||
html_path = await archive_html(html_content, item.id)
|
||||
if html_path:
|
||||
db.add(ItemAsset(
|
||||
id=str(uuid.uuid4()), item_id=item.id,
|
||||
asset_type="archived_html", filename="page.html",
|
||||
content_type="text/html", storage_path=html_path,
|
||||
))
|
||||
|
||||
# ── Step 1b: Process uploaded files (PDF, image, document) ──
|
||||
if item.type in ("pdf", "image", "document", "file"):
|
||||
|
||||
20
services/brain/crawler/Dockerfile
Normal file
20
services/brain/crawler/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
FROM node:20-slim
|
||||
|
||||
# Install Playwright system dependencies
|
||||
RUN npx playwright@1.50.0 install-deps chromium
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
# Install Playwright browser
|
||||
RUN npx playwright install chromium
|
||||
|
||||
COPY server.js ./
|
||||
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 3100
|
||||
|
||||
HEALTHCHECK --interval=15s --timeout=5s --retries=3 CMD wget -qO- http://localhost:3100/health || exit 1
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
24
services/brain/crawler/package.json
Normal file
24
services/brain/crawler/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "brain-crawler",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"playwright-extra": "^4.3.6",
|
||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||
"playwright-core": "^1.50.0",
|
||||
"metascraper": "^5.45.25",
|
||||
"metascraper-image": "^5.45.25",
|
||||
"metascraper-title": "^5.45.25",
|
||||
"metascraper-description": "^5.45.25",
|
||||
"metascraper-author": "^5.45.25",
|
||||
"metascraper-date": "^5.45.25",
|
||||
"metascraper-publisher": "^5.45.25",
|
||||
"metascraper-url": "^5.45.25",
|
||||
"@mozilla/readability": "^0.5.0",
|
||||
"jsdom": "^25.0.0"
|
||||
}
|
||||
}
|
||||
370
services/brain/crawler/server.js
Normal file
370
services/brain/crawler/server.js
Normal file
@@ -0,0 +1,370 @@
|
||||
import http from "node:http";
|
||||
import { chromium } from "playwright-extra";
|
||||
import StealthPlugin from "puppeteer-extra-plugin-stealth";
|
||||
import { Readability } from "@mozilla/readability";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
chromium.use(StealthPlugin());
|
||||
|
||||
const PORT = parseInt(process.env.PORT || "3100");
|
||||
const VIEWPORT = { width: 1440, height: 900 };
|
||||
const USER_AGENT =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
|
||||
const NAV_TIMEOUT = 30_000;
|
||||
const SCREENSHOT_TIMEOUT = 8_000;
|
||||
|
||||
let browser = null;
|
||||
|
||||
async function ensureBrowser() {
|
||||
if (browser && browser.isConnected()) return browser;
|
||||
if (browser) {
|
||||
try { await browser.close(); } catch {}
|
||||
browser = null;
|
||||
}
|
||||
console.log("[crawler] Launching browser...");
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
],
|
||||
});
|
||||
console.log("[crawler] Browser ready");
|
||||
return browser;
|
||||
}
|
||||
|
||||
// Extract og:image and other meta from rendered HTML
|
||||
function extractMeta(html) {
|
||||
const meta = {};
|
||||
|
||||
const patterns = {
|
||||
og_image: [
|
||||
/(?:property|name)=["']og:image["'][^>]*content=["']([^"']+)["']/i,
|
||||
/content=["']([^"']+)["'][^>]*(?:property|name)=["']og:image["']/i,
|
||||
],
|
||||
title: [
|
||||
/(?:property|name)=["']og:title["'][^>]*content=["']([^"']+)["']/i,
|
||||
/content=["']([^"']+)["'][^>]*(?:property|name)=["']og:title["']/i,
|
||||
/<title[^>]*>([^<]+)<\/title>/i,
|
||||
],
|
||||
description: [
|
||||
/(?:property|name)=["']og:description["'][^>]*content=["']([^"']+)["']/i,
|
||||
/name=["']description["'][^>]*content=["']([^"']+)["']/i,
|
||||
/content=["']([^"']+)["'][^>]*(?:property|name)=["']og:description["']/i,
|
||||
],
|
||||
author: [
|
||||
/name=["']author["'][^>]*content=["']([^"']+)["']/i,
|
||||
/property=["']article:author["'][^>]*content=["']([^"']+)["']/i,
|
||||
],
|
||||
favicon: [
|
||||
/rel=["']icon["'][^>]*href=["']([^"']+)["']/i,
|
||||
/rel=["']shortcut icon["'][^>]*href=["']([^"']+)["']/i,
|
||||
],
|
||||
};
|
||||
|
||||
for (const [key, pats] of Object.entries(patterns)) {
|
||||
for (const pat of pats) {
|
||||
const m = html.match(pat);
|
||||
if (m) {
|
||||
meta[key] = m[1].trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
function isRedditUrl(url) {
|
||||
try {
|
||||
const h = new URL(url).hostname;
|
||||
return h === "www.reddit.com" || h === "reddit.com";
|
||||
} catch {}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function resolveRedditShortUrl(url) {
|
||||
// Reddit short URLs (/r/sub/s/xxx) redirect to the actual post
|
||||
if (/\/s\/[a-zA-Z0-9]+/.test(url)) {
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: "HEAD",
|
||||
redirect: "follow",
|
||||
headers: { "User-Agent": "SecondBrain/1.0" },
|
||||
});
|
||||
const resolved = resp.url;
|
||||
if (resolved && resolved.includes("/comments/")) {
|
||||
console.log(`[crawler] Reddit short URL resolved: ${url} -> ${resolved}`);
|
||||
return resolved;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[crawler] Reddit short URL resolve failed:", e.message);
|
||||
}
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
async function fetchRedditJson(url) {
|
||||
// Resolve short URLs first
|
||||
url = await resolveRedditShortUrl(url);
|
||||
|
||||
// Reddit JSON API — append .json to get structured data
|
||||
try {
|
||||
const jsonUrl = url.replace(/\/?(\?.*)?$/, "/.json$1");
|
||||
const resp = await fetch(jsonUrl, {
|
||||
headers: { "User-Agent": "SecondBrain/1.0" },
|
||||
redirect: "follow",
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
const post = data?.[0]?.data?.children?.[0]?.data;
|
||||
if (!post) return null;
|
||||
|
||||
const previewImg = (post.preview?.images?.[0]?.source?.url || "").replace(/&/g, "&") || null;
|
||||
const thumbnail = post.thumbnail?.startsWith("http") ? post.thumbnail : null;
|
||||
|
||||
// If no preview image, try to get subreddit icon
|
||||
let ogImage = previewImg || thumbnail || null;
|
||||
if (!ogImage && post.subreddit) {
|
||||
try {
|
||||
const aboutResp = await fetch(
|
||||
`https://www.reddit.com/r/${post.subreddit}/about.json`,
|
||||
{ headers: { "User-Agent": "SecondBrain/1.0" } }
|
||||
);
|
||||
if (aboutResp.ok) {
|
||||
const about = await aboutResp.json();
|
||||
const icon = about?.data?.community_icon?.replace(/&/g, "&")?.split("?")?.[0]
|
||||
|| about?.data?.icon_img
|
||||
|| about?.data?.header_img;
|
||||
if (icon && icon.startsWith("http")) {
|
||||
ogImage = icon;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
html: null,
|
||||
text: `${post.title || ""}\n\n${post.selftext || ""}`.trim(),
|
||||
title: post.title || null,
|
||||
description: (post.selftext || "").slice(0, 200) || null,
|
||||
author: post.author ? `u/${post.author}` : null,
|
||||
og_image_url: ogImage ? ogImage.replace(/&/g, "&") : null,
|
||||
favicon: null,
|
||||
screenshot: null,
|
||||
status_code: 200,
|
||||
error: null,
|
||||
subreddit: post.subreddit_name_prefixed || null,
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn("[crawler] Reddit JSON failed:", e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function crawl(url) {
|
||||
// Reddit: use JSON API (avoids login walls entirely)
|
||||
if (isRedditUrl(url)) {
|
||||
const redditData = await fetchRedditJson(url);
|
||||
if (redditData) {
|
||||
console.log(`[crawler] Reddit JSON OK: ${url} (og=${!!redditData.og_image_url})`);
|
||||
return redditData;
|
||||
}
|
||||
console.log(`[crawler] Reddit JSON failed, falling back to browser: ${url}`);
|
||||
}
|
||||
|
||||
const crawlUrl = url;
|
||||
let b;
|
||||
try {
|
||||
b = await ensureBrowser();
|
||||
} catch (e) {
|
||||
console.error("[crawler] Browser launch failed, retrying:", e.message);
|
||||
browser = null;
|
||||
b = await ensureBrowser();
|
||||
}
|
||||
const contextOpts = {
|
||||
viewport: VIEWPORT,
|
||||
userAgent: USER_AGENT,
|
||||
ignoreHTTPSErrors: true,
|
||||
};
|
||||
|
||||
// Reddit: set cookies to bypass login walls
|
||||
if (isRedditUrl(url)) {
|
||||
contextOpts.extraHTTPHeaders = {
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
};
|
||||
}
|
||||
|
||||
const context = await b.newContext(contextOpts);
|
||||
|
||||
const page = await context.newPage();
|
||||
const result = {
|
||||
url,
|
||||
html: null,
|
||||
text: null,
|
||||
readable_html: null,
|
||||
title: null,
|
||||
description: null,
|
||||
author: null,
|
||||
og_image_url: null,
|
||||
favicon: null,
|
||||
screenshot: null, // base64
|
||||
status_code: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
try {
|
||||
// Navigate (use normalized URL to avoid login walls)
|
||||
const response = await page.goto(crawlUrl, {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: NAV_TIMEOUT,
|
||||
});
|
||||
result.status_code = response?.status() || null;
|
||||
|
||||
// Wait for network to settle (up to 5s)
|
||||
try {
|
||||
await page.waitForLoadState("networkidle", { timeout: 5000 });
|
||||
} catch {
|
||||
// networkidle timeout is fine, page is probably loaded enough
|
||||
}
|
||||
|
||||
// Reddit: dismiss login modals and overlays
|
||||
if (isRedditUrl(url)) {
|
||||
await page.evaluate(() => {
|
||||
// Remove login modal/overlay
|
||||
document.querySelectorAll('shreddit-overlay-display, [id*="login"], .overlay-container, reddit-cookie-banner').forEach(el => el.remove());
|
||||
// Remove any body scroll locks
|
||||
document.body.style.overflow = 'auto';
|
||||
document.documentElement.style.overflow = 'auto';
|
||||
}).catch(() => {});
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
// Get rendered HTML + screenshot in parallel
|
||||
const [html, screenshot] = await Promise.all([
|
||||
page.content(),
|
||||
page
|
||||
.screenshot({ type: "jpeg", quality: 80, fullPage: false })
|
||||
.catch((e) => {
|
||||
console.warn("[crawler] Screenshot failed:", e.message);
|
||||
return null;
|
||||
}),
|
||||
]);
|
||||
|
||||
result.html = html;
|
||||
|
||||
// Extract text from page
|
||||
result.text = await page
|
||||
.evaluate(() => {
|
||||
const el =
|
||||
document.querySelector("article") ||
|
||||
document.querySelector("main") ||
|
||||
document.querySelector('[role="main"]') ||
|
||||
document.body;
|
||||
return el ? el.innerText.slice(0, 10000) : "";
|
||||
})
|
||||
.catch(() => "");
|
||||
|
||||
// Extract readable article HTML via Mozilla Readability
|
||||
try {
|
||||
const dom = new JSDOM(html, { url: crawlUrl });
|
||||
const reader = new Readability(dom.window.document);
|
||||
const article = reader.parse();
|
||||
if (article && article.content) {
|
||||
result.readable_html = article.content;
|
||||
if (article.textContent) {
|
||||
result.text = article.textContent.slice(0, 10000);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[crawler] Readability failed:", e.message);
|
||||
}
|
||||
|
||||
// Extract meta from rendered DOM
|
||||
const meta = extractMeta(html);
|
||||
result.title = meta.title || (await page.title()) || null;
|
||||
result.description = meta.description || null;
|
||||
result.author = meta.author || null;
|
||||
result.og_image_url = meta.og_image || null;
|
||||
result.favicon = meta.favicon || null;
|
||||
|
||||
// Screenshot as base64
|
||||
if (screenshot) {
|
||||
result.screenshot = screenshot.toString("base64");
|
||||
}
|
||||
} catch (e) {
|
||||
result.error = e.message;
|
||||
console.error("[crawler] Crawl error:", url, e.message);
|
||||
// If browser crashed, reset it for next request
|
||||
if (e.message.includes("closed") || e.message.includes("crashed")) {
|
||||
browser = null;
|
||||
}
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Simple HTTP server
|
||||
const server = http.createServer(async (req, res) => {
|
||||
// Health check
|
||||
if (req.method === "GET" && req.url === "/health") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "ok" }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Crawl endpoint
|
||||
if (req.method === "POST" && req.url === "/crawl") {
|
||||
let body = "";
|
||||
req.on("data", (chunk) => (body += chunk));
|
||||
req.on("end", async () => {
|
||||
try {
|
||||
const { url } = JSON.parse(body);
|
||||
if (!url) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "url is required" }));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[crawler] Crawling: ${url}`);
|
||||
const result = await crawl(url);
|
||||
console.log(
|
||||
`[crawler] Done: ${url} (status=${result.status_code}, og=${!!result.og_image_url}, ss=${!!result.screenshot})`
|
||||
);
|
||||
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(result));
|
||||
} catch (e) {
|
||||
console.error("[crawler] Request error:", e);
|
||||
res.writeHead(500, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: e.message }));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end("Not found");
|
||||
});
|
||||
|
||||
// Startup
|
||||
(async () => {
|
||||
await ensureBrowser();
|
||||
server.listen(PORT, () => {
|
||||
console.log(`[crawler] Listening on :${PORT}`);
|
||||
});
|
||||
})();
|
||||
|
||||
// Graceful shutdown
|
||||
process.on("SIGTERM", async () => {
|
||||
console.log("[crawler] Shutting down...");
|
||||
if (browser) await browser.close().catch(() => {});
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -13,7 +13,7 @@ services:
|
||||
- REDIS_URL=redis://brain-redis:6379/0
|
||||
- MEILI_URL=http://brain-meili:7700
|
||||
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY:-brain-meili-secure-key-2026}
|
||||
- BROWSERLESS_URL=http://brain-browserless:3000
|
||||
- CRAWLER_URL=http://brain-crawler:3100
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
- OPENAI_MODEL=${OPENAI_MODEL:-gpt-4o-mini}
|
||||
- PORT=8200
|
||||
@@ -44,7 +44,7 @@ services:
|
||||
- REDIS_URL=redis://brain-redis:6379/0
|
||||
- MEILI_URL=http://brain-meili:7700
|
||||
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY:-brain-meili-secure-key-2026}
|
||||
- BROWSERLESS_URL=http://brain-browserless:3000
|
||||
- CRAWLER_URL=http://brain-crawler:3100
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
- OPENAI_MODEL=${OPENAI_MODEL:-gpt-4o-mini}
|
||||
- TZ=${TZ:-America/Chicago}
|
||||
@@ -90,14 +90,17 @@ services:
|
||||
volumes:
|
||||
- ./data/meili:/meili_data
|
||||
|
||||
# ── Browserless (headless Chrome for JS rendering + screenshots) ──
|
||||
brain-browserless:
|
||||
image: ghcr.io/browserless/chromium:latest
|
||||
container_name: brain-browserless
|
||||
# ── Crawler (Playwright + stealth for JS rendering + screenshots) ──
|
||||
brain-crawler:
|
||||
build:
|
||||
context: ./crawler
|
||||
dockerfile: Dockerfile
|
||||
container_name: brain-crawler
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- MAX_CONCURRENT_SESSIONS=3
|
||||
- TIMEOUT=30000
|
||||
- PORT=3100
|
||||
- TZ=${TZ:-America/Chicago}
|
||||
shm_size: '1gb'
|
||||
|
||||
networks:
|
||||
pangolin:
|
||||
|
||||
254
services/brain/migrate_karakeep.py
Normal file
254
services/brain/migrate_karakeep.py
Normal file
@@ -0,0 +1,254 @@
|
||||
"""Migrate all bookmarks from Karakeep into Brain service via API."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import tempfile
|
||||
|
||||
KARAKEEP_URL = os.environ.get("KARAKEEP_URL", "http://192.168.1.42:3005")
|
||||
KARAKEEP_API_KEY = os.environ.get("KARAKEEP_API_KEY", "ak2_f4141e5fe7265e23bd6f_4549c932c262010eafd08acb2139f1ac")
|
||||
BRAIN_URL = "http://localhost:8200"
|
||||
BRAIN_USER = "admin"
|
||||
|
||||
|
||||
def karakeep_get(path):
|
||||
req = urllib.request.Request(
|
||||
f"{KARAKEEP_URL}{path}",
|
||||
headers={"Authorization": f"Bearer {KARAKEEP_API_KEY}"},
|
||||
)
|
||||
return json.loads(urllib.request.urlopen(req, timeout=30).read())
|
||||
|
||||
|
||||
def karakeep_download(asset_id):
|
||||
req = urllib.request.Request(
|
||||
f"{KARAKEEP_URL}/api/v1/assets/{asset_id}",
|
||||
headers={"Authorization": f"Bearer {KARAKEEP_API_KEY}"},
|
||||
)
|
||||
resp = urllib.request.urlopen(req, timeout=120)
|
||||
return resp.read(), resp.headers.get("Content-Type", "application/octet-stream")
|
||||
|
||||
|
||||
def brain_post_json(path, data):
|
||||
body = json.dumps(data).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{BRAIN_URL}/api{path}",
|
||||
data=body,
|
||||
headers={"X-Gateway-User-Id": BRAIN_USER, "Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
resp = urllib.request.urlopen(req, timeout=30)
|
||||
return json.loads(resp.read())
|
||||
|
||||
|
||||
def brain_upload(file_data, filename, content_type, title=None):
|
||||
"""Multipart upload to /api/items/upload."""
|
||||
boundary = "----MigrationBoundary12345"
|
||||
parts = []
|
||||
|
||||
# File part
|
||||
parts.append(f"--{boundary}\r\n".encode())
|
||||
parts.append(f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'.encode())
|
||||
parts.append(f"Content-Type: {content_type}\r\n\r\n".encode())
|
||||
parts.append(file_data)
|
||||
parts.append(b"\r\n")
|
||||
|
||||
# Title part
|
||||
if title:
|
||||
parts.append(f"--{boundary}\r\n".encode())
|
||||
parts.append(b'Content-Disposition: form-data; name="title"\r\n\r\n')
|
||||
parts.append(title.encode())
|
||||
parts.append(b"\r\n")
|
||||
|
||||
parts.append(f"--{boundary}--\r\n".encode())
|
||||
body = b"".join(parts)
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"{BRAIN_URL}/api/items/upload",
|
||||
data=body,
|
||||
headers={
|
||||
"X-Gateway-User-Id": BRAIN_USER,
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
resp = urllib.request.urlopen(req, timeout=60)
|
||||
return json.loads(resp.read())
|
||||
|
||||
|
||||
def brain_get_item(item_id):
|
||||
req = urllib.request.Request(
|
||||
f"{BRAIN_URL}/api/items/{item_id}",
|
||||
headers={"X-Gateway-User-Id": BRAIN_USER},
|
||||
)
|
||||
resp = urllib.request.urlopen(req, timeout=15)
|
||||
return json.loads(resp.read())
|
||||
|
||||
|
||||
def fetch_all_bookmarks():
|
||||
all_bk = []
|
||||
cursor = None
|
||||
while True:
|
||||
url = "/api/v1/bookmarks?limit=100"
|
||||
if cursor:
|
||||
url += f"&cursor={cursor}"
|
||||
data = karakeep_get(url)
|
||||
bks = data.get("bookmarks", [])
|
||||
all_bk.extend(bks)
|
||||
cursor = data.get("nextCursor")
|
||||
if not cursor or not bks:
|
||||
break
|
||||
return all_bk
|
||||
|
||||
|
||||
def wait_for_processing(item_id, timeout=120):
|
||||
"""Poll until item is done processing."""
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
item = brain_get_item(item_id)
|
||||
status = item.get("processing_status", "pending")
|
||||
if status in ("ready", "error"):
|
||||
return item
|
||||
time.sleep(3)
|
||||
return brain_get_item(item_id)
|
||||
|
||||
|
||||
def main():
|
||||
print("Fetching all Karakeep bookmarks...")
|
||||
bookmarks = fetch_all_bookmarks()
|
||||
print(f"Found {len(bookmarks)} bookmarks\n")
|
||||
|
||||
# Sort: notes first, then links, then assets (PDFs take longer)
|
||||
def sort_key(b):
|
||||
t = b.get("content", {}).get("type", "")
|
||||
return {"text": 0, "link": 1, "asset": 2}.get(t, 3)
|
||||
bookmarks.sort(key=sort_key)
|
||||
|
||||
results = {"success": 0, "error": 0, "skipped": 0}
|
||||
comparison = []
|
||||
|
||||
for i, bk in enumerate(bookmarks):
|
||||
content = bk.get("content", {})
|
||||
bk_type = content.get("type", "unknown")
|
||||
bk_title = bk.get("title") or "Untitled"
|
||||
bk_tags = [t["name"] for t in bk.get("tags", [])]
|
||||
bk_list = bk.get("list", {})
|
||||
bk_folder = bk_list.get("name") if bk_list else None
|
||||
|
||||
print(f"[{i+1}/{len(bookmarks)}] {bk_type}: {bk_title[:60]}")
|
||||
|
||||
try:
|
||||
if bk_type == "link":
|
||||
url = content.get("url", "")
|
||||
if not url:
|
||||
print(" SKIP: no URL")
|
||||
results["skipped"] += 1
|
||||
continue
|
||||
resp = brain_post_json("/items", {
|
||||
"type": "link",
|
||||
"url": url,
|
||||
"title": bk_title if bk_title != "Untitled" else None,
|
||||
})
|
||||
|
||||
elif bk_type == "text":
|
||||
text = content.get("text", "")
|
||||
if not text:
|
||||
print(" SKIP: no text")
|
||||
results["skipped"] += 1
|
||||
continue
|
||||
resp = brain_post_json("/items", {
|
||||
"type": "note",
|
||||
"raw_content": text,
|
||||
"title": bk_title if bk_title != "Untitled" else None,
|
||||
})
|
||||
|
||||
elif bk_type == "asset":
|
||||
asset_id = content.get("assetId")
|
||||
asset_type = content.get("assetType", "unknown")
|
||||
if not asset_id:
|
||||
print(" SKIP: no assetId")
|
||||
results["skipped"] += 1
|
||||
continue
|
||||
|
||||
print(f" Downloading {asset_type} ({asset_id[:8]})...")
|
||||
file_data, ct = karakeep_download(asset_id)
|
||||
ext = {"pdf": ".pdf", "image": ".png"}.get(asset_type, ".bin")
|
||||
filename = f"{bk_title[:50]}{ext}" if bk_title != "Untitled" else f"upload{ext}"
|
||||
# Clean filename
|
||||
filename = filename.replace("/", "-").replace("\\", "-")
|
||||
if asset_type == "pdf":
|
||||
ct = "application/pdf"
|
||||
resp = brain_upload(file_data, filename, ct, title=bk_title if bk_title != "Untitled" else None)
|
||||
else:
|
||||
print(f" SKIP: unknown type '{bk_type}'")
|
||||
results["skipped"] += 1
|
||||
continue
|
||||
|
||||
item_id = resp.get("id")
|
||||
print(f" Created: {item_id} — waiting for AI classification...")
|
||||
|
||||
# Wait for processing
|
||||
final = wait_for_processing(item_id, timeout=90)
|
||||
status = final.get("processing_status", "?")
|
||||
ai_folder = final.get("folder", "?")
|
||||
ai_tags = final.get("tags", [])
|
||||
ai_title = final.get("title", "?")
|
||||
|
||||
# Compare
|
||||
entry = {
|
||||
"karakeep_title": bk_title,
|
||||
"karakeep_tags": bk_tags,
|
||||
"karakeep_folder": bk_folder,
|
||||
"ai_title": ai_title,
|
||||
"ai_folder": ai_folder,
|
||||
"ai_tags": ai_tags,
|
||||
"status": status,
|
||||
}
|
||||
comparison.append(entry)
|
||||
|
||||
tag_match = "OK" if set(bk_tags) & set(ai_tags) or (not bk_tags and not ai_tags) else "DIFF"
|
||||
|
||||
print(f" Status: {status}")
|
||||
print(f" AI Folder: {ai_folder} (Karakeep: {bk_folder or 'none'})")
|
||||
print(f" AI Tags: {ai_tags} vs Karakeep: {bk_tags} [{tag_match}]")
|
||||
print(f" AI Title: {ai_title}")
|
||||
|
||||
results["success"] += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f" ERROR: {e}")
|
||||
results["error"] += 1
|
||||
|
||||
print()
|
||||
|
||||
# Summary
|
||||
print("=" * 60)
|
||||
print(f"MIGRATION COMPLETE")
|
||||
print(f" Success: {results['success']}")
|
||||
print(f" Errors: {results['error']}")
|
||||
print(f" Skipped: {results['skipped']}")
|
||||
print()
|
||||
|
||||
# Tag comparison summary
|
||||
matches = 0
|
||||
diffs = 0
|
||||
for c in comparison:
|
||||
kk = set(c["karakeep_tags"])
|
||||
ai = set(c["ai_tags"])
|
||||
if kk & ai or (not kk and not ai):
|
||||
matches += 1
|
||||
else:
|
||||
diffs += 1
|
||||
print(f"Tag overlap: {matches}/{len(comparison)} items had at least one matching tag")
|
||||
print(f"Tag differences: {diffs}/{len(comparison)} items had zero overlap")
|
||||
|
||||
# Save comparison
|
||||
with open("/tmp/migration_comparison.json", "w") as f:
|
||||
json.dump(comparison, f, indent=2)
|
||||
print("\nFull comparison saved to /tmp/migration_comparison.json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -2035,6 +2035,24 @@ class CalorieHandler(BaseHTTPRequestHandler):
|
||||
if user:
|
||||
return user
|
||||
|
||||
# Internal gateway auth (trusted Docker network only)
|
||||
gateway_user_id = self.headers.get('X-Gateway-User-Id', '')
|
||||
gateway_user_name = self.headers.get('X-Gateway-User-Name', '')
|
||||
if gateway_user_id:
|
||||
conn = get_db()
|
||||
row = None
|
||||
# Try username match
|
||||
if gateway_user_name:
|
||||
row = conn.execute("SELECT * FROM users WHERE username = ? COLLATE NOCASE", (gateway_user_name.lower(),)).fetchone()
|
||||
# Try display name match
|
||||
if not row:
|
||||
row = conn.execute("SELECT * FROM users WHERE display_name = ? COLLATE NOCASE", (gateway_user_name,)).fetchone()
|
||||
if not row:
|
||||
row = conn.execute("SELECT * FROM users WHERE id = ?", (gateway_user_id,)).fetchone()
|
||||
conn.close()
|
||||
if row:
|
||||
return dict(row)
|
||||
|
||||
return None
|
||||
|
||||
def _send_json(self, data, status=200):
|
||||
|
||||
22
services/reader/Dockerfile.api
Normal file
22
services/reader/Dockerfile.api
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libpq-dev && rm -rf /var/lib/apt/lists/*
|
||||
RUN pip install --no-cache-dir --upgrade pip
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
RUN adduser --disabled-password --no-create-home appuser
|
||||
|
||||
COPY --chown=appuser app/ app/
|
||||
|
||||
EXPOSE 8300
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD python3 -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8300/api/health', timeout=3)" || exit 1
|
||||
|
||||
USER appuser
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8300"]
|
||||
18
services/reader/Dockerfile.worker
Normal file
18
services/reader/Dockerfile.worker
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libpq-dev && rm -rf /var/lib/apt/lists/*
|
||||
RUN pip install --no-cache-dir --upgrade pip
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
RUN adduser --disabled-password --no-create-home appuser
|
||||
|
||||
COPY --chown=appuser app/ app/
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
USER appuser
|
||||
CMD ["python", "-m", "app.worker.tasks"]
|
||||
0
services/reader/app/__init__.py
Normal file
0
services/reader/app/__init__.py
Normal file
0
services/reader/app/api/__init__.py
Normal file
0
services/reader/app/api/__init__.py
Normal file
49
services/reader/app/api/categories.py
Normal file
49
services/reader/app/api/categories.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Category endpoints."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_user_id, get_db_session
|
||||
from app.models import Category
|
||||
|
||||
router = APIRouter(prefix="/api/categories", tags=["categories"])
|
||||
|
||||
|
||||
class CategoryOut(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CategoryCreate(BaseModel):
|
||||
title: str
|
||||
|
||||
|
||||
@router.get("", response_model=list[CategoryOut])
|
||||
async def list_categories(
|
||||
user_id: str = Depends(get_user_id),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Category)
|
||||
.where(Category.user_id == user_id)
|
||||
.order_by(Category.title)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("", response_model=CategoryOut, status_code=201)
|
||||
async def create_category(
|
||||
body: CategoryCreate,
|
||||
user_id: str = Depends(get_user_id),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
cat = Category(user_id=user_id, title=body.title)
|
||||
db.add(cat)
|
||||
await db.commit()
|
||||
await db.refresh(cat)
|
||||
return cat
|
||||
21
services/reader/app/api/deps.py
Normal file
21
services/reader/app/api/deps.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""API dependencies — auth, database session."""
|
||||
|
||||
from fastapi import Header, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
|
||||
|
||||
async def get_user_id(
|
||||
x_gateway_user_id: str = Header(None, alias="X-Gateway-User-Id"),
|
||||
) -> str:
|
||||
"""Extract authenticated user ID from gateway-injected header."""
|
||||
if not x_gateway_user_id:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
return x_gateway_user_id
|
||||
|
||||
|
||||
async def get_db_session() -> AsyncSession:
|
||||
"""Provide an async database session."""
|
||||
async for session in get_db():
|
||||
yield session
|
||||
264
services/reader/app/api/entries.py
Normal file
264
services/reader/app/api/entries.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""Entry endpoints."""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.api.deps import get_db_session, get_user_id
|
||||
from app.config import CRAWLER_URL
|
||||
from app.models import Entry, Feed
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/entries", tags=["entries"])
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class FeedRef(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class EntryOut(BaseModel):
|
||||
id: int
|
||||
title: str | None = None
|
||||
url: str | None = None
|
||||
content: str | None = None
|
||||
full_content: str | None = None
|
||||
author: str | None = None
|
||||
published_at: str | None = None
|
||||
status: str = "unread"
|
||||
starred: bool = False
|
||||
reading_time: int = 1
|
||||
feed: FeedRef | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@classmethod
|
||||
def from_entry(cls, entry: Entry) -> "EntryOut":
|
||||
# Use full_content if available, otherwise RSS content
|
||||
best_content = entry.full_content if entry.full_content else entry.content
|
||||
return cls(
|
||||
id=entry.id,
|
||||
title=entry.title,
|
||||
url=entry.url,
|
||||
content=best_content,
|
||||
full_content=entry.full_content,
|
||||
author=entry.author,
|
||||
published_at=entry.published_at.isoformat() if entry.published_at else None,
|
||||
status=entry.status,
|
||||
starred=entry.starred,
|
||||
reading_time=entry.reading_time,
|
||||
feed=FeedRef(id=entry.feed.id, title=entry.feed.title) if entry.feed else None,
|
||||
)
|
||||
|
||||
|
||||
class EntryListOut(BaseModel):
|
||||
total: int
|
||||
entries: list[EntryOut]
|
||||
|
||||
|
||||
class EntryBulkUpdate(BaseModel):
|
||||
entry_ids: list[int]
|
||||
status: str
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("", response_model=EntryListOut)
|
||||
async def list_entries(
|
||||
status: Optional[str] = Query(None),
|
||||
starred: Optional[bool] = Query(None),
|
||||
feed_id: Optional[int] = Query(None),
|
||||
category_id: Optional[int] = Query(None),
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
direction: str = Query("desc"),
|
||||
order: str = Query("published_at"),
|
||||
user_id: str = Depends(get_user_id),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
query = select(Entry).where(Entry.user_id == user_id)
|
||||
count_query = select(func.count(Entry.id)).where(Entry.user_id == user_id)
|
||||
|
||||
if status:
|
||||
query = query.where(Entry.status == status)
|
||||
count_query = count_query.where(Entry.status == status)
|
||||
|
||||
if starred is not None:
|
||||
query = query.where(Entry.starred == starred)
|
||||
count_query = count_query.where(Entry.starred == starred)
|
||||
|
||||
if feed_id is not None:
|
||||
query = query.where(Entry.feed_id == feed_id)
|
||||
count_query = count_query.where(Entry.feed_id == feed_id)
|
||||
|
||||
if category_id is not None:
|
||||
# Join through feed to filter by category
|
||||
query = query.join(Feed, Entry.feed_id == Feed.id).where(Feed.category_id == category_id)
|
||||
count_query = count_query.join(Feed, Entry.feed_id == Feed.id).where(Feed.category_id == category_id)
|
||||
|
||||
# Ordering
|
||||
order_col = Entry.published_at if order == "published_at" else Entry.created_at
|
||||
if direction == "asc":
|
||||
query = query.order_by(order_col.asc().nullslast())
|
||||
else:
|
||||
query = query.order_by(order_col.desc().nullsfirst())
|
||||
|
||||
# Total count
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Paginate
|
||||
query = query.options(selectinload(Entry.feed)).offset(offset).limit(limit)
|
||||
result = await db.execute(query)
|
||||
entries = result.scalars().all()
|
||||
|
||||
return EntryListOut(
|
||||
total=total,
|
||||
entries=[EntryOut.from_entry(e) for e in entries],
|
||||
)
|
||||
|
||||
|
||||
@router.put("")
|
||||
async def bulk_update_entries(
|
||||
body: EntryBulkUpdate,
|
||||
user_id: str = Depends(get_user_id),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
if body.status not in ("read", "unread"):
|
||||
raise HTTPException(status_code=400, detail="Status must be 'read' or 'unread'")
|
||||
|
||||
await db.execute(
|
||||
update(Entry)
|
||||
.where(Entry.user_id == user_id, Entry.id.in_(body.entry_ids))
|
||||
.values(status=body.status)
|
||||
)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
class MarkAllReadBody(BaseModel):
|
||||
feed_id: int | None = None
|
||||
category_id: int | None = None
|
||||
|
||||
|
||||
@router.put("/mark-all-read")
|
||||
async def mark_all_read(
|
||||
body: MarkAllReadBody,
|
||||
user_id: str = Depends(get_user_id),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Mark ALL unread entries as read, optionally filtered by feed or category."""
|
||||
q = update(Entry).where(Entry.user_id == user_id, Entry.status == "unread")
|
||||
|
||||
if body.feed_id:
|
||||
q = q.where(Entry.feed_id == body.feed_id)
|
||||
elif body.category_id:
|
||||
from app.models import Feed
|
||||
feed_ids_q = select(Feed.id).where(Feed.category_id == body.category_id, Feed.user_id == user_id)
|
||||
q = q.where(Entry.feed_id.in_(feed_ids_q))
|
||||
|
||||
result = await db.execute(q.values(status="read"))
|
||||
await db.commit()
|
||||
return {"ok": True, "marked": result.rowcount}
|
||||
|
||||
|
||||
@router.get("/{entry_id}", response_model=EntryOut)
|
||||
async def get_entry(
|
||||
entry_id: int,
|
||||
user_id: str = Depends(get_user_id),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Entry)
|
||||
.options(selectinload(Entry.feed))
|
||||
.where(Entry.id == entry_id, Entry.user_id == user_id)
|
||||
)
|
||||
entry = result.scalar_one_or_none()
|
||||
if not entry:
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
return EntryOut.from_entry(entry)
|
||||
|
||||
|
||||
@router.put("/{entry_id}/bookmark")
|
||||
async def toggle_bookmark(
|
||||
entry_id: int,
|
||||
user_id: str = Depends(get_user_id),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Entry).where(Entry.id == entry_id, Entry.user_id == user_id)
|
||||
)
|
||||
entry = result.scalar_one_or_none()
|
||||
if not entry:
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
|
||||
entry.starred = not entry.starred
|
||||
await db.commit()
|
||||
return {"starred": entry.starred}
|
||||
|
||||
|
||||
@router.post("/{entry_id}/fetch-full-content", response_model=EntryOut)
|
||||
async def fetch_full_content(
|
||||
entry_id: int,
|
||||
user_id: str = Depends(get_user_id),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Entry)
|
||||
.options(selectinload(Entry.feed))
|
||||
.where(Entry.id == entry_id, Entry.user_id == user_id)
|
||||
)
|
||||
entry = result.scalar_one_or_none()
|
||||
if not entry:
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
|
||||
if not entry.url:
|
||||
raise HTTPException(status_code=400, detail="Entry has no URL to crawl")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
resp = await client.post(
|
||||
f"{CRAWLER_URL}/crawl",
|
||||
json={"url": entry.url},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except httpx.HTTPError as e:
|
||||
log.error("Crawler error for entry %d: %s", entry_id, e)
|
||||
raise HTTPException(status_code=502, detail="Failed to fetch full content")
|
||||
|
||||
# Prefer readable_html (Readability-extracted clean article with images)
|
||||
readable = data.get("readable_html", "")
|
||||
full_text = data.get("text", "")
|
||||
if readable:
|
||||
entry.full_content = readable
|
||||
elif full_text:
|
||||
paragraphs = [p.strip() for p in full_text.split("\n\n") if p.strip()]
|
||||
if not paragraphs:
|
||||
paragraphs = [p.strip() for p in full_text.split("\n") if p.strip()]
|
||||
entry.full_content = "\n".join(f"<p>{p}</p>" for p in paragraphs)
|
||||
else:
|
||||
entry.full_content = ""
|
||||
|
||||
# Recalculate reading time from plain text
|
||||
if full_text:
|
||||
word_count = len(full_text.split())
|
||||
entry.reading_time = max(1, word_count // 200)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(entry)
|
||||
return EntryOut.from_entry(entry)
|
||||
242
services/reader/app/api/feeds.py
Normal file
242
services/reader/app/api/feeds.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""Feed endpoints."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
import feedparser
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db_session, get_user_id
|
||||
from app.models import Category, Entry, Feed
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/feeds", tags=["feeds"])
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CategoryRef(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class FeedOut(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
feed_url: str
|
||||
site_url: str | None = None
|
||||
category: CategoryRef | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class FeedCreate(BaseModel):
|
||||
feed_url: str
|
||||
category_id: int | None = None
|
||||
|
||||
|
||||
class CountersOut(BaseModel):
|
||||
unreads: dict[str, int]
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _discover_feed_url(html: str, base_url: str) -> str | None:
|
||||
"""Try to find an RSS/Atom feed link in HTML."""
|
||||
patterns = [
|
||||
r'<link[^>]+type=["\']application/(?:rss|atom)\+xml["\'][^>]+href=["\']([^"\']+)["\']',
|
||||
r'<link[^>]+href=["\']([^"\']+)["\'][^>]+type=["\']application/(?:rss|atom)\+xml["\']',
|
||||
]
|
||||
for pat in patterns:
|
||||
match = re.search(pat, html, re.IGNORECASE)
|
||||
if match:
|
||||
href = match.group(1)
|
||||
if href.startswith("/"):
|
||||
# Resolve relative URL
|
||||
from urllib.parse import urljoin
|
||||
href = urljoin(base_url, href)
|
||||
return href
|
||||
return None
|
||||
|
||||
|
||||
async def _fetch_and_parse_feed(feed_url: str) -> tuple[str, str, str | None]:
|
||||
"""
|
||||
Fetch a URL. If it's a valid feed, return (feed_url, title, site_url).
|
||||
If it's HTML, try to discover the feed link and follow it.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
||||
resp = await client.get(feed_url, headers={"User-Agent": "Reader/1.0"})
|
||||
resp.raise_for_status()
|
||||
|
||||
body = resp.text
|
||||
parsed = feedparser.parse(body)
|
||||
|
||||
# Check if it's a valid feed
|
||||
if parsed.feed.get("title") or parsed.entries:
|
||||
title = parsed.feed.get("title", feed_url)
|
||||
site_url = parsed.feed.get("link")
|
||||
return feed_url, title, site_url
|
||||
|
||||
# Not a feed — try to discover from HTML
|
||||
discovered = _discover_feed_url(body, feed_url)
|
||||
if not discovered:
|
||||
raise HTTPException(status_code=400, detail="No RSS/Atom feed found at this URL")
|
||||
|
||||
# Fetch the discovered feed
|
||||
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
||||
resp2 = await client.get(discovered, headers={"User-Agent": "Reader/1.0"})
|
||||
resp2.raise_for_status()
|
||||
|
||||
parsed2 = feedparser.parse(resp2.text)
|
||||
title = parsed2.feed.get("title", discovered)
|
||||
site_url = parsed2.feed.get("link") or feed_url
|
||||
return discovered, title, site_url
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/counters", response_model=CountersOut)
|
||||
async def feed_counters(
|
||||
user_id: str = Depends(get_user_id),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Entry.feed_id, func.count(Entry.id))
|
||||
.where(Entry.user_id == user_id, Entry.status == "unread")
|
||||
.group_by(Entry.feed_id)
|
||||
)
|
||||
unreads = {str(row[0]): row[1] for row in result.all()}
|
||||
return {"unreads": unreads}
|
||||
|
||||
|
||||
@router.get("", response_model=list[FeedOut])
|
||||
async def list_feeds(
|
||||
user_id: str = Depends(get_user_id),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Feed)
|
||||
.where(Feed.user_id == user_id)
|
||||
.order_by(Feed.title)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("", response_model=FeedOut, status_code=201)
|
||||
async def create_feed(
|
||||
body: FeedCreate,
|
||||
user_id: str = Depends(get_user_id),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
# Check for duplicate
|
||||
existing = await db.execute(
|
||||
select(Feed).where(Feed.feed_url == body.feed_url)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=409, detail="Feed already exists")
|
||||
|
||||
# Validate category belongs to user
|
||||
if body.category_id:
|
||||
cat = await db.execute(
|
||||
select(Category).where(
|
||||
Category.id == body.category_id,
|
||||
Category.user_id == user_id,
|
||||
)
|
||||
)
|
||||
if not cat.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
|
||||
# Fetch and discover feed
|
||||
try:
|
||||
actual_url, title, site_url = await _fetch_and_parse_feed(body.feed_url)
|
||||
except httpx.HTTPError as e:
|
||||
log.warning("Failed to fetch feed %s: %s", body.feed_url, e)
|
||||
raise HTTPException(status_code=400, detail=f"Could not fetch feed: {e}")
|
||||
|
||||
# Check again with discovered URL
|
||||
if actual_url != body.feed_url:
|
||||
existing = await db.execute(
|
||||
select(Feed).where(Feed.feed_url == actual_url)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=409, detail="Feed already exists")
|
||||
|
||||
feed = Feed(
|
||||
user_id=user_id,
|
||||
category_id=body.category_id,
|
||||
title=title,
|
||||
feed_url=actual_url,
|
||||
site_url=site_url,
|
||||
)
|
||||
db.add(feed)
|
||||
await db.commit()
|
||||
await db.refresh(feed)
|
||||
return feed
|
||||
|
||||
|
||||
@router.delete("/{feed_id}", status_code=204)
|
||||
async def delete_feed(
|
||||
feed_id: int,
|
||||
user_id: str = Depends(get_user_id),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Feed).where(Feed.id == feed_id, Feed.user_id == user_id)
|
||||
)
|
||||
feed = result.scalar_one_or_none()
|
||||
if not feed:
|
||||
raise HTTPException(status_code=404, detail="Feed not found")
|
||||
await db.delete(feed)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/{feed_id}/refresh")
|
||||
async def refresh_feed(
|
||||
feed_id: int,
|
||||
user_id: str = Depends(get_user_id),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Feed).where(Feed.id == feed_id, Feed.user_id == user_id)
|
||||
)
|
||||
feed = result.scalar_one_or_none()
|
||||
if not feed:
|
||||
raise HTTPException(status_code=404, detail="Feed not found")
|
||||
|
||||
import asyncio
|
||||
from app.worker.tasks import fetch_single_feed
|
||||
await asyncio.to_thread(fetch_single_feed, feed_id)
|
||||
|
||||
return {"ok": True, "message": f"Refreshed {feed.title}"}
|
||||
|
||||
|
||||
@router.post("/refresh-all")
|
||||
async def refresh_all_feeds(
|
||||
user_id: str = Depends(get_user_id),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Feed).where(Feed.user_id == user_id)
|
||||
)
|
||||
feeds = result.scalars().all()
|
||||
|
||||
import asyncio
|
||||
from app.worker.tasks import fetch_single_feed
|
||||
for feed in feeds:
|
||||
try:
|
||||
await asyncio.to_thread(fetch_single_feed, feed.id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"ok": True, "message": f"Refreshed {len(feeds)} feeds"}
|
||||
23
services/reader/app/config.py
Normal file
23
services/reader/app/config.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Reader service configuration — all from environment variables."""
|
||||
|
||||
import os
|
||||
|
||||
# ── Database (reuse Brain's PostgreSQL) ──
|
||||
DATABASE_URL = os.environ.get(
|
||||
"DATABASE_URL",
|
||||
"postgresql+asyncpg://brain:brain@brain-db:5432/brain",
|
||||
)
|
||||
DATABASE_URL_SYNC = DATABASE_URL.replace("+asyncpg", "")
|
||||
|
||||
# ── Redis (reuse Brain's Redis) ──
|
||||
REDIS_URL = os.environ.get("REDIS_URL", "redis://brain-redis:6379/0")
|
||||
|
||||
# ── Crawler (reuse Brain's Playwright crawler) ──
|
||||
CRAWLER_URL = os.environ.get("CRAWLER_URL", "http://brain-crawler:3100")
|
||||
|
||||
# ── Service ──
|
||||
PORT = int(os.environ.get("PORT", "8300"))
|
||||
DEBUG = os.environ.get("DEBUG", "").lower() in ("1", "true")
|
||||
|
||||
# ── Feed fetch interval (seconds) ──
|
||||
FEED_FETCH_INTERVAL = int(os.environ.get("FEED_FETCH_INTERVAL", "600"))
|
||||
18
services/reader/app/database.py
Normal file
18
services/reader/app/database.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Database session and engine setup."""
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.config import DATABASE_URL
|
||||
|
||||
engine = create_async_engine(DATABASE_URL, echo=False, pool_size=10, max_overflow=5)
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
43
services/reader/app/main.py
Normal file
43
services/reader/app/main.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Reader service — FastAPI entrypoint."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.api.categories import router as categories_router
|
||||
from app.api.feeds import router as feeds_router
|
||||
from app.api.entries import router as entries_router
|
||||
from app.config import DEBUG
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if DEBUG else logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
|
||||
app = FastAPI(
|
||||
title="Reader",
|
||||
description="Self-hosted RSS reader — replaces Miniflux.",
|
||||
version="1.0.0",
|
||||
docs_url="/api/docs" if DEBUG else None,
|
||||
redoc_url=None,
|
||||
)
|
||||
|
||||
app.include_router(categories_router)
|
||||
app.include_router(feeds_router)
|
||||
app.include_router(entries_router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
from app.database import engine, Base
|
||||
from app.models import Category, Feed, Entry # noqa: register models
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
logging.getLogger(__name__).info("Reader service started")
|
||||
74
services/reader/app/models.py
Normal file
74
services/reader/app/models.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""SQLAlchemy models for the reader service."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Category(Base):
|
||||
__tablename__ = "reader_categories"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(String(64), nullable=False)
|
||||
title = Column(String(255), nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
feeds = relationship("Feed", back_populates="category", lazy="selectin")
|
||||
|
||||
|
||||
class Feed(Base):
|
||||
__tablename__ = "reader_feeds"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(String(64), nullable=False)
|
||||
category_id = Column(Integer, ForeignKey("reader_categories.id", ondelete="SET NULL"), nullable=True)
|
||||
title = Column(String(500), nullable=False)
|
||||
feed_url = Column(Text, nullable=False, unique=True)
|
||||
site_url = Column(Text)
|
||||
etag = Column(String(255))
|
||||
last_modified = Column(String(255))
|
||||
last_fetched_at = Column(DateTime)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
category = relationship("Category", back_populates="feeds", lazy="selectin")
|
||||
entries = relationship("Entry", back_populates="feed", lazy="noload", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Entry(Base):
|
||||
__tablename__ = "reader_entries"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("feed_id", "url", name="uq_reader_entries_feed_url"),
|
||||
Index("idx_reader_entries_user_status", "user_id", "status"),
|
||||
Index("idx_reader_entries_user_starred", "user_id", "starred"),
|
||||
Index("idx_reader_entries_feed", "feed_id"),
|
||||
Index("idx_reader_entries_published", "published_at"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
feed_id = Column(Integer, ForeignKey("reader_feeds.id", ondelete="CASCADE"), nullable=False)
|
||||
user_id = Column(String(64), nullable=False)
|
||||
title = Column(String(1000))
|
||||
url = Column(Text)
|
||||
content = Column(Text)
|
||||
full_content = Column(Text)
|
||||
author = Column(String(500))
|
||||
published_at = Column(DateTime)
|
||||
status = Column(String(10), default="unread")
|
||||
starred = Column(Boolean, default=False)
|
||||
reading_time = Column(Integer, default=1)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
feed = relationship("Feed", back_populates="entries", lazy="selectin")
|
||||
0
services/reader/app/worker/__init__.py
Normal file
0
services/reader/app/worker/__init__.py
Normal file
363
services/reader/app/worker/tasks.py
Normal file
363
services/reader/app/worker/tasks.py
Normal file
@@ -0,0 +1,363 @@
|
||||
"""Feed fetching worker — RQ tasks and scheduling loop."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import feedparser
|
||||
import httpx
|
||||
from dateutil import parser as dateparser
|
||||
from redis import Redis
|
||||
from rq import Queue
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.config import DATABASE_URL_SYNC, FEED_FETCH_INTERVAL, REDIS_URL
|
||||
from app.models import Category, Entry, Feed
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# ── Sync DB engine (for RQ worker) ──
|
||||
_engine = create_engine(DATABASE_URL_SYNC, echo=False, pool_size=5, max_overflow=3)
|
||||
SyncSession = sessionmaker(_engine, class_=Session, expire_on_commit=False)
|
||||
|
||||
# ── RQ queue ──
|
||||
_redis = Redis.from_url(REDIS_URL)
|
||||
queue = Queue("reader", connection=_redis)
|
||||
|
||||
# HTML tag stripper
|
||||
_html_re = re.compile(r"<[^>]+>")
|
||||
|
||||
|
||||
def _strip_html(text: str) -> str:
|
||||
"""Remove HTML tags for word counting."""
|
||||
if not text:
|
||||
return ""
|
||||
return _html_re.sub("", text)
|
||||
|
||||
|
||||
def _calc_reading_time(html_content: str) -> int:
|
||||
"""Estimate reading time in minutes from HTML content."""
|
||||
plain = _strip_html(html_content)
|
||||
word_count = len(plain.split())
|
||||
return max(1, word_count // 200)
|
||||
|
||||
|
||||
def _parse_date(entry: dict) -> datetime | None:
|
||||
"""Parse published date from a feedparser entry."""
|
||||
for field in ("published", "updated", "created"):
|
||||
val = entry.get(field)
|
||||
if val:
|
||||
try:
|
||||
return dateparser.parse(val)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
# Try struct_time fields
|
||||
for field in ("published_parsed", "updated_parsed", "created_parsed"):
|
||||
val = entry.get(field)
|
||||
if val:
|
||||
try:
|
||||
return datetime(*val[:6], tzinfo=timezone.utc)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _get_entry_content(entry: dict) -> str:
|
||||
"""Extract the best content from a feedparser entry."""
|
||||
# Prefer content field (often full HTML)
|
||||
if entry.get("content"):
|
||||
return entry["content"][0].get("value", "")
|
||||
# Fall back to summary
|
||||
if entry.get("summary"):
|
||||
return entry["summary"]
|
||||
# Fall back to description
|
||||
if entry.get("description"):
|
||||
return entry["description"]
|
||||
return ""
|
||||
|
||||
|
||||
def _get_entry_author(entry: dict) -> str | None:
|
||||
"""Extract author from a feedparser entry."""
|
||||
if entry.get("author"):
|
||||
return entry["author"]
|
||||
if entry.get("author_detail", {}).get("name"):
|
||||
return entry["author_detail"]["name"]
|
||||
return None
|
||||
|
||||
|
||||
def _ensure_uncategorized(db: Session, user_id: str) -> int:
|
||||
"""Ensure an 'Uncategorized' category exists for the user, return its ID."""
|
||||
row = db.execute(
|
||||
select(Category).where(
|
||||
Category.user_id == user_id,
|
||||
Category.title == "Uncategorized",
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row:
|
||||
return row.id
|
||||
cat = Category(user_id=user_id, title="Uncategorized")
|
||||
db.add(cat)
|
||||
db.flush()
|
||||
return cat.id
|
||||
|
||||
|
||||
def fetch_single_feed(feed_id: int):
|
||||
"""Fetch and parse a single feed, inserting new entries."""
|
||||
with SyncSession() as db:
|
||||
feed = db.execute(select(Feed).where(Feed.id == feed_id)).scalar_one_or_none()
|
||||
if not feed:
|
||||
log.warning("Feed %d not found, skipping", feed_id)
|
||||
return
|
||||
|
||||
log.info("Fetching feed %d: %s", feed.id, feed.feed_url)
|
||||
|
||||
headers = {"User-Agent": "Reader/1.0"}
|
||||
if feed.etag:
|
||||
headers["If-None-Match"] = feed.etag
|
||||
if feed.last_modified:
|
||||
headers["If-Modified-Since"] = feed.last_modified
|
||||
|
||||
try:
|
||||
resp = httpx.get(feed.feed_url, headers=headers, timeout=30, follow_redirects=True)
|
||||
except httpx.HTTPError as e:
|
||||
log.error("HTTP error fetching feed %d: %s", feed.id, e)
|
||||
return
|
||||
|
||||
# 304 Not Modified
|
||||
if resp.status_code == 304:
|
||||
log.debug("Feed %d not modified", feed.id)
|
||||
feed.last_fetched_at = datetime.utcnow()
|
||||
db.commit()
|
||||
return
|
||||
|
||||
if resp.status_code != 200:
|
||||
log.warning("Feed %d returned status %d", feed.id, resp.status_code)
|
||||
return
|
||||
|
||||
# Update etag/last-modified
|
||||
feed.etag = resp.headers.get("ETag")
|
||||
feed.last_modified = resp.headers.get("Last-Modified")
|
||||
feed.last_fetched_at = datetime.utcnow()
|
||||
|
||||
parsed = feedparser.parse(resp.text)
|
||||
if not parsed.entries:
|
||||
log.debug("Feed %d has no entries", feed.id)
|
||||
db.commit()
|
||||
return
|
||||
|
||||
new_count = 0
|
||||
new_entry_ids = []
|
||||
for fe in parsed.entries:
|
||||
url = fe.get("link")
|
||||
if not url:
|
||||
continue
|
||||
|
||||
content = _get_entry_content(fe)
|
||||
pub_date = _parse_date(fe)
|
||||
|
||||
stmt = pg_insert(Entry).values(
|
||||
feed_id=feed.id,
|
||||
user_id=feed.user_id,
|
||||
title=fe.get("title", "")[:1000] if fe.get("title") else None,
|
||||
url=url,
|
||||
content=content,
|
||||
author=_get_entry_author(fe),
|
||||
published_at=pub_date,
|
||||
status="unread",
|
||||
starred=False,
|
||||
reading_time=_calc_reading_time(content),
|
||||
).on_conflict_do_nothing(
|
||||
constraint="uq_reader_entries_feed_url"
|
||||
).returning(Entry.id)
|
||||
result = db.execute(stmt)
|
||||
row = result.fetchone()
|
||||
if row:
|
||||
new_entry_ids.append(row[0])
|
||||
new_count += 1
|
||||
|
||||
db.commit()
|
||||
log.info("Feed %d: %d new entries from %d total", feed.id, new_count, len(parsed.entries))
|
||||
|
||||
# Fetch full content for new entries
|
||||
if new_entry_ids:
|
||||
_fetch_full_content_for_entries(db, new_entry_ids)
|
||||
|
||||
|
||||
def _fetch_full_content_for_entries(db, entry_ids: list[int]):
|
||||
"""Fetch full article content for specific entries."""
|
||||
from app.config import CRAWLER_URL
|
||||
|
||||
entries = db.execute(
|
||||
select(Entry).where(Entry.id.in_(entry_ids))
|
||||
).scalars().all()
|
||||
|
||||
log.info("Fetching full content for %d new entries", len(entries))
|
||||
for entry in entries:
|
||||
if not entry.url:
|
||||
continue
|
||||
try:
|
||||
resp = httpx.post(
|
||||
f"{CRAWLER_URL}/crawl",
|
||||
json={"url": entry.url},
|
||||
timeout=45,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
readable = data.get("readable_html", "")
|
||||
full_text = data.get("text", "")
|
||||
if readable:
|
||||
entry.full_content = readable
|
||||
if full_text:
|
||||
entry.reading_time = max(1, len(full_text.split()) // 200)
|
||||
elif full_text and len(full_text) > len(_strip_html(entry.content or "")):
|
||||
paragraphs = [p.strip() for p in full_text.split("\n\n") if p.strip()]
|
||||
if not paragraphs:
|
||||
paragraphs = [p.strip() for p in full_text.split("\n") if p.strip()]
|
||||
entry.full_content = "\n".join(f"<p>{p}</p>" for p in paragraphs)
|
||||
entry.reading_time = max(1, len(full_text.split()) // 200)
|
||||
else:
|
||||
entry.full_content = entry.content or ""
|
||||
else:
|
||||
entry.full_content = entry.content or ""
|
||||
except Exception as e:
|
||||
log.warning("Full content fetch failed for entry %d: %s", entry.id, e)
|
||||
entry.full_content = entry.content or ""
|
||||
|
||||
db.commit()
|
||||
log.info("Full content done for %d entries", len(entries))
|
||||
|
||||
|
||||
def fetch_full_content_batch():
|
||||
"""Fetch full article content for entries that only have RSS summaries."""
|
||||
from app.config import CRAWLER_URL
|
||||
|
||||
with SyncSession() as db:
|
||||
# Find entries with short content and no full_content (limit batch size)
|
||||
entries = db.execute(
|
||||
select(Entry).where(
|
||||
Entry.full_content.is_(None),
|
||||
Entry.url.isnot(None),
|
||||
Entry.status == "unread",
|
||||
).order_by(Entry.published_at.desc()).limit(20)
|
||||
).scalars().all()
|
||||
|
||||
if not entries:
|
||||
return
|
||||
|
||||
log.info("Fetching full content for %d entries", len(entries))
|
||||
for entry in entries:
|
||||
try:
|
||||
resp = httpx.post(
|
||||
f"{CRAWLER_URL}/crawl",
|
||||
json={"url": entry.url},
|
||||
timeout=45,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
readable = data.get("readable_html", "")
|
||||
full_text = data.get("text", "")
|
||||
if readable:
|
||||
entry.full_content = readable
|
||||
if full_text:
|
||||
entry.reading_time = max(1, len(full_text.split()) // 200)
|
||||
elif full_text and len(full_text) > len(_strip_html(entry.content or "")):
|
||||
paragraphs = [p.strip() for p in full_text.split("\n\n") if p.strip()]
|
||||
if not paragraphs:
|
||||
paragraphs = [p.strip() for p in full_text.split("\n") if p.strip()]
|
||||
entry.full_content = "\n".join(f"<p>{p}</p>" for p in paragraphs)
|
||||
entry.reading_time = max(1, len(full_text.split()) // 200)
|
||||
else:
|
||||
entry.full_content = entry.content or ""
|
||||
else:
|
||||
entry.full_content = entry.content or ""
|
||||
except Exception as e:
|
||||
log.warning("Full content fetch failed for entry %d: %s", entry.id, e)
|
||||
entry.full_content = entry.content or ""
|
||||
|
||||
db.commit()
|
||||
log.info("Full content fetched for %d entries", len(entries))
|
||||
|
||||
|
||||
def fetch_all_feeds():
|
||||
"""Fetch all feeds — called on schedule."""
|
||||
with SyncSession() as db:
|
||||
feeds = db.execute(select(Feed)).scalars().all()
|
||||
|
||||
log.info("Scheduling fetch for %d feeds", len(feeds))
|
||||
for feed in feeds:
|
||||
try:
|
||||
fetch_single_feed(feed.id)
|
||||
except Exception:
|
||||
log.exception("Error fetching feed %d", feed.id)
|
||||
|
||||
# Full content is now fetched inline for each new entry
|
||||
|
||||
|
||||
def cleanup_old_entries():
|
||||
"""Delete old entries: read > 30 days, unread > 60 days."""
|
||||
from sqlalchemy import delete as sa_delete
|
||||
|
||||
with SyncSession() as db:
|
||||
now = datetime.utcnow()
|
||||
thirty_days = now - __import__('datetime').timedelta(days=30)
|
||||
sixty_days = now - __import__('datetime').timedelta(days=60)
|
||||
|
||||
# Read entries older than 30 days
|
||||
result1 = db.execute(
|
||||
sa_delete(Entry).where(
|
||||
Entry.status == "read",
|
||||
Entry.created_at < thirty_days,
|
||||
)
|
||||
)
|
||||
|
||||
# Unread entries older than 60 days
|
||||
result2 = db.execute(
|
||||
sa_delete(Entry).where(
|
||||
Entry.status == "unread",
|
||||
Entry.created_at < sixty_days,
|
||||
)
|
||||
)
|
||||
|
||||
db.commit()
|
||||
total = (result1.rowcount or 0) + (result2.rowcount or 0)
|
||||
if total > 0:
|
||||
log.info("Cleanup: deleted %d old entries (%d read, %d unread)",
|
||||
total, result1.rowcount or 0, result2.rowcount or 0)
|
||||
|
||||
|
||||
def run_scheduler():
|
||||
"""Simple loop that runs fetch_all_feeds every FEED_FETCH_INTERVAL seconds."""
|
||||
log.info("Reader scheduler started — interval: %ds", FEED_FETCH_INTERVAL)
|
||||
|
||||
# Create tables on first run (for the sync engine)
|
||||
from app.database import Base
|
||||
from app.models import Category, Feed, Entry # noqa: register models
|
||||
Base.metadata.create_all(_engine)
|
||||
|
||||
cycles = 0
|
||||
while True:
|
||||
try:
|
||||
fetch_all_feeds()
|
||||
except Exception:
|
||||
log.exception("Scheduler error in fetch_all_feeds")
|
||||
|
||||
# Run cleanup once per day (every ~144 cycles at 10min interval)
|
||||
cycles += 1
|
||||
if cycles % 144 == 0:
|
||||
try:
|
||||
cleanup_old_entries()
|
||||
except Exception:
|
||||
log.exception("Scheduler error in cleanup")
|
||||
|
||||
time.sleep(FEED_FETCH_INTERVAL)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
run_scheduler()
|
||||
43
services/reader/docker-compose.yml
Normal file
43
services/reader/docker-compose.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
services:
|
||||
# ── API ──
|
||||
reader-api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.api
|
||||
container_name: reader-api
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- DATABASE_URL=postgresql+asyncpg://brain:brain@brain-db:5432/brain
|
||||
- REDIS_URL=redis://brain-redis:6379/0
|
||||
- CRAWLER_URL=http://brain-crawler:3100
|
||||
- PORT=8300
|
||||
- DEBUG=${DEBUG:-0}
|
||||
- TZ=${TZ:-America/Chicago}
|
||||
networks:
|
||||
- default
|
||||
- pangolin
|
||||
- brain
|
||||
|
||||
# ── Worker (feed fetcher + scheduler) ──
|
||||
reader-worker:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.worker
|
||||
container_name: reader-worker
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- DATABASE_URL=postgresql+asyncpg://brain:brain@brain-db:5432/brain
|
||||
- REDIS_URL=redis://brain-redis:6379/0
|
||||
- CRAWLER_URL=http://brain-crawler:3100
|
||||
- FEED_FETCH_INTERVAL=600
|
||||
- TZ=${TZ:-America/Chicago}
|
||||
networks:
|
||||
- default
|
||||
- brain
|
||||
|
||||
networks:
|
||||
pangolin:
|
||||
external: true
|
||||
brain:
|
||||
name: brain_default
|
||||
external: true
|
||||
11
services/reader/requirements.txt
Normal file
11
services/reader/requirements.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.32.0
|
||||
sqlalchemy[asyncio]==2.0.35
|
||||
asyncpg==0.30.0
|
||||
psycopg2-binary==2.9.10
|
||||
pydantic==2.10.0
|
||||
httpx==0.28.0
|
||||
feedparser==6.0.11
|
||||
redis==5.2.0
|
||||
rq==2.1.0
|
||||
python-dateutil==2.9.0
|
||||
Reference in New Issue
Block a user