* 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.
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.
* 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.
* 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.
* 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.
* 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.
* 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
* 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.
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.
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.
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.
Google Calendar time-block sync failed with 403 rateLimitExceeded. Root cause: every upsert did POST insert -> 409 duplicate -> PATCH (two writes plus a guaranteed 409 per sync), and a single user edit dispatched several actions that each issued a write to the same event within ~1s, bursting past Google's per-event write limit.
- google-calendar-provider: patch-first idempotent upsert (POST only on 404; recover a concurrent-create 409 via PATCH) + bounded exponential backoff on 403 rateLimitExceeded / 429, retried per network call. Follows Google's documented 409 remedy.
- caldav-calendar-provider: PUT is already an idempotent upsert; add the same bounded backoff on transient 429/503 from self-hosted servers. 404-swallow on delete unchanged.
- time-block-sync.effects: merge createOrUpdateOnSchedule$ + updateOnFieldChange$ into one upsertOnTaskChange$ that groups by task id and debounces (COALESCE_MS=1000) then switchMaps, re-reading the latest task post-debounce. One settled edit = one write. Still LOCAL_ACTIONS + action-driven (sync-correctness rules 1-2 preserved).
Adds vitest coverage for both plugins' timeBlock paths and the first effects spec for time-block sync.
Adds two new triggers and a new action to the bundled automations plugin,
along with the host-side and validator changes needed to make them
reliable end to end.
Plugin
- New triggers TriggerTaskStarted / TriggerTaskStopped that fire on timer
start / stop. Switching from task A to task B emits taskStopped(A) and
then taskStarted(B), so a rule subscribed to either trigger gets the
full lifecycle. The trigger descriptions document this explicitly.
- New ActionRemoveTag mirroring ActionAddTag, with a shared resolveTagId
helper covering id/title lookup, missing-tag warning, and idempotent
short-circuiting.
- Import goes through a new RuleRegistry.addRules(rules[]) + addRules
message, so the host's 1 call/sec persistDataSynced rate limit no
longer rejects multi-rule imports.
- Validators in core/rule-registry.ts and utils/rule-validator.ts share a
set of `as const` arrays guarded by a TS _AssertEq exhaustiveness check
against the union types in types.ts. The arrays are duplicated rather
than exported from types.ts because that would turn types.ts into a
shared runtime chunk and break plugin.js (which the host evaluates via
`new Function`, not as an ES module).
- Import validator no longer requires a truthy rule name — the UI saves
empty-named rules, the load-path accepts them, and the import path
needed to match.
- ActionDialog gets a removeTag placeholder. Manifest's hooks list is
updated to include the hooks the plugin actually registers
(taskCreated, currentTaskChange) so the permission disclosure UI is
accurate.
Host
- plugin-hooks.effects.ts onCurrentTaskChange$ now observes
selectCurrentTask directly instead of only setCurrentTask /
unsetCurrentTask actions. This catches transitions through reducer
paths that don't dispatch those actions (loadAllData, deleteProject,
bulk task delete via the shared CRUD meta-reducer).
- The payload changes from the raw new Task to { current, previous },
matching the long-declared CurrentTaskChangePayload shape. The effect
uses pairwise on the selector and filters same-id pairs at the event
boundary (not via distinctUntilChanged on the source) so the
`previous` task always carries the latest snapshot — including
in-place updates a plugin made while the task was running. Without
this, addTag-on-start / removeTag-on-stop only worked every other
cycle because `previous` reflected the pre-mutation snapshot.
Other plugins consuming currentTaskChange (voice-reminder, api-test-plugin)
read payload.current instead of the raw Task.
The new theme upload button's matTooltip ("...never run code...") leaves its
text in the DOM via cdk-describedby, so case-insensitive getByText('never')
matched both the "Never" reminder option and the tooltip. Target the
mat-option by ARIA role and exact case, and scope the post-schedule
assertion to the dialog so only the mat-select trigger text qualifies.
Also prunes a stale nested vitest/esbuild tree from the caldav plugin's
package-lock.json.
The web build at app.super-productivity.com was inheriting the desktop
OAuth client whose accepted redirect URIs are loopback-only, so Google
rejected the https callback with redirect_uri_mismatch.
Add webClientId / webClientSecret to OAuthFlowConfig and route the
browser branch through them. Google's "Web application" client type is
provisioned as confidential, so PKCE alone is not enough — the secret
ships in client JS like the desktop one. Electron and native mobile
flows are unchanged.
Bundle ical.js into the plugin and use it for the read path. The hand-rolled
parser stays for getById/updateIssue/deleteIssue. Anchor the sync window to
start-of-UTC-day so in-progress events stay visible, count only emitted
occurrences toward the per-event safety cap, and isolate per-VEVENT failures
so one malformed event can't drop the rest. Compound id uses '#occ=<ms>' so
a server-controlled href cannot collide with the occurrence delimiter.
Make explicit in the backlog-query help text that the default behavior
without a token imports every open issue, not just the user's, so anyone
enabling auto-import without credentials understands the scope.
The default backlog query 'sort:updated state:open assignee:@me' fails with
HTTP 422 ("The listed users cannot be searched...") when the provider has
no token configured, because GitHub's Search API can't resolve @me without
an authenticated request. This broke auto-import for public repos, which
the docs and UI advertise as not requiring a token.
Fall back to 'sort:updated state:open' when no token is set, and update
the help text to describe both defaults.
Closes#7381
Adds an advanced checkbox on the GitHub issue provider that, when enabled,
drops the hardcoded `is:issue` filter in both backlog auto-import and
in-provider search. Lets users sync PRs (assigned, authored, or
review-requested) into their backlog with a query like
`state:open is:pull-request (assignee:@me OR review-requested:@me)`.
Default is off, existing behavior is unchanged.
packages/vite-plugin/package.json was bumped to vite ^6.4.2 in
4b9bd23e56 but dependent plugin lockfiles still referenced ^6.0.0.
npm install during the e2e build brought them in sync and hoisted
vitest's nested esbuild binaries, dropping ~500 redundant entries.
encodeURIComponent leaves ( and ) intact per RFC 3986, but GitHub's
search API treats them as grouping operators that must be percent-encoded,
returning HTTP 422 on queries like "(author:@me OR assignee:@me)".
The in-repo provider was fixed in 960330dd56 but the fix didn't carry
over when GitHub moved to a plugin. Reapply via a small encodeGithubQuery
helper used at both call sites.
* fix(issue): prevent crash from orphan issueProviderId (#7135)
The Jira image-headers effect in task-detail-panel subscribed to
selectIssueProviderById without an error handler, so a task with an
issueProviderId pointing at a deleted provider (e.g. after sync
convergence where taskIdsToUnlink didn't cover all local tasks)
propagated the selector throw to Zone.js as a crash dialog. Wrap the
inner selector observable in catchError that logs and falls back to
of(null); the downstream jiraCfg?.isEnabled guard handles the fallback.
Also drop IssueLog.log(issueProviderKey, issueProvider) from the
throwing variant of the selector: providers may carry credentials
(host, token, apiKey) and IssueLog history is exportable.
* fix(focus-mode): sync tray countdown with in-app timer during breaks
Tray title was rebuilt from a cached currentFocusSessionTime that only
refreshed when CURRENT_TASK_UPDATED fired. addTimeSpent is gated on an
active current task, so during focus-mode breaks or task-less focus
sessions the cache froze while the in-app timer kept ticking.
Add the tick action to taskChangeElectron$ so the cache refreshes every
second whenever the focus timer is running.
Fixes#7278
* fix(ci): restore GitHub Actions SHA pins undone by 0e9218bd68
Commit 0e9218bd68 silently reverted PR #7212 (github-actions-minor group
bump) along with its stated sync/client-id work. This restores the 15
workflow files to their pre-revert state.
Actions restored to newer pinned SHAs:
- actions/upload-artifact v7.0.0 -> v7.0.1
- step-security/harden-runner v2.16.1 -> v2.17.0
- softprops/action-gh-release v2.6.1 -> v3.0.0
- signpath/github-action-submit-signing-request v2.0 -> v2.1
- anthropics/claude-code-action v1.0.89 -> v1.0.93
- docker/build-push-action v7.0.0 -> v7.1.0
- easingthemes/ssh-deploy v5.1.1 -> v6.0.3
* fix: restore i18n, UI, and docs work undone by 0e9218bd68
Commit 0e9218bd68 silently reverted the following work alongside its stated
sync/client-id changes. Files where later master commits (fec7b25f23, etc.)
already re-applied the reverted work are intentionally left untouched.
Restored:
- #7232 docs/long-term-plans/location-based-reminders.md (513 lines)
- #7199 Romanian i18n phase 3 (ro.json + ro-md.json, ~1168 lines)
- #7049 Polish translation improvements
- #7143 planner component styling (4 scss files)
- #7211 add-task-bar preserve time estimate when typing title
- #7208 task.reducer roll-up estimates for added subtasks
- #6767 focus-mode pomodoro reset button
- #7205 plugin-dev github-issue-provider TOKEN description
- #7231 mobile-bottom-nav FAB fix
- a4fe03272 iOS keyboard accessory bar (global-theme + dialog-fullscreen-markdown)
- 309670db3 ShortSyntaxEffects undefined guard
- 667a7986f Dropbox PKCE auth comment/behavior
* fix(electron): restore electron + e2e work undone by 0e9218bd68
Commit 0e9218bd68 silently reverted the following electron/e2e work.
Files already re-fixed by later master commits are left as-is:
- e2e/tests/sync/supersync-archive-conflict.spec.ts (de33234976 + 191d129ff3)
Restored:
- e8a3e156eb fix(electron): Linux autostart IDB backing-store recovery
(re-adds electron/clear-stale-idb-locks.ts + start-app.ts wiring)
- 5ce78a5b63 fix(electron): macOS Cmd+Q / Dock > Quit hang
(setIsQuiting + before-quit delegate to close-handler)
- 46e0fa2d01 fix(sync): FILE_SYNC_LIST_FILES IPC contract
(electronAPI.d.ts + local-file-sync + preload + ipc-events)
- ea1ef16307 fix(android): session-only SAF permissions on OEM devices
- 8865dc0a50 test(e2e): supersync parallel-worker stampede guard
(SUPERSYNC_SERVER_HEALTHY env-var fallback + goto retry loop)
- af7c7687e2 test(e2e): block WS-triggered downloads in non-WS specs
- 265b44db5d test(e2e): premature waitForURL on daily-summary
(this is literally the fix the bad commit's message claimed to add)
Conflict resolutions:
- e2e/utils/supersync-helpers.ts: kept the refined getDoneTaskElement
checks from d64014d086 (later than c558bcab5e) while restoring the
goto retry loop from 8865dc0a50.
- electron/start-app.ts: unioned imports (setIsQuiting +
clearStaleLevelDbLocks from theirs, fs from ours).
* fix(sync): restore sync-core work undone by 0e9218bd68
Commit 0e9218bd68 silently reverted parts of several sync fixes. Most
sync-core reverts have already been re-addressed differently on master
by later commits (1f5184f6e7, 05cd875dd6, 09f5ced2c9, 7df43358ab,
d9158d6adb, 32dbc95ed9, 8c3b08e016, f89fe1ebc3) — those files are
intentionally left untouched to avoid reverting master's newer work.
This PR restores only the pieces that are genuinely still missing:
- e8a3e156eb fix(electron): IDB backing-store autoreload (in-app piece)
operation-log-hydrator.service.ts + .spec.ts (the electron/clear-
stale-idb-locks.ts piece was restored in the prior commit)
Plus three low-risk documentation/cleanup restorations:
- operation-sync.util.ts — add "Nextcloud" to isFileBasedProvider JSDoc
- dropbox.ts — restore improved _getRedirectUri JSDoc (667a7986fb)
- dialog-get-and-enter-auth-code.component.ts — restore isNativePlatform
comment explaining why manual code entry flow is used (667a7986fb)
- file-adapter.interface.ts — remove stray "// NEW" comment (46e0fa2d01)
Intentionally NOT restored (master's newer work covers or supersedes):
- sync-trigger.service.ts / sync.effects.ts (05cd875dd6)
- sync-wrapper.service.ts (1f5184f6e7)
- sync-errors.ts (1f5184f6e7 re-added LegacySyncFormatDetectedError)
- file-based-sync-adapter.service.ts + spec (1f5184f6e7 + d9158d6adb)
- file-based-sync.types.ts (1f5184f6e7)
- operation-log.const.ts (32dbc95ed9 bumped IDB_OPEN_RETRIES to 5)
- dialog-sync-initial-cfg.component.ts (f89fe1ebc3)
* Implement task parsing with sub-tasks
Added a new function to parse tasks with sub-tasks from a text input, improving task management capabilities. Updated the task addition logic to utilize this new parsing method.
Should solve #7183
* Enhance task parsing and user information
Key Changes Summary:
✅ Critical Fix#1 - 4-space indentation: Detects minimum indent and normalizes relative to it
✅ Critical Fix#2 - Mixed input: Plain-text lines no longer silently dropped; warning shown
✅ Critical Fix#3 - Deeply-nested items: Now flattened to sub-task level with user notification
✅ Minor - Sub-task dueDay: Documented that it inherits from parent (not passed independently)
✅ Suggestion - UI/UX: Updated placeholder text to show expected format with examples
* fix indentation calculation and missing isBullet: true
* fix(brain-dump-plugin): keep sub-tasks when plain-text lines interrupt
Previously, a plain-text line between a bullet and its indented sub-task
caused the sub-task to be silently dropped: the outer loop advanced past
the plain-text entry but the inner look-ahead had already exited at
indent 0, so the following indented bullet was never attached to the
parent and was rejected by the outer loop's else branch.
The sub-task scan now skips over plain-text lines while still breaking
at the next top-level bullet, so indented bullets attach to the previous
parent regardless of interleaved plain text. The plain-text line itself
is still emitted as its own top-level task by the outer loop.
Also drops dead code:
- findMinimumIndent() was declared but never called.
- dueDay on sub-task payload was forwarded but silently discarded by the
plugin bridge (sub-tasks inherit the parent's date).
And de-duplicates the "Note: ... Note: ..." prefix in the deeply-nested
warning snack.
---------
Co-authored-by: Adnoh <git@rotzefull.de>
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
* test(e2e): update Show/hide task panel button label
The TOGGLE_DETAIL_PANEL translation was renamed from "Show/Hide
additional info" to "Show/hide task panel" in dd789d2, but e2e
selectors still referenced the old string, causing every test that
opens the task detail panel to time out.
https://claude.ai/code/session_011mX4Pr24S42svmA5j7fRux
* 18.2.1
---------
Co-authored-by: Claude <noreply@anthropic.com>
- Separate JsonParseError and SyncDataCorruptedError handlers in sync wrapper
- Handle corrupted remote data gracefully instead of throwing
- Add getOrGenerateClientId() as unified entry point, eliminating dual injection
of ClientIdService + CLIENT_ID_PROVIDER in snapshot-upload, file-based-encryption,
and sync-hydration services
- Use crypto.getRandomValues() instead of Math.random() for client ID generation
- Warn user when stored client ID is invalid and must be regenerated
- Fix flaky e2e supersync tests (premature waitForURL match on daily-summary URL)
When a full-state op (SYNC_IMPORT/BACKUP_IMPORT) arrived from another
client, mergeRemoteOpClocks reset the local clock to a "minimal" form
but only preserved the current client's counter from the incoming op's
clock. If the current client had issued ops (e.g. GLOBAL_CONFIG) not
reflected in the incoming full-state op's clock, its counter was dropped,
causing subsequent ops to reuse the same counter value. Downstream clients
then saw these ops as EQUAL (duplicate) and skipped them silently.
Fix: take max(mergedClock[clientId], currentClock[clientId]) when
rebuilding the clock after a full-state op reset.
Also add __SP_E2E_BLOCK_WS_DOWNLOAD flag to WsTriggeredDownloadService
to allow E2E tests to block automatic WS-triggered downloads during
concurrent conflict scenarios.
Fix archive conflict test by blocking WS downloads on Client A during
the concurrent edit phase so it doesn't auto-receive B's rename via
WebSocket before archiving (restoring the intended conflict scenario).
Fix LWW singleton test to assert convergence rather than specific winner.
Fix renameTask helper to avoid Playwright/Angular re-render races.
Fix shepherd.js import paths that broke the Angular dev server build.
Add a CalDAV Calendar plugin that syncs tasks with calendar events via
the CalDAV/WebDAV protocol, supporting self-hosted servers like Nextcloud.
Plugin features:
- CalDAV PROPFIND/REPORT for calendar discovery and event fetching
- iCal parsing and serialization with RFC 5545 compliance
- Two-way sync with field mappings (title, notes, dates, duration)
- Time-block integration for auto-creating calendar events
- Multi-calendar support with compound IDs
Host-side changes:
- Add generic request() method to PluginHttp for WebDAV methods
- Add allowPrivateNetwork manifest flag (bundled plugins only)
- Add dynamic loadOptions support for config field dropdowns
- Add backfill effect for existing scheduled tasks
- Generify translation keys (LOAD_OPTIONS instead of LOAD_CALENDARS)
Security:
- SSRF protection via origin validation in resolveHref
- allowPrivateNetwork gated by _isPluginBundled check
- XML parse error detection in CalDAV response parsing
- Description rendered as plain text (not markdown)
Correctness:
- TZID conversion uses formatToParts (no local timezone contamination)
- modifyICalEvent scoped to VEVENT block (preserves VTIMEZONE)
- responseType: 'text' on all CalDAV PUT/DELETE calls
- CR characters handled in iCal text escaping
- Trailing CRLF added to modifyICalEvent output per RFC 5545
- Fix taskIdToGcalEventId collision by hex-encoding nanoid bytes
- Add timeBlock API to plugin interface, move Google Calendar-specific
logic into the plugin, make TimeBlockSyncEffects provider-agnostic
- Use isPluginIssueProvider() in task selectors
- Replace console.error with Log.err, add i18n for time-block errors
- Use 1-hour default when rescheduling all-day event to timed slot
- Apply config migration in dialog for legacy single-calendar configs
- Remove planTaskForDay/moveBeforeTask from deleteOnUnschedule$
- Rename icalEvents$ to calendarEvents$
- Make issueProviderKey required on CalendarIntegrationEvent
- Parameterize loadAllCalendars/loadWritableCalendars
- Replace deprecated unescape() with TextEncoder in iCal util
- Fix btoa() non-ASCII throw in getIssueLink
- Fix timezone bug in all-day reschedule date parsing
- Deep-clone pluginConfig before mutating in migration dialog
- Add showIf property to PluginFormField so fields can depend on
another config value being truthy
- Show timeBlockCalendarId only when isAutoTimeBlock is enabled
- Improve wording for auto time blocking and calendar field descriptions
Add Google Calendar as a plugin-based calendar provider with OAuth 2.0
authentication, multi-calendar support, and event management.
- Plugin fetches events from selected read calendars, merged into the
existing calendar integration pipeline
- Context menus on planner and schedule views for calendar events:
open link, reschedule, create as task, hide forever, delete
- Reschedule opens date/time picker and updates event via plugin API,
handling both timed and all-day events correctly
- Delete with confirmation dialog
- CalendarEventActionsService extracts shared event action logic
- HiddenCalendarEventsService for permanent event hiding
- triggerRefresh() for immediate UI updates after mutations
- Multi-select config fields for choosing calendars to display
- Fix change detection for plugin select fields in provider dialog
- Electron-safe URL opening with scheme validation
Google rejects custom scheme redirect URIs that don't match the
platform's app identifier. Use the Android applicationId
(com.superproductivity.superproductivity) on Android and the iOS
bundle ID (com.super-productivity.app) on iOS, with single-slash
URI format per Google's documentation.
- Add iosClientId to OAuthFlowConfig and plugin API types
- Add iOS OAuth client ID to Google Calendar plugin
- Select client ID per platform (Android/iOS/Desktop) in bridge service
- Add Android package name intent filter in AndroidManifest
- Accept both URI schemes in OAuth callback handler
- Fix redirect handler to use IS_NATIVE_PLATFORM consistently
Google rejects Desktop OAuth client IDs when the redirect URI is a
custom scheme (as used on mobile via Capacitor), returning a 400 error.
Add mobileClientId to OAuthFlowConfig so plugins can specify a separate
Android/iOS client ID that authenticates via app signing instead of a
client secret. On native platforms, the bridge service automatically
uses the mobile client ID with PKCE only.
Also fix getRedirectUri() to return the custom scheme for all native
platforms (not just Android WebView), and align inline types in the
dialog component and plugin declaration with OAuthFlowConfig.