Sync Alexa Shopping List with Home Assistant โ€“ REVISITED

Sync Alexa Shopping List with Home Assistant – REVISITED

This is an updated version of my earlier Alexa shopping list method for Home Assistant. The basic idea is still the same: Node-RED fetches the Alexa shopping list, optionally sends it through OpenAI to sort it by supermarket aisles, saves the result into a local text file, and Home Assistant displays that file on a dashboard card. What changed in this version is the usability: the list is now visually separated into categories or aisles, items can be checked on and off directly from the dashboard, duplicate Alexa items are cleaned up before the list is sent to OpenAI, and the refresh button now shows a loading spinner while the list is being rebuilt.

Just as important, the Home Assistant side is now clearer. The button, clickable item text, loading state, and shopping list data sensor are exposed from Node-RED Companion. Home Assistant also needs the AnyList integration, the shell command writer, the sync automation, and the dashboard card. In my setup, the card and automation read sensor.node_red_buttons_shopping_list_data and its raw_list attribute, while todo.alexa_shopping_list_3 represents the AnyList/Alexa list.

Updated shopping list card grouped by supermarket category with separators between aisles.

In the screenshot above, notice the vertical separators between groups of items. OpenAI still assigns each product to one of ten supermarket categories, but instead of coloring each item differently, the card now uses the categories to create subtle aisle breaks. In day-to-day use, I find this much easier on the eye for my kitchen Home Assistant dashboard.

Shopping list refresh button showing a loading spinner while the list is updating.

The refresh button now turns into a spinning animated loader while the flow is running. That matters because the OpenAI step is not instantaneous, and without visible feedback it is hard to know whether the button press actually did anything. In this version, Node-RED exposes a switch entity to Home Assistant and the card uses that switch to decide whether to show the normal refresh icon or an activity indicator.

Shopping list item checked off directly from the Home Assistant dashboard.

Items can now also be checked on and off directly from the Home Assistant dashboard. Tapping an unchecked item marks it as completed. Tapping a completed item removes the checkmark. Internally, the current card writes a temporary text value back into a Node-RED text entity, Node-RED updates the Alexa list item itself, and it also updates the local file by adding or removing a leading ! in front of the product name. The card then uses that ! marker to draw the strikethrough.

What Changed From My Previous Version

  • Category separators instead of category colors or simple sorting . OpenAI still sorts by aisles, but the card now displays clean separators between category groups instead of using a different background color per category.
  • Direct check on/off from the dashboard. The list is now interactive. You can tap an item to mark it complete and tap it again to uncheck it.
  • A visible loading state. While Node-RED is rebuilding the file, the refresh button turns into a spinner.
  • No manually created Home Assistant helpers. In the previous approach, helper entities in Home Assistant were part of the flow. In this version, the extra entities are all exposed from Node-RED Companion instead.
  • Duplicate handling for Alexa list items. Alexa shopping lists can contain the same item more than once. Before sending the list to OpenAI, this version deduplicates intelligently. If all copies are completed, it keeps one. If both completed and incomplete versions exist, it keeps only one incomplete version. If multiple incomplete copies exist, it keeps one and removes the rest.

How This Version Works

  1. Node-RED fetches the Alexa shopping list. I still use the Alexa Remote node for this because there is no direct Home Assistant equivalent for Alexa shopping lists.
  2. Node-RED deduplicates the list. This happens before categorization, so repeated Alexa items do not make the dashboard messy or get sent through the AI path unnecessarily.
  3. Node-RED checks the category memory first. The flow looks in shoppingCategoryDb for products it has already categorized. Known products reuse the stored aisle/category immediately.
  4. Only unknown products are sent to the LLM. OpenAI is used for new items that are not yet in the category database. The returned categories are then saved back into shoppingCategoryDb, so the same products can skip the LLM next time.
  5. Node-RED writes the categorized text file and publishes the same data to Home Assistant. The file is saved as /config/www/shopping_list.txt, and the Node-RED Companion sensor sensor.node_red_buttons_shopping_list_data exposes the current text in its raw_list attribute.
  6. Home Assistant syncs the file-side view with AnyList. The automation watches the Node-RED sensor’s raw_list attribute and the AnyList todo entity’s items_signature attribute. Whichever side changed becomes the source, and the automation only writes differences.
  7. The dashboard card renders the Node-RED sensor data. It reads raw_list, shows the loading state from Node-RED Companion, and sends item taps back into the Node-RED flow.

Final Setup Checklist

  • Install moryoav/ha-anylist through HACS as a custom Integration repository, then add the AnyList integration in Home Assistant.
  • Install Node-RED Companion and the required Node-RED nodes, including the Alexa Remote node and the Home Assistant websocket nodes.
  • Install and configure an OpenAI node if you want automatic category discovery for products that are not yet in shoppingCategoryDb.
  • Enable Node-RED file-backed context storage so shoppingCategoryDb survives restarts.
  • Add the Home Assistant shell_command and place write_shopping_list_file.sh under /config/shell.
  • Restart Home Assistant and Node-RED after changing their configuration files.
  • Confirm your entity IDs before saving the automation, especially todo.alexa_shopping_list_3, sensor.node_red_buttons_shopping_list_data, and button.node_red_buttons_sync_shopping_list_text_with_alexa.

Install the New AnyList Integration

One important change in this version is the AnyList side of the setup. The original AnyList integration approach I tried was too fragile for my taste. It relied on an external AnyList Python package, and that package relied on a Rust extension. When that dependency chain did not line up cleanly with the Home Assistant environment, installs and updates became much more cumbersome than I wanted for something as basic as a shopping list.

For this version, I use moryoav/ha-anylist. It is a Home Assistant custom integration for AnyList that exposes shopping lists as Home Assistant todo entities. That todo entity is what the automation below uses when it reads and updates todo.alexa_shopping_list_3.

Install it through HACS as a custom repository:

  1. Open HACS in Home Assistant.
  2. Open the HACS menu and choose Custom repositories.
  3. Add https://github.com/moryoav/ha-anylist as an Integration repository.
  4. Search for AnyList in HACS and install it.
  5. Restart Home Assistant.
  6. Go to Settings โ†’ Devices & Services โ†’ Add Integration, search for AnyList, and sign in with your AnyList account.

After setup, check the entity name Home Assistant created for your shopping list. Mine is todo.alexa_shopping_list_3, but yours may be different. If it is, update the automation before saving it. The automation also calls anylist.refresh, so this integration needs to be installed before the automation will run correctly.

A Quick Note About Home Assistant Entities in This Version

The main Home Assistant entity used by both the card and the automation is the Node-RED Companion sensor sensor.node_red_buttons_shopping_list_data. The flow publishes the current categorized shopping list into that sensor’s raw_list attribute. The card reads that attribute directly, and the automation watches it for file-side changes.

If Home Assistant OS blocks access to the file path, add the following to configuration.yaml and restart Home Assistant:

homeassistant:
  allowlist_external_dirs:
    - /config/www

The physical file is still /config/www/shopping_list.txt, so keep /config/www allowed if your Home Assistant install requires it for local file access. The current card and automation snippets do not read from a separate file-sensor entity; they expect the Node-RED Companion sensor and its raw_list attribute.

Add the Shell Command Writer

The automation below needs one more Home Assistant-side helper: a shell command that safely rewrites /config/www/shopping_list.txt. Add this to your configuration.yaml:

shell_command:
  write_shopping_list_file_b64: 'bash /config/shell/write_shopping_list_file.sh "{{ content_b64 }}"'

Then create the folder /config/shell if it does not already exist, and place this script inside it as write_shopping_list_file.sh:

#!/bin/sh
set -eu

TMP_FILE="/config/www/shopping_list.txt.tmp"
TARGET_FILE="/config/www/shopping_list.txt"

printf '%s' "$1" | base64 -d > "$TMP_FILE"
mv "$TMP_FILE" "$TARGET_FILE"

The automation sends the desired file content as base64, and the script decodes it into a temporary file before moving it into place. That keeps the write simple and avoids quoting problems with commas, spaces, or special characters in shopping list items. After adding the shell command to configuration.yaml, restart Home Assistant so the new service is available.

The Missing Home Assistant Automation

The Node-RED flow handles the Alexa and file side of the setup, but this Home Assistant automation is the piece that keeps the file and the AnyList todo entity in sync. It watches both sides, normalizes the items, compares the lists, and only writes changes when something actually differs.

Replace the entity IDs with the ones from your own setup before saving it. In my setup, the important entities are sensor.node_red_buttons_shopping_list_data, todo.alexa_shopping_list_3, button.node_red_buttons_sync_shopping_list_text_with_alexa, and shell_command.write_shopping_list_file_b64.

alias: Shopping List Sync | Alexa File <-> AnyList
description: >
  Two-way sync between shopping_list.txt (via
  sensor.node_red_buttons_shopping_list_data.raw_list) and
  todo.alexa_shopping_list_3. Source of truth is whichever side triggered. File
  format is category-prefixed, e.g. 2-milk or 2-!milk. New AnyList-only items
  get temporary category 10 until Node-RED/Alexa re-categorizes them.
triggers:
  - alias: File sensor changed - Node-RED shopping_list.txt is the source
    entity_id:
      - sensor.node_red_buttons_shopping_list_data
    attribute: raw_list
    id: file_source
    trigger: state
    for:
      seconds: 5
  - alias: AnyList todo changed - Alexa/AnyList is the source
    entity_id:
      - todo.alexa_shopping_list_3
    id: anylist_source
    trigger: state
    attribute: items_signature
conditions: []
actions:
  - alias: Stop early if the triggering source is unavailable
    choose:
      - alias: Stop when the Node-RED file sensor has no usable raw_list
        conditions:
          - alias: >-
              File-side trigger, but the sensor or raw_list attribute is
              unavailable
            condition: template
            value_template: |
              {{ trigger.id == 'file_source'
                 and (
                   states('sensor.node_red_buttons_shopping_list_data') in ['unknown', 'unavailable']
                   or state_attr('sensor.node_red_buttons_shopping_list_data', 'raw_list') is none
                 ) }}
        sequence:
          - alias: Stop because the file-side source cannot be read
            stop: Shopping list Node-RED sensor/attribute is unavailable
      - alias: Stop when the AnyList todo entity is unavailable
        conditions:
          - alias: AnyList-side trigger, but the todo entity is unavailable
            condition: template
            value_template: |
              {{ trigger.id == 'anylist_source'
                 and states('todo.alexa_shopping_list_3') in ['unknown', 'unavailable'] }}
        sequence:
          - alias: Stop because the AnyList source cannot be read
            stop: AnyList entity is unavailable
  - alias: Refresh AnyList first when the file side triggered the sync
    choose:
      - alias: File is source, so refresh AnyList before comparing lists
        conditions:
          - alias: Continue only when the trigger came from the file sensor
            condition: template
            value_template: "{{ trigger.id == 'file_source' }}"
        sequence:
          - alias: Refresh AnyList data from the integration
            action: anylist.refresh
            data: {}
          - alias: Give AnyList refresh a few seconds to settle
            delay: "00:00:03"
  - alias: Read all active and completed AnyList items
    action: todo.get_items
    target:
      entity_id: todo.alexa_shopping_list_3
    data:
      status:
        - needs_action
        - completed
    response_variable: anylist_response
  - alias: Normalize file and AnyList items into comparable JSON lists
    variables:
      file_raw: |-
        {{ state_attr('sensor.node_red_buttons_shopping_list_data', 'raw_list')
           | default('', true) }}
      file_items_json: >-
        {% set raw = file_raw %} {% set ns = namespace(items=[]) %} {% if raw
        not in ['unknown', 'unavailable', 'none', ''] %}
          {% for part in raw.split(',') %}
            {% set entry = part | trim %}
            {% if entry %}
              {% set bits = entry.split('-', 1) %}
              {% if bits | count > 1 %}
                {% set category = bits[0] | trim %}
                {% set raw_name = bits[1] | trim %}
              {% else %}
                {% set category = '10' %}
                {% set raw_name = bits[0] | trim %}
              {% endif %}
              {% set completed = raw_name.startswith('!') %}
              {% set summary = raw_name[1:] if completed else raw_name %}
              {% set ns.items = ns.items + [{
                'summary': summary,
                'completed': completed,
                'category': category,
                'key': (summary | lower | trim)
              }] %}
            {% endif %}
          {% endfor %}
        {% endif %} {{ ns.items | to_json }}
      anylist_items_json: >-
        {% set todo_items = anylist_response.get('todo.alexa_shopping_list_3',
        {}).get('items', []) %} {% set ns = namespace(items=[]) %} {% for item
        in todo_items %}
          {% set summary = item.summary | trim %}
          {% if summary %}
            {% set ns.items = ns.items + [{
              'summary': summary,
              'completed': item.status == 'completed',
              'uid': item.uid,
              'status': item.status,
              'key': (summary | lower | trim)
            }] %}
          {% endif %}
        {% endfor %} {{ ns.items | to_json }}
      source_signature: >-
        {% set src = (file_items_json | from_json(default=[])) if trigger.id ==
        'file_source' else (anylist_items_json | from_json(default=[])) %} {%
        set ns = namespace(vals=[]) %} {% for item in src |
        sort(attribute='key') %}
          {% set ns.vals = ns.vals + [item.key ~ '|' ~ ('1' if item.completed else '0')] %}
        {% endfor %} {{ ns.vals | join(',') }}
      dest_signature: >-
        {% set dst = (anylist_items_json | from_json(default=[])) if trigger.id
        == 'file_source' else (file_items_json | from_json(default=[])) %} {%
        set ns = namespace(vals=[]) %} {% for item in dst |
        sort(attribute='key') %}
          {% set ns.vals = ns.vals + [item.key ~ '|' ~ ('1' if item.completed else '0')] %}
        {% endfor %} {{ ns.vals | join(',') }}
  - alias: Stop when both sides already contain the same items and completion states
    if:
      - alias: Compare source and destination signatures
        condition: template
        value_template: "{{ source_signature == dest_signature }}"
    then:
      - alias: Stop because no sync changes are needed
        stop: Lists already synchronized
  - alias: Apply changes in the correct direction based on the trigger source
    choose:
      - alias: File changed, so update AnyList to match shopping_list.txt
        conditions:
          - alias: Run file-to-AnyList sync only for file-side triggers
            condition: template
            value_template: "{{ trigger.id == 'file_source' }}"
        sequence:
          - alias: Calculate AnyList additions, removals, and completion updates
            variables:
              anylist_add_json: >-
                {% set src = file_items_json | from_json(default=[]) %} {% set
                dst_keys = (anylist_items_json | from_json(default=[])) |
                map(attribute='key') | list %} {% set ns = namespace(items=[])
                %} {% for item in src %}
                  {% if item.key not in dst_keys %}
                    {% set ns.items = ns.items + [item] %}
                  {% endif %}
                {% endfor %} {{ ns.items | to_json }}
              anylist_remove_json: >-
                {% set src_keys = (file_items_json | from_json(default=[])) |
                map(attribute='key') | list %} {% set dst = anylist_items_json |
                from_json(default=[]) %} {% set ns = namespace(items=[]) %} {%
                for item in dst %}
                  {% if item.key not in src_keys %}
                    {% set ns.items = ns.items + [item] %}
                  {% endif %}
                {% endfor %} {{ ns.items | to_json }}
              anylist_update_json: >-
                {% set src = file_items_json | from_json(default=[]) %} {% set
                dst = anylist_items_json | from_json(default=[]) %} {% set ns =
                namespace(items=[]) %} {% for s in src %}
                  {% set match = (dst | selectattr('key', 'eq', s.key) | list | first | default(none)) %}
                  {% if match is not none and match.completed != s.completed %}
                    {% set ns.items = ns.items + [{
                      'uid': match.uid,
                      'summary': s.summary,
                      'completed': s.completed
                    }] %}
                  {% endif %}
                {% endfor %} {{ ns.items | to_json }}
          - alias: Add items that exist in the file but not in AnyList
            repeat:
              for_each: "{{ anylist_add_json | from_json(default=[]) }}"
              sequence:
                - alias: Add missing item to AnyList
                  action: todo.add_item
                  target:
                    entity_id: todo.alexa_shopping_list_3
                  data:
                    item: "{{ repeat.item.summary }}"
                - alias: >-
                    Mark newly added AnyList item completed when the file item
                    starts with !
                  if:
                    - alias: Check whether the file item is completed
                      condition: template
                      value_template: "{{ repeat.item.completed }}"
                  then:
                    - alias: Set the newly added AnyList item to completed
                      action: todo.update_item
                      target:
                        entity_id: todo.alexa_shopping_list_3
                      data:
                        item: "{{ repeat.item.summary }}"
                        status: completed
          - alias: Remove AnyList items that no longer exist in the file
            repeat:
              for_each: "{{ anylist_remove_json | from_json(default=[]) }}"
              sequence:
                - alias: Remove extra item from AnyList by UID
                  action: todo.remove_item
                  target:
                    entity_id: todo.alexa_shopping_list_3
                  data:
                    item: "{{ repeat.item.uid }}"
          - alias: Update AnyList completion states to match the file
            repeat:
              for_each: "{{ anylist_update_json | from_json(default=[]) }}"
              sequence:
                - alias: Set AnyList item status to completed or needs_action
                  action: todo.update_item
                  target:
                    entity_id: todo.alexa_shopping_list_3
                  data:
                    item: "{{ repeat.item.uid }}"
                    status: >-
                      {{ 'completed' if repeat.item.completed else
                      'needs_action' }}
      - alias: >-
          AnyList changed, so rewrite shopping_list.txt and ask Node-RED to sync
          Alexa
        conditions:
          - alias: Run AnyList-to-file sync only for AnyList-side triggers
            condition: template
            value_template: "{{ trigger.id == 'anylist_source' }}"
        sequence:
          - alias: >-
              Build the desired file text from AnyList while preserving known
              categories
            variables:
              desired_file_text: >-
                {% set src = anylist_items_json | from_json(default=[]) %} {%
                set current_file = file_items_json | from_json(default=[]) %} {%
                set ns = namespace(entries=[]) %} {% for item in src %}
                  {% set existing = (current_file | selectattr('key', 'eq', item.key) | list | first | default(none)) %}
                  {% set category = existing.category if existing is not none else '10' %}
                  {% set entry = category ~ '-' ~ ('!' if item.completed else '') ~ item.summary %}
                  {% set ns.entries = ns.entries + [entry] %}
                {% endfor %} {{ ns.entries | join(',') }}
          - alias: Rewrite the file only if the generated file text changed
            if:
              - alias: Compare desired file text with the current raw file text
                condition: template
                value_template: "{{ desired_file_text != file_raw }}"
            then:
              - alias: Write updated shopping_list.txt through shell command
                action: shell_command.write_shopping_list_file_b64
                data:
                  content_b64: "{{ desired_file_text | base64_encode }}"
              - alias: Let the file write settle before triggering Node-RED
                delay: "00:00:01"
              - alias: Tell Node-RED to sync the rewritten file text with Alexa
                action: button.press
                target:
                  entity_id: button.node_red_buttons_sync_shopping_list_text_with_alexa
mode: queued
max: 10

Node-RED Setup Notes

  • Install node-red-contrib-alexa-remote2-applestrudel.
  • Install and configure an OpenAI node if you want automatic category discovery for unknown products. I used @inductiv/node-red-openai-api. Once an item is stored in shoppingCategoryDb, the flow can reuse that category and skip the LLM for that product.
  • Install Node-RED Companion from HACS and set up the Node-RED Home Assistant server connection.
  • Import the flow below and then edit the account, server, service host, entity config, and file path details so they match your own environment.
  • Do not assume my Alexa account IDs, list IDs, OpenAI service config, Home Assistant server config, or local IP addresses will match yours. They will not. You must open the relevant nodes and adapt them to your own system.

Node-RED Category Memory

There is another important feature in the Node-RED flow: it remembers which products belong to which aisles. The first time a product appears, the flow can ask the AI LLM to categorize it. After that, the result is saved, so the next time the same product appears, Node-RED can reuse the known aisle instead of sending it back to the LLM.

This means the system slowly builds its own product-to-category database. Over time, common household items should already be known, and the LLM only needs to help with new or unusual products. Eventually, for a stable shopping list, the AI step becomes less and less important.

The flow stores this mapping in a flow variable called shoppingCategoryDb. That variable must be stored on disk, not only in memory, otherwise the learned categories would disappear after a Node-RED restart.

To enable file-backed context storage, edit your Node-RED settings.js / config.js and add a contextStorage section like this:

contextStorage: {
    default: "memoryOnly",
    memoryOnly: { module: "memory" },
    file: { module: "localfilesystem" }
},

This keeps the default context in memory, but also adds a file storage option using Node-RED’s local filesystem storage. The flow uses that file store for shoppingCategoryDb, so the category database survives restarts and keeps growing as the system learns more products.

After changing the Node-RED configuration, restart Node-RED before importing or running the flow. If the file context store is missing, the flow will not be able to persist the learned aisle/category database properly.

The Updated TailwindCSS Template Card

This is the current card I am using. It reads the file sensor, shows a spinner while the list is refreshing, inserts separators between aisle groups, and lets you tap items to check or uncheck them. Paste it into a TailwindCSS Template Card in Home Assistant.

entity: ""
content: "<div class=\"flex flex-wrap gap-2 justify-center p-2\">\r\n  {% set raw_list = state_attr('sensor.node_red_buttons_shopping_list_data', 'raw_list') %}\r\n  {% set raw_list = raw_list if raw_list is string else '' %}\r\n  {% set raw_list = raw_list | trim %}\r\n  {% set invalid_states = ['unknown', 'unavailable', 'none', ''] %}\r\n  {% set is_updating = is_state('switch.node_red_buttons_updating_shopping_list', 'on') %}\r\n\r\n  <div class=\"bg-accent rounded-lg p-3\">\r\n    {% if is_updating %}\r\n      <div\r\n        class=\"flex items-center justify-center\"\r\n        style=\"width: 24px; height: 24px;\"\r\n      >\r\n        <div\r\n          style=\"\r\n            width: 18px;\r\n            height: 18px;\r\n            border: 3px solid rgba(255,255,255,0.35);\r\n            border-top-color: rgba(255,255,255,1);\r\n            border-radius: 50%;\r\n            animation: spin 0.8s linear infinite;\r\n          \"\r\n        ></div>\r\n      </div>\r\n    {% else %}\r\n      <div\r\n        onclick=\"hass.callService('button', 'press', {entity_id: 'button.update_shopping_list_nodered'});\"\r\n        class=\"hover:scale-105 transition-all cursor-pointer\"\r\n        style=\"font-size:14px;\"\r\n      >\r\n        ๐Ÿ›’๐Ÿ“‹๐Ÿ”ƒ\r\n      </div>\r\n    {% endif %}\r\n  </div>\r\n\r\n  {% if raw_list not in invalid_states %}\r\n    <div class=\"self-center px-1\" style=\"font-size:28px; line-height:1; opacity:0.35;\">|</div>\r\n  {% endif %}\r\n\r\n  {% if raw_list in invalid_states %}\r\n    <div class=\"bg-accent rounded-lg p-3\">\r\n      <span style=\"font-size:14px;\">Shopping list is empty</span>\r\n    </div>\r\n  {% else %}\r\n    {% set items = raw_list.split(',') %}\r\n    {% set first_group = namespace(done=false) %}\r\n\r\n    {% for cat in range(1, 11) %}\r\n      {% set has_items = namespace(value=false) %}\r\n\r\n      {% for i in range(0, items | count) %}\r\n        {% set item = items[i].strip() %}\r\n        {% if item.startswith(cat|string + '-') %}\r\n          {% set has_items.value = true %}\r\n        {% endif %}\r\n      {% endfor %}\r\n\r\n      {% if has_items.value %}\r\n        {% if first_group.done %}\r\n          <div class=\"self-center px-1\" style=\"font-size:28px; line-height:1; opacity:0.35;\">|</div>\r\n        {% else %}\r\n          {% set first_group.done = true %}\r\n        {% endif %}\r\n\r\n        {% for i in range(0, items | count) %}\r\n          {% set item = items[i].strip() %}\r\n\r\n          {% if item.startswith(cat|string + '-') %}\r\n            {% set item_name_raw = item.split('-', 1)[1] %}\r\n\r\n            {% if item_name_raw.startswith('!') %}\r\n              {% set item_name_display = item_name_raw[1:] %}\r\n              {% set is_source_checked = true %}\r\n              {% set click_value = '!' + item_name_display %}\r\n            {% else %}\r\n              {% set item_name_display = item_name_raw %}\r\n              {% set is_source_checked = false %}\r\n              {% set click_value = item_name_display %}\r\n            {% endif %}\r\n\r\n            {% if is_source_checked %}\r\n              {% set text_decoration = 'line-through' %}\r\n              {% set opacity = '0.65' %}\r\n            {% else %}\r\n              {% set text_decoration = 'none' %}\r\n              {% set opacity = '1' %}\r\n            {% endif %}\r\n\r\n            <div\r\n              class=\"bg-accent rounded-lg p-3 cursor-pointer hover:scale-105 transition-all\"\r\n              style=\"opacity: {{ opacity }};\"\r\n              onclick=\"hass.callService('text', 'set_value', {entity_id: 'text.node_red_buttons_shopping_list_item_removed', value: '{{ click_value }}'});\"\r\n            >\r\n              <span\r\n                id=\"item-{{ i }}\"\r\n                style=\"font-size:14px; text-decoration: {{ text_decoration }};\"\r\n              >\r\n                {{ item_name_display }}\r\n              </span>\r\n            </div>\r\n          {% endif %}\r\n        {% endfor %}\r\n      {% endif %}\r\n    {% endfor %}\r\n  {% endif %}\r\n\r\n  <style>\r\n    @keyframes spin {\r\n      to { transform: rotate(360deg); }\r\n    }\r\n  </style>\r\n</div>"
ignore_line_breaks: true
always_update: false
parse_jinja: true
code_editor: Ace
entities: []
bindings: []
actions: []
debounceChangePeriod: 100
plugins:
  daisyui:
    enabled: true
    url: https://cdn.jsdelivr.net/npm/daisyui@latest/dist/full.css
    theme: dark - dark
    overrideCardBackground: false
  tailwindElements:
    enabled: false
type: custom:tailwindcss-template-card


The Updated Node-RED Flow

Below is the current full flow. Import it into Node-RED, then go through the nodes and update the parts that are environment-specific. That includes your Alexa account node, your Alexa shopping list ID, your Home Assistant server config, your Node-RED Companion entity configs, your OpenAI API node, and your file paths if they differ from mine.

[{“id”:”85a5b4e1f8122fe2″,”type”:”tab”,”label”:”Shopping List Sync”,”disabled”:false,”info”:””,”env”:[]},{“id”:”2d026458d960f714″,”type”:”inject”,”z”:”85a5b4e1f8122fe2″,”name”:””,”props”:[{“p”:”payload”},{“p”:”topic”,”vt”:”str”}],”repeat”:””,”crontab”:””,”once”:true,”onceDelay”:”60″,”topic”:””,”payload”:””,”payloadType”:”date”,”x”:510,”y”:180,”wires”:[[“ae60e039ccbf0430”]]},{“id”:”517dfc064fb104c9″,”type”:”alexa-remote-event”,”z”:”85a5b4e1f8122fe2″,”name”:””,”account”:”5134235e3c4bb88d”,”event”:”ws-device-activity”,”x”:130,”y”:220,”wires”:[[“07c762a205447390″,”57bb9d0e60ff6dc6”]]},{“id”:”07c762a205447390″,”type”:”switch”,”z”:”85a5b4e1f8122fe2″,”name”:”Success?”,”property”:”payload.data.utteranceType”,”propertyType”:”msg”,”rules”:[{“t”:”eq”,”v”:”GENERAL”,”vt”:”str”}],”checkall”:”true”,”repair”:false,”outputs”:1,”x”:320,”y”:220,”wires”:[[“af51a9292af7c40c”]]},{“id”:”af51a9292af7c40c”,”type”:”switch”,”z”:”85a5b4e1f8122fe2″,”name”:”Shopping list?”,”property”:”payload.description.summary”,”propertyType”:”msg”,”rules”:[{“t”:”cont”,”v”:”shopping list”,”vt”:”str”}],”checkall”:”true”,”repair”:false,”outputs”:1,”x”:500,”y”:220,”wires”:[[“ae60e039ccbf0430″,”45d92cf5085241e7”]]},{“id”:”ae60e039ccbf0430″,”type”:”alexa-remote-list”,”z”:”85a5b4e1f8122fe2″,”name”:””,”account”:”5134235e3c4bb88d”,”config”:{“option”:”getListItems”,”value”:{“list”:{“type”:”str”,”value”:”YW16bjEuYWNjb3VudC5BRURXNTRVS0E3U0dNQk5YREdXMlVWUU1NQ1RBLVNIT1BQSU5HX0lURU0=”}}},”x”:700,”y”:220,”wires”:[[“ecaf0902308da924″,”9935a03644f95ab2”]]},{“id”:”ecaf0902308da924″,”type”:”debug”,”z”:”85a5b4e1f8122fe2″,”name”:”debug 5″,”active”:false,”tosidebar”:true,”console”:false,”tostatus”:false,”complete”:”payload”,”targetType”:”msg”,”statusVal”:””,”statusType”:”auto”,”x”:700,”y”:180,”wires”:[]},{“id”:”789854bf25089966″,”type”:”ha-button”,”z”:”85a5b4e1f8122fe2″,”name”:”Update Shopping List”,”version”:0,”debugenabled”:false,”outputs”:1,”entityConfig”:”493ff69ce7a88be3″,”outputProperties”:[{“property”:”payload”,”propertyType”:”msg”,”value”:””,”valueType”:”entityState”},{“property”:”topic”,”propertyType”:”msg”,”value”:””,”valueType”:”triggerId”},{“property”:”data”,”propertyType”:”msg”,”value”:””,”valueType”:”entity”}],”x”:140,”y”:280,”wires”:[[“0e21977de6004ca1″,”2f8353a31767b7d4″,”d67e733380a118c7”]]},{“id”:”9935a03644f95ab2″,”type”:”function”,”z”:”85a5b4e1f8122fe2″,”name”:”Prepare AI”,”func”:”const inputItems = Array.isArray(msg.payload) ? msg.payload : [];\n\n// Persistent category DB\nconst categoryDb = flow.get(\”shoppingCategoryDb\”, \”file\”) || {};\n\n// Normalize a shopping-list item name for dedupe/category matching\nfunction normalizeValue(value) {\n return String(value || \”\”).trim().toLowerCase();\n}\n\nfunction getCachedCategory(itemName) {\n const key = normalizeValue(itemName);\n const entry = categoryDb[key];\n\n if (!entry) return null;\n\n if (typeof entry === \”string\”) return entry;\n\n if (entry.category !== undefined && entry.category !== null) {\n return String(entry.category);\n }\n\n return null;\n}\n\n// Group items by normalized value, while preserving original order\nconst groups = new Map();\n\ninputItems.forEach((item, index) => {\n const key = normalizeValue(item.value);\n if (!key) return;\n\n if (!groups.has(key)) {\n groups.set(key, []);\n }\n\n groups.get(key).push({ item, index });\n});\n\nconst keptEntries = [];\nconst removedEntries = [];\n\n// Apply dedupe rules per item name\nfor (const [key, entries] of groups.entries()) {\n const completedEntries = entries.filter(e => !!e.item.completed);\n const notCompletedEntries = entries.filter(e => !e.item.completed);\n\n if (notCompletedEntries.length > 0) {\n keptEntries.push(notCompletedEntries[0]);\n\n completedEntries.forEach(e => {\n removedEntries.push({\n …e,\n reason: \”removed_completed_because_non_completed_exists\”\n });\n });\n\n notCompletedEntries.slice(1).forEach(e => {\n removedEntries.push({\n …e,\n reason: \”removed_duplicate_non_completed\”\n });\n });\n } else {\n keptEntries.push(completedEntries[0]);\n\n completedEntries.slice(1).forEach(e => {\n removedEntries.push({\n …e,\n reason: \”removed_duplicate_completed\”\n });\n });\n }\n}\n\n// Restore original ordering\nkeptEntries.sort((a, b) => a.index – b.index);\nremovedEntries.sort((a, b) => a.index – b.index);\n\nconst dedupedItems = keptEntries.map(e => e.item);\n\nconst knownItems = [];\nconst unknownItems = [];\n\ndedupedItems.forEach(item => {\n const category = getCachedCategory(item.value);\n\n if (category) {\n knownItems.push({\n item,\n category,\n key: normalizeValue(item.value)\n });\n } else {\n unknownItems.push({\n item,\n key: normalizeValue(item.value)\n });\n }\n});\n\nfunction buildCategorizedList(items) {\n return items.map(item => {\n const category = getCachedCategory(item.value);\n const prefix = item.completed ? \”!\” : \”\”;\n\n return `${category}-${prefix}${item.value}`;\n }).join(\”,\”);\n}\n\n// Shared metadata for later nodes\nconst baseMsg = {\n …msg,\n originalItems: inputItems,\n dedupedItems,\n removedItems: removedEntries.map(e => e.item),\n knownItems,\n unknownItems,\n categoryDb\n};\n\n// Output 1: only if OpenAI is needed\nlet openAiMsg = null;\n\n// Output 3: only if OpenAI can be skipped\nlet skipOpenAiMsg = null;\n\nif (unknownItems.length > 0) {\n const unknownValues = JSON.stringify(unknownItems.map(e => e.item.value));\n\n openAiMsg = {\n …baseMsg,\n payload: {\n model: \”gpt-5-mini\”,\n messages: [\n {\n role: \”user\”,\n content:\n ‘Here is a list of uncategorized shopping list items. The following 10 categories are defined to minimize overlap: 1) Fresh fruits and vegetables (all fresh fruits and vegetables). 2) Meat, fish, and seafood (all meats, fish, and seafood). 3) Dairy and eggs (milk, yogurt, cheese, butter, eggs). 4) Bakery and pastries (bread, pastries, cakes). 5) Savory groceries (pasta, rice, canned goods, sauces, spices, oils, condiments). 6) Sweet groceries (cookies, jams, chocolate, sugar, honey, sweetened cereals, desserts). 7) Beverages (water, juice, soda, coffee, tea, alcohol). 8) Hygiene and beauty products (shampoo, soap, toothpaste, makeup, skincare products). 9) Maintenance and cleaning products (laundry detergent, dish soap, cleaners, disinfectants, sponges). 10) Household and miscellaneous items (kitchen utensils, batteries, stationery, garbage bags, light bulbs). Return ONLY valid JSON. The JSON must be an object where each key is the exact item name and each value is the category number as a string. Do not add explanations. Do not rename items. Here is the list: ‘ + unknownValues\n }\n ]\n }\n };\n} else {\n skipOpenAiMsg = {\n …baseMsg,\n payload: buildCategorizedList(dedupedItems),\n skippedOpenAI: true\n };\n}\n\n// Send output 1 or 3 immediately\nnode.send([openAiMsg, null, skipOpenAiMsg]);\n\n// Output 2: removed duplicate items one by one\nif (removedEntries.length === 0) {\n node.done();\n return;\n}\n\nlet idx = 0;\n\nfunction sendNextRemoved() {\n if (idx >= removedEntries.length) {\n node.done();\n return;\n }\n\n const removed = removedEntries[idx++];\n\n node.send([null, {\n topic: msg.topic,\n payload: removed.item,\n dedupeReason: removed.reason,\n dedupeKey: normalizeValue(removed.item.value),\n removedIndex: removed.index\n }, null]);\n\n setTimeout(sendNextRemoved, 10);\n}\n\nsendNextRemoved();\nreturn;”,”outputs”:3,”timeout”:0,”noerr”:0,”initialize”:””,”finalize”:””,”libs”:[],”x”:890,”y”:220,”wires”:[[“87d1511581e1db30″,”d7f0238bb18508d9”],[“209e99df9289730b”],[“88aee2df697de297”]]},{“id”:”87d1511581e1db30″,”type”:”debug”,”z”:”85a5b4e1f8122fe2″,”name”:”debug 7″,”active”:false,”tosidebar”:true,”console”:false,”tostatus”:false,”complete”:”true”,”targetType”:”full”,”statusVal”:””,”statusType”:”auto”,”x”:880,”y”:180,”wires”:[]},{“id”:”d7f0238bb18508d9″,”type”:”OpenAI API”,”z”:”85a5b4e1f8122fe2″,”name”:””,”property”:”payload”,”propertyType”:”msg”,”service”:”e8f2c1a964b803e8″,”method”:”createChatCompletion”,”x”:1100,”y”:180,”wires”:[[“b498f1d4c245ad75″,”bd027e21bc9018e7”]]},{“id”:”b498f1d4c245ad75″,”type”:”debug”,”z”:”85a5b4e1f8122fe2″,”name”:”debug 8″,”active”:true,”tosidebar”:true,”console”:false,”tostatus”:false,”complete”:”true”,”targetType”:”full”,”statusVal”:””,”statusType”:”auto”,”x”:1080,”y”:100,”wires”:[]},{“id”:”57bb9d0e60ff6dc6″,”type”:”debug”,”z”:”85a5b4e1f8122fe2″,”name”:”debug 9″,”active”:false,”tosidebar”:true,”console”:false,”tostatus”:false,”complete”:”true”,”targetType”:”full”,”statusVal”:””,”statusType”:”auto”,”x”:320,”y”:180,”wires”:[]},{“id”:”0e21977de6004ca1″,”type”:”alexa-remote-init”,”z”:”85a5b4e1f8122fe2″,”name”:””,”account”:”5134235e3c4bb88d”,”option”:”initialise”,”x”:520,”y”:280,”wires”:[[“ae60e039ccbf0430”]]},{“id”:”88aee2df697de297″,”type”:”file”,”z”:”85a5b4e1f8122fe2″,”name”:”Save Shopping List”,”filename”:”/homeassistant/www/shopping_list.txt”,”filenameType”:”str”,”appendNewline”:true,”createDir”:true,”overwriteFile”:”true”,”encoding”:”utf8″,”x”:1530,”y”:220,”wires”:[[“f6616d08f55d7305″,”16f6c55b7d3d68f8”]]},{“id”:”f6616d08f55d7305″,”type”:”debug”,”z”:”85a5b4e1f8122fe2″,”name”:”debug 12″,”active”:true,”tosidebar”:true,”console”:false,”tostatus”:false,”complete”:”true”,”targetType”:”full”,”statusVal”:””,”statusType”:”auto”,”x”:1720,”y”:220,”wires”:[]},{“id”:”bd027e21bc9018e7″,”type”:”change”,”z”:”85a5b4e1f8122fe2″,”name”:””,”rules”:[{“t”:”set”,”p”:”payload”,”pt”:”msg”,”to”:”payload.choices[0].message.content”,”tot”:”msg”}],”action”:””,”property”:””,”from”:””,”to”:””,”reg”:false,”x”:1320,”y”:180,”wires”:[[“1943af27619fe565”]]},{“id”:”2f8353a31767b7d4″,”type”:”debug”,”z”:”85a5b4e1f8122fe2″,”name”:”debug 6″,”active”:true,”tosidebar”:true,”console”:false,”tostatus”:false,”complete”:”false”,”statusVal”:””,”statusType”:”auto”,”x”:320,”y”:320,”wires”:[]},{“id”:”415ebc836bee5319″,”type”:”ha-text”,”z”:”85a5b4e1f8122fe2″,”name”:”Shopping List Item Removed”,”version”:1,”debugenabled”:false,”inputs”:0,”outputs”:1,”entityConfig”:”cecb42b20c7a4cba”,”exposeAsEntityConfig”:””,”mode”:”listen”,”value”:”payload”,”valueType”:”msg”,”outputProperties”:[{“property”:”clickedValue”,”propertyType”:”msg”,”value”:””,”valueType”:”value”},{“property”:”previousValue”,”propertyType”:”msg”,”value”:””,”valueType”:”previousValue”}],”x”:160,”y”:420,”wires”:[[“45051e23d54eded4”]]},{“id”:”29a2df27a6419812″,”type”:”alexa-remote-list”,”z”:”85a5b4e1f8122fe2″,”name”:””,”account”:”5134235e3c4bb88d”,”config”:{“option”:”getListItems”,”value”:{“list”:{“type”:”str”,”value”:”YW16bjEuYWNjb3VudC5BRURXNTRVS0E3U0dNQk5YREdXMlVWUU1NQ1RBLVNIT1BQSU5HX0lURU0=”}}},”x”:900,”y”:420,”wires”:[[“12b40cafaa8dc829”]]},{“id”:”45051e23d54eded4″,”type”:”switch”,”z”:”85a5b4e1f8122fe2″,”name”:”not empty”,”property”:”clickedValue”,”propertyType”:”msg”,”rules”:[{“t”:”nempty”}],”checkall”:”true”,”repair”:false,”outputs”:1,”x”:360,”y”:420,”wires”:[[“ece0872e24436516″,”9bdee0ff07ec3b0a”]]},{“id”:”12b40cafaa8dc829″,”type”:”function”,”z”:”85a5b4e1f8122fe2″,”name”:”Find item”,”func”:”let raw = String(msg.clickedValue || \”\”).trim();\nif (!raw) return null;\n\n// Determine target action from clickedValue:\n// \”!white fish\” -> uncheck in file, completed = false\n// \”white fish\” -> check in file, completed = true\nconst wantsUnchecked = raw.startsWith(\”!\”);\nmsg.completed = !wantsUnchecked;\n\n// Normalize clicked name for matching\nif (wantsUnchecked) {\n raw = raw.slice(1).trim();\n}\n\n// Supports both formats:\n// \”3|white fish\” -> \”white fish\”\n// \”white fish\” -> \”white fish\”\nif (raw.includes(\”|\”)) {\n raw = raw.split(\”|\”).slice(1).join(\”|\”).trim();\n}\n\nmsg.clickedName = raw;\n\n// — Find matching Alexa item in msg.payload —\nconst clicked = raw.toLowerCase();\nconst items = Array.isArray(msg.payload) ? msg.payload : [];\n\nconst match = items.find(item =>\n String(item.value || \”\”).trim().toLowerCase() === clicked\n);\n\nif (!match) {\n node.warn(`No matching Alexa item found for \”${msg.clickedName}\”`);\n return null;\n}\n\nmsg.itemToRemove = {\n item: String(match.id),\n version: parseInt(match.version, 10),\n value: match.value\n};\n\n// — Update matching entry in msg.fileText —\n// If clickedValue started with \”!\” => remove leading \”!\” from file entry\n// Otherwise => add leading \”!\” to file entry\nlet fileText = String(msg.fileText || \”\”).trim();\n\nif (!fileText) {\n node.warn(\”msg.fileText is empty\”);\n return msg;\n}\n\n// shopping_list.txt format example:\n// 6-sugar,10-napkins,4-brioche bread,2-!white fish,9-dryer sheets,5-Brown lentils\nlet entries = fileText\n .split(\”,\”)\n .map(s => s.trim())\n .filter(Boolean);\n\nlet updatedInFile = false;\n\nentries = entries.map(entry => {\n if (updatedInFile) return entry;\n\n const dashIndex = entry.indexOf(\”-\”);\n if (dashIndex === -1) return entry;\n\n const categoryPart = entry.slice(0, dashIndex);\n const namePart = entry.slice(dashIndex + 1).trim();\n\n const normalizedName = namePart.startsWith(\”!\”)\n ? namePart.slice(1).trim()\n : namePart;\n\n if (normalizedName.toLowerCase() !== clicked) {\n return entry;\n }\n\n updatedInFile = true;\n\n let updatedName;\n if (wantsUnchecked) {\n // Remove \”!\” if present\n updatedName = normalizedName;\n } else {\n // Add \”!\” if not already present\n updatedName = \”!\” + normalizedName;\n }\n\n return `${categoryPart}-${updatedName}`;\n});\n\nif (!updatedInFile) {\n node.warn(`No matching file entry found for \”${msg.clickedName}\” in shopping_list.txt`);\n}\n\n// Save updated file contents back into msg.fileText\nmsg.fileText = entries.join(\”,\”);\n\n// Keep payload ready for file save node\nmsg.payload = msg.fileText;\n\nreturn msg;”,”outputs”:1,”timeout”:0,”noerr”:0,”initialize”:””,”finalize”:””,”libs”:[],”x”:1300,”y”:420,”wires”:[[“85308bbd924431cf”,”60c35c6c4507d69b”,”88aee2df697de297″]]},{“id”:”ece0872e24436516″,”type”:”file in”,”z”:”85a5b4e1f8122fe2″,”name”:”Read file”,”filename”:”/homeassistant/www/shopping_list.txt”,”filenameType”:”str”,”format”:”utf8″,”chunk”:false,”sendError”:false,”encoding”:”none”,”allProps”:false,”x”:520,”y”:420,”wires”:[[“b2659ef3121d2f5d”]]},{“id”:”b2659ef3121d2f5d”,”type”:”change”,”z”:”85a5b4e1f8122fe2″,”name”:”Save content”,”rules”:[{“t”:”set”,”p”:”fileText”,”pt”:”msg”,”to”:”payload”,”tot”:”msg”}],”action”:””,”property”:””,”from”:””,”to”:””,”reg”:false,”x”:690,”y”:420,”wires”:[[“29a2df27a6419812”]]},{“id”:”85308bbd924431cf”,”type”:”alexa-remote-list”,”z”:”85a5b4e1f8122fe2″,”name”:””,”account”:”5134235e3c4bb88d”,”config”:{“option”:”editItem”,”value”:{“list”:{“type”:”str”,”value”:”YW16bjEuYWNjb3VudC5BRURXNTRVS0E3U0dNQk5YREdXMlVWUU1NQ1RBLVNIT1BQSU5HX0lURU0=”},”item”:{“type”:”msg”,”value”:”itemToRemove.item”},”text”:{“type”:”msg”,”value”:”clickedName”},”completed”:{“type”:”msg”,”value”:”completed”},”version”:{“type”:”msg”,”value”:”itemToRemove.version”}}},”x”:1500,”y”:280,”wires”:[[]]},{“id”:”60c35c6c4507d69b”,”type”:”debug”,”z”:”85a5b4e1f8122fe2″,”name”:”debug 11″,”active”:true,”tosidebar”:true,”console”:false,”tostatus”:false,”complete”:”true”,”targetType”:”full”,”statusVal”:””,”statusType”:”auto”,”x”:1300,”y”:460,”wires”:[]},{“id”:”e69f0afec6f72637″,”type”:”ha-switch”,”z”:”85a5b4e1f8122fe2″,”name”:”Updating Shopping List”,”version”:0,”debugenabled”:false,”inputs”:1,”outputs”:2,”entityConfig”:”d9fef8e9012a8afb”,”enableInput”:true,”outputOnStateChange”:false,”outputProperties”:[{“property”:”outputType”,”propertyType”:”msg”,”value”:”state change”,”valueType”:”str”},{“property”:”payload”,”propertyType”:”msg”,”value”:”string”,”valueType”:”entityState”}],”x”:480,”y”:540,”wires”:[[],[]]},{“id”:”7473cf8c71a0f1bb”,”type”:”change”,”z”:”85a5b4e1f8122fe2″,”name”:”enable=true”,”rules”:[{“t”:”set”,”p”:”enable”,”pt”:”msg”,”to”:”true”,”tot”:”bool”}],”action”:””,”property”:””,”from”:””,”to”:””,”reg”:false,”x”:210,”y”:540,”wires”:[[“e69f0afec6f72637”]]},{“id”:”d11611e142a60fc2″,”type”:”link in”,”z”:”85a5b4e1f8122fe2″,”name”:”Shopping List Updating Starting”,”links”:[“d67e733380a118c7″,”45d92cf5085241e7″,”9bdee0ff07ec3b0a”],”x”:65,”y”:540,”wires”:[[“7473cf8c71a0f1bb”]]},{“id”:”4db46ee588d3759d”,”type”:”change”,”z”:”85a5b4e1f8122fe2″,”name”:”enable=false”,”rules”:[{“t”:”set”,”p”:”enable”,”pt”:”msg”,”to”:”false”,”tot”:”bool”}],”action”:””,”property”:””,”from”:””,”to”:””,”reg”:false,”x”:210,”y”:580,”wires”:[[“e69f0afec6f72637”]]},{“id”:”65d9522d17470f87″,”type”:”link in”,”z”:”85a5b4e1f8122fe2″,”name”:”Shopping List Updating Stopped”,”links”:[“9c077bfefa3eb3f3″],”x”:65,”y”:580,”wires”:[[“4db46ee588d3759d”]]},{“id”:”d67e733380a118c7″,”type”:”link out”,”z”:”85a5b4e1f8122fe2″,”name”:”Shopping List Updating Starting”,”mode”:”link”,”links”:[“d11611e142a60fc2″],”x”:275,”y”:360,”wires”:[]},{“id”:”45d92cf5085241e7″,”type”:”link out”,”z”:”85a5b4e1f8122fe2″,”name”:”Shopping List Updating Starting”,”mode”:”link”,”links”:[“d11611e142a60fc2″],”x”:655,”y”:280,”wires”:[]},{“id”:”9bdee0ff07ec3b0a”,”type”:”link out”,”z”:”85a5b4e1f8122fe2″,”name”:”Shopping List Updating Starting”,”mode”:”link”,”links”:[“d11611e142a60fc2″],”x”:475,”y”:360,”wires”:[]},{“id”:”9c077bfefa3eb3f3″,”type”:”link out”,”z”:”85a5b4e1f8122fe2″,”name”:”Shopping List Updating Stopped”,”mode”:”link”,”links”:[“65d9522d17470f87″],”x”:1905,”y”:280,”wires”:[]},{“id”:”209e99df9289730b”,”type”:”alexa-remote-list”,”z”:”85a5b4e1f8122fe2″,”name”:””,”account”:”5134235e3c4bb88d”,”config”:{“option”:”removeItem”,”value”:{“list”:{“type”:”str”,”value”:”YW16bjEuYWNjb3VudC5BRURXNTRVS0E3U0dNQk5YREdXMlVWUU1NQ1RBLVNIT1BQSU5HX0lURU0=”},”item”:{“type”:”msg”,”value”:”payload.id”},”version”:{“type”:”msg”,”value”:”payload.version”}}},”x”:1070,”y”:260,”wires”:[[]]},{“id”:”14fd7b57ebb183bd”,”type”:”ha-button”,”z”:”85a5b4e1f8122fe2″,”name”:”Sync shopping list text”,”version”:0,”debugenabled”:false,”outputs”:1,”entityConfig”:”783e578710bbc9ad”,”outputProperties”:[{“property”:”payload”,”propertyType”:”msg”,”value”:”string”,”valueType”:”entityState”},{“property”:”topic”,”propertyType”:”msg”,”value”:””,”valueType”:”triggerId”},{“property”:”data”,”propertyType”:”msg”,”value”:””,”valueType”:”entity”}],”x”:120,”y”:760,”wires”:[[“295b6903c89ee02a”]]},{“id”:”9a832e16617d3099″,”type”:”alexa-remote-list”,”z”:”85a5b4e1f8122fe2″,”name”:””,”account”:”5134235e3c4bb88d”,”config”:{“option”:”getListItems”,”value”:{“list”:{“type”:”str”,”value”:”YW16bjEuYWNjb3VudC5BRURXNTRVS0E3U0dNQk5YREdXMlVWUU1NQ1RBLVNIT1BQSU5HX0lURU0=”}}},”x”:700,”y”:680,”wires”:[[“7c1ea17cee17bdaf”]]},{“id”:”a8d167e4f9509899″,”type”:”file in”,”z”:”85a5b4e1f8122fe2″,”name”:”Read file”,”filename”:”/homeassistant/www/shopping_list.txt”,”filenameType”:”str”,”format”:”utf8″,”chunk”:false,”sendError”:false,”encoding”:”none”,”allProps”:false,”x”:340,”y”:680,”wires”:[[“705f0c9d5cb6fa78”]]},{“id”:”705f0c9d5cb6fa78″,”type”:”change”,”z”:”85a5b4e1f8122fe2″,”name”:”Save content”,”rules”:[{“t”:”set”,”p”:”fileText”,”pt”:”msg”,”to”:”payload”,”tot”:”msg”}],”action”:””,”property”:””,”from”:””,”to”:””,”reg”:false,”x”:510,”y”:680,”wires”:[[“9a832e16617d3099”]]},{“id”:”7c1ea17cee17bdaf”,”type”:”function”,”z”:”85a5b4e1f8122fe2″,”name”:”Process differences”,”func”:”const fileText = String(msg.fileText || \”\”).trim();\nconst alexaItems = Array.isArray(msg.payload) ? msg.payload : [];\n\n// Parse shopping_list.txt into desired items\n// Example entries:\n// 2-white fish\n// 2-!white fish\nfunction parseFileItems(text) {\n if (!text) return [];\n\n return text\n .split(\”,\”)\n .map(s => s.trim())\n .filter(Boolean)\n .map((entry, index) => {\n const dashIndex = entry.indexOf(\”-\”);\n const rawName = dashIndex >= 0 ? entry.slice(dashIndex + 1).trim() : entry.trim();\n const completed = rawName.startsWith(\”!\”);\n const name = completed ? rawName.slice(1).trim() : rawName;\n\n return {\n key: name.toLowerCase(),\n name,\n completed,\n index\n };\n });\n}\n\n// Normalize Alexa items\nfunction parseAlexaItems(items) {\n return items.map((item, index) => {\n const name = String(item.value || \”\”).trim();\n return {\n key: name.toLowerCase(),\n name,\n completed: !!item.completed,\n id: String(item.id),\n version: parseInt(item.version, 10),\n index\n };\n });\n}\n\n// Group array by key\nfunction groupByKey(items) {\n const map = new Map();\n for (const item of items) {\n if (!map.has(item.key)) {\n map.set(item.key, []);\n }\n map.get(item.key).push(item);\n }\n return map;\n}\n\nconst desiredItemsRaw = parseFileItems(fileText);\nconst desiredByKey = new Map();\n\nfor (const item of desiredItemsRaw) {\n const prev = desiredByKey.get(item.key);\n\n // Keep one item per name, preferring the active item over completed.\n if (!prev || (prev.completed && !item.completed)) {\n desiredByKey.set(item.key, item);\n }\n}\n\nconst desiredItems = Array.from(desiredByKey.values())\n .sort((a, b) => a.index – b.index);\n\nconst currentItems = parseAlexaItems(alexaItems);\n\nconst desiredMap = groupByKey(desiredItems);\nconst currentMap = groupByKey(currentItems);\n\nconst allKeys = new Set([\n …desiredMap.keys(),\n …currentMap.keys()\n]);\n\nconst addMsgs = [];\nconst editMsgs = [];\nconst removeMsgs = [];\n\nfor (const key of allKeys) {\n const desiredGroup = (desiredMap.get(key) || []).map(item => ({\n …item,\n matched: false\n }));\n\n const currentGroup = (currentMap.get(key) || []).map(item => ({\n …item,\n matched: false\n }));\n\n // Pass 1: exact matches (same name, same completed state)\n for (const desired of desiredGroup) {\n const exact = currentGroup.find(curr =>\n !curr.matched && curr.completed === desired.completed\n );\n\n if (exact) {\n desired.matched = true;\n exact.matched = true;\n }\n }\n\n // Pass 2: same name exists but wrong completed state -> edit\n for (const desired of desiredGroup) {\n if (desired.matched) continue;\n\n const wrongState = currentGroup.find(curr => !curr.matched);\n if (wrongState) {\n desired.matched = true;\n wrongState.matched = true;\n\n editMsgs.push({\n …msg,\n payload: {\n id: wrongState.id,\n item: wrongState.id,\n version: wrongState.version,\n name: desired.name\n },\n id: wrongState.id,\n item: wrongState.id,\n version: wrongState.version,\n name: desired.name,\n completed: desired.completed\n });\n }\n }\n\n // Pass 3: desired items still unmatched -> add\n for (const desired of desiredGroup) {\n if (desired.matched) continue;\n\n addMsgs.push({\n …msg,\n payload: {\n id: null,\n item: null,\n version: null,\n name: desired.name\n },\n id: null,\n item: null,\n version: null,\n name: desired.name,\n completed: desired.completed\n });\n }\n\n // Pass 4: Alexa items still unmatched -> remove\n for (const curr of currentGroup) {\n if (curr.matched) continue;\n\n removeMsgs.push({\n …msg,\n payload: {\n id: curr.id,\n item: curr.id,\n version: curr.version,\n name: curr.name\n },\n id: curr.id,\n item: curr.id,\n version: curr.version,\n name: curr.name,\n completed: curr.completed\n });\n }\n}\n\n// Optional debug counters\nnode.status({\n fill: \”green\”,\n shape: \”dot\”,\n text: `add:${addMsgs.length} edit:${editMsgs.length} remove:${removeMsgs.length}`\n});\n\n// Return arrays: Node-RED will emit them one-by-one on each output\nreturn [addMsgs, editMsgs, removeMsgs];”,”outputs”:3,”timeout”:0,”noerr”:0,”initialize”:””,”finalize”:””,”libs”:[],”x”:910,”y”:680,”wires”:[[“ea65499580dbca54”],[“a88a14d7f8af768b”],[“5bce6fc503d4dd03”]]},{“id”:”ea65499580dbca54″,”type”:”alexa-remote-list”,”z”:”85a5b4e1f8122fe2″,”name”:””,”account”:”5134235e3c4bb88d”,”config”:{“option”:”addItem”,”value”:{“list”:{“type”:”str”,”value”:”YW16bjEuYWNjb3VudC5BRURXNTRVS0E3U0dNQk5YREdXMlVWUU1NQ1RBLVNIT1BQSU5HX0lURU0=”},”text”:{“type”:”msg”,”value”:”name”}}},”x”:1120,”y”:640,”wires”:[[“f618f7d03f95d717″,”7956ccd551c7bbd6”]]},{“id”:”a88a14d7f8af768b”,”type”:”alexa-remote-list”,”z”:”85a5b4e1f8122fe2″,”name”:””,”account”:”5134235e3c4bb88d”,”config”:{“option”:”editItem”,”value”:{“list”:{“type”:”str”,”value”:”YW16bjEuYWNjb3VudC5BRURXNTRVS0E3U0dNQk5YREdXMlVWUU1NQ1RBLVNIT1BQSU5HX0lURU0=”},”item”:{“type”:”msg”,”value”:”id”},”text”:{“type”:”msg”,”value”:”name”},”completed”:{“type”:”msg”,”value”:”completed”},”version”:{“type”:”msg”,”value”:”version”}}},”x”:1120,”y”:680,”wires”:[[“3f1c99d82a699268″,”7956ccd551c7bbd6”]]},{“id”:”5bce6fc503d4dd03″,”type”:”alexa-remote-list”,”z”:”85a5b4e1f8122fe2″,”name”:””,”account”:”5134235e3c4bb88d”,”config”:{“option”:”removeItem”,”value”:{“list”:{“type”:”str”,”value”:”YW16bjEuYWNjb3VudC5BRURXNTRVS0E3U0dNQk5YREdXMlVWUU1NQ1RBLVNIT1BQSU5HX0lURU0=”},”item”:{“type”:”msg”,”value”:”id”},”version”:{“type”:”msg”,”value”:”version”}}},”x”:1130,”y”:720,”wires”:[[“381c81cefe2267f4″,”7956ccd551c7bbd6”]]},{“id”:”f618f7d03f95d717″,”type”:”debug”,”z”:”85a5b4e1f8122fe2″,”name”:”debug 1″,”active”:true,”tosidebar”:true,”console”:false,”tostatus”:false,”complete”:”true”,”targetType”:”full”,”statusVal”:””,”statusType”:”auto”,”x”:1340,”y”:640,”wires”:[]},{“id”:”3f1c99d82a699268″,”type”:”debug”,”z”:”85a5b4e1f8122fe2″,”name”:”debug 2″,”active”:true,”tosidebar”:true,”console”:false,”tostatus”:false,”complete”:”true”,”targetType”:”full”,”statusVal”:””,”statusType”:”auto”,”x”:1340,”y”:680,”wires”:[]},{“id”:”381c81cefe2267f4″,”type”:”debug”,”z”:”85a5b4e1f8122fe2″,”name”:”debug 3″,”active”:true,”tosidebar”:true,”console”:false,”tostatus”:false,”complete”:”true”,”targetType”:”full”,”statusVal”:””,”statusType”:”auto”,”x”:1340,”y”:720,”wires”:[]},{“id”:”1943af27619fe565″,”type”:”function”,”z”:”85a5b4e1f8122fe2″,”name”:”Get stored categories”,”func”:”function normalizeValue(value) {\n return String(value || \”\”).trim().toLowerCase();\n}\n\nfunction parseOpenAiPayload(payload) {\n if (payload && typeof payload === \”object\”) {\n return payload;\n }\n\n let text = String(payload || \”\”).trim();\n\n // Remove “`json fences if OpenAI ever adds them\n text = text\n .replace(/^“`json\\s*/i, \”\”)\n .replace(/^“`\\s*/i, \”\”)\n .replace(/“`$/i, \”\”)\n .trim();\n\n return JSON.parse(text);\n}\n\nconst dedupedItems = Array.isArray(msg.dedupedItems) ? msg.dedupedItems : [];\nconst unknownItems = Array.isArray(msg.unknownItems) ? msg.unknownItems : [];\n\nlet categoryDb = flow.get(\”shoppingCategoryDb\”, \”file\”) || {};\n\nlet aiResult;\ntry {\n aiResult = parseOpenAiPayload(msg.payload);\n} catch (err) {\n node.error(\”Failed to parse OpenAI category JSON: \” + err.message, msg);\n return null;\n}\n\n// Save new OpenAI categories into persistent DB\nfor (const unknown of unknownItems) {\n const itemName = unknown.item && unknown.item.value;\n const key = normalizeValue(itemName);\n\n if (!key) continue;\n\n const category =\n aiResult[itemName] ||\n aiResult[key];\n\n if (!category) {\n node.warn(`OpenAI did not return a category for: ${itemName}`);\n continue;\n }\n\n categoryDb[key] = {\n category: String(category),\n originalName: itemName,\n updatedAt: new Date().toISOString(),\n source: \”openai\”\n };\n}\n\nflow.set(\”shoppingCategoryDb\”, categoryDb, \”file\”);\n\n// Build the final categorized list for the file node\nconst finalValues = dedupedItems.map(item => {\n const key = normalizeValue(item.value);\n const entry = categoryDb[key];\n\n const category = typeof entry === \”string\”\n ? entry\n : entry && entry.category;\n\n if (!category) {\n node.warn(`Missing category after OpenAI step for: ${item.value}`);\n const completedPrefix = item.completed ? \”!\” : \”\”;\n return `10-${completedPrefix}${item.value}`;\n }\n\n const completedPrefix = item.completed ? \”!\” : \”\”;\n return `${category}-${completedPrefix}${item.value}`;\n});\n\nmsg.payload = finalValues.join(\”,\”);\nmsg.categoryDb = categoryDb;\nmsg.savedNewCategories = unknownItems.length;\n\nreturn msg;”,”outputs”:1,”timeout”:0,”noerr”:0,”initialize”:””,”finalize”:””,”libs”:[],”x”:1540,”y”:160,”wires”:[[“88aee2df697de297”]]},{“id”:”16f6c55b7d3d68f8″,”type”:”ha-sensor”,”z”:”85a5b4e1f8122fe2″,”name”:”Shopping List Data”,”entityConfig”:”faec321857ea1154″,”version”:0,”state”:”$millis()”,”stateType”:”jsonata”,”attributes”:[{“property”:”raw_list”,”value”:”payload”,”valueType”:”msg”}],”inputOverride”:”allow”,”outputProperties”:[],”x”:1750,”y”:280,”wires”:[[“9c077bfefa3eb3f3”]]},{“id”:”295b6903c89ee02a”,”type”:”debounce-advanced”,”z”:”85a5b4e1f8122fe2″,”time”:”10″,”timeunit”:”s”,”debouncetype”:”trailing”,”name”:””,”x”:210,”y”:680,”wires”:[[“a8d167e4f9509899”]]},{“id”:”b330f46b5a4a6b45″,”type”:”comment”,”z”:”85a5b4e1f8122fe2″,”name”:”A switch for showing loading animation on the card”,”info”:””,”x”:570,”y”:500,”wires”:[]},{“id”:”7956ccd551c7bbd6″,”type”:”debounce-advanced”,”z”:”85a5b4e1f8122fe2″,”time”:”10″,”timeunit”:”s”,”debouncetype”:”trailing”,”name”:””,”x”:1330,”y”:600,”wires”:[[“8b34bbc236fda48c”]]},{“id”:”8b34bbc236fda48c”,”type”:”link out”,”z”:”85a5b4e1f8122fe2″,”name”:”Anylist to Alexa”,”mode”:”link”,”links”:[“48305a6062518e83″],”x”:1425,”y”:600,”wires”:[]},{“id”:”48305a6062518e83″,”type”:”link in”,”z”:”85a5b4e1f8122fe2″,”name”:”Anylist to Alexa”,”links”:[“8b34bbc236fda48c”],”x”:585,”y”:140,”wires”:[[“ae60e039ccbf0430”]]},{“id”:”5134235e3c4bb88d”,”type”:”alexa-remote-account”,”name”:”AlexaHome”,”authMethod”:”proxy”,”proxyOwnIp”:”192.168.1.229″,”proxyPort”:”3456″,”cookieFile”:”/config/alexacookie.txt”,”refreshInterval”:”3″,”alexaServiceHost”:”pitangui.amazon.com”,”pushDispatchHost”:””,”amazonPage”:”amazon.com”,”acceptLanguage”:”en-US”,”onKeywordInLanguage”:”on”,”userAgent”:””,”usePushConnection”:”on”,”autoInit”:”on”,”autoQueryActivityOnTrigger”:”on”},{“id”:”493ff69ce7a88be3″,”type”:”ha-entity-config”,”server”:”48c9541e.53a2fc”,”deviceConfig”:”e89ac03c8f552ff3″,”name”:”Update Shopping List”,”version”:”6″,”entityType”:”button”,”haConfig”:[{“property”:”name”,”value”:”Update Shopping List”},{“property”:”icon”,”value”:””},{“property”:”entity_picture”,”value”:””},{“property”:”entity_category”,”value”:”config”},{“property”:”device_class”,”value”:”update”}],”resend”:false,”debugEnabled”:false},{“id”:”e8f2c1a964b803e8″,”type”:”Service Host”,”apiBase”:”https://api.openai.com/v1″,”secureApiKeyHeaderOrQueryName”:”Authorization”,”organizationId”:””,”name”:””},{“id”:”cecb42b20c7a4cba”,”type”:”ha-entity-config”,”server”:”48c9541e.53a2fc”,”deviceConfig”:”e89ac03c8f552ff3″,”name”:”Shopping List Item Removed”,”version”:6,”entityType”:”text”,”haConfig”:[{“property”:”name”,”value”:”Shopping List Item Removed”},{“property”:”icon”,”value”:””},{“property”:”entity_picture”,”value”:””},{“property”:”entity_category”,”value”:””},{“property”:”mode”,”value”:”text”},{“property”:”min_length”,”value”:””},{“property”:”max_length”,”value”:””},{“property”:”pattern”,”value”:””}],”resend”:false,”debugEnabled”:false},{“id”:”d9fef8e9012a8afb”,”type”:”ha-entity-config”,”server”:”48c9541e.53a2fc”,”deviceConfig”:”e89ac03c8f552ff3″,”name”:”Updating Shopping List”,”version”:6,”entityType”:”switch”,”haConfig”:[{“property”:”name”,”value”:”Updating Shopping List”},{“property”:”icon”,”value”:””},{“property”:”entity_picture”,”value”:””},{“property”:”entity_category”,”value”:”config”},{“property”:”device_class”,”value”:”switch”}],”resend”:false,”debugEnabled”:false},{“id”:”783e578710bbc9ad”,”type”:”ha-entity-config”,”server”:”48c9541e.53a2fc”,”deviceConfig”:”e89ac03c8f552ff3″,”name”:”Sync shopping list text with Alexa”,”version”:6,”entityType”:”button”,”haConfig”:[{“property”:”name”,”value”:”Sync shopping list text with Alexa”},{“property”:”icon”,”value”:””},{“property”:”entity_picture”,”value”:””},{“property”:”entity_category”,”value”:”config”},{“property”:”device_class”,”value”:””}],”resend”:false,”debugEnabled”:false},{“id”:”faec321857ea1154″,”type”:”ha-entity-config”,”server”:”48c9541e.53a2fc”,”deviceConfig”:”e89ac03c8f552ff3″,”name”:”Shopping List Data”,”version”:6,”entityType”:”sensor”,”haConfig”:[{“property”:”name”,”value”:”Shopping List Data”},{“property”:”icon”,”value”:””},{“property”:”entity_picture”,”value”:””},{“property”:”entity_category”,”value”:””},{“property”:”device_class”,”value”:””},{“property”:”unit_of_measurement”,”value”:””},{“property”:”state_class”,”value”:””}],”resend”:false,”debugEnabled”:false},{“id”:”48c9541e.53a2fc”,”type”:”server”,”name”:”Home Assistant”,”addon”:true,”rejectUnauthorizedCerts”:true,”ha_boolean”:[],”connectionDelay”:false,”cacheJson”:false,”heartbeat”:true,”heartbeatInterval”:”30″,”statusSeparator”:””,”enableGlobalContextStore”:false},{“id”:”e89ac03c8f552ff3″,”type”:”ha-device-config”,”name”:”Node Red Buttons”,”hwVersion”:””,”manufacturer”:”Node-RED”,”model”:””,”swVersion”:””},{“id”:”696ecdccf39a286b”,”type”:”global-config”,”env”:[],”modules”:{“node-red-contrib-alexa-remote2-applestrudel”:”5.0.58″,”node-red-contrib-home-assistant-websocket”:”0.80.3″,”@inductiv/node-red-openai-api”:”6.32.0″,”node-red-contrib-debounce-advanced”:”0.1.1″}}]

Why The Duplicate Cleanup Matters

Alexa shopping lists can contain duplicates surprisingly easily. You can add the same item multiple times by voice, or end up with both a completed and an incomplete version of the same item. If I sent that directly to OpenAI, the dashboard could become messy, and the list would not reflect how I actually want to shop.

So in this version I clean duplicates before the AI step. The logic is:

  • If all copies of an item are completed, keep only one completed copy.
  • If at least one copy is incomplete, keep one incomplete copy and remove all completed copies.
  • If multiple incomplete copies exist, keep one and remove the rest.

The removed duplicates are emitted from a second function output one at a time so they can be removed from Alexa asynchronously. This keeps the list cleaner and avoids showing repeated items on the dashboard.

One Remaining Caveat

The AnyList side is much better in this version because ha-anylist exposes an items_signature attribute. The automation watches that attribute, so edits coming from the AnyList/Alexa side have a real Home Assistant trigger instead of depending only on a Node-RED voice event or a manual dashboard refresh.

The remaining caveat is timing. If a change is made in AnyList or through Alexa, Home Assistant can only react after the AnyList integration sees the updated list and refreshes the todo entity. In normal use that is fine, but if something looks delayed, I would first check the AnyList integration refresh behavior and the entity’s items_signature attribute.

Bottom Line

This version feels much closer to what I originally wanted. The list is still built on top of a workaround, because Amazon does not provide a friendly official path for this use case, but the experience is much nicer now. The dashboard is compact, the aisle grouping is easy to scan, duplicates are handled, the refresh flow gives visual feedback, and the items can be checked on or off directly from the wall tablet while shopping.

It is still not perfect. The setup depends on the Alexa community node, the AnyList custom integration, and the local Node-RED/Home Assistant glue staying healthy. But the important part is that changes from the file side and the AnyList side now have a clear sync path, and repeated products get easier to handle as the category database grows.

If you adapt this for your own setup and find a nicer way to display the aisles on the card, or a cleaner way to manage the category memory, I would love to hear about it.


Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply