feat(tasks): import Obsidian Tasks plugin lines as first-class Memry tasks - #1922
Conversation
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.
|
React Doctor found no new issues. 🎉 Reviewed by React Doctor for commit |
Codecov Report❌ Patch coverage is
📢 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.
|
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 ( Rationale: the plugin only ever writes The alternative of keeping This is documented in |
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.
# Conflicts: # packages/shared/src/link-references.test.ts # packages/shared/src/link-references.ts
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.
) * 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.
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
tasksrow the first time you open that note, through the converter that already exists inContentArea.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 tocrdt-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 underapps/desktop/src/main/sync/is touched.The constraint that shapes every decision
Memry's task identity is a trailing suffix:
serializeTaskBlockwrites- [x] <title> {task:<id>}, andparseTaskBlockSuffixonly 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
🔺⏫🔼🔽⏬priority4, 3, 2, 1, 1⏬lowest collapses onto Low and the original line records which it was📅📆🗓dueDate🛫startDate⏳⌛startDatewhen there is no🛫, else preserved✅completedAt, and the box is ticked❌➕TaskCreateInputhardcodes now🔁repeatConfig+repeatFrom, else preserved🏁#tagtask_tags, original casing keptsetTaskTagsenforces; clamped to the 50 characters and 20 tagsTaskCreateSchemaaccepts🆔⛔point at this id; deleting it breaks a graph spanning files Memry has never read⛔^blockid[/][-]…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-01into 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, plusevery weekdayand day lists likeevery Monday, Wednesday. A trailingwhen donesetsrepeatFrom: '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 installedremark-gfm@4.0.1:- [/] in progressparses withchecked: nulland keeps[/] in progressas literal text, so it never becomes a checkbox block and the converter never sees it.serializeTaskBlockcan 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/yDocToMarkdownpair over the declined shapes rather than trusting that reasoning.- [/] in progress,- [-] cancelled,- [ ] Do first 🆔 dcf64cand- [ ] Linked ^abc123all 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.jsongains oneexportssubpath and nothing else.A vault with no Obsidian task lines behaves exactly as it did:
buildObsidianTaskImportreturns null when the parser finds no plugin field, and the converter falls through to the existingparseQuickAddpath 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
486ace0dffixes it. Memry's suffix has to be last forparseTaskBlockSuffixto 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,scanTaskCheckboxStatesmissed 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 eggsis 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>},normalizeTaskBlocksupgrades it to ataskBlockandhasTaskSuffixinanalyzeTaskIntentsstops 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.
handleEditorContextMenucallsconvertCheckboxToTaskdirectly and never consultsanalyzeTaskIntents, 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
checkedfalse, so the block serialized as- [ ] Buy milk {task:id}.reconcileTaskCheckboxesFromMarkdownruns on every note save and every watcher event, sawchecked === falseagainst a non-nullcompletedAt, 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.
convertCheckboxToSubtaskpassed the raw text as the title, so a nested- [ ] Book flight 📅 2026-01-01serialized 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 ataskBlockbefore the create call resolves, and the barecatchonly cleared the dismissal. Two inputs reached it. A done date the calendar does not have,✅ 2026-02-30, parses as a date andz.string().datetime()rejects it, aftercreatehas already succeeded. A tag longer than the 50 charactersTaskCreateSchematakes 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.completedAtwas also a UTC-midnight instant built from a local calendar date, so✅ 2026-01-07rendered as the 6th anywhere west of UTC. It is built at local midnight now, for the same reasonformatDateKeyis 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-01with the suffix mid-line and the fields still end-anchored. This is the only option that keeps Obsidian fully functional. Rejected here because it needsblocksToMarkdownPreservinginapps/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
trailerprop on thetaskBlockconfig soserializeTaskBlockcan re-emit the tail. Needspackages/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 run391 passed.pnpm --filter @memry/desktop test:renderer702 files, 8593 passed.pnpm typecheck,pnpm lint,pnpm check:architecture,pnpm --filter @memry/desktop typecheck:test,git diff --checkall exit 0.pnpm docs:buildclean,pnpm docs:impact --strictcovered.pnpm --filter @memry/desktop test:mainis 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 topackages/shared/src/task-block.test.ts,apps/desktop/src/renderer/src/lib/obsidian-task-import.test.ts(42 cases), six cases inscan-task-intents.test.ts, one end-to-end case inContentArea.test.tsx, and the E2E specapps/desktop/tests/e2e/obsidian-tasks-import.e2e.ts.The E2E spec has not been run;
pnpm test:e2ewas out of bounds for this branch.Follow-ups worth filing
Pay rent [due:: …]becomesPay 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.convertCheckboxToSubtaskdoes not read plugin fields, so an indented Obsidian task imports with its symbols left in the title.Closes #1908