Commit graph

785 commits

Author SHA1 Message Date
Johannes Millan
c8e5ed2c15 fix(plugins): harden node execution grants 2026-06-09 14:07:06 +02:00
Johannes Millan
3d2c811e78 chore(task-repeat): revert RRULE Phase 1 from master (#7948) Develop the full RFC-5545 RRULE epic on the long-running feat/rrule-epic branch and merge once complete and testable in final form, rather than landing 13 phases into master one half-state at a time. Work preserved on feat/rrule-epic. Not yet shipped (post-v18.9.1), so no user impact. 2026-06-09 13:56:38 +02:00
dependabot[bot]
4ccba1f578
chore(deps): bump @types/supertest from 6.0.3 to 7.2.0 (#7430)
Bumps [@types/supertest](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/supertest) from 6.0.3 to 7.2.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/supertest)

---
updated-dependencies:
- dependency-name: "@types/supertest"
  dependency-version: 7.2.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-09 11:54:04 +02:00
Johannes Millan
7eabbc34b9 fix(sync): make super-sync-server migrations buildable from scratch
The Prisma migration chain began with an ALTER on the operations table, so
a fresh database could not be initialized from migrations alone (the first
migration failed with no table to ALTER). Separately, the login_token /
login_token_expires_at columns and index existed in schema.prisma and were
used at runtime by src/auth.ts (magic-link login) but were never created by
any migration, so a migrate-only database crashed with
"column users.login_token does not exist".

- Add a 0_init baseline migration that creates the base tables (the
  pre-2025-12-12 shape), sorting before the first incremental migration so
  the existing ALTER migrations apply cleanly on top.
- Add 20260601000000_add_login_token to create the missing magic-link
  columns and index (IF NOT EXISTS, safe on installs that already have them
  from a prior db push).
- Add migration_lock.toml (provider = postgresql), the standard companion to
  a real migration baseline.
- Document baselining for existing databases: `migrate resolve --applied
  0_init` for installs with migration history, and resolving the whole chain
  for db-push installs with none (covers the Helm/Docker unattended paths).
- Add regression tests asserting the baseline exists and that every @map
  column in schema.prisma stays backed by a migration (guards the drift class
  that caused this bug, not just login_token).

Verified by replaying all migrations on a fresh Postgres: the chain applies
cleanly and the resulting schema matches schema.prisma exactly.

Closes #8187
2026-06-09 11:36:44 +02:00
felix bear
a562b24b82
feat(github): configure issue provider api base url #5749 (#8183)
Co-authored-by: cocojojo5213 <cocojojo5213@users.noreply.github.com>
2026-06-09 11:28:05 +02:00
Filip
1718b0a8b8
feat(task-repeat): RFC 5545 RRULE recurring schedules — EPIC · Phase 1/13 (Closes #4020) (#7948)
* feat(task-repeat): add cron / natural-language recurring schedules

Add a CRON repeat cycle so a single recurring task can express schedules
the day/week/month/year cycles cannot (e.g. every Saturday, March through
November). The cron expression drives occurrence generation; all other
schedule fields are ignored for CRON cfgs.

- Occurrence engine: getNewestPossibleCronDueDate / getNextCronOccurrence in
  cron-occurrence.util.ts (cron-parser), wired into the existing
  getNewestPossibleDueDate / getNextRepeatOccurrence dispatchers. Recurrence
  stays day-granular: sub-daily crons create at most one task per day.
- English to cron via the crono-eng WASM module (src/assets/crono-eng.wasm,
  loaded lazily), with a builtin regex parser as fallback until/if the module
  is unavailable. naturalLanguageToCron() also passes raw cron through.
- Cron to english live preview under the input via cronstrue; the field shows
  the interpreted cron + humanized reading as the user types and warns when an
  expression is sub-daily.
- Invalid / unrecognized input shows an error snack and blocks save.
- Add-task bar: @+<cron-or-phrase> short syntax attaches a CRON repeat cfg.
- tools/build-crono-wasm.js regenerates the WASM asset from the sibling
  crono-eng Zig project (committed asset is the source of truth for builds).

* fix(task-repeat): cron edge cases, clearer warnings, and corpus tests

Hardening + UX pass on the cron repeat cycle, driven by verifying the full
crono-eng corpus (415 phrases) end to end.

Fixes
- Silent never-fires: crono-eng can translate phrases (a specific year, "nearest
  weekday"/W, "n-to-last day"/L-n, biweekly #-lists) into Quartz forms the
  recurrence engine (cron-parser) cannot run. These passed UI validation yet a
  task would never be created. naturalLanguageToCron now gates its output on
  cron-parser, so such input is rejected at the field instead.
- Off-by-one for midnight crons: cron-parser's next() is exclusive of an
  exact-boundary currentDate, so getNextCronOccurrence skipped the first
  eligible day for 00:00 schedules (e.g. first occurrence landed a day late).
  Seed the search one ms before the lower-bound day so a midnight fire stays
  eligible; now matches the non-cron daily convention.

UX
- Live preview shows the interpreted cron + plain-English reading below the
  field, and warns when the time of day is ignored (day-granular engine) or the
  schedule is sub-daily ("runs at most once per day").
- Friendlier validation/snack message with examples instead of terse jargon.

Tests
- tools/test-crono-wasm.js (npm run test:crono): corpus-driven harness asserting
  the committed WASM matches all 415 corpus crons, and that cron-parser +
  cronstrue accept them — classifying the 27 known engine-unsupported forms so
  it stays a regression guard.
- cron-occurrence.util.spec.ts: occurrence engine across daily/weekly/monthly/
  month-range/sub-daily, varied times, start-date and last-creation gating.
- parse-natural-cron.util.spec.ts: raw passthrough, phrase recognition, null
  cases, loader resilience.
- task-repeat-cfg-form.cron.spec.ts: field validator + live-preview/time-warning.

* fix(task-repeat): live cron preview + @+ title strip; add E2E

Fixes found by adding end-to-end coverage of the cron feature.

- Live preview did not render: Formly's mat-hint does not reflect a dynamic
  expressionProperties.description, so the interpreted-cron/English preview never
  updated as the user typed. Move it into the dialog template, driven by a
  computed signal off the form's valueChanges (getCronPreview in cron-preview.util),
  so it updates live and persists after applying. The time-of-day-ignored and
  sub-daily warnings now render as proper translated lines.
- "@+<phrase>" short syntax left the clause in the title: shortSyntax set
  taskChanges.title for the cron strip, but the later parseTimeSpentChanges
  reassignment wiped it, so a bare "Foo @+every day" submitted the raw title.
  Strip into the working title and emit the cleaned title at the end.
- shortSyntax returned cronExpression unconditionally (undefined when absent),
  breaking 38 exact-match assertions; only include the key when set.

Tests
- e2e/tests/recurring/cron-repeat.spec.ts: @+ short-syntax attaches a CRON cfg
  (title stripped); full dialog flow (phrase → live preview + time warning →
  save → CRON cfg persisted).
- short-syntax.spec: @+ extraction/stripping, with and without other syntax.
- getCronPreview unit tests (interpreted cron, English, timed/sub-daily flags).

* test(task-repeat): property/fuzz/metamorphic/edge cron tests; fix first-occurrence

Large second testing pass (≈+58 unit tests, +2 E2E, new property harness),
which surfaced one gap and one upstream quirk.

Fix
- getFirstRepeatOccurrence returned null for CRON cfgs (no case → default), so a
  timed CRON task's first instance and the startDate-moved-earlier path fell
  back to today instead of the cron's first fire. Add getFirstCronOccurrence
  (first fire on/after startDate) and route CRON through it.

Tests
- cron-occurrence.invariants.spec: property/invariant battery over many crons
  (no-throw, result strictly-after / on-or-before bounds, determinism,
  monotonicity), calendar edges (leap-day, year rollover, DST spring/fall),
  pathological "Feb 30" (null, no hang), and getFirstCronOccurrence /
  getFirstRepeatOccurrence routing.
- parse-natural-cron.metamorphic.spec: relational tests (case/whitespace/order/
  irrelevant-text invariance, idempotence, determinism, raw passthrough).
- short-syntax.spec: more @+ cases (raw cron after @+, bare @ is not cron,
  unrecognized phrase ignored, clause stops at the next delimiter).
- tools/test-crono-properties.js (npm run test:crono:props): WASM determinism,
  marshalling fuzz (empty/oversized/unicode/boundary), English→cron→English
  round-trip, and occurrence simulation across every runnable corpus cron.
- e2e: invalid phrase blocks save + shows inline error (Save disabled);
  raw cron expression preview + save. Shared dialog-open helper.

Note: documented a cron-parser DST quirk — prev() skips the spring-forward
midnight, so "newest due" can land a day earlier on that date (day-granular,
once a year; lastTaskCreationDay prevents duplicates).

* test(task-repeat): selector integration, serialization, and WASM buffer-reuse

- selectors.spec: CRON cases for the selectors that drive task creation —
  selectAllUnprocessedTaskRepeatCfgs (due today, overdue, already-created,
  paused, future start, invalid expr, deleted instance) and
  selectTaskRepeatCfgsForExactDay (exact-day match). Proves a CRON cfg is
  picked up by addAllDueToday's selector path.
- cron-occurrence.invariants.spec: a CRON cfg survives a JSON round-trip with
  identical occurrence behavior (sync / backup integrity).
- test-crono-properties.js: buffer-reuse checks — a short translation after a
  long one is uncontaminated, and results stay deterministic across a 600-call
  interleaved loop (the WASM module reuses static input/output buffers).

* test(task-repeat): cross-timezone day-resolution harness

The Karma suite only runs in the host timezone (Chrome on Windows ignores the
TZ env var — the reason the pre-existing *.tz.spec fails), so cron day-resolution
was never verified across zones. Add a Node harness (npm run test:crono:tz) that
spawns a child process per timezone — Node honors TZ — and asserts day-class
invariants in each: weekly-Monday resolves to a Monday, monthly day-15 to the
15th, daily to the next calendar day, time-of-day never shifts the day, and a
full-year daily iteration is monotonic with 366 distinct days (no DST skip in
next()). Covers fractional offsets (India +05:30, Chatham +12:45/+13:45) and
Southern-hemisphere DST (Sydney). Mirrors getNextCronOccurrence's core math.

* fix(task-repeat): DST-safe newest-due; verify real engine across timezones

Closes the time-based reliability gaps from the previous testing pass.

Fix
- getNewestPossibleCronDueDate no longer uses cron-parser's prev(), which skips
  the spring-forward midnight in DST zones (a daily cron then looked like it
  didn't fire that day). It now walks days backward and asks "does the cron fire
  during this local day?" via a next()-based probe (next() is monotonic across
  DST). Spring-forward and fall-back now resolve the exact day.

Tests
- main.ts exposes the real occurrence utils on __e2eTestHelpers.cron (dev/stage
  only, stripped from production).
- e2e/cron-timezone.spec: runs the REAL engine under five forced browser
  timezones (Playwright timezoneId — reliable regardless of host OS, unlike the
  Karma TZ env). Each asserts the timezone was actually applied (live UTC
  offset) AND that day-class results are correct, incl. fractional offsets
  (Chatham +12:45) and the spring-forward day.
- test-crono-tz.js: also asserts a daily cron fires on every calendar day of the
  year in all 8 zones (regression guard for the prev() skip).
- invariants.spec: spring-forward / fall-back now assert the exact day for both
  next() and newest (no longer hedged).
- effects.spec: a CRON cfg's first occurrence is computed through the real
  updateTaskAfterMakingItRepeatable$ effect path (live new Date()).

* test(task-repeat): dev jumpDay clock helper + live day-change e2e

Adds __e2eTestHelpers.jumpDay(n) / resetClock() (dev/stage only, stripped from
production) so recurring/day-change behavior can be fast-forwarded from the
DevTools console without touching the OS clock: jumpDay shifts Date.now() by a
cumulative day offset and forces the day-change re-sample so addAllDueToday()
runs. New e2e asserts jumpDay(1) creates the next daily instance — covering the
live day-change -> creation path end to end.

* feat(task-repeat): add "create a task for each missed occurrence" option

By default, opening the app after several scheduled occurrences were
missed creates only a single instance for the most recent occurrence.
When enabled (advanced section), a separate overdue task is created for
every missed occurrence instead, oldest first, capped at the 30 most
recent to avoid flooding the list / emitting a large sync batch.

- new getAllMissedDueDates() util reuses getNextRepeatOccurrence so the
  per-cycle and CRON occurrence logic stays single-sourced and DST-safe
- service multi-spawn path is gated to the catch-up flow (target day is
  today or earlier) and is mutually exclusive with skipOverdue /
  waitForCompletion; the single newest-occurrence path is unchanged
- form checkbox hides when "skip overdue" is set, and vice versa

Part of #4020

* fix(task-repeat): re-anchor lastTaskCreationDay when cron expression changes

cronExpression was missing from SCHEDULE_AFFECTING_FIELDS, so editing a
CRON schedule did not re-run rescheduleTaskOnRepeatCfgUpdate$. A
previously computed (often future) lastTaskCreationDay then stuck around
and silently suppressed every occurrence until real time caught up to it
— e.g. a "Jan-Aug" cron set while the clock was past August anchored to
Jan 1 next year and produced no tasks in between.

Adding 'cronExpression' makes a cron edit re-anchor from the current
date, consistent with repeatCycle / repeatEvery / weekday edits.

Part of #4020

* feat(tasks): surface @+ recurring short syntax in autocomplete and help

The `@+<schedule>` add-task short syntax existed but was undiscoverable.

- add recurring examples to the `@` autocomplete suggestions: typing `@+`
  now autocompletes phrases like `@+every monday` or
  `@+every saturday from march through november`
- document `@+` in the Settings -> Short Syntax help text

Part of #4020

* fix(tasks): remove @+ recurring autocomplete entries

The `@+` examples added under the `@` mention trigger kept the suggestion
dropdown open while typing `@+<phrase>`, so pressing Enter selected a
suggestion instead of submitting the task — breaking the headline
type-and-go flow. The `@+` syntax stays documented in Settings -> Short
Syntax; only the dropdown entries are removed.

Part of #4020

* refactor(task-repeat): show cron only inside Custom recurring config

Cron appeared twice in the recurring-config dropdown: as a top-level
"Cron / natural language" quick-setting AND as a cycle inside "Custom
recurring config". Drop the duplicate top-level option; cron is now
reached via Custom -> repeatCycle = Cron.

- remove the CRON quick-setting option from the dropdown builder
- keep the repeatCycle select visible when CRON is chosen (only hide
  repeatEvery) so the user can switch back
- map existing cron configs (incl. legacy quickSetting 'CRON') to CUSTOM
  on load so the dropdown matches the stored cycle
- update the cron E2E to drive the dialog via Custom -> Cron

Part of #4020

* test(task-repeat): make @+ short-syntax e2e date-independent

The test asserted the `@+every monday` task appears in the Today view, but
a weekly schedule's first instance is the next Monday — so on any non-Monday
the task is correctly scheduled for a future day and isn't in Today, making
the test fail depending on the day it runs. Verify the attached CRON config
and the stripped title via the store instead, which is view- and
date-independent.

Part of #4020

* feat(task-repeat): RFC 5545 RRULE recurring schedules [WIP]

Overhaul recurring tasks from the bespoke repeatCycle format to RFC 5545
RRULE as the canonical recurrence representation (legacy fields kept
populated for old-client forward-compat). Adds a structured RRULE builder,
RRULE-backed quick presets, per-instance due-date derivation, multiple end
conditions (COUNT / UNTIL / after-N-completions), an occurrence heatmap with
completion simulation, natural-language @+ short syntax, and a REST API for
recurring tasks. Migration is lazy (on dialog open) so existing configs keep
firing untouched until edited.

WIP: some required features and tests are still outstanding, and it needs
much more real-world (user-based) testing before it is merge-ready.

* feat(task-repeat): per-occurrence overrides, due-date control move, @+ preview, NL fix [WIP]

- RECURRENCE-ID: per-instance overrides (move / re-time / re-title) surfaced to
  the engine as RDATE + EXDATE so every projection stays consistent.
- Move the due-date derivation control out of the rrule builder into the dialog
  so it applies to all recurring configs (presets + Custom).
- Live @+ recurrence preview (humanized rule + next occurrence date) in the
  add-task-bar, so a far-off rule is obvious before submit.
- Fix @+ "every other <weekday> in <month>" mis-parsing to YEARLY;INTERVAL=2
  ("every 2 years"); weekly-style interval + weekdays now stays WEEKLY.

* refactor(task-repeat): trim PR to engine+builder+migration+preview; fix curator review

Trim the WIP recurring-schedules PR to its reviewable core — RFC 5545 occurrence
engine, structured RRULE builder, legacy<->RRULE migration (both directions),
live text preview, quick-setting presets, and tests — and defer the rest to
follow-up sub-MRs: natural-language @+, due-date derivation, ends-after-N-
completions, missed-occurrence backfill, heatmap preview + simulation, REST
recurring creation, and RECURRENCE-ID per-instance overrides. The full
implementation is preserved on branch feat/recurring-full.

Curator review fixes:
- quickSetting forward-compat: never persist a value outside the released
  (master) union. The newer preset literals AND 'RRULE' map to 'CUSTOM' at every
  persist boundary (dialog save, add-task-bar, data-repair downgrade); the rich
  value drives the dialog UI in-memory only and the builder reconstructs from the
  opaque rrule on open. Fixes old/mobile typia sync-validation treating such
  tasks as corrupt.
- Malformed rrule now falls back to the legacy schedule fields (guarded by
  isRRuleValid) instead of silently stopping the task.
- Reverse converter maps a BYDAY-less FREQ=WEEKLY onto the start weekday, so the
  legacy WEEKLY engine still fires on old clients.
- Stop logging the raw rule body (the raw-override field makes it free-text user
  input; log history is exportable).
- DRY: share normalizeWeekdays / toNumArray between the two converters.

* refactor(task-repeat): clamp quickSetting at the action creators

The op-log replays the action payload, not reduced state
(operation-capture.service.ts), so a reducer-level clamp would not keep an
out-of-union quickSetting off the wire. Clamp in the addTaskRepeatCfgToTask /
updateTaskRepeatCfg / updateTaskRepeatCfgs creators instead -- the single
boundary every dispatcher passes through, so old/mobile clients always replay a
value their typia union accepts. The newer preset literals and 'RRULE' stay
in-memory in the dialog form only.

Removes the now-redundant per-call-site clamps in the dialog save path and the
add-task-bar; the @+ and REST paths inherit the clamp for free when their phases
return. Adds an action-creator spec covering the clamp at each entry point.

* feat(task-repeat): nth-weekday rows support multiple weekdays per ordinal

Each ordinal row (1st/2nd/3rd/4th/last) now multi-selects weekdays via toggle
buttons, so one line can mean "the 1st Monday and the 1st Tuesday" -> BYDAY=1MO,1TU.
The per-row ordinal dropdown excludes positions already used by other rows (each
ordinal anchors at most one line), and the add button hides once all are used.
Parsing groups weekdays that share an ordinal back into one row. Applies to both
the monthly and yearly nth-weekday modes; updates the helper copy accordingly.

* test(task-repeat): complex RRULE coverage + day-by-day create-loop sim

Adds high-complexity coverage for the occurrence engine and builder:
- engine variants x settings: per-day ordinals, BYSETPOS last-weekday,
  BYMONTHDAY=-1, seasonal BYMONTH, BYWEEKNO/BYYEARDAY, leap years, mixed with
  COUNT / UNTIL / EXDATE / lastTaskCreationDay
- builder forward + mode-detection + lossless round-trips (incl. multi-weekday
  ordinals and sub-daily raw-override)
- cfg->engine routing for complex rules with deletedInstanceDates and the
  malformed-rrule legacy fallback
- a "day-march" simulator that drives getNewestPossibleDueDate one day at a time
  with lastTaskCreationDay fed back like the service, asserting the created
  stream has no dupes/skips, honors EXDATE/COUNT, is idempotent within a day,
  and documents Phase-1 app-closed catch-up (newest missed only)

Also trims the deferred @+ short-syntax e2e (returns with its phase) and fixes
the dialog-flow e2e to target the current .rrule-result preview selector.

* feat(task-repeat): custom ordinal input for nth-weekday rows and BYSETPOS

The nth-weekday ordinal dropdown and the weekday-set 'which occurrence'
dropdown each gain a 'custom…' option that reveals a free-form input,
allowing any non-zero ordinal (e.g. -2 = 2nd-to-last, 5FR) and
comma-separated BYSETPOS lists (e.g. 2,-1). Parsed rules with such values
now render structurally instead of falling back to the raw override.

* feat(task-repeat): make weekday-set occurrence (BYSETPOS) a multi-select

Replace the single-value 'which occurrence' dropdown with toggle buttons
matching the weekday toggles, so several occurrences can combine (e.g.
first + last weekday = BYSETPOS=1,-1). 'Every' clears the narrowing; the
custom input stays for values without a toggle.

* fix(task-repeat): address review findings on rrule migration fidelity

- Yearly builder rules seed BYMONTH from the start month: a bare
  FREQ=YEARLY;BYMONTHDAY=n expands across every month (RFC 5545) and
  fired monthly for fresh yearly configs.
- quickSetting change handler passes the selected start date, so
  date-writing presets no longer overwrite a user-picked future anchor
  with today.
- Remove the createForEachMissed checkbox + copy: the engine has no
  backfill support yet, so the setting silently did nothing (and wrote
  an undeclared field into synced state).
- rruleToLegacyTaskRepeatCfg always resets the monthly anchor fields so
  stale nth-weekday/last-day anchors can't survive the merge, sets
  monthlyLastDay only for a pure BYMONTHDAY=-1 rule, and aligns
  startDate to the rule's first occurrence for date-anchored
  monthly/yearly rules (legacy clients read day/month from startDate);
  save() re-derives the fallback so later startDate edits stay aligned.
- _normalizeMonthlyAnchor keeps monthlyLastDay on RRULE saves — it is
  the derived old-client fallback for BYMONTHDAY=-1, not a stale preset
  flag.
- Legacy CUSTOM migration preserves clamp semantics: day > 28 anchors
  emit BYMONTHDAY=<d>,-1;BYSETPOS=1 (the day, or the last day of
  shorter months) instead of a plain BYMONTHDAY that skips such months;
  the builder serializer emits BYSETPOS in day-of-month modes so these
  rules round-trip structurally.
- Regenerate package-lock.json to drop stale cron-parser/cronstrue
  entries left over from the earlier cron approach.

* fix(task-repeat): harden rrule builder + legacy fallback after deep review

Correctness:
- Clear bySetPos (and its custom-input state) on monthly/yearly mode and
  frequency switches: a BYSETPOS left over from the weekday-set mode
  silently narrowed day-of-month rules (BYMONTHDAY=15;BYSETPOS=2 never
  fires) with no UI to see or clear it.
- Move startDate alignment out of rruleToLegacyTaskRepeatCfg into a new
  arithmetic getAlignedStartDate, applied once at save and only when the
  schedule actually changed: the occurrence-search version corrupted the
  clamp idiom (aligned a day-31 rule onto Feb 28/29 so old clients fired
  on the wrong day forever), collapsed multi-day BYMONTHDAY lists to
  their earliest day, rewrote the visible start-date field live on every
  builder interaction, cost ~60ms/keystroke for sparse rules, and put
  startDate into the change diff on title-only edits — retriggering the
  issue-#7373 reschedule. Weekday-anchored rules are no longer aligned.
- Emit the monthly-anchor resets as null/false instead of undefined (in
  the converter AND the presets' MONTHLY_ANCHOR_RESET): JSON.stringify
  drops undefined keys from op-log payloads, so the reset never reached
  remote clients and stale nth-weekday/last-day anchors survived there.
  The anchor model fields now allow null; the nth-weekday engine guard
  strips it.
- Enforce the YEARLY BYMONTH guard in the serializer: yearly date mode
  without months now emits a plain FREQ=YEARLY (anniversary) instead of
  a bare BYMONTHDAY that fires monthly; parsed bare yearly rules fall
  back to the raw override, preserving their semantics verbatim.
- Drop the auto-seeded BYMONTH when switching away from YEARLY (it
  silently constrained the new rule to one month) and seed from the
  CURRENT start date rather than the ngOnInit-captured month.
- Clamp custom nth ordinals per frequency (±5 monthly, ±53 yearly) —
  BYDAY=10MO is valid RFC but matches nothing, ever — and reject an
  ordinal another row already anchors (duplicate rows collapse on
  reload). Re-clamp poses when switching into MONTHLY.
- Drop BYSETPOS=0 on parse (parseString accepts it; re-emitting creates
  a rule the occurrence engine silently treats as dead).
- Predefined set-position toggles close the explicitly-opened custom
  input (both rendered active with contradictory state).

Cleanup:
- Extract parseIntList (4 cloned int-list sanitizers), pushBySetPos /
  pushByDaySet (4 copy-pasted emit blocks), _clampedMonthDayPart
  (monthly/yearly clamp duplication); share the monthly/yearly
  weekday-set template block via ngTemplateOutlet; parse BYSETPOS via a
  computed signal instead of per-CD string parsing.
- Correct the BYMONTHDAY hint: it claimed short months clamp to their
  last day, but plain BYMONTHDAY skips them.

* test: make timezone demo specs host-independent

Six .tz.spec demonstration tests branched on the host's CURRENT
getTimezoneOffset() (or a literal 'America/Los_Angeles' name check)
while asserting against January test instants. In DST-observing zones
the summer offset differs from the January one (e.g. New York: EDT 240
vs EST 300), so the wrong branch was taken and the suite failed every
summer on US-east machines, blocking pre-push hooks.

Branch on the offset AT the test instant instead, with the correct
longitude threshold for each instant (07:00 UTC flips the local day at
UTC-7, 06:00 UTC at UTC-6, midnight UTC at UTC±0) — the tests now pass
in any host timezone year-round.

* test: pin browser timezone to Europe/Berlin in karma config

Re-enable the previously commented-out TZ pin so timezone-sensitive
specs run deterministically where Chrome honors the env var
(Linux/macOS, incl. CI). Verified empirically that headless Chrome on
Windows IGNORES TZ and keeps the host zone — the *.tz.spec.ts files
keep their offset-at-test-instant branching as the Windows backstop.

* test: make supersync dump/restore helpers cross-platform

createFullDump/restoreFullDump used POSIX-only shell constructs (output
redirection to /tmp, 'cat | psql', 'rm -f'). On Windows the restore
pipeline failed AFTER the 'DROP SCHEMA public CASCADE' had already
succeeded, leaving the test database without any tables — every
subsequent spec in the run then failed in createTestUser with 'table
public.users does not exist' (5 cascading failures + dozens of
never-ran tests).

Capture pg_dump via execSync stdout + fs.writeFileSync, feed the
restore through stdin (execSync input), use os.tmpdir() and
fs.unlinkSync. Verified: full dump-restore spec plus the three spec
files it previously poisoned all pass on Windows (14/14).

* test(e2e): round-trip coverage for new rrule builder widgets

Covers the four gaps hand-testing would otherwise need to catch, using
the dialog's live result band as the oracle (.rrule-result__expr =
exact assembled rule, .rrule-result__next = engine-computed upcoming
dates — no waiting for real time):

- custom nth ordinal (-2 = 2nd-to-last weekday) emits the right BYDAY
  and persists verbatim
- BYSETPOS multi-select (first + last) emits BYSETPOS=1,-1 and persists
- mode switch drops a weekday-set BYSETPOS instead of leaking it into a
  day-of-month rule (dead-rule regression)
- switching to YEARLY seeds BYMONTH and the upcoming dates are strictly
  one per year

Persistence asserted via the NgRx store: saving a recurring cfg
re-plans the task onto its first occurrence (usually not today), so a
UI reopen from the work view is date-dependent; the parse-to-widget
rendering side is covered by the dialog/builder unit specs.

* fix(add-task-bar): open the repeat dialog for the custom recurring option

The repeat quick-setting menu emits the 'RRULE' value for 'Custom
recurring config', but the add-task-bar still branched on the legacy
'CUSTOM' value — picking the option fell through to the preset path and
silently created a weekly-fallback cfg instead of opening the dialog.

Route both 'RRULE' and 'CUSTOM' through the dialog path, preselect the
RRULE builder in the dialog via a new initialQuickSetting dialog input
(the user explicitly asked for a custom rule), and show the tune icon
for the menu entry again. Covered by a new e2e: menu pick → task
created → builder dialog opens → saved rule persists verbatim.

* fix(task-repeat): address review on rrule alignment + anchor sync safety

- getAlignedStartDate re-anchors onto the rule's actual first occurrence
  (occurrence-set-neutral for INTERVAL/COUNT/UNTIL) instead of pure date
  math that shifted INTERVAL cadence and skipped valid clamped occurrences
- save() always re-derives the legacy fallback fields against the final
  startDate, not only when alignment moved it
- monthly anchors never persist null (released clients' typia schema only
  allows absent-or-numeric): undefined resets everywhere, null stripped at
  the action-creator boundary, model reverted to master signature
- round-trip guard ignores BYSETPOS=0 so the cleaned rule is emitted
  instead of being preserved verbatim via raw override

* docs(wiki): fix MD024 duplicate headings on repeating-tasks page

* fix(task-repeat): harden rrule save guards + restore preset labels on reopen

Correctness (from deep review of the branch diff):
- reject COUNT combined with repeatFromCompletionDate at save: completion
  re-anchors startDate/lastTaskCreationDay, restarting the COUNT window, so
  the series would never terminate
- reject parseable rules that yield zero occurrences (e.g.
  FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=30): they persisted as silently dead
  recurrences with the legacy fallback bypassed
- map the builder's single-weekday BYSETPOS form (BYDAY=FR;BYSETPOS=-1,
  'last Friday') onto the legacy nth-weekday anchor; old clients previously
  fell back to startDate's day-of-month — a wrong recurrence

Polish:
- infer the preset back from the stored rrule when quickSetting was
  wire-clamped to CUSTOM, so Weekends & co. reopen under their label
  instead of the raw builder; new QUICK_SETTING_PRESETS single source
  replaces the hand-maintained KNOWN_PRESETS set
- extract shared safeParseRRuleOptions + FREQ_TO_CYCLE (six drifted
  hand-rolled parse wrappers); memoize isRRuleValid (per-cfg routing guard
  on every overdue scan); share noonUtc/toLocalNoon between engine and
  preview; fold toMonthArray onto toNumArray; merge the duplicate
  _toPersisted action-creator helpers
- trim the wiki section documenting the create-for-each-missed feature
  that was deliberately cut from this PR

* fix(task-repeat): address curator review on anchor validity + fallback phase

- never emit/persist an out-of-union monthlyWeekOfMonth: range-guard the
  BYDAY ordinal in rruleToLegacyTaskRepeatCfg (the builder's custom input
  allows ±5, the synced model only 1..4|-1), sanitize null AND out-of-range
  anchors at the action-creator boundary, and mirror the repair in
  data-repair — an out-of-union value trips released clients' typia
  validation/repair flow
- reject sub-daily frequencies (raw override FREQ=HOURLY/...) at save: the
  day-granular engine would accept but silently collapse them to ~daily,
  and they have no legacy repeatCycle for old clients
- log hygiene: the update-all-instances flow now logs changed keys + task
  ids instead of raw changes/task objects (title, notes and rrule body are
  user content; the log history is exportable); engine warns log the error
  name only, since rrule.js messages can embed the free-text rule body
- align single-BYDAY weekly rules onto the engine's first occurrence: the
  engine groups weeks by WKST while the legacy fallback counts rolling
  7-day blocks from startDate, so biweekly cfgs whose start is off the
  pattern day fired on OPPOSITE alternating weeks on old vs new clients
  (and WKST shifted the engine's phase, which legacy cannot express);
  after re-anchoring the cadence is exactly interval*7 days in both
- diff dialog saves against the PROCESSED cfg: the lazy legacy->rrule
  migration (and preset inference) no longer leak into the change set of a
  title-only edit — rrule is schedule-affecting, so that leak relocated
  today's live instance on unrelated edits; empty change sets skip the
  update dispatch entirely instead of creating a no-op sync op

* fix(task-repeat): keep presets rrule-backed + builder mode for completion cfgs

- stop stripping the preset-generated rrule at save: getQuickSettingUpdates
  OVERWRITES the rule with the preset's canonical one, so the old 'clear on
  switch away from builder' branch broke the every-cfg-carries-its-rrule
  contract — and an rrule:undefined clear would not even propagate (dropped
  by the op-log JSON wire, leaving remote clients on the old rule); the spec
  asserting the stale behavior is inverted
- completion-relative cfgs always open in builder mode: the schedule-type
  toggle only exists there, so preset inference (or a stored faithful preset
  label) would hide the one control that explains how the cfg fires
- monthly anchor clears: document the wire gap properly instead of flipping
  values again — null trips released clients' blocking data-repair confirm
  dialog on every sync (typia has no null on these fields), undefined clears
  only locally; durable clears require shipping '| null' on both fields in a
  release FIRST, then switching the reset value (sequenced migration note in
  the model + an op-log JSON round-trip spec pinning current behavior)
- replace 6 hardcoded rrule-builder placeholders with translation keys
- revert the settings help text advertising the deferred @+ short syntax

* test(e2e): drop CUSTOM weekday-checkbox spec superseded by rrule builder

The #8025 e2e (merged in from master) drives the legacy CUSTOM recurring
form's cycle select + weekday checkboxes, which this branch removes — the
RRULE builder replaces that UI and its weekday toggles are covered by
rrule-builder-roundtrip.spec.ts. The #8025 failure mode (a saved weekly
cfg that never fires) is blocked generally at save by the first-occurrence
probe.

* test(e2e): de-flake worklog history day-row wait

The worklog is rebuilt from the archive on the history navigation, so the
day row only appears once that async load + month-expand animation settles.
The bare `waitFor` fell back to the 15s action timeout, which CI load
(prod build, 3 workers) could exceed. Use a web-first `expect().toBeVisible()`
with 30s headroom instead.

* fix(task-repeat-cfg): clear repeat-from-completion when switching to a preset

The "from completion" schedule-type toggle exists only inside the RRULE
builder. Selecting a preset hides it, but the flag stuck on the cfg, so a
preset could keep firing relative to completion with no visible control.

Clear it at the save() edit boundary, but only when actually set, so an
untouched preset save stays an empty-diff no-op (#7373 class) rather than
dispatching a spurious undefined->false op. The ADD path already starts from
a false default, so the dialog is the only path that needs it.

Also reword the monthly-anchor clear comments: the cross-client divergence on
an nth-weekday -> day-of-month switch is an inherent, unfixable limitation for
pre-rrule clients (no in-schema "no anchor" value; a `| null` migration would
help an empty client band), not a deferred TODO. Make the round-trip test pin
this explicitly and start the monthlyLastDay case from `true` so it proves the
`false` clear survives the wire instead of passing vacuously.
2026-06-09 11:25:35 +02:00
Johannes Millan
8d43c50631 fix(plugins): dedupe Google Calendar auto-time-block events in schedule #8171
With Auto time blocking on, scheduling a task writes a mirror event to
Google Calendar. When the read calendar overlaps the time-block calendar,
that mirror was fetched back and rendered next to the local task that
created it — a duplicate. Filter SP-owned events (tagged with
extendedProperties.private.spTaskId) out of all plugin read paths.
2026-06-09 10:50:07 +02:00
felix bear
62f8141b82
fix(supersync): improve account badge contrast (#8186)
Co-authored-by: cocojojo5213 <cocojojo5213@users.noreply.github.com>
2026-06-09 00:13:33 +02:00
felix bear
fe65d4b9a6
fix(plugin): refresh procrastination buster i18n #5102 (#8145)
Co-authored-by: cocojojo5213 <cocojojo5213@users.noreply.github.com>
2026-06-08 20:44:43 +02:00
Johannes Millan
aa2ad88ff1
fix(caldav-plugin): make recurring-occurrence edits/deletes safe and quiet #7492 (#8149)
* build: drop unused elevate.exe from Windows build #8135

elevate.exe is bundled by electron-builder's NSIS target solely so
electron-updater can apply privileged per-machine updates. We ship no
in-app auto-updater (electron-updater is not a dependency; the autoUpdater
block in electron/start-app.ts is commented out), so the binary is unused
dead weight and a frequent AV false-positive. Set packElevateHelper: false
to stop bundling it; safe because perMachine is not true.

* fix(caldav-plugin): make recurring-occurrence edits/deletes safe and quiet #7492

Expanded RRULE occurrences all share one master .ics, so operating on a
single occurrence hit the whole series: updateIssue rewrote the master
(and the next poll pulled it onto every sibling), deleteIssue deleted the
master (the entire series), and getById returned the master's DTSTART,
collapsing every instance onto the first one's time.

Plugin:
- parseCompoundId surfaces occurrenceMs instead of discarding it
- getById re-anchors start/end onto the requested occurrence (timed via
  toIcalUtcDateTime, all-day via toIcalDate matching ical.js local-midnight)
- updateIssue/deleteIssue refuse occurrence writes with a marked error
  (isExpectedSyncSkip), so the series is never mutated

Host two-way sync:
- editing or deleting a task linked to one occurrence now stays silent (the
  user changed their task, not the calendar) and the local change still
  applies. Explicit agenda reschedule/delete keep surfacing the honest
  "can't change a single occurrence" message (no false success).

Single non-recurring events are unaffected. Full per-occurrence editing
(RECURRENCE-ID overrides + EXDATE) remains a follow-up; it needs an If-Match
primitive and recurrence-value preservation.
2026-06-08 16:05:52 +02:00
Johannes Millan
0d1869263f docs: remove outdated and implemented plan docs
Delete 29 plan/design docs whose work has shipped or been superseded
(SuperSync slices, sync-core extraction, encryption-at-rest drafts,
document-mode/Stage-A persistence, calendar/CalDAV concepts, focus-mode
time-tracking sync, etc.).

Kept the still-forward-looking docs (e.g. supersync-encryption-at-rest,
sync-core-simplification-roadmap, calendar-two-way-sync-technical-analysis).

Source comments that cited deleted docs are rewritten into self-contained
inline rationale so no "see docs/..." reference dangles.
2026-06-08 12:38:51 +02:00
felix bear
71f4ca484c
fix(plugins): return dialog result #5239 (#8106)
Co-authored-by: cocojojo5213 <cocojojo5213@users.noreply.github.com>
2026-06-08 12:14:08 +02:00
felix bear
d82754faf2
feat(supersync): configure server bind host #7301 (#8108)
Co-authored-by: cocojojo5213 <cocojojo5213@users.noreply.github.com>
2026-06-08 12:12:49 +02:00
Johannes Millan
24a691bed5
fix(sync): surface native WebDAV errors (#8058) 2026-06-06 15:09:46 +02:00
Johannes Millan
49ac0ca823 test(sync-providers): import log helpers from source, not built subpath
error-meta.spec.ts was the only test importing via the package
self-reference subpath @sp/sync-providers/log, which exports maps to
./dist/log.mjs — requiring a tsup build that 'npm test' never runs, so
vitest could not resolve it. Import ../../src/log like the other 15
test files and drop the now-dead tsconfig.spec path mapping that only
existed to satisfy that import's typecheck.
2026-06-03 16:34:51 +02:00
Johannes Millan
c41c18f247
fix(sync): guard fresh-client first sync against data-loss trap & decrypt-race (#7980)
* fix(sync): guard fresh-client first sync against data-loss trap & decrypt-race

A genuinely-fresh client seeds example tasks before its first sync, so on first
connect to a populated, encrypted SuperSync dataset it hit a SYNC_IMPORT conflict
dialog whose USE_LOCAL would overwrite the real remote, plus a red
DecryptNoPasswordError.

- A1: SyncImportConflictGateService flags isNeverSynced (\!hasSyncedOps) on the
  incoming-import dialog; the dialog guards the destructive USE_LOCAL with a
  confirm and focuses the scenario-appropriate safe button via cdkFocusInitial.
- B: OperationLogDownloadService logs 'encrypted ops, no key' quietly only for a
  genuinely-fresh client (never synced AND no local encryption flag); it stays
  loud (dropped-credential signature) for already-synced clients and for a wiped
  op store whose config still flags encryption on, via the new optional provider
  method SuperSync.isEncryptionEnabled().
- D: SuperSync.isReady() returns false while isEncryptionEnabled && \!encryptKey,
  keeping a self-inconsistent encrypted config out of auto-sync; shares the
  _isEncryptionHalfConfigured predicate with getEncryptKey().

Tests: super-sync isReady/isEncryptionEnabled, conflict-gate isNeverSynced,
dialog confirm-guard + focus-by-scenario, download severity branches, and a
sync-service end-to-end first-sync-trap guard.

Plan/rationale: docs/plans/2026-06-03-fresh-client-first-sync-data-loss-trap.md

* docs(sync): add fresh-client first-sync data-loss-trap plan

Documents the shared root cause (example tasks seeded pre-first-sync), the A1/B/D
fixes, and the accepted Fix C residual (example-task pollution onto non-encrypted
accounts) with the rationale for deferring an identity-based cleanup.

* fix(sync): capture never-synced guard pre-download for piggyback SYNC_IMPORT

The SYNC_IMPORT conflict gate's never-synced guard (which blocks a
fresh client from force-overwriting a populated remote via USE_LOCAL)
was computed from a live hasSyncedOps() read on the piggyback-upload
path. By the time that gate runs, the same sync has already persisted
downloaded ops with syncedAt and marked accepted uploads synced, so the
flag flips to "has synced" mid-cycle and the guard disarms itself —
re-opening the data-loss trap on the piggyback path.

Capture the never-synced snapshot once at sync-cycle start, before
download, and thread it through downloadRemoteOps()/uploadPendingOps()
into the gate. The gate falls back to a live read only for standalone
callers (download gate is safe live: nothing is persisted until
processRemoteOps).

Also stop the sync-wrapper catch from re-logging the expected
DecryptNoPasswordError at error level — the download/upload service
already logs it at the right severity, so re-logging undid the
fresh-client onboarding-prompt quieting.

Tests: gate honors a caller-provided isNeverSynced without consulting
live history; piggyback path flags isNeverSynced=true even when the
upload marks ops synced; wrapper threads the pre-download snapshot.
Refresh the plan doc's stale test counts and document the fix (§11).
2026-06-03 16:01:16 +02:00
Johannes Millan
5aea4b0143
fix(sync): force no-cache on native WebDAV reads (iOS data loss) (#7982)
iOS reads a stale sync-data.json on every WebDAV sync: URLSession's
default configuration caches GET responses, so the client never sees
remote changes and — because the pre-upload conflict check re-reads the
same cached body — blindly overwrites newer remote data. Android (OkHttp,
no cache) and web (fetch `cache: 'no-store'`) are unaffected, which is why
this is iOS-only.

- iOS WebDavHttpPlugin: disable the HTTP cache (urlCache=nil +
  reloadIgnoringLocalCacheData) so sync reads always hit the network.
- WebDavHttpAdapter: send `Cache-Control: no-cache, no-store` + `Pragma`
  on the native request path, covering an upstream proxy/CDN cache too.
- Log allowlisted, non-sensitive cache-relevant response headers so a
  future log capture localises a stale read (client cache vs proxy).

Fixes #7144
2026-06-03 14:46:18 +02:00
Johannes Millan
54671f87be
chore(deps): resolve 30 Dependabot security alerts (dev/build tooling) (#7960)
* chore(deps): bump plugin-dev dev tooling to patch security alerts

Update vite, vitest, postcss, glob, and minimatch across the
packages/plugin-dev/* sample plugins to clear 26 Dependabot alerts.
These are dev/build tooling for the example plugins and are not
shipped to end users.

- vite >=7.3.2, vitest 4.1.x, postcss 8.5.15, glob 10.5.0,
  minimatch 9.0.9/5.1.9 (all within declared semver ranges)
- clickup-issue-provider: vitest ^3.2.1 -> ^4.1.0 (only manifest change)

Verified test suites pass: clickup (28), automations (109).

* chore(deps): bump minimatch + webpack-dev-server overrides for security

Resolve the remaining root-lockfile Dependabot alerts in build/dev
tooling (not shipped to end users):

- minimatch: the existing `app-builder-lib` override pinned minimatch to
  10.1.1, which npm cascades across electron-builder's entire subtree
  (@electron/asar, @electron/universal, dir-compare, filelist, temp).
  Bump the override to 10.2.5 (within app-builder-lib's ^10.0.3); the
  vulnerable nested copies dedupe to the patched top-level. Clears
  Dependabot #341, #372, #373 (minimatch ReDoS).
- webpack-dev-server: add override to 5.2.4. Clears #593.

Note: electron-builder `npm run dist` packaging not run in this env;
the minimatch bump is a patch within range so impact is expected nil.
2026-06-02 19:44:21 +02:00
Johannes Millan
726d877d17
fix(sync): point manual supersync image push at super-productivity org (#7871)
build-and-push.sh defaulted the image namespace to the deprecated ghcr.io/johannesjo/supersync — the source of the stale 7-day-token :latest image in #7865. Default to the super-productivity org instead, matching the CI workflow, docker-compose.yml and the Helm chart.

Since you cannot docker login as an org, split the image namespace (GHCR_NAMESPACE, defaulting to the org) from the GHCR login account (GHCR_USER), and fail fast if a push is attempted with no GHCR_USER set.

Refs #7865
2026-06-01 13:32:06 +02:00
Johannes Millan
2580ea6eb1
perf(plugins): skip no-op doc-mode saves + rename to doc-mode (#7825)
* perf(plugins): skip no-op doc-mode saves via lastSeenDocBytes equality

Closes #7815.

flushSave and flushSaveSync now compute the would-be-written raw bytes
first and short-circuit when they equal lastSeenDocBytes — a typed-then-
reverted cycle inside the 30s throttle window no longer produces a
postMessage round-trip, an IDB transaction, or an op-log entry.

The baseline is intentionally NOT updated on a skip: it already equals
these bytes, so the self-echo invariant for PERSISTED_DATA_CHANGED
(#7752) is preserved.

Drive-by: added the isDocCorrupt guard to flushSave for symmetry with
flushSaveSync / scheduleSave. The schedule gate normally prevents
flushSave from firing on a corrupt doc, but corruption can be set
between schedule and fire (e.g. a remote-update reload turning up an
unparseable blob), so the explicit guard is cheaper than reasoning
about that invariant.

Persistence rename: saveContextDoc → persistContextDocRaw. The caller
now owns serialisation (via serializeContextDoc) so the byte-compare
can run before the persist dispatch. Three persistence.spec.ts tests
replace the dropped saveContextDoc test: exact-raw write, encoder
determinism (the premise the byte-compare relies on), and
write-idempotence (which proves the skip loses no information).

* refactor(plugins): apply doc-mode no-op-save review findings (#7815)

Multi-review follow-ups for #7815:

- Tighten serializeContextDoc determinism test: previously asserted
  equality between a doc and its `{...doc}` spread, which preserves V8
  insertion order — the test would pass even against a future encoder
  that randomised iteration over Map-backed nodes (the real failure
  mode for the byte-compare). Now asserts repeated-call determinism on
  the same input directly, plus type+non-empty shape so a future return-
  type change (e.g. Uint8Array) breaks here, not in the editor.

- Document the sync/async stamp asymmetry in flushSaveSync: it stamps
  lastSeenDocBytes BEFORE the void dispatch, whereas flushSave stamps
  AFTER `await`. Both are correct (the sync path is fire-and-forget and
  the iframe can be torn down mid-call, so a post-dispatch stamp would
  silently drop on teardown) but the divergence wasn't called out in
  the inline comments.

* refactor(plugins): rename document-mode to doc-mode

Renames the bundled plugin's id (`document-mode` → `doc-mode`), display
name (`Document Mode (Alpha)` → `Doc Mode (Alpha)`), package name,
directory, and asset path. No migration: the plugin has never been
published, so there is no on-disk user data to preserve.

Touched everywhere the id was hardcoded:
- Plugin manifest, package.json, build/deploy/test scripts, log prefixes
- Host BUNDLED_PLUGIN_PATHS entry (src/app/plugins/plugin.service.ts)
- Host comments and test fixtures (plugin-hooks, plugin-persistence-key,
  plugin-persistence.model, conflict-resolution.service.spec)
- E2E spec filenames + PLUGIN_ID constant + label assertions
- build-all.js plugin orchestrator
- build/release-notes.md user-facing entry

Test pass: 88/88 plugin specs, 8/8 plugin-hooks, 132/132
conflict-resolution, 15/15 plugin-persistence-key.util.
Bundle redeployed to src/assets/bundled-plugins/doc-mode (gitignored).

The old `src/assets/bundled-plugins/document-mode/` dir is gitignored and
left on disk after this commit; the host loader no longer references it,
so it's inert. Remove with `rm -rf src/assets/bundled-plugins/document-mode`.

* refactor(plugins): apply doc-mode rename review findings

Multi-review follow-ups for the rename in 3443bcacd0:

- editor.ts:1920: missed runtime log string ('Document mode:' →
  'doc-mode:') — leaked into the dev console with inconsistent brand
  because the rename sweep matched on the kebab `document-mode` or the
  PascalCase `Document Mode`, not the bare-space `Document mode`.
- editor.ts + background.ts header JSDoc: `Document-Mode editor` /
  `Document-Mode background script` → `Doc-Mode …`. Cosmetic but the
  only remaining branded references in the source.
- scripts/build.js: esbuild iife `globalName: 'DocumentModePlugin'` →
  `'DocModePlugin'`. Internal global, no consumers, but worth keeping
  consistent with the new id.
- src/app/plugins/plugin.service.ts: add a header comment to
  BUNDLED_PLUGIN_PATHS noting that pluginIds become entityId prefixes in
  IDB / op-log / sync wire and must be treated as permanent for any
  plugin that has ever shipped. Records the rule we relied on being
  absent for this rename.

DOM-document references (`installDocumentDragHandlers`,
`Document-status banner`, `Document-level pointer handlers`) left
unchanged — those refer to the browser document object, not the brand.
Historical plan docs in `docs/plans/2026-05-2[123]-*.md` also left
unchanged per the rename commit's intent.
2026-05-27 17:30:38 +02:00
Federico Simonetta
9a7a86c8ec
Feature plugins app state (#7803)
* chore(plugin-api): normalize index exports

* feat(plugin-api): expose app state types and getAppState

* feat(plugins): expose getAppState through PluginAPI

* feat(plugins): expose getAppState in plugin-bridge

* fix(plugins): restore tag selector import

* test(plugins): cover PluginAPI.getAppState

* fix(plugins): expose getAppState for iframe PluginAPI

* docs(plugin-api): add getAppState permission + example

* docs(plugins): concise getAppState entry

* fix(plugins): redact credentials from getAppState snapshot

`getAppState` previously returned `globalConfig` and `projects[]` verbatim,
exposing per-installation secrets to every loaded plugin. Strip the three
known credential surfaces before returning:

- `globalConfig.sync` — WebDAV/Nextcloud passwords, SuperSync access tokens,
  encryption keys
- `globalConfig.misc.unsplashApiKey` — user-supplied API key
- per-project `issueIntegrationCfgs` — Jira/CalDAV passwords, GitLab/Redmine
  tokens, OpenProject/Trello/Linear keys

Add a security-regression spec that seeds sentinel credential strings into
each surface and asserts none appear in `JSON.stringify(snapshot)`.

---------

Co-authored-by: 00sapo <00sapo@noreply.codeberg.org>
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
2026-05-27 17:02:29 +02:00
Johannes Millan
6d81dde043
feat(plugins): adopt PERSISTED_DATA_CHANGED in document-mode (#7752) (#7812)
* feat(plugins): adopt PERSISTED_DATA_CHANGED in document-mode (#7752)

Document-mode's iframe editor went stale when another device edited the
same context's doc — the editor kept typing on in-memory `storedState`
and the next throttled save merged-on-stale-base, partially clobbering
the remote edit. Background's `enabledIds` set had the same gap for
remote toggles.

Adopts the host-side `PERSISTED_DATA_CHANGED` hook (shipped in #7805,
made multi-handler-safe in #7811) in both surfaces:

- `background.ts` — on fire: re-read `enabledIds`, diff, and call
  `showInWorkContext`/`closeWorkContextView` only when the active
  context's membership flipped. Idempotent on no-op re-fires; the
  hook also fires for `doc:` writes which background ignores via the
  set-unchanged short-circuit.

- `ui/editor.ts` — on fire: load the current ctx's raw `doc:` bytes via
  the new `loadContextDoc` `{ raw, parsed }` shape, byte-compare to
  `lastSeenRemoteData`. Equal → noop (self-echo or another key's fire).
  Different → show a one-button "reload" banner with a distinct
  `doc-banner--remote-update` modifier class. Clicking Reload re-runs
  `setActiveContext` which already re-reads + setContent's.
  `isDocCorrupt === true` short-circuits to a direct reload (the
  corruption banner already promises saved data is untouched).

The load-bearing piece is `lastSeenRemoteData` — captured as the raw
`loadSyncedData()` string on both load (`setActiveContext`) and write
(`flushSave` / `flushSaveSync`). The editor's own `JSON.stringify(getJSON())`
is NOT byte-stable across the load path because `prepareStoredDoc`
reshapes chip content against the local task cache, so comparing
against editor output would mis-flag every load as remote. Raw-against-raw
keeps the host's deterministic encoding the only thing that matters.

Acceptance criteria from #7752: all met except the E2E append, which
needs iframe-level state injection that the existing spec helpers
don't cover; covered by manual verification + unit specs instead.

What this doesn't fix:
- No cross-context notification (editor catches up silently on switch).
- Same-context concurrent edits still resolve whole-doc LWW.
- No silent swap or selection preservation.
- No "Keep mine" force-flush — dismiss-and-keep-typing already wins LWW.

Tests: 84/84 plugin specs pass (+6 background hook membership branches,
+1 corrupt-entry raw-bytes case, +1 saveContextDoc-returns-raw case).
Plan v6 changelog documents the v5/v6 multi-review iterations and the
cuts (pre-reload backup, pure-fn file, coalesce timer, "Keep mine").

* refactor(plugins): apply doc-mode review findings (#7752)

Multi-review of the prior commit surfaced two real and one likely-real
bugs in the reconciler and a few cleanup wins. Apply all that survived
verification.

W1 — wrap getActiveWorkContext in try/catch. The prior handler only
caught loadEnabledCtxIds rejections; a getActiveWorkContext throw
would escape via the void onPersistedDataChanged() registration as an
unhandled rejection and leave enabledIds stale across every subsequent
fire.

W2 — snapshot enabledIds at entry. The prior handler read the closure
variable across multiple awaits before assigning at the end; two
interleaved fires could mis-compute wasActiveEnabled and drop a
close/show call. Pass-by-parameter to reconcileEnabledIds eliminates
the in-function race; concurrent fires now race only on the final
caller assignment, which is at least eventual-consistent.

W3 — extract reconcileEnabledIds to its own file and import the real
function from the spec (no more local copy). reconcile-enabled.ts
sidesteps the testability problem at its root: background.ts has
top-level PluginAPI side-effects (registerWorkContextHeaderButton,
registerHook) that crash in node, so the spec can't import from there.
The new file holds only the pure reconciler + try/catch hardening.

W4 — extract serializeContextDoc(doc) helper. flushSave and
flushSaveSync now produce byte-identical output via the same function,
so a future encoding change can't silently desync the sync path from
the async one.

W5 — fix the inline comment on showRemoteUpdateBanner re-entrancy to
match what the code actually does (early-return because text is
invariant, not "replace text in place" as the v6 plan implied).

S1 — rename lastSeenRemoteData → lastSeenDocBytes. The variable also
holds local-write bytes, not only "remote" ones; the new name follows
the existing lastSeenTaskIds convention.

S2 — drop the try/catch around loadSyncedData in
onRemotePersistedDataChanged. The hook dispatcher already catches +
logs handler rejections (PluginHooksService._invokeWithTimeout), and
loadContextDoc elsewhere in this file follows the no-wrap convention.

S3 — add primitive-JSON case to persistence.spec.ts. loadContextDoc's
new {raw, parsed} shape silently forwards parsed=123/null/"hi" to
callers; the editor's truthy guard is the safety net, but the
persistence contract is now locked.

S5 — explain why isDocCorrupt short-circuits to a direct reload
(auto-recovery without user click; alternative would leave the user
stuck on the corruption banner even when the remote already fixed
the entry).

Tests: 86/86 plugin specs pass (+7 reconciler error/membership cases,
+1 persistence primitive-JSON). Bundle redeployed.

* docs(plugins): update stale lastSeenRemoteData refs in JSDoc/comments

Two comments referenced the pre-rename variable name.
2026-05-27 11:46:50 +02:00
Johannes Millan
3c69aa961e
feat(plugins): fire PERSISTED_DATA_CHANGED hook on persisted-data changes (#7805)
* feat(plugins): fire PERSISTED_DATA_CHANGED hook on persisted-data changes

Wires the dead PluginHooks.PERSISTED_DATA_UPDATE enum into a fired hook,
renamed PERSISTED_DATA_CHANGED. Plugins are notified when their persisted
data changes for any reason after the host's initial boot load — local
writes, remote incremental sync via bulkApplyOperations, and post-boot
wholesale loadAllData paths (SYNC_IMPORT / BACKUP_IMPORT / validation
repair / recovery).

Selector-based effect on selectPluginUserDataFeatureState, gated on
SyncTriggerService.afterInitialSyncDoneAndDataLoadedInitially$ so the
boot-time state seeds the pairwise baseline. Differ compares prev/next
by === on the encoded data blob (never decoded). Stage A composite
entityIds (pluginId:key) are normalized to owner pluginId and deduped
so a plugin with N keyed entries changing in one emission fires exactly
once. Per-pluginId dispatch via new PluginHooksService.dispatchHookToPlugin.

Effect is { dispatch: false } and creates no ops, so no sync-window
guard is needed — and adding one (skipDuringSyncWindow) would silently
suppress the very remote-sync deliveries the hook is designed to catch.

Closes #7754. Follow-up: doc-mode adoption tracked in #7752.

* test(plugins): tighten PERSISTED_DATA_CHANGED spec + docs

Multi-review pass 2 surfaced that the 5s timeout spec asserted only that
the dispatcher resolved — it would have silently passed if the timeout
race were removed entirely. Spy on PluginLog.err and assert the timeout
message actually reached the catch branch.

Also:
- Add `:` guard to PluginHooksService.registerHookHandler so the
  persistence-key grammar is enforced at both the persistence and
  hooks-registry endpoints (defense-in-depth; composeId already throws
  at the bridge).
- Add async-rejected-promise spec to cover the Promise.race branch that
  sync-throw didn't exercise.
- Carry the PERSISTED_DATA_CHANGED contract paragraph into
  docs/plugin-development.md and docs/wiki/3.01-API.md (previously only
  in packages/plugin-api/README.md).
- Clarify loadSyncedData(key?) in the README for keyed plugins.
2026-05-26 23:41:01 +02:00
Johannes Millan
923d946fae
fix(sync): harden OneDrive provider follow-ups from #7523 (#7800)
Address review items from issue #7797:

- @odata.nextLink absolute-URL pass-through (sovereign clouds), gated by
  an HTTPS + Microsoft Graph host allowlist so a tampered nextLink can't
  redirect this request's Bearer token to a hostile origin.
- Hard cap (500 pages) on listFiles pagination to prevent unbounded
  growth from a buggy or cyclic @odata.nextLink.
- Collapse the missing/mismatched OAuth state errors into a single
  i18n'd snack key (T.F.SYNC.S.OAUTH_STATE_INVALID).
- Replace the `as OneDrivePrivateCfg` cast with an explicit field merge
  that coerces form-side nulls to undefined; surface a snack if required
  fields are missing instead of returning silently.
- Mark the OneDrive provider as experimental in the sync provider
  dropdown.

Adds unit tests covering the sovereign-cloud nextLink pass-through, the
pagination cap, and the new bearer-token allowlist (asserts the hostile
host is rejected before fetch is called).
2026-05-26 20:41:33 +02:00
Harbinger
9910d30fca
feat(sync): add OneDrive sync provider with PKCE auth (#7523)
* feat(sync): integrate onedrive with pkce and operation log sync

* fix(sync): add oauth state validation and token refresh concurrency control

* fix(sync): refactor onedrive oauth cleanup and reduce download API calls

- Replace setInterval-based OAuth state pruning with on-demand
  _pruneExpiredOAuthStates() to avoid background timer overhead
- Optimize downloadFile to read ETag from content response headers
  first, falling back to a metadata request only when needed (reduces
  API calls from 2 to 1 in the common case)
- Narrow OneDrive class interface from SyncProviderServiceInterface to
  FileSyncProvider for stronger type guarantees
- Accept optional devPath constructor parameter for non-production
  folder namespacing
- Extract isOneDriveClientIdRequired to a helper function in
  sync-form.const.ts for readability
- Change default OneDrive sync folder name to 'Super Productivity'

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

* fix(sync): address PR #7523 review feedback for OneDrive sync

Critical fixes:
- Detect invalid_grant in token refresh responses and clear credentials
- Reset _tokenRefreshInFlightPromise in clearAuthCredentials to prevent
  stale refresh results from overwriting cleared state
- Re-read config before persisting refreshed tokens to avoid race with
  concurrent clearAuthCredentials calls

Important fixes:
- Extract OAuth state management to shared oauth-state.util.ts to fix
  circular dependency between OneDrive provider and callback handler
- Add 401 retry pattern (_is401Retry) matching Dropbox's approach:
  on 401, proactively refresh token and retry, only clear credentials
  if retry fails
- Remove unused auth status translation keys (AUTH_SUCCESS,
  BTN_AUTHENTICATE, BTN_REAUTHENTICATE, REAUTH_SUCCESS,
  STATUS_CONFIGURED, STATUS_NOT_CONFIGURED) and form props
- Remove unused requireAuth parameter from _cfgOrError

Minor fixes:
- Replace path-exposing log messages with structured logging
- Remove unused _formatHttpErrorDetails helper
- Return empty ETag fallback in getFileRev instead of throwing
- Add folder cache invalidation comment for path changes
- Add afterEach to restore global fetch in tests
- Add PKCE defence-in-depth comment in auth code dialog

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

* fix(sync): use eslint-disable-next-line for @microsoft.graph.conflictBehavior

Replace spread+computed-property workaround with a plain property plus
an explicit eslint-disable-next-line comment. Clearer intent: the
naming violation is a Graph API requirement, not accidental.

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

* fix(sync): pre-save OneDrive form config in reauth() to prevent MissingCredentialsSPError

Re-auth was failing silently on Linux because getAuthHelper() reads
clientId from IndexedDB (privateCfg), but the form values were never
written before calling configuredAuthForSyncProviderIfNecessary().

- Extract shared BTN_REAUTHENTICATE and REAUTH_SUCCESS translation keys
- Add OneDrive to OAUTH_SYNC_PROVIDERS
- Pre-save form config in reauth() matching the existing save() flow

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

* fix(sync): adapt TooManyRequestsAPIError to new constructor signature after rebase

* refactor(sync): migrate OneDrive provider to packages/sync-providers

Move OneDrive core logic into the shared sync-providers package,
matching the pattern used by Dropbox, WebDAV, and other providers.
The app-side code is now a thin adapter with a createOneDriveProvider()
factory function.

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

* fix(sync): re-throw MissingRefreshTokenAPIError from _requestOAuthToken catch block

The catch block in _requestOAuthToken was swallowing the
MissingRefreshTokenAPIError thrown after clearing credentials on
invalid_grant, causing it to fall through to a generic HttpNotOkAPIError
instead. Now re-throws MissingRefreshTokenAPIError explicitly.

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

* fix(sync): address PR #7523 second review critical and important items

- Replace _is401Retry instance field with per-call parameter to prevent
  concurrent 401 races (Critical #1)
- Fix _requestOAuthToken catch block to re-throw MissingRefreshTokenAPIError
  instead of swallowing it (Critical #2)
- Fix _parseOAuthCallback defaulting unknown provider to 'unknown' instead
  of 'dropbox' to prevent misrouting (Critical #3 + Important #6)
- Change conflictBehavior from 'replace' to 'fail' to prevent data loss
  if a file exists with the same name as the folder (Critical #4)
- Await in-flight token refresh promise in clearAuthCredentials before
  clearing, to close the refresh-during-clear race (Important #5)

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

* fix(sync): address PR #7523 review important and minor items

- Extract duplicate OneDrive pre-auth save block into
  _persistOneDriveFormCfgBeforeAuth() method (Important #7)
- Add GET probe in _ensureSyncFolderExists to skip per-segment
  creation when folder already exists (Important #9)
- Redact sensitive params (code, code_verifier, refresh_token,
  access_token) from token endpoint and Graph error bodies
  (Important #11)
- Remove targetPath from _mapAndThrow error messages to prevent
  logging user content (Important #12)
- Add OAuth state validation in manual paste flow for OneDrive
  (Important #13)
- Fix unknown provider routing in _parseOAuthCallback (Important #14)
- Guard Electron IPC listener against post-destroy emissions
  (Important #15)
- Remove dead authStatus Formly field from sync-form.const.ts (Minor)
- Simplify Headers builder in _ensureSyncFolderExists (Minor)
- Fix spec afterEach to restore original fetch instead of undefined
  (Minor)

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

* fix(sync): fix clearAuthCredentials race and add unit tests for OneDrive

Fix a race condition in clearAuthCredentials where _tokenRefreshInFlightPromise
was nulled AFTER awaiting, causing the invalid_grant handler's clearAuthCredentials
to wait on itself. Now captures the promise and nulls the field before awaiting.

Add 5 new unit tests covering critical code paths:
- 401 token refresh and retry
- 401 retry failure with credential clearing
- 400 invalid_grant credential clearing
- 412 precondition failed mapping
- Concurrent token refresh deduplication

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

* fix(sync): address PR #7523 third review items

- Simplify clearAuthCredentials: null _tokenRefreshInFlightPromise without
  awaiting to avoid deadlock when called from inside the refresh IIFE
- Add superproductivity:// scheme validation in Electron OAuth listener
- Fix invalid_grant test to assert MissingRefreshTokenAPIError
- Fix folder cache test to match GET-probe optimization
- Set setComplete default in beforeEach

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

* test(sync): fix OAuthCallbackHandler spec to expect 'unknown' provider

The _parseOAuthCallback now returns 'unknown' instead of 'dropbox' for
URLs without an explicit provider path segment.

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

* fix(sync): address PR #7523 fourth review items

- Handle generic 401 from token endpoint (clear creds + MissingRefreshTokenAPIError)
- Remove user-supplied paths from error constructors (rule 9 compliance)
- Soften clearAuthCredentials comment to reflect actual TOCTOU guarantee
- Tighten Electron scheme gate to match native path prefix
- Add scheme info to Electron rejection log
- Fix folder-cache test to assert GET probe count
- Remove redundant per-test setComplete stub

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

* fix(sync): address PR #7523 fifth review items

- Scope invalid_grant/401 credential clearing to refresh_token grant only
  (bad auth code exchange no longer wipes existing credentials)
- Redact OAuth code/state from Electron protocol handler logs
- Add @sp/sync-providers/onedrive TS path alias to tsconfig files
- Default undefined sync config fields instead of overwriting with undefined
- Handle OneDrive in provider-switch branch (preserve encryptKey)
- Update sync-config test expectations to match default values

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

* fix(sync): address PR #7523 sixth review items

- Use If-None-Match: * for null-rev uploads to prevent concurrent overwrite
- Guard in-flight refresh against stale credentials (match refreshToken/clientId/tenantId)
- Gate OneDrive provider option behind Electron/Native (web CORS unsupported)
- Don't clear credentials on transient token endpoint errors (429/5xx)
- Preserve null syncProvider instead of defaulting to WebDAV
- Hydrate full OneDrive privateCfg on provider switch
- Remove duplicate syncInterval/isManualSyncOnly Formly controls
- Revert non-English locale changes (zh.json, zh-tw.json)
- Redact URL fragments (#) in Electron protocol handler logs
- listFiles rethrows non-404 errors instead of swallowing all
- Update 401 test expectations for new rethrow behavior

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

* fix(sync): guard credential clearing against stale concurrent refresh

Prevent a stale in-flight refresh from wiping newer credentials after
a concurrent re-auth. The refresh IIFE now throws a plain Error (not
MissingRefreshTokenAPIError) when it detects config drift, and all
clearAuthCredentials() call sites now verify the stored config still
matches before clearing. Also fix listFiles() to handle 404 from
_requestJson (which throws HttpNotOkAPIError, not RemoteFileNotFoundAPIError).

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

* fix(sync): address PR #7523 eighth review items

- Replace If-None-Match:* with @microsoft.graph.conflictBehavior=fail query param for upload create-only semantics
- Add identity change detection in _persistOneDriveFormCfgBeforeAuth to clear tokens when useCustomApp/clientId/tenantId change
- Extract _clearIfConfigMatches helper to guard credential clearing against stale concurrent refresh
- Restrict folder probe fallthrough to 404 only, propagate other errors
- Restore locale files from upstream master
- Centralize IS_ONEDRIVE_SUPPORTED in onedrive-auth-mode.const.ts, use in factory and form config

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

* fix(sync): address PR #7523 ninth review items

- Paginate listFiles via @odata.nextLink to avoid silent data loss
- Fix logger misuse (log -> normal with proper string message)
- Replace as any with OneDrivePrivateCfg in dialog-sync-cfg
- Throw NoRevAPIError when uploadFile response lacks eTag
- Omit undefined optional booleans from global config to prevent overwrite
- Move OneDrive dynamic import inside IS_ONEDRIVE_SUPPORTED gate
- Require state param for OneDrive full-URL paste (CSRF)
- Add id_token to sensitive keys for body redaction

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

* fix(sync): add OneDrive type alias to electron tsconfig paths

The @sp/sync-providers/onedrive path alias was missing from
electron/tsconfig.electron.json, causing the Electron build to fail
with "Cannot find module '@sp/sync-providers/onedrive'". The alias was
already present in tsconfig.base.json but electron uses its own paths.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 15:31:06 +02:00
Spyros Seimenis
00c30d34e7
fix: preserve #<n> prefix on imported issue tasks (#7772)
* fix(plugins/github): correct issue-number prefix on imported tasks

The title field mapping mishandled issue imports two ways:

- Search/backlog results carry no `number`, so ctx.issueNumber was
  undefined and titles got a literal "#undefined " prefix. Fall back
  to ctx.issueId (equals String(issue.number) for GitHub).
- mapSearchResult already display-prefixes the title with "#<n> ", so
  re-applying it yielded "#<n> #<n> ...". Short syntax then treated the
  non-leading "#<n>" as a tag and stripped it. toTaskValue is now
  idempotent so the prefix lands exactly once.

* fix(tasks): ignore numeric #tags in issue task titles

An issue task's leading "#<number>" is the issue id, and issue titles
routinely reference other issues ("fixes #1234"). Short syntax parsed
those non-leading "#<n>" tokens as tags, prompted to create them, and
stripped them from the title. Extend the existing numeric-at-start
guard to also block numeric "#<n>" anywhere on issue tasks. Non-numeric
tags ("#mytag") still parse, so manual tagging of issue tasks via the
title keeps working.
2026-05-26 14:15:33 +02:00
Johannes Millan
8f274582e2
feat(plugins): Stage A keyed persistence with LWW (#7749) (#7763)
* feat(plugins): keyed persistence API for per-context LWW (Stage A Phase 1+3)

Add an optional `key` argument to `persistDataSynced` / `loadSyncedData`,
composed at the bridge transport boundary into `pluginId:key` entity ids.
Distinct keys now produce distinct ops that LWW-resolve per-entity,
enabling document-mode-style plugins to avoid cross-context blob
overwrites without changing existing keyless callers.

Phase 3: `removePluginUserData(pluginId)` now sweeps the full prefix
(legacy entry + every keyed entry), dispatching one delete per match
with the rule-6 setTimeout(0) trailer so remote replicas don't keep
keyed entries after uninstall. The reducer-only "smart prefix match"
shortcut is wrong (one op for the prefix only, remote keyed entries
leak) — see docs/plans/2026-05-23-stage-a-keyed-plugin-persistence.md
Phase 3.

Phase 4 (document-mode plugin-side migration of the legacy single-blob
entry) is left as a separate follow-up so the host change can be
reviewed in isolation.

Issue #7749

* feat(document-mode): migrate to keyed persistence (Stage A Phase 4)

Move from one synced blob under the bare plugin id to per-entity keyed
entries:
 - meta            — { enabledCtxIds: string[] }, owned by background.ts
 - doc:${ctxId}    — one entry per context, owned by the editor iframe
 - __meta__        — migration stamp

Each entry has its own LWW timestamp on the host, so a concurrent edit
in project A on Device 1 and project B on Device 2 no longer
whole-blob-collide.

The migration runs idempotently from both background.ts and editor.ts
(stamp-guarded), splits the legacy single blob into keyed entries, then
tombstones the legacy entry with an empty payload — giving LWW a
winning side against any offline device that still writes the old
shape.

flushSave / flushSaveSync no longer need to read+merge sibling state,
since each context's entry stands alone. The future-version blob guard
(isStorageUnreadable) is dropped — it referenced the wrapping blob's
version, which no longer exists; per-doc corruption still falls back
via isDocCorrupt.

Issue #7749

* fix(plugins): tighten Stage A keyspace at the boundaries

Multi-review surfaced three small gaps in the keyed-persistence rollout:

- The synchronous composeId throw covers the bridge's iframe and
  direct-API entry points, but three in-process callers
  (plugin-config.service, plugin.service, plugin-config-dialog) bypass
  the bridge and route directly into the persistence service. A
  user-installed plugin with `id: "evil:plugin"` passed manifest
  validation and would have collided with the legitimate `evil`
  plugin's keyed namespace — `removePluginUserData('evil')` would have
  over-matched the sweep. Reject the colon at install time in
  `validatePluginManifest`; keep the bridge throw as defense-in-depth.

- The new `key` arg at the bridge was typia-asserted on `data` but
  unchecked itself. A compromised iframe could pass a multi-megabyte
  string or a non-string value via postMessage. `data` is capped at
  1 MB, but the entity id composed from `key` would be stored verbatim
  in NgRx state, IndexedDB, the op-log, and on the sync wire — bypassing
  the data cap. Add `assertPluginPersistenceKey` with a 256-char cap.

- `_loadPersistedData` silently returned `null` when composeId threw,
  while `_persistDataSynced` rethrew. The asymmetry made a malformed
  pluginId look like "no data yet" on the load side, indistinguishable
  from a fresh install. Hoist composeId + key validation out of the
  load try/catch so it throws symmetrically.

Issue #7749

* fix(plugins): lower per-write cap to 256 KB

The pre-Stage-A 1 MB cap was sized for the old single-blob shape, where
one entry held every context's data. With the keyed split, each entity
gets its own write budget — 1 MB per write is wildly over-provisioned
for the realistic upper bound of plugin payloads (heavy document-mode
docs ~30–100 KB, configs and automations KB-scale).

256 KB keeps 2–5× headroom over realistic payloads while bounding the
per-plugin storage growth more tightly.

Document-mode's migration loop now skips oversized legacy docs instead
of aborting the whole run: a user whose legacy blob holds one ~500 KB
doc (legal under the old cap) keeps the other contexts migrated and
the original bytes preserved in the legacy entry. The success stamp
stays at migrated:0 in that case so a future build (or pruning of the
doc) can complete the migration without data loss.

Issue #7749

* test(plugins): e2e migration of legacy single-blob to keyed entries

The migration logic in document-mode is unit-tested against a mock
PluginAPI, which can't catch real-iframe quirks (postMessage handling
of undefined second args, commit-chain timing under the host's
per-entity rate limiter, hydration ordering against the op-log). Add
two end-to-end scenarios:

- Fresh install: enable the plugin, verify the __meta__ stamp lands
  at migrated:1 (the migration's final write — observing it implies
  every earlier step completed).
- Legacy blob: seed a pre-Stage-A single-blob entry via the e2e helper
  store, enable the plugin, verify the legacy entry is tombstoned,
  meta carries the enabledCtxIds, and each doc landed under its own
  doc:${ctxId} key.

Issue #7749

* chore(plugins): drop dead code and review-driven polish

Four small follow-ups from the multi-review pass:

- Don't log the plugin-supplied `key` value. Plugins may use user
  content (search queries, doc titles) as keys; the log history is
  exportable. Log `keyLen` instead, per CLAUDE.md rule 9.
- Delete `detectStaleLegacyWrite` and its 3 specs. Exported and
  fully tested, but zero non-test callers — banner UI is forbidden
  by project convention for transient-only messaging. If the need
  resurfaces, the implementation is four lines.
- Drop the `attemptedAt` field from `MigrationStamp`. It was written
  but never read; the success stamp is the only re-entry gate, and
  the resume path is just "re-run the loop" — re-writes are content-
  idempotent. Saves one rate-limited write per fresh migration.
- Update `docs/plans/2026-05-23-stage-a-keyed-plugin-persistence.md`
  with an implementation-status table referencing the shipping
  commits, so future readers don't have to dig through git.

Issue #7749
2026-05-23 22:23:17 +02:00
Johannes Millan
334b14aa26 feat: migrate to capacitor 8
- fix(sync): unblock @sp/sync-core/@sp/sync-providers typecheck under vitest 4
- chore(mobile): pin @capacitor/keyboard to 8.0.1
- chore(mobile): migrate to Capacitor 8
- Merge branch 'feat/issue-7749-3341d1'
- docs(plugins): plan for wiring PERSISTED_DATA_CHANGED hook
- test(plugins): cover document-mode bundled load and PLUGIN_USER_DATA LWW
- chore(plugins): re-bundle document-mode and document Stage A path
2026-05-23 20:42:00 +02:00
Johannes Millan
196e50b906
test: strengthen unit test assertions and revive disabled plugin specs (#7755)
* chore(plugins): re-bundle document-mode and document Stage A path

Reverts the unbundling from b0cae69ffe. Stage 0 (gzip + throttle, shipped
in 84625be849) handles size; document-mode remains opt-in per context, so
cross-context conflict risk is bounded. Stage A (keyed plugin-persistence
API, issue #7749) is the documented future path for closing the LWW gap
on different-context concurrent edits — picked up when conflicts are
observed in practice.

Design sketch with multi-reviewed phasing lives in
docs/plans/2026-05-23-stage-a-keyed-plugin-persistence.md. Predecessor
plan's "Future work" section now links to it.

* test(plugins): cover document-mode bundled load and PLUGIN_USER_DATA LWW

Follow-ups from the multi-review of 199e816479's re-bundling decision:

- E2E smoke test asserts document-mode appears in plugin management so
  a typo in BUNDLED_PLUGIN_PATHS fails loudly.
- Spec exercises PLUGIN_USER_DATA conflict resolution end-to-end, which
  previously relied on analogy to REMINDER (same array+null branch) but
  was never directly asserted after the migration off 'virtual'.
- Stage A plan risks: stale-editor-view gap surfaced by the review;
  PluginHooks.PERSISTED_DATA_UPDATE already exists in the API but is
  never dispatched host-side — wiring it is the path to a fix.
- background.ts: comment marks the known gap at the registerHook site.

* docs(plugins): plan for wiring PERSISTED_DATA_CHANGED hook

Designs the host-side wiring for the currently-dead
PluginHooks.PERSISTED_DATA_CHANGED so plugins can react to remote-driven
changes to their persisted data. Multi-reviewed twice; v4 trims scope to
host-only (no plugin adoption in this design) and preserves the
multi-review insights as seeds for the follow-up doc-mode adoption
tracked at issue #7752.

Implementation lands in a separate PR.

* test: strengthen unit test assertions and revive disabled plugin specs

Replace tautological assertions, setTimeout-without-expect patterns, and
placeholder `expect(true).toBe(true)` tests with real assertions across
~30 spec files. Revive 5 plugin spec files that were fully commented out
on master with live tests covering core behavior.

Production-side: extract pure helpers for testability:
- app.component: getBackgroundOverlayOpacity, getBackgroundImageBlur
- android-sync-bridge.effects: getSuperSyncCredentialBridgeCommand
- super-sync-server: export escapeHtml, SERVER_HELMET_CONFIG

Delete the always-skipped xdescribe placeholder
src/app/imex/sync/sync-fixes.spec.ts (412 LOC).
Rename operation-log-stress.spec.ts to .benchmark.ts to match its
header comment ("excluded from regular test runs").

No production behavior changes; no master commits reverted.
2026-05-23 18:31:53 +02:00
Johannes Millan
69a0ba05bf fix(sync): accept encrypted retries as duplicate, not invalid op id
Encrypt() generates a fresh random IV per call, so a retried upload of
the same logical op produces different ciphertext for the same plaintext.
The server compared encrypted payloads byte-for-byte in
isSameDuplicateOperation, mis-classifying legitimate retries as
INVALID_OP_ID instead of DUPLICATE_OPERATION. Clients only handle
DUPLICATE_OPERATION as a silent success; INVALID_OP_ID is marked as a
permanent rejection, breaking sync after a partial-success network
failure (server committed the op, client never saw the 200).

Skip payload byte-equality when both sides declare isPayloadEncrypted.
The remaining structural fields (clientId, vectorClock, clientTimestamp,
entityType/Id, actionType, schemaVersion, syncImportReason) still guard
against a UUIDv7 id collision with genuinely different content.
2026-05-23 16:46:05 +02:00
Johannes Millan
84625be849 feat(plugin): improve sync data size for plugins
- docs(plugins): remove delta-sync plan; track Stage A as GH issue
- fix(plugins): preserve teardown safety net + earlier base64 size gate
- fix(plugins): address second-round multi-review findings
- refactor(sync): drop unused isVirtualEntity host re-export
- fix(plugins): address multi-review findings in plugin persistence
- perf(plugins): delegate plugin-data codec to sync-core helpers, cap decompression
- docs(sync): mark delta-sync plan implementation status
- perf(plugins): gzip plugin user data at the persistence boundary
- perf(document-mode): raise save throttle and add focus-loss flush triggers
- fix(sync): resolve plugin-data LWW conflicts via array storagePattern
2026-05-23 15:41:05 +02:00
Johannes Millan
69b1cb2877 perf(document-mode): strip redundant chip content from synced data
The document-mode plugin persisted each taskRef/subTaskRef chip with its task title as inline content plus an isDone attr. Both are re-derived from the host task cache on load (migrateStoredDoc backfills content, refreshChipContentFromCache overwrites both), so storing them only inflates the synced blob -- and the title is its byte-heavy part.

Add a pure stripChipContent() that collapses every chip to a bare identity atom { type, attrs: { taskId } }, and apply it in flushSave / flushSaveSync before serializing. The live editor document keeps its inline content, so title write-back is unaffected; only the persisted copy shrinks. A bare-atom chip loads through the existing unchanged pipeline, so no schema bump or migration is needed. Roughly halves the per-change sync payload.
2026-05-22 20:21:19 +02:00
Johannes Millan
06477e9fd9 feat(super-sync-server): add recover-user data-recovery script
Reconstructs a single user's AppDataComplete by replaying their op log up to a chosen serverSeq and decrypting E2E-encrypted payloads — the recovery path the server's restore endpoint cannot offer for encrypted accounts (it throws EncryptedOpsNotSupportedError). Read-only on the DB; documented in docs/backup-and-recovery.md. Unverified against real encrypted data — see the script header.
2026-05-22 19:18:40 +02:00
Johannes Millan
50e2b53d90 Merge branch 'master' into feat/doc-mode4-2880bb 2026-05-22 18:05:03 +02:00
Johannes Millan
1c10ff67dd feat(document-mode): add TipTap-based document-mode plugin
A document-mode plugin under packages/plugin-dev/document-mode/ that
renders the active work context's tasks as an editable TipTap document.
Per-context doc state is persisted as a last-writer-wins JSON blob via
PluginAPI.persistDataSynced; task identity (title, done state, hierarchy)
stays in NgRx and is reached through PluginAPI.updateTask and the
ANY_TASK_UPDATE hook.

It registers a work-context header button and embeds itself into the
work-view embed slot added in the preceding commit. See
docs/plans/2026-05-21-document-mode-tiptap-plugin.md for the full design.

Squashed from the feat/doc-mode-v4 work; full per-step history is
preserved in the archive/doc-mode-v4-full-history branch and the
doc-mode-v4-pre-squash tag.
2026-05-22 17:33:22 +02:00
Johannes Millan
9176fa5238 feat(plugins): work-context header buttons, embed slot, and WORK_CONTEXT_CHANGE hook
Add the host-side plugin API for work-context-scoped UI:

- registerWorkContextHeaderButton — context-filtered (PROJECT/TAG/TODAY)
  header buttons, with toggled-state support on the active button
- a work-view embed slot so a plugin can replace the task list with its
  own UI for the active context (showInWorkContext / closeWorkContextView)
- the WORK_CONTEXT_CHANGE hook and getActiveWorkContext() pull API,
  sharing one toActiveWorkContext() projection helper as the single
  source of truth
- selectTask API plus the plugin-bridge / plugin-hooks wiring and specs

Squashed from the feat/doc-mode-v4 work. The full per-step history —
including the earlier in-tree (non-plugin) Angular document-mode feature
that was prototyped and then removed on the same branch — is preserved
in the archive/doc-mode-v4-full-history branch and the
doc-mode-v4-pre-squash tag.
2026-05-22 17:33:12 +02:00
Johannes Millan
0075463cf4 build: update package.json and fix server build 2026-05-20 16:01:41 +02:00
Johannes Millan
d05444e2af test(supersync): cover WS storm fixes with unit + integration tests
Both round-1 and round-2 reviewers flagged a coverage gap for the
two new behaviors most likely to silently regress on future refactors:
the WS rate-limit keyGenerator (per-(ip,clientId) keying) and the
setErrorHandler WS-429 downgrade. Closing that gap, plus adding a
real-network storm-survival test that the fake-timer unit tests
cannot model.

- Extract `wsRateLimitKeyGenerator` as a named export from
  websocket.routes.ts so the (ip,cid) composition logic is reachable
  from a unit test. Behavior unchanged — it was previously an inline
  property on the rate-limit config object.
- Extract `pickErrorLogLevel(url, statusCode)` as a named export from
  server.ts. The previous inline branch in setErrorHandler did the
  same work but was untestable without booting a full Fastify app.
- Add 7 keyGenerator unit tests covering: compose, distinct keys per
  cid, missing cid, regex failure, oversize cid (DoS hardening),
  array-shaped query (?cid=a&cid=b), undefined query.
- Add 10 pickErrorLogLevel unit tests covering 5xx → error, the four
  WS-429 path shapes (exact, trailing slash, query, both) → debug,
  sibling-route over-match guard, and the 4xx-stays-loud cases.
- Add a real-network integration test that boots Fastify on a random
  TCP port, connects a real `ws` incumbent + 20 challengers under
  the same clientId, and asserts: every challenger receives 4008,
  the incumbent stays OPEN, WARN fires exactly once, and the
  storm-summary INFO fires exactly once on incumbent close.
  Exercises the @fastify/websocket + ws library + sliding cooldown
  path end-to-end (no DB needed, auth is mocked).
2026-05-20 15:57:00 +02:00
Johannes Millan
3fbde60742 refactor(supersync): address round-2 review of WS reconnect cooldown fix
- Replace the `client.refusedChallengers = 0` post-summary reset with a
  dedicated `summaryLogged: boolean` flag. The reset muddied the
  field's documented "lifetime count" semantics for any future reader
  observing the closing socket. The flag dedupes the `ws.on('close')`
  re-entry without lying about the count.
- Refresh the stale `RECONNECT_COOLDOWN_MS` doc that still described
  the non-sliding semantics from before fbdedf597e.
- Short-circuit the WS-429 path normalize on `statusCode === 429` in
  setErrorHandler so the split+replace doesn't run on the 99%+ of
  error responses that aren't 429.
- Drop the redundant `cid.length > 0` guard in `isValidClientId` —
  the trailing regex already requires `+` (≥1 char).
- Route the websocket.routes spec simulator through `isValidClientId`
  so it can't drift from production validation order again.
- Add a heartbeat-path regression test for the storm-summary dedup
  (the explicit-eviction path was already covered by fbdedf597e).
2026-05-20 15:57:00 +02:00
Johannes Millan
0c70e7906f fix(supersync): make WS reconnect cooldown sliding and quiet the storm logs
The 5s cooldown introduced in ec1f09c85b kept the incumbent socket from
being evicted by a single challenger, but the gate was anchored to the
incumbent's connectedAt — so after 5s of a sustained pre-18.6.0
reconnect storm, the next challenger evicted the incumbent and the
cycle restarted every ~5s. Production logs confirmed: the same clientId
appeared in both "Reconnect within cooldown" WARNs and "Replacing stale
connection" INFOs within ~200ms.

Make the cooldown a sliding window via a single cooldownUntil field:
each refused challenger pushes it forward, so eviction only resumes
after RECONNECT_COOLDOWN_MS of quiet (no challengers). Sustained storm
keeps the incumbent forever; genuine network-blip reconnect after the
storm subsides still recovers.

Cut the per-attempt log spam:
- First refusal per incumbent emits the WARN; subsequent refusals are
  silently counted on the ConnectedClient.
- On incumbent removal, log a single INFO summary with the total count
  and incumbent lifetime. Zero the counter after logging so the
  inevitable ws.on('close') re-entry (triggered by removeConnection's
  own ws.close) cannot double-fire the summary.
- WS-route 429s downgraded to debug in setErrorHandler (storm tail, not
  actionable). Exact-match on /api/sync/ws (path + query strip) so
  future sibling routes do not silently inherit debug level.

Stop one bad client from draining shared-NAT quota: the WS route's
rate-limit keyGenerator now keys on \${ip}:\${clientId} instead of ip
alone. Per-IP amplification stays bounded by the server-wide 500/15min
cap. Extract isValidClientId into sync.const so the keyGenerator and
the route handler share one definition; length check runs before the
regex to reject multi-MB clientId probes cheaply.

Tests cover sliding window under sustained storm, one-WARN +
summary-on-removal, no-summary on clean close, and the
double-summary regression on the explicit-eviction path.
2026-05-20 15:57:00 +02:00
dependabot[bot]
f8317929dd
chore(deps): bump vitest from 3.2.4 to 4.1.6 (#7687)
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 3.2.4 to 4.1.6.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.6/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 4.1.6
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-20 12:50:58 +02:00
dependabot[bot]
9f27d04056
chore(deps): bump @xmldom/xmldom from 0.8.12 to 0.9.10 (#7691)
Bumps [@xmldom/xmldom](https://github.com/xmldom/xmldom) from 0.8.12 to 0.9.10.
- [Release notes](https://github.com/xmldom/xmldom/releases)
- [Changelog](https://github.com/xmldom/xmldom/blob/master/CHANGELOG.md)
- [Commits](https://github.com/xmldom/xmldom/compare/0.8.12...0.9.10)

---
updated-dependencies:
- dependency-name: "@xmldom/xmldom"
  dependency-version: 0.9.10
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-20 12:50:37 +02:00
Johannes Millan
174fd364e7 fix(supersync): pin incumbent WS socket to break shared-clientId reconnect storm
Pre-18.6.0 clients reconnect immediately on the 4009 eviction. With a
per-origin clientId reused across rapid reconnects, addConnection evicted
the incumbent and accepted the challenger, emitting 4009 every cycle ->
self-sustaining reconnect storm (observed ~32 evictions/sec fleet-wide,
each triggering a client download, saturating the VM).

Add a 5s reconnect cooldown: if a still-OPEN socket for the clientId was
accepted < RECONNECT_COOLDOWN_MS ago, refuse the challenger (4008) and
keep the incumbent untouched. The incumbent is never evicted, so the
server stops emitting 4009 and the loop loses its fuel. A dead/closing
incumbent bypasses the cooldown, so a genuine network-blip reconnect
still recovers.
2026-05-19 17:34:47 +02:00
Johannes Millan
7f7500010a docs(supersync): document DATABASE_URL connection_limit requirement
Prisma's default pool is host-core-scaled and silently consumes the
entire postgres max_connections cap. Document the required
?connection_limit=N&pool_timeout=20 in env.example so operators and
deploy.sh keep the bound (host .env is not in the repo).
2026-05-19 17:34:47 +02:00
Johannes Millan
57ed3c94d0 fix(supersync): raise postgres max_connections to 120 for pool headroom
Prisma's default connection pool is ~(host_cpu_count * 2), read from host
cores not the container cpu limit. On the production VPS that silently
consumed the entire postgres max_connections=40 cap, leaving zero
headroom for psql, migrations, the old-ops cleanup job, or the reconnect
stampede after an in-place crash recovery -> sustained 'sorry, too many
clients already', WS reconnect storms and 429s.

Raise max_connections to 120 (120 x 4MB work_mem + 384MB shared_buffers
stays well inside the 1.5g mem_limit). The app must also bound its pool
via DATABASE_URL ?connection_limit=60 (host .env, not in repo).
2026-05-19 16:42:46 +02:00
Johannes Millan
ea9ed90258 fix(plugin-automations): guard nullish currentTaskChange payload
The currentTaskChange handler destructured { current, previous } from
the hook payload with no nullish guard, unlike its sibling handlers
(taskCreated/taskComplete/taskUpdate) which all check payload first.
A nullish payload threw TypeError into PluginHooksService.dispatchHook,
logged as a plugin hook handler error. Guard the payload before
destructuring, matching the existing handler pattern.
2026-05-18 21:59:25 +02:00
Johannes Millan
5f97f56487 docs(supersync): align caddy admin healthcheck note to 127.0.0.1
Comment-only follow-up to the docker-compose healthcheck fix: the Caddyfile note still said localhost:2019; align it to the literal IP the probe now uses.
2026-05-18 16:06:45 +02:00
Johannes Millan
7da451d2a3 fix(supersync): use 127.0.0.1 in caddy healthcheck to avoid ::1 refusal
busybox wget in caddy:2.11-alpine resolves `localhost` to ::1 first, but Caddy binds its admin API to IPv4 127.0.0.1:2019. The healthcheck got "connection refused" and marked a healthy Caddy (serving prod traffic with valid TLS) as unhealthy, making deploy.sh report a false "Container startup failed\!".
2026-05-18 15:10:28 +02:00
Johannes Millan
fd11c2f85a Merge branch 'feat/debug-why-our-super-sync-production-001683'
* feat/debug-why-our-super-sync-production-001683:
  perf(sync): use indexed findFirst for op-download minSeq
2026-05-18 14:21:15 +02:00
Johannes Millan
410dac6785 perf(sync): use indexed findFirst for op-download minSeq
Prisma operation.aggregate({ _min: { serverSeq } }) compiles to MIN()
over a `SELECT ... OFFSET 0` subquery. The OFFSET is a Postgres planner
optimization fence, so the (user_id, server_seq) index could not serve a
first-row seek and the query degraded to a per-user O(N) scan. Under a
client reconnect stampede this ran minutes inside the 60s interactive
download transaction, blowing the tx timeout (500s) and exhausting the
connection pool (cascading upload+download failures).

Replace it with findFirst ordered by serverSeq asc, which compiles to
ORDER BY server_seq ASC LIMIT 1 — a guaranteed index seek on the existing
unique (user_id, server_seq) index. Behaviour-preserving: same minSeq
value and same null-when-empty semantics.

Update both consuming specs (operation-download.service, gap-detection)
to the two-findFirst-call shape and add a regression test asserting the
indexed query is used and the aggregate path is not reintroduced.
2026-05-18 14:21:02 +02:00