To-do List Sync — one-way items, two-way completion

To-do List Sync — one-way items, two-way completion

Syncs two todo lists: items flow from a source list to a target list, and checking an item off works in both directions.

I built this to get my Cookidoo shopping list into Bring!, but nothing in the blueprint is specific to those two integrations — any pair of todo entities works.

What it does

  • New items on the source list are added to the target list, including their description (quantity, notes)
  • Check an item off on the target list → it gets completed on the source list
  • Check an item off on the source list → it gets completed on the target list
  • Items you add directly to the target list are left alone

How it works

The hard part is knowing which item is which. Matching by name breaks as soon as an item sits in the target list’s “recently used” section: a new source item with the same name looks identical to one you already bought, and gets closed immediately.

So each item gets a marker. The source item’s UID is hashed (md5, first 16 bits) and mapped into the CJK Unified Ideographs block, giving a single character that is appended to the item’s description on the target list. A UID of 01KZBYQW20W9F1P1FTC24HRAC1 hashes to 0x73a4, which maps to codepoint 0x4E00 + (0x73a4 % 20992) — one character, appended after the quantity.

That character is the entire sync state — no helper entity, no input_text, nothing to clean up. A new source item means a new UID, a new hash, a new marker, so re-added items are never confused with old ones. The blueprint only ever compares markers; it never has to decode one.

I put the marker in the description rather than the item name on purpose. Bring! (and most shopping apps) use the item name for category sorting and autocomplete — appending anything there breaks both. The description already holds the quantity, so an extra character is barely noticeable.

Requirements

  • The target list must support item descriptions (SET_DESCRIPTION_ON_ITEM). This is a hard requirement — without a description field the marker has nowhere to live and the blueprint silently does nothing. Bring!, Local To-do and most others are fine.
  • The source list must expose stable item UIDs (any integration does).
  • HA 2024.10+ (uses the triggers: / actions: / action: syntax).

Known limitation

If you clear the target list’s completed items before HA has polled the completed state, the marker disappears and the item gets re-added on the next run. It self-heals — check it off again and it closes properly — but it is worth knowing before you report it as a bug.

Collisions: 20,992 possible markers means roughly 0.5% chance of two simultaneously open items sharing one, at ~15 items. The consequence would be one item closing alongside another, not data loss.

The blueprint

blueprint:
  name: To-do List Sync (one-way items, two-way completion)
  description: >-
    Syncs a source to-do list (e.g. Cookidoo) with a target shopping list
    (e.g. Bring!). New items are copied to the target list; completing an item
    works in both directions. Items are matched via a single CJK character
    derived from the source item's UID, appended to the item description.
    The target list must support item descriptions.
  domain: automation
  input:
    quelle:
      name: Source list
      description: The list items are taken from (e.g. Cookidoo).
      selector:
        entity:
          filter:
            domain: todo
    ziel:
      name: Target list
      description: The list you shop from (e.g. Bring!). Must support descriptions.
      selector:
        entity:
          filter:
            domain: todo

mode: single
max_exceeded: silent

variables:
  quelle: !input quelle
  ziel: !input ziel

triggers:
  - trigger: state
    entity_id: !input quelle
  - trigger: state
    entity_id: !input ziel
  - trigger: time_pattern
    minutes: "/5"

actions:
  # ---------- fetch ----------
  - action: todo.get_items
    target:
      entity_id: !input quelle
    data:
      status:
        - needs_action
    response_variable: r_q

  - action: todo.get_items
    target:
      entity_id: !input quelle
    data:
      status:
        - completed
    response_variable: r_q_done

  - action: todo.get_items
    target:
      entity_id: !input ziel
    response_variable: r_z

  # ---------- evaluate ----------
  - variables:
      q: "{{ (r_q.values() | list)[0]['items'] }}"
      q_done: "{{ (r_q_done.values() | list)[0]['items'] }}"
      z: "{{ (r_z.values() | list)[0]['items'] }}"

      offen: >-
        {{ z | selectattr('status','eq','needs_action')
             | map(attribute='description', default='') | join(' § ') }}

      erledigt: >-
        {{ z | selectattr('status','eq','completed')
             | map(attribute='description', default='') | join(' § ') }}

      neu: >-
        {% set ns = namespace(l=[]) %}
        {% for i in q %}
          {% set cp = 0x4E00 + (((i.uid | md5)[:4] | int(base=16)) % 20992) %}
          {% set m = cp | pack('>H') | base64_encode | base64_decode('utf-16-be') %}
          {% if m not in offen and m not in erledigt %}
            {% set ns.l = ns.l + [{
                 'summary': i.summary,
                 'description': i.description | default('', true),
                 'mark': m }] %}
          {% endif %}
        {% endfor %}
        {{ ns.l }}

      fertig: >-
        {% set ns = namespace(l=[]) %}
        {% for i in q %}
          {% set cp = 0x4E00 + (((i.uid | md5)[:4] | int(base=16)) % 20992) %}
          {% set m = cp | pack('>H') | base64_encode | base64_decode('utf-16-be') %}
          {% if m in erledigt %}{% set ns.l = ns.l + [i.uid] %}{% endif %}
        {% endfor %}
        {{ ns.l }}

      q_done_marks: >-
        {% set ns = namespace(l=[]) %}
        {% for i in q_done %}
          {% set cp = 0x4E00 + (((i.uid | md5)[:4] | int(base=16)) % 20992) %}
          {% set ns.l = ns.l + [cp | pack('>H') | base64_encode | base64_decode('utf-16-be')] %}
        {% endfor %}
        {{ ns.l }}

      z_schliessen: >-
        {% set ns = namespace(l=[]) %}
        {% for b in z if b.status == 'needs_action' %}
          {% set d = b.description | default('', true) %}
          {% set hit = namespace(x=false) %}
          {% for m in q_done_marks if m in d %}{% set hit.x = true %}{% endfor %}
          {% if hit.x %}{% set ns.l = ns.l + [b.uid] %}{% endif %}
        {% endfor %}
        {{ ns.l }}

  # ---------- source -> target: add ----------
  - repeat:
      for_each: "{{ neu }}"
      sequence:
        - action: todo.add_item
          target:
            entity_id: "{{ ziel }}"
          data:
            item: "{{ repeat.item.summary }}"
            description: "{{ (repeat.item.description ~ ' ' ~ repeat.item.mark) | trim }}"

  # ---------- target -> source: complete ----------
  - repeat:
      for_each: "{{ fertig }}"
      sequence:
        - action: todo.update_item
          target:
            entity_id: "{{ quelle }}"
          data:
            item: "{{ repeat.item }}"
            status: completed

  # ---------- source -> target: complete ----------
  - repeat:
      for_each: "{{ z_schliessen }}"
      sequence:
        - action: todo.update_item
          target:
            entity_id: "{{ ziel }}"
          data:
            item: "{{ repeat.item }}"
            status: completed

Notes

The time_pattern trigger is a fallback, not the main path. A todo entity’s state is just the number of open items, so adding and completing one item within the same polling window produces no state change and no trigger. Five minutes costs nothing — todo.get_items reads the integration’s cache rather than hitting the network.

Want a different-looking marker? Change 0x4E00 and 20992 consistently in all four templates to any contiguous Unicode range. Smaller ranges raise the collision rate.

Feedback and edge cases welcome — this has been running on my own setup, so real-world reports from other list integrations are useful.

MIT licensed.

1 Like

Thank you.