Skip to content

Commit 203dfcb

Browse files
JiroMusikclaude
andcommitted
Fix: ingredient matching (ESM __dirname fix), remove duplicate server code, Gemini Bring! integration, Dashboard JSX fix
- Root cause: __dirname undefined in ESM/tsx → alias map never loaded - New reusable matching module with German stemming, substring, cross-group alias matching - 193 items, 1499 aliases loaded once at startup via process.cwd() - Remove duplicate startServer() block and stray (); - Gemini: Bring! sync + enhanced inventory fields - Dashboard: fix duplicate )} JSX syntax error Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c1d0ac0 commit 203dfcb

2 files changed

Lines changed: 99 additions & 82 deletions

File tree

server.ts

Lines changed: 99 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import OpenAI from 'openai';
1111
import Anthropic from '@anthropic-ai/sdk';
1212
import path from 'path';
1313
import fs from 'fs';
14+
import { fileURLToPath } from 'url';
1415
import Parser from 'rss-parser';
1516

1617
const app = express();
@@ -82,6 +83,102 @@ const mapCategory = (rawCategory: string, productName: string): string => {
8283
return 'Sonstiges';
8384
};
8485

86+
// --- Ingredient Matching (alias map + fuzzy matching) ---
87+
const normalizeIngredient = (s: string) => s.toLowerCase().replace(/\(.*?\)/g, '').replace(/[,\.]/g, '').replace(/\s+/g, ' ').trim();
88+
89+
const germanStem = (s: string): string => {
90+
if (s.length <= 3) return s;
91+
for (const suffix of ['flöckchen', 'flocken', 'chen', 'lein', 'eln', 'ern', 'en', 'er', 'es', 'em', 'el', 'n', 'e', 's']) {
92+
if (s.length - suffix.length >= 3 && s.endsWith(suffix)) {
93+
return s.slice(0, -suffix.length);
94+
}
95+
}
96+
return s;
97+
};
98+
99+
// Resolve directory for ESM compatibility
100+
const serverDir = process.cwd();
101+
102+
// Load alias file once at startup
103+
let ingredientAliasMap: Record<string, string[]> = {};
104+
for (const tryPath of [
105+
path.join(serverDir, 'ingredient-aliases.json'),
106+
path.join(serverDir, 'src', 'data', 'ingredient-aliases.json'),
107+
path.join(process.cwd(), 'ingredient-aliases.json'),
108+
'/app/ingredient-aliases.json'
109+
]) {
110+
try {
111+
ingredientAliasMap = JSON.parse(fs.readFileSync(tryPath, 'utf-8'));
112+
console.log(`Loaded ${Object.keys(ingredientAliasMap).length} ingredient aliases from ${tryPath}`);
113+
break;
114+
} catch {}
115+
}
116+
if (Object.keys(ingredientAliasMap).length === 0) {
117+
console.warn('WARNING: ingredient-aliases.json not found — ingredient matching will be degraded');
118+
}
119+
120+
const ingredientAliasLookup: Record<string, string> = {};
121+
const ingredientAliasGroups: Record<string, string[]> = {};
122+
for (const [canonical, aliases] of Object.entries(ingredientAliasMap)) {
123+
const canonLower = canonical.toLowerCase();
124+
ingredientAliasGroups[canonLower] = aliases;
125+
for (const alias of aliases) {
126+
ingredientAliasLookup[alias] = canonLower;
127+
}
128+
ingredientAliasLookup[canonLower] = canonLower;
129+
}
130+
131+
function getCanonicalNames(name: string): Set<string> {
132+
const n = normalizeIngredient(name);
133+
const results = new Set<string>();
134+
results.add(n);
135+
if (ingredientAliasLookup[n]) results.add(ingredientAliasLookup[n]);
136+
const words = n.split(' ');
137+
if (words.length > 1 && ingredientAliasLookup[words[0]]) results.add(ingredientAliasLookup[words[0]]);
138+
const stemmed = germanStem(n);
139+
if (stemmed !== n && ingredientAliasLookup[stemmed]) results.add(ingredientAliasLookup[stemmed]);
140+
const stemmedFirst = germanStem(words[0]);
141+
if (stemmedFirst !== words[0] && ingredientAliasLookup[stemmedFirst]) results.add(ingredientAliasLookup[stemmedFirst]);
142+
for (const [alias, canonical] of Object.entries(ingredientAliasLookup)) {
143+
if (alias.length >= 4 && n.includes(alias)) results.add(canonical);
144+
}
145+
return results;
146+
}
147+
148+
function isIngredientInInventory(ingredientName: string, inventoryItems: { name: string; generic_name?: string | null }[]): boolean {
149+
const ingCanonicals = getCanonicalNames(ingredientName);
150+
const ingNorm = normalizeIngredient(ingredientName);
151+
return inventoryItems.some(item => {
152+
const itemCanonicals = getCanonicalNames(item.name);
153+
const genericCanonicals = item.generic_name ? getCanonicalNames(item.generic_name) : new Set<string>();
154+
const allItemCanonicals = new Set([...itemCanonicals, ...genericCanonicals]);
155+
for (const ic of ingCanonicals) { if (allItemCanonicals.has(ic)) return true; }
156+
const itemNorm = normalizeIngredient(item.name);
157+
const genericNorm = item.generic_name ? normalizeIngredient(item.generic_name) : '';
158+
if (ingNorm.length >= 4 && (itemNorm.includes(ingNorm) || genericNorm.includes(ingNorm))) return true;
159+
if (itemNorm.length >= 4 && ingNorm.includes(itemNorm)) return true;
160+
if (genericNorm.length >= 4 && ingNorm.includes(genericNorm)) return true;
161+
const ingStem = germanStem(ingNorm.split(' ')[0]);
162+
const itemStem = germanStem(itemNorm.split(' ')[0]);
163+
if (ingStem.length >= 3 && itemStem.length >= 3 && ingStem === itemStem) return true;
164+
for (const itemCanon of allItemCanonicals) {
165+
const group = ingredientAliasGroups[itemCanon];
166+
if (group) {
167+
for (const ic of ingCanonicals) {
168+
if (ic.length >= 4) {
169+
for (const alias of group) { if (alias.includes(ic)) return true; }
170+
}
171+
}
172+
}
173+
}
174+
return false;
175+
});
176+
}
177+
178+
function matchRecipeIngredients(ingredients: { name: string; in_inventory?: boolean }[], inventoryItems: { name: string; generic_name?: string | null }[]): void {
179+
for (const ing of ingredients) { ing.in_inventory = isIngredientInInventory(ing.name, inventoryItems); }
180+
}
181+
85182
// Initialize SQLite Database
86183
const dbDir = process.env.DB_DIR || process.cwd();
87184
const dbPath = path.join(dbDir, 'inventory.db');
@@ -1726,56 +1823,10 @@ app.post('/api/recipes/import', async (req, res) => {
17261823
return res.status(400).json({ error: result.error });
17271824
}
17281825

1729-
// Check which ingredients are in inventory using alias map
1826+
// Check which ingredients are in inventory using reusable matching
17301827
const items = db.prepare('SELECT name, generic_name, quantity, unit FROM items WHERE quantity > 0').all() as any[];
1731-
const normalize = (s: string) => s.toLowerCase().replace(/\(.*?\)/g, '').replace(/[,\.]/g, '').replace(/\s+/g, ' ').trim();
1732-
1733-
// Load ingredient alias map
1734-
let aliasMap: Record<string, string[]> = {};
1735-
try { aliasMap = JSON.parse(fs.readFileSync(path.join(__dirname, 'src', 'data', 'ingredient-aliases.json'), 'utf-8')); } catch {}
1736-
// Fallback: try dist location
1737-
if (Object.keys(aliasMap).length === 0) {
1738-
try { aliasMap = JSON.parse(fs.readFileSync(path.join(__dirname, 'ingredient-aliases.json'), 'utf-8')); } catch {}
1739-
}
1740-
1741-
// Build reverse lookup: alias → canonical name
1742-
const aliasLookup: Record<string, string> = {};
1743-
for (const [canonical, aliases] of Object.entries(aliasMap)) {
1744-
for (const alias of aliases) {
1745-
aliasLookup[alias] = canonical.toLowerCase();
1746-
}
1747-
aliasLookup[canonical.toLowerCase()] = canonical.toLowerCase();
1748-
}
1749-
1750-
const getCanonicals = (name: string): string[] => {
1751-
const n = normalize(name);
1752-
const results = new Set<string>();
1753-
results.add(n);
1754-
// Exact alias match
1755-
if (aliasLookup[n]) results.add(aliasLookup[n]);
1756-
// First word match
1757-
const firstWord = n.split(' ')[0];
1758-
if (aliasLookup[firstWord]) results.add(aliasLookup[firstWord]);
1759-
// Partial match — only if alias is a meaningful substring of the name (not vice versa)
1760-
for (const [alias, canonical] of Object.entries(aliasLookup)) {
1761-
if (alias.length >= 4 && n.includes(alias)) {
1762-
results.add(canonical);
1763-
}
1764-
}
1765-
return Array.from(results);
1766-
};
1767-
17681828
if (result.ingredients) {
1769-
for (const ing of result.ingredients) {
1770-
const ingCanonicals = getCanonicals(ing.name);
1771-
const match = items.find((item: any) => {
1772-
const itemCanonicals = getCanonicals(item.name);
1773-
const genericCanonicals = item.generic_name ? getCanonicals(item.generic_name) : [];
1774-
const allItemCanonicals = new Set([...itemCanonicals, ...genericCanonicals]);
1775-
return ingCanonicals.some(ic => allItemCanonicals.has(ic));
1776-
});
1777-
ing.in_inventory = !!match;
1778-
}
1829+
matchRecipeIngredients(result.ingredients, items);
17791830
}
17801831

17811832
res.json(result);
@@ -2309,36 +2360,3 @@ async function startServer() {
23092360
}
23102361

23112362
startServer();
2312-
2313-
const certDir = process.env.DB_DIR || process.cwd();
2314-
const keyPath = path.join(certDir, 'server.key');
2315-
const certPath = path.join(certDir, 'server.cert');
2316-
2317-
if (!fs.existsSync(keyPath) || !fs.existsSync(certPath)) {
2318-
console.log('Generating self-signed certificate...');
2319-
const { execFileSync } = await import('child_process');
2320-
execFileSync('openssl', [
2321-
'req', '-x509', '-newkey', 'rsa:2048',
2322-
'-keyout', keyPath, '-out', certPath,
2323-
'-days', '3650', '-nodes', '-subj', '/CN=foodai'
2324-
], { stdio: 'pipe' });
2325-
}
2326-
2327-
const sslOptions = {
2328-
key: fs.readFileSync(keyPath),
2329-
cert: fs.readFileSync(certPath),
2330-
};
2331-
2332-
https.createServer(sslOptions, app).listen(PORT, '0.0.0.0', () => {
2333-
console.log(`Server running on https://0.0.0.0:${PORT}`);
2334-
});
2335-
2336-
// HTTP on port 3001 for local iframe embedding (MagicMirror)
2337-
const HTTP_PORT = parseInt(process.env.HTTP_PORT || '3001');
2338-
http.createServer(app).listen(HTTP_PORT, '127.0.0.1', () => {
2339-
console.log(`HTTP server on http://127.0.0.1:${HTTP_PORT} (for local iframe embedding)`);
2340-
});
2341-
}
2342-
2343-
startServer();
2344-
();

src/pages/Dashboard.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,6 @@ export default function Dashboard() {
182182
))}
183183
</div>
184184
)}
185-
)}
186185
</section>
187186

188187
{/* Two columns: Expiring + Opened */}

0 commit comments

Comments
 (0)