habe das Skript um "excludeWords" erweitert. Bis jetzt klappt es.
/**
* 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!),
* 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.7066fb7c-0b17-4f30-b988-30182e89b59a';
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}"`);
// Wörter, bei denen kein Eintrag zu Bring! erfolgen soll
const excludeWords = ['playlist'];
// 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) {
const isExcluded = excludeWords.some(word =>
itemNameRaw.toLowerCase().includes(word.toLowerCase())
);
if (isExcluded) {
debug(`Artikel "${itemNameRaw}" enthält ein Ausschlusswort und wird ignoriert.`);
return;
}
// 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.");