Commit graph

21384 commits

Author SHA1 Message Date
Rushi
f9d65debe9
refactor(tasks): extract shared task ordering helpers (#8926)
* refactor(tasks): extract shared task ordering helpers

Extract getReorderedSubTaskIds (membership check + move shared by
moveSubTaskUp/Down/ToTop/ToBottom) and moveValidIdsToFront (shared by
removeTasksFromTodayTag and localRemoveOverdueFromToday) into pure,
tested utils. No behavior change.

Closes #7912

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(tasks): drop getReorderedSubTaskIds, inline check in reorderSubTask

Review feedback (#8926): the moveSubTask* actions already funnel through
the single reorderSubTask helper, so the extraction added no dedup value.
Keep moveValidIdsToFront, which removes real duplication.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 17:59:50 +02:00
aakhter
962c5bbeb1
feat(sync): conflict journal + disjoint-field auto-merge + review UI (#8874)
* feat(sync): conflict-journal foundation (observe-only)

Add a device-local IndexedDB conflict journal that records every sync-
conflict auto-resolution so the discarded ("losing") side is preserved
and reviewable later. Foundation subtask for the conflict-review epic;
no UI here, verifiable purely by unit tests.

- New SUP_CONFLICT_JOURNAL IndexedDB store (own DB; never touches the
  op-log SUP_OPS schema) + ConflictJournalService with record/query API
  (unreviewedCount$, list, markKept, markFlipped, getEntry) and 14-day /
  200-entry retention pruning, wired to run on app start via APP_INITIALIZER.
- Pure classifier buildConflictJournalEntry maps each resolution to the
  agreed taxonomy (newer/tie/delete-wins/noise/clock-corruption-
  suspected; disjoint-merge reserved for the next subtask), capturing the
  loser's field values verbatim. NOISE_FIELDS is limited to metadata
  timestamps (modified/lastModified/created): the list-ordering arrays
  carry membership as well as order, so an overlap on them is surfaced as
  a reviewable conflict rather than silently classified as noise.
- Emission is strictly observe-only: journaling runs after the LWW plan is
  built, wrapped in try/catch (record() swallows its own errors); clock-
  corruption attribution uses a WeakSet side-channel tagged at detection.
  The existing conflict-resolution suite stays 138/138 green, proving LWW
  picks are unchanged. One-sided / sequential / EQUAL updates never become
  conflicts and produce zero journal entries.

SPAP-13

* feat(sync): disjoint-field auto-merge for concurrent edits

When two clients concurrently edit the same entity but different fields
(A changes title, B changes notes), whole-entity LWW previously discarded
one side. Keep both when the non-noise changed-field sets are disjoint.

In _resolveConflictsWithLWW, before LWW picks a winner, each CONCURRENT
conflict is tested for merge eligibility (neither side deleting/archiving;
both changed >=1 real field; non-noise field sets disjoint). If eligible,
synthesize a single merged UPDATE op — the current entity overlaid with
the other side's non-noise fields, noise fields resolved by the greater
(timestamp, clientId) — carrying a vector clock that dominates both sides,
so it propagates through normal sync. The resolution is winner 'merged'
and is journaled reason 'disjoint-merge' / status 'info' (not counted as
unreviewed). Any overlap on a non-noise field, or a delete/archive on
either side, falls through to the existing LWW path unchanged.

Convergence: both clients compute the byte-identical merged entity
(disjoint real fields each owned by one side; noise resolved by the same
global tiebreak) with clocks dominating both originals, so the two
independently-synthesized merged ops carry identical full-entity payloads
and re-resolve to the same state via ordinary LWW without re-merging.

No sync-core/protocol change. Existing conflict-resolution suite stays
138/138; SPAP-13 journal specs stay green.

SPAP-14

* feat(sync): sync conflicts review UI (banner, badge, page, flip)

Builds the conflict-review UI on top of the device-local conflict journal.

- Post-sync summary banner "N conflicts auto-resolved (X remote, Y local
  won)" with REVIEW / DISMISS, replacing the bare LWW_CONFLICTS_AUTO_
  RESOLVED snack at its emission sites; a persistent badge on the sync
  icon bound to unreviewedCount$ (survives banner dismiss).
- New /sync-conflicts page: Unreviewed | History tabs, rows grouped by
  entity type with winner + reason chips, expandable per-field diff
  (LOCAL vs REMOTE, device name + wall-clock time, winner marked),
  per-row KEEP / FLIP and bulk KEEP ALL / FLIP ALL → LOCAL / → REMOTE.
  History renders merged auto-merges as per-field chips.
- Flip dispatches a normal entity update with the loser's journaled field
  values (syncs like a user edit, no history rewind) and marks the entry
  flipped; a stale-flip confirm appears (with the current value shown)
  when the entity changed since resolution.
- i18n under F.SYNC.CONFLICT_REVIEW.

Delete-restore and archived-entity flip are surfaced as unsupported for
now (an update op can't recreate an absent entity) — follow-up.

SPAP-15

* fix(sync): count disjoint-merge ops in localWinOpsCreated

autoResolveConflictsLWW returned only newLocalWinOps.length, excluding the
synthesized merged ops appended in STEP 3b. A sync whose only conflicts were
disjoint-field merges returned 0, so the caller (immediate-upload.service.ts)
skipped the immediate re-upload and reported IN_SYNC while the merged op sat
unsynced until a later cycle.

Count mergedResolutions.length too — each merge appends exactly one pending
local op. Mirrors the rejection-handler path (operation-log-sync.service.ts).
Add a regression spec asserting a merge-only conflict returns
localWinOpsCreated: 1 (fails on the old return, passes now).

Also harden the _corruptionSuspectedConflicts WeakSet doc against a future
refactor that clones EntityConflict between detect and resolve.

Addresses review feedback on PR #8874.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(sync): address second-pass review (delete-lost, archive guard test, polish)

Second-pass review follow-ups (johannesjo). The MEDIUM localWinOpsCreated
item was already fixed in 23e9a56.

Important:
- delete-lost classification: when a delete LOSES to a concurrent newer edit,
  the loser side is a pure Delete op (no field changes), so it fell through to
  `noise`/`info` and never surfaced. Add a `delete-lost` reason classified as
  `unreviewed` (inverse of `delete-wins`), checked before the noise fallthrough.
  + regression spec.
- archive-vs-disjoint-edit test (d2): an archive is an UPDATE, so eligibility
  does NOT reject it — only the `_isArchivePlan` guard does. New test forces
  eligibility TRUE and asserts no merged op is synthesized, so a guard
  regression now fails a test (previously nothing covered it).

Minor:
- pruneOnStart: wrap in try/catch (log + return 0, matching record()'s
  observe-only contract); reset the poisoned `_initPromise` in `_ensureDb` on
  open failure so a transient IndexedDB error can't wedge the service.
- sync-conflicts-page.scss: use `--color-success` token + color-mix tint
  instead of hardcoded #4caf50 / rgba(76,175,80,…); drop the dead
  `--color-warning` fallback (#e6a817 didn't match the real #ff9800 token).
- main-header badge: matBadgeColor warn -> accent (a resolved count is not an
  error); move the count announcement to `matBadgeDescription` and give the
  button a stable sync-action aria-label (it was null at count 0, leaving the
  icon-only button unnamed).
- remove dead i18n key LWW_CONFLICTS_AUTO_RESOLVED (t.const.ts + en.json).
- add direct unit spec for noiseTiebreakSide (incl. equal-timestamp clientId
  determinism) and buildMergedFieldDiffs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(sync): address third-pass review (flip capability, field presence, opaque ops, profile isolation)

Blocking #3 — delete-lost/delete-wins offered a false-success Flip:
canFlip() is now reason/field-aware (refuses delete-lost/delete-wins,
empty loser changes, and relationship-bearing fields whose bare adapter
update would bypass meta-reducer invariants); flip() guards on it and
only marks an entry flipped when an op was actually dispatched.

Blocking #2 — field absent vs side-set-undefined: fieldDiffs now record
per-side presence (localChanged/remoteChanged); loserChangesFor/
winnerChangesFor only emit fields the side actually changed, so Flip no
longer clears winner-only fields and the stale guard no longer compares
undefined. Legacy entries without flags fall back to value-presence
(exact, since op payloads are pure JSON).

Blocking #1 — non-adapter action payloads: per-op extraction now falls
back to capture-time entityChanges (TIME_TRACKING/syncTimeSpent), and
ops with no readable delta (convertToSubTask & co) are "opaque": never
classified noise/info, preserved verbatim as kind:'action' diffs for
review, excluded from flip/stale computations, and never disjoint-merge
eligible (merging would drop the mutation and diverge the two clients).

Profile isolation — switchProfile clears the conflict journal
(ConflictJournalService.clearAll) so a new profile cannot see the
previous profile's entity data or Flip against the wrong dataset.

Also: replace the as-any selector cast with a typed union-member
narrowing, and add the required docs (sync-and-op-log/
conflict-journal-and-review.md incl. the atomicity/no-re-merge
contract, wiki 3.06 + 4.23).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sync): address fourth-pass review (flip short-syntax, selector throws, restore isolation)

HIGH — a title-only flip matched shortSyntax$'s exact trigger shape, so
#tag/+project/@schedule tokens in the discarded title re-parsed into
cross-entity mutations: the TASK flip now dispatches with
isIgnoreShortSyntax: true (journaled literal value, not user input).

MEDIUM — selectTagById/selectNoteById THROW on a missing entity, so
expanding (or flipping) a TAG/NOTE row whose entity is gone rejected
unhandled before the !current guard: _readCurrentEntity now catches and
returns undefined, and getStaleState/flip degrade gracefully.

MEDIUM — the journal survived backup restores: clearAll() moved to the
BackupService.importCompleteBackup chokepoint, covering every full
dataset replacement (profile switch, JSON import, local-backup restore,
SuperSync restore) instead of only the profile switch.

LOW — ISSUE_PROVIDER's factory selectById is now special-cased (was
rendering the inner selector function as the "current" entity);
schedule/reminder fields (dueDay/dueWithTime/deadline*/reminderId) join
the flip blocklist (renamed FLIP_UNSAFE_FIELDS) since their invariants
live in dedicated flows; loser/winnerChangesFor early-return for merged
entries (their tiebreak diffs carry pickedSide, so the per-diff check
alone did not exclude them).

Docs updated accordingly (dev doc + wiki 3.06/4.23).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sync): refuse flip for remindAt/deadlineRemindAt (reminder-lifecycle)

FLIP_UNSAFE_FIELDS is a deny-list: any Task field not listed is flippable
via a bare updateTask. reminderId was covered, but the sibling reminder-
lifecycle timestamps remindAt and deadlineRemindAt were not — a conflict on
either alone (e.g. concurrent deadline-reminder-lead edits) would pass
canFlip and, when flipped, write the field without scheduling/cancelling the
actual reminder in ReminderService, leaving a dangling/missing notification.

Add both to FLIP_UNSAFE_FIELDS, document the deny-list drift risk, and pin
the reminder/schedule members with per-field canFlip=false spec cases.

* fix(sync): make disjoint-merge convergent + close flip stale-guard blind spot

Two confirmed cross-device data defects found in the SPAP-14 whole-PR review:

1. Full-entity merged op diverges (CRITICAL). The merged op was a full-entity
   snapshot of each client's CURRENT state, so any field NEITHER conflicting
   side touched rode along. Under ordinary staggered sync (one client already
   applied a third device's edit to that field, the other not yet), the two
   clients synthesize different snapshots that tie under LWW at the identical
   max(timestamp) and diverge PERMANENTLY. Fix: synthesize a PARTIAL delta
   (union of the two sides' changed fields only), derived purely from the ops
   so both clients compute the byte-identical map; lwwUpdateMetaReducer applies
   it via updateOne (shallow merge), leaving untouched fields alone.
   synthesizeMergedEntity -> synthesizeMergedChanges.

   Also: detectConflicts emits one conflict per remote op with no per-entity
   aggregation, so an entity with >=2 concurrent remote ops synthesized multiple
   merged ops whose clocks dominate one another -> a dominated sibling's field
   is silently dropped, falsely journaled as 'kept both'. Fix: refuse merge for
   any entity with >1 conflict this batch and fall back to whole-entity LWW.

2. Flip stale-guard blind to loser-only fields (HIGH). getStaleState only
   compared WINNER-changed fields, but flip writes LOSER-changed fields. A
   post-resolution edit to a loser-only field was undetected and silently
   overwritten. Fix: also flag stale when a loser-only field flip will write
   diverged from the value flip would write; bulk flipAllToSide now SKIPS stale
   entries instead of silently applying them.

Adds regression specs for the un-conflicted-field ride-along, multi-remote-op
refusal, and loser-only stale detection (per-entry + bulk). Full conflict suite
green (disjoint-merge 12, ui 24, conflict-resolution 138, journal 20, hook 6,
util 14, review-util 13, superseded 42, banner 3, page 6).

* fix(sync): scope flip stale-guard loser-only check to remote-won entries

Re-review of efe66d7 found the new loser-only stale check false-positives on
LOCAL-won conflicts: there the loser is the REMOTE side, whose value was never
applied (current holds the un-recorded base), so current != flipVal is the
NORMAL post-resolution state, not an edit. That made flipAllToSide silently skip
legitimate local-won entries and single flip nag with a spurious confirm.

Scope loserOnlyStale to winner==='remote' (the only case where the loser's
optimistically-applied local value persists, giving a valid unedited baseline).
Remote-won silent-loss detection (the originally-reported defect) is unchanged.
Loser-only fields on LOCAL-won entries remain undetectable without a journaled
post-resolution baseline — documented as a follow-up. +2 regression specs
(local-won no-false-positive: getStaleState + bulk flip).

* fix(sync): restrict disjoint-merge to types with a RECREATE_FALLBACK

The merged op is a partial delta. If it wins over a concurrent DELETE on a
passive-observer client (one that already applied that delete), it reaches
lwwUpdateMetaReducer's addOne recreate branch WITHOUT passing through the
full-entity reconstruction in _convertToLWWUpdatesIfNeeded (that runs only for
conflict winners, not non-conflicting remote ops). For a type without a
RECREATE_FALLBACK (NOTE/METRIC/TASK_REPEAT_CFG/ISSUE_PROVIDER) the bare partial
addOne yields a Typia-invalid entity -> 'Repair failed' dead-end; the parent's
full-snapshot merged op recreated validly, so the partial delta is a regression
there.

Refuse disjoint-merge for fallback-less types (fall back to whole-entity LWW,
whose local-win op carries a full snapshot). Residual: fallback types can still
recreate with DEFAULT_* backfill diverging in that rare 3-device race — the same
bounded limitation already documented in recreate-fallback.const.ts. +regression
spec (NOTE disjoint conflict -> LWW, not merged).

* fix(sync): harden conflict-journal failure and lifecycle paths

Three hardening improvements from the final review pass:

1. Journal disjoint merges only AFTER the merged op is durably appended
   (STEP 3b), not at plan time. A 'merged' entry claims both sides were
   kept, which is only true once the op is persisted — an append failure
   could previously leave a phantom 'kept both' journal entry. +regression
   spec (a6): append failure -> no merged journal entry.

2. Extend the never-throw contract to ALL ConflictJournalService methods.
   list() is awaited (via maybeShowSummaryBanner) inside
   autoResolveConflictsLWW's notification step — i.e. after ops were
   already applied — so a transient IndexedDB failure there failed an
   otherwise-completed sync. list -> [], getEntry -> undefined,
   markKept/markFlipped swallowed (entry stays unreviewed, user retries).
   +spec.

3. Recover from abnormal IndexedDB closure: idb's terminated hook now
   drops the memoized _db/_initPromise handles so the next call reopens
   instead of failing on a dead connection for the rest of the session
   (deferred fourth-pass item). +spec.

Plus: reciprocal note in recreate-fallback.const.ts that membership also
opts a type into disjoint-merge, and doc updates for the new contracts.

* test(sync): pin the terminated-hook wiring in the journal recovery spec

The recovery spec called _resetDbHandles() directly, so removing the
terminated: callback from openDB (functionally reverting the force-close
recovery) kept all tests green. Fire the real 'close' event idb listens
for instead (duck-typed FakeEvent for fake-indexeddb) and assert the
handles were cleared. Mutation-verified: deleting the terminated line
now fails this spec.

* fix(sync): clear conflict journal inside the op-log lock on import

Author-review finding: importCompleteBackup released the OPERATION_LOG
lock before clearAll(), leaving a narrow cross-tab window where a
concurrent conflict resolution's fresh post-import journal entry gets
wiped. Clearing inside the lock serializes the clear strictly before any
post-import journaling. (_resetAllLastServerSeqs is journal-independent
local bookkeeping and keeps its persist-first ordering.)

Also documents the merged-op composition residual from the same review:
a merged partial op is not closed under later whole-op LWW composition
in the no-pending-local concurrent-apply path — verified pre-existing
(branch and behavior identical at the pre-SPAP-14 merge-base with plain
user ops), so recorded as a class-level op-log residual rather than
patched here.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
2026-07-11 17:48:46 +02:00
Rushi
830b7acefa
refactor(config): reduce duplicated form and selector boilerplate (#8928)
- sync-form: shared rootSyncProvider/isSyncProvider/isNotSyncProvider
  helpers replace ~25 repeated parent-depth syncProvider predicates
- keyboard-form: drop two dead commented-out field blocks
- global-config.reducer.spec: itBehavesLikeConfigSectionSelector helper
  replaces 10 duplicated describe blocks (same assertions)
- config-page: shared tab-label ng-template + isSectionExpanded()
  replace 6 duplicated template blocks

No behavior change.

Closes #7922

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 16:11:09 +02:00
J. Loops
7733c4a7e5
feat(work-view): show break time today (#8909) 2026-07-11 15:37:57 +02:00
Johannes Millan
b52087338e
feat(tasks): navigate from empty add-subtask input (#8916) 2026-07-11 13:33:35 +02:00
Lane Sawyer
ae158f8cd5
docs(development): add instructions for setting up local tests with Chromium (#8887)
* docs(wiki): document CHROME_BIN setup for unit tests

The pre-push hook runs the Karma unit tests, which need a resolvable
Chrome binary. Without a system Chrome or CHROME_BIN set, git push
fails with "No binary for Chrome browser". Add a section covering both
a system Chrome install and installing Chrome for Testing via
@puppeteer/browsers with CHROME_BIN wired into the shell profile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(wiki): note snap/flatpak caveats for the test browser

Snap Chromium works but isn't auto-detected (set CHROME_BIN=/snap/bin/chromium);
Flatpak is not recommended because karma-chrome-launcher execs the binary
directly and the sandbox blocks Karma's temp profile dir.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Improve documentation around Chromium

* add bash to codeblock

* docs(wiki): fix Chrome-for-Testing install path and Chromium claims

Address review feedback:
- Option A: only real Google Chrome is auto-detected on Linux
  (karma-chrome-launcher probes google-chrome/-stable, not chromium);
  drop the overstated "finds it automatically" for Chromium.
- Option B: pin `--path "$HOME/.cache/puppeteer"` — the standalone
  @puppeteer/browsers CLI defaults to the cwd, so the bare command
  downloaded chrome into ./chrome and left CHROME_BIN empty.
- Note macOS differs (binary is "Google Chrome for Testing" in a .app,
  so `find -name chrome` is Linux-only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Human polish of instructions

* final PR feedback

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:25:02 +02:00
Lane Sawyer
5f79e0f7fe
chore(lint): remove unused eslint-disable directives (#8913)
These eslint-disable directives no longer suppress any reported
problems, so ESLint flags them as unused directive warnings. Remove
them to clear the 11 lint warnings.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:13:06 +02:00
Salma Abdelrhman Mostafa Mahmoud
c760b35dca
fix(accessibility): add ARIA roles, live regions, and alt attributes to banner component (#8888)
Co-authored-by: SalmaAbdelrhmanMostafaMahmoud <salmaabdelrhmanmostafamahmoud@my.uopeople.com>
2026-07-11 13:06:50 +02:00
Lane Sawyer
c437dfc35a
chore(ui): removes dead utilities and op-log leftovers [#8260 - Tier A] (#8892)
* chore(ui): removes dead utilities and op-log leftovers [#8260 - Tier A]

* update readme and remove-unused-log-imports.ts

* restore benchmark test and make runnable
2026-07-11 13:05:34 +02:00
Johannes Millan
5220684b7e
fix(app): hide donation page on macOS (#8915)
* fix(app): hide donation page on macOS

Use the stable macOS bridge instead of the MAS runtime flag and redirect direct donation-page navigation on restricted Apple builds.

* fix(app): harden donation platform gating

Give the restriction a behavior-specific contract, cover platform classification and real router redirects, and correct the platform documentation.
2026-07-11 13:02:19 +02:00
Lane Sawyer
6f1cacc553
chore(scripts): remove one-off codemod scripts [#8260 - Tier A] (#8893)
* chore(scripts): remove one-off codemod scripts [#8260 - Tier A]

* Address PR feedback
2026-07-11 10:36:24 +02:00
Johannes Millan
c6480d1cae
fix(sync): harden SuperSync E2EE against metadata tampering (GHSA-8pxh-mgc7-gp3g) (#8904)
* fix(sync): defense-in-depth vs entityId retarget on encrypted ops

SuperSync E2EE (AES-256-GCM) covers only op.payload; metadata fields
(entityId, opType, actionType, vectorClock, isPayloadEncrypted, ...)
travel as plaintext and are not bound by the auth tag. A malicious/
compromised sync server or MITM could retag an encrypted LWW-update op
with a different entityId, redirecting the authenticated changes onto an
attacker-chosen entity — convertOpToAction() previously trusted the
tampered entityId over the authenticated payload.id (coercing even a
missing payload.id) and only warned.

At the decrypt boundary (where encryption origin is known) verify that
an in-scope LWW op's authenticated payload carries a string id equal to
op.entityId; otherwise fail closed via a new OperationIntegrityError,
distinct from DecryptError so it does not trigger the enter-password
dialog. The gate mirrors convertOpToAction's predicate (alias resolution
+ singleton exclusion) so the two boundaries cannot drift.

Scoped defense-in-depth for GHSA-8pxh-mgc7-gp3g, NOT full integrity.
Still open (durable AAD-envelope fix): plaintext-injection downgrade via
isPayloadEncrypted=false (needs a download-side mandatory-encryption
guard), opType promotion, entityType swap, vectorClock replay. Correct
the overstated integrity claim in the encryption architecture doc.

* fix(sync): reject plaintext ops when SuperSync encryption is mandatory

The isPayloadEncrypted flag is unauthenticated plaintext metadata, so a
compromised SuperSync server or MITM could set it to false and inject a
fully attacker-authored plaintext op — it would skip decryption AND the
payload/metadata integrity check and be applied verbatim (arbitrary op
forgery). This is a strictly more powerful bypass than the ciphertext
entityId retarget closed previously.

assertOpsEncryptedWhenExpected rejects any inbound plaintext op (download
+ piggyback paths) when encryption is enabled. It gates on config INTENT
(isEncryptionMandatory && isEncryptionEnabled()), not key presence, so it
also fails closed in the dropped-credential state (a !!encryptKey gate
would fail open there). Safe with no legacy-data loss: enabling
encryption deletes the server copy and re-uploads everything encrypted,
so no legitimate plaintext op remains; a never-encrypted account
(isEncryptionEnabled()===false) still accepts plaintext. The SuperSync
op-level twin of the file-based GHSA-vrc7 download guard and the
GHSA-9544 upload guard.

Also give OperationIntegrityError a dedicated sync-wrapper branch: fail
closed with a calm translated message instead of the raw GHSA/technical
string.

Follows up the review of GHSA-8pxh-mgc7-gp3g.
2026-07-10 19:06:27 +02:00
J. Loops
e972de039e
feat: add Home Assistant Bridge to community plugins (#8891)
* feat: add Home Assistant Bridge to community plugins

* feat: add Home Assistant Bridge to community plugins
2026-07-10 18:10:01 +02:00
Johannes Millan
0aa2941947
docs(sync): note local-file rev-check/write is non-atomic (#8898) (#8902)
Record the LocalFile check-then-write TOCTOU race as an accepted limitation
and correct stale references in the reliability doc:

- §5 documents the non-atomic rev-check + write as an accepted limitation,
  honestly scoping each mitigation: the upload lock only serializes a single
  client's own uploads (not across machines); the mismatch-fallback catches only
  a concurrent write visible at check time (never force-overwriting), so a write
  landing inside the check→write window can still be lost; .bak recovery is
  best-effort and only covers a corrupt/interrupted primary, not a valid
  concurrent overwrite or a fully-missing one.
- Distinguishes torn writes (prevented on Electron via temp-file + renameSync;
  still in-place on Android SAF) from the CAS race (not closed by atomic rename;
  needs OS-level CAS, left accepted).
- §1 corrected: _uploadWithRetry()/:474/"retries once" → current
  _uploadWithMismatchFallback with _MAX_UPLOAD_RETRIES=2 (3 attempts), and clarify
  that genuine concurrency throws immediately rather than exhausting retries.
2026-07-10 17:31:24 +02:00
Johannes Millan
2bc3641cfa 18.14.0 2026-07-10 17:23:35 +02:00
Johannes Millan
d5db11f329
fix(tasks): remove postal-mime dep and harden eml import (#8901)
* style(tasks): elevate add-subtask input

* fix(tasks): parse eml files without external dependency

Replace postal-mime with a bounded parser for sender, subject, and unencoded plain-text bodies. Ignore unsupported MIME body formats and document the root dependency policy.

* fix(tasks): isolate and harden eml import

Lazy-load the local parser, reject lossy or unsupported bodies, and store accepted text as literal notes so imported content cannot trigger remote resource loads. Document the title-only fallback and expand regression coverage.

* fix(tasks): decode eml headers and harden import parsing

- Decode RFC 2047 encoded-words in the subject and sender name so
  international titles show "Grüße" instead of raw "=?UTF-8?...?=".
- Replace the charset regex with a quote-aware Content-Type parser so a
  charset inside another quoted parameter can't be mistaken for the real
  one, and ignore RFC 822 header comments (e.g. "7bit (comment)").
- Cap the synced title (300) and body (100k) so a crafted .eml can't
  amplify into an oversized op (the literal fence can double the body).
- Document parseEml's intentional MIME omissions to prevent a later
  "fix" that reopens the untrusted-body attack surface.
2026-07-10 17:05:50 +02:00
Johannes Millan
76f2371a56 style(tasks): elevate add-subtask input 2026-07-10 15:04:17 +02:00
Johannes Millan
ec81c226c0
fix(config): restore day-start offset after operation replay (#8899)
* chore(deps): pin npm version to remove lockfile drift

Contributors on npm 10 (Node 22's bundled default) vs npm 11 reconcile the
app-builder-lib.minimatch override differently, rewriting package-lock.json on
npm install. Pin npm 11.7.0 via the standard packageManager field (Corepack)
and complete the existing volta block so volta users pick it up automatically.

Ref: discussion #8869

* chore: stop tracking .claude/skills symlink (runtime-managed)

The Claude Code runtime scaffolds .claude/ each session and materializes
.claude/skills as a real directory, clobbering the committed symlink
(-> ../.agents/skills). Git then reported a perpetual 'D .claude/skills' in
every worktree/session. Skill sources remain tracked under .agents/skills/;
let the runtime own .claude/skills and ignore it via the existing /.claude/*
rule.

* fix(config): restore day-start offset after operation replay

Reconcile DateService and app-state from config after incremental or full-state bulk replay without re-minting persistent task operations.

Fixes #8890

* fix(sync): replay full-state operations through reducers

Normalize canonical full-state operation types to LOAD_ALL_DATA during replay while preserving their original operation metadata. This lets repair and legacy backup operations install state through the existing reducers.
2026-07-10 13:59:48 +02:00
Johannes Millan
c68929d1fb
fix(task-repeat): allow selecting day-of-month recurrence (#8896)
* fix(task-repeat): allow selecting day-of-month mode

* fix(task-repeat): clarify monthly recurrence pattern

* test(task-repeat): cover day-of-month persistence

* docs(task-repeat): explain custom monthly patterns
2026-07-10 13:55:23 +02:00
Johannes Millan
762b3c5d8d
feat(plugins): add Todoist import plugin with Import/Export launcher (#8882)
* feat(plugins): add Todoist import plugin with Import/Export launcher

* fix(plugins): fix cross-chunk subtask loss in todoist-import + hardening

* fix(plugins): clamp non-finite Todoist durations + review polish

* feat(plugins): add Eisenhower priority mapping to Todoist import

Replace the single "map priorities to p1-p3 tags" checkbox with one
mutually-exclusive control (Nothing / p1-p3 tags / Eisenhower matrix).

The new Eisenhower option reuses SP's built-in urgent/important tags by
title (p1 -> urgent+important, p2 -> important, p3 -> urgent, p4 -> none),
so imported tasks populate the Eisenhower Matrix board with no new tag
and no new plugin API. Collapsing Todoist's single priority axis onto the
2-D matrix is opinionated, so it stays opt-in and off by default.

Requested in the review of #8882.

* fix(plugins): harden Todoist import data integrity

* fix(plugins): improve Todoist import feedback
2026-07-10 13:30:03 +02:00
John Costa
d2015beb44
fix(task): schedule overdue tasks for today via Shift+T and guard stale task focus (#8867)
* fix(task): schedule overdue tasks for today via Shift+T and guard stale task focus (#8851)

* fix(planner): only bind data-task-id on planner-task when focusable

The unconditional data-task-id host binding broke the e2e done-confirmation
strategy in e2e/pages/task.page.ts, which selects its wait branch based on
the attribute's presence. Boards render planner-task cards without an inner
<task> element, so the id-based wait could never resolve (#8851).

* refactor(tasks): share overdue predicate across selector and util

The rebase onto #8858 re-inlined the overdue comparison into
selectOverdueTaskIds and dropped the delegation to isTaskOverdue, so the
two overdue definitions (the overdue list vs. the Shift+T "Add to Today"
path) were duplicated and free to drift — the exact footgun #8851 set out
to avoid, and the util's JSDoc still claimed the selector delegated to it.

Extract isTaskOverdueByThreshold as the single source of truth for the
comparison and getLogicalTodayStartMs for the boundary. isTaskOverdue and
selectOverdueTaskIds both route through it, so they cannot drift. The
selector still computes the threshold once per recompute (no per-task date
parsing), preserving #8858's perf posture. No behavior change.

* test(e2e): base markTaskAsDone strategy on <task> host, not attr

planner-task now carries data-task-id in the Planner overdue list (#8851),
so keying the done-confirmation strategy off data-task-id presence would send
a wrapper down the <task> path — document.querySelectorAll('task') finds no
match and the wait hangs 10s. Key it off the element actually being a <task>
instead. No behavior change for real <task> rows; every wrapper keeps the
300ms wrapper path. Verified: worklog-basic (real task) and boards #7498
(planner-task wrapper) both pass.

---------

Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
2026-07-10 12:07:19 +02:00
Lane Sawyer
6bb0472549
chore(ui): removes dead components and features [#8260 - Tier A] (#8889) 2026-07-10 11:40:35 +02:00
Johannes Millan
d02f2271da
fix(gitlab): link issues API doc for search help (#8884) (#8894) 2026-07-10 11:39:17 +02:00
Rushikesh Barve
23b1d4adf0
fix(a11y): name icon-only buttons, add keyboard access, fix task tabindex (#8885)
* fix(a11y): name icon-only buttons, add keyboard access, fix task tabindex

Addresses the three groups from #8826:

- Add translated aria-labels to unnamed icon-only buttons (search
  clear, note/side-nav/feature more_vert triggers, focus-mode close,
  board edit, track-time dialog suffix buttons); simple-counter
  buttons use the counter title as their accessible name. Adds one
  new global key G.MORE_ACTIONS.
- Make mouse-only controls keyboard-accessible (role=button,
  tabindex=0, Enter/Space handlers, aria-expanded where they toggle):
  formly-collapsible header, worklog week day rows, task detail panel
  created/completed date editors, and the task time/estimate cell
  (attributes conditional so parents with subtasks gain no tab stop).
- Change task host tabindex from 1 to 0 so tasks no longer jump ahead
  of all other page content in tab order (WCAG 2.4.3).

Fixes #8826

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(a11y): import TranslatePipe in focus-mode-overlay

FocusModeOverlayComponent's template uses the translate pipe (added
aria-label) but the standalone component did not import TranslatePipe,
breaking the production AOT build (NG8004). Add it to imports, matching
every other component touched in this change.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
2026-07-10 11:38:38 +02:00
Lane Sawyer
ed26c2a9ff
fix(project): carry sections forward when duplicating a project (#8872)
* fix(project): carry sections forward when duplicating a project

Duplicating a project recreated its tasks and notes but never touched
the section store, so the copy came up with a flat, uncategorized task
list. Duplicate the template's sections too, remapping each section's
taskIds through an old->new task id map so tasks keep their section
membership in the copy.

Fixes #8293

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Address PR feedback

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 11:02:59 +02:00
Yehonatan Avrahimi
18c48d5ea8
feat(tasks): add task through eml file hover (#8282)
* feat(eml): add EML file parsing and task creation on drop

Currently a simple skeleton, needs more work, but should be a good POC before refining.

Fixes #518

* fix(main-header): correct email message formatting and handle EML upload errors

while this commit looks bad, its a first commit before refining this solution and sending the final PR.

Fixes #518

* refactor(eml): move eml-parser to util and tighten isFileEml check

* feat(eml): on eml hover error parse error to log and show a snack message

* bugfix(eml-typecheck): tighten type for 'sender' to not have bugs

* refactor(eml-drop): updated file locations for each file drop

* bugfix(eml-drop): use stable npm package for parsing eml files

* bugfix(eml-drop): remove relevant "dev" flag

* refactor(eml-drop): drop empty emails with a snack instead of throwing

* test(eml-drop): add isFileEml and parseEml unit tests

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* rafactor(eml-service): refactor code to be consistent  as written in issue number 3

* bugfix(eml-drop): remove file name when geting an error

as written in 1. inside the pr

* docs: update task creation instructions to include .eml file support

* refactor(eml-parser): update import statement for PostalMime to dynamic import

* refactor(eml-drop): enhance file drop handling and improve error logging

* test(eml-drop): add unit tests for EmlDropDirective and EmlDropService

* feat(tasks): save eml body to task notes and harden eml parsing

- capture the parsed plain-text email body into task notes (text/plain
  only, never HTML, to avoid injecting untrusted markup into markdown notes)
- pass the File/Blob straight to postal-mime so it applies the message's
  own charset/transfer-encoding instead of forcing UTF-8 via file.text()
- use relative import paths to match repo convention
- document why the drop handler relies on the global dragover preventDefault
- update wiki wording + tests for the new add() signature and notes behavior

* fix(tasks): harden eml-to-task against untrusted subject/size

Follow-up to the eml-drop feature from a multi-agent review:

- suppress short-syntax on imported email subjects: email content is
  untrusted external input, but TaskService.add() dispatches addTask which
  ShortSyntaxEffects parses for #tag/@date/+project/URL tokens — stripping
  them from the intended literal "sender: subject" title and mutating
  tags/scheduling/attachments. Thread isIgnoreShortSyntax through add()
  (matching the existing plugin-bridge pattern) and set it here.
- guard against oversized .eml files (postal-mime parses synchronously on
  the main thread and the body syncs to every device) with a 10MB cap +
  EML_TOO_LARGE snack.
- trim sender/subject so whitespace-only headers don't bypass the empty
  check or block the address fallback.
- drop the stale "add attachment" TODO.

* fix(tasks): log bounded eml parse reason, not the raw error

The source is untrusted email content and the log history is exportable
(rule #9). Log only e.message (postal-mime's throw messages are structural,
so no email content leaks) instead of the raw error object + stack.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
2026-07-09 17:58:35 +02:00
Johannes Millan
df88d43f22
refactor(i18n): unify Due/Deadline/Planned/Scheduled labels (#8819) (#8878)
Resolve the terminology collision where "Due" labeled both the deadline
and the planned-day concept. Standardize UI state labels on "Deadline"
(deadlineDay/deadlineWithTime) and "Planned" (dueDay/dueWithTime), and
move the deadline-passed label off "Overdue!" to "Past deadline!" so it
no longer clashes with the planned-past Overdue list.

en.json values only; keys unchanged (t.const.ts regenerates identically).
The "Schedule" action verb/keybinding and the issue-provider "Due Date"
field are intentionally left. The behavioral question of what "Overdue"
should mean is split out to #8877.
2026-07-09 17:12:59 +02:00
Johannes Millan
e11b5ec5a2
feat(tasks): create a task from a link dropped on the app (#8879)
* feat(tasks): create "Check <url>" task from links dropped on empty app chrome

Dropping a web link anywhere on the app except onto a task, note, or panel
now creates a "Check <url>" task with a success snack, mirroring the Android
share-a-link flow. Task/note/panel drop handlers already stopPropagation, so
the document-level handler only sees links dropped on empty chrome.

- add pure getDroppedUrl() helper (http(s) only; rejects files, plain text,
  non-web schemes, and whitespace/multiline selections)
- wire the existing global onDrop in AppComponent to create the task
- add APP.DROP_LINK translations

* fix(tasks): robustly extract dropped link URL; stop notes double-fire

The dropped-link → task feature could no-op for real link drags: getDroppedUrl
only read text/plain and rejected anything with inner whitespace, but browsers
often deliver the URL only in text/uri-list, or as "<url>\n<title>" in
text/plain (and Electron cross-app drops fill only one). Now scan both types
line by line and take the first exact http(s) URL.

Also fix a duplicate-task bug found in review: dropping a non-image link on the
Notes panel created BOTH a note and a "Check" task, because NoteService only
calls stopPropagation after an await (too late). Claim the event synchronously
in the notes drop handler.

* feat(tasks): give dropped-link tasks a readable title + clickable attachment

Mirror the Android share flow more closely: the dropped-link task now gets a
human-readable title ("Check example.com: some article") plus the raw URL as a
clickable LINK attachment, instead of a raw URL crammed into the title.

Extract the shared readableUrl() helper out of android.effects into
util/readable-url.ts (now used by both the Android share effect and the desktop
drop handler) with its own spec.
2026-07-09 17:11:06 +02:00
Johannes Millan
36aba1c5c5 fix(ci): relax yamllint line-length for SHA-pinned wiki-sync workflow
SHA-pinning the actions in wiki-sync.yml (#8868) pushed the
setup-python `uses:` line to 84 chars, tripping yamllint's default
80-char line-length rule and failing the Lint job on every wiki push
and every PR touching wiki paths.

Relax the yamllint step to accept the 40-char SHA pins and their
single-space `# vX.Y.Z` version comments. Tightening the comments to
two spaces instead would push the already-80-char pin lines over the
limit, so raising the ceiling is the correct fix.
2026-07-09 17:03:51 +02:00
Lane Sawyer
0f397cd9b0
fix(nav): prevent icon jump on sidebar close (#8866) 2026-07-09 16:21:56 +02:00
dependabot[bot]
84bedacd22
chore(deps-dev): bump ws (#8876)
Bumps the npm_and_yarn group with 1 update in the /packages/plugin-dev/sync-md directory: [ws](https://github.com/websockets/ws).


Updates `ws` from 8.18.3 to 8.21.0
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.18.3...8.21.0)

---
updated-dependencies:
- dependency-name: ws
  dependency-version: 8.21.0
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-09 15:57:08 +02:00
John Costa
24c799ee13
fix(work-context): make isTodayListActive reactive to context changes (#8863)
task.component's `isTodayListActive` computed read the plain mutable
`WorkContextService.isTodayList` boolean, which is not a signal producer,
so the computed never invalidated and returned its first value for the
component's lifetime.

Expose `isTodayListSignal` (a toSignal mirror of isTodayList$) on the
service and read that from the computed. The plain boolean stays for the
synchronous reader in history.component.

#8843
2026-07-09 15:35:59 +02:00
aakhter
8a1105bbb2
perf(tasks): content-stable scheduling snapshot for collection task selectors (#8858)
* perf(tasks): content-stable scheduling snapshot for collection selectors

The collection selectors (overdue, due-time-sorted, deadline-sorted,
due-day, later-today, all-with-subtasks, and today-task-ids) all derive
from selectAllTasks, which returns a new array every second while a task
tracks time. None of them read timeSpent, yet each re-ran its O(n)
filter/sort and reallocated every tick.

Introduce selectTaskSchedulingSnapshot: a per-entity ref-cached, array-
ref-stable projection of the scheduling fields only (id, isDone,
dueDay/dueWithTime, deadlineDay/deadlineWithTime, parentId, subTaskIds),
so a timeSpent-only tick yields the identical snapshot ref. Each
collection selector now derives its filter/sort DECISION (an ordered id
list) from the snapshot — skipped by NgRx memoization when the snapshot
is stable — and a per-selector memoized re-map turns the stable id list
back into live task entities, keeping the existing output types and
returning the previous array ref unless a genuine member changed. No
consumer/API changes; task refs stay live (no staleness).

selectTimeConflictTaskIds is intentionally left on the live sorted
selector because getTimeLeftForTask reads timeSpent.

SPAP-20

* test(tasks): clear leaked selectAllTasksWithDueTimeSorted override between tests

dialog-schedule-task.* and time-block-sync.effects specs call
store.overrideSelector(selectAllTasksWithDueTimeSorted, ...). overrideSelector
uses setResult(), which sticks on the singleton selector across spec files and
is only reset by clearResult() (not release()). When one of those specs ran
before task.selectors.spec in the full karma bundle, the new
selectAllTasksWithDueTimeSorted(mockState) test got the leaked value instead of
computing from mockState — "Expected [ ] to contain 'task5'". Single-file runs
passed, so it only surfaced in CI's full run.

Add the selector to the existing beforeEach clearResult()/release() defensive
block (mirrors selectOverdueTasks etc.). Repro: dialog-schedule-task +
task.selectors together went 1 FAILED -> 90 SUCCESS.

SPAP-20
2026-07-09 15:32:22 +02:00
Lane Sawyer
87f15c8c65
chore(config): adopt AGENTS.md as shared AI-agent config with skills (#8864)
Make AGENTS.md the single source of guidance read natively by Claude
Code, OpenAI Codex, and GitHub Copilot. CLAUDE.md becomes a symlink to
it. Extract commit-message guidance into a reusable Agent Skill under
.agents/skills (read by Codex and Copilot); .claude/skills symlinks to
it for Claude Code, and .gitignore is narrowed to /.claude/* so only
that skills pointer is tracked. Drop the now-redundant
.github/copilot-instructions.md symlink since Copilot reads AGENTS.md.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 15:30:56 +02:00
Lane Sawyer
c2ab4fe973
ci(actions): SHA-pin remaining workflow actions (#8868)
* ci(actions): SHA-pin remaining workflow actions

Replace the mutable derekprior/add-autoresponse@master ref in
issue-open-auto-reply.yml with an inline actions/github-script snippet
to remove the third-party code-injection risk in CI. SHA-pin the 4
remaining tag-pinned actions in wiki-sync.yml (checkout, setup-python)
to match the rest of the workflows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* wrong sha for the github-script v9

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 15:28:59 +02:00
Johannes Millan
7f604f68f7
feat(task-repeat): default skip-overdue for new everyday recurring tasks (#8861)
* feat(task-repeat): default skip-overdue for new everyday recurring tasks

New recurring configs now default skipOverdue ("Don't let overdue instances
pile up") to ON only for a plain everyday schedule — the "Daily" preset or a
CUSTOM every-single-day cycle. That is the one case where the option is both
useful and provably safe: everyday tasks are the only schedule that actually
piles up (one empty overdue copy per missed day), and today is always
scheduled so a missed instance regenerates the same day and can never
silently vanish (it cannot even drop to zero).

Every other schedule stays OFF — workday/weekly/monthly/yearly and every-N-day
custom cycles keep their one missed occurrence visible, so a real obligation
(pay rent on the 1st, renew the domain) never disappears until its next
occurrence. Deriving the default purely from the effective schedule means the
"Daily" preset and a CUSTOM every-day cycle (the same schedule entered two
ways) get the same default — no "same schedule, different default" surprise.

The default is seeded from the chosen schedule in both config-creation paths:
the repeat dialog (re-derived from the final schedule on save, and skipped
when the user explicitly toggled the Advanced checkbox) and the inline
add-task-bar recurrence. Existing configs and DEFAULT_TASK_REPEAT_CFG
(skipOverdue: false) are unchanged; only newly created configs are affected.

Supersedes the broader daily+Mon-Fri variant, dropping the schedule-change
checkbox re-sync machinery: an Advanced checkbox opened on a Daily config and
then switched may briefly show a stale ON, but save always persists the safe
re-derived value.

* docs(task-repeat): clarify custom every-day default; signpost baseline

Multi-review follow-ups (no behavior change):
- Wiki 2.06 + 4.13: a CUSTOM every-single-day cycle also defaults skipOverdue
  ON (it is the same schedule as the Daily preset); "off" now reads "custom
  cycles longer than a day" so the docs match getDefaultSkipOverdue.
- Comment on DEFAULT_TASK_REPEAT_CFG.skipOverdue pointing to the schedule-aware
  creation default, so the model's `false` baseline is not mistaken for the
  effective default.
- Tighten the save() display-gap comment: the cosmetic seeded-ON/persist-OFF
  gap applies to any new non-Daily config whose Advanced panel is opened
  untouched, not only a Daily→switch.

* refactor(task-repeat): use type-only import in skip-overdue predicate

TaskRepeatCfgCopy is used only in type position; match the codebase's import type convention (58 other files). No behavior change.
2026-07-08 19:55:37 +02:00
John Costa
5edb659a65
fix(archive): write compressed archives in a single atomic transaction (#8862)
compressArchive() persisted archiveYoung and archiveOld via two
independent writes (Promise.all). A crash between them left a
half-compressed archive, and because compression is op-replayed on
other clients, a torn local result diverged from replicas.

Route both writes through the existing saveArchivesAtomic API (one
IndexedDB transaction over both stores), matching the flush-young-to-old
path in archive-operation-handler.service.ts. Adds a regression spec.

#8843
2026-07-08 19:54:30 +02:00
Johannes Millan
6236dc7458
fix(tasks): add sub-task submit button and commit-on-blur on touch (#8860)
Android/mobile soft keyboards hide the Enter key or deliver it as a composing
keydown that onKeydown filters out (to protect CJK candidate confirmation), so
the inline sub-task draft could not be committed at all: neither Enter, tapping
away, nor any on-screen control saved the text.

- Add an always-visible submit button beside the input as an accessible,
  discoverable commit target (mouse-clickable, screen-reader labelled). Its
  mousedown is preventDefaulted so a desktop click keeps input focus, commits,
  and keeps the field open for rapid entry — mirroring the main add-task bar.
  On touch the tap commits via the blur path below.
- Commit the draft on blur, but only on touch (isTouchActive): the natural
  mobile "done" gesture is tapping away, and the soft-keyboard Enter is
  unreliable there (#8791). Desktop keeps click-away-to-cancel — Enter and the
  button are the reliable commit paths — so blur no longer silently creates a
  task, and a user can move to the button without the draft being committed out
  from under them. Escape still discards on all devices.

Both commit paths read the live input value, so IME/predictive-text buffering
no longer strands the text.

Closes #8856. Supersedes the standalone #8791 commit-on-blur branch.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 17:16:42 +02:00
Johannes Millan
420292e37c feat(notes): allow outlook: + note/task-app deep-links in URL-scheme allowlist (#8859)
Restore outlook: (reported in #8859) plus the same-class note/task apps
notion:, things:, omnifocus:, bear:, joplin: that the GHSA-hr87 fix also
silently broke. All low-risk registered-app deep-links, same class as the
already-allowed obsidian:/logseq:/zotero:.
2026-07-08 17:12:09 +02:00
aakhter
0e0b82542c
perf(ui): make date/keys pipes pure to stop per-CD formatting (#8854)
* perf(ui): make date/keys pipes pure to stop per-CD formatting

localeDate and scheduledDateGroup were pure:false, so they re-ran (and
localeDate re-allocated a DatePipe + formatted via Intl) on EVERY change-
detection pass — 35-42 times per CD in the schedule month grid alone.
keys was pure:false too; to-array was impure with zero usages.

Make all four pure (delete to-array). Preserve locale reactivity: the
three hot grids (schedule-month/week, planner-day) precompute their day
labels in a computed() keyed on currentLocale(), binding plain strings
(removes the per-cell pipe entirely); the remaining localeDate call
sites pass the reactive locale as an explicit arg (:locale()) so change
detection re-formats only when the locale signal changes.
scheduledDateGroup takes today as an arg. keys becomes pure — and
dialog-time-estimate.deleteValue now replaces the map reference instead
of deleting in place so the pure keys pipe still updates.

SPAP-26

* test(pipes): stub DateTimeFormatService in specs broken by pure-pipe change

SPAP-26 added inject(DateTimeFormatService) to DialogSyncImportConflict and
IssuePanelCalendarAgenda so their templates can pass the reactive locale to
the now-pure localeDate pipe. Both component specs construct the component but
their TestBeds never provided the service, so DI failed at construction with
NG0201 (DateTimeFormatService -> GlobalConfigService -> Store) — 7 + 2 = 9
failing specs.

Provide the same { currentLocale, formatTime } stub already used in the
dialog-sync-import-conflict render block. Both spec files now pass
(9 + 2 SUCCESS locally).

SPAP-26
2026-07-08 17:02:36 +02:00
Johannes Millan
761c08056c ci(sync): detect PR changes via merge-base, not stale base.sha
The E2E Sync PR gate ran the full SuperSync + WebDAV suites on PRs with
no sync changes. The detect job diffed `${BASE_SHA}...HEAD` where HEAD is
the refs/pull/N/merge commit (PR merged into current base) and BASE_SHA is
the event-time pull_request.base.sha. As the base branch advances while a
PR is open, that sha goes stale; since it stays an ancestor of the merge
commit, the three-dot range expands to every commit merged into the base
since the PR opened, tripping the sync patterns on unrelated churn.

Diff the PR head against its merge-base with the live base (HEAD^1) so the
range is exactly the PR's own changes, regardless of base movement. No
extra fetch needed: HEAD^1 and the PR head are both parents of the
checked-out merge commit, and their merge-base is reachable under
fetch-depth: 0. Verified on PR #8722: 250 leaked files -> the real 7.
2026-07-08 16:48:56 +02:00
Johannes Millan
610ca64894
fix(sync): harden file-based .bak recovery, split gap detection & conflict counts (#8857)
* fix(sync): harden file-based .bak recovery and split gap detection

Post-merge review of the SPAP-8/9/10/11 series found five defects in the
file-based sync adapter; all fixed here with regression tests proven
red/green against the previous code. Design cross-checked by a 7-reviewer
multi-agent pass; its findings are folded in.

- Split gap detection suppressed a syncVersion reset when the remote
  clock was EQUAL OR GREATER_THAN the last-seen clock — the exact bug the
  SPAP-9 review follow-up removed from the single-file path. A dominating
  client's snapshot reset (which compacts ops this client never saw) was
  treated as cosmetic, skipping snapshot hydration and silently
  diverging. Now EQUAL only, matching the single-file path.

- .bak recovery staged the CORRUPT primary's rev, which setLastServerSeq
  then promoted to _lastSeenRevs: every later poll's SPAP-10 pre-check
  read "unchanged" and skipped the re-download while the upload path (no
  .bak recovery) kept failing on the corrupt primary — sync wedged until
  another client rewrote the file. A recovery download now never
  stages/promotes the rev (each poll re-recovers and re-seeds the heal
  cache), and every site that rewrites _lastSeenRevs drops any stale
  staged rev via the shared _commitLastSeenRev.

- Snapshot uploads (force-upload / "Use Local" / E2EE re-encryption) left
  the pre-snapshot .bak behind. After a password rotation the stale
  old-key .bak was silently "recovered" by a still-old-key client
  (suppressing its wrong-password prompt) and heal-uploaded back over the
  new-key primary, reverting the rotation. Snapshot uploads now write the
  same payload to .bak FIRST, then the primary (_forceUploadWithBakFirst;
  deliberately FATAL — aborting pre-primary leaves the remote consistent
  for a retry). Applies to sync-data.json.bak and sync-ops.json.bak;
  sync-state.json.bak is deliberately exempt: its adoption is
  ref-validated (EQUAL clock vs snapshotRef), so a stale copy is inert,
  and it must keep serving the compaction crash window it exists for.

- Recovery additionally refuses a PLAINTEXT .bak when encryption is
  expected: decoding trusts the file's own prefix flags, so a plaintext
  .bak decodes even under a wrong/rotated key — the same
  wrong-password-suppression class via mode (rather than key) mismatch.

- The split migration wrote the v3 tombstone BEFORE neutralizing the
  legacy .bak (best-effort): a crash between the writes left a live v2
  .bak that an OFF client's recovery would resurrect over the tombstone,
  forking the folder. Neutralize-first, fatal. Residual: a step-3 failure
  after the migration's ops-file commit leaves a live v2 file until the
  next snapshot upload (documented at the call site).

- sync-ops.json — the hot file, rewritten on every op-bearing sync — had
  no backup at all, so a torn write wedged split sync until a manual
  force-upload. It now gets the same backup-before-overwrite + recovery +
  heal treatment as the single-file format. deleteAllData deletes the
  split files BEFORE the tombstone and treats source-of-truth deletion
  failures as errors (success:false) — it previously left every split
  file behind and reported success.

The three backup/recover pairs are collapsed into shared _writeBakFile /
_readBakFile helpers (the EQUAL||GREATER_THAN divergence above is exactly
the copy-drift failure mode this prevents); .bak file names move into
FILE_BASED_SYNC_CONSTANTS as remote-format surface.

* fix(sync): show the exact pending-op count in the conflict dialog

Compaction can fold still-unsynced ops into the snapshot baseline clock,
so the dialog's vector-clock delta could report "0 changes" right next to
"N local changes pending" — and the false 0 skipped the secondary
USE_REMOTE overwrite confirmation. The dialog now prefers the EXACT
pending-op count carried on LocalDataConflictError (new optional
ConflictData.localUnsyncedOpsCount): it is precisely "what USE_REMOTE
would discard", so both the displayed count and the >= 20-difference
confirmation threshold work from a truthful figure; the clock delta
remains the fallback when no measured count is supplied.
Display/confirmation-only — no clock or op-log semantics touched. Also
asserts the explicit-null lastSyncedVectorClock contract at the
fresh-client conflict throw sites (test gap from SPAP-7).

* chore(lint): enforce tx-handle-only access in op-log transactions

The SQLite op-log adapter serializes every entry point through a
per-connection FIFO queue; awaiting an adapter method inside a
.transaction() callback enqueues behind the transaction's own slot and
silently deadlocks all op-log persistence. The port contract documents
the precondition and #8849 promised a lint rule — this adds it
(no-adapter-in-tx, scoped to src/app/op-log) with RuleTester specs.
Matching is rename-proof: it flags access on the SAME receiver the
transaction was opened on (plain identifiers and any `this.<field>`), so
it does not depend on the field being named `_adapter`; known heuristic
gaps (extracted callbacks, aliasing, method indirection) are documented.
Also corrects the port doc: IndexedDB serializes only overlapping-scope
transactions; SQLite provides the stronger whole-connection exclusion.
2026-07-08 16:45:16 +02:00
Maikel Hajiabadi
1810412015
feat(filetree): add option to add subfolders and items within folders #8525 (#8722)
* feat(filetree): add option to add subfolders and items within folders #8525

* fix(menu-tree): prevent item duplication when creating inside folder

* test(op-log): update ActionType spec and compact code for MENU_TREE_ADD_ITEM

* refactor(filetree): dedupe folder-id prefix strip in context menu

* refactor(menu-tree): add items to folders via existing tree-update op

Rework "add project/tag inside folder" to compute the new tree in the
service and dispatch the existing updateProjectTree/updateTagTree ops,
matching how "add subfolder" and drag-reorder already persist.

Removes the newly-introduced MENU_TREE_ADD_ITEM op type (action, enum
member, compact code 'MA', reducer handler) and the on(addProject)
handler (scope creep). Benefits:
- No new synced op type (the highest-risk change class).
- Forward-compatible: old clients understand updateProjectTree/Tag, so
  the item lands in the folder everywhere (the granular op no-op'd on
  old clients, leaving the item at root).
- _insertNodeIntoFolder falls back to root-append when the target
  folder is missing, so a placement is never silently dropped.

Behavior covered by new MenuTreeService specs (insert, null-folder,
missing-folder fallback, tag move-dedup).

---------

Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
2026-07-08 16:33:40 +02:00
aakhter
eca816a68c
feat(sync): SPAP-11 opt-in split-file sync format for O(delta) syncs (#8802)
* feat(sync): SPAP-11 opt-in split-file sync format for O(delta) syncs

Splits the single sync-data.json into a small always-read/written sync-ops.json
(the commit point) plus a sync-state.json snapshot rewritten only on
compaction / force-upload / gap-repair / migration, so a normal sync transfers
O(delta) instead of O(dataset). Opt-in via the isUseSplitSyncFiles setting; the
legacy file is left as a v3 tombstone so old clients hard-stop rather than
silently diverge.

Review follow-ups:
- Recompaction triggers at combinedOps > MAX_RECENT_OPS (2000) and trims to
  SPLIT_COMPACTION_THRESHOLD (1000), so it no longer rebuilds the snapshot on
  every op-bearing sync once the folder crosses 1000 (test b2).
- Split download stages the ops-file rev to _pendingRevs (not _lastSeenRevs),
  promoted only in setLastServerSeq after durable apply — mirrors the single-file
  crash-safety path (test g).
- Split rev-precheck is gated on sinceSeq > 0, so a forceFromSeq0 download
  (USE_REMOTE / rehydrate) always fetches ops + snapshot (test h).

Stacked on SPAP-9 (#8787, merged) and SPAP-10 (#8795): the split path reuses
their per-provider causality (_lastSeenVectorClocks / compareVectorClocks) and
rev (_lastSeenRevs / _pendingRevs) infrastructure.

SPAP-11

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(sync): return whole file-based ops buffer per download page

The file-based download paths (single-file _downloadOps and split-format
_downloadOpsSplit) truncated recentOps to the caller's page size
(DOWNLOAD_PAGE_SIZE = 500) via slice(0, limit), but they return ops by array
index and ignore sinceSeq. The caller (operation-log-download.service) loops on
`hasMore`, advancing sinceSeq by the returned index-based serverSeq — so when the
buffer exceeds the page size the adapter kept re-returning the SAME oldest slice.
A behind client never received its newest ops and the loop spun until it bailed
(MAX_DOWNLOAD_ITERATIONS / in-memory cap), leaving the sync stuck (success:false)
and unable to converge.

This is the common case for the split format, whose ops buffer floor is
SPLIT_COMPACTION_THRESHOLD (1000) — always above DOWNLOAD_PAGE_SIZE. The
single-file path shares it, latent since MAX_RECENT_OPS was raised 500 -> 2000
(before, buffer == page, so hasMore never tripped).

File-based providers have no server-side cursor (they re-download the whole file
each call), so cross-call pagination cannot advance and truncation just drops the
newest ops. The recentOps buffer is already bounded by MAX_RECENT_OPS, so return
it WHOLE with hasMore=false and let the caller's appliedOpIds dedup decide what is
actually new. Returning everything (rather than slicing to a cap) also converges
safely if a future higher-MAX_RECENT_OPS client writes an over-cap buffer, instead
of re-entering the same stranding loop.

Documents the `limit` divergence on the OperationSyncCapable.downloadOps
interface. Regression tests cover a 600-op buffer with page size 500 on both the
split and single-file paths (newest ops delivered, hasMore=false).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(sync): drop redundant recentOps-length clause from gap detection (SPAP-33)

The file-based gap detector treated a trimming gap as real only when the ops
buffer was also at its cap:

    oldestOpSyncVersion > sinceSeq + 1 && recentOps.length >= <cap>

The length clause is redundant for correctness — syncVersion is contiguous and
every bump carries at least one op, so oldestOpSyncVersion > sinceSeq + 1 already
proves the op at sinceSeq+1 existed and was trimmed away, and it never
false-positives. But the clause caused a false NEGATIVE: a buffer trimmed at a
SMALLER floor than the current cap — a legacy buffer written by an old client
with a lower MAX_RECENT_OPS, or (in the split format) a buffer trimmed to
SPLIT_COMPACTION_THRESHOLD — has a genuine gap while holding fewer ops than the
cap, so the gap was suppressed and the behind client applied ops without the
snapshot base and silently diverged.

Drop the clause in both the single-file (>= MAX_RECENT_OPS) and split
(>= SPLIT_COMPACTION_THRESHOLD) paths, so a behind client correctly falls back to
the snapshot. Updates the single-file test that asserted the suppressed behavior
and adds a split-path regression (short trimmed buffer -> gap + snapshot load).

Previously deferred as SPAP-33; pulled in as it prevents silent cross-client
divergence and sits in the same download-correctness path as the pagination fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
2026-07-08 14:29:50 +02:00
Johannes Millan
fdbfb2ee9f ci: gate both SuperSync and WebDAV E2E on sync PR changes
Supersede the SuperSync-only gate with e2e-sync-pr.yml, which shares
one change-detection job and one frontend build across both suites and
exposes two independent required checks (SuperSync E2E Gate, WebDAV E2E
Gate). Each suite runs only when its own path set changed; sharing the
build avoids a third frontend build per sync PR.
2026-07-08 13:15:18 +02:00
Johannes Millan
a26dec85c3 ci: cover sync-providers in supersync e2e triggers
The SuperSync client (packages/sync-providers/src/super-sync) drives
the @supersync suite but was absent from both the new PR gate's detect
pattern and the nightly push paths, so a PR editing only the client
could merge green without the suite ever running. Add the package to
both. Also switch the detect grep to a here-string to remove a
theoretical SIGPIPE under pipefail.
2026-07-08 13:15:18 +02:00
Johannes Millan
3a10e14c72 ci: add path-gated SuperSync E2E PR check
Runs the SuperSync E2E suite on PRs only when sync-affecting code
changed, via a detect/build/shard/gate job shape. The always-running
gate job is the required status check, so path-filtered runs never
leave a required check pending. Nightly scheduled run remains the
backstop for cross-cutting changes the path heuristic misses.
2026-07-08 13:15:18 +02:00
aakhter
ae7cfe1646
perf(sync): SPAP-10 skip full download when the remote rev is unchanged (#8795)
Adds a cheap getFileRev pre-check to the file-based sync download path: when the
provider reports the same rev we last confirmed, skip the full sync-data.json
download and return an empty "nothing new" result. Any getFileRev error falls
through to a full download, so the check never fails a sync.

Review follow-ups:
- Allowlist is [Dropbox, OneDrive] — the two providers whose getFileRev is a
  cheap metadata-only call with a content-stable rev. WebDAV and LocalFile are
  excluded: their getFileRev does a full-body read (no bandwidth saved, and a
  changed rev would download the file twice) and can return a weak/coarse
  validator. WebDAV re-add behind a strong-ETag/PROPFIND check: SPAP-29.
- The downloaded rev is staged as pending and promoted to last-seen (and
  persisted) only in setLastServerSeq — after the caller durably applies the ops,
  the same ordering as the seq cursor. A crash between download and apply can no
  longer strand a rev ahead of un-applied ops and skip them on the next poll.

Also isolates the Sync Cycle Cache integration specs onto the LocalFile provider
so they exercise in-cycle cache invalidation without being short-circuited by the
rev-precheck.

SPAP-10

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 13:05:59 +02:00
John Costa
3dc9dc8db2
fix(tasks): persist collapsed subtask state across restart (#8788)
* fix(tasks): persist collapsed subtask state across restart #8781

Collapse/expand of a task's subtasks was written by non-persistent
actions, so the op-log never captured it and the state reset to
expanded on every reload.

Route collapse changes through the absolute, replay-safe updateTaskUi
op. toggleSubTaskMode now resolves the next _hideSubTasksMode at
dispatch time (extracted into a pure getNextHideSubTasksMode helper)
and persists that absolute value, so it is captured to the op-log and
restored on reload. Remove the old relative toggleTaskHideSubTasks
action and its reducer, which was non-deterministic on replay and threw
on a missing task. Register [Task] Update Task Ui in the ActionType enum
and compaction code map.

* test(tasks): add op-log round-trip test for _hideSubTasksMode #8781

The existing updateTaskUi test only asserted the action's persistence
metadata. Add a test that runs the value through the full op-log path
(dispatch -> capture -> JSON serialize -> convertOpToAction -> reducer
replay) and asserts the collapse value survives. The JSON hop is where a
dropped value would regress on the sync transport and SQLite op-log
backend.
2026-07-08 13:01:04 +02:00
Johannes Millan
c08832c9dc test(tasks): harden notes focus timing spec 2026-07-07 18:28:11 +02:00