NEWS
Alexa Shopping List mit Bring synchronisieren
-
Todoist hat die API angepasst, v2 gibt es nicht mehr.
https://developer.todoist.com/api/v1/#tag/TasksDu musst die API-URL auf https://api.todoist.com/api/v1/tasks ändern und die Projekt-IDs aktualisieren.
@no6mis Ich danke dir! Genau das hat geholfen!
-
Sorry für meine späte Antwort - bei mir geht es auch nicht mehr. Die Projekt ID (zumindest die aus dem Browser) ist keine reine numerische Zahl mehr?
@Heimweh sagte in Alexa Shopping List mit Bring synchronisieren:
Die Projekt ID (zumindest die aus dem Browser) ist keine reine numerische Zahl mehr?
ja, die Zeichenfolge aus dem Browser (der Teil nach dem Klarnamen-) kannst du 1:1 in dein Script übernehmen.
-
Ich habe diese Änderung der Todoist API zum Anlass genommen den Script zu überarbeiten, da mittlerweile auch wieder die Listen in den Alexa Adapter eingelesen werden. Vielleicht kann den Script ja jemand brauchen.....
Alexa Shoppingliste ↔ Todoist (Projekt-Sync)
Dieses Script synchronisiert die Alexa Shoppingliste
(alexa2.0.Lists.SHOP.json) mit einem definierten Todoist-Projekt.Funktionen
Beim Scriptstart werden alle offenen Alexa-Einträge in das Todoist-Projekt übernommen.
Neue Einträge in Alexa werden automatisch in Todoist angelegt.
Wird ein Task in Todoist erledigt, wird er in Alexa als „completed“ markiert.
Gelöschte oder erledigte Tasks in Todoist verschwinden damit auch aus der offenen Alexa-Liste.
Doppelimporte werden durch ein internes Mapping verhindert.
Deutsche Zahlwörter werden automatisch in Zahlen umgewandelt (z. B. „zwei Liter“ → „2 Liter“).
Voraussetzungen
ioBroker mit javascript-Adapter
alexa2-Adapter mit aktivierter Listenfunktion
Todoist API Token (REST v1)
axios (muss installiert sein)
Unterschied zur älteren Version
Es wird nicht mehr der Datenpunkt alexa2.0.History.summary ausgewertet.
Es wird direkt der vom alexa2-Adapter bereitgestellte Listen-JSON verwendet.
Die Synchronisation funktioniert jetzt sauber in beide Richtungen.
Es wird ausschließlich die aktuelle Todoist REST v1 API verwendet.
const axios = require("axios"); const TAG = "A2S-SHOP-PROD-2"; const ALEXA_JSON_DP = "alexa2.0.Lists.SHOP.json"; const ALEXA_ITEMS_ROOT = "alexa2.0.Lists.SHOP.items"; const TODOIST_TOKEN = "HIER_DEIN_TODOIST_TOKEN"; const TODOIST_BASE = "https://api.todoist.com/api/v1"; const TODOIST_PROJECT_ID = "HIER_DEINE_TODOIST_PROJEKT_ID"; const POLL_SEC = 20; const MIN_TASK_AGE_MS = 5_000; const STORE_ROOT = "0_userdata.0.alexaTodoistBiSync"; const MAP_STATE = `${STORE_ROOT}.map_shop`; function headers(extra = {}) { return { Authorization: `Bearer ${TODOIST_TOKEN}`, ...extra }; } async function todoistCreateTask(content, projectId) { const res = await axios.post( `${TODOIST_BASE}/tasks`, { content, project_id: projectId }, { headers: headers({ "Content-Type": "application/json" }), timeout: 15000 } ); return res.data; } async function todoistGetTask(taskId) { const res = await axios.get(`${TODOIST_BASE}/tasks/${taskId}`, { headers: headers(), timeout: 15000, validateStatus: (s) => (s >= 200 && s < 300) || s === 404, }); return res; } function ensureStateIfMissing(id, def, common) { if (!existsState(id)) createState(id, def, common); } function readJsonSafe(id, fallback) { const st = getState(id); if (!st || st.val === null || st.val === undefined || st.val === "") return fallback; try { return JSON.parse(st.val); } catch { return fallback; } } function writeJson(id, obj) { setState(id, JSON.stringify(obj), true); } function parseAlexaJson(raw) { if (raw === null || raw === undefined) return []; const data = typeof raw === "string" ? JSON.parse(raw) : raw; return Array.isArray(data) ? data : []; } function isCompletedAlexa(it) { const v = it?.completed; return v === true || v === 1 || v === "1" || v === "true"; } function normalizeMap(map) { let changed = false; const now = Date.now(); for (const [alexaId, info] of Object.entries(map)) { if (!info || typeof info !== "object") { map[alexaId] = { todoistId: null, value: "", createdAt: now, alexaCompletedPushed: false }; changed = true; continue; } if (info.todoistId === undefined) { info.todoistId = null; changed = true; } if (info.value === undefined) { info.value = ""; changed = true; } if (!info.createdAt || isNaN(Number(info.createdAt))) { info.createdAt = now; changed = true; } if (info.alexaCompletedPushed === undefined) { info.alexaCompletedPushed = false; changed = true; } } return changed; } // ---- Text normalisieren (Zahlwörter) ---- function capitalizeFirst(text) { if (!text || typeof text !== "string") return ""; return text.charAt(0).toUpperCase() + text.slice(1); } function wordsToNumbersSmart(text) { const ones = { "null": 0, "eins": 1, "eine": 1, "einen": 1, "ein": 1, "zwei": 2, "drei": 3, "vier": 4, "fünf": 5, "sechs": 6, "sieben": 7, "acht": 8, "neun": 9, "zehn": 10, "elf": 11, "zwölf": 12, "dreizehn": 13, "vierzehn": 14, "fünfzehn": 15, "sechzehn": 16, "siebzehn": 17, "achtzehn": 18, "neunzehn": 19 }; const tens = { "zwanzig": 20, "dreißig": 30, "dreissig": 30, "vierzig": 40, "fünfzig": 50, "sechzig": 60, "siebzig": 70, "achtzig": 80, "neunzig": 90 }; const multipliers = { "hundert": 100, "tausend": 1000 }; const skipWords = ["und", "oder", "mit", "für", "pro"]; const words = String(text).toLowerCase().split(/\s+/).filter(Boolean); const out = []; let i = 0; let capNext = 0; while (i < words.length) { const w = words[i]; if (ones[w] !== undefined) { if (i + 2 < words.length && words[i + 1] === "und" && tens[words[i + 2]] !== undefined) { out.push(String(ones[w] + tens[words[i + 2]])); capNext = 2; i += 3; continue; } if (i + 1 < words.length && multipliers[words[i + 1]] !== undefined) { out.push(String(ones[w] * multipliers[words[i + 1]])); capNext = 2; i += 2; continue; } out.push(String(ones[w])); capNext = 2; i++; continue; } if (tens[w] !== undefined) { out.push(String(tens[w])); capNext = 2; i++; continue; } if (!isNaN(w)) { out.push(w); capNext = 2; i++; continue; } if (capNext > 0 && !skipWords.includes(w)) { out.push(w.charAt(0).toUpperCase() + w.slice(1)); capNext--; } else { out.push(w); } i++; } return out.join(" "); } function normalizeTaskText(raw) { const withNums = wordsToNumbersSmart(String(raw || "").trim()); return capitalizeFirst(withNums); } // ---- Debounce/Locks ---- let alexaSyncRunning = false; let pollRunning = false; let debounceTimer = null; function triggerAlexaSyncDebounced(fullImport = false) { if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { syncAlexaToTodoist(fullImport).catch(e => log(`[${TAG}] [Alexa→Todoist] ERROR ${e.message}`, "error")); }, 800); } // ---- Alexa -> Todoist ---- async function syncAlexaToTodoist(fullImport) { if (alexaSyncRunning) return; alexaSyncRunning = true; try { const raw = getState(ALEXA_JSON_DP)?.val; const items = parseAlexaJson(raw); const map = readJsonSafe(MAP_STATE, {}); const changed = normalizeMap(map); if (changed) writeJson(MAP_STATE, map); for (const it of items) { const alexaId = it?.id ? String(it.id) : null; const valueRaw = it?.value ? String(it.value).trim() : ""; if (!alexaId || !valueRaw) continue; if (isCompletedAlexa(it)) continue; if (map[alexaId]?.todoistId) continue; const value = normalizeTaskText(valueRaw); const task = await todoistCreateTask(value, TODOIST_PROJECT_ID); map[alexaId] = { todoistId: String(task.id), value, createdAt: Date.now(), alexaCompletedPushed: false, }; writeJson(MAP_STATE, map); log(`[${TAG}] [Alexa→Todoist] Angelegt: "${value}"`, "info"); } if (fullImport) log(`[${TAG}] [Init] Start-Import abgeschlossen.`, "info"); } finally { alexaSyncRunning = false; } } // ---- Todoist -> Alexa ---- async function pollTodoistAndMirror() { if (pollRunning) return; pollRunning = true; try { const map = readJsonSafe(MAP_STATE, {}); const changed = normalizeMap(map); if (changed) writeJson(MAP_STATE, map); const now = Date.now(); const entries = Object.entries(map).filter(([, info]) => info?.todoistId && !info.alexaCompletedPushed); for (const [alexaId, info] of entries) { const todoistId = String(info.todoistId); const age = now - Number(info.createdAt || now); if (age < MIN_TASK_AGE_MS) continue; const res = await todoistGetTask(todoistId); let markDone = false; if (res.status === 404) { // Task nicht mehr abrufbar -> behandeln wir als "weg/erledigt" markDone = true; } else if (res.status >= 200 && res.status < 300) { const t = res.data || {}; const checked = t.checked === true; const deleted = t.is_deleted === true; // Wenn erledigt ODER gelöscht, dann Alexa abhaken markDone = (checked || deleted); } else { continue; } if (!markDone) continue; const completedDp = `${ALEXA_ITEMS_ROOT}.${alexaId}.completed`; if (existsState(completedDp)) setState(completedDp, true, false); info.alexaCompletedPushed = true; writeJson(MAP_STATE, map); log(`[${TAG}] [Todoist→Alexa] Abgehakt (TodoistId=${todoistId})`, "info"); } } catch (e) { const status = e.response?.status; const body = e.response?.data ? JSON.stringify(e.response.data) : e.message; log(`[${TAG}] [Poll] ERROR status=${status} body=${body}`, "error"); } finally { pollRunning = false; } } // ---- INIT ---- ensureStateIfMissing(MAP_STATE, "{}", { type: "string", role: "json", read: true, write: true }); log(`[${TAG}] START poll=${POLL_SEC}s`, "info"); triggerAlexaSyncDebounced(true); on({ id: ALEXA_JSON_DP, change: "any" }, () => triggerAlexaSyncDebounced(false)); schedule(`*/${POLL_SEC} * * * * *`, () => pollTodoistAndMirror()); -
@mcBirne Es ist zwar schon einige Zeit her, aber hast Du das Skript als TypeScript eingefügt? Die Fehlermeldungen sehen nach JavaScript aus.
-
Hallo zusammen,
nach dem ich hier viel über IO Broker in Verbindung mit Bring und der Alexa Shopping Liste gelernt habe
und immer mal wieder "tweaks" an meinem abgewandelten Script vorgenommen habe und es nun super geschmeidigt läuft, möchte ich gerne das aktuelle ALEXA BRING SCRIPT mit euch teilen und der hervorragenden Community auf diesem Weg etwas zurück geben und einfach nur DANKE
sagen.Zum Script:
Bei der brindBaseID müsst ihr XXX wie gewohnt durch eure Bring Shopping List ID ersetzen, damit die richtige Liste befüllt wird. Ansonste alles wie immer: Alexa2.0 Adapter und bring.0 Adapter sind Voraussetzung, dass es funktioniert. Steht aber auch auskommentiert im Script.
// ===================================================================== // Alexa Shopping List <-> Bring! Zwei-Wege-Sync // Engine: 3-Wege-Merge gegen persistierten Schatten + Zeitstempel-Konfliktlösung // Behandelt korrekt: Hinzufügen, Abhaken, Reaktivieren, Löschen (beidseitig) // ===================================================================== // --- KONFIGURATION: an die eigene Installation anpassen --- // Bring-Listen-ID findest du in den Objekten unter "bring.0" -> Channel deiner Liste (UUID): const bringBaseId = 'bring.0.xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'; // Alexa-Einkaufsliste, bei den meisten Setups: const alexa2BaseId = 'alexa2.0.Lists.SHOPPING_LIST'; // Voraussetzungen: Adapter "alexa2" + "bring" eingerichtet, 0_userdata.0 vorhanden. // Persistenter Speicher für den Schatten (0_userdata.0 existiert in modernen ioBroker-Installs) const SHADOW_STATE = '0_userdata.0.alexaBringSync.shadow'; // Timing const DEBOUNCE_MS = 2000; // warte nach der letzten Änderung, bevor synchronisiert wird const LOCK_MS = 5000; // Beruhigungsfenster, bis async Schreibvorgänge der Adapter durch sind // Bei einem echten Konflikt (beide Seiten gleichzeitig geändert, Zeitstempel uneindeutig): // true = im Zweifel "active" gewinnen lassen (nichts verlieren, was man evtl. noch braucht) const CONFLICT_PREFER_ACTIVE = true; // Optionaler Watchdog: warnt im Log, wenn die alexa2-Cloud-Verbindung wegbricht const WATCH_ALEXA_CONNECTION = true; // --- ABGELEITETE IDs --- const bringListId = bringBaseId + '.content'; const bringListCompletedId = bringBaseId + '.recentContent'; const bringAddToList = bringBaseId + '.saveItem'; // hinzufügen / aus recent reaktivieren const bringCompleteItem = bringBaseId + '.moveToRecentContent'; // abhaken (-> recent) const bringRemoveItem = bringBaseId + '.removeItem'; // komplett entfernen (content + recent) const alexaAddToList = alexa2BaseId + '.#New'; const alexaListId = alexa2BaseId + '.json'; // --- LAUFZEIT-ZUSTAND --- let isSyncing = false; let pendingSync = false; let syncTimeout = null; let debounceTimer = null; // --- HILFSFUNKTIONEN --- function cleanName(str) { return str ? String(str).trim().toLowerCase() : ''; } function formatName(str) { if (!str) return ''; str = String(str).trim(); return str.charAt(0).toUpperCase() + str.slice(1); } function loadShadow() { try { const s = getState(SHADOW_STATE); return (s && s.val) ? JSON.parse(s.val) : {}; } catch (e) { log('Schatten nicht lesbar, starte leer: ' + e, 'warn'); return {}; } } function saveShadow(sh) { setState(SHADOW_STATE, JSON.stringify(sh), true); } // Konfliktlösung: jüngerer Zeitstempel gewinnt, sonst konfigurierte Vorzugsregel function resolveConflict(aStatus, bStatus, aTs, bTs, displayName) { let winner; if (aTs > bTs) winner = aStatus; else if (bTs > aTs) winner = bStatus; else winner = CONFLICT_PREFER_ACTIVE ? ((aStatus === 'active' || bStatus === 'active') ? 'active' : 'done') : ((aStatus === 'done' || bStatus === 'done') ? 'done' : 'active'); log(`[KONFLIKT] "${displayName}": Alexa=${aStatus}(${aTs}) vs Bring=${bStatus}(${bTs}) -> ${winner}`, 'warn'); return winner; } function finishLock() { if (syncTimeout) clearTimeout(syncTimeout); syncTimeout = setTimeout(() => { isSyncing = false; if (pendingSync) { pendingSync = false; triggerSync(); } // Nachlauf für während der Sperre Eingetroffenes }, LOCK_MS); } // --- HAUPTLOGIK --- function doSync() { if (isSyncing) { pendingSync = true; return; } isSyncing = true; try { const aState = getState(alexaListId); const bState = getState(bringListId); const rState = getState(bringListCompletedId); if (!aState || aState.val == null || !bState || bState.val == null) { finishLock(); return; } const alexaList = JSON.parse(aState.val); const bringActive = JSON.parse(bState.val); const bringRecent = (rState && rState.val) ? JSON.parse(rState.val) : []; // grobe Bring-Zeitstempel (Listenebene) nur für die Konfliktlösung const bringContentLc = bState.lc || bState.ts || 0; const bringRecentLc = (rState && (rState.lc || rState.ts)) || 0; // --- Maps der aktuellen Zustände --- const alexaByName = {}; // active gewinnt bei Namens-Duplikaten alexaList.forEach(it => { const k = cleanName(it.value); if (!k) return; const st = it.completed ? 'done' : 'active'; if (!alexaByName[k] || st === 'active') alexaByName[k] = it; }); const bringActiveMap = {}; bringActive.forEach(it => { const k = cleanName(it.name); if (k) bringActiveMap[k] = it; }); const bringRecentMap = {}; bringRecent.forEach(it => { const k = cleanName(it.name); if (k) bringRecentMap[k] = it; }); const shadow = loadShadow(); const newShadow = {}; const names = new Set([ ...Object.keys(alexaByName), ...Object.keys(bringActiveMap), ...Object.keys(bringRecentMap), ...Object.keys(shadow) ]); names.forEach(name => { const aItem = alexaByName[name]; const aStatus = aItem ? (aItem.completed ? 'done' : 'active') : 'absent'; const bStatus = bringActiveMap[name] ? 'active' : (bringRecentMap[name] ? 'done' : 'absent'); const prev = shadow[name] || { a: 'absent', b: 'absent', c: 'absent' }; const aChanged = aStatus !== prev.a; const bChanged = bStatus !== prev.b; const displayName = (bringActiveMap[name] || bringRecentMap[name] || {}).name || (aItem ? aItem.value : null) || prev.name || formatName(name); const aTs = aItem ? (aItem.updatedDateTime || 0) : 0; const bTs = (bStatus === 'active') ? bringContentLc : (bStatus === 'done' ? bringRecentLc : 0); // --- Zielzustand T bestimmen --- let T; if (aChanged && !bChanged) T = aStatus; else if (bChanged && !aChanged) T = bStatus; else if (aChanged && bChanged) T = (aStatus === bStatus) ? aStatus : resolveConflict(aStatus, bStatus, aTs, bTs, displayName); else T = prev.c; // nichts geändert -> Kanon halten if (!T) T = (aStatus !== 'absent') ? aStatus : bStatus; // --- Alexa angleichen --- let aResult = aStatus; if (T === 'active') { if (aStatus === 'absent') { log(`[ALEXA +] ${displayName}`); setState(alexaAddToList, displayName); aResult = 'active'; } else if (aStatus === 'done') { log(`[ALEXA reaktiv.] ${displayName}`); setState(`${alexa2BaseId}.items.${aItem.id}.completed`, false); aResult = 'active'; } } else if (T === 'done') { if (aStatus === 'active') { log(`[ALEXA abgehakt] ${displayName}`); setState(`${alexa2BaseId}.items.${aItem.id}.completed`, true); aResult = 'done'; } // aStatus 'absent' -> erledigtes Item lässt sich in Alexa nicht erzeugen, bleibt absent } else if (T === 'absent') { if (aStatus !== 'absent') { log(`[ALEXA gelöscht] ${displayName}`); setState(`${alexa2BaseId}.items.${aItem.id}.#delete`, true); aResult = 'absent'; } } // --- Bring angleichen --- let bResult = bStatus; if (T === 'active') { if (bStatus !== 'active') { log(`[BRING +] ${displayName}`); setState(bringAddToList, displayName); bResult = 'active'; } } else if (T === 'done') { if (bStatus === 'active') { log(`[BRING abgehakt] ${displayName}`); setState(bringCompleteItem, displayName); bResult = 'done'; } // bStatus 'absent' -> kann nicht als erledigt erzeugt werden, bleibt absent } else if (T === 'absent') { if (bStatus !== 'absent') { log(`[BRING gelöscht] ${displayName}`); setState(bringRemoveItem, displayName); bResult = 'absent'; } } // --- neuen Schatten schreiben (komplett verschwundene Items fallen raus) --- if (!(aResult === 'absent' && bResult === 'absent')) { newShadow[name] = { a: aResult, b: bResult, c: T, name: displayName, ts: Date.now() }; } }); saveShadow(newShadow); } catch (e) { log('Fehler im Sync-Skript: ' + e, 'error'); } finishLock(); } // --- TRIGGER mit Debounce --- function triggerSync() { if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(doSync, DEBOUNCE_MS); } on({ id: bringListId, change: 'ne' }, triggerSync); on({ id: bringListCompletedId, change: 'ne' }, triggerSync); on({ id: alexaListId, change: 'ne' }, triggerSync); if (WATCH_ALEXA_CONNECTION) { on({ id: 'alexa2.0.info.connection', change: 'ne' }, obj => { if (obj.state.val === false) log('alexa2-Cloud-Verbindung verloren – Sync evtl. inaktiv bis zum Reconnect.', 'warn'); }); } // --- START: Schatten-State sicherstellen, dann initial abgleichen --- createState(SHADOW_STATE, '{}', false, { name: 'Alexa-Bring Sync Shadow', type: 'string', read: true, write: true }, () => { log('Alexa <-> Bring Sync (Shadow-Merge) gestartet. Initialer Abgleich …'); triggerSync(); });Das Script müsste ihr einfach als JS Script in IO Broker kopieren und starten. Der Rest funktioniert dann automatisch. WICHTIG NOCHMAL: Die Bring Base ID bzw. der Platzhalter XXX mit eurer echten Listen ID ersetzen.
DANKE nochmal an die gesamte Community, mit deren Hilfe dieses angepasste und für meine Zwecke hervorragend funktionierende Script entstanden ist. KUDOS an alle die ihre Scripts geteilt haben.

-
@schiefvancleef danke, eine Umsetzung auf Groß/Klein wäre noch super
-
Hi zusammen,
ich habe mir ein eigenes schlankes Skript gebaut, weil ich Alexa einfach nur als reines Spracheingabe-Mikrofon für meine Bring!-Liste nutzen will. Nach intensiven Tests im Alltag wurde der Code noch einmal überarbeitet und optimiert.
UPDATE (02.07.2026):
Was ist neu?
-
🚀 Multi-Item-Support (Großeinkäufe):
Sagt ihr z. B. "Alexa, füge Äpfel und Bananen hinzu", zerschneidet das Skript den Satz ab sofort automatisch bei jedem "und". Es schickt getrennte Artikel an Bring! und löscht auch alle einzeln bei Alexa. -
🧮 Intelligenter Mengen-Addierer:
Wenn ihr schon "2 Flaschen Bier" auf der Liste habt und später "Schreibe zwei Flaschen Bier auf die Liste" sagt, liest das Skript die aktuelle Bring-Liste aus und rechnet zusammen ➔ Es landen "Bier, 4 Flaschen" in der App! -
📦 Riesiges Verpackungs-Wörterbuch:
Der Filter erkennt jetzt auch unzählige Haushalts-Einheiten wie Packungen, Dosen, Gläser, Rollen, Tuben, Kisten und Kartons. -
⏱️ Anti-Verschluck-Garantie (Staggering):
Werden mehrere Artikel gleichzeitig angesagt, schickt das Skript diese nun mit 1,5 Sekunden Abstand an den Adapter. So geht garantiert nichts mehr verloren. -
🛠️ Feinschliff & Bugfixes:
Das Skript reagiert nun auch auf wiederholte, identische Sätze (change: "any"), erkennt Trigger-Wörter wie "hinzu" und löscht hängende Cloud-Warteschleifen beim Skript-Neustart sauber auf.
Prinzip:
-
Erkennen:
Ihr sagt: "Alexa, pack drei Bananen auf die Einkaufsliste". Das Skript reagiert auf den neuen Text in eurem Alexa-Verlauf (alexa2.0.History.summary). -
Eintragen:
Das Skript schnappt sich den Artikel, formatiert ihn und schickt ihn sofort an euren Bring!-Adapter. -
Löschen (Hier kommt die Alexa-Liste ins Spiel):
Da Alexa den Artikel parallel natürlich trotzdem auf ihre eigene Liste setzt, wartet das Skript ein paar Sekunden, bis die Amazon-Cloud synchronisiert hat. Dann durchsucht es das JSON eurer echten Alexa-Einkaufsliste (alexa2.0.Lists.SHOP.json), sucht dort nach den "Bananen", schnappt sich die von Amazon vergebene Artikel-ID und löscht den Eintrag direkt von der Alexa-Liste. -
Ergebnis:
Das Produkt landet perfekt in eurer Bring!-App und eure Alexa-Liste wird im Hintergrund automatisch bereinigt.
Funktionen:
-
Mathematischer Zahlen-Gesamt-Parser:
Macht aus gesprochenen Wörtern echte Zahlen. Die Logik basiert auf der genialen Grundidee des Users Heimweh, wurde von uns aber komplett neu aufgebaut, damit nun auch komplexe, zusammengesetzte Zahlenketten (z. B. "zwei hundert fünfzig gramm butter" ➔ 250) im selben Durchlauf fehlerfrei aufaddiert und zusammengefasst werden! -
Bruchzahlen-Erkennung:
Unterstützt jetzt auch native Bruchteile wie "ein halbes Kilo" (➔ 0.5Kg) oder "ein viertel Pfund". -
Automatische Groß-/Kleinschreibung:
Der Produktname in Bring! wird mit einem großen Anfangsbuchstaben übernommen (z. B. "frische milch" ➔ "Frische milch"). -
Saubere Mengentrennung:
Zahlen und Einheiten am Anfang werden erkannt und so an den Bring!-Adapter übergeben, dass sie in der Bring!-App perfekt getrennt im Feld specification (Menge) und name (Produkt) landen. Kein Textmatsch! -
Cloud-Warteschleife (Dynamic Retry):
Weil die Amazon-Cloud manchmal pennt und ein paar Sekunden braucht, um das JSON im ioBroker zu aktualisieren, prüft mein Skript bis zu 5-mal alle 3 Sekunden nach, ob der Artikel da ist. Erst wenn er im JSON auftaucht, wird er per .#delete-Befehl weggebeamt. -
Error-Handling & Logging:
Jede Texttransformation und Regex-Filterung ist einzeln per try-catch abgesichert. Sollte die Spracherkennung von Alexa mal totalen Kauderwelsch liefern, stürzt das Skript nicht mehr ab, sondern loggt den Fehler sauber und schickt den Artikel im Notfall-Fallback einfach als Rohtext an Bring!. -
Schont die Ressourcen:
Kein permanentes JSON-Dauer-Polling im Hintergrund. Das Skript springt nur an, wenn ich auch wirklich was ansage.
Voraussetzungen:
JavaScript-Adapter, alexa2-Adapter (Listenfunktion aktiv), bring-Adapter.Code (Einfach als neues JavaScript (JS) anlegen, eure eigene Bring-ID oben eintragen und fertig!):
/** * Alexa Voice-Input to Bring! List Pushing Engine * * Erkennt Sprachbefehle aus der Alexa-History, wandelt Zahlwörter um, * trennt Mengen sauber ab (z.B. "3 Erdbeeren" -> "Erdbeeren, 3" für Bring!), * teilt Aufzählungen ("und", ",") in einzelne Artikel auf, * addiert Mengen bestehender Artikel intelligent zusammen, * formatiert den Artikel und löscht ihn anschließend sofort von Alexa. */ // --- KONFIGURATION --- // ID des Datenpunkts für die Alexa History Zusammenfassung und Antworttext const alexaHistorySummaryId = 'alexa2.0.History.summary'; const alexaHistoryAnswerId = 'alexa2.0.History.answerText'; // Basis-ID deiner Bring! Adapter-Instanz (HIER DEINE EIGENE ID EINTRAGEN) const bringBaseId = 'bring.0.deine-bring-liste-id-hier'; const bringAddItemStateId = bringBaseId + '.saveItem'; // Pfad zur Alexa-Einkaufsliste (Basis und JSON) const alexa2BaseId = 'alexa2.0.Lists.SHOP'; const alexaShoppingListJsonId = `${alexa2BaseId}.json`; // Schalter für Debug-Ausgaben im ioBroker-Log const printDebug = true; // Array für laufende Timeouts (verhindert Fehler bei Skript-Neustart) let activeTimeouts = []; // --- HILFSFUNKTIONEN --- function debug(msg) { if (printDebug) { log('[Alexa->Bring Push] ' + msg); } } // Schneidet führende Alexa-Befehlswörter ab function stripPrefixes(text) { if (!text) return ''; const prefixesToRemove = [ /^(?:hey\s+)?alexa,\s*/i, /^(?:hey\s+)?alexa\s+/i, /^füge\s+/i, /^setz\s+/i, /^setze\s+/i, /^packe\s+/i, /^pack\s+/i, /^schreib\s+/i, /^schreibe\s+/i, /^notiere\s+/i ]; let result = String(text).trim(); let changed = true; while (changed) { let startLen = result.length; for (const prefixRegex of prefixesToRemove) { result = result.replace(prefixRegex, '').trim(); } changed = result.length !== startLen; } return result; } // Prüft alte und neue Mengen und addiert sie, falls die Einheiten passen function mergeQuantities(oldSpec, newSpec) { if (!oldSpec) return newSpec; if (!newSpec) return oldSpec; const regex = /^(\d+(?:[.,]\d+)?)\s*(.*)$/i; const matchOld = String(oldSpec).match(regex); const matchNew = String(newSpec).match(regex); if (matchOld && matchNew) { let numOld = parseFloat(matchOld[1].replace(',', '.')); let numNew = parseFloat(matchNew[1].replace(',', '.')); let unitOld = matchOld[2].trim().toLowerCase(); let unitNew = matchNew[2].trim().toLowerCase(); // Wenn die Einheiten gleich sind (oder eine fehlt), wird addiert if (unitOld === unitNew || unitOld === '' || unitNew === '') { let total = numOld + numNew; let unitToUse = matchNew[2].trim() || matchOld[2].trim(); return unitToUse ? `${total} ${unitToUse}` : `${total}`; } else { // Wenn Einheiten unterschiedlich sind (z.B. "500 g" und "1 Packung"), // hängen wir sie einfach mit einem "+" zusammen return `${oldSpec} + ${newSpec}`; } } return newSpec; // Fallback, falls keine Zahlen gefunden wurden } // Zuverlässiger Parser für deutsche Zahlwörter function wordsToNumbersSmart(text) { if (!text) return ''; try { let t = String(text).toLowerCase().replace(/\s+/g, ' '); const ones = { "null": 0, "eins": 1, "eine": 1, "einen": 1, "ein": 1, "milli": 1, "zwei": 2, "drei": 3, "vier": 4, "fünf": 5, "sechs": 6, "sieben": 7, "acht": 8, "neun": 9, "zehn": 10, "elf": 11, "zwölf": 12, "dreizehn": 13, "vierzehn": 14, "fünfzehn": 15, "sechzehn": 16, "siebzehn": 17, "achtzehn": 18, "neunzehn": 19 }; const tens = { "zwanzig": 20, "dreißig": 30, "dreissig": 30, "vierzig": 40, "fünfzig": 50, "sechzig": 60, "siebzig": 70, "achtzig": 80, "neunzig": 90 }; const multipliers = { "hundert": 100, "tausend": 1000 }; const skipWords = ["und"]; const words = t.split(/\s+/).filter(Boolean); const out = []; let currentNumber = 0; let hasNumber = false; for (let i = 0; i < words.length; i++) { const w = words[i]; if (ones[w] !== undefined) { currentNumber += ones[w]; hasNumber = true; } else if (tens[w] !== undefined) { currentNumber += tens[w]; hasNumber = true; } else if (multipliers[w] !== undefined) { if (currentNumber === 0) currentNumber = 1; currentNumber *= multipliers[w]; hasNumber = true; } else if (!isNaN(Number(w))) { if (hasNumber) { out.push(String(currentNumber)); currentNumber = 0; hasNumber = false; } out.push(w); } else if (skipWords.includes(w) && hasNumber) { continue; } else { if (hasNumber) { out.push(String(currentNumber)); currentNumber = 0; hasNumber = false; } if (out.length === 0 || !isNaN(Number(out[out.length - 1]))) { out.push(w.charAt(0).toUpperCase() + w.slice(1)); } else { out.push(w); } } } if (hasNumber) { out.push(String(currentNumber)); } let resultString = out.join(" "); // Brüche umwandeln resultString = resultString.replace(/\b(1\s+)?halbe?s?\b/gi, '0.5'); resultString = resultString.replace(/\b(1\s+)?viertel\b/gi, '0.25'); return resultString; } catch (err) { log(`Fehler in wordsToNumbersSmart bei Text "${text}": ${err.message}`, 'warn'); return String(text); } } // Bereinigt den Text, trennt Mengen ab und sorgt für korrekte Groß-/Kleinschreibung function ListCleaner(Eintrag = '') { if (!Eintrag) return { formatted: '', product: '', quantity: '' }; try { let textWithNumbers = wordsToNumbersSmart(Eintrag); if (!textWithNumbers || typeof textWithNumbers !== 'string') { textWithNumbers = String(Eintrag); } textWithNumbers = textWithNumbers.trim(); const quantityRegex = /^(\d+(?:[.,]\d+)?)(?:\s*(mal|x|stck|stück|l|ltr|liter|kg|kilo|g|gramm|pfund|flasche|flaschen|packung|packungen|päckchen|dose|dosen|becher|glas|gläser|rolle|rollen|tube|tuben|tafel|tafeln|kiste|kisten|karton|kartons))?\s+(.+)$/i; const match = textWithNumbers.match(quantityRegex); if (match && match[1] && match[3]) { let quantity = String(match[1]).trim(); let unit = match[2] ? String(match[2]).trim().toLowerCase() : ''; let product = String(match[3]).trim(); if (product.length > 0) { product = product.charAt(0).toUpperCase() + product.slice(1); if (['kilo', 'kg'].includes(unit)) { unit = 'kg'; } else if (['gramm', 'g'].includes(unit)) { unit = 'g'; } else if (['liter', 'l', 'ltr'].includes(unit)) { unit = 'l'; } else if (['stck', 'stück'].includes(unit)) { unit = 'Stück'; } else if (['mal', 'x'].includes(unit)) { unit = ''; } else if (unit) { unit = unit.charAt(0).toUpperCase() + unit.slice(1); } let finalQuantity = unit ? `${quantity} ${unit}` : quantity; debug(`Menge erkannt: "${finalQuantity}", Produkt: "${product}" -> Formatiere für Bring als "${product}, ${finalQuantity}"`); return { formatted: `${product}, ${finalQuantity}`, product: product, quantity: finalQuantity }; } } let normProduct = textWithNumbers.charAt(0).toUpperCase() + textWithNumbers.slice(1); return { formatted: normProduct, product: normProduct, quantity: '' }; } catch (err) { log(`Fehler in ListCleaner bei Eintrag "${Eintrag}": ${err.message}`, 'error'); let fallbackProduct = String(Eintrag).charAt(0).toUpperCase() + String(Eintrag).slice(1); return { formatted: fallbackProduct, product: fallbackProduct, quantity: '' }; } } // Intelligente Löschfunktion mit Cloud-Warteschleife und Multi-Match-Logik function removeItemFromAlexaList(itemName, extractedProduct, attempt = 1) { const maxAttempts = 5; const retryDelay = 3000; let cleanedAlexaName = stripPrefixes(itemName); debug(`Versuche, "${cleanedAlexaName}" (Produkt-Match: "${extractedProduct}") von der Alexa-Liste zu entfernen (Versuch ${attempt}/${maxAttempts}).`); try { const listState = getState(alexaShoppingListJsonId); if (!listState || !listState.val) { debug("Alexa Einkaufslisten-JSON ist leer. Warte auf Adapter-Update..."); if (attempt < maxAttempts) { let timer = setTimeout(() => removeItemFromAlexaList(itemName, extractedProduct, attempt + 1), retryDelay); activeTimeouts.push(timer); } return; } const shoppingList = JSON.parse(listState.val); let itemIdToDelete = null; const search1 = cleanedAlexaName.toLowerCase(); const search2 = extractedProduct.toLowerCase(); for (const item of shoppingList) { if (item && item.value) { const valLower = item.value.toLowerCase(); if (valLower === search1 || valLower === search2 || search1.includes(valLower) || valLower.includes(search2)) { itemIdToDelete = item.id; cleanedAlexaName = item.value; break; } } } if (itemIdToDelete) { const deleteStateId = `${alexa2BaseId}.items.${itemIdToDelete}.#delete`; debug(`Artikel-ID gefunden. Sende Löschbefehl an: ${deleteStateId}`); setState(deleteStateId, true); debug(`Löschbefehl für "${cleanedAlexaName}" erfolgreich an Amazon übermittelt.`); } else { if (attempt < maxAttempts) { debug(`Artikel "${cleanedAlexaName}" / "${extractedProduct}" noch nicht im JSON. Cloud synchronisiert noch... Erneuter Versuch in 3 Sek.`); let timer = setTimeout(() => removeItemFromAlexaList(itemName, extractedProduct, attempt + 1), retryDelay); activeTimeouts.push(timer); } else { debug(`Artikel wurde nach ${maxAttempts} Versuchen nicht im JSON gefunden.`); } } } catch (e) { log(`Fehler beim Entfernen des Artikels von der Alexa-Liste: ${e}`, 'error'); } } // --- HAUPTLOGIK --- on({ id: alexaHistorySummaryId, change: "any", ack: true }, function (obj) { try { if (!obj.state || !obj.state.val || typeof obj.state.val !== 'string') return; const summaryTextFull = obj.state.val; const summaryText = summaryTextFull.toLowerCase(); const alexaAnswer = getState(alexaHistoryAnswerId)?.val || 'Keine Antwort aufgezeichnet'; debug(`Neuer History Summary Eintrag: "${summaryTextFull}" | Alexa Antwort: "${alexaAnswer}"`); // Trigger-Phrasen inklusive "hinzu" aus dem Forum const triggerPhrases = [' auf die einkaufsliste', ' zur einkaufsliste', ' hinzu', ' hinzufügen']; let itemFound = false; let itemNameRaw = ''; for (const phrase of triggerPhrases) { const index = summaryText.lastIndexOf(phrase); if (index !== -1 && index > 0) { itemNameRaw = summaryTextFull.substring(0, index).trim(); itemNameRaw = stripPrefixes(itemNameRaw); itemFound = true; break; } } if (itemFound && itemNameRaw) { // 1. Text bei Komma oder " und " aufteilen für Multi-Item-Support const itemsToProcess = itemNameRaw.split(/,\s*|\s+und\s+/i).filter(Boolean); debug(`Multi-Item-Erkennung: Satz wurde in ${itemsToProcess.length} Artikel geteilt.`); // 2. Jeden Artikel einzeln verarbeiten itemsToProcess.forEach((singleItemRaw, index) => { const cleanResult = ListCleaner(singleItemRaw); const cleanedItemName = cleanResult.formatted; const originalItemName = singleItemRaw.trim(); if (cleanedItemName) { // --- NEU: Bring! JSON prüfen und Mengen addieren --- let finalItemNameToBring = cleanedItemName; try { const bringContentState = getState(bringBaseId + '.content'); if (bringContentState && bringContentState.val) { let bringList = typeof bringContentState.val === 'string' ? JSON.parse(bringContentState.val) : bringContentState.val; // Sucht, ob das Produkt schon auf der Bring!-Liste steht let existingItem = bringList.find(i => i.name.toLowerCase() === cleanResult.product.toLowerCase()); if (existingItem && existingItem.specification) { let mergedSpec = mergeQuantities(existingItem.specification, cleanResult.quantity); finalItemNameToBring = `${cleanResult.product}, ${mergedSpec}`; debug(`Artikel "${cleanResult.product}" existiert bereits. Addiere Menge: "${existingItem.specification}" + "${cleanResult.quantity}" -> "${mergedSpec}"`); } } } catch (e) { debug(`Fehler beim Auslesen der bestehenden Bring-Liste: ${e.message}`); } // --------------------------------------------------- // 3. Gestaffelte Ausführung (1.5 Sekunden Abstand pro Artikel), // damit der Bring! Adapter sich nicht verschluckt. let processingDelay = index * 1500; let processTimer = setTimeout(() => { debug(`[Artikel ${index + 1}/${itemsToProcess.length}] Roh: "${originalItemName}" -> Bring!: "${finalItemNameToBring}".`); // Schritt 1: Zu Bring! hinzufügen (mit eventuell addierter Menge) setState(bringAddItemStateId, finalItemNameToBring, false); // Schritt 2: Verzögertes Löschen auf Alexa-Seite triggern let deleteTimer = setTimeout(() => { removeItemFromAlexaList(originalItemName, cleanResult.product); }, 4000); activeTimeouts.push(deleteTimer); }, processingDelay); activeTimeouts.push(processTimer); } else { debug(`Artikel "${originalItemName}" ist nach Bereinigung leer. Ignoriere.`); } }); } } catch (globalErr) { log(`Unerwarteter globaler Fehler in der Hauptschleife: ${globalErr.message}`, 'error'); } }); // --- CLEANUP BEIM BEENDEN --- onStop(function () { activeTimeouts.forEach(timer => clearTimeout(timer)); activeTimeouts = []; debug("Skript gestoppt. Alle laufenden Timeouts wurden sicher beendet."); }, 10); // --- SKRIPTSTART --- debug("Skript gestartet. Multi-Item-Parser, Mengen-Addierer und erweiterte Triggerwörter aktiv."); -
-
Super, vielen Dank! Klappt super bei mir.
Aber mir ist aufgefallen, dass es nur funktioniert wenn ich sage:
"packe 3 Äpfel auf die Eingaufsliste". Wenn ich sage: "füge 3 Äpel hinzu" passiert leider nichts. Alexa packt sie auf die internen Einkaufsliste aber das Skript reagiert nicht. Gibt es eine Möglichgeit auch das noch abzugreifen?
Habe es gefunden.
Zeile 252 sieht bei mir jetzt so aus:const triggerPhrases = [' auf die einkaufsliste', ' zur einkaufsliste',' hinzu',' hinzufügen'];Werde es weiter testen.
-
Super, vielen Dank! Klappt super bei mir.
Aber mir ist aufgefallen, dass es nur funktioniert wenn ich sage:
"packe 3 Äpfel auf die Eingaufsliste". Wenn ich sage: "füge 3 Äpel hinzu" passiert leider nichts. Alexa packt sie auf die internen Einkaufsliste aber das Skript reagiert nicht. Gibt es eine Möglichgeit auch das noch abzugreifen?
Habe es gefunden.
Zeile 252 sieht bei mir jetzt so aus:const triggerPhrases = [' auf die einkaufsliste', ' zur einkaufsliste',' hinzu',' hinzufügen'];Werde es weiter testen.
Hey! Du scheinst an dieser Unterhaltung interessiert zu sein, hast aber noch kein Konto.
Hast du es satt, bei jedem Besuch durch die gleichen Beiträge zu scrollen? Wenn du dich für ein Konto anmeldest, kommst du immer genau dorthin zurück, wo du zuvor warst, und kannst dich über neue Antworten benachrichtigen lassen (entweder per E-Mail oder Push-Benachrichtigung). Du kannst auch Lesezeichen speichern und Beiträge positiv bewerten, um anderen Community-Mitgliedern deine Wertschätzung zu zeigen.
Mit deinem Input könnte dieser Beitrag noch besser werden 💗
Registrieren Anmelden