@@ -11,6 +11,7 @@ import OpenAI from 'openai';
1111import Anthropic from '@anthropic-ai/sdk' ;
1212import path from 'path' ;
1313import fs from 'fs' ;
14+ import { fileURLToPath } from 'url' ;
1415import Parser from 'rss-parser' ;
1516
1617const 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
86183const dbDir = process . env . DB_DIR || process . cwd ( ) ;
87184const 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
23112362startServer ( ) ;
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- ( ) ;
0 commit comments