Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions _tools/icloud_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,17 @@ def _view_changes(base, current, renames, status_types):
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]
Expand All @@ -576,8 +587,6 @@ def _view_changes(base, current, renames, status_types):
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')
if taskfmt.fields(b['suffix'])['description']:
raise Rejected('keep the checkbox and the trailing source link before task metadata; description text belongs before the link')
a_fields = taskfmt.fields(a['title'])
if len(group) == 2:
if not a_fields['fields'].get('repeat') or status_types.get(a['status']) == 'DONE':
Expand Down Expand Up @@ -1356,7 +1365,12 @@ def reject(key, reason):
preview.append(f'PHONE EDIT REJECTED {name}: {exc}; the text would be saved and the view restored')
else:
if toggles or renames or any(w.get('delete') or w.get('recurrence') for w in wanted.values()):
preview.append(f'PHONE EDIT {name}: would toggle {len(toggles)} and rename {len(renames)} task(s)')
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')
Expand Down
24 changes: 24 additions & 0 deletions _tools/selftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2651,6 +2651,30 @@ def phone(name, transform):
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())
Expand Down
4 changes: 3 additions & 1 deletion docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,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
Expand Down Expand Up @@ -465,6 +465,8 @@ 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.
Expand Down
7 changes: 4 additions & 3 deletions docs/ibridge-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,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 <project>: would toggle <n> and rename <m> 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 <project>: would toggle <n> and rename <m> task(s); fields=<f> recurrence=<r> delete=<d>` 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 "<path>"` 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
Expand Down Expand Up @@ -174,6 +174,7 @@ These came from a real fictional-data acceptance run on a Mac and iPhone. Tests
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

Expand Down Expand Up @@ -204,7 +205,7 @@ Each row is a scenario from the checklist. **Automated checks** are exact labels
| R1 | Renames are off by default and the setting is validated | `views explain metadata support and the separate title-edit opt-in`<br>`a boolean-only adapter view does not promise metadata or title edits`<br>`a non-boolean rename setting is rejected`<br>`project doctor recognizes folderwise-ibridgerename`<br>`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`<br>`rename commit has provenance, counts and no titles`<br>`rename stamps the note as human with a rename summary`<br>`regenerated view carries the new title`<br>`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`<br>`a view rename and a toggle in the copied note combine into one change` | The real phone edit |
| R4 | Due date appended after the source link imports; a removed link is rejected | `a due date appended by the Tasks edit dialog imports`<br>`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`<br>`removing the source link is rejected`<br>`text after a smart-quoted source link has a precise rejection`<br>`a removed generated source link has a precise rejection`<br>`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`<br>`renaming to an existing title is rejected`<br>`duplicate tasks cannot be renamed` | None |
| R6 | Links in titles are rejected | `a title cannot embed links`<br>`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`<br>`a title cannot start a spreadsheet formula`<br>`an over-long title is rejected`<br>`invisible direction characters are rejected` | None |
Expand All @@ -219,7 +220,7 @@ 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`<br>`the adapter project imports after the failure clears`<br>`an uninventoriable project is paused without losing its export`<br>`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`<br>`plan previews a rejected edit without applying it`<br>`plan changes nothing`<br>`the planned edits apply on the next sync`<br>`plan describes cleanup without removing files` | None |
| Q1 | `plan` previews pending and rejected phone edits and changes nothing | `plan previews a pending toggle`<br>`plan previews a rejected edit without applying it`<br>`plan changes nothing`<br>`the planned edits apply on the next sync`<br>`plan describes cleanup without removing files`<br>`plan distinguishes field edits from description renames`<br>`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`<br>`resolve refuses a file without a pending edit`<br>`resolve saves the phone text and restores the Mac version`<br>`the project resumes after resolve`<br>`resolve reports when nothing is pending` | None |
| N1 | Notification modes: errors, imports (counts only), off | `setup accepts the imports notification mode`<br>`unknown notification modes are rejected`<br>`errors mode does not announce routine imports`<br>`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`<br>`notification app launches and clears the message handoff`<br>`notification test distinguishes a request from visible delivery`<br>`one-command setup notification replay uses the configured interval`<br>`notification failure is reported by the explicit test command` | Whether a banner is actually seen (see N3) |
Expand Down
Loading