Commit graph

509 commits

Author SHA1 Message Date
Johannes Millan
d00e2df354
refactor(ui): unify primary/secondary action button treatment across dialogs (#8706)
Standardize dialog actions: primary=mat-flat-button color=primary,
secondary/cancel=mat-button, destructive=color=warn. Drop dead Bootstrap
'btn btn-primary' classes and decorative check/close icons. Document the
convention in docs/styling-guide.md and update e2e selectors that keyed
on the old stroked variant.

Closes #8683
2026-07-02 14:42:59 +02:00
Johannes Millan
e7d98439a8
feat(update-check): notify desktop users about new releases (#5463) (#8705)
* feat(update-check): notify desktop users about new releases (#5463)

* fix(update-check): build release URL locally, use HttpClient with timeout

* feat(update-check): add translations for all locales
2026-07-02 14:13:09 +02:00
Johannes Millan
222fa4c8aa
feat(sync): show banner when LWW sync discards a user content edit (#8694) (#8702)
* feat(sync): show banner when LWW discards a user content edit

Auto-resolved LWW conflicts were only surfaced as a generic "N local/
remote wins" count snack, so a field-level edit silently dropped by
last-write-wins (e.g. a title edit lost to a concurrent notes edit) was
invisible to the user (#8694).

Split the resolution outcome: routine self-healing (reschedule/repeat/
archive/done churn) keeps the quiet count snack, while a resolution that
discarded a genuine content edit (task title/notes/subtasks/attachments)
shows a dismissible banner naming the affected task(s).

- New pure summarizeLwwResolutions() classifier in @sp/sync-core (inspects
  the losing side's changed fields; UPDATE only, so create/delete/move and
  scheduling churn stay routine).
- Titles are HTML-escaped before the innerHTML banner (they come from
  synced remote data) and never logged.

* fix(sync): correct + simplify LWW content-conflict notice (multi-review)

Address multi-agent review of the previous commit:

- CRITICAL: the classifier's multi-entity branch made the feature a no-op
  in production. Captured ops are always wrapped as
  { actionPayload, entityChanges: [] } and task edits never populate
  entityChanges (only time-tracking does), so a real title/notes edit was
  read as having no changed fields → classified routine → banner never
  fired. Fixed by dropping the branch and relying on extractUpdateChanges
  (which unwraps actionPayload), gated to UPDATE ops. Spec now uses the
  real wrapped payload shape so this can't regress.
- Move the classifier out of the framework-agnostic @sp/sync-core package
  into the app (findLwwContentConflicts); it held app-specific TASK field
  names and was exported but unused. TASK-only, no generics/callback.
- De-duplicate content conflicts per task (one task can yield several
  concurrent conflicts) so the banner never lists a title twice.
- Direction-neutral wording ("Older edits may have been discarded") since
  the discarded side depends on which client won.
- Use the banner's built-in dismiss button instead of a no-op action.
- Consolidate escapeHtml: escapeHtmlAttr now re-exports the shared util.
- Guard against a non-string title before trim().

* fix(sync): drop dead 'attachments' from LWW content fields (review)

Second-review WARNING: attachments are edited via dedicated
[TaskAttachment] actions with payload { taskId, taskAttachment }, never
updateTask({ task: { changes: { attachments } } }), so extractUpdateChanges
never surfaces an 'attachments' key — the entry could never match and
falsely claimed coverage. Removed it (title/notes/subTaskIds are live) and
added a guard test asserting a real attachment-action-shaped op is not
flagged, so it isn't naively re-added.
2026-07-02 12:53:02 +02:00
Johannes Millan
e89306a630
feat(plainspace): create tasks directly in a Plainspace-backed project (#8676)
* feat(plainspace): create tasks directly in a Plainspace-backed project

Adding a top-level task to an SP project that has a bound PLAINSPACE issue
provider now creates the task in the Plainspace space so the team sees it,
symmetric with the existing auto-import. Wires into the generic
autoCreateIssueOnTaskAdd$ pipeline:

- PlainspaceApiService.createTask$ -> POST /api/integration/tasks { spaceId,
  title }; errors propagate so a failed add surfaces a snack.
- PlainspaceSyncAdapterService.createIssue links the returned SPTask id and
  seeds the two-way-sync baseline (no issueNumber, so no '#123' title prefix).
- _hasAutoCreateEnabled recognises the native PLAINSPACE key (no opt-in flag;
  the bound provider is the opt-in).

Requires a new PAT-authed server route POST /api/integration/tasks (documented
in docs/plainspace-api-extension-plan.md 4c); inert until that ships.

* fix(plainspace): only auto-create tasks once the provider is configured

Multi-review follow-ups to the create-task feature:

- _hasAutoCreateEnabled now requires a bound Plainspace provider (spaceId +
  token), not just isEnabled. selectEnabledIssueProviders filters on the flag
  only, so a mid-connect provider (spaceId/token still null) previously POSTed an
  invalid create and error-snacked on every task add; now it skips silently until
  configured. Adds a covering spec.
- Drop the unnecessary 'as unknown as' double cast in createIssue.
- Drop the unnecessary 'as any' on the valid 'PLAINSPACE' key in the effect spec.
2026-07-01 19:36:52 +02:00
Johannes Millan
97e97042cd
fix(electron): remove exec IPC to close GHSA-256q (#8669)
* fix(electron): fail-safe exec confirmation dialog (GHSA-256q)

The EXEC confirmation was the only gate before an arbitrary shell command
runs with the user's privileges, but it did not fail safe:

- defaultId: 2 was out of range for a two-button dialog, leaving the
  focused default per-platform-undefined, so an accidental Enter could
  execute. Cancel is now defaultId + cancelId (Enter/Escape never runs).
- 'Remember my answer' defaulted to checked, so one careless click could
  whitelist a command to the silent allow-list forever. It is now opt-in.

Add electron/ipc-handlers/exec.test.cjs as a regression guard.

* docs(plugins): correct misleading plugin sandboxing claims

Docs claimed JS plugins run in 'isolated VM contexts' and iframes run
'without allow-same-origin' — both are false. JS plugins run in the host
renderer via new Function, and iframes use allow-same-origin (required for
#8467), so both can reach the privileged window.ea bridge. Align the docs
with the code (plugin-iframe.util.ts) and stress the trust model.

* fix(electron): fail closed on corrupt exec allow-list, cover error paths

The EXEC handler is wired to ipcMain.on (fire-and-forget), so a throw on a
corrupt allow-list surfaced as an unhandled promise rejection with no user
feedback. Wrap the handler body in try/catch and route failures through
errorHandlerWithFrontendInform (the same channel exec errors already use),
failing closed so a corrupt store never falls through to executing.

Expand the regression tests: assert the corrupt-config path informs the error
and runs nothing, cover exec-error routing, and guard allow-list append (an
overwrite regression that wipes remembered commands previously passed green).

* docs(plugins): document window.ea.exec shell path in trust model

The trust-model section implied executeNodeScript() was the only process
path; on desktop window.ea.exec also runs arbitrary shell commands via
child_process.exec behind a separate, weaker gate (confirmation dialog +
persistent allow-list, not the nodeExecution consent).

* test(electron): run exec security regression tests in CI

The test:electron runner globs electron/*.test.cjs (non-recursive), so the
guard at electron/ipc-handlers/exec.test.cjs was never executed by CI —
verified: the suite went 160 -> 168 tests once discovered. Move it to
electron/exec.test.cjs (matching every sibling electron test) and point
execModulePath at ipc-handlers/exec.ts.

* refactor(electron): narrow exec allow-list without an unsafe cast

Drop the `as string[]` cast that asserted away the very corruption the
Array.isArray guard is meant to catch; narrow the unknown value honestly so
the guard is a real type-check. Behavior is unchanged (falsy -> empty list,
truthy non-array -> fail closed).

* fix(electron): stop logging exec command content

Per CLAUDE.md rule 9 (log history is exportable, never log user content), a
command can carry a secret in its arguments; log a content-free line instead.
Also fold the duplicated exec-spawn into a single runCommand() helper so the
allow-listed and just-confirmed paths share one audited call site.

* fix(electron): remove exec IPC to close GHSA-256q at the root

window.ea.exec exposed arbitrary shell (child_process.exec) to the whole
renderer: JS plugins (new Function in the host realm), same-origin iframe
plugins (window.parent.ea), and any renderer XSS — bypassing the per-plugin
nodeExecution consent gate entirely. Its only consumer, COMMAND task
attachments, is dormant: no UI creates them (the edit dialog offers only
LINK/IMG/FILE).

Rather than guard a dormant RCE primitive, remove it: delete the EXEC IPC
handler + preload.exec + the ElectronAPI method + the directive's COMMAND
branch. This closes the vector for plugins, iframes, AND host-realm XSS at
once (a bootstrap handoff would only hide it from plugins), and supersedes
the earlier dialog hardening (that primitive no longer exists).

Keep the 'COMMAND' literal in the synced TaskAttachment type (removing a
synced union member breaks typia validation on peers/legacy data); a click
on a legacy COMMAND attachment now shows an informational snack instead of
executing.

* docs(plugins): reflect exec IPC removal in the trust model

window.ea.exec no longer exists (removed to close GHSA-256q); the docs now
state executeNodeScript is the only sanctioned native-code path.

* test(electron): guard exec IPC stays removed (GHSA-256q)

The interim exec security tests were deleted together with the executor, so the
shipped fix had no regression coverage. Add electron/exec.test.cjs (picked up by
the electron/*.test.cjs CI glob) asserting the bare exec primitive stays gone:
no IPC.EXEC event, no exec.ts handler, no preload exec bridge, no ElectronAPI.exec
method, no initExecIpc wiring. Targets only the removed surface, not the
sanctioned PLUGIN_EXEC_NODE_SCRIPT/executeScript nodeExecution path.

* chore(electron): mark ALLOWED_COMMANDS store key as legacy

Its only reader/writer was the deleted exec handler (GHSA-256q). Document that it
is retained purely so older persisted stores keep loading.
2026-07-01 17:55:35 +02:00
Johannes Millan
c7d6131db8
feat(plainspace): Collaborate-on-Plainspace discovery + smoother connect (#8649)
* feat(plainspace): add Collaborate action to project context menu

Surface Plainspace sharing from the project context menu (active, non-Inbox,
not-yet-shared projects) so it can be discovered at the moment of collaboration
intent, reusing PlainspaceShareService. A new selectIsProjectSharedOnPlainspace
selector hides the action once a project is shared to avoid provisioning a
duplicate space on a repeat click.

* docs(plainspace): document collaboration in wiki

Add Plainspace to the issue-integration comparison (matrix + per-provider
section) and a how-to for sharing a project via the project menu, including the
network/privacy caveat.

* feat(plainspace): place Collaborate action below the share-list item

Group the two share/export actions and lift Collaborate higher for
discoverability. Gated to active, non-inbox, not-already-shared projects.

* fix(plainspace): smoother first-run connect and value-first dialog

Pre-check connectivity and revalidate a stored token before the space
picker, so a stale/foreign token routes to the connect dialog and an
offline state shows a calm message instead of the raw 'check your token'
picker error. Make the connect dialog value-first: lead with what you
get, drop the 4-step how-to and email hint in favor of one short pointer
(token-creation guidance moves to plainspace.org).

* docs(plans): dedicated from-Super-Productivity flow on plainspace.org

Open plan for a guided token/connect flow on plainspace.org when a user
arrives from SP (Model A manual token, Model B OAuth-style handoff).

* refactor(plainspace): drop redundant connect pre-check (multi-review)

The space picker already detects a stale token and offers a reconnect
(#8616), so the revalidate() pre-check duplicated that path, cost an
extra GET /me, and forced re-auth on a valid token during a transient
server blip. Keep only the one-line offline guard; drop revalidate(),
the discriminated union, and the unnecessary _isOnline() seam (navigator
.onLine is spyable in the runner). Fix stale doc comments and document
the disabled-provider trade-off in the selector.

* feat(plainspace): show brand icon in the connect dialog title

* feat(plainspace): deep-link connect dialog to the from-SP onboarding flow

Point the dialog's 'Open Plainspace' link at the dedicated
/connect/super-productivity entrypoint (which guides token creation)
instead of the bare marketing host, closing the connect-flow funnel leak.

* feat(plainspace): bounce back to the app after connecting (desktop)

Append a validated `?return=superproductivity://plainspace-connect` deep
link to the connect URL, gated on IS_ELECTRON (only desktop registers the
scheme — web/mobile would get dead buttons). Handle that action in the
Electron protocol handler by surfacing the window, so the connect page's
"Open Super Productivity" button re-focuses the app.

Desktop-only; needs an on-device check.
2026-07-01 16:39:34 +02:00
Johannes Millan
14a56f861e
feat(electron): trigger global shortcuts via superproductivity:// URLs (#8645)
* feat(electron): trigger global shortcuts via superproductivity:// URLs

On Wayland the compositor owns global hotkeys, so Electron's globalShortcut
often does not register. Add three actions — toggle-visibility, new-note and
new-task — to the existing protocol handler so a compositor keybind can call
`xdg-open superproductivity://<action>`. No extra CLI tool or runtime is
needed: xdg-open (Linux), open (macOS) and start (Windows) already ship with
the OS, and the running instance receives the URL via the existing
single-instance / second-instance path.

Extract the show/hide logic into a shared toggleWindowVisibility() used by both
the globalShowHide shortcut and the new protocol action, and add a key-repeat
debounce so one held key press no longer hides then immediately re-shows the
window.

Docs: add a Wayland keybind recipe (Niri/sway/Hyprland) to the keyboard
shortcuts wiki page.

Refs #7114

* fix(electron): correct toggle-visibility focus race and debounce

On Linux/Windows the second-instance handler pre-focused the window before processProtocolUrl ran, so toggle-visibility always read 'visible' and hid the window the user asked to show. Skip that pre-focus for toggle-visibility only (new getProtocolAction helper); every other action keeps the bring-to-front behavior.

Make the key-repeat debounce direction-agnostic with a sliding quiet-gap (1000->750ms): it now guards both show and hide, settles a held key on a single toggle, and fires on the xdg-open path where the old isHidden-only guard was always false.

Stop logging the create-task title and URL path to the exportable log (CLAUDE.md rule 9 / privacy).

Add coverage for the real second-instance path, held-key-from-hidden, gap expiry, the #7282 minimize fallback, the macOS hide path, unfocused-show, and the log redaction; document AppImage/Flatpak/Snap scheme registration. Refs #7114.

* fix(electron): show window on cold-start toggle-visibility launch

Cold start: when superproductivity://toggle-visibility launches the app (it wasn't running), the freshly-shown window was immediately hidden again because the toggle saw it focused. Flag the cold-start URL during the argv scan and SHOW — never toggle — the window once it's ready (via processPendingProtocolUrls), respecting start-minimized-to-tray. The already-running second-instance path keeps real toggle behavior.

Rename the two interactive protocol actions new-task/new-note to add-task/add-note: aligns with the app's Add-Task vocabulary and the globalAddTask/globalAddNote keys, and avoids colliding with the programmatic create-task/<title>. The action names are a frozen public contract once users bind them in compositor configs, so this is the pre-merge moment to settle the naming. Refs #7114.
2026-06-30 17:22:30 +02:00
Johannes Millan
06a7425698
feat(focus-mode): make preparation opt-in, smooth start transition (#8639)
* feat(focus-mode): make preparation opt-in, smooth start transition

The full-screen preparation countdown is now opt-in (off by default) via a
new isShowPreparation config flag; the deprecated isSkipPreparation is kept
for synced-config back-compat. By default, starting a session now plays a
brief inline rocket launch from the play button, then begins.

Smooth the prep->running swap: the clock/controls cross-fade sequentially
(old fades out, then new fades in) via a new fadeSwap animation.

Fix the focus task-selector panel that rendered transparent (undefined
--c-bg-raised) -> opaque highest-elevation surface on the standard scrim.

* fix(focus-mode): guard re-entrant start, reduced-motion, clock cross-fade

- Ignore a re-entrant startSession() while the inline launch is playing
  (keyboard Enter / double-click on the still-focused FAB) and disable the
  play button during launch, so a second timer can't reset the new session.
- Skip the inline rocket launch + its 800ms delay under prefers-reduced-motion
  and start immediately (no invisible dead delay for motion-sensitive users).
- Cross-fade the clock digits with the duration slider (fade out before fade
  in) instead of a hard visibility toggle.
- Fix stale e2e launch-duration comment (~600ms -> ~800ms).
2026-06-29 19:44:53 +02:00
Johannes Millan
ceefb5000c
feat(plugins): add local-only secret storage API for plugins (#8633)
* feat(plugins): add local-only secret storage API for plugins

Add setSecret/getSecret/deleteSecret to the plugin API, backed by a
dedicated 'sup-plugin-secrets' IndexedDB that is never part of Super
Productivity's sync, exports, or backups (mirrors the existing OAuth
token store). Secrets are namespaced per plugin and purged on uninstall.

Unblocks credential-using plugins (e.g. IMAP mailbox -> task) that must
not put passwords in persistDataSynced or synced issue-provider config.

Also purge plugin OAuth tokens on uninstall (best-effort, alongside the
secret purge) — they previously leaked past uninstall.

Refs #7511

* fix(plugins): purge plugin secrets and OAuth tokens on cache clear

clearUploadedPluginsFromMemory (the 'Clear plugin cache' action) wiped
plugin code and persisted nodeExecution consent (#8512 Phase 2) but left
secrets and OAuth tokens in their dedicated stores. A same-id re-upload
after a cache clear has no existingState, so the re-upload purge never
fires and the new plugin could inherit the previous plugin's credentials
— the same id-reuse gap #8512 closed for consent. Purge both here too,
best-effort and idempotent, mirroring the per-plugin uninstall purge.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-29 17:14:36 +02:00
Johannes Millan
7182ff4fba
feat(plugins): persist nodeExecution consent per plugin (#8512) (#8600)
* feat(plugins): persist nodeExecution consent per plugin (#8512)

Phase 2 of #8512: remember an uploaded plugin's nodeExecution consent so
it is asked once, not every app session, while keeping the trust decision
local to the device.

- Main-owned, local-only store (electron/plugin-node-consent-store.ts,
  wrapping simple-store under key 'pluginNodeExecutionConsent'); never
  pfapi-synced, so a grant on one device never auto-grants on another.
- Ask-once is scoped to UPLOADED plugins; built-in plugins (sync-md) keep
  the per-session verified prompt unchanged (regression-safe).
- Consent is written only after a native Allow in main; the renderer has a
  delete-only clearConsent IPC (fail-safe) and no way to self-grant.
- Cleared on disable / uninstall / re-upload (never in generic teardown),
  so revoke = the existing disable toggle and changed code re-consents.
- No code hash: re-ask-on-change is structural (re-upload clears consent);
  a renderer hash would be forgeable and security-worthless. version:1 is
  the migration anchor if main-owned hashing is ever added.

Tests: electron 154 pass (consent-store + executor ask-once/deny/clear/
built-in-never-persisted); 474 plugin specs pass incl. the sync-exclusion
guard. Docs updated.

* fix(plugins): harden persisted consent against prototype-pollution ids

Multi-review (security) found a CRITICAL in the Phase 2 consent store: the
consents map was a plain object keyed on an attacker-controlled pluginId, so
an uploaded plugin with id 'constructor' / 'toString' / 'valueOf' /
'hasOwnProperty' resolved consents[id] to the inherited Object.prototype
member (a truthy function). The executor's ask-once check treated that as a
prior grant and minted a nodeExecution token with NO consent dialog on a fresh
install — full code execution with zero user approval. ('__proto__' was already
blocked by the id allowlist; these names pass it.) Unit tests missed it because
the executor test stub used a Map, which is immune to the footgun.

Fix:
- Store: null-prototype consents map (Object.create(null)) + own-property
  (hasOwnProperty) guarded reads + reject non-object entries. Closes the class
  for any prototype-member id, including across a disk round-trip.
- Executor: reject __proto__/prototype/constructor in assertSafePluginId as
  defense-in-depth at the boundary.
- Regression tests: consent store returns null for prototype-member ids (fresh,
  after a real set, after clear); grant request for these ids is rejected with
  no dialog and no mint.

Also from review: ask-once path now re-checks the sender URL after the consent
read (parity with the dialog path); clarified why the consent mutation queue is
not redundant with simple-store's save queue.

Electron suite 156/156 pass.

* refactor(plugins): log consent persist-failure via electron-log

Multi-review follow-ups (non-blocking):
- Route the best-effort consent persist-failure to electron-log/main (the
  user-exportable host log) instead of console; console in the executor is
  otherwise the sandboxed plugin's own output. Only the validated id is logged.
- Clarify the disable-path comment: clearing revokes the live session grant
  always, and the persisted consent only for uploaded plugins (built-ins have
  none).

* fix(plugins): clear persisted consent on cache-clear and disclose persistence in dialog

Two gaps found in multi-agent review of the Phase 2 persisted-consent feature:

- clearUploadedPluginsFromMemory() (the 'Clear plugin cache' button) wiped the
  plugin code from IndexedDB but left the main-owned persisted nodeExecution
  consent behind. A later re-upload of the same id has no existingState, so the
  re-upload consent-clear in loadPluginFromZip never fired and the (possibly
  different) code was silently granted node execution with no prompt — defeating
  the 'replacing code under an id always re-asks' invariant. Now clears consent
  for every evicted uploaded id, mirroring removeUploadedPlugin.

- The uploaded-plugin native consent dialog still said access was valid 'for this
  app session', but Allow is now persisted across sessions. The prompt now
  discloses that the choice is remembered on the device until disable / remove /
  re-upload, so the user consents to the actual scope.

Regression tests added on both sides.

* refactor(plugins): key persisted consent on a Map, not a null-prototype object

Multi-review simplification. The consent store keyed an attacker-controlled
pluginId into a plain object, defended against `Object.prototype` member names
(constructor/toString/…) with a null-prototype object + hasOwn guards + a
typeof-object read check. A `Map` makes that safety structural and self-evident —
an unstored key is simply `undefined` — and matches the sibling `grants` Map in
the executor. The on-disk format is unchanged (a plain {version, consents} object);
the Map is serialized via Object.fromEntries (define-semantics, no prototype write)
and rebuilt via Object.entries with the well-formedness guard moved to load time.

Also corrects the stale 'never downgrade-corrupt it' comment with the accurate
downgrade behavior, and adds a round-trip test proving a hand-edited on-disk
__proto__ data key loads inertly without polluting Object.prototype.

* refactor(plugins): funnel disable through PluginService.disablePlugin and de-dup dialog display

Two more multi-review items, now that we own the PR:

- 'Disabling a node plugin revokes its consent' previously lived only in the
  plugin-management UI handler, so a future programmatic disable path could unload
  the plugin yet leave persisted consent behind — re-enabling would then silently
  re-grant node execution. Added PluginService.disablePlugin(setEnabled=false +
  unload + clearNodeExecutionConsent) and routed the UI through it, making the
  revoke a structural invariant. The consent clear is a safe no-op for non-node
  plugins, so the previous requiresNodeExecution gate is dropped.

- The uploaded-plugin name/version were sanitized once for the dialog and again for
  persistence (same lengths/fallbacks, duplicated). Extracted sanitizedUploadedDisplay
  as the single source of truth so the persisted record always matches what the user
  saw in the prompt.

Tests added for the disablePlugin invariant.

* fix(plugins): harden persisted nodeExecution consent (multi-review)

Follow-ups from a multi-agent review of #8600:

- Re-ask structurally on every upload: clear consent unconditionally in
  loadPluginFromZip (outside the `existingState` branch) so a same-id
  re-upload always re-prompts even if consent was orphaned (crash
  mid-uninstall, IndexedDB eviction, external/partial wipe).
- Fail closed on upload: clearNodeExecutionConsent reports a persist failure
  via its return value; loadPluginFromZip aborts the upload if the prior
  consent could not be revoked, so replacement code can't inherit a stale
  grant. Lifecycle edges (disable/uninstall/cache-clear) ignore the result so
  a rare disk failure can't abort their bookkeeping.
- Mint the grant before the best-effort consent persist in the executor so a
  navigation/destroy during the write drops it via cleanup and a persist
  failure can't lose an approved grant.
- Validate the full consent record shape on load so a corrupt {}/array entry
  can't read as a grant.
- Log only the validated id + error code on persist failure (no userData path).
- Fix a stale comment (the store keys consent in a Map, not null-proto objects).

Adds regression tests: mint-before-persist ordering, best-effort persist,
malformed-entry rejection, and the consent-clear fail-closed return contract.

* test(plugins): add clearNodeExecutionConsent to PluginBridgeService spy

loadPluginFromZip now clears prior persisted nodeExecution consent
before loading replacement code (#8512 Phase 2). The spy in this spec
lacked the method, so the call threw, was caught, returned false, and
aborted the upload, failing both load-from-zip tests.
2026-06-29 16:12:00 +02:00
Johannes Millan
a67323b2b5
feat(plugins): allow uploaded nodeExecution behind consent gate (#8576)
* feat(plugins): allow uploaded nodeExecution behind consent gate

Re-open the nodeExecution permission for uploaded/community plugins
(previously built-in only, #8205) behind the existing main-process
consent dialog. Phase 1 of #8512; unblocks the Super Productivity MCP
plugin (discussion #8385).

- Main process sanitizes the attacker-controlled plugin id and the
  self-declared name/version before they reach the consent dialog or
  the grant map (control/bidi/whitespace rejected, length-capped).
- Bundled vs uploaded is decided by the on-disk manifest, never a
  renderer-supplied flag, so uploaded code can't borrow a built-in
  plugin's verified name.
- Uploaded-plugin dialog anchors on the validated id, flags the plugin
  as unverified third-party with full machine access / no sandbox, and
  defaults to Deny.
- Revoke is main-authoritative by (pluginId, webContents) so a re-upload
  reusing an id can't inherit a live session grant.
- Consent stays session-scoped: an in-memory, never-synced denied set
  prevents re-prompt storms; deny keeps the plugin enabled but fails
  node calls closed until re-enable or restart.

Refs #8512 #8385

* fix(plugins): reject path-segment ids in nodeExecution consent gate

Multi-agent review of the Phase 1 gate found the uploaded plugin id —
used as a path component in the bundled-manifest existsSync probe — was
not rejecting path separators or dot-segments. Impact was bounded (the
verified-builtin branch re-validates with the strict kebab regex before
any read, so no code exec / file read / dialog spoof), but `..` / `/`
left a filesystem-existence oracle and rendered misleadingly as the
dialog "Plugin ID". assertSafePluginId now rejects `/`, `\`, `.`, `..`.

Also from review: factor the shared Allow/Deny dialog shell, and correct
the denied-cache comment (the existing token short-circuit handles the
multi-call-site case; the cache only makes a denial sticky across a later
non-interactive grant re-entry). Documents the uploaded-id constraints.

Refs #8512

* fix(plugins): harden uploaded nodeExecution consent gate (review follow-ups)

Addresses multi-agent review findings on the uploaded-plugin nodeExecution
consent gate:

- id validation: use an allowlist (/^[A-Za-z0-9][A-Za-z0-9._-]*$/) instead of a
  Unicode denylist, closing bidi/zero-width/homoglyph dialog-anchor spoofing the
  range list missed (U+061C, U+2060, U+3164, fullwidth chars); strip all Unicode
  control+format chars from the self-declared display name/version.
- never upgrade trust: describeVerifiedBuiltInDialog returns null on any imperfect
  on-disk verification (id mismatch, missing permission, unreadable manifest) and
  the grant handler falls back to the unverified dialog, so a colliding uploaded id
  can never borrow a built-in's verified dialog.
- reserve the gitea/linear/trello/azure-devops issue-provider bundled ids (they had
  drifted out of BUNDLED_PLUGIN_IDS, leaving an impersonation gap) and guard the
  BUNDLED_PLUGIN_PATHS subset-of BUNDLED_PLUGIN_IDS invariant with a node test.
- key the revoke and exec IPC handlers through the same assertSafePluginId as the
  grant handler so the "revoke by id on teardown/re-upload" guarantee can't drift.
- clear the session nodeExecution denial when a plugin is uninstalled, so a fresh
  re-upload of the same id is prompted again rather than silently failing closed.
- de-duplicate the PluginNodeExecutionElectronApi interface into a single
  electron/shared-with-frontend model (was copied byte-identically in two files).
2026-06-25 16:45:54 +02:00
Johannes Millan
faa9434a6a
fix(sync): surface OneDrive OAuth token errors and document Entra setup (#8580)
A OneDrive token-exchange 400 (issue #8572) surfaced only as a generic
"HTTP 400 Bad Request" / "copy the code exactly" message, hiding the
real cause: the authorize step succeeds, then token redemption fails
because the custom Microsoft Entra app isn't registered as a public
client ("Allow public client flows" disabled -> AADSTS7000218).

- onedrive provider: parse the OAuth error body once, log the safe
  short `error` code on every token failure, and route the verbose
  AADSTSxxxxx `error_description` to HttpNotOkAPIError.detail (UI only,
  per the existing privacy split).
- sync-wrapper: show a OneDrive-specific snack pointing at the Entra
  registration fix and interpolating the AADSTS detail; other providers
  keep INVALID_AUTH_CODE.
- in-app info text now shows the desktop redirect URI and links to the
  setup guide; add a full OneDrive section to the configure-sync wiki
  note (it was entirely missing).
- test: auth-code 400 surfaces the detail to the UI, logs the error
  code, and keeps the description out of the structured log.
2026-06-24 16:18:46 +02:00
Johannes Millan
280c2c1c52
fix(caldav): route WebDAV verbs through native HTTP on Android (#8558) (#8577)
CalDAV calendar discovery (PROPFIND) and event search (REPORT) failed on
the Android (Capacitor) and web apps while working on Mac and iPad.

Root causes:
- Android: Capacitor's CapacitorHttp routes non-GET/HEAD/OPTIONS/TRACE
  requests through Java's HttpURLConnection, which throws ProtocolException
  for WebDAV/CalDAV verbs (PROPFIND, REPORT, ...). Affects every CalDAV
  calendar user on Android, not just iCloud.
- Web: pure CORS — iCloud's CalDAV server sends no Access-Control-Allow-Origin
  header, so the browser blocks the request. Unfixable client-side.

Fix (Android/native): on native platforms, route the WebDAV/CalDAV verbs
through the existing OkHttp/URLSession-backed native HTTP executor (the same
WebDavHttp plugin the WebDAV sync feature already uses), which accepts
arbitrary method strings. Standard verbs (GET/POST/PUT/DELETE/...) keep the
HttpClient path unchanged. The native path preserves SSRF validation, auth
headers, and HttpClient's non-2xx error shape (.status) so plugin retry/404
checks behave identically.

The rerouted set is scoped to the verbs the CalDAV plugin uses (PROPFIND,
REPORT) and stays within what the native WebDavHttp plugin implements.

Web is unchanged and still limited by CORS for servers (like iCloud) that
don't send CORS headers; documented in 3.05-Web-App-vs-Desktop.md.

The reroute is gated by injectable tokens (PLUGIN_HTTP_IS_NATIVE,
PLUGIN_HTTP_NATIVE_EXECUTOR) for testability.
2026-06-24 13:45:39 +02:00
Johannes Millan
87846ad83d
fix(android): keyboard + status-bar follow-ups for edge-to-edge (#8508) (#8548)
* fix(android): size fullscreen markdown/notes dialog above the keyboard (#8508)

The fullscreen markdown editor (project & task notes) is position:fixed;
height:100% but its keyboard rule subtracted --keyboard-overlay-offset, which
is set only on iOS. On Android the rule was a no-op, so with the IME open the
dialog kept full height (content behind the keyboard) or inherited the squashed
sliver, leaving the toolbar + textarea + Close/Save mashed to the top.

Use the resize-detecting --keyboard-height for the Android/mobile-web case
(0 once the window resized, the obscured amount otherwise), mirroring the
add-task bar and .app-container; keep the iOS --keyboard-overlay-offset path as
a second, source-order-later rule (equal specificity, wins on iOS). On a device
that resizes (--keyboard-height == 0) it is identical to the old rule, so it is
never worse than before; it fixes the API >= 30 no-resize/VisualViewport-shrink
case and composes with the SDK < 30 native fix (#8528).

* fix(android): inset the header below the status bar on API < 30 (#8508)

Under enforced edge-to-edge (targetSdk 36) the WebView extends under the status
bar, but on the API < 30 WebView `env(safe-area-inset-top)` resolves to 0 (old
WebViews map only display cutouts into safe-area insets, not the status bar), so
`--safe-area-top` was 0 and the web header overlapped the status bar on Android 9.

The web side cannot tell "edge-to-edge under the status bar" from "already
natively inset" (env() is 0 in both), so a web-only fallback would double-count.
Measure the overlap natively instead: in the existing keyboard layout listener
(gated SDK < 30) compute max(0, rect.top - webViewTopOnScreen) — visible-frame
top (status-bar height, reliable on API 28) minus the WebView's on-screen top (0
when edge-to-edge, == status-bar height once inset) — and publish it as the
`--android-status-bar-overlap` CSS var (physical px -> CSS px, deduped). The web
folds it in with max(env(safe-area-inset-top, 0px), var(--android-status-bar-
overlap, 0px)): never a sum so it can't double-count, and on API >= 30 the var is
never set so `max(env, 0) = env` keeps the verified behavior. JS readers still
parse the token to 0, preserving the #8283 overlay scoping.

Needs on-device validation on API < 30 and a no-op check on API >= 30.

* fix(android): stop double-counting safe-area-top in note dialog height (#8508)

The fullscreen note/markdown dialog subtracted --safe-area-top from its
keyboard-open height while ALSO applying padding-top: --safe-area-top. Since
:host is border-box (global * { box-sizing: border-box }), the padding is already
inside height: 100%, so subtracting --safe-area-top again double-removed the top
inset and left a --safe-area-top-sized gap between the Close/Save controls and
the keyboard.

Invisible while --safe-area-top was 0 on API < 30; it surfaced once the
status-bar fix made it non-zero (and was latent on API >= 30 where env() already
gave a non-zero value). Drop the - --safe-area-top term from the Android rule so
the controls sit flush above the keyboard while the toolbar stays below the
status bar. iOS keeps its own override (different keyboard runtime, unverified on
device) and is flagged for separate checking.

* fix(android): re-publish status-bar overlap after web reload (#8508)

Review follow-ups to the #8508 keyboard/status-bar fixes:

- The native --android-status-bar-overlap lives only as an inline style on
  the document, so a web-side window.location.reload() (language change, PWA
  update, sync-conflict recovery) wipes it while the dedupe field survives on
  the Activity -> the unchanged value is skipped and the header overlaps the
  status bar again on the WebView < 140 / API < 30 tail. Reset
  lastStatusBarOverlapCssPx in flushPendingShareIntent() (runs on every
  frontend (re)load) so the next layout pass re-publishes it.

- Dialog keyboard rule: scope the Android rule with :not(.isIOS). iOS carries
  both isNativeMobile and isIOS and sets --keyboard-height non-zero, so the two
  equal-specificity rules both matched and iOS correctness depended on source
  order; they are now mutually exclusive and order-independent.

- Declare --android-status-bar-overlap: 0px in :root for discoverability,
  alongside the other dynamic vars.
2026-06-23 11:57:49 +02:00
Johannes Millan
431bc50da9
fix(sync): help Nextcloud users find their user ID + auto-detect it (#7617) (#8547)
* docs(sync): point Nextcloud user-ID lookup at the WebDAV URL (#7617)

The field hint, the 404 test-connection message, and the wiki all told
users to find their user ID under 'Settings -> Personal info' / 'your
Files URL'. Both are unreliable: Personal info does not clearly surface
the uid, and a folder's address bar shows a folder ID, not the user ID
(reporter followed it and got a folder ID). Redirect all three to the
authoritative source: Files -> settings gear (bottom-left) -> the WebDAV
URL '.../remote.php/dav/files/<user-id>/'.

* feat(sync): auto-detect Nextcloud user ID via OCS (#7617)

The Nextcloud WebDAV files path needs the account's internal user ID,
which differs from the email/login name people enter and is awkward to
find by hand — the root cause of the recurring '404 / connection test
failed' reports. Add a 'Detect user ID' button to the Nextcloud sync
config that asks the server for it.

- packages/sync-providers: discoverNextcloudUserId() calls the OCS
  endpoint /ocs/v2.php/cloud/user (OCS-APIRequest header) authenticated
  with login + app password and returns ocs.data.id. A 401 reports bad
  credentials (cleanly distinct from the 404 wrong-user-id path); a 200
  that isn't an OCS payload reports 'not a Nextcloud/OCS server'. A
  missing/wrong URL scheme is refused up front (matches the provider's
  own _cfgOrError check) so credentials never hit a schemeless host.
- Dialog: button fills the Username field with the detected ID. To keep
  auth working, if 'Login name' was empty and the user had typed their
  login into 'Username', that login is preserved into 'Login name'
  before Username is overwritten with the ID. Result handling split into
  _applyDetectedUserIdResult for unit-testability.
- Additive and optional: generic WebDAV and the save/sync path are
  untouched, no synced data shapes change.
- Tests: 7 package specs (success, trailing-slash, login fallback, 401,
  non-OCS 200, missing id, scheme guard) + 6 dialog specs (login guard,
  fill+confirm, 3 login-preservation cases, failure).
2026-06-23 11:56:15 +02:00
John Costa
0a0723521b
feat(azure-devops): add optional WIQL override for auto backlog import (#8516)
* feat(azure-devops): add optional WIQL override for auto backlog import

Adds an optional autoImportWiql config field to the Azure DevOps issue
provider plugin. When set, it fully replaces the generated auto-import
backlog query, so the user controls scope, state filtering and ordering
(e.g. to filter by iteration path, area path or work item type, or to
match custom done-state names). When empty, behavior is unchanged from
today's scope-based query, so it is fully backward compatible.

This mirrors the Jira provider's autoAddBacklogJqlQuery (a full query the
user owns) rather than appending a fragment, which can only narrow the
hard-coded English done-state exclusion and cannot accept a real exported
WIQL Select..From..Where statement.

Adds a vitest suite covering the default scopes, quote escaping, the
verbatim override and the blank-fallback, wires the plugin into the
plugin-tests CI matrix, and updates the issue integration comparison wiki.

Closes #7674

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DY1ESWymmU9x9ykLNaRHGH

* test(azure-devops): use node test env, drop unneeded jsdom dependency

The plugin tests exercise no DOM API, so vitest's default node
environment is sufficient (matching the clickup and google-calendar
provider plugins). Removing jsdom prunes ~530 lines of transitive
devDependencies from the lockfile.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
2026-06-22 17:38:49 +02:00
Johannes Millan
bb3ed74480
feat(android): migrate edge-to-edge to built-in SystemBars (#8543)
* feat(android): migrate edge-to-edge to built-in SystemBars

Replace @capawesome/capacitor-android-edge-to-edge-support with Capacitor 8's
built-in SystemBars (insetsHandling: 'css'). SystemBars handles edge-to-edge
insets + IME padding on WebView >= 140 / API >= 35; the WebView < 140 / API < 35
tail is covered by env() fallback plus a native keyboard shim
(adjustWebViewHeightForKeyboardBelowApi30), gated to WebView < 140 so it does
not fight SystemBars' own IME padding.

- config: drop EdgeToEdge plugin config + includePlugins entry + dep; remove the
  plugin from the generated gradle registration
- theme: stop JS-writing --safe-area-inset-* on Android (SystemBars owns them;
  the SCSS env() fallback preserves the #8283 top fix); bars are now transparent
  with the theme color painted behind them via setWebViewBackgroundColor, since
  SystemBars has no bar-color API
- StartupOverlayManager: derive the overlay inset from the system-bar insets
  instead of the removed plugin's WebView margin

Spike: TypeScript verified (tsc clean, lint clean). Kotlin and on-device
behavior NOT yet validated (gradle unavailable in this environment). Requires
`npm install` + `npx cap sync android` and the device matrix in
docs/plans/2026-06-22-android-systembars-migration-corrected.md before merge.

* build(deps): drop @capawesome edge-to-edge from lockfile

Reconcile package-lock.json with the package.json change in the edge-to-edge ->
SystemBars migration (removes only the @capawesome/capacitor-android-edge-to-edge-
support entry). Generated by `npm install`.

* docs(android): clarify SystemBars band model from implementation review

Comment-only + plan-doc follow-up to the multi-agent review of the migration:

- Correct the band model in the inset comments: SystemBars *injects*
  --safe-area-inset-* only on API >= 35; WebView >= 140 is native env()
  passthrough (no injection below API 35). The SCSS var(..., env()) fallback
  covers every band. (capacitor.config.ts, global-theme.service.ts)
- Fix a stale comment in StartupOverlayManager.show() that still described the
  removed @capawesome plugin insetting the WebView.
- Record the device-validation-only findings in the plan doc (API>=35/WebView<140
  double-count corner, env vs var consumer split, API 30-34/WebView<140 IME
  owner, CDK overlay top shift) so they are checked on the matrix, not blind-fixed
  (a blind shim extension would risk re-creating #8508).

No behavior change.
2026-06-22 16:07:06 +02:00
Johannes Millan
20e85a2a19
refactor(tasks): restructure reminder dialog footer into primary + overflow (#8517)
* refactor(tasks): restructure reminder dialog footer into primary + overflow

The footer showed up to four visually identical stroked buttons (Snooze,
Done, Add to Today, Start) with no hierarchy. Reduce to a single filled
primary action plus a Snooze menu and an overflow (kebab) menu:

- Primary: Start (single) / Schedule for today (multiple)
- Snooze button keeps the quick-defer menu (10/30/60 min, tomorrow)
- New overflow menu holds the rest (Done/Complete, Add to Today, edit,
  unschedule, dismiss-keep-today)

All existing actions remain available; only their grouping changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE

* refactor(tasks): add dropdown caret to reminder snooze button

The Snooze button opens a menu but had no visible affordance signalling
that. Add a trailing arrow_drop_down icon (native Material iconPositionEnd
slot) so the menu trigger reads as such, surfaced by the review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE

* test(reminders): open overflow menu to reach Done in deadline specs

The reminder dialog's "Done" action moved from a footer button into the
new overflow ("More actions") menu, so the two deadline e2e specs must
open that menu before clicking Done.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE

* refactor(tasks): refine reminder footer hierarchy after UX review

Follow-up to the footer restructure, addressing review findings:

- Surface a one-tap "Done" (check) icon for single reminders, so the most
  common reminder response isn't two taps deep in the overflow menu. Left
  out for the multi-task case, where a one-tap complete-all is a footgun
  and per-row checks already exist.
- Make the primary action deadline-aware: deadline reminders now default
  to the safe "Add to Today" instead of "Start" (which sets the current
  task and reorders Today as a side effect). Start moves into the overflow
  for deadlines; scheduled reminders keep Start as primary.
- Move "Edit (reschedule)" into the Snooze ("when") menu so all
  time-deferral actions are grouped and the overflow holds pure
  dispositions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE

* test(reminders): click one-tap Done icon in deadline specs

The single-task reminder footer now surfaces "Done" as a one-tap icon
button (aria-label "Mark as done"), so the deadline specs click it
directly instead of opening the overflow menu.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE

* refactor(tasks): put primary reminder action on the right, Done in menu

Per UX feedback:
- Reverse the footer button order so the filled primary action is always
  on the right (overflow on the left, Snooze in the middle).
- Move "Done" back into the overflow menu instead of surfacing it as a
  standalone icon button.

Update the deadline e2e specs to open the overflow menu before clicking
Done accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE

* feat(tasks): allow dismissing reminder dialog + snooze split-button

Three reminder-dialog improvements:

- Click-away to dismiss: backdrop click / Escape now close the dialog and
  dismiss the reminders (clearing the reminder while keeping the task and
  its schedule). disableClose is kept and the events are handled in the
  dialog, so the worker does not immediately re-open it in a loop.
- Snooze split-button: a single click snoozes 10 min (the common default,
  previously a hidden double-click) and a joined dropdown caret opens the
  full options menu. Uses a new shared .g-split-btn style.
- Overflow button: replace the lone vertical "three dots" icon with a
  low-emphasis labeled "More" button, giving a clean emphasis ramp
  (More < Snooze < primary) with the primary kept on the right.

Updates the deadline e2e specs and wiki for the new dismiss behavior, and
adds unit tests for the backdrop/Escape dismissal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE

* fix(tasks): make reminder dialog close on backdrop/Escape

Per product decision, clicking the backdrop or pressing Escape now simply
closes the reminder dialog (drop disableClose) rather than dismissing the
reminders. Deadline reminders are still cleared on close (avoiding the 10s
re-fire loop); scheduled reminders stay active and the worker re-shows them
until the user acts on them.

Reverts the earlier intercept-and-dismiss approach (and its unit tests);
updates the wiki accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE

* feat(tasks): add split-button UI component, restructure reminder footer

Add a reusable <split-button> UI component (src/app/ui/split-button): a
primary default action joined flush to a compact, centered overflow trigger
that opens a passed-in menu. The joined treatment lives in the shared style
layer (styles/components/split-button.scss) and now renders with no gap
between the two halves and a properly centered trigger icon.

Use it in the reminder dialog footer: limit the footer to two actions —
"Snooze 10m" (showing the snooze duration) and the primary CTA (Start /
Add to Today) — and fold the former "More actions" and snooze-options menus
into a single overflow menu opened by the icon button right of snooze.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE

* fix(tasks): fuse split-button halves and update reminder dialog test

The split-button trigger was pushed 8px away from the default action by
Angular Material's `.mat-mdc-dialog-actions .mat-mdc-button-base +
.mat-mdc-button-base` rule (specificity 0,3,0), which outweighed the
joining `margin-left: -1px`. Match Material's selector hooks so the
negative margin wins and the two halves render flush inside dialogs.

Also drop the stale `disableClose: true` assertion from the reminder
module spec to match the now-dismissable dialog (fixes failing CI).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE

* fix(tasks): clarify reminder dialog "add to today" button label

The multi-task primary button said "Schedule for today", which is both long
and misleading: those tasks are already scheduled — the action drops their
time and keeps them on Today (all day), or for deadlines adds them to Today
while keeping the deadline date.

Key the label on deadline vs schedule instead of single vs multiple:
- all deadlines -> "Add to Today" (added to today, deadline preserved)
- scheduled/mixed -> "Today" (already scheduled; kept on today, all day)

Reuses the existing TODAY_TAG_TITLE string; SCHEDULE_FOR_TODAY remains for
the per-row tooltips.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE

* fix(tasks): distinguish deadline vs scheduled in reminder today-button

After weighing options, label the reminder dialog's primary today-action by
what it actually does in each case:
- all deadlines -> "Add to Today" (task is added to Today; deadline kept)
- scheduled/mixed -> "Keep in Today" (already scheduled; kept on Today, all
  day, with the specific time dropped)

This replaces the bare, verb-less "Today" with accurate wording consistent
with the dialog's existing "keep in Today" vocabulary. Adds the
KEEP_IN_TODAY en string (other locales fall back to English via fallbackLang).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE

* chore(reminders): drop unused SNOOZE_OPTIONS string, regenerate t.const.ts

The reminder-dialog footer trigger uses MORE_ACTIONS, not SNOOZE_OPTIONS,
which was a dead key from an earlier iteration. Remove it and regenerate
t.const.ts through prettier so the file is back to the formatted form
(the prior commit had committed raw generator output, churning ~760 lines).

* refactor(ui): trim unused split-button inputs, add unit spec

The split-button's only consumer never overrides color or triggerIcon, so
drop both inputs (and the deprecated ThemePalette type) and inline the
defaults. Add a unit spec covering content projection, mainClick, menu
wiring, disabled state, and the trigger aria-label.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-22 15:42:44 +02:00
Johannes Millan
ad149e138c
docs(android): document SDK 28 (API 28) behind-keyboard add-task bar root cause (#8528)
* docs(android): document SDK 28 (API 28) behind-keyboard add-task bar root cause

After #8508 (patch removed in 18.12.0) a user on Android 9 / API 28 reports the
global add-task bar sitting behind the soft keyboard — the device class open
item #4 predicted.

Capture the precise root cause (edge-to-edge forces no system resize; Type.ime()
unreliable < API 30; old WebView VisualViewport doesn't shrink, so
--keyboard-height stays 0) and why a web-side height fallback is the reverted
#8295 trap (obscured ≈ 0 in both the resized and non-resized cases). Record the
correct resize-detecting native fix recipe and the device-matrix gate it needs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gqH2i456UwdTJXwW5YHPs

* fix(android): lift add-task bar above keyboard on API < 30 (#8508 follow-up)

On Android 9 / API 28 under enforced edge-to-edge (targetSdk 36) the system
does not resize the window for the IME and WindowInsetsCompat.Type.ime() is
unreliable, so the edge-to-edge plugin never insets the WebView and neither the
window nor VisualViewport shrinks. --keyboard-height stays 0 and the
position:fixed add-task bar sits behind the keyboard.

Add a resize-detecting native inset in CapacitorMainActivity, driven from the
existing keyboard OnGlobalLayoutListener: measure the keyboard via
getWindowVisibleDisplayFrame (reliable on every API level) and, only when the
WebView actually extends behind the keyboard, lift it by the exact overlap via
bottomMargin (matching the plugin), self-correcting as the keyboard height
changes and restoring the captured margin on hide. Gated Build.VERSION.SDK_INT
< 30 so it is a strict no-op on every device #8508/18.12.0 verified.

Web-side height fallback was rejected: obscured = innerHeight - vvHeight is ~0
in both the resized and non-resized cases, so disambiguating requires the
baseline-innerHeight + max(obscured, nativeKb - layoutShrink) math that was
reverted as #8295. Native has the unambiguous geometry.

Needs on-device validation across the matrix in docs/android-edge-to-edge-keyboard.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gqH2i456UwdTJXwW5YHPs

* fix(android): harden SDK < 30 keyboard inset after review

Address findings from a multi-agent review of adjustWebViewForKeyboardBelowApi30:

- Bound the feedback loop: clamp appliedKeyboardInsetPx to [0, keypadHeight]
  (the lift never needs to exceed the keyboard height) and skip redundant
  layoutParams writes, so a WebView height that doesn't respond to the margin
  (e.g. the edge-to-edge plugin rewriting it) can't drive an endless
  requestLayout loop.
- Ignore stale/pre-layout geometry while the keyboard is open (webView.height
  == 0) so an already-applied inset isn't collapsed to 0 for a frame; only the
  genuine keyboard-closed path restores the margin.
- Make the dead-band density-relative (8dp) instead of a fixed 16px so it's
  visually constant across densities.

The reviews confirmed the central risk is absent: shrinking the WebView via
bottomMargin shrinks both innerHeight and visualViewport.height, so the web
side's obscured stays under its 100px floor and does not add a second lift
(no #8295 double-count). Still pending on-device validation per the docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gqH2i456UwdTJXwW5YHPs

* fix(android): resize WebView via height for SDK<30 keyboard inset

On API < 30 under enforced edge-to-edge the system does not resize the
window for the IME, so the add-task bar sat behind the keyboard (#8508
follow-up, Android 9 / API 28).

The edge-to-edge plugin owns webView.bottomMargin and rewrites it to 0 on
every inset dispatch while the IME is visible, so correcting the margin
from a second writer flickers; WebView padding does not move the web
layout viewport; and fully replacing the plugin's inset listener stops it
re-painting its color overlays (white navbar gap).

Instead set an explicit WebView layout height to the keyboard top while
the IME is up and restore the resting height on hide. Height is a
different property than the margin the plugin manages and, for an
explicit-height view, the margin does not change the view size — so the
two never fight and the plugin keeps doing everything else (insets +
color overlays). Gated SDK_INT < 30, so API >= 30 is untouched.

Also documents the root cause, the approaches that failed, and the
upstream status, plus a handover/plan for migrating to Capacitor's
built-in SystemBars.

* fix(android): guard zero-height + reuse loc buffer in keyboard inset

Multi-review follow-ups for the SDK<30 keyboard WebView-height workaround:

- Skip the write when the computed keyboard-top height is <= 0 instead of
  coercing to 0. A 0 would collapse the WebView, and the existing
  height==0 guard would then latch and stop recomputing until the keyboard
  hides.
- Reuse a single IntArray for getLocationOnScreen instead of allocating
  one on every layout pass while the IME is up (hot path).
- Document the implicit coupling with StartupOverlayManager: both read/
  mutate webView.height from layout listeners and only coexist because the
  overlay early-returns once its input bar is visible. Cross-referenced in
  both files so the guard isn't removed later.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-22 13:27:45 +02:00
Johannes Millan
ee47891f67 Merge branch 'feat/issue-8508-e897da'
* feat/issue-8508-e897da:
  fix(android): remove edge-to-edge WebView IME-inset patch (#8508)
  fix(android): correct edge-to-edge IME inset (gate API36+, latch, guard) (#8508)
  fix(plainspace): don't tint claim-list link icon with accent color
2026-06-20 17:01:22 +02:00
Johannes Millan
c247bc541a fix(android): remove edge-to-edge WebView IME-inset patch (#8508)
The v18.11.0 patch (5497212b9) always inset the WebView by the IME height,
assuming Android 15/16 enforces edge-to-edge so the system no longer resizes for
the keyboard. On real devices that is false: an Android 16 phone still resizes
the window for the IME (window.innerHeight 732 -> 141 with the keyboard up). The
patch then added another ~909px inset on top -> the WebView was squashed to a
~141px sliver with a large blank gap above the keyboard (the "can't see what I'm
writing" symptom).

Removing the patch restores the plugin's stock behavior (no inset while the
keyboard is visible), letting the system resize handle it. Verified on an
Android 16 phone: gap gone, WebView fills the resized window, bar sits just above
the keyboard (no behind-keyboard regression).

Doc rewritten with the root cause, the ruled-out theories (ngModel writeValue,
DOM churn, SDK-gate/latch), open items (reversal still to confirm on reporters'
devices; system resize on suggestion-strip oscillation; a resize-detecting inset
as the long-term fix) and an updated device test matrix.
2026-06-20 16:56:10 +02:00
Johannes Millan
2a0cc73507 fix(android): correct edge-to-edge IME inset (gate API36+, latch, guard) (#8508)
The v18.11.0 patch always inset the WebView for the IME, which shipped two bugs:
1. double-count on API <=35 (the system still resizes for the IME there - pre-
   enforcement, and we opt out on API 35 via values-v35) -> a keyboard-height
   blank gap above the keyboard;
2. the inset is reapplied on every OnApplyWindowInsetsListener callback via an
   unconditional setLayoutParams(); the IME inset fluctuates during typing
   (suggestion strip, layout switches), so the WebView relayouts mid-composition
   -> the IME composing region resets -> reversed/invisible characters.

Corrected patch (regenerated via patch-package):
- gate the inset to API 36+ where edge-to-edge is actually enforced (no system
  resize) -> no double-count, no gap on API <=35;
- latch the keyboard inset while the keyboard stays visible -> no relayout on
  inset fluctuations during composition;
- skip setLayoutParams when no margin changed.

Verified: white gap gone on API 34 emulator. API 36+ typing-reversal half still
needs real-device confirmation (not reproducible headless / on old emulators).
Doc updated; do-not-naively-reapply guidance retained.
2026-06-20 16:13:30 +02:00
Johannes Millan
9faf0a50d5
fix(sync): clarify Nextcloud connection-test 404 (wrong user ID) (#7617) (#8513)
* fix(plainspace): don't tint claim-list link icon with accent color

* fix(sync): clarify Nextcloud connection-test 404 (wrong user ID) (#7617)

A base-root 404 in the WebDAV connection test means auth succeeded but the
DAV path /remote.php/dav/files/<userName>/ does not exist — i.e. the
"Username" holds an email/display name instead of the account's user ID
(Nextcloud accepts email/login for auth but the files path needs the uid).

Previously this surfaced as a bare scrubbed hostname in the snack, which
users misread as a stripped URL. Now:

- WebdavApi.testConnection maps thrown errors to a readable, privacy-safe
  message plus an HTTP errorCode discriminator (404/401/status).
- The Nextcloud "Test Connection" path shows a specific hint on 404
  explaining that "Username" must be the user ID, not email/display name.
- Wiki + field guidance clarified accordingly.
2026-06-20 15:18:53 +02:00
Harbinger
8171bb05d0
refactor(sync): remove unused error classes, dialog, and constructor-time logging (phase 1, #8325) (#8510)
* fix(op-log): lock snapshot save to prevent lost-update window

saveCurrentStateAsSnapshot() read NgRx state then lastSeq without
holding OPERATION_LOG lock. An op appended between the two reads would
get seq <= lastAppliedOpSeq but its effect would be absent from the
snapshot. On next hydration the tail replay would start after that seq,
silently skipping the op forever.

Fix: wrap in lockService.request(LOCK_NAMES.OPERATION_LOG, ...) and
read lastSeq BEFORE state snapshot so the worst interleaving degrades
to harmless re-replay (idempotent) rather than a missed op.

Fixes #8308

* fix(op-log): address review feedback on snapshot lock PR

- Amend JSDoc idempotency claim: syncTimeSpent is additive on re-replay
- Add inline note about compaction's opposite read order and worse failure mode
- Add lock regression tests (#8308): lock acquired, read order, error handling

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

* refactor(sync): remove unused error classes, dialog, and constructor-time logging

Phase 1 of #8325: clean up orphaned sync error types and their UI.

Removed error classes that are no longer thrown anywhere:
- NoEtagAPIError, FileExistsAPIError (unused API errors)
- RevMismatchForModelError, SyncInvalidTimeValuesError (superseded by file-based flow)
- RevMapModelMismatchErrorOnDownload/Upload, NoRemoteModelFile, NoRemoteMetaFile
- LockPresentError, LockFromLocalClientPresentError, MetaNotReadyError, InvalidRevMapError

Removed DialogSyncErrorComponent and all references in SyncWrapperService
(_forceDownload, _handleIncoherentTimestampsDialog, _handleIncompleteSyncDialog,
_openSyncErrorDialog, _extractModelIdFromError).

Removed constructor-time logging from JsonParseError, ModelValidationError,
DataValidationFailedError — these errors are logged at the catch site; redundant
construction-time logs risk leaking user data.

Cleaned up dead translation keys (D_INCOMPLETE_SYNC block, DIALOG_RESULT_ERROR,
ERROR_DATA_IS_CURRENTLY_WRITTEN) from en.json and t.const.ts.

Updated file-based-sync-flowchart.md to reflect the removed error types.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 13:36:56 +02:00
Mohamed Abdeltawab
0b4bc79354
refactor: retire GET /api/sync/snapshot, re-scope GET /api/sync/status as diagnostic (#8496)
Retire GET /api/sync/snapshot (attack-surface reduction):
- Remove route handler from sync.routes.ts (no production client caller)
- Keep internal generateSnapshot() and all downstream code
- Update tests: delete GET /snapshot describe block, rework isolation
  test (sync.routes.spec.ts), remove GET assertion (sync-fixes.spec.ts),
  remove SimulatedClient.getSnapshot() helper + rework 2 integration tests
  to use GET /api/sync/ops instead (multi-client-sync.integration.spec.ts)

Re-scope GET /api/sync/status as diagnostic:
- Add doc comment to route handler marking it diagnostic
- Update all documentation (README, API wiki, architecture diagrams)

Documentation updates across 5 files remove GET /snapshot references
and label GET /status as diagnostic.

Breaking: self-hosters with external tooling relying on GET /snapshot
must migrate — no shipped client version ever called this endpoint.
2026-06-20 12:02:58 +02:00
Symon Baikov
cbe3f1527f
fix(caldav): support Vikunja project discovery (#8481) 2026-06-19 12:15:31 +02:00
Symon Baikov
69cb69df51
docs(sync): document Proton Drive via rclone WebDAV (#8064) 2026-06-19 11:05:30 +02:00
Johannes Millan
91d8dd80ba
feat(plainspace): add integration for shared projects (#8424)
* docs(plainspace): add integration plan for shared projects

* feat(plainspace): prototype 'assigned to others' work-view panel

UI-only prototype of the dual-list shared-project view: a read-only
component that lists Plainspace tasks owned by other members, grouped by
assignee, rendered as a collapsible panel in the project work view.

Uses hard-coded sample data; foreign tasks never enter the SP task store
or op-log sync. See docs/plainspace-integration-plan.md.

* refactor(plainspace): apply review fixes to prototype panel

- use defined --text-color-muted / --s-half / --s2 tokens instead of an
  undefined token and redundant spacing fallbacks
- drop unused PLAINSPACE.DONE i18n key
- note avatarUrl sanitization needed before live API data
- clarify plan doc: icon registration in GlobalThemeService, issueType
  union widening, and which phases are design-only vs implemented

* feat(plainspace): add mock-backed issue provider + share-on-create toggle

Phase 1: register PLAINSPACE as an issue provider (config form, API
service with mock mode, IssueServiceInterface impl, issue.model/const/
service registration, icon, issue-content config). Tasks assigned to me or
unassigned import via the normal issue->backlog pipeline; tasks assigned to
others are excluded here (shown read-only by the separate panel).

Phase 3: add a 'Share on Plainspace' toggle to the create-project dialog
that provisions a (mock) space and a bound provider via
PlainspaceShareService.

All Plainspace calls are mocked (PLAINSPACE_USE_MOCK) so the flow works
without a live backend; the real HTTP contract is isolated in
PlainspaceApiService. Build + lint:ts + lint:scss pass; added a spec for
the provider's mock/filter logic (unit run blocked: no browser in env).

* refactor(plainspace): address review feedback on provider + share flow

- add a Plainspace tile to the issue-provider setup overview so the
  provider is addable manually (connect to an existing space), not only
  via the share-on-create toggle
- make PlainspaceShareService self-contained: catch errors, surface a
  snack, never reject (safe to fire-and-forget); log ids only
- strip the transient isShareOnPlainspace flag before it can reach
  sessionStorage on the dialog cancel path
- add a getIssueProviderTooltip case for PLAINSPACE (was falling through
  to the raw key)
- drop a redundant cast now that PlainspaceIssue is in IssueDataReduced

* feat(plainspace): add account login / identity (Phase 2, mock)

- PlainspaceAccountService: signals (account/isLoggedIn/currentUserId),
  mock login/logout, localStorage-persisted (local-only, never synced)
- 'mine' in the import filter now derives from the signed-in identity
  instead of a hard-coded constant; not-logged-in => only unassigned
- the Share-on-Plainspace flow prompts (mock) sign-in if needed and
  refuses to provision a space when the user declines
- move the mock user-id constant to the plainspace feature folder to
  avoid a cross-folder import cycle
- specs for the account service and updated api-service spec (sign in
  before asserting the mine/unassigned split)

Identity is mocked (fixed user id) so the assigned/unassigned split lines
up with the mock space data; real OAuth/token exchange is future work.

* feat(plainspace): feed 'assigned to others' panel from live mock data

Wire the work-view panel to a new PlainspaceSharedTasksService instead of
hard-coded sample data: for a project with a bound enabled PLAINSPACE
provider, it fetches the space's tasks, keeps only those assigned to
others (assigneeId !== me), maps them to read-only rows, and threads them
through project-task-page -> work-view as an input. The panel now appears
only for shared projects (not every project), and foreign tasks still
never enter the SP task store / op-log.

Removes the prototype data const; adds a service spec.

* refactor(plainspace): replace 'assigned to others' list with a claim pool

Reframe around task ownership: SP shows only tasks assigned to me (first-
class) plus a read-only 'claim pool' of unclaimed tasks. Tasks assigned to
others are no longer represented in SP.

- import filter is now assigned-to-me only (was mine+unassigned)
- new claim flow: PlainspaceApiService.claimTask$ (assign-to-self) +
  PlainspaceClaimPoolService.claim -> IssueService.addTaskFromIssue, with
  pool refresh; claimed task leaves the pool
- rename AssignedToOthers component/service -> claim-pool; panel is
  collapsed by default (a pool you reach for, not active work)
- drop assignee badges (redundant: list is all 'mine'; provider icon
  already marks Plainspace tasks)
- mock data made resettable for test isolation; specs updated/added
- docs: capture the ownership model + rationale for dropping the list

* docs(plainspace): fix remaining stale claim-pool reference

* feat(plainspace): connect provider to the real integration API

Replace the mock-backed PlainspaceApiService with the real PAT-authed
{host}/api/integration endpoints (me, tasks, claimable-tasks, claim,
spaces, done PATCH) and map the wire SPTask -> the internal PlainspaceIssue
so the rest of the provider depends on one stable shape.

- the server scopes /tasks to the caller, so drop client-side identity and
  the mock data/identity consts; "mine" is decided server-side
- store the personal API token (PAT) in PlainspaceCfg like other providers'
  secrets; keep a local-only account cache to bootstrap share-on-create
- add a token field to the config form; the share flow prompts for and
  validates a PAT via /me before provisioning a space
- isEnabled now requires a token; testConnection hits /me; issueLink uses
  the task's real url

Adds docs/plainspace-api-extension-plan.md documenting the server contract
this client consumes (the endpoints added to Johannesjo/spaces).

* feat(plainspace): guided connect dialog with token steps + link

Replace the bare one-line API-token prompt with PlainspaceConnectDialog,
which shows where to create a personal API token and validates it before
closing:

- numbered step-by-step (Space -> People -> Advanced -> API tokens) plus an
  "Open Plainspace" link button
- inline validation via /me; an invalid token shows an error and keeps the
  dialog open instead of failing silently
- wire it into the share-on-create flow and sharpen the config-form token
  description to point at the same place

Adds the CONNECT i18n block (regenerates t.const) and drops the now-unused
LOGIN_PROMPT string; a new component spec covers connect/cancel + template.

* feat(plainspace): auto-import + two-way done sync

- default isAutoAddToBacklog to true so tasks assigned to me auto-import
  into the bound project's backlog (poll-to-backlog), matching the seamless
  intent
- add PlainspaceSyncAdapterService, registered for 'PLAINSPACE' in the
  two-way-sync effect, so completing/reopening a task in SP pushes the done
  state back to Plainspace via PATCH /tasks/:id { done }

Only isDone is pushed (the integration API's one writable field); title and
done changes from Plainspace are pulled by the existing update polling. Adds
an adapter spec and a Plainspace adapter spy in the two-way-sync effect spec.

* fix(plainspace): match bound space by slug or id so tasks import

getMyTasks$ filtered by `task.projectId === cfg.spaceId`, but the value users
copy from the space URL is the slug while SPTask.projectId is the UUID — so a
slug-configured provider matched zero tasks and imported nothing, even though
the /tasks response was full.

Match spaceId against both projectId and projectSlug (getMyTasks$ and the claim
pool), drop the server ?projectId= param (UUID-only) in favour of the same
client-side match, and point the Space ID help text at the slug in the URL.

* chore(plainspace): log import counts to diagnose missing imports

getMyTasks$ now logs total vs space-matched task counts (ids/counts only,
no task content) so we can tell whether the space filter or the import is
dropping tasks. Temporary diagnostic.

* feat: add picker for plainspace

* feat: improve wording

* feat(plainspace): use two-circle brand mark for provider icon

* refactor(plainspace): type create-project form to drop isShareOnPlainspace any-cast

* refactor(plainspace): remove temporary getMyTasks$ diagnostic log

* feat(plainspace): push scheduled time (dueWithTime → remindAt)

* fix(plainspace): seed two-way-sync baseline so write-back actually fires

* feat(plainspace): import remindAt as dueWithTime so schedule shows in app

* refactor(plainspace): align client to scheduledAt/isRecurring API contract

* feat(plainspace): flag recurring tasks in the claim pool

* feat(plainspace): pull scheduledAt into dueWithTime on poll (schedule existing + recurring tasks)

* feat(plainspace): push title changes back to Plainspace

* fix(plainspace): address multi-review findings (perf, UX, cleanup)

Performance:
- claim pool no longer re-fetches /claimable-tasks on every task
  add/complete/reorder (distinct on project id + provider identity)
- poll imported tasks via one getMyTasks$ instead of N getById$ calls

UX:
- gate the "Share on Plainspace" toggle to project create (was a dead
  control in edit mode)
- success/failure snacks on claim and share (silent 409/offline before)
- distinct error state in the space-picker (vs misleading "no spaces yet")

Correctness/cleanup:
- normalize scheduledAt to canonical UTC ISO on read so the push guard
  can't silently drop a reschedule on a benign reformat
- drop dead PLAINSPACE_INITIAL_POLL_DELAY, PlainspaceMember, assignee
- translate the "Advanced Config" form label

* docs(plainspace): reconcile token storage + forward-compat rollout note

- document that the PAT lives in synced provider cfg like other providers
  (the earlier "not stored here" note was never true in the shipped code)
- add the typia forward-compat rollout requirement for the new built-in
  PLAINSPACE issue-provider key to the Risks section
- fix stale "assigned to others" wording (now the claim pool)

* feat(plainspace): trim redundant Notes button + issue panel

Plainspace mirrors title/done/schedule onto native task fields, so the
detail panel only echoed the task and the chat button opened an
essentially empty drawer.

- Treat PLAINSPACE like ICAL: don't show the *persistent* toggle button
  just because the task carries an issueId. Keep it for real notes, a
  remote update (issueWasUpdated) and the open-panel close state.
- Keep the panel reachable: the hover-only "open detail panel" button
  (task-hover-controls) now covers PLAINSPACE too — it's the exact
  complement of the persistent button, so plain/iCal/Plainspace tasks all
  get a hover affordance and there's never a double button.
- Replace the redundant title-as-"Summary" link with the two things not
  already on the task: a clear "Open in Plainspace" link and a recurrence
  indicator rendered with the same `repeat` mat-icon the claim pool uses
  (new 'plainspace-recurring' custom field).
- Add unit coverage for the new content config.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-18 17:58:06 +02:00
Johannes Millan
0150ee23b3
feat(focus-mode): surface session-done + notify on countdown completion (#8475)
Countdown is the only focus mode that auto-stops with no follow-up
(Pomodoro transitions into a surfaced break; Flowtime only stops on
explicit user action), so completion could pass unnoticed: the SessionDone
screen wasn't reliably shown and there was no cross-platform notification.

On automatic Countdown completion (surfaceSessionDoneOnCompletion$):
- If the focus overlay is open, the reducer's SessionDone screen is already
  visible — nothing forced.
- If the overlay is hidden (user working elsewhere), surface a non-modal
  banner (BannerId.FocusModeSessionDone) with a 'What's next?' action that
  opens the SessionDone screen and self-dismisses once the overlay opens —
  instead of seizing the screen.
- Raise an OS notification only when the app is unfocused (skipped while
  focused, since the surfaced UI is alert enough and it would fire on
  idle-resume) and not on Android, where the native foreground service
  already posts its own completion notification (documented in
  FocusModeForegroundService.onTimerComplete to keep the contract visible).

Uses the injectable IS_ANDROID_WEB_VIEW_TOKEN (testable). Manual end and
Pomodoro/Flowtime are excluded.
2026-06-18 17:33:02 +02:00
Johannes Millan
c5651f52c7 docs(wiki): add Proton Drive via rclone + WebDAV guide
Document syncing to Proton Drive on desktop using the existing WebDAV
provider pointed at a local 'rclone serve webdav' bridge, instead of a
dedicated built-in provider. Marked experimental and no-support, in line
with how generic WebDAV is treated, since it relies on rclone's
reverse-engineered Proton backend.
2026-06-18 15:18:15 +02:00
Mohamed Abdeltawab
c2bf7b26a0
refactor(sync-core): drop shared-schema vector-clock compat re-export anda app enum (#8441)
* feat(sync-core): drop shared-schema vector-clock compat re-export and app enum

- Remove the shared-schema → sync-core compatibility re-export of
vector-clock types/functions; retarget app files
(vector-clock.ts,operation-log.const.ts) and the server (sync.types.ts)
to import from @sp/sync-core directly
- Add @sp/sync-core to super-sync-server/package.json deps (it was
load-bearing through the re-export)
- Convert VectorClockComparison from a bare type to an as const object +
derived type in sync-core,drop the app-side enum copy and the as cast
- Update spec imports and pa ckage-boundaries.md

* docs: remove stale shared-schema vector-clock references

- Update comments in vector-clocks.md, client vector-clock.ts, and
  server sync.types.ts to reference @sp/sync-core directly
- Remove @sp/sync-core dependency from shared-schema/package.json
- Regenerate package-lock.json to reflect the removed edge

* docs(sync): fix orphaned VectorClockComparison comment

The comment block describing VectorClockComparison was left dangling
above no declaration after the app-side enum was removed, and still
claimed 'Uses enum for client-side ergonomics'. Move it above the
re-export it documents and correct the wording.

---------

Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
2026-06-17 16:21:26 +02:00
Maikel Hajiabadi
ec16757c82
fix(keyboard): resolve macOS global shortcut layout mismatch (#8378) (#8381)
* fix(keyboard): resolve macOS global shortcut layout mismatch (#8378)

* fix(keyboard-layout): log layout-detection failure and resolve layoutReady with map copy

* fix(keyboard-shortcut): remove debug console logs and add macOS scope comment

* fix(keyboard-shortcut): preserve modifier separator when mapping plus key shortcut

* refactor(keyboard-shortcut): export mapping helpers and avoid as any cast in configuration mapping

* test(keyboard-shortcut): add unit tests for layout shortcut translation logic

* test(keyboard-shortcut): use correct KeyboardConfig type instead of as any in test fixture

* docs(keyboard-shortcut): hoist macOS physical shortcut layout comments to helper JSDoc

* refactor(config): extract global shortcut keys and mapping helpers

* test: add test helper capability for IS_ELECTRON and IS_MAC

* test: add comprehensive integration unit tests for global shortcut effects

* feat(electron): eagerly trigger layout detection on Electron startup for macOS timing fix

* refactor(config): InjectionToken migration, layout detection hardening, and startup optimization

* refactor: address non-blocking suggestions for layout detection and DI tokens

* build(electron): move keyboard-config.model to shared-with-frontend to fix ASAR require

* fix(client-id): increase entropy to 6 chars and fix related test regressions
2026-06-17 12:57:47 +02:00
Johannes Millan
5497212b99 fix(android): inset WebView for IME under enforced edge-to-edge (draft)
Durable follow-up to the #8295 revert. On targetSdk 36 (Android 16) edge-to-edge
is mandatory and adjustResize is a no-op for the IME, so the WebView only stays
above the soft keyboard if @capawesome/capacitor-android-edge-to-edge-support
insets it. The plugin instead zeroes the WebView bottom margin while the keyboard
is visible (assuming the system resized the window), leaving fixed content behind
the IME.

patch-package the plugin's EdgeToEdge.applyInsetsInternal so the WebView bottom
margin is always max(imeInsets.bottom, systemBarsInsets.bottom). The WebView then
shrinks above the keyboard, content resizes, and --keyboard-height stays 0 with
the add-task bar above the IME on the existing JS path. Wire patch-package via a
postinstall script; run 'npm install' to apply (also refreshes the lockfile).

DRAFT: verified the patch applies cleanly, but the gradle build and on-device
behavior are UNVERIFIED here. Needs the Android 10/14/15/16 matrix in
docs/android-edge-to-edge-keyboard.md before merge; upstream so the patch can be
dropped.
2026-06-16 17:00:55 +02:00
Meh-S-Eze
3db96fd8a3
fix(plugins): expose focused task API to iframe plugins (#8291)
Co-authored-by: Shem Freeze <whatamehs@Shems-MacBook-Air.local>
2026-06-15 14:57:34 +02:00
Mohamed Abdeltawab
a4c0dc7d46
refactor: remove dead SyncStateCorruptedError, compat exports, and 0 byte sync-providers barrel #8328 (#8396)
* refactor: remove dead SyncStateCorruptedError, compat exports, and 0-byte sync-providers barrel #8328

* docs(sync): drop stale SyncStateCorruptedError/fail-fast references (#8328)

The fail-fast dependency-resolution subsystem (DependencyResolverService +
SyncStateCorruptedError throw) was removed earlier; OperationApplierService now
bulk-dispatches ops in causal arrival order and returns a failedOp for the caller
to re-validate/retry. Update the sync architecture docs, archive-operations
diagram, and user-data wiki to match. Completes the dead-code cleanup for #8396.

---------

Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
2026-06-15 14:16:14 +02:00
Johannes Millan
0ec8c207a8 feat(theme): add Plainspace built-in theme
A warm paper theme ported from plainspace.org: cream surfaces, a
terracotta primary and a teal accent, in light and dark. Registered in
BUILT_IN_THEMES (requiredMode: system) and listed in the theming docs.

Surfaces/ink primitives drive the semantic tokens; the primary/accent
palette ramps recolor the brand. Dark-mode Category-B tokens (subtasks,
notes, selected rows, schedule events) are re-derived from the warm
surface ladder so nothing leaks the base cold-grey ramp.

Signature paper touches:
- squared graph-paper backdrop (reuses the body::before layer)
- hand-drawn terracotta underlines on headings and collapsible headers
- a hand-drawn rule under each board column header in place of a panel box
- warm soft paper shadows (--card-shadow split per mode) and tighter
  3/6/8px radii

The ambient primary-tinted gradient wash is removed (repurposed to the
grid); remaining gradients are contextual and left untouched.
2026-06-15 14:06:21 +02:00
Johannes Millan
b12f72899f
fix(backup): auto-restore on-device backup on blank mobile launch and harden durability (#7901) (#8388)
* fix(backup): auto-restore on-device backup on blank mobile launch and harden durability (#7901)

Durability follow-ups to #7901 / #7892 (Android total data loss when the
WebView IndexedDB is evicted while the durable on-device backup survives).

- Mobile auto-restore: on a blank/evicted launch (no state cache, but a
  usable on-device backup exists) restore the newest usable backup
  automatically instead of behind a confirm dialog users routinely dismiss.
  Only triggers for a genuinely empty live store (a deliberate in-app delete
  leaves a valid empty state cache and is not auto-restored); falls back to
  the informed prompt for corrupt/data-less blobs. Reports counts via snack.

- KeyValStore.onUpgrade no longer DROPs the table. It holds the durable
  on-device backup (keys backup / backup_prev); a destructive upgrade would
  wipe it the moment DATABASE_VERSION was bumped. Upgrades are now additive
  (CREATE TABLE IF NOT EXISTS), preserving rows.

- Record the last successful local-backup time (after the meaningful-data
  guard) and surface a "Last backup: <date>" line in the mobile Automatic
  Backups settings, so no-sync users can see they're protected; the
  timestamp also rides along in exported logs for eviction diagnosis.

Docs: correct 3.06-User-Data (Android backup is native app-private SQLite,
not WebView IndexedDB) and document the auto-restore + timestamp behavior.

Tests: auto-restore (usable / import-failure / corrupt-fallback / no-backup)
and getLastBackupTime in local-backup.service.spec; config-page spy updated.

https://claude.ai/code/session_01E5YzMGtfMr33qQLjMM2SGA

* fix(backup): gate mobile auto-restore to empty op-log + non-synced backups (#7901)

Hardening from the multi-agent review of the previous commit's mobile
auto-restore. Two gates so silent auto-restore fires only in the
unambiguously safe case (eviction of a local-only user's store):

- Gate A (startup.service): only consider a backup restore when the op-log is
  empty (getLastSeq()===0), not merely when the state-cache snapshot is absent.
  A null cache alone can still have real ops the hydrator is concurrently
  replaying; auto-restoring then was unnecessary and raced the hydrator's
  replay against importCompleteBackup's destructive op-log replacement.

- Gate B (local-backup.service): only silently auto-restore a backup that had
  NO sync configured. Restoring a synced backup resets lastServerSeq and writes
  a clean-slate BACKUP_IMPORT, which can silently drop other devices' concurrent
  work, so a synced backup now goes through the informed confirm prompt instead.

Also corrects the misleading comment (cited a non-existent in-app "delete all
data" flow) and adds tests: backupStrHasSyncEnabled units; auto-restore
data-less / synced / disabled-sync cases; config-page last-backup line
shown/omitted cases.

https://claude.ai/code/session_01E5YzMGtfMr33qQLjMM2SGA

* fix(backup): only advance last-backup time on a real write (#7901)

Review follow-ups to the #7901 durability work:

- _backup() recorded LAST_LOCAL_BACKUP unconditionally after the platform
  writers, so the per-platform A3 near-empty guard (#7925) skipping a write
  still advanced the "Last backup" time — falsely claiming "just backed up"
  on exactly the post-eviction boot the guard protects. Platform writers now
  return whether they actually wrote; the time is recorded only then. Adds a
  spec for the skip case.

- Correct the LAST_LOCAL_BACKUP comment: the value is never passed to Log.*,
  so it does not "ride along in exported logs" — it is only surfaced in
  Settings.

- Document askForFileStoreBackupIfAvailable's blank-store precondition (empty
  op-log) on the method itself, not just inline.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-15 14:05:23 +02:00
Johannes Millan
59e2a1791c
fix(sync): prevent lock-timeout from wedging op capture (#8306, #8318) (#8383)
A LockAcquisitionTimeoutError during op capture errored the whole
persistOperation$ stream: concatMap tore down and silently dropped every
buffered action, the positional capture FIFO leaked an entry so
flushPendingWrites() could never reach 0 (every sync then failed after
30s), and after NgRx's 10-resubscribe cap the effect died until reload.

Fix, bundled with the #8318 cleanup:
- Replace the positional FIFO queue with a pending counter. The
  meta-reducer increments it; the effect decrements it in a `finally`
  (writeOperationFromEffect), so a thrown write can never leak the flush
  signal. The decrement runs after the write commits + lock releases,
  preserving the flush commit-ordering invariant.
- The effect catches per write so one failure never tears down the shared
  stream (the resubscribe-death and silent-drop fixes).
- entityChanges is now computed in the write path via the pure
  extractEntityChanges(); the `[]` field is still emitted (Android reads
  it; isMultiEntityPayload requires it).
- writeOperation keeps its throw for the #7700 deferred retry loop
  (that path bypasses the wrapper and is not counted). This also
  structurally removes the #8307 double-dequeue.

Adds operation-log-effect-stream-survival.regression.spec.ts (stream
survives lock timeout, counter drains on always-fail, survives >10
failures) and updates the capture/flush/integration specs + sync docs.
2026-06-13 16:14:48 +02:00
Johannes Millan
d30fd5f434
fix(sync): conflict-check all entities of multi-entity ops (#8334) (#8377)
* fix(sync): conflict-check all entities of multi-entity ops #8334

Multi-entity ops (deleteTasks, moveToArchive, __updateMultipleTaskSimple,
round-time-spent, batch board/issue-provider actions) carry entityIds[], but
the server operations table only persisted the scalar entity_id (= entityIds[0]).
Once such an op was stored, only its first entity took part in future conflict
lookups, so a later stale write to a non-first entity found no prior writer and
was wrongly accepted instead of rejected as CONFLICT_SUPERSEDED/CONCURRENT.

- Add an entity_ids text[] column (populated for multi-entity ops only via
  getStoredEntityIds; single-entity ops store [] and use the scalar) + a GIN
  index. Migration is metadata-only with no backfill: pre-migration rows fall
  back to entity_id, so the fix is forward-only (entities 2..n of already-stored
  ops were never persisted and stay unrecoverable).
- detectConflictForEntity now runs two ordered LIMIT-1 lookups (scalar btree +
  entity_ids GIN) and takes the higher server_seq, preserving the fast ordered
  hot path instead of an OR's BitmapOr+sort.
- detectConflictForEntities / prefetchLatestEntityOpsForBatch match an entity as
  the scalar entity_id OR a member of entity_ids (unnest CASE + && / = ANY).
- Harden validateOp to bound entityIds (length + per-element), mirroring entityId.

Raw SQL validated against Postgres (PGlite) incl. GIN usage and two-query
correctness; single path + validation + migrations covered by unit tests. The
full conflict-detection.spec needs a generated Prisma client (CI), and the
hot-path round-trip tradeoff should be confirmed with a real-PG EXPLAIN.

* fix(sync): store entity_ids when a batch op dedups off the scalar #8334

Multi-review follow-up. getStoredEntityIds gated on `length > 1`, so a batch op
whose entityIds dedup to a single value that differs from entityId (the server
does not enforce entity_id === entityIds[0]) stored []  and that entity became
invisible to conflict lookups — reintroducing #8334 for it. Gate on "is the set
exactly [entity_id]?" instead, and cover it with unit tests.

Also: correct the array-branch comment/doc — a GIN(entity_ids) lookup has no
server_seq so it match-all-then-sorts (cheap only because multi-entity ops are
rare), it is not an ordered walk; drop a stale "OR filter" test docstring; add a
counter-note on getConflictEntityIds vs getStoredEntityIds to prevent swapping.

* refactor(sync): single OR lookup for entity conflict detection #8334

Third multi-review follow-up. Revert detectConflictForEntity from the two ordered
findFirst lookups back to a single Prisma OR [{entityId}, {entityIds:{has}}]. The
two-query split optimized the OR's BitmapOr+sort, but that is bounded by op-log
pruning (sub-ms in practice) while the split added a guaranteed extra round-trip on
the common single-entity path (doubled by the FIX-1.5 re-check) — a net-negative for
the median. The OR is simpler, fully typed/testable, and likely faster in aggregate;
a code comment documents the split as the escalation if a real-PG EXPLAIN ever shows
the OR is a problem.

Review polish (no behaviour change): correct the array-branch/getStoredEntityIds
comments (a GIN @> is match-all-then-sort, not an ordered walk; multi-entity-only
storage's win is GIN size + keeping single-entity inserts off the GIN, not sort
depth); note the batch unnest paths as the first EXPLAIN candidates under load; keep
the two batch queries' CASE/prefilter SQL inline (a shared fragment would shift the
positional params the conflict-detection.spec mock relies on) with a keep-in-sync
note; replace a non-ASCII <= in a client-facing validation error string.

* test(sync): update db mocks for OR + prefetch entity_ids lookups #8334

Running the full super-sync-server suite (with a generated Prisma client) surfaced
two specs whose inline db mocks hadn't tracked the new conflict-detection SQL:

- time-tracking-operations.spec: findFirst now models the single-entity lookup's
  OR: [{entityId}, {entityIds:{has}}] shape (it previously only matched the scalar
  entityId, so a concurrent single-entity update was wrongly "accepted").
- sync.service.spec: the prefetch $queryRaw mock parsed userId as the last param and
  flattened all params into the touched pairs; the #8334 prefilter adds idArray
  params after userId, so it now finds userId by type and reads the touched pairs
  from the VALUES join fragment only.

Production code unchanged; these are test-mock fidelity fixes. Full suite: 800 passing.
2026-06-13 15:40:10 +02:00
Johannes Millan
5e63eeb294
fix(plugins): stop leaked timers on plugin disable via onUnload hook (#8286)
* feat(sync): show actionable error on persistent WebDAV 409

When a WebDAV PUT keeps returning 409 Conflict even after the parent
collection is created, the Base URL / Sync Folder Path is misconfigured
(the classic Synology / raw-WebDAV setup mistake). Previously the raw
"HTTP 409 Conflict" (or a bare "Unknown error") reached the user with no
hint at the cause.

Throw a dedicated WebDavSyncFolderUnusableSPError with a privacy-safe,
actionable message (no path, no response body) at the spot that already
detects this condition. Mirrors NetworkUnavailableSPError: a fixed
user-facing message matched by instanceof.

* ci(release): revive contributors section in release notes

* fix(plugins): stop leaked timers on plugin disable via onUnload hook

* fix(plugins): harden onUnload teardown hook after review

* refactor(plugins): group lifecycle registers into options object

* fix(plugins): close onReady stale-guard gap and timer interleave race
2026-06-12 17:09:28 +02:00
Miklos
67d1e32ffc
feat(focus-mode): focus screen UX overhaul (#7586)
* feat(focus-mode): focus screen UX overhaul

Major rework of the focus mode timer screens (#7349):

- Shared <focus-clock-face> drives Pomodoro / Flowtime / Countdown /
  Break with a single visual chrome; size tokens scale fluidly via
  clamp() + vmin/vh, no discrete breakpoints.
- Single source of truth: --clock-time-size and --control-offset
  derive from --clock-face-size.
- Countdown: click-to-edit duration, draggable handle on the ring,
  hybrid 5-min/15-min snap with 6h-per-rotation above 1h
  ([useFlexibleIncrement] on input-duration-slider, opt-in for other
  consumers).
- Pomodoro prep inherits the click-to-type input; drag handle hidden
  so dragging the ring can't silently shift the value.
- Pause keeps the selected task on screen (displayedTask falls back
  to the paused task while currentTask is null).
- Notes panel acts as a modal: clock stays put, backdrop dims and
  closes on outside-click.
- Session controls row below the circle (pause / complete / reset
  cycles); buttons fade via shared --revealed-opacity gated on host
  hover + document.hasFocus().
- Break screen mirrors focus-mode-main layout; cycle counter inside
  the circle on both focus and break; back-to-planning unified.
- Flowtime settings dialog: all fields render with proper
  disable/enable, stable dialog width when switching modes.
- arrow_backward -> arrow_back: fixes glitched glyph in repeat-type
  context menus (boards, simple counters, take-a-break, flowtime).

* fix(focus-mode): silence naming-convention lint on formly 'props.disabled'

Formly's expressionProperties path-string keys ('props.disabled') aren't
camelCase and the rule has no requiresQuotes exemption; matches the
existing pattern in src/app/features/issue/common-issue-form-stuff.const.ts.

* test(focus-mode): mock pomodoroConfig signal on FocusModeService

The component's initialization effect now reads
focusModeService.pomodoroConfig() in Pomodoro+Preparation mode, but the
three mocked FocusModeService instances in the spec didn't provide it,
causing TypeError in 47 tests on CI (somehow not surfaced locally).

* test(focus-mode): align specs with unified back-to-planning flow

- focus-mode-break.spec: exitBreakToPlanning -> cancelFocusSession
- focus-mode-session-done.spec: drop obsolete hideFocusOverlay assertion
  (cancelFocusSession now handles both clearing tracking and hiding the
  overlay, matching the production component)
- focus-mode-main.spec: storeSpy gains selectSignal returning a signal,
  needed by the displayedTask paused-task fallback

* fix(focus-mode): restore E2E selector hooks on refactored controls

The shared <focus-clock-face> refactor moved pause/complete buttons out
of the clock face into a new .circle-controls row but didn't carry the
class names forward; 23 E2E specs key off them. Also re-add
.task-title-placeholder on the no-task FAB so prep-state checks find it.

- .pause-resume-btn on pause/resume in focus-mode-main + focus-mode-break
- .complete-session-btn on the done_all button in focus-mode-main
- .task-title-placeholder added to .select-task-cta FAB

* fix(focus-mode): aria-label icon buttons; align break E2Es with new flow

- focus-mode-break: pause/resume/skip/reset icon buttons now carry
  [attr.aria-label] in addition to matTooltip — icon ligature alone
  isn't an accessible name and breaks getByRole locators.
- pomodoro-break-timing-bug-6044.spec: skipButton uses getByRole with
  accessible name rather than hasText (mat-icon ligature is "skip_next",
  not "skip break").
- focus-mode-break.spec: "exit break to planning and change timer mode"
  and "Back to Planning should NOT auto-start next session" now match
  the unified back-to-planning flow — overlay closes on click, user
  re-opens focus mode to change settings or verify prep state.

* fix(focus-mode): address PR #7586 review feedback

Maintainer review (johannesjo):
- Debounce the Pomodoro work-duration write so editing it emits one
  synced config op instead of one per keystroke; flush on session start
  so a value typed inside the debounce window is not lost. (A1)
- Replace the local ::ng-deep restyling of the shared input-duration-slider
  with opt-in [bareRing] and [hideHandle] inputs that own the chrome
  overrides in the slider's own styles; the four other consumers keep the
  default look. (A2)
- Add unit tests for the flexible drag math (_setValueFromRotationFlex):
  the A<->B boundary anchoring at 55/60 min and both +/-180 degree wrap
  branches. (A3)
- Delete the orphaned exitBreakToPlanning action, its
  stopTrackingOnExitBreakToPlanning$ effect, reducer case and specs;
  cancelFocusSession already unsets the current task. (B1)
- Drop the unreferenced CONTINUE_TO_NEXT_SESSION and BREAK_RELAX_MSG
  i18n keys. (B2)
- Collapse the duplicated clock-size clamp() into a single
  --clock-face-size-default token. (B4)

Smaller focus-screen fixes (beerkumquatpome):
- Hide the break task title when "pause tracking during breaks" is on. (C7)
- Commit and close the duration editor on Enter. (C9)
- Rename "Back to Planning" to "Exit focus session". (C11)
- Show Flowtime breaks as a neutral "Break". (C13)

Break-circle vertical alignment (C1) is only partially addressed here
(matched the top reservation); exact alignment is a follow-up.

* refactor(focus-mode): share a layout shell across timer screens and auto-start Flowtime breaks

Extract a presentational focus-mode-layout component (4-row content-projection
skeleton: [fmTop]/[fmTask]/[fmClock]/[fmBottom]) shared by the focus-session and
break screens, so both keep a stable clock baseline across the focus<->break and
prep<->in-progress transitions. focus-mode-main and focus-mode-break now consume
the shell instead of each maintaining their own absolute layout.

Replace the Flowtime "break offer" step with an auto-started break, mirroring
Pomodoro:
- Remove the BreakOffer UI state and the offerFlowtimeBreak action/reducer.
- endFlowtimeSession now dispatches completeFocusSession(isManual:false) +
  startBreak (unsetting the task first when tracking-pause-on-break is on), so
  the break starts automatically and the session is logged exactly once via
  logFocusSession$.

Also reorder the bottom controls (Back to Planning leftmost), restore the
BACK_TO_PLANNING label to "Back to Planning", and drop the now-orphaned
FLOWTIME_BREAK_TITLE / START_BREAK i18n keys.

* test(layout): restore document.activeElement after focus-restoration specs

The LayoutService "Focus restoration" tests override document.activeElement
with Object.defineProperty, which shadows the native (inherited) getter with an
own property on document. The afterEach only removed the mock DOM node, so the
override leaked into later specs: once it ran, document.activeElement was frozen
at the mock element and subsequent .focus() calls could no longer move it.

Depending on Karma's spec order this broke the task.service focusTaskById tests
(#7120), which then saw the stale activeElement instead of the element they
focused. Delete the shadowing own property in afterEach to restore native
behavior.

Repro: ng test --include layout.service.spec.ts --include task.service.spec.ts

* refactor(focus-mode): add interactive tracking widget and polish timer-screen layout

- Replace the read-only task-tracking-info with focus-mode-task-tracking
  (vertical time stack + play/pause), wired through the shared layout shell
- Center the task title and floor the task-row height so the clock baseline
  stays aligned across focus <-> break
- Spacing polish: task-title-row and layout gaps to --s2,
  segmented-button-group padding to 0
- Drop redundant safe-area-bottom padding on the action row (the overlay
  already reserves it for the fixed shell)
- Add "Take a moment to relax" break message and a clock-digit edit affordance

* refactor(focus-mode): share timer/break layout, drop dead tracking toggle

- Extract a shared <focus-mode-layout> skeleton and <focus-mode-task-row> used by both the focus session and the break.

- Remove the in-view tracking play/pause toggle (start/stop stays on the global header button); strip focus-mode-task-tracking to read-only and drop RESUME_TRACKING.

- Pin the mode selector out of flow and center the task·clock·bottom group; equal reserved task/bottom rows keep the clock vertically centered, with the selector kept on top.

- Tighten sizing: horizontal selector segments, settings cog matched to the in-session controls, and a fluid clock clamp.
2026-06-12 11:59:56 +02:00
felix bear
d66958d6a8
feat(search): add completed task filter (#8168)
* feat(search): add completed-task filter #5943

* fix(search): tighten completed filter placement

* test(search): include archived tasks in folder path case #5943

---------

Co-authored-by: cocojojo5213 <cocojojo5213@users.noreply.github.com>
2026-06-10 12:51:07 +02:00
felix bear
9cb9aef948
fix(redmine): support non-latin issue search #4149 (#8150)
Co-authored-by: cocojojo5213 <cocojojo5213@users.noreply.github.com>
2026-06-10 12:50:43 +02:00
Maikel Hajiabadi
d2c989663e
feat(shortcuts): add optional shortcuts for scheduling #8093 (#8189)
* feat(shortcuts): add optional shortcuts for scheduling tasks to tomorrow, next week, and next month #8093

This change adds configurable, unassigned-by-default keyboard shortcuts for scheduling tasks to Tomorrow, Next Week, and Next Month. It also renames the existing 'moveToTodaysTasks' shortcut to 'taskScheduleToday' to align with the new schedule shortcuts.

Closes #8093

* feat: add migration for renamed keyboard shortcut moveToTodaysTasks to taskScheduleToday

* docs: fix corrupted fragment in keyboard shortcuts wiki

* test: add unit tests for task scheduling shortcuts and fix migration typing

* refactor: use getNextWeekDayOffset helper and preserve time/reminders for timed tasks

* docs: clarify Next Month shortcut behavior

* fix(config): ensure keyboard shortcut migration is one-shot by stripping legacy keys

* fix(tasks): preserve reminder offset and show snack when rescheduling timed tasks

* fix(tasks): fix regression in scheduleTask call and add missing snackbar confirmation

* fix(tasks): align scheduling snack with planner effect formatting

Use LocaleDatePipe.shortDate and include getSnackExtraStr() so the
timed-task reschedule snack matches planTaskForDay's snack output.

---------

Co-authored-by: johannesjo <johannes.millan@gmail.com>
2026-06-09 22:01:00 +02:00
Maikel Hajiabadi
c9cb8dddfa
feat(search): include note content in global search and support folde… (#8044)
* feat(search): include note content in global search and support folder path disambiguation

* fix(i18n): revert accidental changes to de.json

* fix(notes): implement robust retry mechanism for focusing notes

* fix(search): improve reactivity for folder path labels and archive cache invalidation

* fix(notes): prevent stale focus retry loops

* test(search): add regression spec for folder path reactivity

* fix(notes): track and cancel stale focusItem retry loops

* test(search): add regression test for archive cache invalidation on folder map changes

* fix(search): improve note highlighting cleanup and remove debug log
2026-06-09 19:18:10 +02:00
felix bear
1b46d1b8f1
fix(header): show focus mode entry on mobile (#8179)
* fix(header): show focus mode entry on mobile #8157

* fix(header): remove unused focus session computed

---------

Co-authored-by: cocojojo5213 <cocojojo5213@users.noreply.github.com>
2026-06-09 18:35:36 +02:00
Maikel Hajiabadi
1765a4f74b
feat(boards): enhance project selection with multi-select and sidebar… (#8069)
* feat(boards): enhance project selection with multi-select logic

- Update BoardPanelCfg to use projectIds array instead of single projectId
- Implement multi-project filtering logic in BoardPanelComponent
- Enhance SelectProjectComponent with connected multi-select checkbox logic
- Add migration logic in sanitizePanelCfg to handle legacy board data
- Update and verify all related unit tests

* fix(boards): enhance panel migration and canonicalize project selection

- Prefer legacy projectId during migration even if projectIds is defaulted
- Canonicalize any projectIds containing "" back to [""] (All Projects)
- Add regression tests for migration and canonicalization

* fix(boards): prevent project assignment for All Projects panels

- Ignore project IDs for additionalTaskFields when "" is present in projectIds
- Add regression tests for multi-project filtering and task assignment

* fix(boards): update board form spec to match projectIds changes

* test(e2e): fix outdated keyboard shortcut and comment in add-to-today test

* fix(boards): translate defaultLabel in SelectProjectComponent

* docs(boards): document lossy canonicalization and legacy preference

* refactor(boards): simplify additionalTaskFields and remove redundant test

* feat(boards): add projectIds helper functions

* refactor(boards): use projectIds helpers across board logic

* fix(boards): keep projectIds optional for legacy data validation

Making `projectIds` a required field broke the typia validator on
raw-data paths that run before the boards reducer's `sanitizePanelCfg`
normalizes the shape. Most critically, the one-time legacy PFAPI ->
op-log migration validates, repairs, then re-validates and THROWS on
failure -- and data-repair.ts has no boards handling -- so every legacy
panel (carrying `projectId`, no `projectIds`) would abort that migration
for existing users.

Keep `projectIds` optional (absent/[''] = "All Projects") so legacy data
validates; `sanitizePanelCfg` still normalizes it to a defined array
before it reaches any component. Helpers and the drop() guard handle the
optional type. Adds a regression spec exercising the real typia validator.

---------

Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
2026-06-09 18:33:09 +02:00
Johannes Millan
7f029b1798
fix(plugins): harden nodeExecution grants (#8205)
* fix(plugins): harden node execution grants

* fix(plugins): harden iframe bridge boundaries (#8208)

* fix(plugins): harden iframe bridge boundaries

* fix(plugins): tighten iframe bridge follow-up

* test(plugins): make node-executor electron test hermetic

The new plugin-node-executor test read the real built-in plugin manifest
(src/assets/bundled-plugins/sync-md/manifest.json), which is a build
artifact absent when 'npm run test:electron' runs in CI (before the
frontend/plugin build). Stub the manifest read, scoped to the executor
module via Module._load, so the grant/token/webContents assertions no
longer depend on built plugin assets.

* fix(plugins): allow iframe formatDate & getCurrentLanguage i18n methods

The iframe API allow-list gate added in #8208 only listed a subset of
the i18n methods that master's #8146 exposes to iframe plugins. Without
this, plugin calls to formatDate/getCurrentLanguage (and translate) are
rejected with 'Unknown API method'. Add all three so the merged gate
matches the methods createBoundMethods/createPluginApiScript expose.

* docs(plugins): document node-exec handoff bootstrap-ordering invariant

The one-shot consumePluginNodeExecutionApi() handoff is defended by
construction ordering, not structural isolation: PluginBridgeService must
consume it before any plugin 'new Function' code runs (both share window.ea
in one renderer realm). Document the invariant at the consumption site so a
future lazy-service/early-plugin-load refactor can't silently regress it.
Surfaced by the post-merge security review (latent finding; not currently
exploitable).

* fix(plugins): use static iframe sandbox attribute to avoid NG0910

Binding a security-sensitive iframe attribute (sandbox) via [attr.sandbox]
makes Angular throw RuntimeError NG0910 and tear down the iframe, crashing
the plugin view to the global error screen. This broke the plugin-iframe,
plugin-loading and plugin-lifecycle e2e tests.

Restore the static sandbox attribute (still without allow-same-origin, so
the opaque-origin isolation from #8208 is preserved) and drop the now-unused
iframeSandbox binding/import.
2026-06-09 18:16:41 +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
felix bear
870d4a4882
docs(backup): document Android automatic restore (#8193)
Co-authored-by: cocojojo5213 <cocojojo5213@users.noreply.github.com>
2026-06-09 11:27:43 +02:00