todo.merge
Merge imported todos into a list by id: an imported todo replaces the one with its id, new ids are appended.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 11 tests, run in TypeScript, Python and Rust.
What it does
Merges todos brought in from elsewhere (an imported CSV file, another device) into a list, by id, instead of replacing the whole list.
| id is in | result | | --- | --- | | both lists | the **incoming** todo, in the place the current one had | | only `current` | kept as it is | | only `incoming` | appended after the current todos |
For example
mergeTodos(, )→ two empty lists merge to an empty listmergeTodos(, incoming ×2)→ ×2 into an empty list, the incoming todos arrive in their own manual ordermergeTodos(current ×2, )→ ×2 nothing incoming: the current list in manual order, renumbered
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
export function mergeTodos(current: readonly Todo[], incoming: readonly Todo[]): readonly Todo[]
| current | Todo[] | the list as it is now |
| incoming | Todo[] | the todos being brought in, e.g. from todo.import-csv; they win for ids in both |
| returns | Todo[] | in manual order with order renumbered 0 to n-1: the current todos where they were, then the new ones |
Your code names it in one line, in the file that uses it
import { mergeTodos } from "#fune/todo.merge@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { type Todo } from "./todo_item.ts"; ← from todo.item ^1.0.0 · built alongside by fune
/** Manual order: order ascending, ties by position in the array (a stable sort). */
function inManualOrder(todos: readonly Todo[], which: string): Todo[] {
const seen = new Set<string>();
for (const todo of todos) {
if (seen.has(todo.id)) throw new Error(`duplicate id "${todo.id}" in the ${which} list`);
seen.add(todo.id);
}
return [...todos].sort((a, b) => a.order - b.order);
}
/**
* Merge by id. A todo in both lists is taken from `incoming` (the newer
* copy: todo.item keeps no edit time to compare), in the place the current
* one had, because that is where the person put it. Todos only in `incoming`
* follow, in their own manual order. Todos only in `current` stay. The
* result is in manual order, renumbered 0 to n-1.
*/
export function mergeTodos(current: readonly Todo[], incoming: readonly Todo[]): readonly Todo[] {
const mine = inManualOrder(current, "current");
const theirs = inManualOrder(incoming, "incoming");
const byId = new Map(theirs.map((todo) => [todo.id, todo]));
const kept = new Set(mine.map((todo) => todo.id));
const merged = [...mine.map((todo) => byId.get(todo.id) ?? todo), ...theirs.filter((todo) => !kept.has(todo.id))];
return merged.map((todo, order) => ({ ...todo, order }));
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the TypeScript package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add todo.merge
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./todo.merge-1.0.0-typescript.fune, or fetch it from a terminal with fune pull todo.merge@1.0.0:typescript.
The whole function, every language, is one file too: todo.merge-1.0.0.fune, 19,135 bytes, sha256 70fd49a1e279c942df6f45cf86dbc03153376c38c62cd1836180141e1f1f11db. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before todo.merge
after — your function gets the result and the arguments, and returns the final result.
// fune: after todo.merge
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
// fune: replace todo.item in todo.merge
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show todo.merge --steps.
// fune: step todo.merge after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| two empty lists merge to an empty list | , | → | |
| into an empty list, the incoming todos arrive in their own manual order | , incoming ×2 | → | ×2 |
| nothing incoming: the current list in manual order, renumbered | current ×2, | → | ×2 |
| an id in both: the incoming todo wins, in the current one's place | current ×2, incoming ×1 | → | ×2 |
| new ids are appended after the current todos | current ×2, incoming ×2 | → | ×3 |
| equal orders keep their array order | current ×2, | → | ×2 |
| a new todo with order 0 still goes after the current ones, and gaps close | current ×2, incoming ×1 | → | ×3 |
| a done todo reopened in the file comes back open | current ×1, incoming ×1 | → | ×1 |
| a mixed merge: kept, replaced and new | current ×3, incoming ×2 | → | ×4 |
| an id twice in the incoming list is an error | , incoming ×2 | → | error: duplicate id "a" in the incoming list |
Show the other 1 test
| Case | Arguments | Expected | |
|---|---|---|---|
| an id twice in the current list is an error | current ×2, | → | error: duplicate id "a" in the current list |
More from the author
## Which copy wins
The incoming one. A todo (todo.item) records when it was created and completed but not when it was last edited, so there is no honest way to tell which of two copies is newer. Importing a file is the person saying "these are the versions I want", so the file's row is treated as the newer copy: every field comes from it, done state and timestamps included.
## Order
The result is in manual order (todo.sort's `manual`: `order` ascending, ties by array position), renumbered 0 to n-1, as todo.list's `moveTodo` leaves a list:
1. The current todos in their manual order, each replaced by its incoming copy where there is one. A replaced todo keeps its **current** position: the person arranged this list, and the file's `order` is a position in a different list. 2. Then the todos only in `incoming`, in the incoming list's own manual order, so a file's order among its new rows is kept.
So a new todo whose file `order` is 0 still goes after the existing ones, and gaps in the current orders close up.
## Errors
An id that appears twice in either list is an error (`duplicate id "a" in the incoming list`), because it is ambiguous which copy to keep. The todos are not otherwise checked: todo.import-csv has already checked a file's rows with todo.item's `validateTodo`.
Files
| Path | Bytes |
|---|---|
| README.md | 1,659 |
| impl/python.py | 1,049 |
| impl/rust.rs | 1,646 |
| impl/typescript.ts | 1,301 |
| vectors.json | 10,258 |