Skip to content

feat(tasks): import Obsidian Tasks plugin lines as first-class Memry tasks - #1922

Merged
h4yfans merged 13 commits into
mainfrom
obsidian-tasks-import
Aug 31, 2026
Merged

feat(tasks): import Obsidian Tasks plugin lines as first-class Memry tasks#1922
h4yfans merged 13 commits into
mainfrom
obsidian-tasks-import

Conversation

@h4yfans

@h4yfans h4yfans commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Anyone arriving from an Obsidian vault whose tasks are managed by the Obsidian Tasks plugin got their notes imported and every task dropped, because Memry only recognised its own {task:<id>} suffix. This makes those lines first-class Memry tasks.

Stacked on #1918.

Where the import happens, and why there

Memry has no "import an Obsidian vault" wizard. You point Memry at the folder, and a markdown checkbox becomes a tasks row the first time you open that note, through the converter that already exists in ContentArea.tsx. This change extends that converter rather than adding a second path.

That placement falls out of #1918. Opening a note is the only moment Memry has genuinely read the file's bytes and recorded a contentHash. The {task:<id>} write-back already travels the CRDT to crdt-writeback.ts, which refuses to overwrite a file whose hash it never measured. So this change adds no write path and inherits that guard unchanged. Nothing under apps/desktop/src/main/sync/ is touched.

The constraint that shapes every decision

Memry's task identity is a trailing suffix: serializeTaskBlock writes - [x] <title> {task:<id>}, and parseTaskBlockSuffix only reads a suffix at the very end of the line. Every Obsidian Tasks field regex, and the plugin's block-link regex, is end-anchored ($) and stripped from the tail one at a time. Appending Memry's suffix to a line that still carries plugin metadata therefore un-anchors all of it, and Obsidian silently stops seeing its own fields.

So Memry lifts the fields it can carry off the line and into real task fields, and for the constructs it cannot move it leaves the line alone.

Field mapping

Obsidian Tasks field Symbol Memry home Why
description task title tags stay inline, as the plugin keeps them
priority 🔺 🔼 🔽 priority 4, 3, 2, 1, 1 five plugin levels onto four Memry ones; lowest collapses onto Low and the original line records which it was
due date 📅 📆 🗓 dueDate direct
start date 🛫 startDate direct
scheduled date startDate when there is no 🛫, else preserved Memry has one non-due date field
done date completedAt, and the box is ticked built at local midnight on the written date; a date the calendar does not have is left on the description
cancelled date preserved no column
created date preserved TaskCreateInput hardcodes now
recurrence 🔁 repeatConfig + repeatFrom, else preserved see the supported subset below
on completion 🏁 preserved no equivalent
tags #tag task_tags, original casing kept matches the case-preserving rule setTaskTags enforces; clamped to the 50 characters and 20 tags TaskCreateSchema accepts
id 🆔 line left alone other files' point at this id; deleting it breaks a graph spanning files Memry has never read
depends on line left alone Memry has no task dependencies, and the value is unrecoverable once stripped
block link ^blockid line left alone other notes link to it, and it must stay last on the line
custom status [/] [-] not converted see below

Every imported task keeps its original line, verbatim, on description. Not just the ones carrying a field with no Memry home. Importing rewrites the line in the user's vault, and mapping 📅 2026-09-01 into a due date still loses their symbol choice, field order and spacing. A record of only the fields we happened to find useful is not a record. No labels, no prose, no new translated strings, and the line copies straight back into Obsidian to undo the import by hand.

Recurrence maps for every day, every N days, and the same for weeks, months and years, plus every weekday and day lists like every Monday, Wednesday. A trailing when done sets repeatFrom: 'completion'. Any richer rrule keeps its text and imports without a repeat, because a half-understood rule that fires on the wrong days is worse than no rule.

Custom status characters are declined structurally, not by new code. Standard markdown only treats [ ], [x] and [X] as task list items. Verified against the installed remark-gfm@4.0.1: - [/] in progress parses with checked: null and keeps [/] in progress as literal text, so it never becomes a checkbox block and the converter never sees it. serializeTaskBlock can only write [ ] or [x], so converting one would flatten the user's status character. The parser recognises all of them and the fixture tests cover them; the import declines them.

I also ran the app's own markdownToYFragment / yDocToMarkdown pair over the declined shapes rather than trusting that reasoning. - [/] in progress, - [-] cancelled, - [ ] Do first 🆔 dcf64c and - [ ] Linked ^abc123 all come back byte-identical, so "left alone" is measured, not asserted.

The global filter is declined, visibly. The plugin can be configured so only lines carrying a tag (commonly #task) are tasks. Memry does not read the plugin's settings and treats every checkbox as a candidate, which is what it already did before this change. The filter tag survives as an ordinary Memry tag. This is documented as a known limitation.

Backward compatibility and idempotence

Live beta, real vaults. No schema change, no contract change, no migration, no file-format change. packages/shared/package.json gains one exports subpath and nothing else.

A vault with no Obsidian task lines behaves exactly as it did: buildObsidianTaskImport returns null when the parser finds no plugin field, and the converter falls through to the existing parseQuickAdd path unchanged.

Memry and the plugin both want the end of the line

An adversarial review of the design found a bug I would otherwise have shipped, and 486ace0df fixes it. Memry's suffix has to be last for parseTaskBlockSuffix to find it, but the plugin appends its own field after whatever is already there. So completing an imported task back in Obsidian writes - [x] Buy milk {task:abc} ✅ 2026-09-05, and a strictly end-anchored read returned null: Memry lost the id it had written itself, the block regressed to a bare checkbox, scanTaskCheckboxStates missed the completion, and the next open minted a duplicate task with a garbled title. Ticking a box in Obsidian was enough to trigger it.

A suffix is now still ours when everything behind it is plugin syntax, and only then. Buy milk {task:abc} and eggs is unchanged, so ordinary trailing prose still means this is not a task line. Memry absorbs the plugin's edit, marks the task complete, and drops the trailing on the next save because the completion now lives on the task.

Idempotence is structural and predates this change. Once a line carries {task:<id>}, normalizeTaskBlocks upgrades it to a taskBlock and hasTaskSuffix in analyzeTaskIntents stops it ever being a conversion candidate again. Re-opening the note, or re-running the import over the same file, creates no second task. The E2E spec opens the note twice and asserts the count is unchanged, because that criterion is the one a single-import test would silently fake.

The three declined constructs leave their line byte-identical on disk. They are refused inside the converters themselves, before any block mutation, so the refusal holds however the conversion was asked for.

Review pass on the wiring

A review of the first version found four data-loss paths, all in the wiring rather than in either tested unit. Each is fixed and pinned by a test that failed before the fix.

The context menu bypassed the refusal. handleEditorContextMenu calls convertCheckboxToTask directly and never consults analyzeTaskIntents, so the analyzer's refusal never ran. A line carrying 🆔 plus a due date converted, and the id another note's names was gone from disk and absent from the database. The refusal now lives in both converters, where the rewrite happens, and the user gets a toast naming what to remove.

A done date was reverted on the next save. The converter completed the task but left checked false, so the block serialized as - [ ] Buy milk {task:id}. reconcileTaskCheckboxesFromMarkdown runs on every note save and every watcher event, saw checked === false against a non-null completedAt, and un-completed the task. The imported done date did not survive one save. The box is now ticked to match the completion, which is also what the plugin's own means.

A nested line lost its fields. convertCheckboxToSubtask passed the raw text as the title, so a nested - [ ] Book flight 📅 2026-01-01 serialized as - [ ] Book flight 📅 2026-01-01 {task:id}: the suffix un-anchored the plugin's due-date regex, and Memry had not read the date either. The nested path now lifts the same fields as the top-level one.

A half-converted block could be orphaned on taskId: ''. The block becomes a taskBlock before the create call resolves, and the bare catch only cleared the dismissal. Two inputs reached it. A done date the calendar does not have, ✅ 2026-02-30, parses as a date and z.string().datetime() rejects it, after create has already succeeded. A tag longer than the 50 characters TaskCreateSchema takes rejects the whole create, because the Obsidian tag grammar is looser than the contract. Both are now refused at the import boundary, a rejected completion no longer costs the block its task id, and anything else that fails puts the block back as the checkbox the file has.

completedAt was also a UTC-midnight instant built from a local calendar date, so ✅ 2026-01-07 rendered as the 6th anywhere west of UTC. It is built at local midnight now, for the same reason formatDateKey is local.

Rejected alternatives

Regenerate the plugin tail at write-back time, keyed by task id, so the line reads - [ ] Buy milk {task:abc} 📅 2026-09-01 with the suffix mid-line and the fields still end-anchored. This is the only option that keeps Obsidian fully functional. Rejected here because it needs blocksToMarkdownPreserving in apps/desktop/src/main/sync/, which this PR must not touch, and because it turns a checkbox-only reconcile into a two-writer field-level merge. Worth its own issue.

A trailer prop on the taskBlock config so serializeTaskBlock can re-emit the tail. Needs packages/editor-schema, out of scope, and it is a block-schema change with its own contract test.

Use the plugin's 🆔 as Memry's identity. Hijacks a field the user may already be using for dependencies.

Verified

pnpm --filter @memry/shared exec vitest run 391 passed. pnpm --filter @memry/desktop test:renderer 702 files, 8593 passed. pnpm typecheck, pnpm lint, pnpm check:architecture, pnpm --filter @memry/desktop typecheck:test, git diff --check all exit 0. pnpm docs:build clean, pnpm docs:impact --strict covered.

pnpm --filter @memry/desktop test:main is 7724 passed with one failure, fts-corruption.test.ts, which passes on its own (Tests 8 passed). It is the known parallel-load flake and touches nothing in this change.

Tests added: packages/shared/src/obsidian-tasks.test.ts (119 cases, driven by eight files of real generated plugin output copied verbatim from the plugin's own approved-output fixtures), four cases appended to packages/shared/src/task-block.test.ts, apps/desktop/src/renderer/src/lib/obsidian-task-import.test.ts (42 cases), six cases in scan-task-intents.test.ts, one end-to-end case in ContentArea.test.tsx, and the E2E spec apps/desktop/tests/e2e/obsidian-tasks-import.e2e.ts.

The E2E spec has not been run; pnpm test:e2e was out of bounds for this branch.

Follow-ups worth filing

  • The converter collapses the plugin's deliberate two-space gap before a Dataview field (Pay rent [due:: …] becomes Pay rent [due:: …]). The plugin uses two spaces on purpose to stop Obsidian's reading view hiding every other bracketed inline field. Pre-existing, a [Bug]: Memry rewrites any foreign markdown file it opens into its own dialect — 12 of 14 ordinary inputs mutate #1909-class issue, and it only bites lines Memry declines or has not converted yet.
  • convertCheckboxToSubtask does not read plugin fields, so an indented Obsidian task imports with its symbols left in the title.
  • The conversion is a debounced editor side effect rather than a consented import. That predates this PR, but it now rewrites plugin metadata, which raises the stakes.

Closes #1908

h4yfans added 10 commits August 31, 2026 02:41
Twelve of fourteen ordinary markdown inputs came back changed through
markdownToYFragment -> yDocToMarkdown, the pair the app runs: the seed on note
open and the whole-document re-serialization on the first write-back after it.
Three of those rewrites lose information rather than style, and this file pins
those three and their neighbours.

A two-space hard line break came back as a paragraph break, so a <br> the author
wrote became a blank line. A reference-style link came back with its destination
inlined at every use site and its definition deleted, and a definition several
links shared was deleted once for all of them. An untagged fence came back
tagged ```javascript, so a shell or JSON block claimed to be JavaScript.

The fence row is the one a machine reads. An Obsidian Kanban board keeps its
settings in a bare fence inside a `%% kanban:settings %%` comment, and the
plugin does not parse it as markdown: extractSettingsFooter scans the raw file
backwards from EOF and JSON.parses everything between the opening fence's third
backtick and the closing one, so an invented info string lands inside the slice.
It throws, getParsedBoard discards the parse, and every lane and card is
replaced by a stack trace. The two blank lines the round trip also injects are
harmless there, so they are recorded as canonical rather than chased.

Same shape as the corpus in blocknote-converter.roundtrip.test.ts: exact bytes
in, exact bytes out, and a second pass that changes nothing. Eighteen of the
twenty-one cases fail on this commit.

Part of #1906.
Three separate losses on the same leg, each fixed where the information was
still there to keep.

A hard line break was never lost in the document. The editor holds a soft break
as one newline inside a text node and a hard break as two, and remark writes one
backslash break for the first and two for the second. normalizeSerializedMarkdown
collapsed every backslash break to a plain newline, which turned that pair into
a blank line and the author's <br> into a paragraph gap. A run of exactly two is
now written as a two-space hard break. Runs of one keep the old collapse, and so
do runs of three or more, which have no spelling inside a paragraph.

A reference definition had no document to survive in. The parser resolves
[docs][d] against its definition and hands over an ordinary link, and the
definition itself is a block BlockNote has no node for, so it was dropped and
the destination inlined at every use site. Definitions are now lifted out before
the editor sees the markdown and ride beside the document in the shared doc, the
same way CriticMarkup marks do, and each use site's exact spelling is recorded
with them. On the way out, each recorded usage rewrites the first inline link
that matches its text and destination, so an inline link that merely shares a
destination is left alone, and the definitions are re-emitted with the blank
lines the author put between them. A definition several links share is restored
at all of them. One deliberate concession: definitions come back at the END of
the file. CommonMark resolves a definition wherever it sits, so this costs the
position of a mid-file definition and loses nothing.

An untagged fence lost its bareness at parse time. A ``` with no info string
becomes a codeBlock whose language is the schema default, javascript, and by the
time the document exists the source is the only thing that still knows. The
fences are now counted off the markdown and matched against the parsed code
blocks in document order, and only the invented language is cleared. A count
mismatch means some fence did not become a code block, and the whole pass is
abandoned rather than guessed at.

No new node type and no schema change. The two arrays are additive and an older
build ignores them, so a note written here still opens on an install that
predates this. normalizeSerializedMarkdown is shared by both pipelines, so the
hard-break half lands on the renderer's serializer too.

Closes the round-trip half of #1909.
53c1672 made the write-back compare the file against contentHash and skip when
they differ, and its own message named the hole it left: a note whose hash was
never measured, a tier-0 sidebar row listed from stat alone, had nothing to
compare and wrote as it always did.

That hole is how a stranger's vault was rewritten without anyone opening a file.
FullSyncRunner.sweepAllCrdtNotes queues a pull for EVERY markdown note in the
vault after a reconnect and in the finally of every full sync, and pack bootstrap
does the same on a fresh device. The inbound update applies with ORIGIN_NETWORK,
which schedules a write-back, and a write-back re-serializes the whole document
and writes the whole file even when the update touched one word. Nothing in that
chain needs the user to have opened the note.

The rule is now the plain one. A row that exists with no hash is refused.

Refusing alone would have cost a real save, because nothing else fills that
column in: indexVault skips a path that already has a row, so a file that
appeared while the app was running and whose backfill was cancelled by a vault
close keeps a null hash forever. So the other half is that seeding a doc records
what it was built from. seedFromMarkdown already reads the file and builds the
document out of those bytes; it now writes their hash onto the row when the row
has none, which is the honest claim that this app has read them. Opening a note
is what makes the guard let go.

A row that is not in the index at all is left as it was. cached then came from
canonical metadata, which means the item handler applied this note and wrote this
file moments ago and only the projection is late. Those bytes are ours.

No schema change and no migration: content_hash is an existing nullable column
and this only ever fills a hole in it. A file an older build already rewrote is
not touched again, since the index measured the mutated bytes and the write is a
no-op against them.

Two fixtures move with the behaviour rather than around it. crdt-writeback.test
now defaults to an indexed note, because indexVault hashes every file present
when the vault opens and a row without a hash is the exception. And the
note-open-byte-stability harness records the seed hash in its openNote stand-in
and re-measures in its syncNoteToCache stub, exactly as the two production
functions it names do; without that the whole corpus would have passed by never
saving at all.

Closes the write-back half of #1909.
…kdown note

The conformance corpus pins the round trip per serializer. What it cannot show
is that the running app wires that serializer to the vault, so this seeds the
bytes on disk, opens the note in the real editor with collaboration live, makes
an edit, waits for write-back, closes the note, and reads the .md back with fs.

Read back with fs on purpose. getNoteFileBodyById and the write-back debug
state's lastMarkdown both run through normalizeBodyText, and the bytes under
test here include the two spaces that spell a hard line break.

All three layers land in one poll per case, so a failure names the layer in its
diff instead of leaving a screenshot to be guessed at. Three cases, one per
information-losing row: the hard break, a definition referenced twice, and the
bare fence an Obsidian Kanban board's settings live in.

Whole-file byte identity is deliberately not the assertion for the Kanban case.
The round trip inserts a blank line after `%% kanban:settings` and before the
closing `%%`, which the plugin's backwards scan tolerates; the info string is
what it cannot.

Not run locally: E2E runs serially under the coordinator because it needs an
Electron native rebuild.
Two promises that are now true and testable. A file memrynote has never read is
never written over, so pointing it at an existing vault leaves every unopened
note exactly as its author wrote it. And opening one keeps the three things that
carry meaning: a two-space hard line break, a reference-style link with its
definition, and a code fence that was given no language.

The cosmetic normalizations are listed as what they are, so the difference
between "spelled differently" and "says something else" is on the page rather
than left to be discovered.
…n notes save

Two review findings on #1909.

The parse was fed the body with its reference definitions stripped, and
CommonMark reads [docs][d] with no definition in sight as literal bracket
text — the round trip stayed byte-perfect while every reference link
opened dead on screen, which is why no byte assertion caught it. The
parse now sees the definitions, the converter drops the definition block
as it always did, and the Y.Array side-channel restores both halves on
the way out. Pinned by a block-level test and a mid-file-definition
round-trip case.

seedFromMarkdown returned before recording the content hash for an
empty or frontmatter-only file, so the write-back's never-read guard
would have refused the first keystroke into such a foreign note forever
— nothing else fills that column in. The bytes were read and the empty
doc represents them faithfully, so both early returns now record.

crdt-provider.test.ts mocked ../vault/frontmatter without
generateContentHash, so every seed-hash recording in that suite threw
inside its try/catch and the recording half ran untested. The mock now
hashes, and the record / fill-only / failed-conversion cases are pinned.
…mats

The Obsidian Tasks plugin is the standard way to keep tasks in an Obsidian
vault, and Memry could not see any of them: it only recognises its own
`{task:<id>}` suffix, so a vault that arrived full of tasks arrived empty.

Parses the emoji format and the Dataview inline-field format in one strip
loop rather than behind a format flag, because a half-migrated vault mixes
them on a single line and the two syntaxes share no characters. Mirrors the
plugin's own end-anchored, strip-from-the-tail algorithm, including the
optional Variant Selector 16 on every emoji, the alternate due (📆 🗓) and
scheduled (⌛) symbols, and re-appending trailing tags to the description.

`obsidianTaskImportBlocker` names the three constructs Memry must not
rewrite. Memry's own suffix has to be last on the line, and the plugin's
block-link and field regexes are all end-anchored, so appending the suffix
un-anchors them; and 🆔/⛔ form a dependency graph spanning files Memry has
never read.

Tests run over eight files of real generated plugin output copied verbatim
from the plugin's own approved-output fixtures.
Memry and the Obsidian Tasks plugin both want the end of the line. Memry's
`{task:<id>}` suffix has to be last for `parseTaskBlockSuffix` to find it,
and the plugin appends its own field after whatever is already there.

So completing an imported task back in Obsidian writes
`- [x] Buy milk {task:abc} ✅ 2026-09-05`, and a strictly end-anchored read
returned null: Memry lost the id it had written itself, the block regressed
to a bare checkbox, `scanTaskCheckboxStates` missed the completion, and the
next open minted a duplicate task with a garbled title. Ticking a box in
Obsidian was enough to trigger it.

A suffix is now still ours when everything behind it is plugin syntax, and
only then. Ordinary trailing prose still means this is not a task line, so
`Buy milk {task:abc} and eggs` is unchanged.
Memry has no Obsidian vault importer. You point it at the folder, and a
checkbox becomes a `tasks` row the first time you open the note, through
the converter that already lives in ContentArea. So that converter is the
import path, and this extends it rather than adding a second one.

That placement is what #1918 decided. Opening a note is the only moment
Memry has genuinely read the file's bytes and recorded a contentHash, and
the `{task:<id>}` write-back already travels the CRDT to crdt-writeback,
which refuses to overwrite a file whose hash it never measured. No new
write path, and nothing under src/main/sync is touched.

Due, start, scheduled, done, priority, recurrence and tags land on real
task fields. Every imported task also keeps its original line verbatim on
`description`: importing rewrites that line, and mapping a due date still
loses the user's symbol choice, field order and spacing, so a record of
only the fields we found useful is not a record.

Lines carrying 🆔, ⛔ or a trailing ^blockid are refused outright and left
byte-identical. Memry's suffix must be last on the line and every plugin
field regex is end-anchored, so appending it would un-anchor them; and the
id/dependency pair forms a graph spanning files Memry has never read.

Custom status characters need no code: markdown only treats [ ], [x] and
[X] as checkboxes, so - [/] never becomes a checkbox block and the
converter never sees it. Verified against remark-gfm and against the app's
own converter, which returns - [/] byte-identical.
…s not

Documents the field mapping, the three constructs Memry refuses to rewrite
and why, the repeat rules it can express, and the two limitations a
migrating user will hit: the plugin's global filter is not read, and a
custom status character is left alone.

Adds an E2E spec that seeds both serializer formats plus one line Memry
must not touch, opens the note, drives title, project, date and delete on
the real Tasks page, reads the untouched line back off disk with fs, then
opens the note a second time and asserts the task count is unchanged.
That second open is the point: a spec that imports once passes whether or
not the import is idempotent.
@github-actions github-actions Bot added dependencies documentation Improvements or additions to documentation enhancement New feature or request test labels Aug 31, 2026
@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown

React Doctor found no new issues. 🎉

Reviewed by React Doctor for commit 1415e0c.

@codecov

codecov Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.71429% with 15 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...r/src/components/note/content-area/ContentArea.tsx 78.87% 15 Missing ⚠️

📢 Thoughts on this report? Let us know!

…date

The context menu called the checkbox converter directly, so the refusal that
lived only in the analyzer never ran. A line carrying an id or a dependency was
rewritten as `- [ ] Buy milk {task:<id>}`, and the id another note's ⛔ names
was gone from disk and absent from the database. The refusal now sits where the
rewrite happens, in both converters.

A done date completed the task but left the box unticked, so the block
serialized as `- [ ]` over a row with a completedAt and the markdown reconciler
un-completed it on the next save. The box is now ticked to match.

A nested line converted to a subtask with its plugin fields still on the title.
The suffix then landed behind the plugin's end-anchored due-date regex and
Memry had not read the date either. The nested path lifts the same fields as
the top-level one.

Two ways a half-converted block could be orphaned on `taskId: ''` are closed. A
done date the calendar does not have, `✅ 2026-02-30`, is no longer sent as a
completion time; `z.string().datetime()` rejects it after the row exists. A tag
longer than the 50 characters `TaskCreateSchema` takes no longer rejects the
whole create. Anything else that fails puts the block back as the checkbox the
file has.

A done date is also built at local midnight rather than UTC midnight, so
`✅ 2026-01-07` no longer reads as the 6th west of UTC.
@h4yfans

h4yfans commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

Decision recorded on the one place this PR chose behaviour rather than restoring it.

An Obsidian line that is unticked but carries a done date (- [ ] Water plants ✅ 2026-01-07) imports as completed, and Memry writes - [x] back into the file.

Rationale: the plugin only ever writes for a finished task, so the source line is internally inconsistent and the done date is the more reliable of the two signals. Ticking preserves the user's completion date; honouring the empty checkbox would discard it. The cost is that Memry changes a visible mark in a file it did not author, which is why this was escalated rather than decided silently.

The alternative of keeping - [ ] on disk while holding completedAt in the database was rejected: reconcileTaskCheckboxesFromMarkdown would un-complete the task on the first save, so that state cannot be held without further work against the reconciler.

This is documented in apps/docs/src/user-guide/tasks/import-obsidian.md.

The editability case never reached a single assertion. Its `createProject`
helper sent one status and `ProjectCreateSchema` requires at least two, so the
test died on `Validation failed: statuses: Invalid input` before it touched the
imported task.

Two more waits were wrong once it got past that. The due-date preset button is
named "Today Aug 31", not "Today", because the label and the formatted date are
separate spans and the button carries no aria-label. And the task drawer never
unmounts; closed it keeps a 1px transparent border, so Playwright reports it
visible in both states and `state: 'visible'` / `state: 'hidden'` proved
nothing in either direction. Both now wait on `aria-hidden`, which is the state
the drawer publishes.

Title, project, due date and delete now run against a real imported task. All
four work.
@h4yfans
h4yfans changed the base branch from foreign-markdown-roundtrip to main August 31, 2026 10:04
# Conflicts:
#	packages/shared/src/link-references.test.ts
#	packages/shared/src/link-references.ts
@h4yfans
h4yfans marked this pull request as ready for review August 31, 2026 10:10
@h4yfans
h4yfans merged commit 5ec779d into main Aug 31, 2026
20 of 22 checks passed
h4yfans added a commit that referenced this pull request Aug 31, 2026
Both sides edited convertCheckboxToTask and convertCheckboxToSubtask, and both
independently invented restoreCheckbox. The merge keeps every behaviour from
each.

#1922's guarded body wins for restoreCheckbox: the extra `stale.props.taskId`
bail stops a late revert from clobbering a block whose create has already
landed. #1919's coverage wins for the exits. The project lookup stays inside
the try, so a listProjects() rejection reverts and not only a failed create,
and an unsuccessful create result reverts through the else branch on both
paths. That is four reverting exits on the top-level path and three on the
nested one.

The `dismissedBlocksRef.current.delete(blockId)` that main paired with each
revert is dropped. It was harmless before #1919, because a failed conversion
left the block a taskBlock the analyzer would not re-propose. Paired with a
revert it becomes a loop: the block goes back to a checkListItem, leaves the
dismissed set, and the analyzer re-proposes the conversion that just failed,
now with a toast every round. Keeping the block dismissed leaves the retry
where it belongs, at the next open of the note.

The import blocker gets no revert. It returns before dismissedBlocksRef.add
and before the updateBlock that makes the block a taskBlock, so there is
nothing to undo, and reverting there would overwrite the untouched checkbox's
props. #1922's invalid-date guard is localMidnight in obsidian-task-import.ts,
not an exit from either converter; a rolled-over date yields completedAt: null
and the conversion continues.

Test files keep all fifteen added cases, nine from #1919 and six from #1922.
No name collided, so nothing was renamed.
h4yfans added a commit that referenced this pull request Sep 2, 2026
)

* test(contracts): pin the impossible task dates the date regex accepts

TaskCreateSchema and TaskUpdateSchema validate dueDate and startDate with a
bare \d{4}-\d{2}-\d{2}, so 2026-02-30, 2025-02-29 and 2026-13-01 parse
clean and reach the tasks row. completedAt is a z.string().datetime() and zod
already refuses the impossible instant, which is the asymmetry these cases
record. 2024-02-29 is here to hold the line the other way: a leap day is a
real date and must keep parsing.

Fourteen of the sixteen cases fail on this commit.

Part of #1923.

* fix(tasks): refuse a due or start date the calendar does not have

CalendarDateSchema replaces the shape regex on dueDate and startDate in
TaskCreateSchema and TaskUpdateSchema. It keeps the regex as a first pass and
then rebuilds the date from its parts, so a value that rolls over to another
day is refused. The IPC boundary already turns a ZodError into
`Validation failed: dueDate: <message>`, and the task mutation hooks already
read that through extractErrorMessage, so the refusal reaches the user as a
toast instead of a row nobody asked for.

The round trip uses the local constructor and the local getters, the pair
localMidnight already uses for the done date in #1922. new Date('2026-01-01')
parses as UTC, so reading getDate() off it answers 31 December anywhere west of
UTC and refuses a real date; the test pins that in four zones plus two whose
clocks skip local midnight.

taskPatchSchema gets the same schema on due, due_date and start_date. The
agent's writes go handles.tasks.create -> createDesktopTasksDomain, which never
touches TaskCreateSchema, so the MCP tool schema is the only gate on that path.
The isoDateSchema the journal and calendar read tools share is left alone.

Validate on write, tolerate on read. Nothing parses a task row on the way out:
the IPC responses, the RPC Task and the calendar projection items are all plain
interfaces, and the query layer compares dueDate as a SQLite string. A seeded
row holding 2026-02-30 and 2025-02-29 is read back verbatim, listed, sorted,
counted and duplicated. TaskSyncPayloadSchema stays deliberately loose, because
a strict date there would make a receiving device reject a peer's old row
forever. The renderer keeps rolling such a value onto a real day rather than
throwing.

Closes #1923.

* docs(tasks): say what happens to an Obsidian line carrying an impossible date

The page already covered a done date the calendar does not have. Due and start
dates now behave the same way, and the outcome is different enough to write
down: the import stops, the checkbox stays a checkbox, the line is left as the
user wrote it, and the message names the refused date.

Part of #1923.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies documentation Improvements or additions to documentation enhancement New feature or request test

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Import Obsidian Tasks plugin lines as first-class Memry tasks

1 participant