diff --git a/AGENTS.md b/AGENTS.md index d011491..ac9fe2b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,8 @@ The root `.gitignore` is an allowlist. Never add knowledge projects, caches, cre - `docs_mv.py` — move/rename a doc or folder, or rename a heading, rewriting links and anchors (`--dry-run` to preview) - `docs_doctor.py` — health check of a project's setup (hook, template sync, config, git, lint, staleness); `--all` for every project - `sync_rules.py` — push the template's shared rules, hook and config files (`.vscode/*`, `.markdownlint.json`, `_doc-template.md`) into all projects (`--check` for drift) - - `icloud_bridge.py` — selective iCloud mirror, centralized Obsidian task views, boolean imports, local commits and optional macOS scheduling/notifications + - `icloud_bridge.py` — selective iCloud mirror, centralized Obsidian task views, task/metadata imports, local commits and optional macOS scheduling/notifications + - `ibridge_tasks.py` — internal lossless Tasks/Dataview field parser and stable-row projection protocol - `framework_checkout.py` — preview/enable a minimal pull-only clone, update it, or restore the full checkout - `docsource.py` — shared working-tree/Git-index reader for lint and README catalogue checks - `selftest.py` — automated self-test of all the tools in an isolated temp copy (about 15 seconds) @@ -38,7 +39,7 @@ The root `.gitignore` is an allowlist. Never add knowledge projects, caches, cre ## Changing the tools or template (verification required) - Before editing anything in `_tools/` or `_template/`, make sure no other session is editing them (one writer at a time). Snapshot shared inputs and check for concurrent changes before deploying a release. -- Run `uv run _tools/selftest.py` before you start (to know the baseline) and again when you finish. It must report all checks passed. Use `-k
` while iterating (sections: create, new_doc, stamp, hook, lint, timezone, links, staged_source, markdown_fences, toc, mv, tables, stale, sync, doctor, layout, cli_errors, failure_paths, uv_cache, framework_checkout, ibridge, ibridge_rename, ibridge_isolation, ibridge_notes, ibridge_transient, ibridge_status, ibridge_setup, framework_index, ci_allowlist). +- Run `uv run _tools/selftest.py` before you start (to know the baseline) and again when you finish. It must report all checks passed. Use `-k
` while iterating (sections: create, new_doc, stamp, hook, lint, timezone, links, staged_source, markdown_fences, toc, mv, tables, stale, sync, doctor, layout, cli_errors, failure_paths, uv_cache, framework_checkout, ibridge, ibridge_tasks, ibridge_rich_adapter, ibridge_rename, ibridge_isolation, ibridge_notes, ibridge_transient, ibridge_status, ibridge_setup, framework_index, ci_allowlist). - Every new tool, setting or behaviour gets a check in `_tools/selftest.py` and an entry in the public documentation: keep README.md concise and place detailed commands/configuration in docs/guide.md (`uv run .github/scripts/test_public_docs.py` checks both in the full development checkout and fails if a tool or `.docs.yaml` setting is undocumented) in the same change. Run that public-documentation check alongside the self-test before submitting. A tool change without passing checks is not done. - When managed template rules change, sync them into affected projects only with the owner's authorization, then validate and commit in each independent project repository. Publishing the framework alone does not authorize modifying knowledge projects. - The pre-commit hook fails closed: if a project cannot find `../_tools` or `uv`, its commits are blocked (`DOCS_SKIP_HOOK=1` bypasses once, only when the user says so). diff --git a/README.md b/README.md index 7fda7ac..57758de 100755 --- a/README.md +++ b/README.md @@ -109,6 +109,8 @@ Every tool, flag and setting is documented in the [full guide](docs/guide.md). - **Rename tasks** with per-project opt-in, or **edit selected notes** with both the bridge text-edit switch and project path-pattern opt-in. Generated task views accept only supported task edits. - **Keep a local history.** An available Mac validates imports against clean working trees and conflict rules, updates source files, and commits locally. Local commits do not push to GitHub. +The iPhone bridge supports nested and quoted tasks, Tasks dates/priorities and custom statuses, and plugin-generated recurrence. See the [task compatibility and acceptance requirements](docs/obsidian.md#task-format-compatibility). + Scheduled sync defaults to **five minutes, plus iCloud delivery delay**. Folderwise provides no hosted sync service. **[Set up Obsidian + iCloud →](docs/obsidian.md)** · [See the task round trip](docs/obsidian.md#complete-and-reopen-from-iphone) · [Acceptance checklist](docs/ibridge-testing.md) diff --git a/_tools/fixtures/obsidian_tasks_8_6_0.json b/_tools/fixtures/obsidian_tasks_8_6_0.json new file mode 100644 index 0000000..a6a94a6 --- /dev/null +++ b/_tools/fixtures/obsidian_tasks_8_6_0.json @@ -0,0 +1,192 @@ +{ + "upstream": "obsidian-tasks-group/obsidian-tasks", + "commit": "6c7e9a1443e645f29998619974329b61cd2d52dd", + "version": "8.6.0", + "generatedAt": "2026-10-26T12:00:00Z", + "cases": [ + { + "name": "tasksPluginEmoji_complete", + "format": "tasksPluginEmoji", + "before": "- [ ] Fixture __SOURCE__ ⏫ 📅 2026-11-01", + "after": [ + "- [x] Fixture __SOURCE__ ⏫ 📅 2026-11-01 ✅ 2026-10-26" + ] + }, + { + "name": "tasksPluginEmoji_cancel", + "format": "tasksPluginEmoji", + "before": "- [ ] Fixture __SOURCE__ 📅 2026-11-01", + "after": [ + "- [-] Fixture __SOURCE__ 📅 2026-11-01 ❌ 2026-10-26" + ] + }, + { + "name": "tasksPluginEmoji_reopen", + "format": "tasksPluginEmoji", + "before": "- [x] Fixture __SOURCE__ ✅ 2026-10-25", + "after": [ + "- [ ] Fixture __SOURCE__" + ] + }, + { + "name": "tasksPluginEmoji_daily", + "format": "tasksPluginEmoji", + "before": "- [ ] Fixture __SOURCE__ 🆔 original 🔁 every day 📅 2026-11-01", + "after": [ + "- [ ] Fixture __SOURCE__ 🔁 every day 📅 2026-11-02", + "- [x] Fixture __SOURCE__ 🆔 original 🔁 every day 📅 2026-11-01 ✅ 2026-10-26" + ] + }, + { + "name": "tasksPluginEmoji_weekly", + "format": "tasksPluginEmoji", + "before": "- [ ] Fixture __SOURCE__ 🔁 every week on Monday 📅 2026-11-02", + "after": [ + "- [ ] Fixture __SOURCE__ 🔁 every week on Monday 📅 2026-11-09", + "- [x] Fixture __SOURCE__ 🔁 every week on Monday 📅 2026-11-02 ✅ 2026-10-26" + ] + }, + { + "name": "tasksPluginEmoji_monthly", + "format": "tasksPluginEmoji", + "before": "- [ ] Fixture __SOURCE__ 🔁 every month 📅 2026-11-01", + "after": [ + "- [ ] Fixture __SOURCE__ 🔁 every month 📅 2026-12-01", + "- [x] Fixture __SOURCE__ 🔁 every month 📅 2026-11-01 ✅ 2026-10-26" + ] + }, + { + "name": "tasksPluginEmoji_when_done", + "format": "tasksPluginEmoji", + "before": "- [ ] Fixture __SOURCE__ 🔁 every week when done 📅 2026-11-01", + "after": [ + "- [ ] Fixture __SOURCE__ 🔁 every week when done 📅 2026-11-02", + "- [x] Fixture __SOURCE__ 🔁 every week when done 📅 2026-11-01 ✅ 2026-10-26" + ] + }, + { + "name": "tasksPluginEmoji_no_date", + "format": "tasksPluginEmoji", + "before": "- [ ] Fixture __SOURCE__ 🔁 every day", + "after": [ + "- [ ] Fixture __SOURCE__ 🔁 every day", + "- [x] Fixture __SOURCE__ 🔁 every day ✅ 2026-10-26" + ] + }, + { + "name": "tasksPluginEmoji_delete", + "format": "tasksPluginEmoji", + "before": "- [ ] Fixture __SOURCE__ 🏁 delete", + "after": [] + }, + { + "name": "tasksPluginEmoji_recur_delete", + "format": "tasksPluginEmoji", + "before": "- [ ] Fixture __SOURCE__ 🔁 every day 🏁 delete 📅 2026-11-01", + "after": [ + "- [ ] Fixture __SOURCE__ 🔁 every day 🏁 delete 📅 2026-11-02" + ] + }, + { + "name": "tasksPluginEmoji_completed_first", + "format": "tasksPluginEmoji", + "before": "- [ ] Fixture __SOURCE__ 🔁 every day 📅 2026-11-01", + "after": [ + "- [x] Fixture __SOURCE__ 🔁 every day 📅 2026-11-01 ✅ 2026-10-26", + "- [ ] Fixture __SOURCE__ 🔁 every day 📅 2026-11-02" + ] + }, + { + "name": "dataview_complete", + "format": "dataview", + "before": "- [ ] Fixture __SOURCE__ [priority:: high] [due:: 2026-11-01]", + "after": [ + "- [x] Fixture __SOURCE__ [priority:: high] [due:: 2026-11-01] [completion:: 2026-10-26]" + ] + }, + { + "name": "dataview_cancel", + "format": "dataview", + "before": "- [ ] Fixture __SOURCE__ [due:: 2026-11-01]", + "after": [ + "- [-] Fixture __SOURCE__ [due:: 2026-11-01] [cancelled:: 2026-10-26]" + ] + }, + { + "name": "dataview_reopen", + "format": "dataview", + "before": "- [x] Fixture __SOURCE__ [completion:: 2026-10-25]", + "after": [ + "- [ ] Fixture __SOURCE__" + ] + }, + { + "name": "dataview_daily", + "format": "dataview", + "before": "- [ ] Fixture __SOURCE__ [id:: original] [repeat:: every day] [due:: 2026-11-01]", + "after": [ + "- [ ] Fixture __SOURCE__ [repeat:: every day] [due:: 2026-11-02]", + "- [x] Fixture __SOURCE__ [id:: original] [repeat:: every day] [due:: 2026-11-01] [completion:: 2026-10-26]" + ] + }, + { + "name": "dataview_weekly", + "format": "dataview", + "before": "- [ ] Fixture __SOURCE__ [repeat:: every week on Monday] [due:: 2026-11-02]", + "after": [ + "- [ ] Fixture __SOURCE__ [repeat:: every week on Monday] [due:: 2026-11-09]", + "- [x] Fixture __SOURCE__ [repeat:: every week on Monday] [due:: 2026-11-02] [completion:: 2026-10-26]" + ] + }, + { + "name": "dataview_monthly", + "format": "dataview", + "before": "- [ ] Fixture __SOURCE__ [repeat:: every month] [due:: 2026-11-01]", + "after": [ + "- [ ] Fixture __SOURCE__ [repeat:: every month] [due:: 2026-12-01]", + "- [x] Fixture __SOURCE__ [repeat:: every month] [due:: 2026-11-01] [completion:: 2026-10-26]" + ] + }, + { + "name": "dataview_when_done", + "format": "dataview", + "before": "- [ ] Fixture __SOURCE__ [repeat:: every week when done] [due:: 2026-11-01]", + "after": [ + "- [ ] Fixture __SOURCE__ [repeat:: every week when done] [due:: 2026-11-02]", + "- [x] Fixture __SOURCE__ [repeat:: every week when done] [due:: 2026-11-01] [completion:: 2026-10-26]" + ] + }, + { + "name": "dataview_no_date", + "format": "dataview", + "before": "- [ ] Fixture __SOURCE__ [repeat:: every day]", + "after": [ + "- [ ] Fixture __SOURCE__ [repeat:: every day]", + "- [x] Fixture __SOURCE__ [repeat:: every day] [completion:: 2026-10-26]" + ] + }, + { + "name": "dataview_delete", + "format": "dataview", + "before": "- [ ] Fixture __SOURCE__ [onCompletion:: delete]", + "after": [] + }, + { + "name": "dataview_recur_delete", + "format": "dataview", + "before": "- [ ] Fixture __SOURCE__ [repeat:: every day] [onCompletion:: delete] [due:: 2026-11-01]", + "after": [ + "- [ ] Fixture __SOURCE__ [repeat:: every day] [onCompletion:: delete] [due:: 2026-11-02]" + ] + }, + { + "name": "dataview_completed_first", + "format": "dataview", + "before": "- [ ] Fixture __SOURCE__ [repeat:: every day] [due:: 2026-11-01]", + "after": [ + "- [x] Fixture __SOURCE__ [repeat:: every day] [due:: 2026-11-01] [completion:: 2026-10-26]", + "- [ ] Fixture __SOURCE__ [repeat:: every day] [due:: 2026-11-02]" + ] + } + ] +} diff --git a/_tools/ibridge_tasks.py b/_tools/ibridge_tasks.py new file mode 100644 index 0000000..a1e9219 --- /dev/null +++ b/_tools/ibridge_tasks.py @@ -0,0 +1,191 @@ +"""Lossless task fields and the Obsidian Tasks projection protocol (no Obsidian runtime required). + +The plugin owns recurrence calculations. The bridge validates its resulting rows and imports +them atomically, rather than implementing a second recurrence engine. See docs/obsidian.md. +""" +from __future__ import annotations + +import datetime as dt +import re +import unicodedata + + +CHECKBOX = re.compile(r"^([\t >]*(?:[-*+]|\d+[.)])\s+\[)([^\]\r\n])(\].*)$") +SOURCE = re.compile(r' \(\[source\]\(([^)\n]+)\)\)') +ROW_ID = re.compile(r' "fw:([a-f0-9]+)"$') +DATES = {'start', 'scheduled', 'due', 'created', 'done', 'cancelled'} +SYMBOLS = {'🛫': 'start', '⏳': 'scheduled', '⌛': 'scheduled', '📅': 'due', '📆': 'due', + '🗓': 'due', '➕': 'created', '✅': 'done', '❌': 'cancelled', '🔁': 'repeat', + '🏁': 'onCompletion', '🆔': 'id', '⛔': 'dependsOn'} +PRIORITIES = {'🔺': 'highest', '⏫': 'high', '🔼': 'medium', '🔽': 'low', '⏬': 'lowest'} +EMOJI = re.compile(r'(?:^|[ \t]+)([🛫⏳⌛📅📆🗓➕✅❌🔁🏁🆔⛔🔺⏫🔼🔽⏬])\ufe0f?') +INLINE = re.compile(r'(?:^|[ \t]+)(\[(\w+):: *([^\]\n]*)\]|\((\w+):: *([^\)\n]*)\)) *,?$') +BLOCK = re.compile(r'\s+(\^[a-zA-Z0-9-]+)$') +KEYS = {'completion': 'done', **{k: k for k in [*DATES, 'priority', 'repeat', 'onCompletion', 'id', 'dependsOn']}} + + +def mask_fences(text): + """Hide quoted/list-indented fences while retaining source line numbers and offsets.""" + lines, fence = [], None + for raw in text.splitlines(keepends=True): + content = re.sub(r'^[\t >]*', '', raw.rstrip('\r\n')) + marker = re.match(r'(`{3,}|~{3,})(.*)$', content) + hidden = fence is not None + if marker: + token, tail = marker.groups() + if fence: + if token[0] == fence[0] and len(token) >= fence[1] and not tail.strip(): + fence = None + elif token[0] != '`' or '`' not in tail: + fence = (token[0], len(token)) + hidden = True + lines.append(re.sub(r'[^\r\n]', ' ', raw) if hidden else raw) + return ''.join(lines) + + +def fields(text): + """Split description and trailing fields, preserving each field's exact spelling/order. + + Like Tasks, only a trailing metadata run is interpreted. Unknown inline fields remain + description text. Tags may occur between fields. No date/recurrence is inferred. + """ + rest, parts, values = text.rstrip(), [], {} + hardbreak = text[len(text.rstrip()):] if text.endswith(' ') else '' + block = BLOCK.search(rest) + if block: + parts.insert(0, ('block', block[0])) + values['block'] = block[1] + rest = rest[:block.start()].rstrip() + while rest: + tag = re.search(r'(?:^|[ \t]+)(#[^\s#]+)$', rest) + inline = INLINE.search(rest) + emojis = list(EMOJI.finditer(rest)) + emoji = emojis[-1] if emojis else None + if tag: + key, value, raw, start = 'tag', tag[1], rest[tag.start():], tag.start() + elif inline and (inline[2] or inline[4]) in KEYS: + key = KEYS[inline[2] or inline[4]] + value, raw, start = (inline[3] if inline[2] else inline[5]).strip(), rest[inline.start():], inline.start() + elif emoji: + symbol, value = emoji[1], rest[emoji.end():].strip() + key = 'priority' if symbol in PRIORITIES else SYMBOLS[symbol] + if key == 'priority': + if value: + break + value = PRIORITIES[symbol] + raw, start = rest[emoji.start():], emoji.start() + else: + break + if key != 'tag' and key in values: + raise ValueError(f'duplicate task field: {key}') + parts.insert(0, (key, raw)) + if key != 'tag': + values[key] = value + rest = rest[:start].rstrip() + return dict(description=rest, fields=values, parts=parts, hardbreak=hardbreak) + + +def render(parsed, description=None, source=''): + """A source link belongs to the description, before metadata (block IDs stay last).""" + text = parsed['description'] if description is None else description + return text + source + ''.join(raw if raw[:1].isspace() else ' ' + raw for _, raw in parsed['parts']) + parsed.get('hardbreak', '') + + +def validate(parsed): + for key, value in parsed['fields'].items(): + if key in DATES: + try: + if dt.date.fromisoformat(value).isoformat() != value: + raise ValueError() + except ValueError: + raise ValueError(f'{key} must be a valid YYYY-MM-DD date') from None + elif key == 'priority' and value not in PRIORITIES.values(): + raise ValueError('unrecognised task priority') + elif key == 'onCompletion' and value not in {'keep', 'delete'}: + raise ValueError('onCompletion must be keep or delete') + elif key == 'repeat' and (not value or not re.fullmatch(r'[a-zA-Z0-9, !]+', value)): + raise ValueError('invalid recurrence rule') + elif key == 'id' and not re.fullmatch(r'[a-zA-Z0-9_-]+', value): + raise ValueError('invalid task ID') + elif key == 'dependsOn' and not re.fullmatch(r'[a-zA-Z0-9_-]+(?: *, *[a-zA-Z0-9_-]+)*', value): + raise ValueError('invalid task dependencies') + + +def project_ids(text, inverse=False): + """Keep Tasks' globally indexed IDs distinct from the copied authoritative notes.""" + parsed = fields(text) + values = parsed['fields'] + for i, (key, raw) in enumerate(parsed['parts']): + if key not in {'id', 'dependsOn'}: + continue + value = values[key] + def convert(match): + token = match[0] + return token.removeprefix('fwview-') if inverse else 'fwview-' + token + projected = re.sub(r'[a-zA-Z0-9_-]+', convert, value) + start = raw.rfind(value) + parsed['parts'][i] = (key, raw[:start] + projected + raw[start + len(value):]) + return render(parsed) + + +def safe_text(text): + """Allow emoji shaping (ZWJ/variation selectors), never bidi/control or forged markers.""" + if any(unicodedata.category(c) in {'Cc', 'Cf', 'Zl', 'Zp'} and c != '\u200d' for c in text): + raise ValueError('a title cannot contain control, direction or line-break characters') + if '' in text or ' ([source](' in text: + raise ValueError('a title cannot contain comment markers or a source link') + + +def view_row(line): + body = line.rstrip('\r\n') + match = CHECKBOX.match(body) + if not match or not match[3].startswith('] '): + return None + links = list(SOURCE.finditer(match[3][1:])) + if len(links) != 1: + return None + link = links[0] + text = match[3][1:] + title = (text[:link.start()] + text[link.end():]).lstrip(' ') + marker = ROW_ID.search(link[1]) + return dict(head=match[1], status=match[2], title=title, source=link[0], + token=marker[1] if marker else link[0], ending=line[len(body):], suffix=text[link.end():]) + + +def merge_title(base, phone, current): + """Merge independent field changes; a same-field disagreement requires review.""" + a, b, c = map(fields, (base, phone, current)) + validate(b) + a_raw, b_raw = dict(a['parts']), dict(b['parts']) + changed = {k for k in a['fields'].keys() | b['fields'].keys() + if a['fields'].get(k) != b['fields'].get(k) or a_raw.get(k) != b_raw.get(k)} + if a['description'] != b['description']: + if c['description'] not in (a['description'], b['description']): + raise ValueError('description also changed on the Mac') + c['description'] = b['description'] + for key in changed: + if c['fields'].get(key) not in (a['fields'].get(key), b['fields'].get(key)): + raise ValueError(f'{key} also changed on the Mac') + # Keep untouched Mac tokens verbatim. Replace only fields edited by the phone. + seen, result = set(), [] + for key, raw in c['parts']: + seen.add(key) + if key not in changed: + result.append((key, raw)) + elif key in b_raw: + result.append((key, b_raw[key])) + result += [(key, raw) for key, raw in b['parts'] if key in changed and key not in seen] + c['parts'] = [p for p in result if p[0] != 'block'] + [p for p in result if p[0] == 'block'] + # Tags are part of the description semantically; preserve them unless the phone changed them. + tags_a = [raw for key, raw in a['parts'] if key == 'tag'] + tags_b = [raw for key, raw in b['parts'] if key == 'tag'] + if tags_a != tags_b: + tags_c = [raw for key, raw in c['parts'] if key == 'tag'] + if tags_c not in (tags_a, tags_b): + raise ValueError('tags also changed on the Mac') + c['parts'] = [(k, v) for k, v in c['parts'] if k != 'tag'] + pos = next((i for i, p in enumerate(c['parts']) if p[0] == 'block'), len(c['parts'])) + c['parts'][pos:pos] = [('tag', raw) for raw in tags_b] + if a['hardbreak'] != b['hardbreak']: + c['hardbreak'] = b['hardbreak'] + return render(c) diff --git a/_tools/icloud_bridge.py b/_tools/icloud_bridge.py index 60b0d31..bd04c87 100755 --- a/_tools/icloud_bridge.py +++ b/_tools/icloud_bridge.py @@ -3,7 +3,7 @@ # requires-python = ">=3.10" # dependencies = ["pyyaml", "tzdata"] # /// -"""Folderwise iCloud bridge: Mac-owned export and boolean task import. +"""Folderwise iCloud bridge: Mac-owned export and Obsidian task/note imports. Commands: setup, configure, plan, sync, resolve, status, enable, disable, schedule-enable, schedule-disable, schedule-prepare, doctor, logs, notify-test, vault-init, uninstall. See docs/obsidian.md before setup. @@ -27,7 +27,7 @@ import tempfile import time import unicodedata -from contextlib import contextmanager +from contextlib import contextmanager, suppress from pathlib import Path, PurePosixPath from urllib.parse import quote, unquote, urlsplit @@ -35,6 +35,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) import docslib as dl # noqa: E402 +import ibridge_tasks as taskfmt # noqa: E402 TOOLS = Path(__file__).resolve().parent LABEL = "org.folderwise.ibridge" @@ -44,7 +45,7 @@ REPORT, DASHBOARD, TASK_DIR = "_folderwise-sync-status.md", "Tasks.md", "_Folderwise Tasks" IMAGES = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif", ".heic", ".bmp"} SKIP = {"AGENTS.md", "CLAUDE.md", "CLAUDE.local.md", "_doc-template.md", "node_modules", "__pycache__", "#recycle", "credentials.json", "id_rsa", "id_ed25519"} -CHECKBOX = re.compile(r"^(\s*(?:[-*+]|\d+[.)])\s+\[)([ xX])(\].*)$") +CHECKBOX = taskfmt.CHECKBOX SOURCE_TAIL = " ([source](" MAX_TITLE = 200 KEEP_DAYS, KEEP_ENTRIES = 30, 10 @@ -249,11 +250,14 @@ def tracked(project): return sorted(files) -def markdown_tasks(text, path): +def markdown_tasks(text, path, status_types=None): """List tasks outside front matter, fenced examples and generated blocks.""" tasks, counts = [], {} + status_types = status_types or {'x': 'DONE', 'X': 'DONE'} generated = False - for line, raw in enumerate(dl.mask_for_links(text).splitlines()): + lines = text.splitlines() + masked_lines = taskfmt.mask_fences(dl.mask_for_links(text)).splitlines() + for line, raw in enumerate(masked_lines): if re.search(r"(.*?)', text, re.S) if project.name not in settings.get("adapters", {}) and any(CHECKBOX.match(line) for _, block in blocks for line in block.splitlines()): omissions.append(dict(path=project.name + '/' + rel, bytes=0, reason='generated task source requires an adapter (document is still exported)')) @@ -363,14 +394,28 @@ def inventory(settings, selected_projects=None, failed=None): total_images += size files[key] = p can_rename = cfg.get("folderwise-ibridgerename", False) - note = ("Generated task view. Checkbox toggles and title edits are imported; keep each line's trailing (source) link." - if can_rename else "Generated task view. Only checkbox toggles are imported.") + note = ("Generated task view. Checkbox toggles, statuses, metadata and title edits are imported; keep source links before metadata." + if can_rename else "Generated task view. Checkbox toggles, statuses and metadata are imported; title edits are off for this project.") + if project.name in settings.get("adapters", {}) and not any(t.get("editable") for t in local_tasks): + note = "Generated task view. Only checkbox toggles are imported for this adapter-backed project; metadata, status and title edits are rejected." projection = [f"# {project.name} tasks", "", note, ""] mapping = [] + emitted, last_path = set(), None for task in local_tasks: + if task['path'] != last_path: + projection.extend(['', '## ' + task['path'], '']) + last_path = task['path'] + for number, ancestor in task.get('ancestors', []): + if (task['path'], number) not in emitted: + projection.append(ancestor) + emitted.add((task['path'], number)) target = quote(project.name + "/" + task["path"], safe="/") mapping.append(dict(line=len(projection), id=task["id"], checked=task["checked"])) - projection.append(f"- [{'x' if task['checked'] else ' '}] {view_title(task['title'])}{SOURCE_TAIL}../{target}))") + link = f'{SOURCE_TAIL}../{target} "fw:{digest(task["id"].encode())}"))' + title = taskfmt.render(taskfmt.fields(taskfmt.project_ids(view_title(task['title']))), source=link) + projection.append(f"{task.get('head', '- [')}{task.get('status', 'x' if task['checked'] else ' ')}] {title}") + projection.extend(task.get('context', [])) + emitted.update((task['path'], task.get('line', -1) + 1 + i) for i in range(len(task.get('context', [])))) files[f"{TASK_DIR}/{project.name}.md"] = ("\n".join(projection) + "\n").encode() tasks[project.name]["_projection"] = mapping except (ValueError, OSError, yaml.YAMLError) as exc: @@ -393,8 +438,9 @@ class Rejected(ValueError): """A phone edit the Mac will not apply. A generated view can be restored once its text is saved.""" -def task_changes(base, current): +def task_changes(base, current, status_types=None): before, after = base.splitlines(keepends=True), current.splitlines(keepends=True) + status_types = status_types or {' ': 'TODO', 'x': 'DONE', 'X': 'DONE', '/': 'IN_PROGRESS', '-': 'CANCELLED'} if len(before) != len(after): raise Rejected("only checkbox on/off changes are supported (the number of lines changed)") changed = {} @@ -402,9 +448,25 @@ def task_changes(base, current): if old == new: continue a, b = CHECKBOX.match(old.rstrip("\r\n")), CHECKBOX.match(new.rstrip("\r\n")) - if not a or not b or a[1] != b[1] or a[3] != b[3] or old[len(old.rstrip('\r\n')):] != new[len(new.rstrip('\r\n')):]: + if (not a or not b or re.sub(r'\s+\[$', ' [', a[1]) != re.sub(r'\s+\[$', ' [', b[1]) or + old[len(old.rstrip('\r\n')):] != new[len(new.rstrip('\r\n')):]): raise Rejected(f"line {i + 1}: only checkbox on/off changes are supported") - changed[i] = dict(checked=b[2] != " ", title=None) + title = None + if a[3] != b[3]: + try: + prior, parsed = taskfmt.fields(a[3][1:].lstrip()), taskfmt.fields(b[3][1:].lstrip()) + taskfmt.validate(parsed) + taskfmt.safe_text(b[3]) + if prior['description'] != parsed['description']: + raise ValueError('description edits require an editable note') + except ValueError as exc: + raise Rejected(f'line {i + 1}: only checkbox or task metadata changes are supported ({exc})') from None + title = b[3][1:].lstrip() + if b[2] not in status_types: + raise Rejected('unknown task status; configure its type in the Tasks plugin first') + changed[i] = dict(checked=None if a[2] == b[2] else status_types[b[2]] == 'DONE', title=title) + if a[2] != b[2]: + changed[i]['status'] = b[2] return changed @@ -413,8 +475,10 @@ def clean_title(title): text = unicodedata.normalize("NFC", title).strip() if not text or len(text) > MAX_TITLE: raise Rejected(f"a title must be 1-{MAX_TITLE} characters") - if any(unicodedata.category(c) in ("Cc", "Cf", "Zl", "Zp") for c in text): - raise Rejected("a title cannot contain control, direction or line-break characters") + try: + taskfmt.safe_text(text) + except ValueError as exc: + raise Rejected(str(exc)) from None if "" in text or SOURCE_TAIL in text or text[0] in "=+-@" or re.match(r"\[[ xX]\]", text): raise Rejected("a title cannot contain comment markers, a source link or a checkbox, or start with = + - @") if view_title(text) != text: @@ -435,7 +499,7 @@ def split_view(line): return m[1], m[2], rest[:cut], rest[cut:], line[len(body):] -def view_changes(base, current, renames): +def legacy_view_changes(base, current, renames): """Toggles and, when allowed, title edits in a generated view; anything else is rejected.""" before, after = base.splitlines(keepends=True), current.splitlines(keepends=True) if len(before) != len(after): @@ -470,16 +534,118 @@ def view_changes(base, current, renames): return changed +def view_changes(base, current, renames, status_types=None): + try: + return _view_changes(base, current, renames, status_types) + except ValueError as exc: + raise Rejected(str(exc)) from None + + +def _view_changes(base, current, renames, status_types): + """Validate stable rows, allowing Tasks field edits and its two-row recurrence output.""" + before, after = base.splitlines(keepends=True), current.splitlines(keepends=True) + before_mask = taskfmt.mask_fences(dl.mask_for_links(base)).splitlines() + after_mask = taskfmt.mask_fences(dl.mask_for_links(current)).splitlines() + old = [(i, taskfmt.view_row(line)) for i, line in enumerate(before) if CHECKBOX.match(before_mask[i]) and taskfmt.view_row(line)] + if not old or any(' "fw:' not in row['source'] for _, row in old): + return legacy_view_changes(base, current, renames) + new_rows = [(i, taskfmt.view_row(line)) for i, line in enumerate(after) if CHECKBOX.match(after_mask[i]) and taskfmt.view_row(line)] + new = [row for _, row in new_rows] + status_types = status_types or {' ': 'TODO', 'x': 'DONE', 'X': 'DONE', '/': 'IN_PROGRESS', '-': 'CANCELLED'} + old_lines, new_lines = {i for i, _ in old}, {i for i, _ in new_rows} + for _, row in new_rows: + if taskfmt.fields(row['suffix'])['description']: + raise Rejected('keep the checkbox and the trailing source link before task metadata; description text belongs before the link') + if len(before) == len(after): + for line, _ in old: + checkbox = CHECKBOX.match(after_mask[line]) + if checkbox and checkbox[3].startswith('] ') and not taskfmt.view_row(after[line]): + raise Rejected(f'line {line + 1}: task source link is missing or malformed; restore the complete original source link') + known_tokens = {row['token'] for _, row in old} + if any(row['token'] not in known_tokens for row in new): + raise Rejected('source link identity changed; restore the complete original source link') + if [line for i, line in enumerate(before) if i not in old_lines] != [line for i, line in enumerate(after) if i not in new_lines]: + raise Rejected('only checkbox or task field edits are supported; keep the checkbox and source link (the number of lines changed or task context was edited)') + tokens = [row['token'] for row in new] + collapsed = [token for i, token in enumerate(tokens) if i == 0 or tokens[i - 1] != token] + deletable = {row['token'] for _, row in old if taskfmt.fields(row['title'])['fields'].get('onCompletion') == 'delete'} + expected = [row['token'] for _, row in old if row['token'] in tokens or row['token'] not in deletable] + if collapsed != expected: + raise Rejected('reordering, duplicating or deleting tasks is not supported; keep each task source link') + known = [taskfmt.fields(row['title'])['description'] for _, row in old] + changed, fresh, offset = {}, set(), 0 + for line, a in old: + group = [] + while offset < len(new) and new[offset]['token'] == a['token']: + group.append(new[offset]) + offset += 1 + if not group: + changed[line] = dict(checked=None, title=None, delete=True) + continue + if len(group) > 2: + raise Rejected('a recurrence may create exactly one next occurrence') + for b in group: + if (re.sub(r'\s+\[$', ' [', a['head']), a['source'], a['ending']) != (re.sub(r'\s+\[$', ' [', b['head']), b['source'], b['ending']): + raise Rejected('keep task indentation, checkbox and source link unchanged') + a_fields = taskfmt.fields(a['title']) + if len(group) == 2: + if not a_fields['fields'].get('repeat') or status_types.get(a['status']) == 'DONE': + raise Rejected('only a recurring task may create a next occurrence') + completed = [row for row in group if status_types.get(row['status']) == 'DONE'] + if len(completed) != 1: + raise Rejected('a recurrence must contain one completed and one open occurrence') + b = completed[0] + completed_fields = taskfmt.fields(b['title']) + for row in group: + parsed = taskfmt.fields(row['title']) + if (parsed['description'] != completed_fields['description'] or not parsed['fields'].get('repeat') or + parsed['fields'].get('repeat') != completed_fields['fields'].get('repeat')): + raise Rejected('recurrence rows must share a description and repeat rule') + else: + b = group[0] + if a == b and len(group) == 1: + continue + try: + if b['status'] not in status_types: + raise ValueError('unknown task status; configure its type in the Tasks plugin first') + parsed = taskfmt.fields(b['title']) + taskfmt.validate(parsed) + taskfmt.safe_text(b['title']) + for row in group: + if row['status'] not in status_types: + raise ValueError('unknown task status; configure its type in the Tasks plugin first') + taskfmt.validate(taskfmt.fields(row['title'])) + taskfmt.safe_text(row['title']) + except ValueError as exc: + raise Rejected(f'line {line + 1}: {exc}') from None + description_changed = parsed['description'] != a_fields['description'] + if description_changed: + if not renames: + raise Rejected(f'line {line + 1}: title edits are off for this project') + clean_title(parsed['description']) + if parsed['description'] in known or parsed['description'] in fresh: + raise Rejected('that title is already used by another task; reordering or duplicating tasks is not supported') + fresh.add(parsed['description']) + change = dict(checked=None if a['status'] == b['status'] else status_types[b['status']] == 'DONE', + title=None if a['title'] == b['title'] else b['title']) + if a['status'] != b['status']: + change['status'] = b['status'] + if len(group) == 2: + change['recurrence'] = [dict(title=row['title'], status=row['status']) for row in group] + changed[line] = change + return changed + + def merge_edits(target, staged): """Merge one file's task edits into the project's; two files may not disagree about a task.""" merged = {key: dict(value) for key, value in target.items()} for key, change in staged.items(): prior = merged.get(key) if prior: - for field in ("checked", "title"): - if prior[field] is not None and change[field] is not None and prior[field] != change[field]: + for field in prior.keys() | change.keys(): + if prior.get(field) is not None and change.get(field) is not None and prior[field] != change[field]: raise Rejected("conflicting edits of the same task") - change = {field: prior[field] if change[field] is None else change[field] for field in prior} + change = {field: prior.get(field) if change.get(field) is None else change[field] for field in prior.keys() | change.keys()} merged[key] = change return merged @@ -530,7 +696,7 @@ def settled(quiet, key, fingerprint): return quiet["now"] - seen["since"] >= quiet["settle"] -def requests(vault, state, renames=frozenset(), failures=None, editors=None, quiet=None): +def requests(vault, state, renames=frozenset(), failures=None, editors=None, quiet=None, status_types=None): """Collect phone edits: (task edits per project, files each project's edits came from, vault hashes accepted as valid, generated views rejected with the reason, note text edits per project, notes still waiting out the quiet period). @@ -543,28 +709,44 @@ def requests(vault, state, renames=frozenset(), failures=None, editors=None, qui p = safe(vault, key) if not p.is_file() or "text" not in entry: continue - data = p.read_text() + raw = p.read_bytes() + data = raw.decode().replace('\r\n', '\n') + fingerprint = digest(raw) if data == entry["text"]: continue try: - delta = view_changes(entry["text"], data, project in renames) if view else task_changes(entry["text"], data) + delta = view_changes(entry["text"], data, project in renames, status_types) if view else task_changes(entry["text"], data, status_types) except Rejected: - if view or not editors or not editable(editors, key): + if view: + if quiet is not None and quiet['settle'] and not settled(quiet, key, fingerprint): + waiting.add(key) + continue + raise + if not editors or not editable(editors, key): raise text = note_edit(entry["text"], data) - if quiet is not None and not settled(quiet, key, content_hash(p)): + if quiet is not None and not settled(quiet, key, fingerprint): waiting.add(key) continue edits.setdefault(project, {})[key.split("/", 1)[1]] = text else: + textual = any(change.get('title') is not None or change.get('recurrence') or change.get('delete') for change in delta.values()) + if textual and quiet is not None and quiet['settle'] and not settled(quiet, key, fingerprint): + waiting.add(key) + continue mapping = {t["line"]: t for t in entry.get("tasks", [])} staged = {} for line, change in delta.items(): if line not in mapping: raise Rejected("checkbox has no authoritative task mapping") + if view and change.get('title') is not None: + change['title'] = taskfmt.project_ids(change['title'], inverse=True) + if view and change.get('recurrence'): + for row in change['recurrence']: + row['title'] = taskfmt.project_ids(row['title'], inverse=True) staged[mapping[line]["id"]] = change wanted[project] = merge_edits(wanted.get(project, {}), staged) - accepted_hash = content_hash(p) + accepted_hash = fingerprint except Rejected as exc: if view: rejected[key] = str(exc) @@ -574,8 +756,8 @@ def requests(vault, state, renames=frozenset(), failures=None, editors=None, qui failures.setdefault(project, f"{key}: {exc}") continue except (ValueError, OSError) as exc: - if failures is None or project is None: - raise + if failures is None or project is None or transient(exc): + raise # a brief iCloud lock defers the whole run instead of holding the project failures.setdefault(project, f"{key}: {exc}") continue sources.setdefault(project, set()).add(key) @@ -583,14 +765,20 @@ def requests(vault, state, renames=frozenset(), failures=None, editors=None, qui return wanted, sources, accepted, rejected, edits, waiting -def rewrite_task(line, checked, title): +def rewrite_task(line, checked, title, status=None): """Change one task line's checkbox and/or title, keeping its bullet, spacing and line ending.""" body = line.rstrip("\r\n") m = CHECKBOX.match(body) rest = m[3][1:] if title is not None: - rest = rest[:len(rest) - len(rest.lstrip())] + title + rest[len(rest.rstrip()):] - mark = m[2] if checked is None else ("x" if checked else " ") + trailing = title[len(title.rstrip()):] + rest = rest[:len(rest) - len(rest.lstrip())] + title.rstrip() + trailing + mark = status if status is not None else m[2] if checked is None else ("x" if checked else " ") + if checked is False or mark == ' ': + parsed = taskfmt.fields(rest.lstrip()) + if 'done' in parsed['fields']: + parsed['parts'] = [(key, raw) for key, raw in parsed['parts'] if key != 'done'] + rest = rest[:len(rest) - len(rest.lstrip())] + taskfmt.render(parsed) return m[1] + mark + "]" + rest + line[len(body):] @@ -601,24 +789,35 @@ def plan_import(name, wanted, baseline, current): if key not in current or key not in baseline: raise Rejected(f"{name}: task changed or disappeared on Mac; resolve pending edit") task, old = current[key], baseline[key] + status = want.get('status') + if status is not None and task.get('status') not in {old.get('status'), status, None}: + raise Rejected(f'{name}: task status also changed on the Mac') if task.get("duplicate") and task["source_hash"] != old.get("source_hash"): raise Rejected(f"{name}: duplicate task moved during concurrent edit") - if want["checked"] is not None and task["checked"] != want["checked"]: + if want["checked"] is not None and (task["checked"] != want["checked"] or want.get('status') != task.get('status')): toggles[key] = want["checked"] title = want["title"] if title is not None and title != task["title"]: reason = None - if task["title"] != old["title"]: - reason = "its title also changed on the Mac" - elif task.get("duplicate"): + parsed = taskfmt.fields(title) + original_description = taskfmt.fields(old['title'])['description'] + if parsed['description'] == view_title(original_description): + title = taskfmt.render(parsed, description=original_description) + desc_changed = taskfmt.fields(title)['description'] != taskfmt.fields(old['title'])['description'] + if task.get("duplicate") and desc_changed: reason = "other tasks share its title" - elif not task["title"] or view_title(task["title"]) != task["title"]: + elif desc_changed and (not task["title"] or view_title(task["title"]) != task["title"]): reason = "its title is empty or contains links or images" - elif key.startswith("adapter:") and "title" not in task.get("editable", []): + elif desc_changed and key.startswith("adapter:") and "title" not in task.get("editable", []): reason = "its adapter does not allow title edits" if reason: raise Rejected(f"{name}: a task cannot be renamed from the phone ({reason}); edit it on the Mac") - renames[key] = title + try: + merged = taskfmt.merge_title(old['title'], title, task['title']) + if merged != task['title']: + renames[key] = merged + except ValueError as exc: + raise Rejected(f'{name}: task title or metadata conflict: {exc}') from None return toggles, renames @@ -656,27 +855,71 @@ def project_transaction(project, home): git(project, "merge", "--ff-only", "--quiet", "FETCH_HEAD") finally: if clone.exists(): - shutil.rmtree(clone) + # Python 3.10 can race uv removing its own temporary lock file. A missing + # cleanup entry must not hide the actual transaction/phone-conflict error. + with suppress(FileNotFoundError): + shutil.rmtree(clone) -def import_project(settings, project, wanted, baseline, current, home, edits=None): +def import_project(settings, project, wanted, baseline, current, home, edits=None, phone_guard=None): edits = edits or {} + wanted = dict(wanted) + # A crash may publish the local commit before publishing its export/state journal. + # Recognise the exact already-created pair; never execute recurrence creation twice. + for key, change in list(wanted.items()): + rows = change.get('recurrence') + if not rows or key not in baseline: + continue + path = baseline[key]['path'] + matches = [] + for row in rows: + found = [k for k, task in current.items() if k != '_projection' and task['path'] == path and + task['title'] == row['title'] and task.get('status', 'x' if task['checked'] else ' ') == row['status']] + if len(found) == 1: + matches.append(found[0]) + if len(matches) == len(rows) and any(k not in baseline for k in matches): + del wanted[key] toggles, renames = plan_import(project.name, wanted, baseline, current) - if not toggles and not renames and not edits: + recurrences = {key: want['recurrence'] for key, want in wanted.items() if want.get('recurrence')} + deletions = {key for key, want in wanted.items() if want.get('delete')} + title_count = sum(taskfmt.fields(value)['description'] != taskfmt.fields(current[key]['title'])['description'] + for key, value in renames.items()) + field_count = len(renames) - title_count + if not toggles and not renames and not edits and not recurrences and not deletions: return None - touched = {current[key]["path"] for key in toggles.keys() | renames.keys() if not key.startswith("adapter:")} + touched = {current[key]["path"] for key in toggles.keys() | renames.keys() | recurrences.keys() | deletions if not key.startswith("adapter:")} if touched & edits.keys(): raise ValueError(f"{project.name}: a note was edited on the phone and one of its tasks was changed in the same run; " "resolve one of them first") with project_transaction(project, home) as clone: md, ac, originals = {}, [], {} - for key in sorted(toggles.keys() | renames.keys()): + for key in sorted(toggles.keys() | renames.keys() | recurrences.keys() | deletions): task = current[key] checked, title = toggles.get(key), renames.get(key) if key.startswith("adapter:"): item = dict(id=key[len("adapter:"):], checked=task["checked"] if checked is None else checked) if title is not None: item.update(title=title, expected_title=task["title"]) + old_fields, new_fields = taskfmt.fields(task['title']), taskfmt.fields(title) + fields = {field: new_fields['fields'].get(field) for field in old_fields['fields'].keys() | new_fields['fields'].keys() + if old_fields['fields'].get(field) != new_fields['fields'].get(field)} + if fields: + if not fields.keys() <= set(task.get('editable', [])): + raise Rejected('adapter does not allow these task fields: ' + ', '.join(sorted(fields.keys() - set(task.get('editable', []))))) + item.update(fields=fields, expected_fields=old_fields['fields']) + status = wanted[key].get('status') + if status is not None and status not in ' xX': + if 'status' not in task.get('editable', []): + raise Rejected('adapter does not allow custom status edits') + item['status'] = status + if key in recurrences: + if 'recurrence' not in task.get('editable', []): + raise Rejected('adapter does not allow recurrence creation') + item['recurrence'] = recurrences[key] + if key in deletions: + if 'delete' not in task.get('editable', []): + raise Rejected('adapter does not allow on-completion deletion') + item.update(delete=True, expected_title=task['title']) ac.append(item) else: rel = task["path"] @@ -684,15 +927,28 @@ def import_project(settings, project, wanted, baseline, current, home, edits=Non raise ValueError("source changed while preparing task transaction") original = originals.setdefault(rel, safe(clone, rel).read_bytes().decode()) lines = md.setdefault(rel, original.splitlines(keepends=True)) - lines[task["line"]] = rewrite_task(lines[task["line"]], checked, title) + if key in deletions: + if task['source_hash'] != baseline[key]['source_hash']: + raise Rejected('task source changed on the Mac; review before deleting its completed line') + lines[task['line']] = '' + elif key in recurrences: + if task['source_hash'] != baseline[key]['source_hash']: + raise Rejected('recurring task source changed on the Mac; review before creating its next occurrence') + parts = [rewrite_task(lines[task['line']], None, row['title'], row['status']) for row in recurrences[key]] + lines[task['line']] = ''.join(parts) if lines[task['line']].endswith('\n') else '\n'.join(parts) + else: + lines[task["line"]] = rewrite_task(lines[task["line"]], checked, title, wanted[key].get('status')) for rel, lines in md.items(): renamed = {key: renames[key] for key in renames if current[key].get("path") == rel and not key.startswith("adapter:")} before = markdown_tasks(originals[rel], rel) after = markdown_tasks("".join(lines), rel) ids = {t["id"] for t in before} - # A rename must replace exactly the renamed tasks and nothing else in the note. - if (len(after) != len(before) or ids - {t["id"] for t in after} != set(renamed) - or sorted(t["title"] for t in after if t["id"] not in ids) != sorted(renamed.values())): + actual_renames = {key for key, value in renamed.items() + if taskfmt.fields(value)['description'] != taskfmt.fields(current[key]['title'])['description']} + additions = sum(1 for key in recurrences if current[key]['path'] == rel) + removed = {key for key in deletions if current[key]['path'] == rel} + if (len(after) != len(before) + additions - len(removed) or + (not additions and ids - {t["id"] for t in after} != actual_renames | removed)): raise ValueError(f"{project.name}/{rel}: rename would change more than the requested tasks") safe(clone, rel).write_bytes("".join(lines).encode()) for rel, text in sorted(edits.items()): @@ -702,6 +958,8 @@ def import_project(settings, project, wanted, baseline, current, home, edits=Non adapter(settings, clone, "apply", ac) config = dl.parse_config((clone / dl.CONFIG_NAME).read_text()) parts = ["Toggle and rename tasks" if toggles and renames else "Rename tasks" if renames else "Toggle tasks"] if toggles or renames else [] + if field_count or recurrences or deletions: + parts = ['Update task fields and occurrences'] if edits: parts.append("edit notes" if parts else "Edit notes") summary = " and ".join(parts) + " via Obsidian iCloud bridge" @@ -709,7 +967,8 @@ def import_project(settings, project, wanted, baseline, current, home, edits=Non if rel.endswith(".md"): p = safe(clone, rel) raw = p.read_bytes().decode() - text, errors, _ = dl.stamp_text(raw.replace("\r\n", "\n"), p.name, config, "human", summary) + text, errors, _ = dl.stamp_text(raw.replace("\r\n", "\n"), p.name, config, "human", + summary + f' (import {digest(raw.encode())[:12]})') if errors: raise ValueError("; ".join(errors)) p.write_bytes((text.replace("\n", "\r\n") if "\r\n" in raw else text).encode()) @@ -718,29 +977,46 @@ def import_project(settings, project, wanted, baseline, current, home, edits=Non actual = {t["id"]: t for t in adapter(settings, clone, "list")} for item in ac: got = actual.get("adapter:" + item["id"]) + if item.get('delete'): + if got is not None: + raise ValueError('adapter did not delete the completed task') + continue if got is None or got["checked"] != item["checked"]: raise ValueError("adapter did not apply requested checkbox states") if "title" in item and unicodedata.normalize("NFC", got["title"]).strip() != item["title"]: raise ValueError("adapter did not apply the requested title") + if 'status' in item and got.get('status') != item['status']: + raise ValueError('adapter did not apply the requested status') + if 'recurrence' in item: + next_rows = [t for t in actual.values() if t['id'] != 'adapter:' + item['id'] and + any(t['title'] == r['title'] for r in item['recurrence'] if r['status'] not in 'xX')] + if len(next_rows) != 1: + raise ValueError('adapter did not create exactly one next occurrence') run(["uv", "run", "--quiet", TOOLS / "docs_lint.py"], clone) git(clone, "add", "--all") if edits: subject = "Update tasks and notes from Obsidian" if toggles or renames else "Update notes from Obsidian" - body = (f"Apply {len(toggles)} user checkbox toggle(s), {len(renames)} user title edit(s) and {len(edits)} user note edit(s) " + body = (f"Apply {len(toggles)} user checkbox toggle(s), {title_count} user title edit(s) and {len(edits)} user note edit(s) " "and regenerate authoritative views.\n\nCheckbox state, task titles and note text are user-typed; " "no factual verification was performed.\n") elif renames: subject = "Update tasks from Obsidian" - body = (f"Apply {len(toggles)} user checkbox toggle(s) and {len(renames)} user title edit(s) and regenerate authoritative views.\n\n" + body = (f"Apply {len(toggles)} user checkbox toggle(s) and {title_count} user title edit(s) and regenerate authoritative views.\n\n" "Checkbox state and task titles are user-typed; no factual verification was performed.\n") else: subject = "Update task completion from Obsidian" body = (f"Apply {len(toggles)} user checkbox toggle(s) and regenerate authoritative views.\n\n" "Checkbox state is user-reported; no factual verification was performed.\n") + if field_count or recurrences or deletions: + body += f'Metadata updates: {field_count}; recurrences: {len(recurrences)}; completed-line deletions: {len(deletions)}.\n' git(clone, "commit", "--quiet", "-m", subject, "-m", body + f"Validation: project adapter, Folderwise lint and Git hooks.\n\nBridge: folderwise-ibridge\n" - f"Bridge-Ops: toggle={len(toggles)} rename={len(renames)}" + (f" text-edit={len(edits)}" if edits else "")) - return dict(commit=git(project, "rev-parse", "--short", "HEAD").strip(), toggled=len(toggles), renamed=len(renames), edited=len(edits)) + f"Bridge-Ops: toggle={len(toggles)} rename={title_count}" + (f" text-edit={len(edits)}" if edits else "") + + (f" metadata={field_count} recur={len(recurrences)} delete={len(deletions)}" if field_count or recurrences or deletions else "")) + if phone_guard is not None: + phone_guard() + return dict(commit=git(project, "rev-parse", "--short", "HEAD").strip(), toggled=len(toggles), renamed=title_count, + edited=len(edits), metadata=field_count, recurred=len(recurrences), deleted=len(deletions)) def stash_phone_text(home, key, path): @@ -817,9 +1093,14 @@ def resolve(settings, home, key=None): return 0 +def transient(exc): + """A brief operating-system lock, typically iCloud syncing a file.""" + return isinstance(exc, OSError) and exc.errno in TRANSIENT + + def deferrable(home, exc): """True, and recorded, for a brief iCloud file lock; the third consecutive failure is reported.""" - if not (isinstance(exc, OSError) and exc.errno in TRANSIENT): + if not transient(exc): return False last = read_json(home / "last-run.json", {}) if last.get("deferred", 0) >= 2: @@ -940,8 +1221,8 @@ def report(files, omissions, issues, settings, managed_bytes, ledger=None, copie rows += ["", "## Phone edits waiting", "", "These notes are imported once their text has stayed unchanged for the quiet period.", ""] rows += [f"- {cell(w)}" for w in waiting] if ledger.get("imports"): - rows += ["", "## Recent imports", "", "| When | Project | Commit | Toggled | Renamed | Edited |", "| --- | --- | --- | ---: | ---: | ---: |"] - rows += [f"| {e['at']} | {cell(e['project'])} | {e['commit']} | {e['toggled']} | {e['renamed']} | {e.get('edited', 0)} |" for e in ledger["imports"]] + rows += ["", "## Recent imports", "", "| When | Project | Commit | Toggled | Renamed | Edited | Fields | Recurred | Deleted |", "| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |"] + rows += [f"| {e['at']} | {cell(e['project'])} | {e['commit']} | {e['toggled']} | {e['renamed']} | {e.get('edited', 0)} | {e.get('metadata', 0)} | {e.get('recurred', 0)} | {e.get('deleted', 0)} |" for e in ledger["imports"]] if copies: rows += ["", "## iCloud conflict copies", "", "These files were not imported. Compare each with its original and delete the one you do not want.", ""] rows += [f"- {cell(c)}" for c in copies] @@ -1012,7 +1293,7 @@ def sync(settings, home, dry=False): issues, notices, held, run_imports, preview = [], [], {}, [], [] files, tasks, omissions = {}, {}, [] ledger = read_json(home / 'ledger.json', {'imports': [], 'rejected': []}) - accepted, restored, edits, waiting = {}, set(), {}, set() + accepted, restored, edits, waiting, settling = {}, set(), {}, set(), set() quiet = dict(pending=copy.deepcopy(read_json(home / 'pending.json', {})), now=time.time(), keep=set(), settle=settings.get('edit_settle_seconds', EDIT_SETTLE_SECONDS)) @@ -1031,18 +1312,35 @@ def reject(key, reason): state = read_json(home / 'state.json', {'files': {}, 'tasks': {}}) active = projects(settings, failed=None if dry else held) files, tasks, omissions = inventory(settings, selected_projects=active, failed=None if dry else held) + # Upgrade schema-2 text-derived identities only when the exact source snapshot agrees. + for name, previous in state.get('tasks', {}).items(): + replacements = {} + for key, old_task in list(previous.items()): + if not key.startswith('md:') or key in tasks.get(name, {}): + continue + candidates = [t for k, t in tasks.get(name, {}).items() if k.startswith('md:') and + all(t.get(field) == old_task.get(field) for field in ('path', 'line', 'source_hash'))] + if len(candidates) == 1: + replacements[key] = candidates[0]['id'] + for old_key, new_key in replacements.items(): + previous[new_key] = previous.pop(old_key) + for entry in state['files'].values(): + if entry.get('project') == name: + for task in entry.get('tasks', []): + task['id'] = replacements.get(task['id'], task['id']) snapshot = {} for key in state['files'].keys() | files.keys(): try: snapshot[key] = existing_hash(safe(vault, key)) except (ValueError, OSError) as exc: name = owner(key) - if name is None: + if name is None or transient(exc): raise held[name] = f"{key}: {exc}" renames = {p.name for p, cfg in active.items() if cfg.get("folderwise-ibridgerename", False)} editors = edit_projects(settings, active) - pending, sources, accepted, rejected, edits, waiting = requests(vault, state, renames, held, editors, quiet) + pending, sources, accepted, rejected, edits, waiting = requests(vault, state, renames, held, editors, quiet, tasks_compatibility(settings)) + settling = {owner(key) for key in waiting if key.startswith(TASK_DIR + '/')} for name in list(edits): for rel in edits[name]: key = name + '/' + rel @@ -1066,17 +1364,27 @@ def reject(key, reason): except Rejected as exc: preview.append(f'PHONE EDIT REJECTED {name}: {exc}; the text would be saved and the view restored') else: - if toggles or renames: - preview.append(f'PHONE EDIT {name}: would toggle {len(toggles)} and rename {len(renames)} task(s)') + if toggles or renames or any(w.get('delete') or w.get('recurrence') for w in wanted.values()): + title_count = sum(taskfmt.fields(value)['description'] != taskfmt.fields(tasks[name][key]['title'])['description'] + for key, value in renames.items()) + recurrence_count = sum(bool(w.get('recurrence')) for w in wanted.values()) + delete_count = sum(bool(w.get('delete')) for w in wanted.values()) + preview.append(f'PHONE EDIT {name}: would toggle {len(toggles)} and rename {title_count} task(s); ' + f'fields={len(renames) - title_count} recurrence={recurrence_count} delete={delete_count}') else: if pending.keys() - {p.name for p in active} - held.keys(): raise ValueError('disabled project has pending phone edits; re-enable and import before disabling') for project in active: - if (project.name not in pending and project.name not in edits) or project.name in held: + if (project.name not in pending and project.name not in edits) or project.name in held or project.name in settling: continue try: + def phone_guard(): + for key in sources.get(project.name, set()): + if existing_hash(safe(vault, key)) != accepted[key] or accepted[key] != snapshot.get(key): + raise ValueError(f'{key}: vault changed during import; phone text preserved for next run') + phone_guard() done = import_project(settings, project, pending.get(project.name, {}), state['tasks'].get(project.name, {}), - tasks[project.name], home, edits.get(project.name)) + tasks[project.name], home, edits.get(project.name), phone_guard) except Rejected as exc: origin = sources.get(project.name, set()) if origin and all(k.startswith(TASK_DIR + '/') for k in origin): @@ -1126,7 +1434,7 @@ def reject(key, reason): if key in waiting and key in state['files']: next_files[key] = state['files'][key] continue - if name in held: + if name in held or name in settling: continue try: target = safe(vault, key) @@ -1157,7 +1465,7 @@ def reject(key, reason): held[name] = str(exc) for key, old in state['files'].items(): name = owner(key) - if key in files or name in held: + if key in files or name in held or name in settling: continue try: p = safe(vault, key) @@ -1169,15 +1477,15 @@ def reject(key, reason): if name is None: raise held[name] = str(exc) - for key in [k for k in list(next_files) + list(removes) if owner(k) in held]: + for key in [k for k in list(next_files) + list(removes) if owner(k) in held or owner(k) in settling]: next_files.pop(key, None) writes.pop(key, None) removes.pop(key, None) for key, old in state['files'].items(): - if owner(key) in held: + if owner(key) in held or owner(key) in settling: next_files[key] = old - kept = {name: state['tasks'][name] for name in held if name in state['tasks']} - save(home / 'export.json', dict(writes=writes, removes=removes, state={'files': next_files, 'tasks': {**tasks, **kept}, 'schema': 2})) + kept = {name: state['tasks'][name] for name in held.keys() | settling if name in state['tasks']} + save(home / 'export.json', dict(writes=writes, removes=removes, state={'files': next_files, 'tasks': {**tasks, **kept}, 'schema': 3})) recover_export(home, vault) except (ValueError, OSError) as exc: if deferrable(home, exc): @@ -1222,7 +1530,7 @@ def reject(key, reason): if mode != 'off' and (issues or actionable or notices) and previous.get('issue_signature') != signature: last['notification_sent'] = notify('Folderwise bridge needs attention. See _folderwise-sync-status.md in your vault.', home) elif mode == 'imports' and run_imports: - changes = sum(item['toggled'] + item['renamed'] + item.get('edited', 0) for item in run_imports) + changes = sum(sum(item.get(field, 0) for field in ('toggled', 'renamed', 'edited', 'metadata', 'recurred', 'deleted')) for item in run_imports) last['notification_sent'] = notify(f'Folderwise applied {changes} phone change(s) in {len(run_imports)} project(s). ' 'See _folderwise-sync-status.md in your vault.', home) last['issue_signature'] = signature @@ -1336,6 +1644,43 @@ def seed_vaults(settings, home): save(home / 'vault-defaults.json', manifest) +def tasks_compatibility(settings, diagnose=False): + """Read the phone vault's settings without changing user choices or installing plugins.""" + root = Path(settings['vault']) + data = read_json(safe(root, '.obsidian/plugins/obsidian-tasks-plugin/data.json'), {}) + if not isinstance(data, dict): + raise ValueError('Tasks settings must be a JSON object') + if data.get('taskFormat', 'tasksPluginEmoji') not in {'tasksPluginEmoji', 'dataview'}: + raise ValueError('unsupported Tasks taskFormat; use tasksPluginEmoji or dataview') + statuses = {' ': 'TODO', 'x': 'DONE', 'X': 'DONE', '/': 'IN_PROGRESS', '-': 'CANCELLED'} + registry = data.get('statusSettings', {}) + if not isinstance(registry, dict): + raise ValueError('Tasks statusSettings must be an object') + for entries in ('coreStatuses', 'customStatuses'): + values = registry.get(entries, []) + if not isinstance(values, list): + raise ValueError('Tasks status entries must be lists') + for entry in values: + if (not isinstance(entry, dict) or not isinstance(entry.get('symbol'), str) or len(entry['symbol']) != 1 or + unicodedata.category(entry['symbol']).startswith('C') or entry.get('type') not in {'TODO', 'DONE', 'IN_PROGRESS', 'CANCELLED', 'NON_TASK'}): + raise ValueError('invalid Tasks status configuration') + statuses[entry['symbol']] = entry['type'] + if diagnose: + manifest = read_json(safe(root, '.obsidian/plugins/obsidian-tasks-plugin/manifest.json'), {}) + plugins = read_json(safe(root, '.obsidian/community-plugins.json'), []) + print('Tasks version: ' + str(manifest.get('version', 'not installed; install and enable Tasks on each device'))) + print('Compatibility fixtures: Tasks 8.6.0; verify other versions with the iPhone acceptance checklist') + print('Task format: ' + data.get('taskFormat', 'tasksPluginEmoji') + '; automatic lifecycle dates supported') + if 'obsidian-tasks-plugin' not in plugins: + print('WARNING Tasks is not enabled in this vault; the generated Tasks dashboard requires it') + if data.get('globalFilter'): + print('WARNING non-empty Tasks globalFilter may hide tasks; clear it to show the complete dashboard') + if manifest.get('version') and manifest['version'] != '8.6.0': + print('WARNING installed Tasks version differs from the compatibility fixtures') + print('Custom status types: ' + ', '.join(repr(k) + '=' + v for k, v in statuses.items())) + return statuses + + def enroll_project(project, home): """Enroll through the same validated transaction used for task imports.""" with project_transaction(project, home) as clone: @@ -1535,7 +1880,15 @@ def main(): print(message) print(NOTIFICATION_VISIBILITY) elif args.command == "doctor": - files, _, omissions = inventory(settings) + tasks_compatibility(settings, diagnose=True) + files, task_sets, omissions = inventory(settings) + plugin = read_json(safe(Path(settings['vault']), '.obsidian/plugins/obsidian-tasks-plugin/data.json'), {}) + for group in task_sets.values(): + for key, task in group.items(): + if key.startswith('adapter:'): + for option, field in (('setDoneDate', 'done'), ('setCancelledDate', 'cancelled'), ('setCreatedDate', 'created')): + if plugin.get(option, option != 'setCreatedDate') and field not in task.get('editable', []): + raise ValueError(f'adapter lacks {field} capability but Tasks {option} is enabled; disable that option or update the adapter') for key in files: safe(Path(settings["vault"]), key) if not shutil.which('uv'): diff --git a/_tools/selftest.py b/_tools/selftest.py index 36f7ec3..2efe3c8 100755 --- a/_tools/selftest.py +++ b/_tools/selftest.py @@ -1125,6 +1125,11 @@ def s_ibridge(): sandbox_env = {} def invoke(*args, env=None): + # Legacy fixtures exercise immediate transactions; settling has its own mobile tests. + if args and args[0] == 'sync' and conf.exists(): + cfg = json.loads(conf.read_text()) + cfg['edit_settle_seconds'] = 0 + conf.write_text(json.dumps(cfg)) return tool('icloud_bridge.py', '--config', conf, *args, env={**sandbox_env, **(env or {})}) def local(name, *args, cwd=None): @@ -1235,7 +1240,7 @@ def local(name, *args, cwd=None): and not any(p.stat().st_mode & 0o077 for p in (home / 'rejected').rglob('*')), out) status_text = (vault / '_folderwise-sync-status.md').read_text() check('rejected phone edit is named in the status report', '## Phone edits not applied' in status_text and '_Folderwise Tasks/alpha.md' in status_text - and 'line 5' in status_text and 'only checkbox on/off changes are supported' in status_text, status_text) + and 'title edits are off' in status_text, status_text) rc, out = invoke('sync') check('a restored view is not reported again', rc == 0 and 'NOTICE' not in out, out) # Adapter lives in project, uses a CSV, and hides completed rows in normal Markdown. @@ -1269,6 +1274,8 @@ def local(name, *args, cwd=None): cfg = json.loads(conf.read_text());cfg['adapters']={'alpha':[sys.executable,'scripts/adapter.py']};conf.write_text(json.dumps(cfg)) invoke('sync') check('generated checkbox appears once in dashboard projection', view.read_text().count('CSV action') == 1) + check('a boolean-only adapter view does not promise metadata or title edits', 'Only checkbox toggles are imported for this adapter-backed project' in view.read_text() + and 'statuses and metadata are imported' not in view.read_text()) view.write_text(view.read_text().replace('- [ ] CSV action', '- [x] CSV action')) rc, out = invoke('sync') check('CSV task toggle updates authoritative row and regenerates Markdown', rc == 0 and 'item-1,CSV action,true' in (alpha / 'data/tasks.csv').read_text() @@ -1588,6 +1595,11 @@ def s_ibridge_rename(): conf = home / 'config.json' def invoke(*args): + # Legacy fixtures exercise immediate transactions; settling has its own mobile tests. + if args and args[0] == 'sync' and conf.exists(): + cfg = json.loads(conf.read_text()) + cfg['edit_settle_seconds'] = 0 + conf.write_text(json.dumps(cfg)) return tool('icloud_bridge.py', '--config', conf, *args) def local(name, *args, cwd=None): @@ -1635,8 +1647,8 @@ def adapter_script(editable, honor_title): # Renames are opt-in: the default keeps today's checkbox-only behavior. view = vault / '_Folderwise Tasks/alpha.md' invoke('sync') - check('rename sandbox exports a schema-2 state', json.loads((home / 'state.json').read_text()).get('schema') == 2) - check('views of projects without the setting say only toggles are imported', 'Only checkbox toggles are imported' in view.read_text()) + check('rename sandbox exports a schema-3 state', json.loads((home / 'state.json').read_text()).get('schema') == 3) + check('views explain metadata support and the separate title-edit opt-in', 'metadata are imported; title edits are off' in view.read_text()) view.write_text(view.read_text().replace('- [ ] Alpha one', '- [ ] Alpha renamed')) head = sh(['git', 'rev-parse', 'HEAD'], cwd=alpha)[1] rc, out = invoke('sync') @@ -1688,11 +1700,15 @@ def swap(text): rejected('a title cannot embed links', lambda s: s.replace('Beta two', 'Beta [x](http://example.com)'), 'links or images') rejected('an over-long title is rejected', lambda s: s.replace('Beta two', 'B' * 201), 'characters') rejected('invisible direction characters are rejected', lambda s: s.replace('Beta two', 'Beta \u202etwo'), 'direction') - rejected('removing the source link is rejected', lambda s: s.replace('Beta two ([source](../alpha/docs/checklist.md))', 'Beta two'), 'trailing source link') - rejected('a due date appended by the Tasks edit dialog is rejected', lambda s: s.replace('Beta two ([source](../alpha/docs/checklist.md))', 'Beta two ([source](../alpha/docs/checklist.md)) \U0001F4C5 2026-10-25'), 'trailing source link') + rejected('removing the source link is rejected', lambda s: re.sub(r'Beta two \(\[source\]\([^\n]+?\)\)', 'Beta two', s), 'source link') + view.write_text(re.sub(r'(Beta two \(\[source\]\([^\n]+?\)\))', r'\1 📅 2026-10-25', view.read_text())) + rc, out = invoke('sync') + check('a due date appended by the Tasks edit dialog imports', rc == 0 and 'Beta two 📅 2026-10-25' in note.read_text(), out) + view.write_text(view.read_text().replace(' 📅 2026-10-25', '')) + invoke('sync') rejected('a task whose title has links cannot be renamed from the view', lambda s: s.replace('See the docs now', 'See the docs soon'), 'links or images') rejected('duplicate tasks cannot be renamed', lambda s: s.replace('- [ ] Twin', '- [ ] Twin renamed', 1), 'share its title') - check('rejected text is saved on the Mac', sum(1 for p in (home / 'rejected').rglob('*') if p.is_file()) >= 10) + check('rejected text is saved on the Mac', sum(1 for p in (home / 'rejected').rglob('*') if p.is_file()) >= 9) check('restored views match the Mac again', 'Beta two' in view.read_text() and 'TMP' not in view.read_text()) # A rename pending while the Mac renames the same task is a conflict. view.write_text(view.read_text().replace('Beta two', 'Beta phone')) @@ -1749,7 +1765,7 @@ def swap(text): csv_file.write_text(csv_file.read_text().replace('CSV renamed', 'CSV mac')) commit(source / 'beta', 'Rename CSV row on Mac') rc, out = invoke('sync') - check('an adapter rename pending during a Mac rename is a reported conflict', rc == 0 and 'its title also changed on the Mac' in out + check('an adapter rename pending during a Mac rename is a reported conflict', rc == 0 and 'description also changed on the Mac' in out and 'CSV mac' in beta_view.read_text(), out) gamma_view = vault / '_Folderwise Tasks/gamma.md' gamma_view.write_text(gamma_view.read_text().replace('CSV one', 'CSV nope')) @@ -1811,6 +1827,11 @@ def s_ibridge_isolation(): (fake / 'osacompile').chmod(0o755) def invoke(*args, extra=None): + # Legacy fixtures exercise immediate transactions; settling has its own mobile tests. + if args and args[0] == 'sync' and conf.exists(): + cfg = json.loads(conf.read_text()) + cfg['edit_settle_seconds'] = 0 + conf.write_text(json.dumps(cfg)) return tool('icloud_bridge.py', '--config', conf, *args, env={'PATH': str(fake) + os.pathsep + os.environ['PATH'], **(extra or {})}) def local(name, *args, cwd=None): @@ -2257,6 +2278,19 @@ def locked_run(): check('a locked run never removes exported files', sorted(p.relative_to(vault).as_posix() for p in vault.rglob('*') if p.is_file() and '.obsidian' not in p.parts) == files_before) rc, out = tool('icloud_bridge.py', '--config', conf, 'sync') check('a normal run clears the deferral counter', rc == 0 and 'deferred' not in json.loads((home / 'last-run.json').read_text()), out) + # The real lock appears while hashing an exported vault file, not in the whole-run helper mocked above. + code = ("import errno, sys\nfrom pathlib import Path\nsys.path.insert(0, %r)\nimport icloud_bridge as bridge\n" + "vault = Path(%r)\noriginal = bridge.existing_hash\n" + "def flaky(path):\n if Path(path).name == 'README.md' and Path(path).is_relative_to(vault):\n raise OSError(errno.EDEADLK, 'Resource deadlock avoided')\n return original(path)\n" + "bridge.existing_hash = flaky\nsys.argv = ['icloud_bridge.py', '--config', %r, 'sync']\nsys.exit(bridge.main())\n") % (str(source / '_tools'), str(vault), str(conf)) + for number in (1, 2): + rc, out = locked_run() + check(f'a lock while hashing a vault file is deferred quietly (run {number})', rc == 0 and 'Deferred: iCloud is busy' in out + and json.loads((home / 'last-run.json').read_text()).get('deferred') == number, out) + rc, out = locked_run() + check('the third consecutive vault-file lock failure is reported', rc == 1 and 'Resource deadlock avoided' in out, out) + rc, out = tool('icloud_bridge.py', '--config', conf, 'sync') + check('a normal run after vault-file locks clears the deferral counter', rc == 0 and 'deferred' not in json.loads((home / 'last-run.json').read_text()), out) def s_ibridge_status(): @@ -2278,6 +2312,11 @@ def s_ibridge_status(): (fake / 'osacompile').chmod(0o755) def invoke(*args, extra=None): + # Legacy fixtures exercise immediate transactions; settling has its own mobile tests. + if args and args[0] == 'sync' and conf.exists(): + cfg = json.loads(conf.read_text()) + cfg['edit_settle_seconds'] = 0 + conf.write_text(json.dumps(cfg)) return tool('icloud_bridge.py', '--config', conf, *args, env={'PATH': str(fake) + os.pathsep + os.environ['PATH'], **(extra or {})}) def local(name, *args, cwd=None): @@ -2555,6 +2594,502 @@ def phone(path, edit): check('alerts carry no task or document text', 'PHONE-SECRET' not in banners() and all(l.startswith('Folderwise bridge needs attention.') for l in banners().splitlines() if l.strip()), banners()) +def s_ibridge_tasks(): + """Tasks 8.6 serialization fixtures through real sync transactions, lint and hooks.""" + root = CTX['root'] / 'tasks-compatibility' + source, vault, home = root / 'source', root / 'vault', root / 'state' + source.mkdir(parents=True) + for name in ('_tools', '_template'): + shutil.copytree(CTX['root'] / name, source / name, ignore=shutil.ignore_patterns('__pycache__')) + conf = home / 'config.json' + + def local(name, *args, cwd=None): + return sh(['uv', 'run', '--quiet', str(source / '_tools' / name), *map(str, args)], cwd=cwd or source) + + def invoke(*args): + return local('icloud_bridge.py', '--config', conf, *args) + + local('new_project.py', '--name', 'alpha', '--description', 'Mobile compatibility fixtures', '--no-prompt') + project = source / 'alpha' + note = project / 'docs/mobile.md' + local('docs_new.py', 'docs/mobile.md', '--type', 'plan', '--title', 'Mobile tasks', '--by', 'human', cwd=project) + config = project / '.docs.yaml' + config.write_text(config.read_text() + '\nfolderwise-ibridgerename: true\nfolderwise-ibridgeedit: ["docs/mobile.md"]\n') + fixtures = ('- [ ] Parent\n - [ ] Same child\n continuation text\n - [x] Same child\n' + '\t- [ ] Tab child\n 1. [ ] Number child\n 2) [ ] Paren child\n' + ' - Detail group\n + [ ] Deep child\n> - [ ] Quoted\n> > - [ ] Callout child\n' + '- [ ] Ship ⏫ 📅 2026-11-01 #release ^ship\n' + '- [ ] Repeat 🔁 every day 📅 2026-11-01 🆔 oldid\n' + '- [ ] Remove 🏁 delete\n- [ ] Remove repeat 🔁 every day 📅 2026-11-01 🏁 delete\n' + '- [x] Finished ✅ 2026-10-25\n- [ ] Plain\n' + '- [ ] Linked [guide](mobile.md) 📅 2026-11-01\n' + '- [ ] Hard break 📅 2026-11-01 \n continuation after hard break\n' + ' ```md\n - [ ] Indented code example\n ```\n' + '> - [ ] Quote with example\n> ```md\n> - [ ] Quoted code example\n> ```\n' + '- [ ] Unknown [effort:: 2] 📅 2026-11-01\n- [ ] Spaced marker\n') + + def commit_mac(): + local('docs_stamp.py', 'docs/mobile.md', '--by', 'human', '--summary', 'Update mobile fixtures', cwd=project) + sh(['git', 'add', '-A'], cwd=project) + return sh(['git', 'commit', '-qm', 'Update mobile fixtures'], cwd=project) + + note.write_text(note.read_text().replace('## Change Log', '## Mobile fixtures\n\n' + fixtures + '\n## Change Log')) + commit_mac() + rc, out = invoke('setup', '--source', source, '--vault', vault, '--notifications', 'off', '--no-schedule') + check('task compatibility sandbox setup passes real project hooks', rc == 0, out) + invoke('configure', '--edit-settle-seconds', '0') + view = vault / '_Folderwise Tasks/alpha.md' + + def row(name): + return next(line for line in view.read_text().splitlines(True) if re.search(r'\] ' + re.escape(name) + r' \(\[source\]', line)) + + def phone(name, transform): + text, old = view.read_text(), row(name) + view.write_text(text.replace(old, transform(old), 1)) + return invoke('sync') + + def head(): + return sh(['git', 'rev-parse', 'HEAD'], cwd=project)[1] + + # Preview counts must distinguish description renames from metadata changes. + saved_view, saved_head = view.read_text(), head() + preview_view = saved_view.replace(row('Plain'), row('Plain').rstrip() + ' 📅 2026-12-01\n') + view.write_text(preview_view) + rc, out = invoke('plan') + check('plan distinguishes field edits from description renames', rc == 0 and + 'would toggle 0 and rename 0 task(s); fields=1 recurrence=0 delete=0' in out, out) + check('field edit preview leaves source and phone bytes unchanged', head() == saved_head and + view.read_text() == preview_view, out) + view.write_text(saved_view) + + def rejection_message(transform, reason): + rc, out = phone('Plain', transform) + return (rc == 0 and reason in out and head() == saved_head and view.read_text() == saved_view and + 'PHONE-SECRET' not in (vault / '_folderwise-sync-status.md').read_text(), out) + + check('text after a smart-quoted source link has a precise rejection', *rejection_message( + lambda line: line.replace('"fw:', '“fw:').replace('"))', '”))').rstrip() + ' PHONE-SECRET\n', + 'description text belongs before the link')) + check('a removed generated source link has a precise rejection', *rejection_message( + lambda line: re.sub(r' \(\[source\]\([^\n]+?\)\)', '', line), 'task source link is missing or malformed')) + check('a corrupted generated identity has a precise rejection', *rejection_message( + lambda line: line.replace('"fw:', '"fw:0'), 'source link identity changed')) + + # Reconstruct a genuine schema-2 flat view with the former text-derived identities. + state_path = home / 'state.json' + state = json.loads(state_path.read_text()) + previous, mapping, legacy_lines, ordinals = {}, [], ['# alpha tasks', '', 'Legacy view', ''], {} + for task in sorted((t for k, t in state['tasks']['alpha'].items() if k.startswith('md:')), key=lambda t: t['line']): + identity = task['head'] + ' ] ' + task['title'] + ordinal = ordinals.get(identity, 0) + ordinals[identity] = ordinal + 1 + old_id = 'md:' + hashlib.sha256((task['path'] + '\0' + identity + '\0' + str(ordinal)).encode()).hexdigest() + previous[old_id] = {**task, 'id': old_id, 'identity': identity} + mapping.append(dict(line=len(legacy_lines), id=old_id, checked=task['checked'])) + legacy_lines.append('- [' + ('x' if task['checked'] else ' ') + '] ' + task['title'] + ' ([source](../alpha/' + task['path'] + '))') + legacy = '\n'.join(legacy_lines) + '\n' + state['tasks']['alpha'] = {**previous, '_projection': mapping} + state['files']['_Folderwise Tasks/alpha.md'].update(text=legacy, hash=hashlib.sha256(legacy.encode()).hexdigest(), tasks=mapping) + state_path.write_text(json.dumps(state)) + view.write_text(legacy.replace('- [ ] Plain', '- [x] Plain')) + rc, out = invoke('sync') + check('schema-2 pending toggles migrate against the exact source snapshot', rc == 0 and '- [x] Plain' in note.read_text() and '"fw:' in view.read_text(), out) + phone('Plain', lambda line: line.replace('[x]', '[ ]', 1)) + + check('task metadata follows the source link and block IDs remain last', re.search(r'Ship \(\[source\].*\) ⏫ 📅 2026-11-01 #release \^ship$', row('Ship').rstrip()) is not None) + check('projection task IDs are namespaced without changing source IDs', '🆔 fwview-oldid' in row('Repeat') and '🆔 oldid' in note.read_text()) + mapped = json.loads(state_path.read_text())['tasks']['alpha'] + check('quoted and list-indented fenced examples are never authoritative tasks', + not any('code example' in t.get('title', '') for t in mapped.values() if isinstance(t, dict))) + rc, out = phone('Linked guide', lambda line: line.replace('2026-11-01', '2026-11-02')) + check('metadata edits preserve the original Markdown link in a source description', rc == 0 and 'Linked [guide](mobile.md) 📅 2026-11-02' in note.read_text(), out) + rc, out = phone('Hard break', lambda line: line.replace('2026-11-01', '2026-11-02')) + check('task metadata edits preserve Markdown hard-break spaces and fenced continuation context', rc == 0 and 'Hard break 📅 2026-11-02 \n' in note.read_text() and ' ```md\n - [ ] Indented code example\n ```' in view.read_text(), out) + rc, out = phone('Hard break', lambda line: line.rstrip() + '\n') + check('an explicit hard-break removal imports without changing continuation text', rc == 0 and 'Hard break 📅 2026-11-02\n' in note.read_text() and 'continuation after hard break' in note.read_text(), out) + rc, out = phone('Unknown [effort:: 2]', lambda line: line.replace('2026-11-01', '2026-11-02')) + check('unrecognised inline metadata remains intact as description text', rc == 0 and 'Unknown [effort:: 2] 📅 2026-11-02' in note.read_text(), out) + rc, out = phone('Spaced marker', lambda line: line.replace('- [ ]', '- [x]', 1).rstrip() + ' ✅ 2026-10-26\n') + check('plugin bullet-spacing normalization preserves original source spacing', rc == 0 and '- [x] Spaced marker ✅ 2026-10-26' in note.read_text(), out) + check('task hierarchy preserves tabs, numbered children, non-task parents and continuation text', + all(s in view.read_text() for s in ('\t- [ ] Tab child', ' 1. [ ] Number child', ' 2) [ ] Paren child', + ' - Detail group\n + [ ] Deep child', ' continuation text'))) + for name, prefix in [('Same child', ' - '), ('Tab child', '\t- '), ('Number child', ' 1. '), + ('Paren child', ' 2) '), ('Deep child', ' + '), ('Quoted', '> - '), ('Callout child', '> > - ')]: + rc, out = phone(name, lambda line: line.replace('[ ]', '[x]', 1)) + check('nested or quoted checkbox round trip: ' + name, rc == 0 and prefix + '[x] ' + name in note.read_text(), out) + a, b = [line for line in view.read_text().splitlines(True) if '] Same child ' in line] + original, before = view.read_text(), head() + view.write_text(original.replace(a, 'ROWPLACEHOLDER\n', 1).replace(b, a, 1).replace('ROWPLACEHOLDER\n', b, 1)) + rc, out = invoke('sync') + check('swapping repeated child rows cannot become unintended toggles', rc == 0 and 'reordering' in out and head() == before, out) + + # Task metadata controls in the screenshot, plus lifecycle and dependency fields. + for suffix in ('📅 2026-11-02', '📆 2026-11-03', '🗓️ 2026-11-04', '🛫 2026-11-01', + '⏳ 2026-11-02', '⌛ 2026-11-03', '➕ 2026-10-26', '⏫', '🔼', '🔽', '🔺', '⏬', + '[due:: 2026-11-01]', '(start:: 2026-10-30)', '[scheduled:: 2026-10-31]', + '[created:: 2026-10-26]', '[priority:: highest]', '[priority:: high]', + '[priority:: medium]', '[priority:: low]', '[priority:: lowest]', + '🆔 task1 ⛔ dep1, dep2', '[id:: task1] [dependsOn:: dep1, dep2]', + '🔁 every week on Monday', '[repeat:: every 2 weeks]', '[onCompletion:: keep]'): + rc, out = phone('Plain', lambda line: re.sub(r'(\)\)).*\n$', r'\1 ' + suffix + '\n', line)) + check('mobile task field imports: ' + suffix, rc == 0 and '- [x] Plain ' not in note.read_text() and 'Plain ' + suffix in note.read_text(), out) + rc, out = phone('Plain', lambda line: re.sub(r'(\)\)).*\n$', r'\1\n', line)) + check('removing task metadata imports', rc == 0 and '- [ ] Plain\n' in note.read_text(), out) + for suffix, message in [('📅 2026-02-30', 'valid YYYY-MM-DD'), ('📅 tomorrow', 'valid YYYY-MM-DD'), ('📅 20261101', 'valid YYYY-MM-DD'), + ('[priority:: urgent]', 'priority'), ('🆔 bad!id', 'task ID'), + ('⛔ bad!id', 'dependencies'), ('🏁 explode', 'onCompletion'), + ('🔁 every day?', 'recurrence'), ('📅 2026-11-01 📆 2026-11-02', 'duplicate task field')]: + before = head() + rc, out = phone('Plain', lambda line: line.rstrip() + ' ' + suffix + '\n') + check('invalid task metadata is saved and refused: ' + suffix, rc == 0 and message in out and head() == before, out) + + rc, out = phone('Ship', lambda line: line.replace('Ship ', 'Ship renamed ', 1)) + check('description rename preserves dates priority tags and block IDs', rc == 0 and 'Ship renamed ⏫ 📅 2026-11-01 #release ^ship' in note.read_text(), out) + rc, out = phone('Ship renamed', lambda line: line.replace('#release', '#mobile #work')) + check('task tags can change without dropping dates priority or block IDs', rc == 0 and '📅 2026-11-01 #mobile #work ^ship' in note.read_text(), out) + view.write_text(view.read_text().replace('#mobile #work', '#phone')) + note.write_text(note.read_text().replace('#mobile #work', '#mac')) + commit_mac() + before = head() + rc, out = invoke('sync') + check('concurrent tag disagreements preserve the source and save the phone edit', rc == 0 and 'tags also changed on the Mac' in out and head() == before, out) + rc, out = phone('Plain', lambda line: line.replace('Plain ', 'Plain 🔺 important ', 1)) + check('priority emoji in prose remains description text', rc == 0 and 'Plain 🔺 important' in note.read_text(), out) + phone('Plain 🔺 important', lambda line: line.replace('Plain 🔺 important', 'Plain')) + before = head() + rc, out = phone('Plain', lambda line: line.replace('] Plain', ']Plain')) + check('a malformed checkbox cannot acquire authoritative ownership', rc == 0 and 'keep the checkbox' in out and head() == before, out) + rc, out = phone('Plain', lambda line: line.replace('Plain ', '👩‍💻 Plain ', 1)) + check('mobile titles allow joined emoji without allowing bidi controls', rc == 0 and '👩‍💻 Plain' in note.read_text(), out) + phone('👩‍💻 Plain', lambda line: line.replace('👩‍💻 Plain', 'Plain')) + for status in ('/', '-', '!', 'D'): + data = vault / '.obsidian/plugins/obsidian-tasks-plugin/data.json' + options = json.loads(data.read_text()) + options['statusSettings'] = dict(customStatuses=[dict(symbol='!', type='TODO'), dict(symbol='D', type='DONE')]) + data.write_text(json.dumps(options)) + rc, out = phone('Plain', lambda line: re.sub(r'\[.\]', '[' + status + ']', line, count=1)) + check('configured custom status is preserved: ' + status, rc == 0 and '- [' + status + '] Plain' in note.read_text(), out) + phone('Plain', lambda line: line.replace('[D]', '[ ]', 1)) + before = head() + rc, out = phone('Plain', lambda line: line.replace('[ ]', '[?]', 1)) + check('unknown status has an actionable rejection', rc == 0 and 'configure its type' in out and head() == before, out) + rc, out = phone('Plain', lambda line: line.replace('[ ]', '[x]', 1).rstrip() + ' ✅ 2026-10-26\n') + check('plugin completion with an automatic done date imports atomically', rc == 0 and '- [x] Plain ✅ 2026-10-26' in note.read_text(), out) + rc, out = phone('Plain', lambda line: line.replace('[x]', '[ ]', 1)) + check('reopening a task clears stale completion metadata', rc == 0 and '- [ ] Plain\n' in note.read_text(), out) + rc, out = phone('Plain', lambda line: line.replace('[ ]', '[-]', 1).rstrip() + ' ❌ 2026-10-26\n') + check('plugin cancellation with its date imports', rc == 0 and '- [-] Plain ❌ 2026-10-26' in note.read_text(), out) + phone('Plain', lambda line: line.replace('[-]', '[ ]', 1).replace(' ❌ 2026-10-26', '')) + rc, out = phone('Plain', lambda line: line.replace('[ ]', '[x]', 1).rstrip() + ' [completion:: 2026-10-26]\n') + check('Dataview lifecycle fields import', rc == 0 and '[completion:: 2026-10-26]' in note.read_text(), out) + phone('Plain', lambda line: line.replace('[x]', '[ ]', 1).replace(' [completion:: 2026-10-26]', '')) + + # Actual Tasks recurrence shape: next row first, completed row second; new IDs are removed. + def recur(line): + next_row = line.replace('📅 2026-11-01', '📅 2026-11-02').replace(' 🆔 fwview-oldid', '') + completed = line.replace('[ ]', '[x]', 1).rstrip() + ' ✅ 2026-11-01\n' + return next_row + completed + prior_recurrence_state = state_path.read_bytes() + prior_recurrence_view = view.read_text().replace(row('Repeat'), recur(row('Repeat'))) + rc, out = phone('Repeat', recur) + check('recurrence creates its next occurrence and completes the original atomically', rc == 0 and + '- [ ] Repeat 🔁 every day 📅 2026-11-02\n' in note.read_text() and + '- [x] Repeat 🔁 every day 📅 2026-11-01 🆔 oldid ✅ 2026-11-01' in note.read_text(), out) + before = head() + invoke('sync') + check('replaying a recurring sync creates no duplicate occurrence or commit', head() == before and note.read_text().count('- [ ] Repeat ') == 1) + state_path.write_bytes(prior_recurrence_state) + for key, entry in json.loads(prior_recurrence_state)['files'].items(): + if 'text' in entry: + (vault / key).write_text(entry['text']) + view.write_text(prior_recurrence_view) + rc, out = invoke('sync') + check('recurrence replay after a published commit and lost export state creates no duplicate', rc == 0 and head() == before and note.read_text().count('- [ ] Repeat ') == 1, out) + rc, out = phone('Remove', lambda line: '') + check('explicit on-completion delete removes only its authoritative task line', rc == 0 and '- [ ] Remove 🏁' not in note.read_text() and 'Remove repeat' in note.read_text(), out) + rc, out = phone('Remove repeat', lambda line: line.replace('2026-11-01', '2026-11-02')) + check('recurrence with on-completion delete keeps just the next occurrence', rc == 0 and note.read_text().count('Remove repeat 🔁') == 1 and '2026-11-02 🏁 delete' in note.read_text(), out) + + # Independent metadata fields merge; same-field disagreements preserve the phone save. + original = row('Plain') + view.write_text(view.read_text().replace(original, original.rstrip() + ' 📅 2026-11-05\n')) + note.write_text(note.read_text().replace('- [ ] Plain\n', '- [ ] Plain 🔼\n')) + commit_mac() + rc, out = invoke('sync') + check('independent Mac priority and phone due-date edits merge', rc == 0 and 'Plain 🔼 📅 2026-11-05' in note.read_text(), out) + view.write_text(view.read_text().replace('📅 2026-11-05', '📅 2026-11-06')) + note.write_text(note.read_text().replace('📅 2026-11-05', '📅 2026-11-07')) + commit_mac() + before = head() + rc, out = invoke('sync') + check('same-field concurrent edits are rejected with recovery text retained', rc == 0 and 'due also changed on the Mac' in out and head() == before and list((home / 'rejected').rglob('*.md')), out) + + runtime = json.loads((CTX['tools'] / 'fixtures/obsidian_tasks_8_6_0.json').read_text()) + runtime_results = [] + for case in runtime['cases']: + text = re.sub(r'^- \[.\] Fixture.*\n', '', note.read_text(), flags=re.M) + source_line = case['before'].replace(' __SOURCE__', '') + '\n' + note.write_text(text.replace('## Change Log', source_line + '\n## Change Log')) + commit_mac() + invoke('sync') + old = row('Fixture') + link = re.search(r'\(\[source\]\([^\n]+?\)\)', old)[0] + new = ''.join(line.replace('__SOURCE__', link) + '\n' for line in case['after']) + view.write_text(view.read_text().replace(old, new)) + rc, out = invoke('sync') + actual = [line for line in note.read_text().splitlines() if re.match(r'^- \[.\] Fixture(?: |$)', line)] + expected = [line.replace(' __SOURCE__', '') for line in case['after']] + ok = rc == 0 and actual == expected + runtime_results.append(ok) + check('pinned upstream output imports: ' + case['name'], ok, out + repr(actual) + repr(expected)) + before = head() + invoke('sync') + check('pinned upstream output is idempotent: ' + case['name'], head() == before) + check('pinned Tasks runtime fixtures round trip through the CLI', len(runtime_results) == 22 and all(runtime_results)) + + # Copied-note metadata needs no broad edit permission. Structural edits use the note opt-in. + mirror = vault / 'alpha/docs/mobile.md' + mirror.write_text(mirror.read_text().replace('- [ ] Plain ', '- [ ] Plain 🛫 2026-11-01 ', 1)) + rc, out = invoke('sync') + check('copied-note metadata edits use task mappings', rc == 0 and any('Plain ' in line and '🛫 2026-11-01' in line for line in note.read_text().splitlines()), out) + invoke('configure', '--text-edits', 'on') + mirror.write_text(mirror.read_text().replace('- [ ] Parent\n', '- [ ] Parent\n - [ ] Mobile child\n')) + invoke('sync') + rc, out = invoke('sync') + check('creating a child in an editable copied note refreshes the task inventory', rc == 0 and ' - [ ] Mobile child' in note.read_text() and 'Mobile child' in view.read_text(), out) + rc, out = phone('Mobile child', lambda line: line.replace('[ ]', '[x]', 1)) + check('a newly created child can be toggled through the dashboard', rc == 0 and ' - [x] Mobile child' in note.read_text(), out) + mirror.write_text(mirror.read_text().replace(' - [x] Mobile child\n', ' - [x] Mobile child\n')) + invoke('sync') + rc, out = invoke('sync') + check('reparenting a child through an editable copied note preserves the new indentation', rc == 0 and ' - [x] Mobile child' in note.read_text(), out) + mirror.write_text(mirror.read_text().replace(' - [x] Mobile child\n', '')) + invoke('sync') + rc, out = invoke('sync') + check('deleting a child through an editable copied note refreshes the dashboard', rc == 0 and 'Mobile child' not in note.read_text() and 'Mobile child' not in view.read_text(), out) + + # Simulate an iCloud write during the real commit hook: the prepared commit must not publish. + hook = project / '.git/hooks/pre-commit' + hook_text = hook.read_text() + writer = root / 'cloud-writer.py' + writer.write_text('from pathlib import Path\np=Path(' + repr(str(view)) + ')\np.write_text(p.read_text().replace("Before guard", "After guard"))\n') + hook.write_text('#!/bin/sh\n' + sys.executable + ' ' + str(writer) + '\n' + hook_text.split('\n', 1)[1]) + before = head() + rc, out = phone('Plain', lambda line: line.replace('Plain ', 'Before guard ', 1)) + check('a phone write during the commit hook aborts publication and preserves final phone bytes', rc == 1 and 'vault changed during import' in out and head() == before and 'After guard' in view.read_text(), out) + hook.write_text(hook_text) + rc, out = invoke('sync') + check('the next run imports the final phone save after the writer stops', rc == 0 and 'After guard' in note.read_text() and 'Before guard' not in note.read_text(), out) + phone('After guard', lambda line: line.replace('After guard', 'Plain')) + + # Typing is observed across runs; no partial title or temporary malformed row is restored. + invoke('configure', '--edit-settle-seconds', '3600') + original, before = row('Plain'), head() + view.write_text(view.read_text().replace(original, original.replace('Plain ', 'Part ', 1))) + rc, out = invoke('sync') + check('generated-view partial titles wait without a commit', rc == 0 and head() == before and '] Part ' in view.read_text(), out) + mirror.write_text(mirror.read_text().replace('- [ ] Parent\n', '- [x] Parent\n')) + rc, out = invoke('sync') + check('other imports in the same project wait while its generated view is being edited', rc == 0 and head() == before and '- [ ] Parent\n' in note.read_text(), out) + view.write_text(view.read_text().replace('] Part ', '] Final title ')) + invoke('sync') + pending = home / 'pending.json' + data = json.loads(pending.read_text()) + for entry in data.values(): + entry['since'] -= 4000 + pending.write_text(json.dumps(data)) + rc, out = invoke('sync') + check('only the settled final title is committed', rc == 0 and 'Final title' in note.read_text() and '- [ ] Part ' not in note.read_text() and '- [x] Parent\n' in note.read_text(), out) + text, before = view.read_text(), head() + view.write_text(text.replace(row('Final title'), '- [ ] Incomplete source link\n')) + rc, out = invoke('sync') + check('a temporarily missing source link waits before rejection or restoration', rc == 0 and 'Incomplete source link' in view.read_text() and head() == before, out) + view.write_text(text) + invoke('sync') + check('finishing a temporary edit clears the pending observation', not json.loads(pending.read_text())) + view.write_text(text.replace(row('Final title'), '- [ ] Incomplete source link\n')) + invoke('sync') + data = json.loads(pending.read_text()) + for entry in data.values(): + entry['since'] -= 4000 + pending.write_text(json.dumps(data)) + rc, out = invoke('sync') + check('a settled malformed view is saved before restoration', rc == 0 and 'Incomplete source link' not in view.read_text() and head() == before, out) + + # Both formats/settings are diagnosed without changing the user's settings. + manifest = vault / '.obsidian/plugins/obsidian-tasks-plugin/manifest.json' + manifest.write_text('{"version":"8.6.0"}') + rc, out = invoke('doctor') + check('doctor reports the installed Tasks version and supported lifecycle dates', rc == 0 and 'Tasks version: 8.6.0' in out and 'lifecycle dates supported' in out, out) + settings_path = manifest.with_name('data.json') + options = json.loads(settings_path.read_text()) + options.update(globalFilter='#private', taskFormat='dataview', setDoneDate=True) + settings_path.write_text(json.dumps(options)) + manifest.write_text('{"version":"99.0.0"}') + rc, out = invoke('doctor') + check('doctor warns about filters and unverified versions while preserving settings', rc == 0 and 'globalFilter' in out and 'differs' in out and json.loads(settings_path.read_text()) == options, out) + options['taskFormat'] = 'unknown' + settings_path.write_text(json.dumps(options)) + rc, out = invoke('doctor') + check('doctor rejects an unsupported Tasks serialization format', rc == 1 and 'taskFormat' in out, out) + for invalid, message in [([], 'JSON object'), ({'statusSettings': []}, 'must be an object'), + ({'statusSettings': {'customStatuses': {}}}, 'must be lists'), + ({'statusSettings': {'customStatuses': [{'symbol': 5, 'type': 'DONE'}]}}, 'invalid Tasks status')]: + settings_path.write_text(json.dumps(invalid)) + rc, out = invoke('doctor') + check('malformed plugin settings are diagnosed: ' + message, rc == 1 and message in out, out) + + +def s_ibridge_rich_adapter(): + """A schema-owning adapter must explicitly implement each rich task capability.""" + root = CTX['root'] / 'rich-adapter' + source, vault, home = root / 'source', root / 'vault', root / 'state' + source.mkdir(parents=True) + for name in ('_tools', '_template'): + shutil.copytree(CTX['root'] / name, source / name, ignore=shutil.ignore_patterns('__pycache__')) + conf = home / 'config.json' + + def local(name, *args, cwd=None): + return sh(['uv', 'run', '--quiet', str(source / '_tools' / name), *map(str, args)], cwd=cwd or source) + + def invoke(*args): + return local('icloud_bridge.py', '--config', conf, *args) + + local('new_project.py', '--name', 'alpha', '--description', 'Rich adapter fixture', '--no-prompt') + project = source / 'alpha' + local('docs_new.py', 'docs/tasks.md', '--type', 'plan', '--title', 'Adapter tasks', '--by', 'human', cwd=project) + cfg = project / '.docs.yaml' + cfg.write_text(cfg.read_text() + '\nfolderwise-ibridgerename: true\n') + note, records = project / 'docs/tasks.md', project / 'data/tasks.json' + records.parent.mkdir(exist_ok=True) + caps = ['title', 'due', 'status', 'recurrence', 'delete', 'done'] + rows = [dict(id=str(i), title=title, status=' ', editable=editable) for i, (title, editable) in enumerate([ + ('Adapter', caps), ('Adapter repeat 🔁 every day 📅 2026-11-01', caps), + ('Adapter delete 🏁 delete', caps), ('Limited', []), ('Limited repeat 🔁 every day 📅 2026-11-01', ['due', 'done']), + ('Limited delete 🏁 delete', []), ('Control', caps)])] + records.write_text(json.dumps(dict(rows=rows, ignore_status=False, ignore_title=False, ignore_create=False, ignore_delete=False))) + note.write_text(note.read_text().replace('## Change Log', '\n\n\n## Change Log')) + script = project / 'scripts/adapter.py' + script.parent.mkdir(exist_ok=True) + script.write_text('''import json,sys +from pathlib import Path +sys.path.insert(0,str(Path.cwd().parent/'_tools')) +import ibridge_tasks as fmt +p=Path('data/tasks.json');data=json.loads(p.read_text());rows=data['rows'];op=sys.argv[1] +def done(status): return status in ('x','X','D') +def block(): return '\\n'+''.join('- ['+r['status']+'] '+r['title']+'\\n' for r in rows if not done(r['status']))+'' +if op=='list': + print(json.dumps([dict(r,checked=done(r['status']),path='docs/tasks.md') for r in rows])) +elif op=='apply': + for c in json.load(sys.stdin): + r=next(r for r in rows if r['id']==c['id']) + if 'expected_title' in c: assert r['title']==c['expected_title'] + if 'expected_fields' in c: assert fmt.fields(r['title'])['fields']==c['expected_fields'] + if c.get('delete'): + if not data['ignore_delete']: rows.remove(r) + continue + if 'title' in c and not data['ignore_title']: r['title']=c['title'] + if not data['ignore_status']: + r['status']=c.get('status',('x' if c['checked'] else ' ') if done(r['status'])!=c['checked'] else r['status']) + if 'recurrence' in c and not data['ignore_create']: + for nextrow in c['recurrence']: + if not done(nextrow['status']): rows.append(dict(nextrow,id=r['id']+'-next',editable=r['editable'])) + p.write_text(json.dumps(data)) + note=Path('docs/tasks.md');text=note.read_text();a=text.index('');b=text.index('')+len('');note.write_text(text[:a]+block()+text[b:]) +elif op=='validate': + text=Path('docs/tasks.md').read_text();assert block() in text +''') + + def commit(): + local('docs_stamp.py', 'docs/tasks.md', '--by', 'human', '--summary', 'Adapter fixture', cwd=project) + sh(['git', 'add', '-A'], cwd=project) + return sh(['git', 'commit', '-qm', 'Update adapter fixture'], cwd=project) + + # Generate the initial block through the same project-owned code. + text = note.read_text().replace('\n', '\n' + ''.join('- [ ] ' + r['title'] + '\n' for r in rows) + '') + note.write_text(text) + commit() + invoke('setup', '--source', source, '--vault', vault, '--notifications', 'off', '--no-schedule') + options = json.loads(conf.read_text()) + options.update(adapters={'alpha': [sys.executable, 'scripts/adapter.py']}, edit_settle_seconds=0) + conf.write_text(json.dumps(options)) + invoke('sync') + view = vault / '_Folderwise Tasks/alpha.md' + + def phone(name, transform): + old = next(line for line in view.read_text().splitlines(True) if '] ' + name + ' ([source]' in line) + view.write_text(view.read_text().replace(old, transform(old), 1)) + return invoke('sync') + + def head(): + return sh(['git', 'rev-parse', 'HEAD'], cwd=project)[1] + + rc, out = phone('Adapter', lambda line: line.rstrip() + ' 📅 2026-11-05\n') + check('a rich adapter receives and validates typed metadata fields', rc == 0 and json.loads(records.read_text())['rows'][0]['title'] == 'Adapter 📅 2026-11-05', out) + rc, out = phone('Adapter', lambda line: line.replace('[ ]', '[/]', 1)) + check('a rich adapter imports configured status characters', rc == 0 and json.loads(records.read_text())['rows'][0]['status'] == '/', out) + + def repeat(line): + return line.replace('2026-11-01', '2026-11-02') + line.replace('[ ]', '[x]', 1).rstrip() + ' ✅ 2026-11-01\n' + + prior_state = (home / 'state.json').read_bytes() + old_view = view.read_text() + old_row = next(line for line in old_view.splitlines(True) if '] Adapter repeat ([source]' in line) + prior_view = old_view.replace(old_row, repeat(old_row)) + rc, out = phone('Adapter repeat', repeat) + check('a rich adapter creates exactly one next occurrence', rc == 0 and len([r for r in json.loads(records.read_text())['rows'] if r['title'].startswith('Adapter repeat')]) == 2, out) + before = head() + invoke('sync') + check('rich adapter recurrence is idempotent', head() == before) + (home / 'state.json').write_bytes(prior_state) + for key, entry in json.loads(prior_state)['files'].items(): + if 'text' in entry: + (vault / key).write_text(entry['text']) + view.write_text(prior_view) + rc, out = invoke('sync') + check('adapter recurrence recovers a published commit with lost export state exactly once', rc == 0 and head() == before and len([r for r in json.loads(records.read_text())['rows'] if r['title'].startswith('Adapter repeat')]) == 2, out) + rc, out = phone('Adapter delete', lambda line: '') + check('a rich adapter can explicitly delete a completed record', rc == 0 and not any(r['title'].startswith('Adapter delete') for r in json.loads(records.read_text())['rows']), out) + for name, transform, message in [ + ('Limited', lambda line: line.rstrip() + ' 📅 2026-11-05\n', 'does not allow these task fields: due'), + ('Limited', lambda line: line.replace('[ ]', '[/]', 1), 'does not allow custom status'), + ('Limited repeat', repeat, 'does not allow recurrence creation'), + ('Limited delete', lambda line: '', 'does not allow on-completion deletion')]: + before = head() + rc, out = phone(name, transform) + check('adapter capability boundary: ' + message, rc == 0 and message in out and head() == before, out) + for option, name, transform, message in [ + ('ignore_status', 'Adapter', lambda line: line.replace('[/]', '[-]', 1), 'did not apply the requested status'), + ('ignore_title', 'Adapter', lambda line: line.replace('2026-11-05', '2026-11-06'), 'did not apply the requested title'), + ('ignore_create', 'Limited repeat', repeat, 'did not create exactly one next occurrence'), + ('ignore_delete', 'Limited delete', lambda line: '', 'did not delete the completed task')]: + data = json.loads(records.read_text()) + data[option] = True + for r in data['rows']: + if r['title'].startswith(name): + r['editable'] = caps + records.write_text(json.dumps(data)) + commit() + invoke('sync') + before = head() + rc, out = phone(name, transform) + check('an adapter that ignores a rich operation cannot publish: ' + option, rc == 1 and message in out and head() == before, out) + saved = json.loads((home / 'state.json').read_text())['files']['_Folderwise Tasks/alpha.md']['text'] + view.write_text(saved) + data = json.loads(records.read_text()) + data[option] = False + records.write_text(json.dumps(data)) + commit() + invoke('sync') + plugin = vault / '.obsidian/plugins/obsidian-tasks-plugin/data.json' + check('rich adapter imports preserve an unrelated authoritative record', + [r for r in json.loads(records.read_text())['rows'] if r['title'] == 'Control'] == [rows[-1]]) + data = json.loads(plugin.read_text()); data['setCancelledDate'] = True; plugin.write_text(json.dumps(data)) + rc, out = invoke('doctor') + check('doctor diagnoses automatic dates unsupported by an adapter', rc == 1 and 'adapter lacks cancelled capability' in out, out) + + def s_ibridge_setup(): """Verify explicit vault setup, persistent project defaults, and safe uninstall.""" root=CTX['root']/'obsidian-setup-sandbox';source=root/'source';source.mkdir(parents=True) @@ -2691,7 +3226,7 @@ def create(name,*extra): ("create", s_create), ("new_doc", s_new_doc_and_index), ("stamp", s_stamp_and_hook), ("hook", s_hook_fail_closed), ("lint", s_lint_rules), ("timezone", s_timezone), ("links", s_links), ("staged_source", s_staged_source), ("markdown_fences", s_markdown_fences), ("toc", s_toc), ("mv", s_mv), ("tables", s_tables), ("stale", s_stale_index_recent), ("sync", s_sync), - ("ibridge", s_ibridge), ("ibridge_rename", s_ibridge_rename), ("ibridge_isolation", s_ibridge_isolation), ("ibridge_notes", s_ibridge_notes), ("ibridge_transient", s_ibridge_transient), ("ibridge_status", s_ibridge_status), ("ibridge_setup", s_ibridge_setup), ("doctor", s_doctor), ("layout", s_layout), ("cli_errors", s_cli_errors), ("failure_paths", s_failure_paths), ("uv_cache", s_uv_cache), + ("ibridge_tasks", s_ibridge_tasks), ("ibridge_rich_adapter", s_ibridge_rich_adapter), ("ibridge", s_ibridge), ("ibridge_rename", s_ibridge_rename), ("ibridge_isolation", s_ibridge_isolation), ("ibridge_notes", s_ibridge_notes), ("ibridge_transient", s_ibridge_transient), ("ibridge_status", s_ibridge_status), ("ibridge_setup", s_ibridge_setup), ("doctor", s_doctor), ("layout", s_layout), ("cli_errors", s_cli_errors), ("failure_paths", s_failure_paths), ("uv_cache", s_uv_cache), ("framework_checkout", s_framework_checkout), ("framework_index", s_framework_index), ("ci_allowlist", s_ci_allowlist), ] diff --git a/docs/examples/obsidian_tasks_fixtures.test.ts b/docs/examples/obsidian_tasks_fixtures.test.ts new file mode 100644 index 0000000..c0f05f9 --- /dev/null +++ b/docs/examples/obsidian_tasks_fixtures.test.ts @@ -0,0 +1,51 @@ +import fs from 'node:fs'; +import moment from 'moment'; +import { Task } from '../src/Task/Task'; +import { Status } from '../src/Statuses/Status'; +import { StatusRegistry } from '../src/Statuses/StatusRegistry'; +import { resetSettings, updateSettings } from '../src/Config/Settings'; +import { fromLine } from './TestingTools/TestHelpers'; +window.moment = moment; + +it('exports Folderwise compatibility fixtures through the Tasks runtime', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-10-26T12:00:00Z')); + const source = ' ([source](../alpha/docs/mobile.md "fw:0123456789abcdef"))'; + const rows: any[] = []; + for (const format of ['tasksPluginEmoji', 'dataview'] as const) { + for (const [name, suffix, operation] of [ + ['complete', '📅 2026-11-01 ⏫', 'toggle'], + ['cancel', '📅 2026-11-01', 'cancel'], + ['reopen', '✅ 2026-10-25', 'toggle'], + ['daily', '🔁 every day 📅 2026-11-01 🆔 original', 'toggle'], + ['weekly', '🔁 every week on Monday 📅 2026-11-02', 'toggle'], + ['monthly', '🔁 every month 📅 2026-11-01', 'toggle'], + ['when_done', '🔁 every week when done 📅 2026-11-01', 'toggle'], + ['no_date', '🔁 every day', 'toggle'], + ['delete', '🏁 delete', 'toggle'], + ['recur_delete', '🔁 every day 📅 2026-11-01 🏁 delete', 'toggle'], + ['completed_first', '🔁 every day 📅 2026-11-01', 'toggle'], + ]) { + resetSettings(); StatusRegistry.getInstance().resetToDefaultStatuses(); + updateSettings({ setDoneDate: true, setCancelledDate: true, taskFormat: 'tasksPluginEmoji' }); + let task = fromLine({ line: `- [${name === 'reopen' ? 'x' : ' '}] Fixture${source} ${suffix}` }); + expect(task).toBeTruthy(); + if (suffix.includes('📅')) expect(task.dueDate).not.toBeNull(); + if (suffix.includes('🔁')) expect(task.recurrence, name).not.toBeNull(); + updateSettings({ taskFormat: format, recurrenceOnNextLine: name === 'completed_first' }); + const before = task.toFileLineString(); + task = fromLine({ line: before }); + const output = operation === 'cancel' + ? task.handleNewStatusWithRecurrenceInUsersOrder(Status.CANCELLED) + : task.toggleWithRecurrenceInUsersOrder(); + const after = output.map(t => t.toFileLineString()); + const normalize = (line: string) => line.replace(source, ' __SOURCE__'); + rows.push({ name: `${format}_${name}`, format, before: normalize(before), after: after.map(normalize) }); + } + } + fs.writeFileSync(process.env.FOLDERWISE_TASK_FIXTURES ?? '/tmp/folderwise-tasks-runtime-fixtures.json', JSON.stringify({ + upstream: 'obsidian-tasks-group/obsidian-tasks', commit: '6c7e9a1443e645f29998619974329b61cd2d52dd', version: '8.6.0', + generatedAt: '2026-10-26T12:00:00Z', cases: rows + }, null, 2) + '\n'); + vi.useRealTimers(); +}); diff --git a/docs/guide.md b/docs/guide.md index f398feb..c86b3ac 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -438,7 +438,7 @@ A fresh Folderwise installation creates no Obsidian vault and leaves bridge sync uv run _tools/icloud_bridge.py setup --source ~/folderwise --vault "$HOME/Library/Mobile Documents/iCloud~md~obsidian/Documents/Folderwise" --dry-run uv run _tools/icloud_bridge.py setup --source ~/folderwise --vault "$HOME/Library/Mobile Documents/iCloud~md~obsidian/Documents/Folderwise" uv run _tools/icloud_bridge.py configure --mode lean --interval-minutes 5 -uv run _tools/icloud_bridge.py plan # also previews pending phone edits +uv run _tools/icloud_bridge.py plan # previews pending toggles, renames, fields, recurrence and deletion uv run _tools/icloud_bridge.py sync uv run _tools/icloud_bridge.py configure --text-edits on --edit-settle-seconds 120 # opt-in phone note edits (see folderwise-ibridgeedit) uv run _tools/icloud_bridge.py resolve # list vault files with pending phone edits @@ -469,11 +469,15 @@ The `.docs.yaml` setting `folderwise-ibridgeedit` is a list of project-relative The `.docs.yaml` setting `folderwise-ibridgerename` is a boolean that defaults to false. When true, the project's generated task view also accepts task title edits from the phone (Markdown tasks and adapter tasks that advertise `editable: ["title"]`); see [Rename tasks from iPhone](obsidian.md#rename-tasks-from-iphone-opt-in) for the validation rules, commit provenance and conflict handling. A non-boolean value is rejected. A rejected edit to a generated view is saved privately on the Mac under the bridge state's `rejected/` folder (30 days), the view is restored, and the status report lists it; bridge state also keeps a small `ledger.json` of recent imports and rejections and records a `schema` version. The status report additionally lists recent imports and iCloud conflict copies. Brief iCloud file locks are retried on the next run instead of alerting. +Generated-view rejection messages distinguish description text after a source link, a missing or malformed source link, and a changed link identity. Restore the complete original link rather than retyping it with phone smart punctuation. `plan` counts description renames separately from metadata field edits and also reports recurrence and deletion counts. + Each sync validates project configurations once and reuses that snapshot for inventory, rename permissions and imports. An invalid timezone, malformed YAML, a non-boolean project flag, a symlink config or a project-specific read/adapter failure pauses that project and preserves its previous export; healthy projects continue. The status report and run diagnostics record the pause. `plan` still exits with an error for an invalid configuration or inventory. Setup remains strict about its selected projects. Setup enrollment and phone task imports share the same disposable-clone transaction: copy the project's hooks and Git identity, apply the requested edit, validate and commit, then fast-forward the live repository only if its worktree and HEAD are unchanged. A failed operation removes its disposable clone and preserves pending phone edits for retry. -The bridge also generates `Tasks.md` and `_Folderwise Tasks/.md` in the iCloud vault: one combined task view per enabled project, refreshed after imports on each sync. These are interactive views; Mac project files and adapter-managed records remain authoritative. Checkbox toggles are supported in mapped task views and supported handwritten source checkboxes; title edits only for projects with `folderwise-ibridgerename: true`. See [generated task folder ownership, naming, and lifecycle](obsidian.md#generated-task-folder-and-naming). +Task compatibility uses the internal `ibridge_tasks.py` parser for emoji and Dataview fields. Dates, priorities, lifecycle dates, configured statuses, recurrence output and explicit completion deletion import through validated local transactions. Stable source-link identities preserve row ownership; generated text edits wait for `edit_settle_seconds`. See [task format compatibility](obsidian.md#task-format-compatibility) for supported operations, adapter capabilities and the real-iPhone release gate. + +The bridge also generates `Tasks.md` and `_Folderwise Tasks/.md` in the iCloud vault: one combined task view per enabled project, refreshed after imports on each sync. These are interactive views; Mac project files and adapter-managed records remain authoritative. Checkbox, status and task metadata edits are supported in mapped task views and supported handwritten source tasks; description edits require `folderwise-ibridgerename: true`. Adapter fields require explicit capabilities. See [generated task folder ownership, naming, and lifecycle](obsidian.md#generated-task-folder-and-naming). Bridge-local JSON configuration is separate from `.docs.yaml`: `version` is 1; `enabled` records installed integration state; `source` and `vault` are separate absolute directories; `mode` is full/lean; `interval_minutes` defaults to 5; `text_edits` (boolean, default false) and `edit_settle_seconds` (default 120) control phone note edits; `notifications` is errors (default), imports (errors plus a counts-only note when phone edits are applied) or off; `max_image_bytes` and `max_images_total_bytes` are null for unlimited (zero means zero bytes). `adapters` maps project names to local argv arrays implementing list/apply/validate. `launchagents_dir` is an optional override for isolated scheduling tests. Configure limits with `--max-image-bytes` and `--max-images-total-bytes`, clear both with `--unlimited-images`, and inspect omissions/estimated export size in root-vault `_folderwise-sync-status.md`. These are file-size estimates, not actual iCloud account usage or billing. diff --git a/docs/ibridge-testing.md b/docs/ibridge-testing.md index 9142fd5..c174725 100644 --- a/docs/ibridge-testing.md +++ b/docs/ibridge-testing.md @@ -9,11 +9,15 @@ There are two layers: The [traceability table](#traceability-table) links every manual scenario to the automated checks that cover it, and marks the parts only a real device can verify. CI fails when this page drifts from the self-tests (see [Keeping this page honest](#keeping-this-page-honest)). +The pinned runtime fixtures in `_tools/fixtures/obsidian_tasks_8_6_0.json` were generated by executing Tasks' actual serializer and status/recurrence methods, including both row-order settings and both metadata formats. To regenerate: clone the upstream commit named in the fixture into a temporary folder, install its development dependencies using its own lockfile, copy [the generator](examples/obsidian_tasks_fixtures.test.ts) to its `tests/FolderwiseCompatibility.test.ts`, and run `vitest run tests/FolderwiseCompatibility.test.ts`. The output defaults to `/tmp/folderwise-tasks-runtime-fixtures.json` (override `FOLDERWISE_TASK_FIXTURES`). Review the output before replacing the fixture. Normal Folderwise tests consume JSON and need no Node or Obsidian dependency. + ## Self-test sections | Section (`-k` name) | What it covers | | --- | --- | | `ibridge` | The core: export and dashboard, phone toggles for Markdown and CSV-backed tasks, idempotence, dirty-project and hook failures, lean mode, adapters, schedule and notification plumbing, journal recovery, status report. | +| `ibridge_rich_adapter` | Typed field/status capabilities, recurrence creation/deletion, adapter round-trip validation and automatic-date diagnostics. | +| `ibridge_tasks` | Tasks 8.6 field fixtures, custom statuses, nested/quoted tasks, recurrence/deletion, metadata conflicts, generated-view settling and plugin diagnostics. | | `ibridge_rename` | Opt-in task renames from the phone: the setting, accepted renames, every rejected shape, Mac-side conflicts, copied-note edits, CSV adapters, iCloud conflict copies. | | `ibridge_isolation` | One project's problem pausing only that project, the `plan` preview of phone edits, the `resolve` command, and notification modes. | | `ibridge_notes` | Opt-in phone text edits to copied notes: the two switches, patterns, quiet period, protected regions, limits, Mac conflicts, CRLF, recovery. | @@ -80,7 +84,7 @@ Goal: prove that an edit the bridge does not support is contained, reported once 2. Edit the **title text** of a generated view (for example, change the heading of `_Folderwise Tasks/.md`, or a task's words) in the vault copy. 3. Wait for the next scheduled run. If you need to capture the banner at a known moment, you may start the real job once with `launchctl kickstart gui/$(id -u)/org.folderwise.ibridge`. That is the real scheduled job, not a foreground `sync`; note in your report that you used it. 4. Verify: - - The run reports the issue "only checkbox on/off changes are supported"; `ok` is false. + - After the quiet period, the run reports the unsupported edit under **Phone edits not applied**; the exact text is recoverable. - The status report has a **Phone edits not applied** entry naming the file and line. - The exact phone text is saved on the Mac in the bridge state's private `rejected/` folder, and the vault view is restored from Mac data. - **Exactly one notification** was requested (`notification_sent` is true for that run). Capture the banner if macOS shows it. @@ -91,10 +95,10 @@ Goal: prove that an edit the bridge does not support is contained, reported once ### 5. Opt-in rename test 1. In the fictional project, add `folderwise-ibridgerename: true` to `.docs.yaml` and commit it. After the next scheduled run, the generated view should say that title edits are imported. -2. **Rename.** On the phone, edit one task's wording in the view file (see the [dialog warning](#findings-from-real-runs)); keep the checkbox and the trailing `([source](…))` link. Wait for the scheduled run. Expect a commit "Update tasks from Obsidian" with `Bridge-Ops: toggle=0 rename=1`, a source note whose line carries the new wording and is stamped `human` with the change-log row "Rename tasks via Obsidian iCloud bridge", a regenerated view with the new title, a **Recent imports** entry with the commit, a clean working tree, and no titles anywhere in the commit message. Under the default `errors` notification mode, no notification. +2. **Rename.** On the phone, edit one task's wording in the view file (see the [dialog warning](#findings-from-real-runs)); keep the checkbox and complete `([source](…))` link before metadata. Wait for the text to settle across scheduled runs. Expect a commit "Update tasks from Obsidian" with `Bridge-Ops: toggle=0 rename=1`, a source note whose line carries the new wording and is stamped `human` with the change-log row "Rename tasks via Obsidian iCloud bridge", a regenerated view with the new title, a **Recent imports** entry with the commit, a clean working tree, and no titles anywhere in the commit message. Under the default `errors` notification mode, no notification. 3. **Rename plus toggle.** In a single save, rename a task and tick it. Expect one commit with `Bridge-Ops: toggle=1 rename=1`. 4. **Rejected cases.** Make each of the following edits in turn (restore the view before the next). For each, expect that **the whole save of the view is rejected as one unit**: the phone text is saved on the Mac, the view is restored, the reason is listed in the status report, one notification is sent, and the project head does not move. - - A due date or typed text appended **after** the source link (what the Tasks edit dialog does). + - Description text appended **after** the source link. Valid dates and other metadata after the source link are supported. - The source link removed from the line. - Two titles swapped. - A title changed to another task's title (a duplicate). @@ -112,7 +116,7 @@ Goal: prove that an edit the bridge does not support is contained, reported once ### 7. `plan` preview and `resolve` -1. With a phone edit pending (not yet picked up by a run), `uv run _tools/icloud_bridge.py plan` shows `PHONE EDIT : would toggle and rename task(s)` for a valid edit or `PHONE EDIT REJECTED` with the reason for an unsupported one, and changes nothing (compare checksums before and after). +1. With a phone edit pending (not yet picked up by a run), `uv run _tools/icloud_bridge.py plan` shows `PHONE EDIT : would toggle and rename task(s); fields= recurrence= delete=` for a valid edit or `PHONE EDIT REJECTED` with the reason for an unsupported one, and changes nothing (compare checksums before and after). Rename counts refer to description changes; metadata-only updates count under `fields`. 2. Run `uv run _tools/icloud_bridge.py resolve` to list vault files that differ from the last export. Run `resolve ""` on an unwanted edit: the phone text is saved under `rejected/` and the exported version is restored. A path with no pending edit is refused. The paused project resumes on the next scheduled run. ### 8. Notification modes @@ -128,6 +132,18 @@ Goal: prove that an edit the bridge does not support is contained, reported once 3. After the quiet period the next run commits "Update notes from Obsidian" with `Bridge-Ops: … text-edit=1`; the stamped note, with a new Change Log row, returns to the phone; the report's **Recent imports** shows the count under *Edited*. 4. Try an edit the bridge must refuse (change the Change Log, or empty the note): the project pauses, the note is named in the report, and `resolve ""` restores the Mac version. +### 9. Expanded task compatibility release gate + +Record the framework commit, macOS/iOS/Obsidian/Tasks versions and fixture evidence. The automated reference is Tasks 8.6.0; a source-fixture pass is not an iPhone acceptance pass. + +1. Use each date/priority control, add/remove metadata, edit descriptions without dropping existing fields, and verify filters/sorting recognize dates and priorities after sync. Repeat in emoji and Dataview formats, with tags, dependencies and block IDs. +2. Enable automatic done/cancelled dates for Markdown tasks; complete, cancel and reopen. Test in-progress and a custom DONE status. For adapters, advertise/test matching capabilities or leave automatic dates off; `doctor` must identify incompatible adapters. +3. Round-trip a three-level tree with spaces/tabs, numbered children, quoted/callout tasks, repeated child names and continuation text. Toggle children independently. Reorder duplicate rows deliberately and verify rejection rather than unintended toggles. +4. Test recurring tasks with next-first and completed-first output, relative-to-completion rules, undated recurrence and `onCompletion: delete`. Confirm exactly one next occurrence and no duplicate commits on later runs. The plugin supplies recurrence dates. +5. Type a partial title across a scheduler tick, briefly remove the source link, then finish. Nothing intermediate may be committed/restored. Change different metadata fields on Mac and phone, then the same field; expect a merge followed by a recoverable conflict respectively. +6. In an opted-in copied note, create/delete/reparent children and complete a recurring task. Verify the generated inventory refreshes and a newly created child can then be toggled from the dashboard. +7. Repeat offline/reconnect, interrupted delivery and recovery tests with fresh and existing vault settings. Keep the results table and captures outside the framework repository. A release remains awaiting real-device acceptance until this evidence is recorded. + ### Notification caveat A successful notification request is **not** proof that a banner was visible. Screen capture, iPhone Mirroring, Screen Sharing, a computer-use assistant, or a Focus mode can make macOS keep the notification in Notification Center without showing it. The bridge reports "Notification requested", not "shown". For acceptance: @@ -158,10 +174,13 @@ CI mocks the iCloud transport with plain file copies between the Mac source and These came from a real fictional-data acceptance run on a Mac and iPhone. Tests must keep guarding them. 1. **The Tasks edit dialog keeps the source link but can add text after it.** When a task is edited from `Tasks.md`, the plugin preserves the `([source](…))` link but can append a due date, and any text typed in the dialog may end up after the link. A generated line must keep the link as its last part, so such an edit is rejected as a whole and the phone text is saved on the Mac. Rename from the view file with the plugin's suggestion popup dismissed, or make the change on the Mac. A rejected save is one unit: a toggle in the same save is not applied either, and the view returns to the Mac's state. -2. **Brief iCloud file locks look like errors but are not.** While iCloud is syncing a file, the bridge can hit transient operating-system errors such as `[Errno 11] Resource deadlock avoided`. They must be retried on the next run without alerting. Only the third consecutive failure is reported. A scheduled job that alerts on a one-off lock trains users to ignore alerts. +2. **Brief iCloud file locks look like errors but are not.** While iCloud is syncing a file, the bridge can hit transient operating-system errors such as `[Errno 11] Resource deadlock avoided`. They must be retried on the next run without alerting, wherever the lock hits (including while hashing a vault view file that iCloud is still writing; the 0.2.0 acceptance run saw this alert on the first occurrence before it was fixed). Only the third consecutive failure is reported. A scheduled job that alerts on a one-off lock trains users to ignore alerts. 3. **iCloud delivery was fast; the job interval dominated.** In the observed runs, iCloud delivered a phone edit to the Mac within 6 to about 30 seconds, and the scheduled job added roughly one and a half to two minutes; with a five-minute interval it can add up to five. Do not fail a run for being slow inside the interval plus a generous delivery margin, and do not use a manual `sync` to hide a slow delivery. 4. **Mirroring is an unreliable input device.** Clicks were sometimes dropped (a tap that did not register) and a click once landed on the heading, so a stray character was typed into the title line. The stray text was removed before the scheduled run and no commit resulted. Always verify the phone's state (screenshot) before the five-minute run; if a bad edit does reach the run, it is rejected and the view restored. 5. **The error banner needs a human.** Window captures work; native screenshots of the mirrored phone do not, and the mirrored frame can be stale after an error test. Record these limitations in the result table rather than skipping the evidence. +6. **A rejected view can come back from the phone.** After a rejection the Mac restores the view, but Obsidian on the phone may keep showing (and later re-save) the rejected text until the file is reopened, especially if a Tasks dashboard or edit dialog writes the stale copy. The bridge then rejects the whole save again, for the old reason, and an unrelated valid edit in the same save is rejected with it. Wait until the restored view is visible on the phone (reopen the view file) before editing again. Rejected text is always saved under the bridge state's `rejected/` folder. +7. **A Tasks plugin toggled off in Obsidian's settings may not load again until Obsidian is restarted** ("Failed to load plugin"). Change Tasks settings, then restart Obsidian instead of toggling the plugin; do not use this to test settings mid-run. +8. **iPhone smart punctuation can alter the source link's identity.** A real rejected save contained curly quotes around `fw:` as well as description text after the link. Diagnostics now identify text after the link before checking row order, and separately name a missing/malformed link or a changed identity. Keep the original source link intact; the bridge never guesses a replacement identity. ## Traceability table @@ -189,10 +208,10 @@ Each row is a scenario from the checklist. **Automated checks** are exact labels | U5 | The error banner is visible on the Mac | Manual only | Real macOS banner, screenshot, Focus and sharing state | | U6 | A stray edit typed into the wrong line (mirroring slip) is contained | `unsupported phone edits to a generated view are saved outside iCloud and the view is restored`
`whitespace-only title edits are ignored` | The mirroring slip itself cannot be reproduced on demand | | U7 | iCloud conflict copies are reported, not imported | `conflict copies are reported and not imported`
`the conflict-copy section disappears once the copy is gone` | Real iCloud creating the copy | -| R1 | Renames are off by default and the setting is validated | `views of projects without the setting say only toggles are imported`
`a non-boolean rename setting is rejected`
`project doctor recognizes folderwise-ibridgerename`
`opted-in views explain title edits` | None | +| R1 | Renames are off by default and the setting is validated | `views explain metadata support and the separate title-edit opt-in`
`a boolean-only adapter view does not promise metadata or title edits`
`a non-boolean rename setting is rejected`
`project doctor recognizes folderwise-ibridgerename`
`opted-in views explain title edits` | None | | R2 | Accepted rename: source note, human stamp, commit, view, status | `phone rename with a toggle edits the source note once`
`rename commit has provenance, counts and no titles`
`rename stamps the note as human with a rename summary`
`regenerated view carries the new title`
`status report lists the import with its commit` | The real phone edit | | R3 | Rename plus toggle combine into one commit | `phone rename with a toggle edits the source note once`
`a view rename and a toggle in the copied note combine into one change` | The real phone edit | -| R4 | Due date or text appended after the source link, or a removed link, is rejected | `a due date appended by the Tasks edit dialog is rejected`
`removing the source link is rejected` | What the Tasks edit dialog really appends on iPhone (see R12) | +| R4 | Due date appended after the source link imports; a removed link is rejected | `a due date appended by the Tasks edit dialog imports`
`removing the source link is rejected`
`text after a smart-quoted source link has a precise rejection`
`a removed generated source link has a precise rejection`
`a corrupted generated identity has a precise rejection` | What the Tasks edit dialog really appends on iPhone (see R12) | | R5 | Swapped titles, duplicate titles and renames of duplicate tasks are rejected | `swapped titles are not read as two renames`
`renaming to an existing title is rejected`
`duplicate tasks cannot be renamed` | None | | R6 | Links in titles are rejected | `a title cannot embed links`
`a task whose title has links cannot be renamed from the view` | None | | R7 | Marker, formula, over-long and direction-character titles are rejected | `a title cannot hide generated-block markers`
`a title cannot start a spreadsheet formula`
`an over-long title is rejected`
`invisible direction characters are rejected` | None | @@ -207,12 +226,12 @@ Each row is a scenario from the checklist. **Automated checks** are exact labels | Z3 | A paused project resumes once fixed | `the paused project resumes once fixed` | None | | Z4 | A bad edit to a copied note pauses only its project | `a bad copied-note edit pauses its project and names the file` | None | | Z5 | Adapter or inventory failures pause only their project | `an adapter failure pauses only that project`
`the adapter project imports after the failure clears`
`an uninventoriable project is paused without losing its export`
`the project resumes after the inventory problem is fixed` | None | -| Q1 | `plan` previews pending and rejected phone edits and changes nothing | `plan previews a pending toggle`
`plan previews a rejected edit without applying it`
`plan changes nothing`
`the planned edits apply on the next sync`
`plan describes cleanup without removing files` | None | +| Q1 | `plan` previews pending and rejected phone edits and changes nothing | `plan previews a pending toggle`
`plan previews a rejected edit without applying it`
`plan changes nothing`
`the planned edits apply on the next sync`
`plan describes cleanup without removing files`
`plan distinguishes field edits from description renames`
`field edit preview leaves source and phone bytes unchanged` | None | | Q2 | `resolve` lists, saves and restores an unwanted edit | `resolve lists files with pending phone edits`
`resolve refuses a file without a pending edit`
`resolve saves the phone text and restores the Mac version`
`the project resumes after resolve`
`resolve reports when nothing is pending` | None | | N1 | Notification modes: errors, imports (counts only), off | `setup accepts the imports notification mode`
`unknown notification modes are rejected`
`errors mode does not announce routine imports`
`imports notify with counts only` | None | | N2 | `notify-test` and the setup replay request a notification honestly | `notification app bundles the Folderwise identity and icon`
`notification app launches and clears the message handoff`
`notification test distinguishes a request from visible delivery`
`one-command setup notification replay uses the configured interval`
`notification failure is reported by the explicit test command` | Whether a banner is actually seen (see N3) | | N3 | A notification banner is visible on the Mac desktop | Manual only | Real macOS presentation; screen capture, mirroring and Focus can suppress it; never change global privacy settings silently | -| I1 | A transient iCloud lock such as `[Errno 11] Resource deadlock avoided` is retried quietly and only the third consecutive failure alerts | `the third consecutive lock failure is reported`
`a locked run never removes exported files`
`a normal run clears the deferral counter` | Real iCloud rarely reproduces the lock on demand | +| I1 | A transient iCloud lock such as `[Errno 11] Resource deadlock avoided` is retried quietly and only the third consecutive failure alerts | `the third consecutive lock failure is reported`
`a locked run never removes exported files`
`a normal run clears the deferral counter`
`the third consecutive vault-file lock failure is reported`
`a normal run after vault-file locks clears the deferral counter` | Real iCloud locks appear in bursts right after phone saves (observed on every scheduled run that followed a batch of edits at a one-minute interval) | | I2 | Mirroring drops a click or types into the wrong line; the operator verifies the phone before the run | Manual only | Operator discipline; the bridge's reaction to a bad edit is covered by U1 and U6 | | E1 | Note edits are off until the bridge switch is on, and settings are validated | `phone text edits are refused until the bridge switch is on`
`the text edit switch accepts only on or off`
`the text edit switch must be a real boolean`
`the quiet period cannot be negative`
`edit patterns must be a list of project-relative paths`
`edit patterns cannot leave the project`
`project doctor recognizes folderwise-ibridgeedit`
`turning the bridge switch off refuses note edits again` | None | | E2 | A phone note edit waits out the quiet period, then commits once with human provenance | `plan says the phone note is waiting for the quiet period`
`the first run only notes the edit`
`an edit that has not stayed unchanged long enough keeps waiting`
`plan previews the note edit that would apply`
`a settled phone note edit is committed once`
`the note edit has human provenance and counts only`
`the stamped note returns to the vault`
`an unchanged run after a note edit creates no commit` | The real phone typing and the iCloud delivery | @@ -235,6 +254,16 @@ Each row is a scenario from the checklist. **Automated checks** are exact labels | S11 | Source and config problems: unavailable source removes nothing, invalid config stops before export, doctor agrees with the report | `an unavailable source is reported in the status file and removes nothing`
`restoring the source recovers`
`an invalid bridge config stops before any export and says so on the CLI`
`doctor agrees with a clean report`
`doctor reports the last issue state`
`doctor stays healthy after handled rejections` | Config errors cannot appear in the report (CLI and doctor only) | | S12 | Privacy: the report and alerts never contain phone-typed text, source or state paths | `alerts carry no task or document text` | None (every `ibridge_status` scenario also asserts this for the report) | +| F1 | Metadata positions and hierarchy are preserved | `task metadata follows the source link and block IDs remain last`
`task hierarchy preserves tabs, numbered children, non-task parents and continuation text`
`swapping repeated child rows cannot become unintended toggles` | Reading/live-preview hierarchy, callouts and continuation rendering on iPhone | +| F2 | Automatic completion, cancellation and reopening round trip | `plugin completion with an automatic done date imports atomically`
`reopening a task clears stale completion metadata`
`plugin cancellation with its date imports`
`Dataview lifecycle fields import` | Real checkbox and status controls with automatic dates enabled | +| F3 | Recurrence and explicit completion deletion are atomic and idempotent | `recurrence creates its next occurrence and completes the original atomically`
`replaying a recurring sync creates no duplicate occurrence or commit`
`explicit on-completion delete removes only its authoritative task line`
`recurrence with on-completion delete keeps just the next occurrence` | Test daily, weekly, monthly, when-done, undated recurrence and both plugin row-order settings | +| F4 | Text edits settle and concurrent metadata changes are safe | `generated-view partial titles wait without a commit`
`only the settled final title is committed`
`a temporarily missing source link waits before rejection or restoration`
`independent Mac priority and phone due-date edits merge`
`same-field concurrent edits are rejected with recovery text retained` | Keep typing across a real scheduler tick, edit offline then reconnect, and test iCloud conflict delivery | +| F5 | Plugin versions and settings are diagnosed without replacing user settings | `doctor reports the installed Tasks version and supported lifecycle dates`
`doctor warns about filters and unverified versions while preserving settings`
`doctor rejects an unsupported Tasks serialization format` | Record iOS/macOS/Obsidian/Tasks versions; test fresh, existing and upgraded vaults | + +| F6 | Actual Tasks serializer outputs remain compatible | `pinned Tasks runtime fixtures round trip through the CLI`
`projection task IDs are namespaced without changing source IDs`
`schema-2 pending toggles migrate against the exact source snapshot`
`recurrence replay after a published commit and lost export state creates no duplicate`
`adapter recurrence recovers a published commit with lost export state exactly once` | Confirm the installed plugin matches the reference and upgrades preserve pending edits | +| F7 | Rich adapter capabilities and authoritative round trips | `a rich adapter receives and validates typed metadata fields`
`a rich adapter imports configured status characters`
`a rich adapter creates exactly one next occurrence`
`a rich adapter can explicitly delete a completed record`
`doctor diagnoses automatic dates unsupported by an adapter` | Validate each project's actual adapter/schema before enabling metadata controls | +| F8 | Copied-note child operations and writes during a transaction | `creating a child in an editable copied note refreshes the task inventory`
`a newly created child can be toggled through the dashboard`
`reparenting a child through an editable copied note preserves the new indentation`
`deleting a child through an editable copied note refreshes the dashboard`
`a phone write during the commit hook aborts publication and preserves final phone bytes` | Real iCloud scheduling and delivery during an import | + ## Keeping this page honest `uv run .github/scripts/test_public_docs.py` (a required CI check) reads this page and `_tools/selftest.py` and fails when: diff --git a/docs/obsidian.md b/docs/obsidian.md index 108deea..e73fadd 100644 --- a/docs/obsidian.md +++ b/docs/obsidian.md @@ -104,11 +104,33 @@ On each scheduled or manual `sync`, the bridge: To refresh immediately, run `uv run _tools/icloud_bridge.py sync` on the Mac. The scheduled job performs the same operation at your configured interval. -**Toggle checkboxes; do not add tasks, reorder lines, or rename these files or their folder.** By default only checkbox on/off changes are imported; title edits need the per-project opt-in below. If you make any other edit to a generated task view, the bridge saves your exact text on the Mac (outside iCloud, kept 30 days), restores the view from Mac data on the next sync, lists the edit under **Phone edits not applied** in `_folderwise-sync-status.md`, and sends one notification. Unsupported edits to a copied source note are different: they stay in the vault and pause that project so they cannot be overwritten (see [Conflicts and recovery](#conflicts-and-recovery)). Missing generated files are restored from Mac data on the next sync. Task identity and ownership mappings live in the bridge’s local state outside iCloud; the generated Markdown is an interactive view, not the authoritative task store. +**Use the Tasks controls to edit tasks; keep each task’s source link, and do not reorder rows or rename the generated files/folder.** Checkboxes, configured statuses and task metadata are imported; description edits need the per-project opt-in below. Textual edits and temporary malformed saves wait for the quiet period before import or rejection. Tasks-generated recurrence rows and explicit completion deletion are supported as described below. If you make any other edit to a generated task view, the bridge saves your exact text on the Mac (outside iCloud, kept 30 days), restores the view from Mac data on the next sync, lists the edit under **Phone edits not applied** in `_folderwise-sync-status.md`, and sends one notification. Unsupported edits to a copied source note are different: they stay in the vault and pause that project so they cannot be overwritten (see [Conflicts and recovery](#conflicts-and-recovery)). Missing generated files are restored from Mac data on the next sync. Task identity and ownership mappings live in the bridge’s local state outside iCloud; the generated Markdown is an interactive view, not the authoritative task store. -Configure Tasks for simple toggles: disable automatic done/created/cancelled dates, leave its global filter empty, use ordinary space/x statuses, and avoid recurring tasks or on-completion deletion. Those features modify more than a checkbox and are outside this bridge's contract. A plugin-added date is preserved and reported as an unsupported edit, rather than silently discarded. Dataview is optional for property tables; [Dataview TASK](https://blacksmithgu.github.io/obsidian-dataview/queries/query-types/#task) can also toggle a checkbox, provided completion metadata insertion is disabled. +### Task format compatibility -Handwritten task checkboxes can also be toggled in the copied source document. Generated checkboxes must be toggled through their mapped dashboard projection. Text edits to notes are imported only for the opt-in notes described below. New documents, deletions, custom task statuses and capture inboxes are not imported in this release; the only title changes accepted are the opt-in renames below. Missing exported files are restored from Mac; phone deletions never delete a source file. Unmanaged files are left alone. +The compatibility reference is **Obsidian Tasks 8.6.0**, upstream commit `6c7e9a1443e645f29998619974329b61cd2d52dd`. The controls shown by the iPhone app come from this community plugin. Source: [Tasks suggestor and serializers](https://github.com/obsidian-tasks-group/obsidian-tasks/tree/6c7e9a1443e645f29998619974329b61cd2d52dd/src). `ibridge_tasks.py` is the bridge's internal field/projection helper, not a separate command. + +| Operation | Supported behavior | +| --- | --- | +| Due/start/scheduled dates | `📅` (also `📆`, `🗓`), `🛫`, `⏳` (also `⌛`), followed by a valid `YYYY-MM-DD`; emoji variation selectors are accepted | +| Priorities | Highest `🔺`, high `⏫`, medium `🔼`, low `🔽`, lowest `⏬`; removing the field restores normal priority | +| Lifecycle dates | Created `➕`, done `✅`, cancelled `❌`; automatic dates may be enabled for handwritten tasks. Reopening clears a stale done date | +| Other metadata | Recurrence `🔁`, task ID `🆔`, dependencies `⛔`, on-completion `🏁 keep` / `🏁 delete`, tags and final `^block-id` | +| Dataview format | Square or parenthesized inline fields: `[due:: ...]`, `[start:: ...]`, `[scheduled:: ...]`, `[created:: ...]`, `[completion:: ...]`, `[cancelled:: ...]`, `[priority:: high]`, `[repeat:: ...]`, `[id:: ...]`, `[dependsOn:: ...]`, `[onCompletion:: ...]` | +| Custom statuses | The original character is retained. The iCloud vault's Tasks `statusSettings` defines its type; space/x/X, in-progress `/` and cancelled `-` have defaults. Unknown statuses require configuration before importing them through generated views | +| Subtasks | Spaces/tabs, `-`, `*`, `+`, numbered `1.`/`1)` tasks, quoted/callout `>` prefixes, non-task list parents and indented continuation text are preserved. Parent and child checkboxes are independent | +| Recurrence | Tasks calculates the next dates and supplies the next/completed rows. The bridge validates the matching identities, repeat rule and description, then imports both in one transaction. Replaying an unchanged save creates no extra occurrence, including recovery after a source commit was published before its export-state update. The bridge does not calculate recurrence when a plain Markdown checkbox is toggled without the plugin. Finite/count-limited schedules inherit the upstream plugin’s documented limitations | +| On-completion deletion | Removing a row is accepted only when its existing authoritative task explicitly contains `🏁 delete` or `[onCompletion:: delete]`. This authorizes deletion of that task line; unrelated lines and source documents are retained. Recurring deletion keeps the plugin's next occurrence | + +Generated source links include a stable `fw:` link title for row ownership. Keep that link intact. Generated Tasks IDs and dependencies use the reserved `fwview-` prefix so they do not collide with IDs indexed from copied source notes; imports remove that projection prefix. Source IDs should remain globally unique across the vault. The source link belongs **after the description and before metadata**, allowing Tasks to recognize the trailing fields. Plugin serialization may reorder fields; edits to independent metadata fields merge with Mac changes, while disagreements on the same field require review. Description renames preserve metadata. Metadata-only edits preserve source links/images; changing the description of a linked task requires a Mac edit or the copied-note text-edit opt-in. Unknown inline fields remain ordinary description text. + +Generated-view text edits (including automatic date insertion), structural recurrence edits and malformed intermediate saves wait for `edit_settle_seconds` (default 120 seconds across runs). Simple checkbox/status-only changes import immediately. While a generated view is settling, other imports/exports for that project wait with it, preserving a consistent baseline; healthy projects continue. A value of zero explicitly disables generated-view settling. Copied-note text edits still require two observations. Recent-import counts include field updates, recurrence and deletion. Each imported note gets a distinct Change Log fingerprint, including repeated edits within the same second. Files are rechecked before publication; a concurrent phone save aborts the local import and remains pending. + +Handwritten task checkboxes and metadata may also be edited in copied source documents. Broader text edits, new task lines, child creation/deletion/reparenting and plugin recurrence inside a copied note require the note-edit opt-in below. Generated blocks stay protected and adapter-managed records must use mapped views. Creating arbitrary tasks, moving/reordering generated rows, phone-created documents, source file deletions/renames and attachment imports remain unsupported. Missing exported files are restored from Mac; phone file deletion never deletes a source document. + +Run `doctor` after setup or plugin upgrades. It reports the installed Tasks version, serialization format and custom status types, warns about a non-empty global filter or a version outside the compatibility fixtures, and does not overwrite user settings. Existing settings are retained. Fresh defaults keep automatic lifecycle dates off for compatibility with boolean-only adapters; enable them only when the relevant adapters advertise date support. Dataview remains optional; its metadata format is supported, and its own TASK-query behavior needs the real-device acceptance test. + +An upgrade accepts pending schema-2 edits when their source identity can be verified against the exact source snapshot. Ambiguous or concurrently changed old mappings require review and retain the phone text. See [the release acceptance checklist](ibridge-testing.md) before using production data; passing CLI fixtures does not certify actual iCloud delivery or an iPhone app version. ### Rename tasks from iPhone (opt-in) @@ -126,8 +148,8 @@ folderwise-ibridgerename: true The value must be a boolean. After the next sync the project's generated view says that title edits are imported. Then, on iPhone: -1. Open `_Folderwise Tasks/.md` and edit the task text, or use the Tasks plugin's edit dialog on `Tasks.md` and change the description. Keep the checkbox and keep the `([source](…))` link at the end of the line. A due date, tag or other text the dialog adds after the link is rejected. -2. At the next scheduled run the Mac validates the edit, applies it in an isolated clone, stamps the changed note as `human`, lints and commits locally. The commit message records counts and `Bridge-Ops: toggle=N rename=M`, never the titles, and states that titles are user-typed and unverified. The Change Log row says "Rename tasks via Obsidian iCloud bridge". `last_verified` is not advanced. +1. Open `_Folderwise Tasks/.md` and edit the task text, or use the Tasks plugin's edit dialog on `Tasks.md` and change the description. Keep the checkbox and the complete `([source](…))` link after the description. Dates, priorities and other metadata belong after that link; a block ID stays last. +2. After the text has settled across scheduled runs the Mac validates the edit, applies it in an isolated clone, stamps the changed note as `human`, lints and commits locally. The commit message records counts and `Bridge-Ops: toggle=N rename=M`, never the titles, and states that titles are user-typed and unverified. The Change Log row says "Rename tasks via Obsidian iCloud bridge". `last_verified` is not advanced. 3. The regenerated view shows the new title and the status report lists the import under **Recent imports**. You can rename and toggle the same task in one save; both are applied in one commit. A rename is accepted only when all of these hold. Otherwise the whole save of that view is rejected: the text is saved on the Mac, the view is restored, and the reason is listed in the status report. @@ -142,7 +164,7 @@ A rename is accepted only when all of these hold. Otherwise the whole save of th | The task must be unchanged on the Mac since the last export | A rename never overwrites a Mac-side change | | Adapter tasks must advertise `editable: ["title"]` | Only the project's adapter knows how to rewrite its records | -Task renames made inside the copied source notes are not accepted; keep using the dashboard views. +Description edits inside copied source notes require the separate note-edit opt-in below; generated views use the rename opt-in. ### Edit notes from iPhone (opt-in) @@ -176,7 +198,7 @@ What the bridge protects and refuses (the project pauses and names the note; not | More than 200 changed lines, more than half the note removed, an emptied note, over 256 KB or 5,000 lines, or NUL bytes | Refused | | Edit fails the project's lint or hooks (for example a broken link) | The Mac note stays unchanged; the error is shown | | The note also changed on the Mac since its last export | Paused: the Mac version stays; use `resolve` to keep it, or merge by hand | -| A task in the same note was toggled or renamed in the same run | Paused until one of the two is resolved | +| A task in the same note was edited in a generated view in the same run | Paused until one of the two is resolved | | Notes not matching a pattern, new files, deletions, renames, CSV files and attachments | Not imported | Checkbox toggles inside an editable note still import as ordinary task changes. Windows-style (CRLF) notes keep their line endings. Text typed on the phone enters your repository and may later be read by people or agents, so keep the pattern list narrow, review the commits, and never list notes holding instructions you rely on. A compromised phone or Obsidian plugin can only reach the notes you list, and every change is a local Git commit you can revert. @@ -205,6 +227,10 @@ This is an excerpt, not a replacement for the complete generated config. Adapter [Example CSV adapter](examples/ibridge_csv_adapter.py) demonstrates an open/complete CSV and a generated backlog that hides completed tasks. Copy it into a fictional project first, adapt its schema and renderer, and commit it with the project. Its unchecked state is explicitly `open`; map richer statuses such as blocked/cancelled deliberately. An adapter task must live in a generated block rather than also appearing as a handwritten task, to avoid two independent identities for the same record. +Adapters remain backward compatible with `{id, checked}`. Rich adapters add field names to `editable`: `due`, `start`, `scheduled`, `created`, `done`, `cancelled`, `priority`, `repeat`, `id`, `dependsOn`, `onCompletion`, `block`, plus `status`, `recurrence` and `delete` capabilities. `title` authorizes description edits. An unsupported field is rejected by name; it is never silently appended to a boolean adapter's record. + +A field edit carries the complete new `title`, `expected_title`, a `fields` object of changed semantic values (null removes a field), and `expected_fields`. The adapter must compare the expected state, write its authoritative records, and regenerate views. A custom state carries `status`. Recurrence carries `recurrence: [{title, status}, ...]`: update the original completed record and create exactly one new stable-ID record for the open occurrence. Deletion carries `delete: true` and `expected_title`; it requires the authoritative task's explicit on-completion action. The bridge re-lists and validates requested titles/statuses, the created occurrence and deletion before publishing. Adapters must advertise only operations their data schema can faithfully represent. + Adapters run in a temporary local Git clone with the project's hooks, not directly against your source working tree. The bridge applies requests, stamps changed Markdown as `human`, runs adapter validation, Folderwise lint and commit hooks, then fast-forwards the clean source repository. It never pushes. Failed validation or hooks leave source records unchanged and phone toggles pending. Local transaction work needs disk space for a project checkout; Git objects are shared with the source rather than duplicating its history. Adapters must use project-relative paths and must not write outside their working directory. Custom Git hook paths are not supported; use the project's standard `.git/hooks` directory. Handwritten tasks use file/text identity plus duplicate occurrence. Line shifts alone do not identify a task. If identical task text occurs more than once and that source file changes concurrently, the bridge stops for review rather than guessing which duplicate moved. Changed task text or a source rename can also require review.