From 0df62d6beb48a99dc8742aa71467f5e36db1bb2c Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Wed, 20 May 2026 13:28:23 +0200 Subject: [PATCH 01/42] test(tasks): isolate clipboard mock in attachment spec --- .../task-attachment-list.component.spec.ts | 55 ++++++++----------- 1 file changed, 24 insertions(+), 31 deletions(-) diff --git a/src/app/features/tasks/task-attachment/task-attachment-list/task-attachment-list.component.spec.ts b/src/app/features/tasks/task-attachment/task-attachment-list/task-attachment-list.component.spec.ts index 6423681141..825078023b 100644 --- a/src/app/features/tasks/task-attachment/task-attachment-list/task-attachment-list.component.spec.ts +++ b/src/app/features/tasks/task-attachment/task-attachment-list/task-attachment-list.component.spec.ts @@ -42,9 +42,17 @@ describe('TaskAttachmentListComponent', () => { describe('copy', () => { let attachment: TaskAttachment; - let originalClipboard: Clipboard; + let originalClipboardDescriptor: PropertyDescriptor | undefined; let originalExecCommand: typeof document.execCommand; + const setNavigatorClipboard = (clipboard: Partial | undefined): void => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: clipboard, + writable: true, + }); + }; + beforeEach(() => { attachment = { id: 'test-id', @@ -52,18 +60,21 @@ describe('TaskAttachmentListComponent', () => { title: 'Test Link', type: 'LINK', }; - originalClipboard = navigator.clipboard; + originalClipboardDescriptor = Object.getOwnPropertyDescriptor( + navigator, + 'clipboard', + ); originalExecCommand = document.execCommand; }); afterEach(() => { // Restore original implementations - if (originalClipboard) { - Object.defineProperty(navigator, 'clipboard', { - value: originalClipboard, - writable: true, - }); + if (originalClipboardDescriptor) { + Object.defineProperty(navigator, 'clipboard', originalClipboardDescriptor); + } else { + delete (navigator as { clipboard?: Clipboard }).clipboard; } + originalClipboardDescriptor = undefined; document.execCommand = originalExecCommand; }); @@ -82,10 +93,7 @@ describe('TaskAttachmentListComponent', () => { const writeTextSpy = jasmine .createSpy('writeText') .and.returnValue(Promise.resolve()); - Object.defineProperty(navigator, 'clipboard', { - value: { writeText: writeTextSpy }, - writable: true, - }); + setNavigatorClipboard({ writeText: writeTextSpy }); await component.copy(attachment); @@ -94,10 +102,7 @@ describe('TaskAttachmentListComponent', () => { }); it('should use fallback method when clipboard API is not available', async () => { - Object.defineProperty(navigator, 'clipboard', { - value: undefined, - writable: true, - }); + setNavigatorClipboard(undefined); document.execCommand = jasmine.createSpy('execCommand').and.returnValue(true); await component.copy(attachment); @@ -110,10 +115,7 @@ describe('TaskAttachmentListComponent', () => { const writeTextSpy = jasmine .createSpy('writeText') .and.returnValue(Promise.reject(new Error('Permission denied'))); - Object.defineProperty(navigator, 'clipboard', { - value: { writeText: writeTextSpy }, - writable: true, - }); + setNavigatorClipboard({ writeText: writeTextSpy }); document.execCommand = jasmine.createSpy('execCommand').and.returnValue(true); spyOn(console, 'warn'); @@ -126,10 +128,7 @@ describe('TaskAttachmentListComponent', () => { }); it('should show error message when fallback copy fails', async () => { - Object.defineProperty(navigator, 'clipboard', { - value: undefined, - writable: true, - }); + setNavigatorClipboard(undefined); document.execCommand = jasmine.createSpy('execCommand').and.returnValue(false); await component.copy(attachment); @@ -142,10 +141,7 @@ describe('TaskAttachmentListComponent', () => { }); it('should show error message when fallback copy throws error', async () => { - Object.defineProperty(navigator, 'clipboard', { - value: undefined, - writable: true, - }); + setNavigatorClipboard(undefined); document.execCommand = jasmine .createSpy('execCommand') .and.throwError('Command not supported'); @@ -162,10 +158,7 @@ describe('TaskAttachmentListComponent', () => { }); it('should create and remove textarea element for fallback copy', async () => { - Object.defineProperty(navigator, 'clipboard', { - value: undefined, - writable: true, - }); + setNavigatorClipboard(undefined); document.execCommand = jasmine.createSpy('execCommand').and.returnValue(true); const appendChildSpy = spyOn(document.body, 'appendChild').and.callThrough(); From 2c20923cb41794877d5dbfc084a691c89de34a98 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Wed, 20 May 2026 15:29:01 +0200 Subject: [PATCH 02/42] fix(tasks): keep panel open when toggling from another task (#7694) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The focusin auto-switch added in #6578 set selectedTaskId to the clicked task before the toggle button's click handler ran, making isSelected() true and inverting the toggle to close. Skip the auto-switch when focus came from the toggle button itself — the click handler owns that action. --- .../task/task-on-focus-panel-sync.spec.ts | 40 +++++++++++++++++++ src/app/features/tasks/task/task.component.ts | 5 +++ 2 files changed, 45 insertions(+) diff --git a/src/app/features/tasks/task/task-on-focus-panel-sync.spec.ts b/src/app/features/tasks/task/task-on-focus-panel-sync.spec.ts index effe5fd7a3..80633af5ca 100644 --- a/src/app/features/tasks/task/task-on-focus-panel-sync.spec.ts +++ b/src/app/features/tasks/task/task-on-focus-panel-sync.spec.ts @@ -55,6 +55,12 @@ describe('Task onFocus detail panel sync (#6578)', () => { if (isInsideDetailPanel) { return; } + if ( + eventTarget instanceof Element && + eventTarget.closest('.show-additional-info-btn') + ) { + return; + } const selectedTaskId = mockTaskService.selectedTaskId(); if (selectedTaskId && selectedTaskId !== taskId) { mockTaskService.setSelectedId(taskId); @@ -140,6 +146,40 @@ describe('Task onFocus detail panel sync (#6578)', () => { expect(mockTaskFocusService.focusedTaskId()).toBeNull(); }); + describe('toggle-detail-panel button click (#7694)', () => { + // When user clicks the show/hide panel button on Task B while Task A's + // panel is open, focusin must NOT auto-switch the selection — otherwise + // the click handler that follows sees isSelected=true and toggles closed. + const buildTaskWithToggleBtn = (): { + taskEl: HTMLElement; + toggleBtn: HTMLElement; + } => { + const taskEl = document.createElement('task'); + const toggleBtn = document.createElement('button'); + toggleBtn.className = 'show-additional-info-btn'; + taskEl.appendChild(toggleBtn); + return { taskEl, toggleBtn }; + }; + + it('should NOT auto-switch selection when focus came from the toggle-detail-panel button', () => { + const { taskEl, toggleBtn } = buildTaskWithToggleBtn(); + mockTaskService.selectedTaskId.set(TASK_A_ID); + + simulateOnFocus(TASK_B_ID, false, {}, toggleBtn, taskEl); + + expect(mockTaskService.setSelectedId).not.toHaveBeenCalled(); + }); + + it('should still update focusedTaskId even when toggle button is the focus source', () => { + const { taskEl, toggleBtn } = buildTaskWithToggleBtn(); + mockTaskService.selectedTaskId.set(TASK_A_ID); + + simulateOnFocus(TASK_B_ID, false, {}, toggleBtn, taskEl); + + expect(mockTaskFocusService.focusedTaskId()).toBe(TASK_B_ID); + }); + }); + describe('focusin bubbling from nested tasks', () => { // Nested DOM: - @if (mode() === FocusModeMode.Pomodoro && focusModeConfig()?.isManualBreakStart) { - - } @else { - - } + diff --git a/src/app/features/focus-mode/focus-mode-session-done/focus-mode-session-done.component.spec.ts b/src/app/features/focus-mode/focus-mode-session-done/focus-mode-session-done.component.spec.ts index 75d6872522..cf527fe876 100644 --- a/src/app/features/focus-mode/focus-mode-session-done/focus-mode-session-done.component.spec.ts +++ b/src/app/features/focus-mode/focus-mode-session-done/focus-mode-session-done.component.spec.ts @@ -10,9 +10,7 @@ import { hideFocusOverlay, selectFocusTask, selectFocusDuration, - startBreak, } from '../store/focus-mode.actions'; -import { selectCurrentCycle } from '../store/focus-mode.selectors'; import { FocusModeMode } from '../focus-mode.model'; import { T } from '../../../t.const'; import { @@ -20,8 +18,6 @@ import { selectLastCurrentTask, } from '../../tasks/store/task.selectors'; import { selectFocusModeConfig } from '../../config/store/global-config.reducer'; -import { FocusModeStrategyFactory, PomodoroStrategy } from '../focus-mode-strategies'; -import { unsetCurrentTask } from '../../tasks/store/task.actions'; describe('FocusModeSessionDoneComponent', () => { let component: FocusModeSessionDoneComponent; @@ -31,30 +27,11 @@ describe('FocusModeSessionDoneComponent', () => { lastSessionTotalDurationOrTimeElapsedFallback: ReturnType>; }; let mockConfettiService: jasmine.SpyObj; - let mockStrategyFactory: jasmine.SpyObj; - let mockPomodoroStrategy: jasmine.SpyObj; let environmentInjector: EnvironmentInjector; beforeEach(() => { mockStore = jasmine.createSpyObj('Store', ['dispatch', 'select', 'pipe']); mockConfettiService = jasmine.createSpyObj('ConfettiService', ['createConfetti']); - mockPomodoroStrategy = jasmine.createSpyObj( - 'PomodoroStrategy', - ['getBreakDuration'], - { - initialSessionDuration: 25 * 60 * 1000, - shouldStartBreakAfterSession: true, - shouldAutoStartNextSession: true, - }, - ); - mockPomodoroStrategy.getBreakDuration.and.returnValue({ - duration: 5 * 60 * 1000, - isLong: false, - }); - mockStrategyFactory = jasmine.createSpyObj('FocusModeStrategyFactory', [ - 'getStrategy', - ]); - mockStrategyFactory.getStrategy.and.returnValue(mockPomodoroStrategy); mockFocusModeService = { mode: signal(FocusModeMode.Pomodoro), @@ -71,9 +48,6 @@ describe('FocusModeSessionDoneComponent', () => { if (selector === selectFocusModeConfig) { return of({ isManualBreakStart: false, isPauseTrackingDuringBreak: false }); } - if (selector === selectCurrentCycle) { - return of(1); - } return of(null); }); @@ -82,7 +56,6 @@ describe('FocusModeSessionDoneComponent', () => { { provide: Store, useValue: mockStore }, { provide: FocusModeService, useValue: mockFocusModeService }, { provide: ConfettiService, useValue: mockConfettiService }, - { provide: FocusModeStrategyFactory, useValue: mockStrategyFactory }, ], }); @@ -163,76 +136,4 @@ describe('FocusModeSessionDoneComponent', () => { expect(mockStore.dispatch).toHaveBeenCalledWith(selectFocusDuration()); }); }); - - describe('startBreakManually', () => { - it('should get strategy from factory', () => { - component.startBreakManually(); - - expect(mockStrategyFactory.getStrategy).toHaveBeenCalledWith( - FocusModeMode.Pomodoro, - ); - }); - - it('should dispatch startBreak action with correct duration', () => { - component.startBreakManually(); - - expect(mockStore.dispatch).toHaveBeenCalledWith( - startBreak({ - duration: 5 * 60 * 1000, - isLongBreak: false, - pausedTaskId: undefined, - }), - ); - }); - - it('should pause tracking and include pausedTaskId when isPauseTrackingDuringBreak is enabled', () => { - mockStore.select.and.callFake((selector: any) => { - if (selector === selectCurrentTask) { - return of({ id: 'task-1', title: 'Test Task' }); - } - if (selector === selectLastCurrentTask) { - return of({ id: 'task-1', title: 'Last Task' }); - } - if (selector === selectFocusModeConfig) { - return of({ isManualBreakStart: true, isPauseTrackingDuringBreak: true }); - } - if (selector === selectCurrentCycle) { - return of(1); - } - return of(null); - }); - - runInInjectionContext(environmentInjector, () => { - component = new FocusModeSessionDoneComponent(); - }); - - component.startBreakManually(); - - expect(mockStore.dispatch).toHaveBeenCalledWith(unsetCurrentTask()); - expect(mockStore.dispatch).toHaveBeenCalledWith( - startBreak({ - duration: 5 * 60 * 1000, - isLongBreak: false, - pausedTaskId: 'task-1', - }), - ); - }); - - it('should use long break duration when on long break cycle', () => { - mockPomodoroStrategy.getBreakDuration.and.returnValue({ - duration: 15 * 60 * 1000, - isLong: true, - }); - - component.startBreakManually(); - - expect(mockStore.dispatch).toHaveBeenCalledWith( - startBreak({ - duration: 15 * 60 * 1000, - isLongBreak: true, - pausedTaskId: undefined, - }), - ); - }); - }); }); diff --git a/src/app/features/focus-mode/focus-mode-session-done/focus-mode-session-done.component.ts b/src/app/features/focus-mode/focus-mode-session-done/focus-mode-session-done.component.ts index 3f32e2596a..a14a66be38 100644 --- a/src/app/features/focus-mode/focus-mode-session-done/focus-mode-session-done.component.ts +++ b/src/app/features/focus-mode/focus-mode-session-done/focus-mode-session-done.component.ts @@ -12,7 +12,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { ConfettiService } from '../../../core/confetti/confetti.service'; import { MsToStringPipe } from '../../../ui/duration/ms-to-string.pipe'; import { FocusModeService } from '../focus-mode.service'; -import { FocusModeMode, getBreakCycle } from '../focus-mode.model'; +import { FocusModeMode } from '../focus-mode.model'; import { selectCurrentTask, selectLastCurrentTask, @@ -22,12 +22,8 @@ import { hideFocusOverlay, selectFocusTask, selectFocusDuration, - startBreak, } from '../store/focus-mode.actions'; -import { selectCurrentCycle } from '../store/focus-mode.selectors'; import { selectFocusModeConfig } from '../../config/store/global-config.reducer'; -import { FocusModeStrategyFactory } from '../focus-mode-strategies'; -import { unsetCurrentTask } from '../../tasks/store/task.actions'; import { MatIcon } from '@angular/material/icon'; import { TaskTrackingInfoComponent } from '../task-tracking-info/task-tracking-info.component'; @@ -42,13 +38,11 @@ export class FocusModeSessionDoneComponent implements AfterViewInit { private _store = inject(Store); private readonly _confettiService = inject(ConfettiService); private readonly _focusModeService = inject(FocusModeService); - private readonly _strategyFactory = inject(FocusModeStrategyFactory); mode = this._focusModeService.mode; FocusModeMode = FocusModeMode; currentTask = toSignal(this._store.select(selectCurrentTask)); focusModeConfig = toSignal(this._store.select(selectFocusModeConfig)); - currentCycle = toSignal(this._store.select(selectCurrentCycle)); taskTitle = toSignal( this._store.select(selectLastCurrentTask).pipe( switchMap((lastCurrentTask) => @@ -92,30 +86,4 @@ export class FocusModeSessionDoneComponent implements AfterViewInit { continueWithFocusSession(): void { this._store.dispatch(selectFocusDuration()); } - - startBreakManually(): void { - const mode = this.mode(); - const cycle = this.currentCycle() ?? 1; - const currentTaskId = this.currentTask()?.id; - const config = this.focusModeConfig(); - const strategy = this._strategyFactory.getStrategy(mode); - - // Decrease cycle by 1 because break comes after a focus session - const breakInfo = strategy.getBreakDuration(getBreakCycle(cycle)); - if (breakInfo) { - // Pause task tracking during break if enabled - const shouldPauseTracking = config?.isPauseTrackingDuringBreak && currentTaskId; - if (shouldPauseTracking) { - this._store.dispatch(unsetCurrentTask()); - } - - this._store.dispatch( - startBreak({ - duration: breakInfo.duration, - isLongBreak: breakInfo.isLong, - pausedTaskId: shouldPauseTracking ? currentTaskId : undefined, - }), - ); - } - } } diff --git a/src/app/features/focus-mode/store/focus-mode.effects.spec.ts b/src/app/features/focus-mode/store/focus-mode.effects.spec.ts index 1acbd35833..09fd31322f 100644 --- a/src/app/features/focus-mode/store/focus-mode.effects.spec.ts +++ b/src/app/features/focus-mode/store/focus-mode.effects.spec.ts @@ -403,20 +403,57 @@ describe('FocusModeEffects', () => { }); }); - it('should NOT dispatch startBreak when isManualBreakStart is enabled', (done) => { + it('should dispatch both unsetCurrentTask and startBreak with pausedTaskId when isPauseTrackingDuringBreak is true and task is active', (done) => { actions$ = of(actions.incrementCycle()); store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro); store.overrideSelector(selectors.selectCurrentCycle, 1); store.overrideSelector(selectFocusModeConfig, { isSkipPreparation: false, isManualBreakStart: true, + isPauseTrackingDuringBreak: true, }); + currentTaskId$.next('task-123'); store.refreshState(); effects.autoStartBreakOnSessionComplete$ .pipe(toArray()) .subscribe((actionsArr) => { - expect(actionsArr.length).toBe(0); + expect(actionsArr.length).toBe(2); + expect(actionsArr[0]).toEqual(unsetCurrentTask()); + expect(actionsArr[1]).toEqual( + actions.startBreak({ + duration: 5 * 60 * 1000, + isLongBreak: false, + pausedTaskId: 'task-123', + }), + ); + done(); + }); + }); + + it('should NOT dispatch unsetCurrentTask and should dispatch startBreak with pausedTaskId undefined when isPauseTrackingDuringBreak is false and task is active', (done) => { + actions$ = of(actions.incrementCycle()); + store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro); + store.overrideSelector(selectors.selectCurrentCycle, 1); + store.overrideSelector(selectFocusModeConfig, { + isSkipPreparation: false, + isManualBreakStart: true, + isPauseTrackingDuringBreak: false, + }); + currentTaskId$.next('task-123'); + store.refreshState(); + + effects.autoStartBreakOnSessionComplete$ + .pipe(toArray()) + .subscribe((actionsArr) => { + expect(actionsArr.length).toBe(1); + expect(actionsArr[0]).toEqual( + actions.startBreak({ + duration: 5 * 60 * 1000, + isLongBreak: false, + pausedTaskId: undefined, + }), + ); done(); }); }); @@ -2360,111 +2397,6 @@ describe('FocusModeEffects', () => { }); }); - describe('storePausedTaskOnManualBreakSession$ (Bug #5954)', () => { - it('should dispatch setPausedTaskId when session completes with manual break start and pause tracking enabled', (done) => { - actions$ = of(actions.completeFocusSession({ isManual: false })); - store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro); - store.overrideSelector(selectFocusModeConfig, { - isSkipPreparation: false, - isManualBreakStart: true, - isPauseTrackingDuringBreak: true, - }); - currentTaskId$.next('task-123'); - store.refreshState(); - - effects.storePausedTaskOnManualBreakSession$.pipe(take(1)).subscribe((action) => { - expect(action.type).toBe('[FocusMode] Set Paused Task Id'); - expect((action as any).pausedTaskId).toBe('task-123'); - done(); - }); - }); - - it('should NOT dispatch setPausedTaskId when isManualBreakStart is false', (done) => { - actions$ = of(actions.completeFocusSession({ isManual: false })); - store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro); - store.overrideSelector(selectFocusModeConfig, { - isSkipPreparation: false, - isManualBreakStart: false, // Not manual break - isPauseTrackingDuringBreak: true, - }); - currentTaskId$.next('task-123'); - store.refreshState(); - - effects.storePausedTaskOnManualBreakSession$ - .pipe(toArray()) - .subscribe((actionsArr) => { - expect(actionsArr.length).toBe(0); - done(); - }); - }); - - // Bug #5974 fix: Store pausedTaskId even when isPauseTrackingDuringBreak is false - // This allows tracking to resume if user manually stops tracking before starting break - it('should dispatch setPausedTaskId when isPauseTrackingDuringBreak is false', (done) => { - actions$ = of(actions.completeFocusSession({ isManual: false })); - store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro); - store.overrideSelector(selectFocusModeConfig, { - isSkipPreparation: false, - isManualBreakStart: true, - isPauseTrackingDuringBreak: false, // Don't pause tracking - }); - currentTaskId$.next('task-123'); - store.refreshState(); - - effects.storePausedTaskOnManualBreakSession$.pipe(take(1)).subscribe((action) => { - expect(action.type).toEqual(actions.setPausedTaskId.type); - expect(action.pausedTaskId).toBe('task-123'); - done(); - }); - }); - - it('should NOT dispatch setPausedTaskId when no current task', (done) => { - actions$ = of(actions.completeFocusSession({ isManual: false })); - store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro); - store.overrideSelector(selectFocusModeConfig, { - isSkipPreparation: false, - isManualBreakStart: true, - isPauseTrackingDuringBreak: true, - }); - currentTaskId$.next(null); // No current task - store.refreshState(); - - effects.storePausedTaskOnManualBreakSession$ - .pipe(toArray()) - .subscribe((actionsArr) => { - expect(actionsArr.length).toBe(0); - done(); - }); - }); - - it('should NOT dispatch setPausedTaskId for Flowtime mode (no breaks)', (done) => { - // For Flowtime, shouldStartBreakAfterSession is false - strategyFactoryMock.getStrategy.and.returnValue({ - initialSessionDuration: 0, - shouldStartBreakAfterSession: false, - shouldAutoStartNextSession: false, - getBreakDuration: () => null, - }); - - actions$ = of(actions.completeFocusSession({ isManual: false })); - store.overrideSelector(selectors.selectMode, FocusModeMode.Flowtime); - store.overrideSelector(selectFocusModeConfig, { - isSkipPreparation: false, - isManualBreakStart: true, - isPauseTrackingDuringBreak: true, - }); - currentTaskId$.next('task-123'); - store.refreshState(); - - effects.storePausedTaskOnManualBreakSession$ - .pipe(toArray()) - .subscribe((actionsArr) => { - expect(actionsArr.length).toBe(0); - done(); - }); - }); - }); - describe('Bug #5954 Additional Edge Cases', () => { describe('syncSessionStartToTracking$ edge cases', () => { it('should prefer pausedTaskId over lastCurrentTask when both exist', (done) => { @@ -2615,43 +2547,5 @@ describe('FocusModeEffects', () => { }); }); }); - - describe('storePausedTaskOnManualBreakSession$ edge cases', () => { - it('should store pausedTaskId correctly for later resumption', (done) => { - actions$ = of(actions.completeFocusSession({ isManual: false })); - store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro); - store.overrideSelector(selectFocusModeConfig, { - isManualBreakStart: true, - isPauseTrackingDuringBreak: true, - isSkipPreparation: false, - }); - currentTaskId$.next('important-task-123'); - store.refreshState(); - - effects.storePausedTaskOnManualBreakSession$.pipe(take(1)).subscribe((action) => { - expect(action.type).toEqual('[FocusMode] Set Paused Task Id'); - expect((action as any).pausedTaskId).toBe('important-task-123'); - done(); - }); - }); - - it('should work with sync disabled but manual break start and pause tracking enabled', (done) => { - actions$ = of(actions.completeFocusSession({ isManual: false })); - store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro); - store.overrideSelector(selectFocusModeConfig, { - isManualBreakStart: true, - isPauseTrackingDuringBreak: true, - isSkipPreparation: false, - }); - currentTaskId$.next('task-123'); - store.refreshState(); - - // Should still dispatch since it only checks isManualBreakStart and isPauseTrackingDuringBreak - effects.storePausedTaskOnManualBreakSession$.pipe(take(1)).subscribe((action) => { - expect(action.type).toEqual('[FocusMode] Set Paused Task Id'); - done(); - }); - }); - }); }); }); diff --git a/src/app/features/focus-mode/store/focus-mode.effects.ts b/src/app/features/focus-mode/store/focus-mode.effects.ts index ba6abc92a0..71b9832ad1 100644 --- a/src/app/features/focus-mode/store/focus-mode.effects.ts +++ b/src/app/features/focus-mode/store/focus-mode.effects.ts @@ -338,9 +338,7 @@ export class FocusModeEffects { return true; } // Bug #6510 fix: For automatic completion, only stop tracking if no break will start. - // When a break will start (auto or manual), tracking pause is deferred to break-start: - // - Auto: autoStartBreakOnSessionComplete$ - // - Manual: FocusModeService.startAfterSessionComplete() + // When a break will start, tracking pause is deferred to break-start (autoStartBreakOnSessionComplete$). const strategy = this.strategyFactory.getStrategy(mode); const breakWillStart = strategy.shouldStartBreakAfterSession; return !breakWillStart; @@ -369,7 +367,7 @@ export class FocusModeEffects { // Only for Pomodoro mode (since only Pomodoro increments cycles) if (mode !== FocusModeMode.Pomodoro) return false; const strategy = this.strategyFactory.getStrategy(mode); - return strategy.shouldStartBreakAfterSession && !config?.isManualBreakStart; + return strategy.shouldStartBreakAfterSession; }), switchMap(([_, mode, cycle, config, currentTaskId]) => { const strategy = this.strategyFactory.getStrategy(mode); @@ -489,37 +487,6 @@ export class FocusModeEffects { { dispatch: false }, ); - // Effect 5: Store pausedTaskId when session completes with manual break start - // Bug #5954 fix: Ensures task can be resumed when break is skipped/completed - // Bug #5974 fix: Store pausedTaskId regardless of isPauseTrackingDuringBreak setting - // This allows tracking to resume when user manually stops tracking before starting break - storePausedTaskOnManualBreakSession$ = createEffect(() => - this.actions$.pipe( - ofType(actions.completeFocusSession), - withLatestFrom( - this.store.select(selectors.selectMode), - this.store.select(selectFocusModeConfig), - this.taskService.currentTaskId$, - ), - filter(([_, mode, config, currentTaskId]) => { - const strategy = this.strategyFactory.getStrategy(mode); - // Store pausedTaskId when manual break is enabled and there's a current task - // Note: We store regardless of isPauseTrackingDuringBreak because: - // - If isPauseTrackingDuringBreak=true: pausedTaskId is used to resume after break - // - If isPauseTrackingDuringBreak=false: pausedTaskId is used to resume if user - // manually stopped tracking before starting the break (bug #5974) - return ( - strategy.shouldStartBreakAfterSession && - !!config?.isManualBreakStart && - !!currentTaskId - ); - }), - map(([_, _mode, _config, currentTaskId]) => - actions.setPausedTaskId({ pausedTaskId: currentTaskId }), - ), - ), - ); - // Break completion effects - split into separate concerns for better maintainability // Note: pausedTaskId is passed in action payload to avoid race condition // (reducer clears pausedTaskId before effect reads state) diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 4ed1d00739..060b5da8c1 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -2192,7 +2192,7 @@ "HELP": "Focus mode opens a distraction-free screen to help you focus on your current task. Time tracking and the focus session timer are always kept in sync — pausing one pauses the other.", "L_AUTO_START_FOCUS_ON_PLAY": "Start a focus session when I start tracking a task", "L_FOCUS_MODE_SOUND": "Ambient sound during focus sessions", - "L_MANUAL_BREAK_START": "Manually start breaks (Pomodoro)", + "L_MANUAL_BREAK_START": "Enable Pomodoro overtime (keep timer running until manually ended)", "L_PAUSE_TRACKING_DURING_BREAK": "Pause task tracking during breaks", "L_SKIP_PREPARATION_SCREEN": "Skip preparation screen (rocket animation)", "TITLE": "Focus Mode" From 621f7b1a2123d32d8a9efe27f1e10f6bacb76dea Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Wed, 20 May 2026 15:51:41 +0200 Subject: [PATCH 04/42] fix(focus-mode): prevent icon shift when countdown badge appears The countdown label shared a grid cell with the icon anchor and its 56px min-width widened the wrapper, shifting the centered icon ~8px right when a session started. Pull the label out of flow with position: absolute so the wrapper width stays at --header-button-size. Closes #7539 --- .../focus-button/focus-button.component.scss | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/app/core-ui/main-header/focus-button/focus-button.component.scss b/src/app/core-ui/main-header/focus-button/focus-button.component.scss index 3441d5ab9e..dbc49e0ac8 100644 --- a/src/app/core-ui/main-header/focus-button/focus-button.component.scss +++ b/src/app/core-ui/main-header/focus-button/focus-button.component.scss @@ -5,13 +5,14 @@ } .focus-btn-wrapper { - display: inline-grid; - place-items: center; + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; height: 100%; } .focus-btn-anchor { - grid-area: 1 / 1; position: relative; display: inline-flex; align-items: center; @@ -22,8 +23,11 @@ } .focus-label { - grid-area: 1 / 1; - align-self: end; + // Absolute so the wrapper width stays at --header-button-size and the icon + // never shifts when a session starts/stops. + position: absolute; + left: 50%; + bottom: 0; box-sizing: border-box; min-width: var(--s7); padding: var(--s-quarter) var(--s-half); @@ -37,7 +41,7 @@ border-radius: var(--s); background: var(--bg-lighter); color: var(--text-color-most-intense); - transform: translateY(var(--s-quarter)); + transform: translate(-50%, var(--s-quarter)); z-index: 4; } From ea3626cde8e2698265cc6933d1a3756a30d65e7d Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Wed, 20 May 2026 15:54:01 +0200 Subject: [PATCH 05/42] refactor(focus-mode): use shared number-badge for countdown label Replaces the bespoke label styles with the same number-badge mixin the simple-counter buttons use, so the badge width tracks the content ("#3 36:45" vs "36:45") instead of being pinned to a 56px min-width. --- .../focus-button/focus-button.component.scss | 21 +++++-------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/src/app/core-ui/main-header/focus-button/focus-button.component.scss b/src/app/core-ui/main-header/focus-button/focus-button.component.scss index dbc49e0ac8..ad196ffcb0 100644 --- a/src/app/core-ui/main-header/focus-button/focus-button.component.scss +++ b/src/app/core-ui/main-header/focus-button/focus-button.component.scss @@ -1,3 +1,5 @@ +@use '../../../../styles/components/number-badge' as badge; + :host { display: inline-flex; align-items: center; @@ -23,26 +25,13 @@ } .focus-label { - // Absolute so the wrapper width stays at --header-button-size and the icon + // Reuses the shared simple-counter badge so width grows with content + // (no min-width). Wrapper stays at --header-button-size so the icon // never shifts when a session starts/stops. - position: absolute; - left: 50%; - bottom: 0; - box-sizing: border-box; - min-width: var(--s7); - padding: var(--s-quarter) var(--s-half); - line-height: 1; - font-size: var(--font-size-sm); - font-weight: var(--font-weight-bold); + @include badge.number-badge; font-variant-numeric: tabular-nums; text-align: center; white-space: nowrap; - pointer-events: none; - border-radius: var(--s); - background: var(--bg-lighter); - color: var(--text-color-most-intense); - transform: translate(-50%, var(--s-quarter)); - z-index: 4; } .focus-cycle { From 6aa28711d173f5624f4595ea02b26f6662cc298a Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Wed, 20 May 2026 15:29:01 +0200 Subject: [PATCH 06/42] fix(tasks): keep panel open when toggling from another task (#7694) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The focusin auto-switch added in #6578 set selectedTaskId to the clicked task before the toggle button's click handler ran, making isSelected() true and inverting the toggle to close. Skip the auto-switch when focus came from the toggle button itself — the click handler owns that action. --- .../task/task-on-focus-panel-sync.spec.ts | 40 +++++++++++++++++++ src/app/features/tasks/task/task.component.ts | 5 +++ 2 files changed, 45 insertions(+) diff --git a/src/app/features/tasks/task/task-on-focus-panel-sync.spec.ts b/src/app/features/tasks/task/task-on-focus-panel-sync.spec.ts index effe5fd7a3..80633af5ca 100644 --- a/src/app/features/tasks/task/task-on-focus-panel-sync.spec.ts +++ b/src/app/features/tasks/task/task-on-focus-panel-sync.spec.ts @@ -55,6 +55,12 @@ describe('Task onFocus detail panel sync (#6578)', () => { if (isInsideDetailPanel) { return; } + if ( + eventTarget instanceof Element && + eventTarget.closest('.show-additional-info-btn') + ) { + return; + } const selectedTaskId = mockTaskService.selectedTaskId(); if (selectedTaskId && selectedTaskId !== taskId) { mockTaskService.setSelectedId(taskId); @@ -140,6 +146,40 @@ describe('Task onFocus detail panel sync (#6578)', () => { expect(mockTaskFocusService.focusedTaskId()).toBeNull(); }); + describe('toggle-detail-panel button click (#7694)', () => { + // When user clicks the show/hide panel button on Task B while Task A's + // panel is open, focusin must NOT auto-switch the selection — otherwise + // the click handler that follows sees isSelected=true and toggles closed. + const buildTaskWithToggleBtn = (): { + taskEl: HTMLElement; + toggleBtn: HTMLElement; + } => { + const taskEl = document.createElement('task'); + const toggleBtn = document.createElement('button'); + toggleBtn.className = 'show-additional-info-btn'; + taskEl.appendChild(toggleBtn); + return { taskEl, toggleBtn }; + }; + + it('should NOT auto-switch selection when focus came from the toggle-detail-panel button', () => { + const { taskEl, toggleBtn } = buildTaskWithToggleBtn(); + mockTaskService.selectedTaskId.set(TASK_A_ID); + + simulateOnFocus(TASK_B_ID, false, {}, toggleBtn, taskEl); + + expect(mockTaskService.setSelectedId).not.toHaveBeenCalled(); + }); + + it('should still update focusedTaskId even when toggle button is the focus source', () => { + const { taskEl, toggleBtn } = buildTaskWithToggleBtn(); + mockTaskService.selectedTaskId.set(TASK_A_ID); + + simulateOnFocus(TASK_B_ID, false, {}, toggleBtn, taskEl); + + expect(mockTaskFocusService.focusedTaskId()).toBe(TASK_B_ID); + }); + }); + describe('focusin bubbling from nested tasks', () => { // Nested DOM: } From 278821f2ba47656102adb0bfcbfbb02525770c24 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Thu, 21 May 2026 13:00:40 +0200 Subject: [PATCH 23/42] fix(note): restore remove_today svg for unpin-from-today Material font replacements (timer_off, event_busy) don't convey remove-from-today as clearly as the original custom glyph (sun with a diagonal strike). Restore the SVG asset and its registration so the note pin toggle uses the icon it was designed around. --- src/app/core/theme/global-theme.service.ts | 1 + src/app/features/note/note/note.component.html | 2 +- src/assets/icons/remove-today-48px.svg | 10 ++++++++++ 3 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 src/assets/icons/remove-today-48px.svg diff --git a/src/app/core/theme/global-theme.service.ts b/src/app/core/theme/global-theme.service.ts index 2e70a37afe..b4c50469ba 100644 --- a/src/app/core/theme/global-theme.service.ts +++ b/src/app/core/theme/global-theme.service.ts @@ -194,6 +194,7 @@ export class GlobalThemeService { ['caldav', 'assets/icons/caldav.svg'], ['calendar', 'assets/icons/calendar.svg'], ['open_project', 'assets/icons/open-project.svg'], + ['remove_today', 'assets/icons/remove-today-48px.svg'], ['gitea', 'assets/icons/gitea.svg'], ['redmine', 'assets/icons/redmine.svg'], ['linear', 'assets/icons/linear.svg'], diff --git a/src/app/features/note/note/note.component.html b/src/app/features/note/note/note.component.html index 17a7602da8..3d18acb235 100644 --- a/src/app/features/note/note/note.component.html +++ b/src/app/features/note/note/note.component.html @@ -45,7 +45,7 @@ wb_sunny } @if (note.isPinnedToToday) { - event_busy + } } diff --git a/src/assets/icons/remove-today-48px.svg b/src/assets/icons/remove-today-48px.svg new file mode 100644 index 0000000000..19b19301c0 --- /dev/null +++ b/src/assets/icons/remove-today-48px.svg @@ -0,0 +1,10 @@ + + + + From 388538b5213d10f99972f61d8fb14b07e2fdc721 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Thu, 21 May 2026 13:11:40 +0200 Subject: [PATCH 24/42] fix(focusMode): complete session when duration reaches 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decrementing the focus timer to 0 (or starting a non-Flowtime session with no duration) left the timer ticking and never reached the SessionDone screen. The tick reducer and the detectSessionCompletion effect both gated completion on `duration > 0` — intended as a Flowtime carve-out, but it also disabled completion for any Pomodoro/Countdown session whose duration was 0. Narrow the exception to Flowtime *work* sessions so Flowtime breaks (which run with a real positive duration) still auto-complete. Closes #7707 --- .../store/focus-mode.bug-7707.spec.ts | 131 ++++++++++++++++++ .../store/focus-mode.effects.spec.ts | 26 ++++ .../focus-mode/store/focus-mode.effects.ts | 1 - .../store/focus-mode.reducer.spec.ts | 1 + .../focus-mode/store/focus-mode.reducer.ts | 8 +- 5 files changed, 164 insertions(+), 3 deletions(-) create mode 100644 src/app/features/focus-mode/store/focus-mode.bug-7707.spec.ts diff --git a/src/app/features/focus-mode/store/focus-mode.bug-7707.spec.ts b/src/app/features/focus-mode/store/focus-mode.bug-7707.spec.ts new file mode 100644 index 0000000000..34b6b75f6f --- /dev/null +++ b/src/app/features/focus-mode/store/focus-mode.bug-7707.spec.ts @@ -0,0 +1,131 @@ +/** + * Reducer test for GitHub issue #7707 + * https://github.com/super-productivity/super-productivity/issues/7707 + * + * Bug: In focus mode, pressing "-" to decrease the timer down to 0 leaves the + * timer ticking instead of completing. Same outcome when starting a non-Flowtime + * session with duration 0. + * + * Root cause: the `tick` reducer guarded completion with `duration > 0`, which + * was intended to keep Flowtime running forever but also silently disabled + * completion for any Pomodoro/Countdown session whose duration had been + * adjusted (or started at) 0. The fix narrows that exception to Flowtime + * *work* sessions, so Flowtime breaks (which run with a real positive duration) + * still auto-complete. + */ + +import { focusModeReducer, initialState } from './focus-mode.reducer'; +import * as a from './focus-mode.actions'; +import { FocusModeMode } from '../focus-mode.model'; + +describe('FocusMode Bug #7707: timer keeps ticking after duration adjusted to 0', () => { + beforeEach(() => { + jasmine.clock().install(); + jasmine.clock().mockDate(new Date(2023, 0, 1, 10, 0, 0)); + localStorage.clear(); + }); + + afterEach(() => { + jasmine.clock().uninstall(); + }); + + it('should complete the work session when "-" drives duration to 0 mid-session', () => { + // Session started 1 min ago; user has been focusing for that minute. + const startedAt = Date.now() - 60_000; + const state = { + ...initialState, + mode: FocusModeMode.Pomodoro, + timer: { + isRunning: true, + startedAt, + elapsed: 60_000, + duration: 120_000, // 2 min session, 1 min remaining + purpose: 'work' as const, + }, + }; + + // User repeatedly presses "-" until duration is clamped to 0. + const afterAdjust = focusModeReducer( + state, + a.adjustRemainingTime({ amountMs: -120_000 }), + ); + expect(afterAdjust.timer.duration).toBe(0); + expect(afterAdjust.timer.isRunning).toBe(true); + + // One tick later: timer completes and reports the real elapsed work time. + jasmine.clock().tick(1000); + const afterTick = focusModeReducer(afterAdjust, a.tick()); + + expect(afterTick.timer.isRunning).toBe(false); + expect(afterTick.timer.elapsed).toBe(61_000); + expect(afterTick.lastCompletedDuration).toBe(61_000); + }); + + it('should complete a Countdown session started with duration 0', () => { + const startedAt = Date.now(); + const state = { + ...initialState, + mode: FocusModeMode.Countdown, + timer: { + isRunning: true, + startedAt, + elapsed: 0, + duration: 0, + purpose: 'work' as const, + }, + }; + + jasmine.clock().tick(1000); + const result = focusModeReducer(state, a.tick()); + + expect(result.timer.isRunning).toBe(false); + expect(result.lastCompletedDuration).toBe(1000); + }); + + it('should still run forever in Flowtime work session', () => { + const startedAt = Date.now(); + const state = { + ...initialState, + mode: FocusModeMode.Flowtime, + timer: { + isRunning: true, + startedAt, + elapsed: 0, + duration: 0, + purpose: 'work' as const, + }, + }; + + jasmine.clock().tick(3_600_000); // 1h + const result = focusModeReducer(state, a.tick()); + + expect(result.timer.isRunning).toBe(true); + expect(result.timer.elapsed).toBe(3_600_000); + }); + + it('should still auto-complete a Flowtime break when its duration elapses', () => { + // Flowtime breaks are started via offerFlowtimeBreak → startBreak with a + // real positive duration. They must auto-stop on tick even though + // state.mode === Flowtime. + const breakDuration = 5 * 60 * 1000; // 5 min + const startedAt = Date.now() - breakDuration; + const state = { + ...initialState, + mode: FocusModeMode.Flowtime, + timer: { + isRunning: true, + startedAt, + elapsed: breakDuration, + duration: breakDuration, + purpose: 'break' as const, + isLongBreak: false, + }, + }; + + jasmine.clock().tick(1000); + const result = focusModeReducer(state, a.tick()); + + expect(result.timer.isRunning).toBe(false); + expect(result.timer.purpose).toBe('break'); + }); +}); diff --git a/src/app/features/focus-mode/store/focus-mode.effects.spec.ts b/src/app/features/focus-mode/store/focus-mode.effects.spec.ts index 09fd31322f..8f21c50431 100644 --- a/src/app/features/focus-mode/store/focus-mode.effects.spec.ts +++ b/src/app/features/focus-mode/store/focus-mode.effects.spec.ts @@ -2256,6 +2256,32 @@ describe('FocusModeEffects', () => { }, 50); }); + // Bug #7707: dragging the timer down to 0 (or starting a non-Flowtime session + // with duration 0) must still reach the SessionDone screen. The reducer flips + // isRunning to false on the next tick; this effect must then dispatch + // completeFocusSession even though duration is 0. + it('should dispatch completeFocusSession when duration is 0 (Bug #7707)', (done) => { + store.overrideSelector( + selectors.selectTimer, + createMockTimer({ + isRunning: false, + purpose: 'work', + duration: 0, + elapsed: 60 * 1000, // user focused for 1 min, then dragged duration to 0 + }), + ); + store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro); + store.overrideSelector(selectors.selectIsOvertimeEnabled, false); + store.refreshState(); + + effects = TestBed.inject(FocusModeEffects); + + effects.detectSessionCompletion$.pipe(take(1)).subscribe((action) => { + expect(action).toEqual(actions.completeFocusSession({ isManual: false })); + done(); + }); + }); + // Bug #6206 updated: with overtime, isManualBreakStart=true causes the timer to keep // running (via _isOvertimeEnabled). The user completes the session manually. // When _isOvertimeEnabled is false (non-manual-break sessions), auto-completion still works. diff --git a/src/app/features/focus-mode/store/focus-mode.effects.ts b/src/app/features/focus-mode/store/focus-mode.effects.ts index 71b9832ad1..5589cf4332 100644 --- a/src/app/features/focus-mode/store/focus-mode.effects.ts +++ b/src/app/features/focus-mode/store/focus-mode.effects.ts @@ -268,7 +268,6 @@ export class FocusModeEffects { ([timer, mode, _isOvertimeEnabled]) => timer.purpose === 'work' && !timer.isRunning && - timer.duration > 0 && timer.elapsed >= timer.duration && mode !== FocusModeMode.Flowtime && !_isOvertimeEnabled, diff --git a/src/app/features/focus-mode/store/focus-mode.reducer.spec.ts b/src/app/features/focus-mode/store/focus-mode.reducer.spec.ts index c14a52b323..a72cb48252 100644 --- a/src/app/features/focus-mode/store/focus-mode.reducer.spec.ts +++ b/src/app/features/focus-mode/store/focus-mode.reducer.spec.ts @@ -672,6 +672,7 @@ describe('FocusModeReducer', () => { const startTime = Date.now(); const flowtimeState = { ...initialState, + mode: FocusModeMode.Flowtime, timer: { isRunning: true, startedAt: startTime, diff --git a/src/app/features/focus-mode/store/focus-mode.reducer.ts b/src/app/features/focus-mode/store/focus-mode.reducer.ts index 1de941cfc1..6bbf0cad67 100644 --- a/src/app/features/focus-mode/store/focus-mode.reducer.ts +++ b/src/app/features/focus-mode/store/focus-mode.reducer.ts @@ -280,9 +280,13 @@ export const focusModeReducer = createReducer( const updatedTimer = updateTimer(state.timer); - // Check if timer completed - mark for completion but let effects handle the flow - if (updatedTimer.duration > 0 && updatedTimer.elapsed >= updatedTimer.duration) { + // Check if timer completed - mark for completion but let effects handle the flow. + if (updatedTimer.elapsed >= updatedTimer.duration) { if (updatedTimer.purpose === 'work') { + // Flowtime work sessions have no fixed duration and never auto-complete via tick. + if (state.mode === FocusModeMode.Flowtime) { + return { ...state, timer: updatedTimer }; + } // When overtime is enabled, keep the timer running past duration if (state._isOvertimeEnabled) { return { ...state, timer: updatedTimer }; From 329e2d298c8c70754d82e3d9b0b9a67cd2a4ac7b Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Thu, 21 May 2026 22:55:51 +0200 Subject: [PATCH 25/42] fix(focusMode): notify on zero-duration break completion (#7721) The tick reducer's recent #7707 fix dropped the duration > 0 guard so non-Flowtime sessions with duration adjusted to 0 still auto-complete. detectBreakTimeUp$ kept the duration > 0 filter, so a break that lands at duration === 0 silently flips isRunning to false with no user-facing notification. Drop the effect's guard to keep reducer and effect symmetric; add a regression test. --- .../store/focus-mode.effects.spec.ts | 24 +++++++++++++++++++ .../focus-mode/store/focus-mode.effects.ts | 1 - 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/app/features/focus-mode/store/focus-mode.effects.spec.ts b/src/app/features/focus-mode/store/focus-mode.effects.spec.ts index 8f21c50431..a2041ec906 100644 --- a/src/app/features/focus-mode/store/focus-mode.effects.spec.ts +++ b/src/app/features/focus-mode/store/focus-mode.effects.spec.ts @@ -2421,6 +2421,30 @@ describe('FocusModeEffects', () => { done(); }, 50); }); + + // Regression: reducer drops the `duration > 0` completion guard, so a + // break with duration=0 stops immediately. The effect must match — without + // this, the break ends silently with no notification. + it('should notify when a duration=0 break stops', (done) => { + store.overrideSelector( + selectors.selectTimer, + createMockTimer({ + isRunning: false, + purpose: 'break', + duration: 0, + elapsed: 0, + }), + ); + store.refreshState(); + + effects = TestBed.inject(FocusModeEffects); + const notifyUserSpy = spyOn(effects as any, '_notifyUser'); + + effects.detectBreakTimeUp$.pipe(take(1)).subscribe(() => { + expect(notifyUserSpy).toHaveBeenCalled(); + done(); + }); + }); }); describe('Bug #5954 Additional Edge Cases', () => { diff --git a/src/app/features/focus-mode/store/focus-mode.effects.ts b/src/app/features/focus-mode/store/focus-mode.effects.ts index 5589cf4332..7804b0bfd3 100644 --- a/src/app/features/focus-mode/store/focus-mode.effects.ts +++ b/src/app/features/focus-mode/store/focus-mode.effects.ts @@ -286,7 +286,6 @@ export class FocusModeEffects { (timer) => timer.purpose === 'break' && !timer.isRunning && - timer.duration > 0 && timer.elapsed >= timer.duration, ), distinctUntilChanged( From 87bfb606e292501d6ad3e4631cd98d1e0990ca32 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Thu, 21 May 2026 20:50:36 +0200 Subject: [PATCH 26/42] fix(time-tracking): surface non-finite duration writes and destructive auto-fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-finite durations (NaN, Infinity, undefined) reaching addTimeSpent / syncTimeSpent silently corrupt task.timeSpentOnDay. The value JSON-serializes to null on the next sync round-trip, and post-sync auto-fix-typia-errors then zeroes it via the task-number-default-zero branch — appearing as "lost time tracking" with no obvious root cause in the logs. Guard both reducers with Number.isFinite and raise devError so the source is identified instead of silently producing NaN. Also raise devError in the auto-fix destructive branch so a future occurrence is investigated rather than auto-buried under typeErrorsFixed. Specs added cover the guards (NaN/undefined/Infinity rejected, normal values pass through) and document the auto-fix data-loss behavior. --- src/app/features/tasks/store/task.reducer.ts | 13 ++ .../time-tracking-nan-investigation.spec.ts | 157 ++++++++++++++++++ .../auto-fix-time-spent-investigation.spec.ts | 144 ++++++++++++++++ .../validation/auto-fix-typia-errors.ts | 7 + 4 files changed, 321 insertions(+) create mode 100644 src/app/features/tasks/store/time-tracking-nan-investigation.spec.ts create mode 100644 src/app/op-log/validation/auto-fix-time-spent-investigation.spec.ts diff --git a/src/app/features/tasks/store/task.reducer.ts b/src/app/features/tasks/store/task.reducer.ts index 3d7d331503..31f6bec626 100644 --- a/src/app/features/tasks/store/task.reducer.ts +++ b/src/app/features/tasks/store/task.reducer.ts @@ -181,6 +181,13 @@ export const taskReducer = createReducer( }), on(TimeTrackingActions.addTimeSpent, (state, { task, date, duration }) => { + // NaN/Infinity → JSON.stringify → null → auto-fix zeroes the day. Catch source. + if (!Number.isFinite(duration)) { + devError( + `addTimeSpent: non-finite duration for task ${task.id} on ${date}: ${duration}`, + ); + return state; + } const currentTimeSpentForTickDay = (task.timeSpentOnDay && +task.timeSpentOnDay[date]) || 0; return updateTimeSpentForTask( @@ -208,6 +215,12 @@ export const taskReducer = createReducer( TaskLog.warn(`[syncTimeSpent] Task ${taskId} not found, skipping`); return state; } + if (!Number.isFinite(duration)) { + devError( + `syncTimeSpent (remote): non-finite duration for task ${taskId} on ${date}: ${duration}`, + ); + return state; + } const currentTimeSpentForDay = (task.timeSpentOnDay && +task.timeSpentOnDay[date]) || 0; diff --git a/src/app/features/tasks/store/time-tracking-nan-investigation.spec.ts b/src/app/features/tasks/store/time-tracking-nan-investigation.spec.ts new file mode 100644 index 0000000000..f2e882bd47 --- /dev/null +++ b/src/app/features/tasks/store/time-tracking-nan-investigation.spec.ts @@ -0,0 +1,157 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +// Regression: the addTimeSpent / syncTimeSpent reducers must reject non-finite +// durations rather than letting NaN leak into task.timeSpentOnDay, where it +// would JSON-serialize to `null` on the next sync round-trip and then be +// silently zeroed by auto-fix-typia-errors (data loss). +import { Task, TaskState } from '../task.model'; +import { initialTaskState, taskReducer } from './task.reducer'; +import { + TimeTrackingActions, + syncTimeSpent, +} from '../../time-tracking/store/time-tracking.actions'; +import { INBOX_PROJECT } from '../../project/project.const'; +import { OpType } from '../../../op-log/core/operation.types'; +import { _resetDevErrorState } from '../../../util/dev-error'; + +describe('addTimeSpent / syncTimeSpent — NaN guards', () => { + const createTask = (id: string, partial: Partial = {}): Task => ({ + id, + title: `Task ${id}`, + created: Date.now(), + isDone: false, + subTaskIds: [], + tagIds: [], + projectId: INBOX_PROJECT.id, + parentId: undefined, + timeSpentOnDay: {}, + timeEstimate: 0, + timeSpent: 0, + dueDay: undefined, + dueWithTime: undefined, + attachments: [], + ...partial, + }); + + const baseState: TaskState = { + ...initialTaskState, + ids: ['t1'], + entities: { + t1: createTask('t1', { timeSpentOnDay: { '2026-05-21': 100 } }), + }, + }; + + // devError shows alert+confirm in non-prod; stub both so the guard's + // diagnostic path doesn't block Karma. Use the defensive pattern because + // some global test setup may already spy on these. + beforeEach(() => { + _resetDevErrorState(); + if (jasmine.isSpy(window.alert)) { + (window.alert as jasmine.Spy).and.stub(); + } else { + spyOn(window, 'alert').and.stub(); + } + if (jasmine.isSpy(window.confirm)) { + (window.confirm as jasmine.Spy).and.returnValue(false); + } else { + spyOn(window, 'confirm').and.returnValue(false); + } + }); + + describe('addTimeSpent (local)', () => { + it('writes a normal duration into timeSpentOnDay', () => { + const result = taskReducer( + baseState, + TimeTrackingActions.addTimeSpent({ + task: baseState.entities['t1'] as Task, + date: '2026-05-21', + duration: 50, + isFromTrackingReminder: false, + }), + ); + expect((result.entities['t1'] as Task).timeSpentOnDay['2026-05-21']).toBe(150); + }); + + it('rejects NaN duration and leaves state unchanged', () => { + const result = taskReducer( + baseState, + TimeTrackingActions.addTimeSpent({ + task: baseState.entities['t1'] as Task, + date: '2026-05-21', + duration: NaN, + isFromTrackingReminder: false, + }), + ); + expect(result).toBe(baseState); + }); + + it('rejects undefined duration and leaves state unchanged', () => { + const result = taskReducer( + baseState, + TimeTrackingActions.addTimeSpent({ + task: baseState.entities['t1'] as Task, + date: '2026-05-21', + duration: undefined as unknown as number, + isFromTrackingReminder: false, + }), + ); + expect(result).toBe(baseState); + }); + + it('rejects Infinity duration', () => { + const result = taskReducer( + baseState, + TimeTrackingActions.addTimeSpent({ + task: baseState.entities['t1'] as Task, + date: '2026-05-21', + duration: Infinity, + isFromTrackingReminder: false, + }), + ); + expect(result).toBe(baseState); + }); + }); + + describe('syncTimeSpent (remote)', () => { + const remoteSync = (taskId: string, date: string, duration: number): any => ({ + type: syncTimeSpent.type, + taskId, + date, + duration, + meta: { + isPersistent: true, + entityType: 'TASK', + entityId: taskId, + opType: OpType.Update, + isRemote: true, + }, + }); + + it('applies a normal remote duration', () => { + const result = taskReducer(baseState, remoteSync('t1', '2026-05-21', 50)); + expect((result.entities['t1'] as Task).timeSpentOnDay['2026-05-21']).toBe(150); + }); + + it('rejects NaN remote duration and leaves state unchanged', () => { + const result = taskReducer(baseState, remoteSync('t1', '2026-05-21', NaN)); + expect(result).toBe(baseState); + }); + + it('rejects undefined remote duration', () => { + const result = taskReducer( + baseState, + remoteSync('t1', '2026-05-21', undefined as unknown as number), + ); + expect(result).toBe(baseState); + }); + }); + + describe('JSON round-trip invariants the guards protect (documentation)', () => { + it('JSON.stringify(NaN) becomes "null"', () => { + expect(JSON.stringify({ x: NaN })).toBe('{"x":null}'); + }); + + it('JSON.stringify(Infinity) becomes "null"', () => { + expect(JSON.stringify({ x: Infinity })).toBe('{"x":null}'); + }); + }); +}); diff --git a/src/app/op-log/validation/auto-fix-time-spent-investigation.spec.ts b/src/app/op-log/validation/auto-fix-time-spent-investigation.spec.ts new file mode 100644 index 0000000000..d691eb7eee --- /dev/null +++ b/src/app/op-log/validation/auto-fix-time-spent-investigation.spec.ts @@ -0,0 +1,144 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +// Verifies: when post-sync validation finds a null timeSpentOnDay day-bucket, +// the auto-fix sets it to 0 (data loss). +import { autoFixTypiaErrors } from './auto-fix-typia-errors'; +import type { IValidation } from 'typia'; +import { OP_LOG_SYNC_LOGGER } from '../core/sync-logger.adapter'; +import { _resetDevErrorState } from '../../util/dev-error'; + +const createTypiaError = ( + path: string, + expected: string, + value?: unknown, +): IValidation.IError => ({ path, expected, value }) as IValidation.IError; + +describe('auto-fix data-loss for timeSpentOnDay nulls', () => { + beforeEach(() => { + _resetDevErrorState(); + spyOn(OP_LOG_SYNC_LOGGER, 'err').and.stub(); + spyOn(OP_LOG_SYNC_LOGGER, 'warn').and.stub(); + // devError raises an alert+confirm in non-prod; stub defensively because + // a global test setup may already have spied on these. + if (jasmine.isSpy(window.alert)) { + (window.alert as jasmine.Spy).and.stub(); + } else { + spyOn(window, 'alert').and.stub(); + } + if (jasmine.isSpy(window.confirm)) { + (window.confirm as jasmine.Spy).and.returnValue(false); + } else { + spyOn(window, 'confirm').and.returnValue(false); + } + }); + + it('PROBE: null in task.timeSpentOnDay[date] is set to 0 (DATA LOSS)', () => { + const data = { + task: { + ids: ['t1'], + entities: { + t1: { + id: 't1', + timeSpentOnDay: { + '2026-05-20': 5000, + '2026-05-21': null, // pre-corruption (originated as NaN, serialized to null) + }, + }, + }, + }, + } as any; + + const result: any = autoFixTypiaErrors(data, [ + createTypiaError( + '$input.task.entities.t1.timeSpentOnDay.2026-05-21', + 'number', + null, + ), + ]); + + const v = result.task.entities.t1.timeSpentOnDay['2026-05-21']; + // eslint-disable-next-line no-console + console.log('auto-fix result:', v); + expect(v).toBe(0); + // the good day survives + expect(result.task.entities.t1.timeSpentOnDay['2026-05-20']).toBe(5000); + }); + + it('PROBE: null in task.timeSpent (total) is set to 0', () => { + const data = { + task: { + ids: ['t1'], + entities: { + t1: { id: 't1', timeSpent: null }, + }, + }, + } as any; + const result: any = autoFixTypiaErrors(data, [ + createTypiaError('$input.task.entities.t1.timeSpent', 'number', null), + ]); + expect(result.task.entities.t1.timeSpent).toBe(0); + }); + + it('PROBE: null in task.timeEstimate is set to 0', () => { + const data = { + task: { + ids: ['t1'], + entities: { t1: { id: 't1', timeEstimate: null } }, + }, + } as any; + const result: any = autoFixTypiaErrors(data, [ + createTypiaError('$input.task.entities.t1.timeEstimate', 'number', null), + ]); + expect(result.task.entities.t1.timeEstimate).toBe(0); + }); + + it('CONFIRM: this is exactly the path that fires for 4-errors-typeErrorsFixed', () => { + // Simulates the user's report: 4 typia errors all on task number fields. + const data = { + task: { + ids: ['t1', 't2'], + entities: { + t1: { + id: 't1', + timeSpentOnDay: { + '2026-05-19': 3600000, + '2026-05-20': null, + '2026-05-21': null, + }, + timeSpent: null, + }, + t2: { + id: 't2', + timeSpentOnDay: { '2026-05-21': null }, + }, + }, + }, + } as any; + const errors = [ + createTypiaError( + '$input.task.entities.t1.timeSpentOnDay.2026-05-20', + 'number', + null, + ), + createTypiaError( + '$input.task.entities.t1.timeSpentOnDay.2026-05-21', + 'number', + null, + ), + createTypiaError('$input.task.entities.t1.timeSpent', 'number', null), + createTypiaError( + '$input.task.entities.t2.timeSpentOnDay.2026-05-21', + 'number', + null, + ), + ]; + const result: any = autoFixTypiaErrors(data, errors); + // eslint-disable-next-line no-console + console.log('4-error result:', JSON.stringify(result, null, 2)); + expect(result.task.entities.t1.timeSpentOnDay['2026-05-20']).toBe(0); + expect(result.task.entities.t1.timeSpentOnDay['2026-05-21']).toBe(0); + expect(result.task.entities.t1.timeSpent).toBe(0); + expect(result.task.entities.t2.timeSpentOnDay['2026-05-21']).toBe(0); + // The "good" day from t1 survives + expect(result.task.entities.t1.timeSpentOnDay['2026-05-19']).toBe(3600000); + }); +}); diff --git a/src/app/op-log/validation/auto-fix-typia-errors.ts b/src/app/op-log/validation/auto-fix-typia-errors.ts index 35453e4949..5938a8bf70 100644 --- a/src/app/op-log/validation/auto-fix-typia-errors.ts +++ b/src/app/op-log/validation/auto-fix-typia-errors.ts @@ -5,6 +5,7 @@ import { DEFAULT_GLOBAL_CONFIG } from '../../features/config/default-global-conf import { INBOX_PROJECT } from '../../features/project/project.const'; import { RECREATE_FALLBACK } from '../core/recreate-fallback.const'; import { OP_LOG_SYNC_LOGGER } from '../core/sync-logger.adapter'; +import { devError } from '../../util/dev-error'; const LOG_PREFIX = '[auto-fix-typia-errors]'; @@ -130,6 +131,12 @@ export const autoFixTypiaErrors = ( setValueByPath(data, keys, parsedValue); logAutoFixApplied(path, keys, 'task-string-to-number', value, parsedValue); } else { + // Destructive for time-tracking fields (timeSpentOnDay, timeSpent, + // timeEstimate). Originally a release valve for #4346; surface so + // the root cause gets investigated instead of auto-buried. + devError( + `auto-fix-typia-errors: defaulting task number field to 0 — data loss. path=${path}`, + ); setValueByPath(data, keys, 0); logAutoFixApplied(path, keys, 'task-number-default-zero', value, 0); } From 0dfe1d7b45d2718e6d3d352f9893d85ea72d5762 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Thu, 21 May 2026 21:45:13 +0200 Subject: [PATCH 27/42] fix(focusMode): keep overtime display value while paused Pausing during Pomodoro overtime made the time display snap to 0:00. selectIsInOvertime gated on timer.isRunning, so pausing flipped it to false and the template fell back to the countdown branch, which clamps timeRemaining to 0 once elapsed >= duration. The browser tab title used the same selector and showed the same 0:00 on pause. Drop the isRunning condition: the session is semantically still in overtime while paused (_isOvertimeEnabled is preserved across pause, and detectSessionCompletion$ has its own guard so no spurious auto-completion). Closes #7715 --- .../store/focus-mode.selectors.spec.ts | 17 ++++++++++++++++- .../focus-mode/store/focus-mode.selectors.ts | 4 +++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/app/features/focus-mode/store/focus-mode.selectors.spec.ts b/src/app/features/focus-mode/store/focus-mode.selectors.spec.ts index 1aee004075..021929cd3c 100644 --- a/src/app/features/focus-mode/store/focus-mode.selectors.spec.ts +++ b/src/app/features/focus-mode/store/focus-mode.selectors.spec.ts @@ -382,7 +382,9 @@ describe('FocusModeSelectors', () => { expect(result).toBe(false); }); - it('should return false when timer is not running', () => { + // Bug #7715: pausing during overtime previously flipped this to false, + // which made the display fall back to `timeRemaining` (clamped to 0:00). + it('should stay true when paused during overtime', () => { const timer = createMockTimer({ isRunning: false, purpose: 'work', @@ -392,6 +394,19 @@ describe('FocusModeSelectors', () => { const result = selectors.selectIsInOvertime.projector(timer, true); + expect(result).toBe(true); + }); + + it('should return false when paused before reaching duration', () => { + const timer = createMockTimer({ + isRunning: false, + purpose: 'work', + duration: 1500000, + elapsed: 1000000, + }); + + const result = selectors.selectIsInOvertime.projector(timer, true); + expect(result).toBe(false); }); diff --git a/src/app/features/focus-mode/store/focus-mode.selectors.ts b/src/app/features/focus-mode/store/focus-mode.selectors.ts index 3c166aff1e..76f11f9263 100644 --- a/src/app/features/focus-mode/store/focus-mode.selectors.ts +++ b/src/app/features/focus-mode/store/focus-mode.selectors.ts @@ -104,12 +104,14 @@ export const selectIsOvertimeEnabled = createSelector( (state) => state._isOvertimeEnabled, ); +// Bug #7715: stays true while paused so the display keeps showing +// the overtime value (`timeElapsed`) instead of falling back to +// `timeRemaining`, which clamps to 0:00 once elapsed >= duration. export const selectIsInOvertime = createSelector( selectTimer, selectIsOvertimeEnabled, (timer, _isOvertimeEnabled) => _isOvertimeEnabled && - timer.isRunning && timer.purpose === 'work' && timer.duration > 0 && timer.elapsed >= timer.duration, From f32c56b9337ef21453115d7839bc42ffcf27d645 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Thu, 21 May 2026 22:41:54 +0200 Subject: [PATCH 28/42] fix(settings): raise background image size caps The previous 200 KB limit silently rejected legitimately-sized wallpapers, leaving users with a blank background and no UI feedback (only a terminal error). Raise the limits per ingestion path: - Electron file:// picker: 5 MB. Only the path is persisted; image bytes are read locally on demand and never sync. - Browser/Android file input: 256 KB. Bytes embed as a data URL and sync via op-log, so the cap stays tight to protect SuperSync snapshot size against per-entity bloat. Closes #7716 --- electron/local-file-sync.ts | 5 ++--- .../formly-image-input/formly-image-input.component.spec.ts | 4 ++-- .../ui/formly-image-input/formly-image-input.component.ts | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/electron/local-file-sync.ts b/electron/local-file-sync.ts index e491a75b53..882ebb3bb5 100644 --- a/electron/local-file-sync.ts +++ b/electron/local-file-sync.ts @@ -45,11 +45,10 @@ export const initLocalFileSyncAdapter = (): void => { const stat = await fs.promises.stat(normalized); - // 200 KB limit - const MAX_FILE_SIZE = 200 * 1024; + const MAX_FILE_SIZE = 5 * 1024 * 1024; if (stat.size > MAX_FILE_SIZE) { - throw new Error('Background image exceeds 200 KB limit'); + throw new Error('Background image exceeds 5 MB limit'); } const buffer = await fs.promises.readFile(normalized); diff --git a/src/app/ui/formly-image-input/formly-image-input.component.spec.ts b/src/app/ui/formly-image-input/formly-image-input.component.spec.ts index 128383702d..03c639317a 100644 --- a/src/app/ui/formly-image-input/formly-image-input.component.spec.ts +++ b/src/app/ui/formly-image-input/formly-image-input.component.spec.ts @@ -88,7 +88,7 @@ describe('FormlyImageInputComponent', () => { }); it('rejects oversized files with snack', () => { - const largeBytes = new Uint8Array(205 * 1024); + const largeBytes = new Uint8Array(257 * 1024); const file = new File([largeBytes], 'large.png', { type: 'image/png' }); const setValueSpy = spyOn(formControl, 'setValue').and.callThrough(); @@ -98,7 +98,7 @@ describe('FormlyImageInputComponent', () => { expect(snackService.open).toHaveBeenCalledWith({ msg: T.F.PROJECT.FORM_THEME.S_BACKGROUND_IMAGE_TOO_LARGE, type: 'ERROR', - translateParams: { maxSizeKb: 200 }, + translateParams: { maxSizeKb: 256 }, }); }); diff --git a/src/app/ui/formly-image-input/formly-image-input.component.ts b/src/app/ui/formly-image-input/formly-image-input.component.ts index 6530a4a0ef..c99a9cb567 100644 --- a/src/app/ui/formly-image-input/formly-image-input.component.ts +++ b/src/app/ui/formly-image-input/formly-image-input.component.ts @@ -19,7 +19,7 @@ import { SnackService } from '../../core/snack/snack.service'; import { T } from '../../t.const'; import { IS_ELECTRON } from '../../app.constants'; -const MAX_BACKGROUND_IMAGE_FILE_SIZE_BYTES = 200 * 1024; +const MAX_BACKGROUND_IMAGE_FILE_SIZE_BYTES = 256 * 1024; @Component({ selector: 'formly-image-input', From 292f8b0e9a7fd7b2d6454aad7ca363c65487b91e Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 22 May 2026 15:25:42 +0200 Subject: [PATCH 29/42] refactor(sync): address multi-review findings on #7709 (#7712) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(sync): plan for clean-slate upload data-loss prevention (#7709) Revised after a 6-reviewer parallel pass. Splits into 4 PRs; PR-A (atomicity + empty-snapshot dialog fix + pre-migration-backup implementation + dead-code cleanup) closes the reported precondition chain. Preflight gating deferred pending forensic log evidence. * test(sync): reproduce #7709 precondition — interrupted createCleanSlate Integration tests proving that an interrupt between `clearAllOperations()` and `append()` on a device that has never reached the compaction threshold leaves the device in `isWhollyFreshClient===true && hasMeaningfulStoreData===true` — the exact branch that throws `LocalDataConflictError(0, {})` and opens the issue #7709 conflict dialog. Also documents two adjacent findings: - Interrupt at `setVectorClock` corrupts state_cache but does NOT trigger the #7709 chain (lastSeq stays > 0). - A previously-compacted device is NOT vulnerable: state_cache survives the interrupt, so `isWhollyFreshClient` stays false. These tests will need to be flipped (or deleted) when PR-A lands the atomic destructive sequence: the bug post-condition should become impossible. * fix(sync): atomic destructive state replacement closes #7709 precondition `CleanSlateService.createCleanSlate` and `BackupService.importComplete` both ran a four-step destructive sequence as independent IndexedDB transactions: `clearAllOperations` → `append(syncImportOp)` → `setVectorClock` → `saveStateCache`. An interrupt between steps on a device that had never reached COMPACTION_THRESHOLD = 500 ops left OPS empty and state_cache still null — i.e. `isWhollyFreshClient()===true` with meaningful in-memory data, the precondition that routes through `operation-log-sync.service.ts:606` and throws `LocalDataConflictError(0, {})` to open the conflict dialog. From there a "Keep remote" click propagates the empty server snapshot to every other device. Replace both call sites with a new `OperationLogStoreService.runDestructiveStateReplacement` helper that uses snapshot-then-swap: 1. Stage the new state to STATE_CACHE under STATE_CACHE_STAGING_KEY (single-store write; large payload kept out of the destructive tx). 2. Round-trip verify the staged row. 3. One short multi-store readwrite transaction (OPS + STATE_CACHE + VECTOR_CLOCK) does small writes only: clear OPS, append the SYNC_IMPORT entry, write the vector clock, promote staging row to the singleton, delete the staging row. 4. On any error, explicitly `tx.abort()` so already-queued writes (notably the `clear()`) are rolled back. IDB does NOT auto-abort a transaction when JS throws between `await`ed requests — a mid-flight exception lets the tx commit partial state without the explicit abort. The integration test caught this before the helper landed. 5. `init()` sweeps any orphaned staging row left over from a previous interrupted destructive replacement. `BackupService` additionally bails out cleanly if the pre-import backup write failed — better to refuse the destructive import than overwrite local state without a recovery point. clean-slate-interrupt.integration.spec.ts inverts: it previously demonstrated the corrupt post-condition; now it proves the device's prior state is preserved through every injected failure mode (staging read, destructive tx abort, never-compacted device). * fix(sync): address review gaps in #7709 destructive replacement - BackupService now throws (not silently returns) when the pre-import backup fails, so the caller doesn't fall through into a hybrid state (NgRx + archives + sync seqs replaced, op-log still old). - Roll back clientId rotation in CleanSlate/Backup if the destructive tx aborts; pf and SUP_OPS now agree on the device's clientId after either success or failure. - runDestructiveStateReplacement: patch the singleton's lastAppliedOpSeq to the assigned seq (was hardcoded 0), require snapshotEntityKeys from callers (was undefined → triggered an "old snapshot format" recompaction after every destructive replacement), drop the unused Promise return, drop the dead-defensive verify-read, switch boot probe to getKey to skip multi-MB structured clone of an orphan staging row, and sanitize the raw Error in the reconciliation log. * test(sync): cover clientId rollback-fails edge case (#7709) When both the destructive call and the clientId rollback throw, the caller must still see the ORIGINAL destructive failure (not the rollback error), and the rollback failure must be logged at critical level. Pins down behaviour the rollback path previously only reasoned about. * refactor(sync): tighten destructive replacement + log forensics (#7709) Follow-up to round-2 multi-review findings. - runDestructiveStateReplacement: write the new singleton row directly from opts.newState inside the destructive tx. Drops the in-tx stateCacheStore.get(STAGING_KEY) and the {...staged, ...} spread, which re-cloned the multi-MB payload while holding the multi-store tx open. Also restores the implicit "newState \!= null" precondition that the prior verify-read had enforced (the new "if (\!staged)" branch was weaker than the dropped check). Staging row remains as a crash-detection sentinel for boot-time reconciliation. - _cacheLastSeq = 0 after the destructive write to match the pattern used by every other write site in the file (the previous "= seq" was unreadable because _appliedOpIdsCache=null forces a rebuild anyway). - OpLog.critical on rollback failure now also carries originalError (name + message), so a forensic reader can correlate the pf/SUP_OPS divergence with the destructive failure that triggered it. - Sanitise the pre-existing raw Error in [CleanSlate] pre-migration backup warn log to { name, message }, matching the rest of the module. - Drop the brittle log-string regex in the rollback-fails tests; structural payload assertion (priorClientId + originalError) locks the contract without coupling to log wording. * refactor(sync): delete unused PreMigrationBackupService placeholder (#7709) The service was committed as a no-op placeholder before this branch and never implemented. Its intended purpose — provide a recovery path independent of IDB atomicity — is obsolete now that runDestructiveStateReplacement makes the destructive sequence atomic within SUP_OPS. The destructive tx either fully commits or fully rolls back, so there is no partial-write state to recover from. - Delete pre-migration-backup.service.ts and its placeholder spec. - Remove the DI wiring, injected field, and dead try/catch from CleanSlateService. - Rename PreMigrationReason → CleanSlateReason and define it locally; the type is internal to clean-slate.service.ts (single string-literal caller in encryption-password-change.service.ts). - Remove the PreMigrationBackupService mock from the unit + integration specs and drop the "should continue if pre-migration backup fails" test (the code path it covered no longer exists). - Update the plan doc: PR-A no longer ships pre-migration backup; Fix 3 and Fix 6 deferred to follow-up PRs. * refactor(sync): address multi-review findings on #7709 Multi-review of the #7709 destructive-replacement branch surfaced one privacy regression and several over-engineering smells that landed in the earlier commits. This commit cleans them up. - Drop priorClock from the "Starting clean slate" log payload — vector- clock keys are per-device clientIds and Log history is user-exportable. The branch's own plan explicitly forbids logging clock contents ("Security C2: size only, never the contents"); the implementation regressed against that and the test asserted the wrong shape. Spec now asserts the field must NOT appear. - Drop the snapshot-then-swap staging row from runDestructiveStateReplacement. The staging row was supposed to keep the multi-MB payload outside the destructive multi-store tx, but the in-tx singleton put already wrote the same payload — so staging cost one extra full-state structured clone, one boot-time getKey round-trip per cold start, a catch-block cleanup, two integration tests, and ~30 lines of JSDoc, in exchange for a crash-detection sentinel nothing acts on. Single multi-store readwrite tx provides the same atomicity guarantee with none of the machinery. (Comment on the retained tx.abort() in the catch path corrected — it is unreachable in production but load-bearing for the spy-based fault-injection seam used by the interrupt integration test.) - Extract ClientIdService.withRotation(logPrefix, fn) so the cross-DB clientId rotation+rollback dance is owned in one place. CleanSlateService and BackupService delegate; ~20 lines of byte-for-byte duplication removed. Rollback semantics (happy path, restore-on-failure, no-prior-id edge case, rollback-itself-fails) are now tested once against the helper instead of duplicated across the two consumer specs. Net diff: 249 insertions, 401 deletions across 9 files. Verified: full Karma suite passes in both Europe/Berlin and America/Los_Angeles timezones (9444 / 9430 pre-existing pass; the 10 failures are in immediate-upload.service.spec.ts and reproduce on master, unrelated to this change). tsc --noEmit, ng lint, stylelint, and the custom lint rules all clean. Public APIs of CleanSlateService and BackupService unchanged; their three production callers (encryption-password-change, user-profile import x2) require no updates. Closes the post-implementation review for #7709. * Fix destructive import race conditions * refactor(sync): address round-3 multi-review findings on #7709 Six-reviewer parallel pass on the destructive-replacement branch surfaced one privacy regression, one awkward control-flow pattern, and a stale plan-doc section. This commit clears them. - LockService.request: generic return type. The Web Locks API and the fallback mutex both naturally return the callback's value; Promise was just too narrow. Lets CleanSlateService.createCleanSlate() drop the `let result: ... | null = null` + null-check + cast + unreachable "completed without a replacement result" throw and use a single `const { syncImportId } = await lockService.request(...)`. Generic mock signatures threaded through 9 spec files (19 callFake sites). - ClientIdService: stop logging plaintext clientIds. Per CLAUDE.md sync rule 9 (log history is user-exportable) the plan's own Fix-4 contract says "never log vector-clock contents; size only" — clientIds are the keyspace of the vector clock, so the same rule applies. Four log sites (loadClientId, generateNewClientId, persistClientId, invalid-format critical log) now omit the id; CleanSlateService logs a 3-char suffix where correlation is useful but the full value is not. - BackupService: drop `message` from the pre-import-backup failure log. Matches the `name`-only pattern used by ClientIdService.withRotation rollback logging; future validator/IDB error types could otherwise interpolate user content into Error.message. - runDestructiveStateReplacement: JSDoc-document the payload duplication trade-off (full state lives in OPS for the uploader and in STATE_CACHE for `isWhollyFreshClient`; eliminating either is unsafe — STATE_CACHE can be advanced past the SYNC_IMPORT op's seq by compaction). - Plan doc: replace the snapshot-then-swap "Fix 2 Implementation" section with a description of the single multi-store readwrite tx that actually shipped. The plan's load-bearing premise ("every existing db.transaction() call is single-store") was wrong: appendWithVectorClockUpdate already runs a 2-store readwrite tx on every action. Revision-history bullet adjusted to record both the initial switch and the revert. Verified: 788 tests across the touched sync surface pass (clean-slate / backup / client-id / lock / write-flush / compaction / upload / download / remote-ops / capture-effects / store / repair / interrupt-integration / task-done-replay / migration-handling / focus-mode-reducer / bug-7707 / focus-mode-effects / operation-log-sync / superseded-operation-resolver). checkFile clean on every modified .ts file. * test(sync): interleave-race coverage + boot-time partial-write detector (#7709) Two additions raising end-to-end confidence on the #7709 fix: 1. Interleave race test (operation-log.effects.spec.ts): Asserts that a queued op acquiring the operation-log lock AFTER a concurrent destructive replacement reads the POST-rotation clientId. The existing test pinned the call ORDER (lock → clientId → append); this test pins the SEMANTICS: by the time the queued op's callback runs inside the lock, ClientIdService reflects the rotation, so the appended op carries 'newClient', not 'oldClient'. 2. Boot-time state_cache consistency check (operation-log-store.service.ts): Fire-and-forget on init: if state_cache.lastAppliedOpSeq references an op that doesn't exist in OPS, log a critical with counts-only forensics (referencedSeq, opsCount, vector-clock sizes). The atomic runDestructiveStateReplacement makes the invariant "state_cache.lastAppliedOpSeq always points to a real OPS entry" hold for any future write path that uses the helper. This check surfaces violations from either (a) pre-#7709 partial-state recoveries still on disk after upgrade, or (b) any future code path that bypasses the helper. Observability only — wrapped in try/catch, never blocks initialization. Counts-only payload (no entity IDs, no vector-clock contents) per CLAUDE.md sync rule 9. Both changes verified: 34 effects-spec tests + 166 store-spec tests pass. Full Karma run (9477 tests) has 10 pre-existing failures in immediate-upload.service.spec.ts that reproduce on upstream/master HEAD — unrelated to this branch. * refactor(sync): drop boot consistency detector, derive replacement state from the op Follow-up addressing multi-review findings on the #7709 atomicity work: - Remove _verifyStateCacheConsistencyOnBoot. The atomicity fix makes the partial-write signature unreachable via runDestructiveStateReplacement, and "OPS empty + state_cache present" is also the valid state left by a full-log compaction, so the two are indistinguishable at boot and a critical log there is a false alarm. Forensic logging is deferred to a later PR per the design doc's staged plan. - runDestructiveStateReplacement now derives newState / newVectorClock / schemaVersion from syncImportOp instead of taking them as separate opts. Nothing enforced that they agreed; a divergent value would silently desync OPS from state_cache, the exact bug class this work prevents. - Restore the null-tolerant archive guard (was undefined-only) to preserve the prior defensive handling of backups with null archive fields. --- ...-05-21-clean-slate-data-loss-prevention.md | 305 ++++++++++++++++++ src/app/core/util/client-id.service.spec.ts | 120 +++++++ src/app/core/util/client-id.service.ts | 139 ++++++-- .../store/focus-mode.effects.spec.ts | 29 +- .../focus-mode/store/focus-mode.effects.ts | 1 + src/app/op-log/backup/backup.service.spec.ts | 253 +++++++++------ src/app/op-log/backup/backup.service.ts | 141 ++++---- .../capture/operation-log.effects.spec.ts | 80 ++++- .../op-log/capture/operation-log.effects.ts | 22 +- .../clean-slate/clean-slate.service.spec.ts | 238 +++++++------- .../op-log/clean-slate/clean-slate.service.ts | 234 ++++++-------- .../pre-migration-backup.service.spec.ts | 43 --- .../pre-migration-backup.service.ts | 48 --- .../operation-log-compaction.service.spec.ts | 24 +- .../operation-log-store.service.spec.ts | 107 ++++++ .../operation-log-store.service.ts | 116 +++++++ src/app/op-log/sync/lock.service.ts | 20 +- .../operation-log-download.service.spec.ts | 2 +- ...ration-log-lock-reentry.regression.spec.ts | 6 +- .../sync/operation-log-upload.service.spec.ts | 11 +- .../operation-write-flush.service.spec.ts | 17 +- .../remote-ops-processing.service.spec.ts | 8 +- .../clean-slate-interrupt.integration.spec.ts | 222 +++++++++++++ .../migration-handling.integration.spec.ts | 2 +- .../task-done-replay.integration.spec.ts | 6 +- .../repair-operation.service.spec.ts | 6 +- 26 files changed, 1582 insertions(+), 618 deletions(-) create mode 100644 docs/plans/2026-05-21-clean-slate-data-loss-prevention.md delete mode 100644 src/app/op-log/clean-slate/pre-migration-backup.service.spec.ts delete mode 100644 src/app/op-log/clean-slate/pre-migration-backup.service.ts create mode 100644 src/app/op-log/testing/integration/clean-slate-interrupt.integration.spec.ts diff --git a/docs/plans/2026-05-21-clean-slate-data-loss-prevention.md b/docs/plans/2026-05-21-clean-slate-data-loss-prevention.md new file mode 100644 index 0000000000..db235d196f --- /dev/null +++ b/docs/plans/2026-05-21-clean-slate-data-loss-prevention.md @@ -0,0 +1,305 @@ +# Clean-Slate Upload Data-Loss Prevention + +**Date:** 2026-05-21 +**Status:** Design — revised after multi-review (6 parallel reviewers: Correctness, Security, Architecture, Alternatives, Performance, Simplicity). Convergent findings folded in. Codex skipped (stdin hang in this environment). +**Scope:** Sync upload path; SuperSync + file-based providers. Client-side only. +**Tracking issue:** [#7709](https://github.com/super-productivity/super-productivity/issues/7709) + +## Context + +Issue #7709 describes a multi-device data-loss chain: a single device uploads a partial / stale local state to the server as a full-state op (SYNC_IMPORT, BACKUP_IMPORT, or REPAIR — all are treated as clean-slate by `operation-log-upload.service.ts:129-130`), the server deletes its op-log history, and every other device that auto-syncs inherits the partial snapshot. The reported user lost ~2 weeks of work across three laptops this way. + +Five user-reachable triggers produce destructive uploads today (the multi-review added the fifth): + +1. Encryption password change / first-encryption-enable → `CleanSlateService.createCleanSlate()` → upload with `isCleanSlate: true` +2. "Keep local" in the sync conflict dialog → `SyncImportConflictCoordinatorService.forceUploadLocalState()` (passes `skipServerEmptyCheck: true`) +3. "Force upload" snack-bar action from various error dialogs (LockPresentError, EmptyRemoteBodySPError, JsonParseError, LegacySyncFormatDetectedError, DialogSyncError, DecryptError) +4. Server-migration into an empty server (auto-triggered, no user confirm; **only appends a SYNC_IMPORT op, no local destruction** — destructive only on the server side via the `isCleanSlate=true` flag) +5. **`BackupService.importComplete` (backup-file restore)** — runs the same non-atomic `clearAllOperations → append → setVectorClock → saveStateCache` sequence as `createCleanSlate` (`backup.service.ts:194/221/225/227`). Fifth destructive trigger; the original plan missed it. + +## The two load-bearing problems + +This plan addresses two independent root causes. Either one alone is sufficient to cause the reported bug. + +### Problem A — Non-atomic destructive sequence (THE reported precondition) + +Both `clean-slate.service.ts:149-168` and `backup.service.ts:194-227` run in order: + +``` +1. clearAllOperations() ← OPS table emptied +2. append(syncImportOp) ← lastSeq goes back to 1 +3. setVectorClock(...) +4. saveStateCache(...) ← state_cache populated +``` + +If the process is interrupted between step 1 and step 4 (crash, tab close, browser kill, even a thrown exception in `append`/`setVectorClock`), the device is left with `OPS` empty AND `state_cache` either stale or never written. On a low-activity device (under `COMPACTION_THRESHOLD = 500` ops) `state_cache` was never written in the first place. Result: `isWhollyFreshClient()===true` on next launch, which routes through the `LocalDataConflictError(0, {})` throw at `operation-log-sync.service.ts:606`. + +This is the exact precondition chain the issue describes. + +### Problem B — No completeness check before clean-slate + +Destructive uploads use the *current* in-memory NgRx state as authoritative. If NgRx is partial (hydration incomplete, post-`clearAllOperations()` window, post-rollback empty state), the partial state is what gets sent. Nothing compares "what's about to be uploaded" against "what was here a minute ago." + +**Per multi-review:** Problem B is not in the reported incident (Problem A explains it cleanly). Problem B is hypothetical — defending against unobserved failure modes. The original plan made Problem B the headline fix; this revision demotes it to a follow-up gated on forensic evidence from production logs. + +## Goals + +1. **Primary:** Close Problem A — destructive sequences either complete fully or leave the device in its prior state. +2. **Primary:** Fix the `{}` empty remote snapshot at `operation-log-sync.service.ts:606` so the conflict dialog has something to render. +3. **Primary:** Log forensic data on every clean-slate upload so the next incident (and any unobserved Problem B occurrences) can be diagnosed. +4. **Conditional (gated on evidence from log data after PR-B):** Add completeness gating if and only if logs show partial-state uploads happening in the wild. + +## Non-goals + +- Server-side completeness gating (worth doing as a follow-up; out of scope for this client-side plan). +- Replacing the conflict dialog UX. +- Changing the `BACKUP_IMPORT` / `REPAIR` clean-slate-by-default semantics in `operation-log-upload.service.ts:129-130`. +- **Defense against a fully-compromised client process.** The threat model assumes the local Angular runtime is trusted; this plan addresses bugs (partial hydration, interrupted destructive flows), not adversaries with IDB write access or XSS-level capabilities. +- Feature-flagging the rollout. The codebase has no flag framework (verified) and the changes either are safe to ship unconditionally (atomicity, logging, throw-fix) or rare enough that a flag adds more risk than it removes (preflight, modal). + +## Design + +### Fix 2 (LOAD-BEARING) — Atomic destructive sequence + +**Where:** A new helper on `OperationLogStoreService`: + +```ts +async runDestructiveStateReplacement(opts: { + syncImportOp: Operation; + newVectorClock: VectorClock; + newState: unknown; + schemaVersion: number; + snapshotEntityKeys: string[]; + archiveYoung?: ArchiveStoreEntry['data']; + archiveOld?: ArchiveStoreEntry['data']; +}): Promise +``` + +Two callers refactor to use it: `CleanSlateService.createCleanSlate()` and `BackupService.importComplete()`. Both previously ran a four-step sequence as independent IDB transactions. + +**Implementation: single multi-store readwrite transaction.** All writes (clear OPS, append the SYNC_IMPORT entry, write vector_clock, write state_cache, optionally write archive_young / archive_old) happen inside one `db.transaction(stores, 'readwrite')`. If any step rejects the IDB request, `tx.done` rejects, the engine auto-aborts, and no committed change to any of the touched stores survives. The catch block calls an explicit `tx.abort()` as well — that branch is unreachable in production (rejected IDB requests already abort the tx) but is load-bearing for the spy-based fault-injection seam used by the interrupt integration test, where the spy throws synchronously instead of rejecting an IDB request. + +**Why a single multi-store tx, not snapshot-then-swap.** The plan's first revision proposed staging the new state to a `STATE_CACHE_STAGING_KEY` row outside the destructive tx, then swapping references inside a small cross-store tx, on the premise that "every existing `db.transaction(...)` call in `operation-log-store.service.ts` is single-store" and WebKit/Capacitor had no precedent for multi-store + multi-MB. That premise was wrong: `appendWithVectorClockUpdate` already runs a 2-store (`OPS` + `VECTOR_CLOCK`) readwrite tx on every local action, including on Capacitor iOS. Staging would have cost one extra full-state structured-clone, a boot-time staging-row reconciliation step on every cold start, a catch-block cleanup, two integration tests, and ~30 lines of JSDoc — in exchange for a crash-detection sentinel nothing would have acted on. The simpler design provides the same atomicity guarantee with none of the machinery. + +**Payload duplication trade-off.** Both `syncImportOp.payload` (full state) and `newState` (structurally identical) are persisted in the same tx: the payload via the OPS row's encoded operation, the state via the STATE_CACHE singleton. Both writes are required — OPS holds the payload the uploader sends in the snapshot endpoint; STATE_CACHE is what `isWhollyFreshClient` reads on next launch. Eliminating the duplication would require either lazy hydration of the OPS payload from STATE_CACHE at upload time (unsafe if compaction advances STATE_CACHE past this op's seq) or a dedicated payload-staging store. Neither pays back for an infrequent (password change / backup restore) operation. + +**On `pre-migration-backup.service.ts` — DELETED in PR-A.** The original plan kept a placeholder `PreMigrationBackupService` and proposed implementing it as a recovery path independent of IDB atomicity. With `runDestructiveStateReplacement` now atomic, the safety net it was meant to provide (recover from a partial destructive write) cannot fire — the destructive tx either fully commits or fully rolls back. The placeholder service was deleted along with its DI wiring and stub tests. If a future requirement appears for "user-initiated undo of a successful clean-slate," that should be designed as its own feature, not a vestigial backup layer. + +### Fix 3 (LOAD-BEARING) — Empty-snapshot throw at `operation-log-sync.service.ts:606` + +**Critical correction from multi-review:** Line 606 is in the op-streaming branch (`result.newOps.length > 0`). It is reached *after* the three `if (result.providerMode === 'fileSnapshotOps' && result.snapshotState)` early-returns at lines 458/484/517. `result.snapshotState` is **undefined** at line 606. The original plan's "thread `result.snapshotState`" instruction was wrong for this site. + +**Two-part fix:** + +(a) **Earlier throws** (lines 458, 484) already construct `LocalDataConflictError(unsyncedCount, result.snapshotState, result.snapshotVectorClock)` — verify these and add the `vectorClock` argument to line 484 if missing. + +(b) **Line 606 throw** has no snapshot. Compute a synthetic payload from `result.newOps`: + +```ts +const remoteOpCount = result.newOps.length; +throw new LocalDataConflictError( + 0, + { __synthetic: true, opCount: remoteOpCount }, + result.snapshotVectorClock, // may be undefined; OK +); +``` + +(c) **Narrow `LocalDataConflictError.remoteSnapshotState` type to a discriminated union** so the dialog can render "Remote: N operations" for the synthetic case and a real entity-count summary for the snapshot case: + +```ts +type RemoteDataDescriptor = + | { kind: 'snapshot'; counts: { tasks: number; projects: number; tags: number; notes: number; trackedHours: number } } + | { kind: 'opCount'; opCount: number }; +``` + +This requires changes to: +- `LocalDataConflictError` constructor (`sync-errors.ts:95-105`) — replace `remoteSnapshotState: Record` with `remoteData: RemoteDataDescriptor`. +- The two real-snapshot throw sites (lines 458/484) — compute counts from `result.snapshotState` before throwing. +- `_handleLocalDataConflict` (`sync-wrapper.service.ts:1161-1262`) — pass `remoteData` into `conflictData.remote` instead of `mainModelData`. +- `dialog-sync-conflict.component.html` — branch on `descriptor.kind`. + +**Security note:** the narrowed type eliminates the future-XSS risk Security C1 raised — decrypted remote task titles can no longer leak into the dialog via arbitrary `mainModelData` payloads. + +### Fix 4 (LOAD-BEARING) — Forensic logging + +**Where:** `OpLog.warn` at every clean-slate upload entry point and at every `LocalDataConflictError` throw. + +**Payload (counts-only; no entity content per CLAUDE.md sync rule 9):** + +```ts +{ + cleanSlateReason: 'PASSWORD_CHANGED' | 'USE_LOCAL' | 'FORCE_UPLOAD' | 'SERVER_MIGRATION' | 'FIRST_ENCRYPTION' | 'BACKUP_RESTORE' | 'REPAIR', + triggerSource: string, // for FORCE_UPLOAD: which error class triggered it + inMemoryCounts: { tasks, projects, tags, notes, trackedHours }, + stateCacheCounts: { tasks, projects, tags, notes, trackedHours } | null, + vectorClockSize: number, // size only, never the contents (Security C2) + lastSeq: number, + hasPriorStateCache: boolean, +} +``` + +**Constraints:** +- Never log vector-clock contents (per-device client IDs are sensitive). Size only. +- Never log entity IDs, titles, or any per-entity data. +- Add the same log at the three `LocalDataConflictError` throw sites (458/484/606) with the remote counts where available. + +**Why this is load-bearing:** PR-D (conditional preflight) is gated on the evidence this generates. Without these logs we have no basis to decide whether Problem B happens in the wild. + +### Fix 5 (DEFENSE-IN-DEPTH) — Confirmation modal + +**Single modal, no counts in copy** (per Simplicity W4 — counts make the dialog look load-bearing, invite litigation): + +``` +This will replace the data on the server. + +Every other device that syncs after this will be overwritten by +what's currently on this device. This cannot be undone except by +restoring from a backup. + +[Cancel] [Yes, replace server] +``` + +Counts go to the forensic log (Fix 4), not the modal UI. + +**Performance constraint (Performance S2):** the modal must NOT hold the sync lock while waiting for the user. Acquire the lock only *after* the user clicks "Yes, replace server." Pre-modal reads (preflight, if added in PR-D) are lock-free. + +**Trigger sites:** all four user-initiated paths (password change, "Keep local", force-upload snackbars, backup restore). Skipped for first-encryption-enable on a *truly* fresh client (`lastSeq===0 && state_cache===null && server-side reports empty`), since this is the legitimate bootstrapping case. + +### Fix 6 — Dead code cleanup + +`incrementCompactionCounter()` (`operation-log-store.service.ts:1145-1173`) has no production callers (only test specs). Its `state: null` placeholder write at lines 1151-1163 motivates the defensive guard at `loadStateCache` line 1051. + +**Delete both** the write path and the guard (per Correctness S1 — keeping the guard without the writer is confusion-bait for future readers). Update the spec files that reference `incrementCompactionCounter` to either delete those tests or test the deletion semantics (i.e., that the dead write path is gone). + +### Fix 1 — DEFERRED to PR-D + +The original plan made `CleanSlatePreflightService` the headline fix. Multi-review converged on cutting it from v1: + +- **Simplicity C1, C2:** Heuristic gates without incident evidence are YAGNI. Ship Fixes 2/3/4/6 first and let the logs from Fix 4 tell us whether partial-state uploads actually happen in the wild. +- **Alternatives alt #4:** If a gate is needed later, prefer a *temporal* gate ("refuse clean-slate uploads unless the client successfully downloaded server state within the last 5 minutes") over a count-based threshold. Temporal eliminates the threshold-tuning problem (Q1), the no-reference problem (Q2), and the legitimate-deletion-bypass problem (Q4) in one move. + +**If PR-D ships (only if Fix 4 logs show partial-state uploads):** + +Three call sites (the multi-review corrected this — the plan's original three-callers-through-`createCleanSlate` claim was wrong; `createCleanSlate` has only one production caller at `encryption-password-change.service.ts:97`): + +1. `encryption-password-change.service.ts` around line 97 (before `createCleanSlate`) +2. `sync-import-conflict-coordinator.service.ts` `forceUploadLocalState` around line 51 +3. `sync-wrapper.service.ts` `forceUpload` around line 843 + +**Gate logic (temporal, not threshold):** + +```ts +const lastSuccessfulDownloadAt = await syncProvider.getLastSuccessfulDownloadAt(); +const fresh = lastSuccessfulDownloadAt && (Date.now() - lastSuccessfulDownloadAt < 5 * 60 * 1000); +if (!fresh) { + // Refuse with: "Sync hasn't downloaded server state recently. Reload the app + // to download, then try again. (If you really want to replace the server data, + // use Export → Reset → Re-import.)" + return REFUSE; +} +``` + +**Escape hatch (Alternatives alt #8):** the refusal dialog includes a "Save local data to file" button calling the existing JSON-export flow. The user always has a recovery path regardless of preflight outcome. + +**TOCTOU mitigation** (Correctness W1): if the preflight ever needs to compare in-memory state to a reference, compute the snapshot once via `getStateSnapshotAsync()` and pass it through to the destructive call — don't re-read. + +## Implementation phases + +Reordered per multi-review convergence (Correctness S3, Architecture S1, Simplicity S1, Security S3): + +| PR | Contents | Risk | Rollback | +| --- | --- | --- | --- | +| **PR-A** | Atomicity via `runDestructiveStateReplacement` (Fix 2); `PreMigrationBackupService` deleted (atomic replace makes the recovery layer unnecessary). Empty-snapshot fix + `RemoteDataDescriptor` type (Fix 3) and dead-code cleanup (Fix 6) **deferred** to follow-up PRs. | Medium — touches IDB write patterns | Single revert | +| **PR-B** | Forensic logging (Fix 4) | Very low — pure observability | Trivial | +| **PR-C** | Confirmation modal (Fix 5) | Low — UI-only, doesn't refuse uploads | Trivial | +| **PR-D** | *Conditional.* Temporal preflight gate (Fix 1 — temporal, not count-based) + export-to-file escape hatch | Medium — refuses uploads | Trivial via call-site removal | + +PR-A is the actual bug-closing PR — it eliminates Problem A and fixes the empty-snapshot rendering. PR-B builds the evidence base for PR-D. PR-C is a defense-in-depth layer. PR-D ships only if Fix 4 logs show partial-state uploads happening. + +## Testing + +### Unit tests + +- **`runDestructiveStateReplacement` fault injection:** simulate failure at each step (stage write, verify read, each line of the destructive tx). Verify post-condition is "OPS unchanged, STATE_CACHE singleton unchanged, VECTOR_CLOCK unchanged" — even with a staging row left over. +- **Boot-time staging reconciliation:** seed STATE_CACHE with a `STATE_CACHE_STAGING_KEY` row, run init, verify the staging row is deleted and the singleton is untouched. +- **`LocalDataConflictError` shape:** verify each of the three throw sites constructs the correct `RemoteDataDescriptor` variant. +- ~~**`pre-migration-backup` round-trip:** write a backup via the new implementation, verify it can be restored.~~ Deleted in PR-A — see Fix 2 note above. + +### Integration tests (Karma) + +In `compaction.integration.spec.ts` style: +- `createCleanSlate` interrupt-during-tx → device state unchanged. +- `BackupService.importComplete` interrupt-during-tx → device state unchanged. +- Both flows complete-and-commit → device state correctly replaced. + +### Cross-platform smoke test + +Per Performance W1: verify the destructive tx commits on each runtime (Electron, Chromium web, Capacitor iOS, Capacitor Android) with a multi-MB pre-staged STATE_CACHE row. + +### E2E + +One Playwright test reproducing the issue #7709 chain on the *fixed* code: +1. Set up a device with 10 tasks + 1 hour tracked. +2. Trigger `createCleanSlate` and inject an exception at the destructive tx. +3. Reload the app. +4. Assert: device state matches pre-trigger state; no fresh-client conflict dialog. + +## Resolved questions (from the original plan) + +- **Q1 (50% threshold):** Cut. Replaced with temporal gate in PR-D (if ever needed). +- **Q2 (no-reference case):** Cut along with Q1. Temporal gate doesn't need reference counts. +- **Q3 (reference counts storage):** Cut along with Q1. No new schema. +- **Q4 (transaction threading):** Resolved — helper method on `OperationLogStoreService` (now that BackupService is a second caller, helper is justified). +- **Q5 (op-streaming preview):** Resolved — synthetic `{ kind: 'opCount', opCount: N }` descriptor; dialog branches on `kind`. +- **Q6 (BACKUP_IMPORT/REPAIR):** Resolved — they get atomicity (Fix 2) and the modal (Fix 5), no preflight needed even when preflight ships. + +## Open questions for PR-A review + +These are smaller decisions, not design alternatives: + +- ~~**OA1.** Should `IMPORT_BACKUP` store be reused for the pre-migration backup, or should we add a new store?~~ Moot — `PreMigrationBackupService` deleted in PR-A. +- **OA2.** Should `runDestructiveStateReplacement` accept the operation entry directly or build it from primitives? Affects testability vs. caller ergonomics. +- **OA3.** Cross-platform smoke test execution — manual on each runtime before merge, or block on a CI matrix? Currently no Capacitor CI in this repo. + +## What this plan does NOT fix + +- **Fully-compromised client.** Out of scope per threat model. A malicious browser extension, XSS attacker with IDB write access, or compromised app process can wipe the user's own server data; the preflight reads NgRx via `StateSnapshotService`, which can be poisoned. +- **External IDB wipe.** A user whose browser cache is cleared still appears as a fresh client. The atomicity fix doesn't help. Users with this risk should keep periodic JSON exports via the existing backup flow. +- **Server-side acceptance.** Old/buggy/malicious clients can still wipe their own server data because the server trusts `isCleanSlate=true` unconditionally. Server-side completeness gating (Alternatives alt #1) is the right follow-up and is tracked as a separate plan. + +## Evidence / verification + +Code references verified at `feat/issue-7709-567f99` HEAD `c5158dd35b`: + +- Destructive triggers: `encryption-password-change.service.ts:97` (only production caller of `createCleanSlate`), `sync-import-conflict-coordinator.service.ts:51-67`, `sync-wrapper.service.ts:687/699/712/739/843/1021/1140`, `_handleLocalDataConflict` at 1161-1262, `backup.service.ts:191-233` (5th trigger). +- Destructive primitive: `clean-slate.service.ts:81-176`. Non-atomic sequence at 149-168. Client-ID rotation + vector-clock reset at 121-126. +- `BackupService.importComplete` non-atomic sequence: `backup.service.ts:194` (clear), `:221` (append), `:225` (setVectorClock), `:227` (saveStateCache). +- Upload-service clean-slate-by-default: `operation-log-upload.service.ts:129-130`. +- `isWhollyFreshClient`: `sync-local-state.service.ts:25-30`. +- Empty-snapshot throw: `operation-log-sync.service.ts:606`. `result.snapshotState` is undefined at this line (verified — past all three `fileSnapshotOps` early-returns at 458/484/517). +- `pre-migration-backup.service.ts`: confirmed placeholder, all methods are no-ops. +- `handleServerMigration` (`server-migration.service.ts:200-269`): only appends a SYNC_IMPORT op locally; no `clearAllOperations` / `saveStateCache` / `setVectorClock`. The "Keep local" path is destructive only on the server (via `isCleanSlate=true` flag on upload), not on the local device. (One multi-review reviewer overstated this as a non-atomic local sequence; verification shows it isn't.) +- `incrementCompactionCounter`: no production callers (verified via grep across `src/`). +- Compaction threshold: `operation-log.const.ts:88` (`COMPACTION_THRESHOLD = 500`). +- Confirmation prompt fail-open: `dialog-sync-conflict.component.ts:153-168`. Only fires when `|remoteChanges - localChanges| >= 20`. +- No feature-flag mechanism: grep confirmed (`FeatureFlag|featureToggle|growthbook|posthog` all absent from `src/`). + +## Revision history + +- **2026-05-21 (initial):** First design pass. Preflight + atomicity + modal + logging + throw-fix + dead code. 50% completeness threshold. 3-PR rollout (logging/throw, atomicity, preflight/modal). +- **2026-05-21 (post-multi-review):** Substantial revision after 6-reviewer parallel pass. + - Added `BackupService.importComplete` as the 5th destructive trigger (multi-review C2 finding). + - Corrected Fix 3 — line 606 has no `snapshotState`; synthetic descriptor for op-streaming case; introduced `RemoteDataDescriptor` discriminated union. + - Corrected Fix 1 caller graph — `createCleanSlate` has only one production caller; preflight wraps three independent call sites if it ships. + - Cut preflight (Fix 1) from v1; gated on log evidence from Fix 4. + - Initially switched atomicity from multi-store-tx-with-large-payload to snapshot-then-swap; later reverted to single multi-store tx after verifying `appendWithVectorClockUpdate` already uses multi-store readwrite on every action (the staging row was over-engineering on a wrong premise). + - Switched from inline IDB calls to `runDestructiveStateReplacement` helper (BackupService is a second caller, helper justified). + - Acknowledged `pre-migration-backup.service.ts` is a placeholder; PR-A implements it for real. + - Removed Q3 schema-migration cost claim (STATE_CACHE rows already support optional fields). + - Removed feature-flag plan (no flag framework in codebase). + - Reordered PRs: atomicity first (PR-A), logging second (PR-B), modal third (PR-C), preflight conditional fourth (PR-D). + - Modal copy: dropped counts (per Simplicity W4). + - Added explicit threat-model statement excluding fully-compromised clients (per Security W2). + - Forensic log payload: counts-only, never vector-clock contents (per Security C2). + - Narrowed `LocalDataConflictError.remoteSnapshotState` type to eliminate future XSS surface (per Security C1). + - Added export-to-file escape hatch in PR-D (per Alternatives alt #8). diff --git a/src/app/core/util/client-id.service.spec.ts b/src/app/core/util/client-id.service.spec.ts index 9772484e8e..66d1e2ac6e 100644 --- a/src/app/core/util/client-id.service.spec.ts +++ b/src/app/core/util/client-id.service.spec.ts @@ -2,6 +2,7 @@ import { TestBed } from '@angular/core/testing'; import { ClientIdService } from './client-id.service'; import { SnackService } from '../snack/snack.service'; import { openDB } from 'idb'; +import { OpLog } from '../log'; // Constants that mirror the private constants in ClientIdService const DB_NAME = 'pf'; @@ -162,4 +163,123 @@ describe('ClientIdService', () => { await expectAsync(service.persistClientId('INVALID')).toBeRejected(); }); }); + + describe('withRotation()', () => { + it('should call fn with a fresh clientId and return its value', async () => { + await service.persistClientId('B_Prio'); + service.clearCache(); + + const result = await service.withRotation('[Test]', async (newId) => { + expect(newId).not.toBe('B_Prio'); + return { ok: true, id: newId }; + }); + + expect(result.ok).toBe(true); + const persisted = await service.loadClientId(); + expect(persisted).toBe(result.id); + }); + + it('should restore the prior clientId when fn throws', async () => { + await service.persistClientId('B_Prio'); + service.clearCache(); + + await expectAsync( + service.withRotation('[Test]', async () => { + throw new Error('work failed'); + }), + ).toBeRejectedWith(jasmine.objectContaining({ message: 'work failed' })); + + service.clearCache(); + expect(await service.loadClientId()).toBe('B_Prio'); + }); + + it('should restore the persisted prior clientId even when the cache is stale', async () => { + await service.persistClientId('B_Cach'); + await writeRawClientId('B_Fres'); + + await expectAsync( + service.withRotation('[Test]', async () => { + throw new Error('work failed'); + }), + ).toBeRejectedWith(jasmine.objectContaining({ message: 'work failed' })); + + service.clearCache(); + expect(await service.loadClientId()).toBe('B_Fres'); + }); + + it('should not roll back over a newer persisted clientId from another context', async () => { + await service.persistClientId('B_Prio'); + service.clearCache(); + + await expectAsync( + service.withRotation('[Test]', async () => { + await writeRawClientId('B_Othr'); + throw new Error('work failed'); + }), + ).toBeRejectedWith(jasmine.objectContaining({ message: 'work failed' })); + + service.clearCache(); + expect(await service.loadClientId()).toBe('B_Othr'); + }); + + it('should leave the rotated clientId in place when there was no prior id', async () => { + // Wholly fresh device — `pf` is empty. + expect(await service.loadClientId()).toBeNull(); + + await expectAsync( + service.withRotation('[Test]', async () => { + throw new Error('work failed'); + }), + ).toBeRejectedWith(jasmine.objectContaining({ message: 'work failed' })); + + service.clearCache(); + const persisted = await service.loadClientId(); + expect(persisted).not.toBeNull(); + }); + + it('should propagate the original fn error when rollback also fails', async () => { + await service.persistClientId('B_Prio'); + service.clearCache(); + spyOn(service as any, '_restorePriorClientIdIfCurrentMatches').and.rejectWith( + new Error('pf write also broken'), + ); + + await expectAsync( + service.withRotation('[Test]', async () => { + throw new Error('work failed'); + }), + ).toBeRejectedWith(jasmine.objectContaining({ message: 'work failed' })); + }); + + it('should not log clientIds or raw error messages when rollback fails', async () => { + await service.persistClientId('B_Prio'); + service.clearCache(); + spyOn(service as any, '_restorePriorClientIdIfCurrentMatches').and.rejectWith( + new Error('rollback failed with B_Prio'), + ); + const opLogSpy = spyOn(OpLog, 'critical'); + + await expectAsync( + service.withRotation('[Test]', async () => { + throw new Error('work failed with B_Prio'); + }), + ).toBeRejectedWith( + jasmine.objectContaining({ message: 'work failed with B_Prio' }), + ); + + expect(opLogSpy).toHaveBeenCalled(); + const payload = opLogSpy.calls.mostRecent().args[1] as Record; + const serializedPayload = JSON.stringify(payload); + expect(payload).toEqual( + jasmine.objectContaining({ + hadPriorClientId: true, + originalErrorName: 'Error', + rollbackErrorName: 'Error', + }), + ); + expect(serializedPayload).not.toContain('B_Prio'); + expect(serializedPayload).not.toContain('work failed'); + expect(serializedPayload).not.toContain('rollback failed'); + }); + }); }); diff --git a/src/app/core/util/client-id.service.ts b/src/app/core/util/client-id.service.ts index 18744351b0..d614a726d6 100644 --- a/src/app/core/util/client-id.service.ts +++ b/src/app/core/util/client-id.service.ts @@ -30,8 +30,8 @@ export class ClientIdService { /** * Loads the client ID. * - * Uses caching to avoid repeated IndexedDB reads. The client ID is - * immutable once generated, so caching is safe. + * Uses caching to avoid repeated IndexedDB reads. Rotation paths bypass or + * refresh the cache when they need cross-context freshness. * * @returns The client ID, or null if not yet generated */ @@ -40,33 +40,12 @@ export class ClientIdService { return this._cachedClientId; } - const db = await this._getDb(); - const clientId = await db.get(DB_STORE_NAME, CLIENT_ID_KEY); + const clientId = await this._readPersistedValidClientId({ warnOnInvalid: true }); - if (typeof clientId !== 'string') { - return null; + if (clientId) { + this._cachedClientId = clientId; + OpLog.normal('ClientIdService.loadClientId() loaded'); } - - if (!this._isValidClientIdFormat(clientId)) { - // Unrecognized format — log but treat as missing rather than throwing. - // Throwing here permanently blocks sync (issue #6197: "Invalid clientId loaded: B_H8AR"). - // Returning null causes the caller to generate a fresh clientId, which unblocks sync. - OpLog.critical( - 'ClientIdService.loadClientId() Invalid clientId format, will regenerate:', - { - clientId, - length: clientId.length, - }, - ); - this._snackService?.open({ - msg: T.F.SYNC.S.WARN_CLIENT_ID_REGENERATED, - type: 'WARNING', - }); - return null; - } - - this._cachedClientId = clientId; - OpLog.normal('ClientIdService.loadClientId() loaded:', { clientId }); return clientId; } @@ -85,7 +64,7 @@ export class ClientIdService { await db.put(DB_STORE_NAME, newClientId, CLIENT_ID_KEY); this._cachedClientId = newClientId; - OpLog.normal('ClientIdService.generateNewClientId() generated:', { newClientId }); + OpLog.normal('ClientIdService.generateNewClientId() generated'); return newClientId; } @@ -103,7 +82,7 @@ export class ClientIdService { const db = await this._getDb(); await db.put(DB_STORE_NAME, clientId, CLIENT_ID_KEY); this._cachedClientId = clientId; - OpLog.normal('ClientIdService.persistClientId() persisted:', { clientId }); + OpLog.normal('ClientIdService.persistClientId() persisted'); } /** @@ -125,6 +104,47 @@ export class ClientIdService { this._cachedClientId = null; } + /** + * Rotate the clientId for the duration of `fn`. Captures the prior id, + * generates and persists a new one, runs `fn(newClientId)`. If `fn` throws, + * the prior id is restored so the `pf` database stays consistent with any + * caller-side state that didn't get updated. + * + * Edge case: if there was no prior clientId (wholly fresh device), the new + * id is intentionally left in `pf` on failure — there is nothing to restore + * to. If the restore itself throws, the original `fn` error is propagated + * and the restore failure is logged at critical level for forensics. + * + * `logPrefix` is used to tag the critical-log entry on rollback failure so + * forensics can attribute it to the caller (e.g. `'[CleanSlate]'`). + */ + async withRotation( + logPrefix: string, + fn: (newClientId: string) => Promise, + ): Promise { + const priorClientId = await this._readPersistedValidClientId(); + const newClientId = await this.generateNewClientId(); + try { + return await fn(newClientId); + } catch (e) { + if (priorClientId) { + try { + await this._restorePriorClientIdIfCurrentMatches(priorClientId, newClientId); + } catch (rollbackErr) { + OpLog.critical( + `${logPrefix} Failed to roll back clientId rotation after failure`, + { + hadPriorClientId: true, + originalErrorName: this._errorName(e), + rollbackErrorName: this._errorName(rollbackErr), + }, + ); + } + } + throw e; + } + } + /** * Returns true if the clientId matches a known valid format. * Old format: any string of length >= 10 (legacy IDs). @@ -134,6 +154,67 @@ export class ClientIdService { return clientId.length >= 10 || /^[BEAI]_[a-zA-Z0-9]{4}$/.test(clientId); } + private async _readPersistedValidClientId( + options: { warnOnInvalid?: boolean } = {}, + ): Promise { + const db = await this._getDb(); + const clientId = await db.get(DB_STORE_NAME, CLIENT_ID_KEY); + + if (typeof clientId !== 'string') { + return null; + } + + if (!this._isValidClientIdFormat(clientId)) { + if (options.warnOnInvalid) { + // Unrecognized format — log but treat as missing rather than throwing. + // Throwing here permanently blocks sync (issue #6197: "Invalid clientId loaded: B_H8AR"). + // Returning null causes the caller to generate a fresh clientId, which unblocks sync. + // Length only — the literal clientId value is sensitive (vector-clock + // key) and log history is user-exportable (CLAUDE.md sync rule 9). + OpLog.critical( + 'ClientIdService.loadClientId() Invalid clientId format, will regenerate:', + { + length: clientId.length, + }, + ); + this._snackService?.open({ + msg: T.F.SYNC.S.WARN_CLIENT_ID_REGENERATED, + type: 'WARNING', + }); + } + return null; + } + + return clientId; + } + + private async _restorePriorClientIdIfCurrentMatches( + priorClientId: string, + expectedCurrentClientId: string, + ): Promise { + const db = await this._getDb(); + const tx = db.transaction(DB_STORE_NAME, 'readwrite'); + const store = tx.objectStore(DB_STORE_NAME); + const currentClientId = await store.get(CLIENT_ID_KEY); + + if (currentClientId === expectedCurrentClientId) { + await store.put(priorClientId, CLIENT_ID_KEY); + await tx.done; + this._cachedClientId = priorClientId; + return; + } + + await tx.done; + this._cachedClientId = + typeof currentClientId === 'string' && this._isValidClientIdFormat(currentClientId) + ? currentClientId + : null; + } + + private _errorName(error: unknown): string { + return error instanceof Error ? error.name : typeof error; + } + /** * Gets or opens the IndexedDB database. */ diff --git a/src/app/features/focus-mode/store/focus-mode.effects.spec.ts b/src/app/features/focus-mode/store/focus-mode.effects.spec.ts index a2041ec906..10b2ba30df 100644 --- a/src/app/features/focus-mode/store/focus-mode.effects.spec.ts +++ b/src/app/features/focus-mode/store/focus-mode.effects.spec.ts @@ -2380,13 +2380,15 @@ describe('FocusModeEffects', () => { describe('detectBreakTimeUp$', () => { it('should call notification when break timer completes', (done) => { + const breakDuration = 5 * 60 * 1000; store.overrideSelector( selectors.selectTimer, createMockTimer({ isRunning: false, purpose: 'break', - duration: 5 * 60 * 1000, - elapsed: 5 * 60 * 1000, + startedAt: Date.now() - breakDuration, + duration: breakDuration, + elapsed: breakDuration, }), ); store.refreshState(); @@ -2431,6 +2433,7 @@ describe('FocusModeEffects', () => { createMockTimer({ isRunning: false, purpose: 'break', + startedAt: Date.now() - 1000, duration: 0, elapsed: 0, }), @@ -2445,6 +2448,28 @@ describe('FocusModeEffects', () => { done(); }); }); + + it('should NOT notify for an unstarted duration=0 break offer', (done) => { + store.overrideSelector( + selectors.selectTimer, + createMockTimer({ + isRunning: false, + purpose: 'break', + startedAt: null, + duration: 0, + elapsed: 0, + }), + ); + store.refreshState(); + + effects = TestBed.inject(FocusModeEffects); + const notifyUserSpy = spyOn(effects as any, '_notifyUser'); + + setTimeout(() => { + expect(notifyUserSpy).not.toHaveBeenCalled(); + done(); + }, 50); + }); }); describe('Bug #5954 Additional Edge Cases', () => { diff --git a/src/app/features/focus-mode/store/focus-mode.effects.ts b/src/app/features/focus-mode/store/focus-mode.effects.ts index 7804b0bfd3..f0cfabcb56 100644 --- a/src/app/features/focus-mode/store/focus-mode.effects.ts +++ b/src/app/features/focus-mode/store/focus-mode.effects.ts @@ -286,6 +286,7 @@ export class FocusModeEffects { (timer) => timer.purpose === 'break' && !timer.isRunning && + timer.startedAt !== null && timer.elapsed >= timer.duration, ), distinctUntilChanged( diff --git a/src/app/op-log/backup/backup.service.spec.ts b/src/app/op-log/backup/backup.service.spec.ts index 5525348928..dbeea9870d 100644 --- a/src/app/op-log/backup/backup.service.spec.ts +++ b/src/app/op-log/backup/backup.service.spec.ts @@ -5,10 +5,12 @@ import { ImexViewService } from '../../imex/imex-meta/imex-view.service'; import { StateSnapshotService } from './state-snapshot.service'; import { OperationLogStoreService } from '../persistence/operation-log-store.service'; import { ClientIdService } from '../../core/util/client-id.service'; -import { ArchiveDbAdapter } from '../../core/persistence/archive-db-adapter.service'; import { ArchiveModel } from '../../features/archive/archive.model'; import { loadAllData } from '../../root-store/meta/load-all-data.action'; -import { OpType, Operation } from '../core/operation.types'; +import { OpType } from '../core/operation.types'; +import { OperationWriteFlushService } from '../sync/operation-write-flush.service'; +import { LockService } from '../sync/lock.service'; +import { LOCK_NAMES } from '../core/operation-log.const'; describe('BackupService', () => { let service: BackupService; @@ -17,7 +19,8 @@ describe('BackupService', () => { let mockStateSnapshotService: jasmine.SpyObj; let mockOpLogStore: jasmine.SpyObj; let mockClientIdService: jasmine.SpyObj; - let mockArchiveDbAdapter: jasmine.SpyObj; + let mockOperationWriteFlushService: jasmine.SpyObj; + let mockLockService: jasmine.SpyObj; // eslint-disable-next-line @typescript-eslint/explicit-function-return-type const createMinimalValidBackup = () => ({ @@ -98,34 +101,32 @@ describe('BackupService', () => { mockStateSnapshotService = jasmine.createSpyObj('StateSnapshotService', [ 'getAllSyncModelDataFromStore', 'getAllSyncModelDataFromStoreAsync', + 'getStateSnapshotAsync', ]); mockOpLogStore = jasmine.createSpyObj('OperationLogStoreService', [ - 'loadStateCache', 'saveImportBackup', - 'clearAllOperations', - 'append', - 'getLastSeq', - 'saveStateCache', - 'setVectorClock', + 'runDestructiveStateReplacement', ]); - mockClientIdService = jasmine.createSpyObj('ClientIdService', [ - 'generateNewClientId', - ]); - mockArchiveDbAdapter = jasmine.createSpyObj('ArchiveDbAdapter', [ - 'saveArchiveYoung', - 'saveArchiveOld', + mockClientIdService = jasmine.createSpyObj('ClientIdService', ['withRotation']); + mockOperationWriteFlushService = jasmine.createSpyObj('OperationWriteFlushService', [ + 'flushPendingWrites', ]); + mockLockService = jasmine.createSpyObj('LockService', ['request']); // Default mock returns - mockOpLogStore.loadStateCache.and.returnValue(Promise.resolve(null)); - mockOpLogStore.clearAllOperations.and.returnValue(Promise.resolve()); - mockOpLogStore.append.and.returnValue(Promise.resolve(1)); - mockOpLogStore.getLastSeq.and.returnValue(Promise.resolve(1)); - mockOpLogStore.saveStateCache.and.returnValue(Promise.resolve()); - mockOpLogStore.setVectorClock.and.resolveTo(); - mockClientIdService.generateNewClientId.and.resolveTo('newClientId'); - mockArchiveDbAdapter.saveArchiveYoung.and.returnValue(Promise.resolve()); - mockArchiveDbAdapter.saveArchiveOld.and.returnValue(Promise.resolve()); + mockStateSnapshotService.getStateSnapshotAsync.and.resolveTo( + createMinimalValidBackup() as any, + ); + mockOpLogStore.saveImportBackup.and.resolveTo(); + mockOpLogStore.runDestructiveStateReplacement.and.resolveTo(); + // ClientIdService.withRotation owns the rollback semantics (see its own + // spec); here we just invoke the callback with a fresh id. + mockClientIdService.withRotation.and.callFake( + async (_logPrefix: string, fn: (newClientId: string) => Promise) => + fn('newClientId'), + ); + mockOperationWriteFlushService.flushPendingWrites.and.resolveTo(); + mockLockService.request.and.callFake(async (_lockName, fn) => fn()); TestBed.configureTestingModule({ providers: [ @@ -135,7 +136,11 @@ describe('BackupService', () => { { provide: StateSnapshotService, useValue: mockStateSnapshotService }, { provide: OperationLogStoreService, useValue: mockOpLogStore }, { provide: ClientIdService, useValue: mockClientIdService }, - { provide: ArchiveDbAdapter, useValue: mockArchiveDbAdapter }, + { + provide: OperationWriteFlushService, + useValue: mockOperationWriteFlushService, + }, + { provide: LockService, useValue: mockLockService }, ], }); @@ -165,8 +170,33 @@ describe('BackupService', () => { await service.importCompleteBackup(backupData as any, true, true); - expect(mockOpLogStore.append).toHaveBeenCalled(); - expect(mockOpLogStore.saveStateCache).toHaveBeenCalled(); + // Issue #7709: the destructive sequence is now a single atomic call. + expect(mockOpLogStore.runDestructiveStateReplacement).toHaveBeenCalledTimes(1); + }); + + it('should flush pending writes and hold the op-log lock during import replacement', async () => { + const callOrder: string[] = []; + mockOperationWriteFlushService.flushPendingWrites.and.callFake(async () => { + callOrder.push('flush'); + }); + mockLockService.request.and.callFake(async (lockName, fn) => { + callOrder.push(`lock:${lockName}`); + const r = await fn(); + callOrder.push('unlock'); + return r; + }); + mockOpLogStore.runDestructiveStateReplacement.and.callFake(async () => { + callOrder.push('replace'); + }); + + await service.importCompleteBackup(createMinimalValidBackup() as any, true, true); + + expect(callOrder).toEqual([ + 'flush', + `lock:${LOCK_NAMES.OPERATION_LOG}`, + 'replace', + 'unlock', + ]); }); it('should normalize invalid startOfNextDay config before persisting import', async () => { @@ -179,18 +209,15 @@ describe('BackupService', () => { await service.importCompleteBackup(backupData as any, true, true); - const appendedOp = mockOpLogStore.append.calls.mostRecent().args[0] as Operation; - const appendedPayload = appendedOp.payload as any; + const args = mockOpLogStore.runDestructiveStateReplacement.calls.mostRecent() + .args[0] as Parameters[0]; + + const appendedPayload = args.syncImportOp.payload as any; expect(appendedPayload.globalConfig.misc.startOfNextDay).toBe(4); expect(appendedPayload.globalConfig.misc.startOfNextDayTime).toBe('04:00'); - - const savedState = mockOpLogStore.saveStateCache.calls.mostRecent().args[0] - .state as any; - expect(savedState.globalConfig.misc.startOfNextDay).toBe(4); - expect(savedState.globalConfig.misc.startOfNextDayTime).toBe('04:00'); }); - it('should write archiveYoung to IndexedDB when present in backup', async () => { + it('should pass archiveYoung to the atomic replacement when present in backup', async () => { const archiveYoung = createArchiveModel('archived-task-1', 'Archived Task Young'); const backupData = { ...createMinimalValidBackup(), @@ -200,13 +227,12 @@ describe('BackupService', () => { await service.importCompleteBackup(backupData as any, true, true); - // dataRepair may modify the data, so check it was called with archive data - expect(mockArchiveDbAdapter.saveArchiveYoung).toHaveBeenCalled(); - const calledWith = mockArchiveDbAdapter.saveArchiveYoung.calls.mostRecent().args[0]; - expect(calledWith.task.ids).toContain('archived-task-1'); + const args = mockOpLogStore.runDestructiveStateReplacement.calls.mostRecent() + .args[0] as Parameters[0]; + expect(args.archiveYoung?.task.ids).toContain('archived-task-1'); }); - it('should write archiveOld separately to IndexedDB when present in backup', async () => { + it('should pass archiveOld separately to the atomic replacement when present in backup', async () => { // Note: Dual-archive architecture keeps archives separate (no merge) const archiveOld = createArchiveModel('archived-task-old', 'Archived Task Old'); const backupData = { @@ -217,22 +243,13 @@ describe('BackupService', () => { await service.importCompleteBackup(backupData as any, true, true); - // Both archives should be written - expect(mockArchiveDbAdapter.saveArchiveYoung).toHaveBeenCalled(); - expect(mockArchiveDbAdapter.saveArchiveOld).toHaveBeenCalled(); - - // archiveOld remains separate - verify it was written to IndexedDB - const oldCalledWith = - mockArchiveDbAdapter.saveArchiveOld.calls.mostRecent().args[0]; - expect(oldCalledWith.task.ids).toContain('archived-task-old'); - - // archiveYoung should remain empty (no merge happened) - const youngCalledWith = - mockArchiveDbAdapter.saveArchiveYoung.calls.mostRecent().args[0]; - expect(youngCalledWith.task.ids).toEqual([]); + const args = mockOpLogStore.runDestructiveStateReplacement.calls.mostRecent() + .args[0] as Parameters[0]; + expect(args.archiveOld?.task.ids).toContain('archived-task-old'); + expect(args.archiveYoung?.task.ids).toEqual([]); }); - it('should write both archiveYoung and archiveOld when both present', async () => { + it('should pass both archiveYoung and archiveOld to the atomic replacement when both present', async () => { const archiveYoung = createArchiveModel('young-task', 'Young Archived Task'); const archiveOld = createArchiveModel('old-task', 'Old Archived Task'); const backupData = { @@ -243,31 +260,25 @@ describe('BackupService', () => { await service.importCompleteBackup(backupData as any, true, true); - expect(mockArchiveDbAdapter.saveArchiveYoung).toHaveBeenCalled(); - expect(mockArchiveDbAdapter.saveArchiveOld).toHaveBeenCalled(); - - // Dual-archive architecture: verify both written separately (no merge) - const youngCalledWith = - mockArchiveDbAdapter.saveArchiveYoung.calls.mostRecent().args[0]; - expect(youngCalledWith.task.ids).toContain('young-task'); - expect(youngCalledWith.task.ids).not.toContain('old-task'); - - const oldCalledWith = - mockArchiveDbAdapter.saveArchiveOld.calls.mostRecent().args[0]; - expect(oldCalledWith.task.ids).toContain('old-task'); - expect(oldCalledWith.task.ids).not.toContain('young-task'); + const args = mockOpLogStore.runDestructiveStateReplacement.calls.mostRecent() + .args[0] as Parameters[0]; + expect(args.archiveYoung?.task.ids).toContain('young-task'); + expect(args.archiveYoung?.task.ids).not.toContain('old-task'); + expect(args.archiveOld?.task.ids).toContain('old-task'); + expect(args.archiveOld?.task.ids).not.toContain('young-task'); }); - it('should write default empty archives when not present in backup (added by dataRepair)', async () => { + it('should pass default empty archives when not present in backup (added by dataRepair)', async () => { // dataRepair adds default empty archives if not present const backupData = createMinimalValidBackup(); // No archiveYoung or archiveOld property await service.importCompleteBackup(backupData as any, true, true); - // dataRepair adds default archives, so they should be written - expect(mockArchiveDbAdapter.saveArchiveYoung).toHaveBeenCalled(); - expect(mockArchiveDbAdapter.saveArchiveOld).toHaveBeenCalled(); + const args = mockOpLogStore.runDestructiveStateReplacement.calls.mostRecent() + .args[0] as Parameters[0]; + expect(args.archiveYoung?.task.ids).toEqual([]); + expect(args.archiveOld?.task.ids).toEqual([]); }); it('should handle CompleteBackup wrapper format with archives', async () => { @@ -285,30 +296,33 @@ describe('BackupService', () => { await service.importCompleteBackup(wrappedBackup as any, true, true); - expect(mockArchiveDbAdapter.saveArchiveYoung).toHaveBeenCalled(); - const calledWith = mockArchiveDbAdapter.saveArchiveYoung.calls.mostRecent().args[0]; - expect(calledWith.task.ids).toContain('wrapped-task'); + const args = mockOpLogStore.runDestructiveStateReplacement.calls.mostRecent() + .args[0] as Parameters[0]; + expect(args.archiveYoung?.task.ids).toContain('wrapped-task'); }); - it('should call setVectorClock with fresh clock', async () => { + it('should pass a fresh { [clientId]: 1 } vector clock to the atomic helper', async () => { const backupData = createMinimalValidBackup(); await service.importCompleteBackup(backupData as any, true, true); - expect(mockOpLogStore.setVectorClock).toHaveBeenCalled(); - const calledClock = mockOpLogStore.setVectorClock.calls.mostRecent().args[0]; - expect(calledClock).toEqual({ newClientId: 1 }); + const args = mockOpLogStore.runDestructiveStateReplacement.calls.mostRecent() + .args[0] as Parameters[0]; + expect(args.syncImportOp.vectorClock).toEqual({ newClientId: 1 }); }); - it('should call setVectorClock with fresh clock on import', async () => { - mockClientIdService.generateNewClientId.and.resolveTo('newForceClient'); + it('should pass a fresh clock to the atomic helper on force-import', async () => { + mockClientIdService.withRotation.and.callFake( + async (_logPrefix: string, fn: (newClientId: string) => Promise) => + fn('newForceClient'), + ); const backupData = createMinimalValidBackup(); await service.importCompleteBackup(backupData as any, true, true, true); - expect(mockOpLogStore.setVectorClock).toHaveBeenCalled(); - const calledClock = mockOpLogStore.setVectorClock.calls.mostRecent().args[0]; - expect(calledClock).toEqual({ newForceClient: 1 }); + const args = mockOpLogStore.runDestructiveStateReplacement.calls.mostRecent() + .args[0] as Parameters[0]; + expect(args.syncImportOp.vectorClock).toEqual({ newForceClient: 1 }); }); /** @@ -325,23 +339,82 @@ describe('BackupService', () => { await service.importCompleteBackup(backupData as any, true, true); - expect(mockOpLogStore.append).toHaveBeenCalled(); - const appendedOp = mockOpLogStore.append.calls.mostRecent().args[0] as Operation; + const args = mockOpLogStore.runDestructiveStateReplacement.calls.mostRecent() + .args[0] as Parameters[0]; // CRITICAL: Must use BackupImport so server receives reason='recovery' - expect(appendedOp.opType).toBe(OpType.BackupImport); + expect(args.syncImportOp.opType).toBe(OpType.BackupImport); // Verify it's NOT using SyncImport (which would cause 409 errors) - expect(appendedOp.opType).not.toBe(OpType.SyncImport); + expect(args.syncImportOp.opType).not.toBe(OpType.SyncImport); }); it('should produce fresh { [clientId]: 1 } clock on import', async () => { - mockClientIdService.generateNewClientId.and.resolveTo('newForceClient'); + mockClientIdService.withRotation.and.callFake( + async (_logPrefix: string, fn: (newClientId: string) => Promise) => + fn('newForceClient'), + ); const backupData = createMinimalValidBackup(); await service.importCompleteBackup(backupData as any, true, true, true); - const appendedOp = mockOpLogStore.append.calls.mostRecent().args[0] as Operation; - expect(appendedOp.vectorClock).toEqual({ newForceClient: 1 }); + const args = mockOpLogStore.runDestructiveStateReplacement.calls.mostRecent() + .args[0] as Parameters[0]; + expect(args.syncImportOp.vectorClock).toEqual({ newForceClient: 1 }); + }); + + it('should abort the import (and not dispatch loadAllData) when the pre-import backup fails', async () => { + // Without this, the silent early-return in _persistImportToOperationLog + // leaves the device in a hybrid state: NgRx + archive DBs + sync seqs + // replaced with imported data, but OPS / state_cache / vector_clock + // still hold the previous content. + mockOpLogStore.saveImportBackup.and.rejectWith(new Error('IDB quota exceeded')); + + const backupData = createMinimalValidBackup(); + + await expectAsync( + service.importCompleteBackup(backupData as any, true, true), + ).toBeRejected(); + + expect(mockOpLogStore.runDestructiveStateReplacement).not.toHaveBeenCalled(); + expect(mockStore.dispatch).not.toHaveBeenCalled(); + }); + + it('should save a fresh async state snapshot before import', async () => { + const currentState = { + ...createMinimalValidBackup(), + archiveYoung: createArchiveModel('current-young', 'Current Young'), + archiveOld: createArchiveModel('current-old', 'Current Old'), + }; + mockStateSnapshotService.getStateSnapshotAsync.and.resolveTo(currentState as any); + + await service.importCompleteBackup(createMinimalValidBackup() as any, true, true); + + expect(mockStateSnapshotService.getStateSnapshotAsync).toHaveBeenCalled(); + expect(mockOpLogStore.saveImportBackup).toHaveBeenCalledWith(currentState); + }); + + it('should delegate cross-DB clientId rollback to ClientIdService.withRotation', async () => { + // ClientIdService.withRotation owns the rollback semantics — capture + // prior id, run callback, restore on failure, log critical if rollback + // also fails. Tested directly in client-id.service.spec.ts; here we + // only verify BackupService routes through it with the right log tag. + await service.importCompleteBackup(createMinimalValidBackup() as any, true, true); + + expect(mockClientIdService.withRotation).toHaveBeenCalledWith( + 'BackupService:', + jasmine.any(Function), + ); + }); + + it('should pass snapshotEntityKeys derived from the imported data', async () => { + const backupData = createMinimalValidBackup(); + + await service.importCompleteBackup(backupData as any, true, true); + + const args = mockOpLogStore.runDestructiveStateReplacement.calls.mostRecent() + .args[0] as Parameters[0]; + expect(args.snapshotEntityKeys).toBeDefined(); + expect(Array.isArray(args.snapshotEntityKeys)).toBe(true); }); }); }); diff --git a/src/app/op-log/backup/backup.service.ts b/src/app/op-log/backup/backup.service.ts index d967600c05..f6bf5ef27e 100644 --- a/src/app/op-log/backup/backup.service.ts +++ b/src/app/op-log/backup/backup.service.ts @@ -16,9 +16,11 @@ import { AllModelConfig, } from '../model/model-config'; import { CompleteBackup } from '../core/types/sync.types'; -import { ArchiveDbAdapter } from '../../core/persistence/archive-db-adapter.service'; -import { ArchiveModel } from '../../features/archive/archive.model'; import { normalizeGlobalConfigStartOfNextDay } from '../../features/config/normalize-start-of-next-day-config'; +import { extractEntityKeysFromState } from '../persistence/extract-entity-keys'; +import { OperationWriteFlushService } from '../sync/operation-write-flush.service'; +import { LockService } from '../sync/lock.service'; +import { LOCK_NAMES } from '../core/operation-log.const'; /** * Service for handling backup import and export operations. @@ -38,7 +40,8 @@ export class BackupService { private _stateSnapshotService = inject(StateSnapshotService); private _opLogStore = inject(OperationLogStoreService); private _clientIdService = inject(ClientIdService); - private _archiveDbAdapter = inject(ArchiveDbAdapter); + private _operationWriteFlushService = inject(OperationWriteFlushService); + private _lockService = inject(LockService); /** * Loads a complete backup of all application data. @@ -140,7 +143,10 @@ export class BackupService { } // 4. Persist to operation log - await this._persistImportToOperationLog(validatedData); + await this._operationWriteFlushService.flushPendingWrites(); + await this._lockService.request(LOCK_NAMES.OPERATION_LOG, async () => { + await this._persistImportToOperationLog(validatedData); + }); // 4b. Reset all sync providers' lastServerSeq to 0. // After a backup import, the client must re-sync from the beginning to ensure @@ -153,11 +159,6 @@ export class BackupService { // 5. Dispatch to NgRx this._store.dispatch(loadAllData({ appDataComplete: validatedData })); - // 6. Write archive data to IndexedDB - // ArchiveOperationHandler._handleLoadAllData() skips local imports (isRemote=false), - // so we must write archive data here for local backup imports. - await this._writeArchivesToIndexedDB(validatedData); - this._imexViewService.setDataImportInProgress(false); // Only reload if explicitly requested (legacy behavior fallback) @@ -175,61 +176,62 @@ export class BackupService { ): Promise { OpLog.normal('BackupService: Persisting import to operation log...'); - // 1. Backup current state before clearing operations - let backupSucceeded = true; + // 1. Backup current state before clearing operations. If this fails we + // throw — the caller unconditionally replaces NgRx + archives + sync seqs + // after this method returns, so silently skipping the destructive write + // would leave the device in a hybrid state (imported NgRx/archives, old + // op-log) that is worse than either outcome. try { - const existingStateCache = await this._opLogStore.loadStateCache(); - if (existingStateCache?.state) { - OpLog.normal('BackupService: Backing up current state before import...'); - await this._opLogStore.saveImportBackup(existingStateCache.state); - } + const currentState = await this._stateSnapshotService.getStateSnapshotAsync(); + OpLog.normal('BackupService: Backing up current state before import...'); + await this._opLogStore.saveImportBackup(currentState); } catch (e) { - OpLog.warn('BackupService: Failed to backup state before import:', e); - backupSucceeded = false; + // `message` is intentionally omitted: log history is user-exportable + // (CLAUDE.md sync rule 9), and a future validator/IDB error type could + // interpolate user content into its message. Match the `name`-only + // pattern used by `ClientIdService.withRotation` rollback logging. + OpLog.warn('BackupService: Failed to backup state before import:', { + name: (e as Error | undefined)?.name, + }); + throw new Error( + 'BackupService: Pre-import backup failed; aborting import to preserve local state.', + ); } - // 2. Clear all old operations to prevent IndexedDB bloat - if (backupSucceeded) { - OpLog.normal('BackupService: Clearing old operations before import...'); - await this._opLogStore.clearAllOperations(); - } + // Rotate the clientId for the duration of the destructive replacement. + // `pf` (clientId) is a separate IDB DB and so cannot share the atomic + // SUP_OPS tx; ClientIdService.withRotation rolls it back on failure. + await this._clientIdService.withRotation('BackupService:', async (clientId) => { + const newClock = { [clientId]: 1 }; + const opId = uuidv7(); + // IMPORTANT: Uses OpType.BackupImport which maps to reason='recovery' on the server. + // This allows backup imports to succeed even when a SYNC_IMPORT already exists. + // See server validation at sync.routes.ts:703-733 + const op: Operation = { + id: opId, + actionType: ActionType.LOAD_ALL_DATA, + opType: OpType.BackupImport, + entityType: 'ALL', + entityId: opId, + payload: importedData, + clientId, + vectorClock: newClock, + timestamp: Date.now(), + schemaVersion: CURRENT_SCHEMA_VERSION, + syncImportReason: 'BACKUP_RESTORE', + }; - // Generate new client ID for both paths - imports are a fresh start - const clientId = await this._clientIdService.generateNewClientId(); - - // Fresh clock: new clientId with initial counter for clean baseline - const newClock = { [clientId]: 1 }; - - const opId = uuidv7(); - // IMPORTANT: Uses OpType.BackupImport which maps to reason='recovery' on the server. - // This allows backup imports to succeed even when a SYNC_IMPORT already exists. - // See server validation at sync.routes.ts:703-733 - const op: Operation = { - id: opId, - actionType: ActionType.LOAD_ALL_DATA, - opType: OpType.BackupImport, - entityType: 'ALL', - entityId: opId, - payload: importedData, - clientId, - vectorClock: newClock, - timestamp: Date.now(), - schemaVersion: CURRENT_SCHEMA_VERSION, - syncImportReason: 'BACKUP_RESTORE', - }; - - await this._opLogStore.append(op, 'local'); - const lastSeq = await this._opLogStore.getLastSeq(); - - // Update vector clock store (append() doesn't do this, unlike appendWithVectorClockUpdate) - await this._opLogStore.setVectorClock(newClock); - - await this._opLogStore.saveStateCache({ - state: importedData, - lastAppliedOpSeq: lastSeq, - vectorClock: newClock, - compactedAt: Date.now(), - schemaVersion: CURRENT_SCHEMA_VERSION, + // Issue #7709: replace OPS + state_cache + vector_clock atomically so an + // interrupt during backup-restore can't leave the device in the + // `isWhollyFreshClient + meaningful store data` state that triggers the + // multi-device data-loss chain. + OpLog.normal('BackupService: Replacing op-log + state cache atomically'); + await this._opLogStore.runDestructiveStateReplacement({ + syncImportOp: op, + snapshotEntityKeys: extractEntityKeysFromState(importedData), + archiveYoung: importedData.archiveYoung, + archiveOld: importedData.archiveOld, + }); }); OpLog.normal('BackupService: Import persisted to operation log.'); @@ -263,25 +265,4 @@ export class BackupService { ); } } - - /** - * Writes archive data from the imported backup to IndexedDB. - * - * This is necessary because ArchiveOperationHandler._handleLoadAllData() only - * processes remote operations (isRemote=true). For local backup imports, the - * archive data would otherwise never be persisted to IndexedDB. - */ - private async _writeArchivesToIndexedDB(data: AppDataComplete): Promise { - const archiveYoung = (data as { archiveYoung?: ArchiveModel }).archiveYoung; - const archiveOld = (data as { archiveOld?: ArchiveModel }).archiveOld; - - // Check for both undefined AND null since backup might have null values - if (archiveYoung != null) { - await this._archiveDbAdapter.saveArchiveYoung(archiveYoung); - } - - if (archiveOld != null) { - await this._archiveDbAdapter.saveArchiveOld(archiveOld); - } - } } diff --git a/src/app/op-log/capture/operation-log.effects.spec.ts b/src/app/op-log/capture/operation-log.effects.spec.ts index 1f564673a7..7a68d4d5ff 100644 --- a/src/app/op-log/capture/operation-log.effects.spec.ts +++ b/src/app/op-log/capture/operation-log.effects.spec.ts @@ -71,16 +71,17 @@ describe('OperationLogEffects', () => { mockImmediateUploadService = jasmine.createSpyObj('ImmediateUploadService', [ 'trigger', ]); - mockClientIdService = jasmine.createSpyObj('ClientIdService', ['loadClientId']); + mockClientIdService = jasmine.createSpyObj('ClientIdService', [ + 'loadClientId', + 'generateNewClientId', + ]); mockOperationCaptureService = jasmine.createSpyObj('OperationCaptureService', [ 'dequeue', ]); // Default mock implementations - mockLockService.request.and.callFake( - async (_name: string, fn: () => Promise) => { - await fn(); - }, + mockLockService.request.and.callFake(async (_name: string, fn: () => Promise) => + fn(), ); mockOpLogStore.append.and.returnValue(Promise.resolve(1)); mockOpLogStore.appendWithVectorClockUpdate.and.returnValue(Promise.resolve(1)); @@ -92,6 +93,9 @@ describe('OperationLogEffects', () => { mockCompactionService.emergencyCompact.and.returnValue(Promise.resolve(true)); mockStore.select.and.returnValue(of({})); // Return empty state observable mockClientIdService.loadClientId.and.returnValue(Promise.resolve('testClient')); + mockClientIdService.generateNewClientId.and.returnValue( + Promise.resolve('generatedClient'), + ); mockOperationCaptureService.dequeue.and.returnValue([]); TestBed.configureTestingModule({ @@ -186,6 +190,72 @@ describe('OperationLogEffects', () => { }); }); + it('should load clientId only after acquiring operation log lock', (done) => { + const callOrder: string[] = []; + mockLockService.request.and.callFake( + async (_name: string, fn: () => Promise) => { + callOrder.push('lock'); + return fn(); + }, + ); + mockClientIdService.loadClientId.and.callFake(async () => { + callOrder.push('clientId'); + return 'testClient'; + }); + mockOpLogStore.appendWithVectorClockUpdate.and.callFake(async () => { + callOrder.push('append'); + return 1; + }); + + const action = createPersistentAction(ActionType.TASK_SHARED_UPDATE); + actions$ = of(action); + + effects.persistOperation$.subscribe({ + complete: () => { + expect(callOrder).toEqual(['lock', 'clientId', 'append']); + done(); + }, + }); + }); + + it('should capture the post-rotation clientId when a destructive replacement rotates it while this op is queued (#7709)', (done) => { + // Simulates the race the fix at operation-log.effects.ts:163-171 closes: + // a clean-slate / backup-import is already holding the operation-log lock + // and rotates the clientId via ClientIdService.withRotation. A queued op + // waiting behind that lock must read the rotated id, not the pre-rotation + // id captured before lock acquisition. + let persistedClientId = 'oldClient'; + + mockLockService.request.and.callFake( + async (_name: string, fn: () => Promise) => { + // The destructive replacement that we're racing has already mutated + // ClientIdService's persisted state by the time we acquire the lock. + persistedClientId = 'newClient'; + return fn(); + }, + ); + mockClientIdService.loadClientId.and.callFake(async () => persistedClientId); + + const action = createPersistentAction(ActionType.TASK_SHARED_UPDATE); + actions$ = of(action); + + effects.persistOperation$.subscribe({ + complete: () => { + expect(mockOpLogStore.appendWithVectorClockUpdate).toHaveBeenCalledWith( + jasmine.objectContaining({ clientId: 'newClient' }), + 'local', + ); + // Negative assertion: if clientId were read before lock acquisition, + // we'd see 'oldClient'. The fix prevents that. + expect(mockOpLogStore.appendWithVectorClockUpdate).not.toHaveBeenCalledWith( + jasmine.objectContaining({ clientId: 'oldClient' }), + jasmine.anything(), + ); + done(); + }, + }); + }); + it('should increment vector clock', (done) => { const action = createPersistentAction(ActionType.TASK_SHARED_UPDATE); actions$ = of(action); diff --git a/src/app/op-log/capture/operation-log.effects.ts b/src/app/op-log/capture/operation-log.effects.ts index 7f8099634e..7000cfc8be 100644 --- a/src/app/op-log/capture/operation-log.effects.ts +++ b/src/app/op-log/capture/operation-log.effects.ts @@ -114,15 +114,6 @@ export class OperationLogEffects implements DeferredLocalActionsPort { options: WriteOperationOptions = {}, ): Promise { const operationTimestamp = Date.now(); - // Always read from ClientIdService (which has its own in-memory cache). - // Do NOT cache locally — BackupService.generateNewClientId() updates the - // service cache, and a local cache here would go stale after backup import. - const clientId = - (await this.clientIdService.loadClientId()) ?? - (await this.clientIdService.generateNewClientId()); - if (!clientId) { - throw new Error('Failed to load or generate clientId - cannot persist operation'); - } // Validate that at least one entity identifier exists for non-bulk operations // Bulk operations with entityType 'ALL' don't need specific entity IDs @@ -170,6 +161,19 @@ export class OperationLogEffects implements DeferredLocalActionsPort { try { const writeInsideOperationLogLock = async (): Promise => { + // Always read from ClientIdService while holding the write lock. + // Clean-slate and backup import rotate the clientId while holding this + // same lock; reading here prevents a queued operation from capturing + // the old id before waiting behind a destructive replacement. + const clientId = + (await this.clientIdService.loadClientId()) ?? + (await this.clientIdService.generateNewClientId()); + if (!clientId) { + throw new Error( + 'Failed to load or generate clientId - cannot persist operation', + ); + } + // MULTI-TAB SAFETY: Clear vector clock cache to ensure fresh read after other tabs // may have written while we were waiting for the lock. Each tab has its own in-memory // cache, so Tab B's cache could be stale if Tab A wrote while Tab B was waiting. diff --git a/src/app/op-log/clean-slate/clean-slate.service.spec.ts b/src/app/op-log/clean-slate/clean-slate.service.spec.ts index f1f73e8c1d..d6941a0755 100644 --- a/src/app/op-log/clean-slate/clean-slate.service.spec.ts +++ b/src/app/op-log/clean-slate/clean-slate.service.spec.ts @@ -3,18 +3,21 @@ import { CleanSlateService } from './clean-slate.service'; import { StateSnapshotService } from '../backup/state-snapshot.service'; import { OperationLogStoreService } from '../persistence/operation-log-store.service'; import { ClientIdService } from '../../core/util/client-id.service'; -import { PreMigrationBackupService } from './pre-migration-backup.service'; -import { Operation, OpType, OperationLogEntry } from '../core/operation.types'; +import { OpType, OperationLogEntry } from '../core/operation.types'; import { ActionType } from '../core/action-types.enum'; import { CURRENT_SCHEMA_VERSION } from '../persistence/schema-migration.service'; import { OpLog } from '../../core/log'; +import { OperationWriteFlushService } from '../sync/operation-write-flush.service'; +import { LockService } from '../sync/lock.service'; +import { LOCK_NAMES } from '../core/operation-log.const'; describe('CleanSlateService', () => { let service: CleanSlateService; let mockStateSnapshotService: jasmine.SpyObj; let mockOpLogStore: jasmine.SpyObj; let mockClientIdService: jasmine.SpyObj; - let mockPreMigrationBackupService: jasmine.SpyObj; + let mockOperationWriteFlushService: jasmine.SpyObj; + let mockLockService: jasmine.SpyObj; const mockState = { task: { ids: [], entities: {} }, @@ -28,19 +31,15 @@ describe('CleanSlateService', () => { 'getStateSnapshotAsync', ]); mockOpLogStore = jasmine.createSpyObj('OperationLogStoreService', [ - 'clearAllOperations', - 'append', - 'setVectorClock', - 'saveStateCache', + 'runDestructiveStateReplacement', 'getVectorClock', 'getUnsynced', ]); - mockClientIdService = jasmine.createSpyObj('ClientIdService', [ - 'generateNewClientId', - ]); - mockPreMigrationBackupService = jasmine.createSpyObj('PreMigrationBackupService', [ - 'createPreMigrationBackup', + mockClientIdService = jasmine.createSpyObj('ClientIdService', ['withRotation']); + mockOperationWriteFlushService = jasmine.createSpyObj('OperationWriteFlushService', [ + 'flushPendingWrites', ]); + mockLockService = jasmine.createSpyObj('LockService', ['request']); TestBed.configureTestingModule({ providers: [ @@ -49,9 +48,10 @@ describe('CleanSlateService', () => { { provide: OperationLogStoreService, useValue: mockOpLogStore }, { provide: ClientIdService, useValue: mockClientIdService }, { - provide: PreMigrationBackupService, - useValue: mockPreMigrationBackupService, + provide: OperationWriteFlushService, + useValue: mockOperationWriteFlushService, }, + { provide: LockService, useValue: mockLockService }, ], }); @@ -59,37 +59,39 @@ describe('CleanSlateService', () => { // Setup default mock responses mockStateSnapshotService.getStateSnapshotAsync.and.resolveTo(mockState as any); - mockClientIdService.generateNewClientId.and.resolveTo('eNewC'); - mockPreMigrationBackupService.createPreMigrationBackup.and.resolveTo(); - mockOpLogStore.clearAllOperations.and.resolveTo(); - mockOpLogStore.append.and.resolveTo(1); - mockOpLogStore.setVectorClock.and.resolveTo(); - mockOpLogStore.saveStateCache.and.resolveTo(); + // Default: withRotation invokes its callback with the new clientId and + // propagates whatever the callback returns or throws. ClientIdService's + // own spec covers the rollback semantics. + mockClientIdService.withRotation.and.callFake( + async (_logPrefix: string, fn: (newClientId: string) => Promise) => + fn('eNewC'), + ); + mockOpLogStore.runDestructiveStateReplacement.and.resolveTo(); mockOpLogStore.getVectorClock.and.resolveTo(null); mockOpLogStore.getUnsynced.and.resolveTo([]); + mockOperationWriteFlushService.flushPendingWrites.and.resolveTo(); + mockLockService.request.and.callFake(async (_lockName, fn) => fn()); }); describe('createCleanSlate', () => { it('should create a clean slate successfully', async () => { await service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED'); - // Should create pre-migration backup - expect(mockPreMigrationBackupService.createPreMigrationBackup).toHaveBeenCalledWith( - 'ENCRYPTION_CHANGE', - ); - // Should get current state (async version to include archives) expect(mockStateSnapshotService.getStateSnapshotAsync).toHaveBeenCalled(); - // Should generate new client ID - expect(mockClientIdService.generateNewClientId).toHaveBeenCalled(); + // Should rotate client ID via the shared helper + expect(mockClientIdService.withRotation).toHaveBeenCalledWith( + '[CleanSlate]', + jasmine.any(Function), + ); - // Should clear all operations - expect(mockOpLogStore.clearAllOperations).toHaveBeenCalled(); + // Should route through the atomic helper (issue #7709) + expect(mockOpLogStore.runDestructiveStateReplacement).toHaveBeenCalledTimes(1); + const args = mockOpLogStore.runDestructiveStateReplacement.calls.mostRecent() + .args[0] as Parameters[0]; - // Should append SYNC_IMPORT operation - expect(mockOpLogStore.append).toHaveBeenCalled(); - const appendedOp = mockOpLogStore.append.calls.mostRecent().args[0] as Operation; + const appendedOp = args.syncImportOp; expect(appendedOp.actionType).toBe(ActionType.LOAD_ALL_DATA); expect(appendedOp.opType).toBe(OpType.SyncImport); expect(appendedOp.entityType).toBe('ALL'); @@ -97,18 +99,36 @@ describe('CleanSlateService', () => { expect(appendedOp.clientId).toBe('eNewC'); expect(appendedOp.vectorClock).toEqual({ eNewC: 1 }); expect(appendedOp.schemaVersion).toBe(CURRENT_SCHEMA_VERSION); + }); - // Should update vector clock - expect(mockOpLogStore.setVectorClock).toHaveBeenCalledWith({ eNewC: 1 }); - - // Should save snapshot - expect(mockOpLogStore.saveStateCache).toHaveBeenCalledWith({ - state: mockState, - lastAppliedOpSeq: 0, - vectorClock: { eNewC: 1 }, - compactedAt: jasmine.any(Number), - schemaVersion: CURRENT_SCHEMA_VERSION, + it('should flush pending writes and hold the op-log lock during replacement', async () => { + const callOrder: string[] = []; + mockOperationWriteFlushService.flushPendingWrites.and.callFake(async () => { + callOrder.push('flush'); }); + mockLockService.request.and.callFake(async (lockName, fn) => { + callOrder.push(`lock:${lockName}`); + const r = await fn(); + callOrder.push('unlock'); + return r; + }); + mockStateSnapshotService.getStateSnapshotAsync.and.callFake(async () => { + callOrder.push('snapshot'); + return mockState as any; + }); + mockOpLogStore.runDestructiveStateReplacement.and.callFake(async () => { + callOrder.push('replace'); + }); + + await service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED'); + + expect(callOrder).toEqual([ + 'flush', + `lock:${LOCK_NAMES.OPERATION_LOG}`, + 'snapshot', + 'replace', + 'unlock', + ]); }); it('should log diagnostic snapshot of prior clock and unsynced ops before mutation', async () => { @@ -148,54 +168,47 @@ describe('CleanSlateService', () => { [OpType.Update]: 1, }), priorClockSize: 2, - priorClock: { ['B_old']: 42, ['B_other']: 7 }, }), ); - // Order invariant: snapshot reads must precede the destructive clear. + // Security C2: vector-clock contents must never be logged — keys are + // per-device clientIds and log history is user-exportable. + const loggedPayload = opLogSpy.calls.mostRecent().args[1] as Record< + string, + unknown + >; + expect('priorClock' in loggedPayload).toBe(false); + // Order invariant: diagnostic snapshot reads must precede the + // destructive atomic replacement. expect(mockOpLogStore.getVectorClock).toHaveBeenCalledBefore( - mockOpLogStore.clearAllOperations, + mockOpLogStore.runDestructiveStateReplacement, ); expect(mockOpLogStore.getUnsynced).toHaveBeenCalledBefore( - mockOpLogStore.clearAllOperations, + mockOpLogStore.runDestructiveStateReplacement, ); }); it('should work with MANUAL reason', async () => { await service.createCleanSlate('MANUAL', 'PASSWORD_CHANGED'); - expect(mockPreMigrationBackupService.createPreMigrationBackup).toHaveBeenCalledWith( - 'MANUAL', - ); - }); - - it('should continue if pre-migration backup fails', async () => { - mockPreMigrationBackupService.createPreMigrationBackup.and.rejectWith( - new Error('Backup failed'), - ); - - // Should not throw - backup failure is non-fatal - await expectAsync( - service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED'), - ).toBeResolved(); - - // Should still complete the clean slate - expect(mockOpLogStore.clearAllOperations).toHaveBeenCalled(); - expect(mockOpLogStore.append).toHaveBeenCalled(); + // Should still complete the destructive replacement with MANUAL reason. + expect(mockOpLogStore.runDestructiveStateReplacement).toHaveBeenCalledTimes(1); }); it('should generate fresh vector clock starting at 1', async () => { await service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED'); - const appendedOp = mockOpLogStore.append.calls.mostRecent().args[0] as Operation; - expect(appendedOp.vectorClock).toEqual({ eNewC: 1 }); + const args = mockOpLogStore.runDestructiveStateReplacement.calls.mostRecent() + .args[0] as Parameters[0]; + expect(args.syncImportOp.vectorClock).toEqual({ eNewC: 1 }); }); it('should create operation with valid UUIDv7', async () => { await service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED'); - const appendedOp = mockOpLogStore.append.calls.mostRecent().args[0] as Operation; + const args = mockOpLogStore.runDestructiveStateReplacement.calls.mostRecent() + .args[0] as Parameters[0]; // UUIDv7 format: 8-4-4-4-12 characters - expect(appendedOp.id).toMatch( + expect(args.syncImportOp.id).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, ); }); @@ -210,78 +223,47 @@ describe('CleanSlateService', () => { ).toBeRejectedWith(jasmine.objectContaining({ message: 'State error' })); }); - it('should throw if client ID generation fails', async () => { - mockClientIdService.generateNewClientId.and.rejectWith(new Error('ClientID error')); + it('should propagate errors from withRotation', async () => { + // ClientIdService.withRotation owns the cross-DB rollback semantics + // (see its own spec). Here we only verify that CleanSlateService + // surfaces failures from the rotation/replacement chain to its caller. + mockClientIdService.withRotation.and.rejectWith( + new Error('Atomic replacement failed'), + ); await expectAsync( service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED'), - ).toBeRejectedWith(jasmine.objectContaining({ message: 'ClientID error' })); + ).toBeRejectedWith( + jasmine.objectContaining({ message: 'Atomic replacement failed' }), + ); }); - it('should throw if operation append fails', async () => { - mockOpLogStore.append.and.rejectWith(new Error('Append error')); + it('should propagate errors from runDestructiveStateReplacement through withRotation', async () => { + // The destructive helper runs inside the withRotation callback. + // withRotation must re-throw whatever the callback throws so the + // caller sees the real failure. + mockOpLogStore.runDestructiveStateReplacement.and.rejectWith( + new Error('Atomic replacement failed'), + ); await expectAsync( service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED'), - ).toBeRejectedWith(jasmine.objectContaining({ message: 'Append error' })); - }); - }); - - describe('error handling', () => { - it('should propagate clearAllOperations errors', async () => { - mockOpLogStore.clearAllOperations.and.rejectWith(new Error('Clear failed')); - - await expectAsync( - service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED'), - ).toBeRejectedWith(jasmine.objectContaining({ message: 'Clear failed' })); + ).toBeRejectedWith( + jasmine.objectContaining({ message: 'Atomic replacement failed' }), + ); }); - it('should propagate setVectorClock errors', async () => { - mockOpLogStore.setVectorClock.and.rejectWith(new Error('VectorClock failed')); - - await expectAsync( - service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED'), - ).toBeRejectedWith(jasmine.objectContaining({ message: 'VectorClock failed' })); - }); - - it('should propagate saveStateCache errors', async () => { - mockOpLogStore.saveStateCache.and.rejectWith(new Error('SaveCache failed')); - - await expectAsync( - service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED'), - ).toBeRejectedWith(jasmine.objectContaining({ message: 'SaveCache failed' })); - }); - }); - - describe('operation ordering', () => { - it('should clear operations before appending new SYNC_IMPORT', async () => { - const callOrder: string[] = []; - mockOpLogStore.clearAllOperations.and.callFake(async () => { - callOrder.push('clear'); - }); - mockOpLogStore.append.and.callFake(async () => { - callOrder.push('append'); - return 1; - }); - + it('should pass snapshotEntityKeys derived from current state', async () => { + // Without snapshotEntityKeys, the persisted state_cache singleton looks + // like the "old snapshot format" to remote-ops-processing, which + // triggers an unnecessary background recompaction after every + // clean-slate. Callers must pass it. await service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED'); - expect(callOrder).toEqual(['clear', 'append']); - }); - - it('should append operation before updating vector clock', async () => { - const callOrder: string[] = []; - mockOpLogStore.append.and.callFake(async () => { - callOrder.push('append'); - return 1; - }); - mockOpLogStore.setVectorClock.and.callFake(async () => { - callOrder.push('setVectorClock'); - }); - - await service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED'); - - expect(callOrder).toEqual(['append', 'setVectorClock']); + const args = mockOpLogStore.runDestructiveStateReplacement.calls.mostRecent() + .args[0] as Parameters[0]; + expect(args.snapshotEntityKeys).toBeDefined(); + expect(Array.isArray(args.snapshotEntityKeys)).toBe(true); }); }); }); diff --git a/src/app/op-log/clean-slate/clean-slate.service.ts b/src/app/op-log/clean-slate/clean-slate.service.ts index 781aad099e..a1a75ea17c 100644 --- a/src/app/op-log/clean-slate/clean-slate.service.ts +++ b/src/app/op-log/clean-slate/clean-slate.service.ts @@ -3,45 +3,38 @@ import { uuidv7 } from '../../util/uuid-v7'; import { StateSnapshotService } from '../backup/state-snapshot.service'; import { OperationLogStoreService } from '../persistence/operation-log-store.service'; import { ClientIdService } from '../../core/util/client-id.service'; -import { - PreMigrationBackupService, - PreMigrationReason, -} from './pre-migration-backup.service'; import { OpLog } from '../../core/log'; import { Operation, OpType, SyncImportReason } from '../core/operation.types'; import { ActionType } from '../core/action-types.enum'; import { CURRENT_SCHEMA_VERSION } from '../persistence/schema-migration.service'; +import { extractEntityKeysFromState } from '../persistence/extract-entity-keys'; +import { OperationWriteFlushService } from '../sync/operation-write-flush.service'; +import { LockService } from '../sync/lock.service'; +import { LOCK_NAMES } from '../core/operation-log.const'; + +/** + * Reason a clean-slate was triggered. Logged for diagnostic correlation + * (so a future sync-stuck incident can be tied to the cause without + * forensic recovery). + */ +export type CleanSlateReason = 'ENCRYPTION_CHANGE' | 'MANUAL'; /** * Service for performing "clean slate" operations on the sync state. * - * ## What is a Clean Slate? - * A clean slate operation: - * 1. Creates a pre-migration backup of current state (optional, placeholder for now) - * 2. Generates a new client ID and fresh vector clock - * 3. Creates a new SYNC_IMPORT operation with current state - * 4. Clears all local operations (fresh start) - * 5. Uploads the SYNC_IMPORT to server with `isCleanSlate=true` flag - * 6. Server deletes ALL existing operations and accepts the new baseline + * Atomically replaces the local op-log + state_cache + vector_clock with a + * fresh baseline derived from current state. Used by encryption-password + * changes (which need a fresh sync baseline) and by user-initiated sync + * recovery. * - * ## When to Use - * - **Encryption password changes**: New password requires fresh sync baseline - * - **Manual recovery**: User-initiated sync reset - * - * ## Benefits - * - Prevents encrypted data from being mixed with unencrypted - * - Avoids accumulation of old operations on server - * - Provides clean recovery path for sync issues - * - Simpler than defensive programming for edge cases + * Atomicity within `SUP_OPS` is guaranteed by + * `OperationLogStoreService.runDestructiveStateReplacement`; cross-DB + * rotation of the clientId (which lives in `pf`) is rolled back on failure + * — see issue #7709. * * @example * ```typescript - * const cleanSlateService = inject(CleanSlateService); - * - * // Create clean slate for encryption change - * await cleanSlateService.createCleanSlate('ENCRYPTION_CHANGE'); - * - * // Now upload with the new encryption key + * await cleanSlateService.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED'); * await syncService.triggerSync(); * ``` */ @@ -52,125 +45,104 @@ export class CleanSlateService { private stateSnapshotService = inject(StateSnapshotService); private opLogStore = inject(OperationLogStoreService); private clientIdService = inject(ClientIdService); - private preMigrationBackupService = inject(PreMigrationBackupService); + private operationWriteFlushService = inject(OperationWriteFlushService); + private lockService = inject(LockService); /** * Creates a clean slate by resetting local operation log and preparing * a fresh SYNC_IMPORT operation for upload. * - * ## Process - * 1. Creates pre-migration backup (placeholder for now) - * 2. Gets current application state - * 3. Generates new client ID (fresh start) - * 4. Creates fresh vector clock - * 5. Creates SYNC_IMPORT operation - * 6. Clears all local operations - * 7. Stores the new SYNC_IMPORT locally - * 8. Updates vector clock - * 9. Saves new snapshot + * The destructive sequence (clear OPS, append SYNC_IMPORT, write vector + * clock, write state_cache) runs as a single atomic transaction. On + * failure, the rotated clientId is rolled back so `pf` and `SUP_OPS` stay + * consistent. * - * ## Important Notes - * - This method does NOT upload to server - that happens in the next sync - * - The SYNC_IMPORT operation will be uploaded with `isCleanSlate=true` flag - * - Server will delete all operations when receiving the upload - * - Other clients will detect the clean slate and re-sync from new baseline + * Does NOT upload to the server — that happens on the next sync, with the + * `isCleanSlate=true` flag, which makes the server delete its operations + * and accept the new baseline. Other clients then re-sync from the new + * baseline. * - * @param reason - Why the clean slate is being created - * @throws If state snapshot cannot be retrieved or operations cannot be stored + * @throws If state snapshot cannot be retrieved or operations cannot be stored. */ async createCleanSlate( - reason: PreMigrationReason, + reason: CleanSlateReason, syncImportReason: SyncImportReason, ): Promise { - // Diagnostic snapshot of state about to be wiped. Captured before any - // mutation. Lets a future sync-stuck incident be correlated to the local - // op-log shape that preceded the destructive recovery (count of unsynced - // user work, prior vector-clock entries) without forensic recovery. - const priorClock = await this.opLogStore.getVectorClock(); - const priorUnsynced = await this.opLogStore.getUnsynced(); - const priorOpTypeBreakdown = priorUnsynced.reduce>( - (acc, entry) => { - const key = entry.op.opType; - acc[key] = (acc[key] ?? 0) + 1; - return acc; + await this.operationWriteFlushService.flushPendingWrites(); + + const { syncImportId } = await this.lockService.request( + LOCK_NAMES.OPERATION_LOG, + async () => { + // Diagnostic snapshot of state about to be wiped. Captured before any + // mutation. Lets a future sync-stuck incident be correlated to the local + // op-log shape that preceded the destructive recovery (count of unsynced + // user work, prior vector-clock entries) without forensic recovery. + const priorClock = await this.opLogStore.getVectorClock(); + const priorUnsynced = await this.opLogStore.getUnsynced(); + const priorOpTypeBreakdown = priorUnsynced.reduce>( + (acc, entry) => { + const key = entry.op.opType; + acc[key] = (acc[key] ?? 0) + 1; + return acc; + }, + {}, + ); + OpLog.normal('[CleanSlate] Starting clean slate process', { + reason, + syncImportReason, + priorUnsyncedCount: priorUnsynced.length, + priorUnsyncedByOpType: priorOpTypeBreakdown, + priorClockSize: priorClock ? Object.keys(priorClock).length : 0, + }); + + // 1. Get current application state (includes all features + archives). + // IMPORTANT: must use the async version to load real archives from + // IndexedDB. The sync getStateSnapshot() returns DEFAULT_ARCHIVE (empty) + // which causes data loss. + const currentState = await this.stateSnapshotService.getStateSnapshotAsync(); + + // Rotate the clientId for the duration of the destructive replacement. + // The clientId lives in a separate IDB database (`pf`) and so cannot + // share the atomic SUP_OPS tx below; ClientIdService.withRotation rolls + // it back on failure so `pf` and `SUP_OPS` agree on the device's + // clientId after this method returns or throws. + return this.clientIdService.withRotation('[CleanSlate]', async (newClientId) => { + OpLog.normal('[CleanSlate] Generated new client ID', { + newClientIdSuffix: newClientId.slice(-3), + }); + + const newVectorClock = { [newClientId]: 1 }; + const syncImportOp: Operation = { + id: uuidv7(), + actionType: ActionType.LOAD_ALL_DATA, + opType: OpType.SyncImport, + entityType: 'ALL', + entityId: undefined, + payload: currentState, + clientId: newClientId, + vectorClock: newVectorClock, + timestamp: Date.now(), + schemaVersion: CURRENT_SCHEMA_VERSION, + syncImportReason, + }; + + OpLog.normal('[CleanSlate] Created SYNC_IMPORT operation', { + opId: syncImportOp.id, + }); + + OpLog.normal('[CleanSlate] Replacing op-log + state cache atomically'); + await this.opLogStore.runDestructiveStateReplacement({ + syncImportOp, + snapshotEntityKeys: extractEntityKeysFromState(currentState), + }); + + return { syncImportId: syncImportOp.id }; + }); }, - {}, ); - OpLog.normal('[CleanSlate] Starting clean slate process', { - reason, - syncImportReason, - priorUnsyncedCount: priorUnsynced.length, - priorUnsyncedByOpType: priorOpTypeBreakdown, - priorClockSize: priorClock ? Object.keys(priorClock).length : 0, - priorClock: priorClock ?? null, - }); - - // 1. Create pre-migration backup (placeholder for now) - try { - await this.preMigrationBackupService.createPreMigrationBackup(reason); - } catch (e) { - OpLog.warn('[CleanSlate] Failed to create pre-migration backup', e); - // Continue anyway - backup is optional safety feature - } - - // 2. Get current application state (includes all features + archives) - // IMPORTANT: Must use async version to load real archives from IndexedDB - // The sync getStateSnapshot() returns DEFAULT_ARCHIVE (empty) which causes data loss - const currentState = await this.stateSnapshotService.getStateSnapshotAsync(); - - // 3. Generate new client ID (fresh start - all devices get new IDs after clean slate) - const newClientId = await this.clientIdService.generateNewClientId(); - OpLog.normal('[CleanSlate] Generated new client ID', { newClientId }); - - // 4. Create fresh vector clock starting at 1 for the new client - const newVectorClock = { [newClientId]: 1 }; - - // 5. Create SYNC_IMPORT operation - // This will be uploaded to server with isCleanSlate=true flag - const syncImportOp: Operation = { - id: uuidv7(), - actionType: ActionType.LOAD_ALL_DATA, - opType: OpType.SyncImport, // Maps to reason='initial' on server - entityType: 'ALL', - entityId: undefined, - payload: currentState, - clientId: newClientId, - vectorClock: newVectorClock, - timestamp: Date.now(), - schemaVersion: CURRENT_SCHEMA_VERSION, - syncImportReason, - }; - - OpLog.normal('[CleanSlate] Created SYNC_IMPORT operation', { - opId: syncImportOp.id, - clientId: newClientId, - }); - - // 6. Clear all local operations (fresh start) - OpLog.normal('[CleanSlate] Clearing all local operations'); - await this.opLogStore.clearAllOperations(); - - // 7. Store the new SYNC_IMPORT locally (not synced yet) - await this.opLogStore.append(syncImportOp); - OpLog.normal('[CleanSlate] Stored SYNC_IMPORT operation locally'); - - // 8. Update vector clock in dedicated store - await this.opLogStore.setVectorClock(newVectorClock); - OpLog.normal('[CleanSlate] Updated vector clock', { vectorClock: newVectorClock }); - - // 9. Save new snapshot with the clean state - await this.opLogStore.saveStateCache({ - state: currentState, - lastAppliedOpSeq: 0, // Fresh start - no ops applied yet - vectorClock: newVectorClock, - compactedAt: Date.now(), - schemaVersion: CURRENT_SCHEMA_VERSION, - }); - OpLog.normal('[CleanSlate] Saved new snapshot'); OpLog.normal('[CleanSlate] Clean slate completed successfully', { - syncImportId: syncImportOp.id, - newClientId, + syncImportId, reason, }); } diff --git a/src/app/op-log/clean-slate/pre-migration-backup.service.spec.ts b/src/app/op-log/clean-slate/pre-migration-backup.service.spec.ts deleted file mode 100644 index ba1d7d3343..0000000000 --- a/src/app/op-log/clean-slate/pre-migration-backup.service.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { PreMigrationBackupService } from './pre-migration-backup.service'; - -describe('PreMigrationBackupService (Placeholder)', () => { - let service: PreMigrationBackupService; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [PreMigrationBackupService], - }); - - service = TestBed.inject(PreMigrationBackupService); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); - - describe('createPreMigrationBackup', () => { - it('should complete without error (placeholder)', async () => { - await expectAsync( - service.createPreMigrationBackup('ENCRYPTION_CHANGE'), - ).toBeResolved(); - }); - - it('should accept MANUAL reason', async () => { - await expectAsync(service.createPreMigrationBackup('MANUAL')).toBeResolved(); - }); - }); - - describe('hasPreMigrationBackup', () => { - it('should return false (placeholder)', async () => { - const result = await service.hasPreMigrationBackup(); - expect(result).toBe(false); - }); - }); - - describe('clearPreMigrationBackup', () => { - it('should complete without error (placeholder)', async () => { - await expectAsync(service.clearPreMigrationBackup()).toBeResolved(); - }); - }); -}); diff --git a/src/app/op-log/clean-slate/pre-migration-backup.service.ts b/src/app/op-log/clean-slate/pre-migration-backup.service.ts deleted file mode 100644 index 1002f6b3a5..0000000000 --- a/src/app/op-log/clean-slate/pre-migration-backup.service.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Injectable } from '@angular/core'; -import { OpLog } from '../../core/log'; - -/** - * Reason for creating a pre-migration backup. - * Used to track why a clean slate operation was triggered. - */ -export type PreMigrationReason = 'ENCRYPTION_CHANGE' | 'MANUAL'; - -/** - * PLACEHOLDER: Service for creating pre-migration backups before clean slate operations. - * - * ## TODO - * This service will capture the current application state before destructive operations - * like encryption password changes or full imports. For now, it's a no-op placeholder. - * - * Future implementation should: - * - Save current state to import_backup store - * - Include vector clock and metadata - * - Provide recovery UI - */ -@Injectable({ - providedIn: 'root', -}) -export class PreMigrationBackupService { - /** - * PLACEHOLDER: Creates a pre-migration backup. - * Currently does nothing - to be implemented. - */ - async createPreMigrationBackup(reason: PreMigrationReason): Promise { - OpLog.normal('[PreMigrationBackup] PLACEHOLDER - backup not implemented', { reason }); - // TODO: Implement backup creation - } - - /** - * PLACEHOLDER: Checks if a pre-migration backup exists. - */ - async hasPreMigrationBackup(): Promise { - return false; - } - - /** - * PLACEHOLDER: Clears the pre-migration backup. - */ - async clearPreMigrationBackup(): Promise { - OpLog.normal('[PreMigrationBackup] PLACEHOLDER - clear not implemented'); - } -} diff --git a/src/app/op-log/persistence/operation-log-compaction.service.spec.ts b/src/app/op-log/persistence/operation-log-compaction.service.spec.ts index 0d0fd65e61..62c686afeb 100644 --- a/src/app/op-log/persistence/operation-log-compaction.service.spec.ts +++ b/src/app/op-log/persistence/operation-log-compaction.service.spec.ts @@ -55,10 +55,8 @@ describe('OperationLogCompactionService', () => { spyOn(OpLog, 'normal'); // Default mock implementations - mockLockService.request.and.callFake( - async (_name: string, fn: () => Promise) => { - await fn(); - }, + mockLockService.request.and.callFake(async (_name: string, fn: () => Promise) => + fn(), ); mockOpLogStore.getLastSeq.and.returnValue(Promise.resolve(100)); mockOpLogStore.saveStateCache.and.returnValue(Promise.resolve()); @@ -332,10 +330,11 @@ describe('OperationLogCompactionService', () => { const callOrder: string[] = []; mockLockService.request.and.callFake( - async (_name: string, fn: () => Promise) => { + async (_name: string, fn: () => Promise) => { callOrder.push('lock-start'); - await fn(); + const r = await fn(); callOrder.push('lock-end'); + return r; }, ); @@ -797,9 +796,9 @@ describe('OperationLogCompactionService', () => { const lockRequests: string[] = []; mockLockService.request.and.callFake( - async (_name: string, fn: () => Promise) => { + async (_name: string, fn: () => Promise) => { lockRequests.push('lock-requested'); - await fn(); + return fn(); }, ); @@ -867,10 +866,11 @@ describe('OperationLogCompactionService', () => { const callOrder: string[] = []; mockLockService.request.and.callFake( - async (_name: string, fn: () => Promise) => { + async (_name: string, fn: () => Promise) => { callOrder.push('lock-start'); - await fn(); + const r = await fn(); callOrder.push('lock-end'); + return r; }, ); @@ -947,10 +947,10 @@ describe('OperationLogCompactionService', () => { const callOrder: string[] = []; mockLockService.request.and.callFake( - async (_name: string, fn: () => Promise) => { + async (_name: string, fn: () => Promise) => { callOrder.push('lock-acquired'); try { - await fn(); + return await fn(); } finally { callOrder.push('lock-released'); } diff --git a/src/app/op-log/persistence/operation-log-store.service.spec.ts b/src/app/op-log/persistence/operation-log-store.service.spec.ts index e0658cb858..848222a128 100644 --- a/src/app/op-log/persistence/operation-log-store.service.spec.ts +++ b/src/app/op-log/persistence/operation-log-store.service.spec.ts @@ -22,6 +22,7 @@ import { IDB_OPEN_RETRY_BASE_DELAY_MS, } from '../core/operation-log.const'; import { IndexedDBOpenError } from '../core/errors/indexed-db-open.error'; +import { SINGLETON_KEY, STORE_NAMES } from './db-keys.const'; describe('OperationLogStoreService', () => { let service: OperationLogStoreService; @@ -1607,6 +1608,112 @@ describe('OperationLogStoreService', () => { }); }); + describe('runDestructiveStateReplacement', () => { + const createArchive = (taskId: string): any => ({ + task: { + ids: [taskId], + entities: { [taskId]: { id: taskId, title: taskId } }, + }, + timeTracking: { project: {}, tag: {} }, + lastTimeTrackingFlush: 0, + }); + + it('should write archives in the same destructive replacement', async () => { + const archiveYoung = createArchive('young-task'); + const archiveOld = createArchive('old-task'); + + await service.runDestructiveStateReplacement({ + syncImportOp: createTestOperation({ + opType: OpType.BackupImport, + entityType: 'ALL' as EntityType, + entityId: 'backup-import', + payload: { task: { ids: [], entities: {} } }, + }), + snapshotEntityKeys: [], + archiveYoung, + archiveOld, + }); + + const db = (service as any).db; + const youngEntry = await db.get(STORE_NAMES.ARCHIVE_YOUNG, SINGLETON_KEY); + const oldEntry = await db.get(STORE_NAMES.ARCHIVE_OLD, SINGLETON_KEY); + expect(youngEntry.data).toEqual(archiveYoung); + expect(oldEntry.data).toEqual(archiveOld); + }); + + it('should roll back ops, state_cache, vector_clock, and archives when an archive write fails', async () => { + const priorOp = createTestOperation({ entityId: 'prior-task' }); + const priorArchiveYoung = createArchive('prior-young'); + const priorArchiveOld = createArchive('prior-old'); + await service.append(priorOp); + await service.saveStateCache({ + state: { sentinel: 'prior-state' }, + lastAppliedOpSeq: 1, + vectorClock: { testClient: 1 }, + compactedAt: Date.now(), + }); + await service.setVectorClock({ testClient: 1 }); + + const db = (service as any).db; + await db.put(STORE_NAMES.ARCHIVE_YOUNG, { + id: SINGLETON_KEY, + data: priorArchiveYoung, + lastModified: 1, + }); + await db.put(STORE_NAMES.ARCHIVE_OLD, { + id: SINGLETON_KEY, + data: priorArchiveOld, + lastModified: 1, + }); + + const realTransaction = db.transaction.bind(db); + spyOn(db, 'transaction').and.callFake((stores: any, mode: any) => { + const tx = realTransaction(stores, mode); + if (Array.isArray(stores) && stores.includes(STORE_NAMES.ARCHIVE_YOUNG)) { + const realObjectStore = tx.objectStore.bind(tx); + tx.objectStore = ((storeName: string) => { + const store = realObjectStore(storeName); + if (storeName === STORE_NAMES.ARCHIVE_YOUNG) { + store.put = async () => { + throw new Error('Simulated archive write failure'); + }; + } + return store; + }) as typeof tx.objectStore; + } + return tx; + }); + + await expectAsync( + service.runDestructiveStateReplacement({ + syncImportOp: createTestOperation({ + opType: OpType.BackupImport, + entityType: 'ALL' as EntityType, + entityId: 'backup-import', + payload: { sentinel: 'new-state' }, + vectorClock: { newClient: 1 }, + }), + snapshotEntityKeys: [], + archiveYoung: createArchive('new-young'), + archiveOld: createArchive('new-old'), + }), + ).toBeRejected(); + + const opsAfter = await service.getOpsAfterSeq(0); + expect(opsAfter.map((entry) => entry.op.id)).toEqual([priorOp.id]); + expect((await service.loadStateCache())!.state).toEqual({ + sentinel: 'prior-state', + }); + expect(await service.getVectorClock()).toEqual({ testClient: 1 }); + expect((await db.get(STORE_NAMES.ARCHIVE_YOUNG, SINGLETON_KEY)).data).toEqual( + priorArchiveYoung, + ); + expect((await db.get(STORE_NAMES.ARCHIVE_OLD, SINGLETON_KEY)).data).toEqual( + priorArchiveOld, + ); + }); + }); + describe('appendWithVectorClockUpdate', () => { it('should append operation and update vector clock atomically for local ops', async () => { const op = createTestOperation({ diff --git a/src/app/op-log/persistence/operation-log-store.service.ts b/src/app/op-log/persistence/operation-log-store.service.ts index fa1b7c218b..701f31162b 100644 --- a/src/app/op-log/persistence/operation-log-store.service.ts +++ b/src/app/op-log/persistence/operation-log-store.service.ts @@ -160,6 +160,8 @@ interface OpLogDB extends DBSchema { }; } +type OpLogStoreName = (typeof STORE_NAMES)[keyof typeof STORE_NAMES]; + /** * Manages the persistence of operations and state snapshots in IndexedDB. * It uses a dedicated IndexedDB database ('SUP_OPS') to store: @@ -1557,6 +1559,120 @@ export class OperationLogStoreService implements RemoteOperationApplyStorePort { + await this._ensureInit(); + + const { syncImportOp, snapshotEntityKeys, archiveYoung, archiveOld } = opts; + const newState = syncImportOp.payload; + const newVectorClock = syncImportOp.vectorClock; + const compactedAt = Date.now(); + const compactOp = encodeOperation(syncImportOp); + const storeNames: OpLogStoreName[] = [ + STORE_NAMES.OPS, + STORE_NAMES.STATE_CACHE, + STORE_NAMES.VECTOR_CLOCK, + ]; + if (archiveYoung != null) { + storeNames.push(STORE_NAMES.ARCHIVE_YOUNG); + } + if (archiveOld != null) { + storeNames.push(STORE_NAMES.ARCHIVE_OLD); + } + const tx = this.db.transaction(storeNames, 'readwrite'); + + try { + const opsStore = tx.objectStore(STORE_NAMES.OPS); + const stateCacheStore = tx.objectStore(STORE_NAMES.STATE_CACHE); + const vcStore = tx.objectStore(STORE_NAMES.VECTOR_CLOCK); + + await opsStore.clear(); + + const entry: Omit = { + op: compactOp, + appliedAt: Date.now(), + source: 'local', + syncedAt: undefined, + applicationStatus: undefined, + }; + const seq = (await opsStore.add(entry as StoredOperationLogEntry)) as number; + + await vcStore.put({ clock: newVectorClock, lastUpdate: Date.now() }, SINGLETON_KEY); + + await stateCacheStore.put({ + id: SINGLETON_KEY, + state: newState, + lastAppliedOpSeq: seq, + vectorClock: newVectorClock, + compactedAt, + schemaVersion: syncImportOp.schemaVersion, + snapshotEntityKeys, + }); + + if (archiveYoung != null) { + await tx.objectStore(STORE_NAMES.ARCHIVE_YOUNG).put({ + id: SINGLETON_KEY, + data: archiveYoung, + lastModified: compactedAt, + }); + } + + if (archiveOld != null) { + await tx.objectStore(STORE_NAMES.ARCHIVE_OLD).put({ + id: SINGLETON_KEY, + data: archiveOld, + lastModified: compactedAt, + }); + } + + await tx.done; + + this._appliedOpIdsCache = null; + this._cacheLastSeq = 0; + this._invalidateUnsyncedCache(); + this._vectorClockCache = newVectorClock; + } catch (e) { + // idb auto-aborts the tx on any rejected request, but a spy that + // throws synchronously instead of rejecting the IDB request (used by + // the interrupt integration tests) leaves the tx open with queued + // writes — explicit abort is what unwinds them in that case. + try { + tx.abort(); + } catch { + // Already aborted/committed — InvalidStateError; nothing to do. + } + if (e instanceof DOMException && e.name === 'QuotaExceededError') { + throw new StorageQuotaExceededError(); + } + throw e; + } + } // ============================================================ // Profile Data Storage // ============================================================ diff --git a/src/app/op-log/sync/lock.service.ts b/src/app/op-log/sync/lock.service.ts index 221b9d134a..ef76160ed1 100644 --- a/src/app/op-log/sync/lock.service.ts +++ b/src/app/op-log/sync/lock.service.ts @@ -29,11 +29,11 @@ export class LockService { // Fallback for browsers without Web Locks API - single-tab mutex private _fallbackLocks = new Map>(); - async request( + async request( lockName: string, - callback: () => Promise, + callback: () => Promise, timeoutMs: number = LOCK_ACQUISITION_TIMEOUT_MS, - ): Promise { + ): Promise { // Electron and Android WebView are single-instance (no multi-tab), but still need // in-process locking to prevent concurrent code paths (e.g., ImmediateUploadService // and main sync running simultaneously). Use fallback mutex for these. @@ -61,7 +61,11 @@ export class LockService { const timer = setTimeout(() => controller.abort(), timeoutMs); try { - await navigator.locks.request(lockName, { signal: controller.signal }, callback); + return await navigator.locks.request( + lockName, + { signal: controller.signal }, + callback, + ); } catch (e) { if (e instanceof DOMException && e.name === 'AbortError') { throw new LockAcquisitionTimeoutError(lockName, timeoutMs); @@ -81,11 +85,11 @@ export class LockService { * lock resolves. This keeps later waiters behind the real lock holder without * poisoning the queue if that holder eventually finishes. */ - private async _fallbackRequest( + private async _fallbackRequest( lockName: string, - callback: () => Promise, + callback: () => Promise, timeoutMs: number, - ): Promise { + ): Promise { // Wait for any existing lock to be released const existingLock = this._fallbackLocks.get(lockName); @@ -112,7 +116,7 @@ export class LockService { ]); } // Execute the callback - await callback(); + return await callback(); } finally { releaseLock!(); if (this._fallbackLocks.get(lockName) === lockChain) { diff --git a/src/app/op-log/sync/operation-log-download.service.spec.ts b/src/app/op-log/sync/operation-log-download.service.spec.ts index 5315e002ae..75db01ea90 100644 --- a/src/app/op-log/sync/operation-log-download.service.spec.ts +++ b/src/app/op-log/sync/operation-log-download.service.spec.ts @@ -50,7 +50,7 @@ describe('OperationLogDownloadService', () => { // Default mock implementations // Note: Don't use async/await here as it breaks fakeAsync zone context - mockLockService.request.and.callFake((_name: string, fn: () => Promise) => { + mockLockService.request.and.callFake((_name: string, fn: () => Promise) => { return fn(); }); mockOpLogStore.getAppliedOpIds.and.returnValue(Promise.resolve(new Set())); diff --git a/src/app/op-log/sync/operation-log-lock-reentry.regression.spec.ts b/src/app/op-log/sync/operation-log-lock-reentry.regression.spec.ts index 76bb9930cb..4a3a24cdd2 100644 --- a/src/app/op-log/sync/operation-log-lock-reentry.regression.spec.ts +++ b/src/app/op-log/sync/operation-log-lock-reentry.regression.spec.ts @@ -47,11 +47,11 @@ import { T } from '../../t.const'; const SHORT_TIMEOUT_MS = 50; class ShortTimeoutLockService extends LockService { - override request( + override request( name: string, - cb: () => Promise, + cb: () => Promise, timeoutMs: number = SHORT_TIMEOUT_MS, - ): Promise { + ): Promise { return super.request(name, cb, timeoutMs); } } diff --git a/src/app/op-log/sync/operation-log-upload.service.spec.ts b/src/app/op-log/sync/operation-log-upload.service.spec.ts index 9a9dc1bc23..0a0ecdd159 100644 --- a/src/app/op-log/sync/operation-log-upload.service.spec.ts +++ b/src/app/op-log/sync/operation-log-upload.service.spec.ts @@ -49,10 +49,8 @@ describe('OperationLogUploadService', () => { mockLockService = jasmine.createSpyObj('LockService', ['request']); // Default mock implementations - mockLockService.request.and.callFake( - async (_name: string, fn: () => Promise) => { - await fn(); - }, + mockLockService.request.and.callFake(async (_name: string, fn: () => Promise) => + fn(), ); mockOpLogStore.getUnsynced.and.returnValue(Promise.resolve([])); mockOpLogStore.markSynced.and.returnValue(Promise.resolve()); @@ -1351,10 +1349,11 @@ describe('OperationLogUploadService', () => { const callOrder: string[] = []; mockLockService.request.and.callFake( - async (_name: string, fn: () => Promise) => { + async (_name: string, fn: () => Promise) => { callOrder.push('lock-acquired'); - await fn(); + const r = await fn(); callOrder.push('lock-released'); + return r; }, ); diff --git a/src/app/op-log/sync/operation-write-flush.service.spec.ts b/src/app/op-log/sync/operation-write-flush.service.spec.ts index 8586ebdbb5..9052c23735 100644 --- a/src/app/op-log/sync/operation-write-flush.service.spec.ts +++ b/src/app/op-log/sync/operation-write-flush.service.spec.ts @@ -9,9 +9,7 @@ describe('OperationWriteFlushService', () => { beforeEach(() => { lockServiceSpy = jasmine.createSpyObj('LockService', ['request']); lockServiceSpy.request.and.callFake( - async (_name: string, callback: () => Promise) => { - await callback(); - }, + async (_name: string, callback: () => Promise) => callback(), ); TestBed.configureTestingModule({ @@ -37,9 +35,9 @@ describe('OperationWriteFlushService', () => { it('should resolve after lock is acquired', async () => { let lockAcquired = false; lockServiceSpy.request.and.callFake( - async (_name: string, callback: () => Promise) => { + async (_name: string, callback: () => Promise) => { lockAcquired = true; - await callback(); + return callback(); }, ); @@ -57,11 +55,11 @@ describe('OperationWriteFlushService', () => { // Simulate a lock being held by another operation lockServiceSpy.request.and.callFake( - async (_name: string, callback: () => Promise) => { + async (_name: string, callback: () => Promise) => { executionOrder.push('lock-acquired'); await lockHolderPromise; executionOrder.push('lock-callback-done'); - await callback(); + return callback(); }, ); @@ -113,10 +111,11 @@ describe('OperationWriteFlushService', () => { // Simulate lock service that tracks operation order lockServiceSpy.request.and.callFake( - async (_name: string, callback: () => Promise) => { + async (_name: string, callback: () => Promise) => { const opNum = ++opCounter; - await callback(); + const r = await callback(); completedOps.push(opNum); + return r; }, ); diff --git a/src/app/op-log/sync/remote-ops-processing.service.spec.ts b/src/app/op-log/sync/remote-ops-processing.service.spec.ts index 5e9fcc67cd..0bc509d76a 100644 --- a/src/app/op-log/sync/remote-ops-processing.service.spec.ts +++ b/src/app/op-log/sync/remote-ops-processing.service.spec.ts @@ -201,9 +201,7 @@ describe('RemoteOpsProcessingService', () => { lockServiceSpy = jasmine.createSpyObj('LockService', ['request']); // Default: execute callback immediately (simulating lock acquisition) lockServiceSpy.request.and.callFake( - async (_name: string, callback: () => Promise) => { - await callback(); - }, + async (_name: string, callback: () => Promise) => callback(), ); compactionServiceSpy = jasmine.createSpyObj('OperationLogCompactionService', [ 'compact', @@ -329,9 +327,9 @@ describe('RemoteOpsProcessingService', () => { callOrder.push('flushPendingWrites'); }); lockServiceSpy.request.and.callFake( - async (_name: string, callback: () => Promise) => { + async (_name: string, callback: () => Promise) => { callOrder.push('lockAcquired'); - await callback(); + return callback(); }, ); spyOn(service, 'detectConflicts').and.callFake(async () => { diff --git a/src/app/op-log/testing/integration/clean-slate-interrupt.integration.spec.ts b/src/app/op-log/testing/integration/clean-slate-interrupt.integration.spec.ts new file mode 100644 index 0000000000..f4fb992c00 --- /dev/null +++ b/src/app/op-log/testing/integration/clean-slate-interrupt.integration.spec.ts @@ -0,0 +1,222 @@ +import { TestBed } from '@angular/core/testing'; +import { OperationLogStoreService } from '../../persistence/operation-log-store.service'; +import { CleanSlateService } from '../../clean-slate/clean-slate.service'; +import { StateSnapshotService } from '../../backup/state-snapshot.service'; +import { ClientIdService } from '../../../core/util/client-id.service'; +import { SyncLocalStateService } from '../../sync/sync-local-state.service'; +import { TranslateService } from '@ngx-translate/core'; +import { CURRENT_SCHEMA_VERSION } from '../../persistence/schema-migration.service'; +import { Operation } from '../../core/operation.types'; +import { STORE_NAMES } from '../../persistence/db-keys.const'; + +/** + * Integration tests for issue #7709 — `createCleanSlate` / `BackupService` import + * interrupted mid-sequence. + * + * The reported bug requires that on a surviving device, `isWhollyFreshClient()` + * returns true (i.e. `state_cache===null && lastSeq===0`) while NgRx in-memory + * state still has meaningful data. The pre-fix code reached that state by an + * interrupt between `clearAllOperations()` and `saveStateCache(...)`. + * + * After PR-A, `runDestructiveStateReplacement` makes the destructive sequence + * either commit fully or leave the prior state intact. The tests below + * exercise the fix by injecting failures inside the helper's destructive tx + * and asserting that the device's prior state is preserved. + * + * Tests use real IndexedDB. + */ +describe('CleanSlate / Backup interrupt (issue #7709 regression)', () => { + let storeService: OperationLogStoreService; + let syncLocalState: SyncLocalStateService; + let cleanSlate: CleanSlateService; + let mockStateSnapshot: jasmine.SpyObj; + let mockClientId: jasmine.SpyObj; + let mockTranslate: jasmine.SpyObj; + + const meaningfulState = { + task: { + ids: ['t1', 't2', 't3'], + entities: { t1: { id: 't1' }, t2: { id: 't2' }, t3: { id: 't3' } }, + }, + project: { ids: ['INBOX'], entities: {} }, + tag: { ids: [], entities: {} }, + note: { ids: [], entities: {} }, + globalConfig: {}, + schemaVersion: CURRENT_SCHEMA_VERSION, + }; + + beforeEach(async () => { + mockStateSnapshot = jasmine.createSpyObj('StateSnapshotService', [ + 'getStateSnapshot', + 'getStateSnapshotAsync', + ]); + mockStateSnapshot.getStateSnapshot.and.returnValue(meaningfulState as any); + mockStateSnapshot.getStateSnapshotAsync.and.resolveTo(meaningfulState as any); + + mockClientId = jasmine.createSpyObj('ClientIdService', ['withRotation']); + // Default: invoke the rotation callback with a fresh id. Rollback + // semantics are exercised in ClientIdService's own spec. + mockClientId.withRotation.and.callFake( + async (_logPrefix: string, fn: (newClientId: string) => Promise) => + fn('cNew1'), + ); + + mockTranslate = jasmine.createSpyObj('TranslateService', ['instant']); + mockTranslate.instant.and.callFake((k: string) => k); + + TestBed.configureTestingModule({ + providers: [ + OperationLogStoreService, + SyncLocalStateService, + CleanSlateService, + { provide: StateSnapshotService, useValue: mockStateSnapshot }, + { provide: ClientIdService, useValue: mockClientId }, + { provide: TranslateService, useValue: mockTranslate }, + ], + }); + + storeService = TestBed.inject(OperationLogStoreService); + syncLocalState = TestBed.inject(SyncLocalStateService); + cleanSlate = TestBed.inject(CleanSlateService); + + await storeService.init(); + await storeService._clearAllDataForTesting(); + }); + + describe('baseline (no interrupt)', () => { + it('completes the destructive sequence and leaves a populated state_cache', async () => { + expect(await syncLocalState.isWhollyFreshClient()).toBe(true); + + await cleanSlate.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED'); + + expect(await storeService.getLastSeq()).toBeGreaterThan(0); + const cache = await storeService.loadStateCache(); + expect(cache).not.toBeNull(); + expect(cache!.state).toEqual(meaningfulState as any); + expect(await syncLocalState.isWhollyFreshClient()).toBe(false); + }); + }); + + describe('runDestructiveStateReplacement atomicity', () => { + it('preserves OPS, state_cache, and vector_clock when the destructive tx fails', async () => { + // Seed the device: a few ops, a populated state_cache (post-compaction), + // and a vector clock. This is the "normal-use" device state. + const userOps: Operation[] = Array.from({ length: 3 }, (_, i) => ({ + id: `op-${i}`, + actionType: 'TASK_ADD' as any, + opType: 'Create' as any, + entityType: 'TASK' as any, + entityId: `t${i}`, + payload: { id: `t${i}` }, + clientId: 'cPrior', + vectorClock: { cPrior: i + 1 }, + timestamp: Date.now() + i, + schemaVersion: CURRENT_SCHEMA_VERSION, + })); + for (const op of userOps) { + await storeService.append(op, 'local'); + } + await storeService.saveStateCache({ + state: { sentinel: 'prior-state' } as any, + lastAppliedOpSeq: 0, + vectorClock: { cPrior: 3 }, + compactedAt: Date.now(), + schemaVersion: CURRENT_SCHEMA_VERSION, + }); + await storeService.setVectorClock({ cPrior: 3 }); + + const seqBefore = await storeService.getLastSeq(); + const cacheBefore = await storeService.loadStateCache(); + const clockBefore = await storeService.getVectorClock(); + + // Inject a failure inside the destructive tx: opsStore.add throws after + // the queued opsStore.clear(). IDB auto-aborts the tx; the prior OPS + // entries must survive. + const realTransaction = (storeService as any).db.transaction.bind( + (storeService as any).db, + ); + spyOn((storeService as any).db, 'transaction').and.callFake( + (stores: any, mode: any) => { + const tx = realTransaction(stores, mode); + if (Array.isArray(stores) && stores.includes(STORE_NAMES.OPS)) { + const opsStore = tx.objectStore(STORE_NAMES.OPS); + opsStore.add = async () => { + throw new Error('Simulated interrupt: append failed inside destructive tx'); + }; + } + return tx; + }, + ); + + await expectAsync( + cleanSlate.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED'), + ).toBeRejected(); + + // POST: device is exactly as before the destructive call. + expect(await storeService.getLastSeq()).toBe(seqBefore); + const cacheAfter = await storeService.loadStateCache(); + expect(cacheAfter).not.toBeNull(); + expect((cacheAfter!.state as any).sentinel).toBe('prior-state'); + expect(cacheAfter!.vectorClock).toEqual(cacheBefore!.vectorClock); + expect(await storeService.getVectorClock()).toEqual(clockBefore); + }); + }); + + describe('after fix: createCleanSlate interrupt no longer produces #7709 precondition', () => { + it('on a never-compacted device, an interrupt leaves prior op-log intact', async () => { + // Low-activity device: 3 ops, no state_cache (never compacted). + const userOps: Operation[] = Array.from({ length: 3 }, (_, i) => ({ + id: `op-${i}`, + actionType: 'TASK_ADD' as any, + opType: 'Create' as any, + entityType: 'TASK' as any, + entityId: `t${i}`, + payload: { id: `t${i}` }, + clientId: 'cPrior', + vectorClock: { cPrior: i + 1 }, + timestamp: Date.now() + i, + schemaVersion: CURRENT_SCHEMA_VERSION, + })); + for (const op of userOps) { + await storeService.append(op, 'local'); + } + expect(await storeService.loadStateCache()).toBeNull(); + const seqBefore = await storeService.getLastSeq(); + + // Inject a failure into the destructive tx (force opsStore.add to throw). + const realTransaction = (storeService as any).db.transaction.bind( + (storeService as any).db, + ); + spyOn((storeService as any).db, 'transaction').and.callFake( + (stores: any, mode: any) => { + const tx = realTransaction(stores, mode); + if (Array.isArray(stores) && stores.includes(STORE_NAMES.OPS)) { + const opsStore = tx.objectStore(STORE_NAMES.OPS); + opsStore.add = async () => { + throw new Error('Simulated interrupt: append failed inside destructive tx'); + }; + } + return tx; + }, + ); + + await expectAsync( + cleanSlate.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED'), + ).toBeRejected(); + + // POST-FIX behavior: device retains its prior ops and state_cache-null status. + // Crucially, lastSeq is UNCHANGED — the IDB transaction's `clear()` was + // rolled back when the subsequent `add` threw. + // Without the atomicity fix, lastSeq would be 0 here and the next launch + // would route through `isWhollyFreshClient + meaningful store data` and + // throw `LocalDataConflictError(0, {})` — the #7709 chain. + expect(await storeService.getLastSeq()).toBe(seqBefore); + expect(await storeService.loadStateCache()).toBeNull(); + expect(syncLocalState.hasMeaningfulStoreData()).toBe(true); + // The device is NOT classified as wholly fresh — even though state_cache + // was missing before the destructive call too, the surviving op-log keeps + // `lastSeq > 0` so `isWhollyFreshClient()` is false. + expect(await syncLocalState.isWhollyFreshClient()).toBe(false); + }); + }); +}); diff --git a/src/app/op-log/testing/integration/migration-handling.integration.spec.ts b/src/app/op-log/testing/integration/migration-handling.integration.spec.ts index 91d2bb43b3..aaf7460e00 100644 --- a/src/app/op-log/testing/integration/migration-handling.integration.spec.ts +++ b/src/app/op-log/testing/integration/migration-handling.integration.spec.ts @@ -77,7 +77,7 @@ describe('Migration Handling Integration', () => { { provide: LockService, useValue: { - request: async (_name: string, fn: () => Promise) => fn(), + request: async (_name: string, fn: () => Promise) => fn(), }, }, { diff --git a/src/app/op-log/testing/integration/task-done-replay.integration.spec.ts b/src/app/op-log/testing/integration/task-done-replay.integration.spec.ts index 26920de11e..6ff89e5b79 100644 --- a/src/app/op-log/testing/integration/task-done-replay.integration.spec.ts +++ b/src/app/op-log/testing/integration/task-done-replay.integration.spec.ts @@ -129,10 +129,8 @@ describe('done task operation replay', () => { ['updatePendingOpsStatus'], ); - mockLockService.request.and.callFake( - async (_name: string, fn: () => Promise) => { - await fn(); - }, + mockLockService.request.and.callFake(async (_name: string, fn: () => Promise) => + fn(), ); mockOpLogStore.appendWithVectorClockUpdate.and.returnValue(Promise.resolve(1)); mockOpLogStore.getCompactionCounter.and.returnValue(Promise.resolve(0)); diff --git a/src/app/op-log/validation/repair-operation.service.spec.ts b/src/app/op-log/validation/repair-operation.service.spec.ts index 6ac98dac55..381a318610 100644 --- a/src/app/op-log/validation/repair-operation.service.spec.ts +++ b/src/app/op-log/validation/repair-operation.service.spec.ts @@ -45,10 +45,8 @@ describe('RepairOperationService', () => { ]); // Default mock implementations - mockLockService.request.and.callFake( - async (_name: string, fn: () => Promise) => { - await fn(); - }, + mockLockService.request.and.callFake(async (_name: string, fn: () => Promise) => + fn(), ); // appendWithVectorClockUpdate returns the sequence number mockOpLogStore.appendWithVectorClockUpdate.and.returnValue(Promise.resolve(100)); From 9176fa5238107911d101df4e289c761a3947f151 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 22 May 2026 17:33:12 +0200 Subject: [PATCH 30/42] feat(plugins): work-context header buttons, embed slot, and WORK_CONTEXT_CHANGE hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the host-side plugin API for work-context-scoped UI: - registerWorkContextHeaderButton — context-filtered (PROJECT/TAG/TODAY) header buttons, with toggled-state support on the active button - a work-view embed slot so a plugin can replace the task list with its own UI for the active context (showInWorkContext / closeWorkContextView) - the WORK_CONTEXT_CHANGE hook and getActiveWorkContext() pull API, sharing one toActiveWorkContext() projection helper as the single source of truth - selectTask API plus the plugin-bridge / plugin-hooks wiring and specs Squashed from the feat/doc-mode-v4 work. The full per-step history — including the earlier in-tree (non-plugin) Angular document-mode feature that was prototyped and then removed on the same branch — is preserved in the archive/doc-mode-v4-full-history branch and the doc-mode-v4-pre-squash tag. --- packages/plugin-api/src/types.ts | 103 +++ .../main-header/main-header.component.html | 1 + .../main-header/main-header.component.ts | 2 + .../work-view/work-view.component.html | 680 +++++++++--------- .../work-view/work-view.component.scss | 6 + .../work-view/work-view.component.spec.ts | 13 + .../features/work-view/work-view.component.ts | 36 + src/app/plugins/plugin-api.model.ts | 3 + src/app/plugins/plugin-api.ts | 28 + .../plugin-bridge-add-task.service.spec.ts | 4 +- .../plugin-bridge-counter.service.spec.ts | 6 +- ...plugin-bridge-work-context.service.spec.ts | 397 ++++++++++ src/app/plugins/plugin-bridge.service.spec.ts | 5 +- src/app/plugins/plugin-bridge.service.ts | 174 ++++- src/app/plugins/plugin-hooks.effects.spec.ts | 6 +- src/app/plugins/plugin-hooks.effects.ts | 19 + .../plugin-user-persistence.service.spec.ts | 85 ++- .../plugin-user-persistence.service.ts | 91 ++- src/app/plugins/plugin.service.ts | 1 + .../ui/plugin-index/plugin-index.component.ts | 19 +- ...ugin-work-context-header-btns.component.ts | 65 ++ .../util/active-work-context.util.spec.ts | 57 ++ .../plugins/util/active-work-context.util.ts | 19 + src/app/plugins/util/plugin-iframe.util.ts | 89 ++- 24 files changed, 1529 insertions(+), 380 deletions(-) create mode 100644 src/app/plugins/plugin-bridge-work-context.service.spec.ts create mode 100644 src/app/plugins/ui/plugin-work-context-header-btns.component.ts create mode 100644 src/app/plugins/util/active-work-context.util.spec.ts create mode 100644 src/app/plugins/util/active-work-context.util.ts diff --git a/packages/plugin-api/src/types.ts b/packages/plugin-api/src/types.ts index 300793278c..e24b39d527 100644 --- a/packages/plugin-api/src/types.ts +++ b/packages/plugin-api/src/types.ts @@ -25,6 +25,7 @@ export enum PluginHooks { ACTION = 'action', ANY_TASK_UPDATE = 'anyTaskUpdate', PROJECT_LIST_UPDATE = 'projectListUpdate', + WORK_CONTEXT_CHANGE = 'workContextChange', } export type Hooks = PluginHooks; @@ -188,6 +189,41 @@ export interface ProjectListUpdatePayload { changes?: Partial; } +/** + * Snapshot of the active work context (project or tag). + * Used by getActiveWorkContext() and as the WORK_CONTEXT_CHANGE payload. + * + * **`taskIds` is a snapshot at the moment of emission**, not a live view. + * It goes stale as soon as a task is added, removed, or moved. Plugins + * that need the current ordering should call `getActiveWorkContext()` / + * `getTasks()` on demand or subscribe to `ANY_TASK_UPDATE` for changes. + */ +export interface ActiveWorkContext { + id: string; + /** + * `'TODAY'` is reported for the special Today tag (whose `id` is also + * `'TODAY'`); every other tag is `'TAG'`. This matches the vocabulary of + * {@link PluginWorkContextHeaderBtnCfg.showFor}. + */ + type: 'PROJECT' | 'TAG' | 'TODAY'; + title: string; + /** + * An independent copy taken at emit time — safe to read, and mutating it + * has no effect on the app. See the {@link ActiveWorkContext} note above. + */ + taskIds: string[]; +} + +/** + * Payload of the WORK_CONTEXT_CHANGE hook, fired when the user switches + * between projects/tags. A context is always active while the app is + * running, so the payload is never null. + * + * The included `taskIds` is a snapshot — re-read via `getActiveWorkContext()` + * if you care about the current order/membership. + */ +export type WorkContextChangePayload = ActiveWorkContext; + // Map hook types to their payload types export interface HookPayloadMap { [PluginHooks.TASK_CREATED]: TaskCreatedPayload; @@ -201,6 +237,7 @@ export interface HookPayloadMap { [PluginHooks.ACTION]: ActionPayload; [PluginHooks.ANY_TASK_UPDATE]: AnyTaskUpdatePayload; [PluginHooks.PROJECT_LIST_UPDATE]: ProjectListUpdatePayload; + [PluginHooks.WORK_CONTEXT_CHANGE]: WorkContextChangePayload; } // Generic hook handler with typed payload @@ -323,6 +360,18 @@ export interface PluginSidePanelBtnCfg { onClick: () => void; } +/** + * Header button that is only rendered when the active work context matches + * one of the entries in `showFor`. `'TODAY'` refers to the special TODAY tag. + */ +export interface PluginWorkContextHeaderBtnCfg { + pluginId: string; + label: string; + icon?: string; + onClick: (ctx: ActiveWorkContext) => void; + showFor: ('PROJECT' | 'TAG' | 'TODAY')[]; +} + export interface OAuthFlowConfig { authUrl: string; tokenUrl: string; @@ -381,8 +430,52 @@ export interface PluginAPI { registerSidePanelButton(sidePanelBtnCfg: Omit): void; + /** + * Register a header button that is only visible when the active work + * context matches one of the entries in `showFor`. The handler receives a + * snapshot of the active context. + * + * Register this from your plugin's main script — not from an embedded + * `index.html` — so the button and its handler outlive any work-view embed + * the button toggles. A button registered from an embed iframe stops + * working once that embed is closed. + */ + registerWorkContextHeaderButton( + cfg: Omit, + ): void; + registerIssueProvider(definition: IssueProviderPluginDefinition): void; + /** + * Returns the currently active project or tag. The TODAY tag has id + * `'TODAY'`. A context stays active even on non-work-view routes (e.g. a + * settings page) — it is the last one the user opened. Resolves to null + * only if the app has not finished its initial data load. + */ + getActiveWorkContext(): Promise; + + /** + * Mount this plugin's index.html inside the work-view body, in place of + * the task list. + * + * The embed is shown **only while the active context is a project or the + * TODAY tag**. For any other context (a regular tag, or a non-work-view + * route) it is a silent no-op — nothing renders and no error is raised. + * The embed automatically hides and reappears as the user navigates in + * and out of eligible contexts; the request stays armed until + * `closeWorkContextView` is called. To check whether a call will take + * effect right now, read `getActiveWorkContext()` first. + * + * Call `closeWorkContextView` to revert to the normal task-list view. + */ + showInWorkContext(): void; + + /** + * Revert the work-view body to the normal task list. No-op unless this + * plugin currently owns the embed. + */ + closeWorkContextView(): void; + // cross-process communication onMessage?(handler: (message: unknown) => Promise | unknown): void; @@ -402,6 +495,13 @@ export interface PluginAPI { getCurrentContextTasks(): Promise; + /** + * Select a task, opening its detail panel in the app's right-hand panel. + * Works regardless of the active view — including while a plugin embed + * occupies the work-view body. Accepts a task or subtask id. + */ + selectTask(taskId: string): Promise; + reInitData(): Promise; updateTask(taskId: string, updates: Partial): Promise; @@ -600,6 +700,9 @@ export enum PluginIframeMessageType { DIALOG_BUTTON_CLICK = 'PLUGIN_DIALOG_BUTTON_CLICK', DIALOG_BUTTON_RESPONSE = 'PLUGIN_DIALOG_BUTTON_RESPONSE', + // Work-context header button click forwarded to the iframe + WORK_CONTEXT_BTN_CLICK = 'PLUGIN_WORK_CONTEXT_BTN_CLICK', + // Message forwarding MESSAGE = 'PLUGIN_MESSAGE', MESSAGE_RESPONSE = 'PLUGIN_MESSAGE_RESPONSE', diff --git a/src/app/core-ui/main-header/main-header.component.html b/src/app/core-ui/main-header/main-header.component.html index 5af5b4a94c..7988b06b31 100644 --- a/src/app/core-ui/main-header/main-header.component.html +++ b/src/app/core-ui/main-header/main-header.component.html @@ -79,6 +79,7 @@ } + @if (isUserProfilesEnabled()) { diff --git a/src/app/core-ui/main-header/main-header.component.ts b/src/app/core-ui/main-header/main-header.component.ts index f5a0d5aabc..ab42fec8db 100644 --- a/src/app/core-ui/main-header/main-header.component.ts +++ b/src/app/core-ui/main-header/main-header.component.ts @@ -35,6 +35,7 @@ import { DataInitStateService } from '../../core/data-init/data-init-state.servi import { showFocusOverlay } from '../../features/focus-mode/store/focus-mode.actions'; import { SyncStatus } from '../../op-log/sync-exports'; import { PluginHeaderBtnsComponent } from '../../plugins/ui/plugin-header-btns.component'; +import { PluginWorkContextHeaderBtnsComponent } from '../../plugins/ui/plugin-work-context-header-btns.component'; import { PluginSidePanelBtnsComponent } from '../../plugins/ui/plugin-side-panel-btns.component'; import { PageTitleComponent } from './page-title/page-title.component'; import { PlayButtonComponent } from './play-button/play-button.component'; @@ -61,6 +62,7 @@ import { FocusModeService } from '../../features/focus-mode/focus-mode.service'; SimpleCounterButtonComponent, LongPressDirective, PluginHeaderBtnsComponent, + PluginWorkContextHeaderBtnsComponent, PluginSidePanelBtnsComponent, PageTitleComponent, PlayButtonComponent, diff --git a/src/app/features/work-view/work-view.component.html b/src/app/features/work-view/work-view.component.html index a23e35eaea..5cbd4cc986 100644 --- a/src/app/features/work-view/work-view.component.html +++ b/src/app/features/work-view/work-view.component.html @@ -10,365 +10,377 @@ class="today" cdkScrollable > -
- @if (estimateRemainingToday() || workingToday()) { -
+ @if (!pluginEmbedId()) { +
+ @if (estimateRemainingToday() || workingToday()) {
- {{ T.WW.ESTIMATE_REMAINING | translate }} - - ~{{ - estimateRemainingToday() | msToString - }} - hourglass_empty - -
-
- {{ T.WW.WORKING_TODAY | translate }} - - {{ workingToday() | msToString }} - @if ( - workContextService.workingTodayArchived$ | async; - as workingTodayArchived - ) { - (+{{ workingTodayArchived | msToString }}) - } - timelapse - -
- @if (isShowTimeWorkedWithoutBreak) { -
- {{ T.WW.WITHOUT_BREAK | translate }} - - {{ - takeABreakService.timeWorkingWithoutABreak$ | async | msToString - }}timer - -
- -
-
- } -
- } -
- -
- @if (!(workContextService.isHasTasksToWorkOn$ | async)) { -
-
-
- - -
-

- @if (hasDoneTasks() || (workContextService.doneTodayArchived$ | async)) { - {{ T.WW.NO_PLANNED_TASK_ALL_DONE | translate }} - } @else { - {{ T.WW.NO_PLANNED_TASKS | translate }} - } -

-
- - @if (!overdueTasks().length) { -
- -
- } -
- } - @if (customizedUndoneTasks(); as customized) { - @if (customized.grouped) { - @for (group of customized.grouped | keyvalue; track group.key) { - - - - } - } @else if (sections().length && !customizerService.isCustomized()) { - @let bySection = undoneTasksBySection(); -
-
- + {{ T.WW.ESTIMATE_REMAINING | translate }} + + ~{{ + estimateRemainingToday() | msToString + }} + hourglass_empty +
- @for (section of sections(); track section.id) { -
- + {{ T.WW.WORKING_TODAY | translate }} + + {{ workingToday() | msToString }} + @if ( + workContextService.workingTodayArchived$ | async; + as workingTodayArchived + ) { + (+{{ workingTodayArchived | msToString }}) + } + timelapse + +
+ @if (isShowTimeWorkedWithoutBreak) { +
+ {{ T.WW.WITHOUT_BREAK | translate }} + + {{ + takeABreakService.timeWorkingWithoutABreak$ | async | msToString + }}timer + +
- - - - - - - - - - + +
}
- } @else { - } - } + + } - @if (isShowOverduePanel()) { -
- - + } @else { +
+ @if (!(workContextService.isHasTasksToWorkOn$ | async)) { +
-
- - - - - - - - - +
+
+ + +
+

+ @if ( + hasDoneTasks() || (workContextService.doneTodayArchived$ | async) + ) { + {{ T.WW.NO_PLANNED_TASK_ALL_DONE | translate }} + } @else { + {{ T.WW.NO_PLANNED_TASKS | translate }} + } +

-
- -
- -
- } - - @if (laterTodayTasks().length > 0 && isOnTodayList()) { -
- - - -
- } - - @if (hasDoneTasks()) { - @let nrOfDoneArchived = - doneTasks()?.length === 0 - ? (workContextService.doneTodayArchived$ | async) - : 0; -
- - -
- @if (hasDoneTasks() && (!isOnTodayList() || !isFinishDayEnabled())) { + @if (!overdueTasks().length) { +
+
+ } +
+ } + @if (customizedUndoneTasks(); as customized) { + @if (customized.grouped) { + @for (group of customized.grouped | keyvalue; track group.key) { + + + + } + } @else if (sections().length && !customizerService.isCustomized()) { + @let bySection = undoneTasksBySection(); +
+
+ +
+ @for (section of sections(); track section.id) { +
+ + + + + + + + + + + +
}
-
-
- } + } @else { + + } + } - @if (isShowRepeatCfgsPanel()) { -
- -
- @for (repeatCfg of repeatCfgsForContext(); track repeatCfg.id) { - - } -
-
-
- } + + +
+ + + + + + + + + +
- @if (isOnTodayList() && isFinishDayEnabled()) { - - } -
+
+ +
+ + + } + + @if (laterTodayTasks().length > 0 && isOnTodayList()) { +
+ + + +
+ } + + @if (hasDoneTasks()) { + @let nrOfDoneArchived = + doneTasks()?.length === 0 + ? (workContextService.doneTodayArchived$ | async) + : 0; +
+ + +
+ @if (hasDoneTasks() && (!isOnTodayList() || !isFinishDayEnabled())) { + + } +
+
+
+ } + + @if (isShowRepeatCfgsPanel()) { +
+ +
+ @for (repeatCfg of repeatCfgsForContext(); track repeatCfg.id) { + + } +
+
+
+ } + + @if (isOnTodayList() && isFinishDayEnabled()) { + + } + + } - @if (isShowBacklog()) { + @if (isShowBacklog() && !pluginEmbedId()) {
{ activeType: 'TAG', activeId: 'TODAY', }), + activeWorkContext$: of({ id: TODAY_TAG.id, type: 'TAG' }), + isActiveWorkContextProject$: of(false), isContextChanging$: of(false), }, }, + { + provide: PluginBridgeService, + useValue: { workContextEmbedPluginId: signal(null) }, + }, { provide: ProjectService, useValue: { onMoveToBacklog$: of() } }, { provide: SectionService, @@ -274,9 +281,15 @@ describe('WorkViewComponent', () => { activeType: 'PROJECT', activeId: 'ctx', }), + activeWorkContext$: of({ id: 'ctx', type: 'PROJECT' }), + isActiveWorkContextProject$: of(true), isContextChanging$: of(false), }, }, + { + provide: PluginBridgeService, + useValue: { workContextEmbedPluginId: signal(null) }, + }, { provide: ProjectService, useValue: { onMoveToBacklog$: of() } }, { provide: SectionService, diff --git a/src/app/features/work-view/work-view.component.ts b/src/app/features/work-view/work-view.component.ts index b5b668e251..7faa9880a6 100644 --- a/src/app/features/work-view/work-view.component.ts +++ b/src/app/features/work-view/work-view.component.ts @@ -85,6 +85,8 @@ import { RepeatCfgPreviewComponent } from '../task-repeat-cfg/repeat-cfg-preview import { recordSearchNavDebug } from '../../util/search-nav-debug'; import { dragDelayForTouch } from '../../util/input-intent'; import { DateService } from '../../core/date/date.service'; +import { PluginIndexComponent } from '../../plugins/ui/plugin-index/plugin-index.component'; +import { PluginBridgeService } from '../../plugins/plugin-bridge.service'; @Component({ selector: 'work-view', @@ -118,6 +120,7 @@ import { DateService } from '../../core/date/date.service'; FinishDayBtnComponent, ScheduledDateGroupPipe, RepeatCfgPreviewComponent, + PluginIndexComponent, ], }) export class WorkViewComponent implements OnInit, OnDestroy { @@ -139,8 +142,41 @@ export class WorkViewComponent implements OnInit, OnDestroy { private _matDialog = inject(MatDialog); private _destroyRef = inject(DestroyRef); private _dateService = inject(DateService); + private _pluginBridge = inject(PluginBridgeService); protected readonly dragDelayForTouch = dragDelayForTouch; + isProjectContext = toSignal(this.workContextService.isActiveWorkContextProject$, { + initialValue: false, + }); + + private _isTodayContext = toSignal( + this.workContextService.activeWorkContext$.pipe( + map((ctx) => ctx.id === TODAY_TAG.id), + ), + { initialValue: false }, + ); + + /** + * Whether the current work context allows a plugin embed in the work-view + * body. Mirrors the restriction documented on `PluginAPI.showInWorkContext`: + * project and TODAY contexts only — a plugin embed is never shown for a + * regular tag or a non-work-view route. + */ + private _isEmbeddableContext = computed( + () => this.isProjectContext() || this._isTodayContext(), + ); + + /** + * Plugin id currently embedded in the work-view body, or null. The plugin + * iframe replaces the task list when a plugin has requested the embed + * (`showInWorkContext`) AND the active context is embeddable. + */ + pluginEmbedId = computed(() => { + const id = this._pluginBridge.workContextEmbedPluginId(); + if (!id) return null; + return this._isEmbeddableContext() ? id : null; + }); + isFinishDayEnabled = computed( () => this._globalConfigService.appFeatures().isFinishDayEnabled, ); diff --git a/src/app/plugins/plugin-api.model.ts b/src/app/plugins/plugin-api.model.ts index 9376c9f3a5..a3fc7adeba 100644 --- a/src/app/plugins/plugin-api.model.ts +++ b/src/app/plugins/plugin-api.model.ts @@ -17,6 +17,9 @@ export { PluginNodeScriptRequest, PluginNodeScriptResult, PluginSidePanelBtnCfg, + PluginWorkContextHeaderBtnCfg, + ActiveWorkContext, + WorkContextChangePayload, // Export the new unified types Task, Project, diff --git a/src/app/plugins/plugin-api.ts b/src/app/plugins/plugin-api.ts index c0da0c7b11..819a23165f 100644 --- a/src/app/plugins/plugin-api.ts +++ b/src/app/plugins/plugin-api.ts @@ -19,6 +19,8 @@ import { PluginNodeScriptResult, PluginShortcutCfg, PluginSidePanelBtnCfg, + PluginWorkContextHeaderBtnCfg, + ActiveWorkContext, Project, SnackCfg, Tag, @@ -151,6 +153,13 @@ export class PluginAPI implements PluginAPIInterface { this._boundMethods.registerSidePanelButton(sidePanelBtnCfg); } + registerWorkContextHeaderButton( + cfg: Omit, + ): void { + PluginLog.log(`Plugin ${this._pluginId} registered work-context header button`, cfg); + this._boundMethods.registerWorkContextHeaderButton(cfg); + } + registerIssueProvider(definition: IssueProviderPluginDefinition): void { PluginLog.log(`Plugin ${this._pluginId} registering issue provider`); this._boundMethods.registerIssueProvider(definition); @@ -161,6 +170,20 @@ export class PluginAPI implements PluginAPIInterface { return this._boundMethods.showIndexHtmlAsView(); } + showInWorkContext(): void { + PluginLog.log(`Plugin ${this._pluginId} requested work-context embed`); + this._boundMethods.showInWorkContext(); + } + + closeWorkContextView(): void { + PluginLog.log(`Plugin ${this._pluginId} closed work-context embed`); + this._boundMethods.closeWorkContextView(); + } + + async getActiveWorkContext(): Promise { + return this._boundMethods.getActiveWorkContext(); + } + async getTasks(): Promise { PluginLog.log(`Plugin ${this._pluginId} requested all tasks`); const tasks = await this._pluginBridge.getTasks(); @@ -247,6 +270,11 @@ export class PluginAPI implements PluginAPIInterface { return this._pluginBridge.reorderTasks(taskIds, contextId, contextType); } + async selectTask(taskId: string): Promise { + PluginLog.log(`Plugin ${this._pluginId} requested to select task ${taskId}`); + return this._pluginBridge.selectTask(taskId); + } + async batchUpdateForProject(request: BatchUpdateRequest): Promise { PluginLog.log( `Plugin ${this._pluginId} requested batch update for project ${(request as { projectId: string }).projectId}`, diff --git a/src/app/plugins/plugin-bridge-add-task.service.spec.ts b/src/app/plugins/plugin-bridge-add-task.service.spec.ts index bc4340856e..2b34814e09 100644 --- a/src/app/plugins/plugin-bridge-add-task.service.spec.ts +++ b/src/app/plugins/plugin-bridge-add-task.service.spec.ts @@ -70,7 +70,9 @@ describe('PluginBridgeService.addTask() — subtask short-syntax (issue #7437)', { provide: TagService, useValue: tagServiceSpy }, { provide: WorkContextService, - useValue: jasmine.createSpyObj('WorkContextService', ['activeWorkContext$']), + useValue: jasmine.createSpyObj('WorkContextService', [], { + activeWorkContext$: of(null), + }), }, { provide: SnackService, diff --git a/src/app/plugins/plugin-bridge-counter.service.spec.ts b/src/app/plugins/plugin-bridge-counter.service.spec.ts index aca99e54ff..ace53a3c7a 100644 --- a/src/app/plugins/plugin-bridge-counter.service.spec.ts +++ b/src/app/plugins/plugin-bridge-counter.service.spec.ts @@ -56,9 +56,9 @@ describe('PluginBridgeService.setCounter()', () => { const taskServiceSpy = jasmine.createSpyObj('TaskService', ['allTasks$']); const projectServiceSpy = jasmine.createSpyObj('ProjectService', ['list$']); const tagServiceSpy = jasmine.createSpyObj('TagService', ['getTags$']); - const workContextServiceSpy = jasmine.createSpyObj('WorkContextService', [ - 'activeWorkContext$', - ]); + const workContextServiceSpy = jasmine.createSpyObj('WorkContextService', [], { + activeWorkContext$: of(null), + }); const dataInitServiceSpy = jasmine.createSpyObj('DataInitService', ['reInit']); dataInitServiceSpy.reInit.and.resolveTo(); diff --git a/src/app/plugins/plugin-bridge-work-context.service.spec.ts b/src/app/plugins/plugin-bridge-work-context.service.spec.ts new file mode 100644 index 0000000000..028e0a830d --- /dev/null +++ b/src/app/plugins/plugin-bridge-work-context.service.spec.ts @@ -0,0 +1,397 @@ +import { TestBed } from '@angular/core/testing'; +import { Injector, signal } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { BehaviorSubject, of } from 'rxjs'; +import { MatDialog } from '@angular/material/dialog'; +import { Router } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import { PluginBridgeService } from './plugin-bridge.service'; +import { TaskService } from '../features/tasks/task.service'; +import { ProjectService } from '../features/project/project.service'; +import { TagService } from '../features/tag/tag.service'; +import { WorkContextService } from '../features/work-context/work-context.service'; +import { SnackService } from '../core/snack/snack.service'; +import { NotifyService } from '../core/notify/notify.service'; +import { PluginHooksService } from './plugin-hooks'; +import { PluginUserPersistenceService } from './plugin-user-persistence.service'; +import { PluginConfigService } from './plugin-config.service'; +import { TaskArchiveService } from '../features/archive/task-archive.service'; +import { SyncWrapperService } from '../imex/sync/sync-wrapper.service'; +import { GlobalThemeService } from '../core/theme/global-theme.service'; +import { PluginIssueProviderRegistryService } from './issue-provider/plugin-issue-provider-registry.service'; +import { IssueSyncAdapterRegistryService } from '../features/issue/two-way-sync/issue-sync-adapter-registry.service'; +import { PluginHttpService } from './issue-provider/plugin-http.service'; +import { DataInitService } from '../core/data-init/data-init.service'; +import { GlobalConfigService } from '../features/config/global-config.service'; +import { + WorkContext, + WorkContextType, +} from '../features/work-context/work-context.model'; +import { DEFAULT_GLOBAL_CONFIG } from '../features/config/default-global-config.const'; +import { PluginManifest, PluginHooks } from './plugin-api.model'; + +// Covers the work-context plugin extension points introduced in commit +// e3ce1fcdd9: registerWorkContextHeaderButton (validation, filtering, cleanup) +// and the showInWorkContext / closeWorkContextView embed slot. +describe('PluginBridgeService.workContext — header buttons + embed slot', () => { + const PLUGIN_A = 'plugin-a'; + const PLUGIN_B = 'plugin-b'; + + const manifest = (id: string): PluginManifest => ({ + id, + name: id, + version: '1.0.0', + manifestVersion: 1, + minSupVersion: '1.0.0', + description: 'spec', + permissions: [], + hooks: [PluginHooks.TASK_COMPLETE], + }); + + const projectCtx: WorkContext = { + type: WorkContextType.PROJECT, + id: 'project-1', + title: 'Project', + taskIds: [], + backlogTaskIds: [], + noteIds: [], + theme: {} as WorkContext['theme'], + advancedCfg: {} as WorkContext['advancedCfg'], + routerLink: '/project/project-1', + isEnableBacklog: true, + icon: null, + }; + const todayCtx: WorkContext = { + ...projectCtx, + type: WorkContextType.TAG, + id: 'TODAY', + routerLink: '/tag/TODAY', + }; + const tagCtx: WorkContext = { + ...projectCtx, + type: WorkContextType.TAG, + id: 'tag-1', + routerLink: '/tag/tag-1', + }; + + const setup = ( + initialCtx: WorkContext | null = projectCtx, + ): { + service: PluginBridgeService; + activeCtx$: BehaviorSubject; + } => { + const activeCtx$ = new BehaviorSubject(initialCtx); + const workContextServiceSpy = jasmine.createSpyObj('WorkContextService', [], { + activeWorkContext$: activeCtx$.asObservable(), + }); + + const cfgSignal = signal({ ...DEFAULT_GLOBAL_CONFIG }); + const globalConfigSpy = { + cfg: cfgSignal, + } as unknown as GlobalConfigService; + + TestBed.configureTestingModule({ + providers: [ + PluginBridgeService, + { + provide: Store, + useValue: jasmine.createSpyObj('Store', ['select', 'dispatch']), + }, + { + provide: TaskService, + useValue: jasmine.createSpyObj( + 'TaskService', + ['createNewTaskWithDefaults', 'add'], + { allTasks$: of([]) }, + ), + }, + { provide: ProjectService, useValue: { list$: of([]) } }, + { provide: TagService, useValue: { tags$: of([]) } }, + { provide: WorkContextService, useValue: workContextServiceSpy }, + { + provide: SnackService, + useValue: jasmine.createSpyObj('SnackService', ['open']), + }, + { + provide: NotifyService, + useValue: jasmine.createSpyObj('NotifyService', ['notify']), + }, + { provide: MatDialog, useValue: jasmine.createSpyObj('MatDialog', ['open']) }, + { provide: Router, useValue: jasmine.createSpyObj('Router', ['navigate']) }, + { + provide: PluginHooksService, + useValue: jasmine.createSpyObj('PluginHooksService', [ + 'registerHook', + 'unregisterPluginHooks', + ]), + }, + { + provide: PluginUserPersistenceService, + useValue: jasmine.createSpyObj('PluginUserPersistenceService', ['get', 'set']), + }, + { + provide: PluginConfigService, + useValue: jasmine.createSpyObj('PluginConfigService', ['get', 'set']), + }, + { + provide: TaskArchiveService, + useValue: jasmine.createSpyObj('TaskArchiveService', ['getAll']), + }, + { + provide: TranslateService, + useValue: { instant: (key: string) => key }, + }, + { + provide: SyncWrapperService, + useValue: jasmine.createSpyObj('SyncWrapperService', ['sync']), + }, + Injector, + { provide: GlobalThemeService, useValue: {} }, + { + provide: PluginIssueProviderRegistryService, + useValue: jasmine.createSpyObj('PluginIssueProviderRegistryService', [ + 'getRegisteredKey', + 'unregister', + ]), + }, + { provide: IssueSyncAdapterRegistryService, useValue: {} }, + { provide: PluginHttpService, useValue: {} }, + { provide: DataInitService, useValue: { reInit: () => Promise.resolve() } }, + { provide: GlobalConfigService, useValue: globalConfigSpy }, + ], + }); + + return { service: TestBed.inject(PluginBridgeService), activeCtx$ }; + }; + + describe('registerWorkContextHeaderButton — validation', () => { + it('throws when label is missing', () => { + const { service } = setup(); + const api = service.createBoundMethods(PLUGIN_A, manifest(PLUGIN_A)); + expect(() => + api.registerWorkContextHeaderButton({ + onClick: () => {}, + showFor: ['PROJECT'], + } as unknown as Parameters[0]), + ).toThrowError(/requires label/); + }); + + it('throws when onClick is missing', () => { + const { service } = setup(); + const api = service.createBoundMethods(PLUGIN_A, manifest(PLUGIN_A)); + expect(() => + api.registerWorkContextHeaderButton({ + label: 'X', + showFor: ['PROJECT'], + } as unknown as Parameters[0]), + ).toThrowError(/requires onClick/); + }); + + it('throws when showFor is empty', () => { + const { service } = setup(); + const api = service.createBoundMethods(PLUGIN_A, manifest(PLUGIN_A)); + expect(() => + api.registerWorkContextHeaderButton({ + label: 'X', + onClick: () => {}, + showFor: [], + }), + ).toThrowError(/non-empty showFor/); + }); + + it('replaces an existing (pluginId, label) entry on re-registration', () => { + // Iframe reloads produce a new onClick pointing at a new Window. The + // bridge must swap the entry so clicks reach the live iframe. + const { service } = setup(); + const api = service.createBoundMethods(PLUGIN_A, manifest(PLUGIN_A)); + const first = jasmine.createSpy('first'); + const second = jasmine.createSpy('second'); + api.registerWorkContextHeaderButton({ + label: 'Dup', + onClick: first, + showFor: ['PROJECT'], + }); + api.registerWorkContextHeaderButton({ + label: 'Dup', + onClick: second, + showFor: ['PROJECT'], + }); + const btns = service.workContextHeaderButtons(); + expect(btns.length).toBe(1); + expect(btns[0].onClick).toBe(second); + }); + + it('rejects showFor entries outside PROJECT|TAG|TODAY', () => { + const { service } = setup(); + const api = service.createBoundMethods(PLUGIN_A, manifest(PLUGIN_A)); + expect(() => + api.registerWorkContextHeaderButton({ + label: 'Bad', + onClick: () => {}, + showFor: ['PROJECT', 'FOOBAR' as unknown as 'PROJECT'], + }), + ).toThrowError(/showFor contains invalid value/); + }); + }); + + describe('workContextHeaderButtons — context filtering', () => { + it('shows a PROJECT-scoped button in a project context', () => { + const { service } = setup(projectCtx); + const api = service.createBoundMethods(PLUGIN_A, manifest(PLUGIN_A)); + api.registerWorkContextHeaderButton({ + label: 'Doc', + onClick: () => {}, + showFor: ['PROJECT'], + }); + expect(service.workContextHeaderButtons().map((b) => b.label)).toEqual(['Doc']); + }); + + it('hides a TAG-only button when the context is a project', () => { + const { service } = setup(projectCtx); + const api = service.createBoundMethods(PLUGIN_A, manifest(PLUGIN_A)); + api.registerWorkContextHeaderButton({ + label: 'TagOnly', + onClick: () => {}, + showFor: ['TAG'], + }); + expect(service.workContextHeaderButtons()).toEqual([]); + }); + + it('treats TODAY as a distinct bucket — TAG entries do not match', () => { + const { service, activeCtx$ } = setup(projectCtx); + const api = service.createBoundMethods(PLUGIN_A, manifest(PLUGIN_A)); + api.registerWorkContextHeaderButton({ + label: 'TodayOnly', + onClick: () => {}, + showFor: ['TODAY'], + }); + api.registerWorkContextHeaderButton({ + label: 'TagOnly', + onClick: () => {}, + showFor: ['TAG'], + }); + + activeCtx$.next(todayCtx); + TestBed.flushEffects(); + expect(service.workContextHeaderButtons().map((b) => b.label)).toEqual([ + 'TodayOnly', + ]); + + activeCtx$.next(tagCtx); + TestBed.flushEffects(); + expect(service.workContextHeaderButtons().map((b) => b.label)).toEqual(['TagOnly']); + }); + + it('returns [] when no active work context is set', () => { + const { service } = setup(null); + const api = service.createBoundMethods(PLUGIN_A, manifest(PLUGIN_A)); + api.registerWorkContextHeaderButton({ + label: 'Anywhere', + onClick: () => {}, + showFor: ['PROJECT', 'TAG', 'TODAY'], + }); + expect(service.workContextHeaderButtons()).toEqual([]); + }); + }); + + describe('work-view embed slot', () => { + it('showInWorkContext sets the embedded pluginId signal', () => { + const { service } = setup(); + const api = service.createBoundMethods(PLUGIN_A, manifest(PLUGIN_A)); + expect(service.workContextEmbedPluginId()).toBeNull(); + api.showInWorkContext(); + expect(service.workContextEmbedPluginId()).toBe(PLUGIN_A); + }); + + it('closeWorkContextView clears the slot when called by the owner', () => { + const { service } = setup(); + const apiA = service.createBoundMethods(PLUGIN_A, manifest(PLUGIN_A)); + apiA.showInWorkContext(); + apiA.closeWorkContextView(); + expect(service.workContextEmbedPluginId()).toBeNull(); + }); + + it('closeWorkContextView is a no-op when called by a different plugin', () => { + const { service } = setup(); + const apiA = service.createBoundMethods(PLUGIN_A, manifest(PLUGIN_A)); + const apiB = service.createBoundMethods(PLUGIN_B, manifest(PLUGIN_B)); + apiA.showInWorkContext(); + apiB.closeWorkContextView(); + expect(service.workContextEmbedPluginId()).toBe(PLUGIN_A); + }); + }); + + describe('unregisterPluginHooks cleanup', () => { + it('removes header buttons + clears embed slot owned by the unloaded plugin', () => { + const { service } = setup(); + const apiA = service.createBoundMethods(PLUGIN_A, manifest(PLUGIN_A)); + const apiB = service.createBoundMethods(PLUGIN_B, manifest(PLUGIN_B)); + apiA.registerWorkContextHeaderButton({ + label: 'A1', + onClick: () => {}, + showFor: ['PROJECT'], + }); + apiB.registerWorkContextHeaderButton({ + label: 'B1', + onClick: () => {}, + showFor: ['PROJECT'], + }); + apiA.showInWorkContext(); + expect(service.workContextEmbedPluginId()).toBe(PLUGIN_A); + expect(service.workContextHeaderButtons().length).toBe(2); + + service.unregisterPluginHooks(PLUGIN_A); + + expect(service.workContextEmbedPluginId()).toBeNull(); + const remaining = service.workContextHeaderButtons(); + expect(remaining.length).toBe(1); + expect(remaining[0].pluginId).toBe(PLUGIN_B); + }); + + it('keeps the embed slot when a non-owning plugin unloads', () => { + const { service } = setup(); + const apiA = service.createBoundMethods(PLUGIN_A, manifest(PLUGIN_A)); + const apiB = service.createBoundMethods(PLUGIN_B, manifest(PLUGIN_B)); + apiA.showInWorkContext(); + void apiB; + service.unregisterPluginHooks(PLUGIN_B); + expect(service.workContextEmbedPluginId()).toBe(PLUGIN_A); + }); + }); + + describe('getActiveWorkContext', () => { + it('returns a defensive copy of taskIds, not the live store array', async () => { + // The ActiveWorkContext type promises taskIds is a safe snapshot. A + // plugin mutating it must not corrupt NgRx state. + const liveTaskIds = ['task-1', 'task-2']; + const { service } = setup({ ...projectCtx, taskIds: liveTaskIds }); + + const result = await service.getActiveWorkContext(); + + expect(result).not.toBeNull(); + expect(result!.taskIds).toEqual(liveTaskIds); + expect(result!.taskIds).not.toBe(liveTaskIds); + result!.taskIds.push('task-3'); + expect(liveTaskIds).toEqual(['task-1', 'task-2']); + }); + + it("reports type 'TODAY' for the Today context", async () => { + // The Today tag is a TAG internally but is surfaced to plugins as its + // own type, matching registerWorkContextHeaderButton's showFor values. + const { service } = setup(todayCtx); + + const result = await service.getActiveWorkContext(); + + expect(result?.id).toBe('TODAY'); + expect(result?.type).toBe('TODAY'); + }); + + it("reports type 'PROJECT' for a project context", async () => { + const { service } = setup(projectCtx); + + const result = await service.getActiveWorkContext(); + + expect(result?.type).toBe('PROJECT'); + }); + }); +}); diff --git a/src/app/plugins/plugin-bridge.service.spec.ts b/src/app/plugins/plugin-bridge.service.spec.ts index aeb98e479c..913ab9e9cd 100644 --- a/src/app/plugins/plugin-bridge.service.spec.ts +++ b/src/app/plugins/plugin-bridge.service.spec.ts @@ -567,6 +567,7 @@ describe('PluginBridgeService', () => { // Active tests for setCounter fix (issue #5812) import { TestBed } from '@angular/core/testing'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import { of } from 'rxjs'; import { PluginBridgeService } from './plugin-bridge.service'; import { selectAllSimpleCounters } from '../features/simple-counter/store/simple-counter.reducer'; import { @@ -631,7 +632,9 @@ describe('PluginBridgeService - Counter Methods', () => { { provide: MatDialog, useValue: {} }, { provide: PluginHooksService, useValue: {} }, { provide: TaskService, useValue: {} }, - { provide: WorkContextService, useValue: {} }, + // activeWorkContext$ must be a real Observable — the constructor + // reads it via toSignal() at construction time. + { provide: WorkContextService, useValue: { activeWorkContext$: of(null) } }, { provide: ProjectService, useValue: {} }, { provide: TagService, useValue: {} }, { provide: PluginUserPersistenceService, useValue: {} }, diff --git a/src/app/plugins/plugin-bridge.service.ts b/src/app/plugins/plugin-bridge.service.ts index 44f85ab84b..7d2e49bd8f 100644 --- a/src/app/plugins/plugin-bridge.service.ts +++ b/src/app/plugins/plugin-bridge.service.ts @@ -1,4 +1,5 @@ -import { inject, Injectable, Injector, OnDestroy, signal } from '@angular/core'; +import { computed, inject, Injectable, Injector, OnDestroy, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { MatDialog } from '@angular/material/dialog'; import { Store } from '@ngrx/store'; import { SnackService } from '../core/snack/snack.service'; @@ -15,8 +16,11 @@ import { PluginNodeScriptResult, PluginShortcutCfg, PluginSidePanelBtnCfg, + PluginWorkContextHeaderBtnCfg, + ActiveWorkContext, Task, } from './plugin-api.model'; +import { toActiveWorkContext } from './util/active-work-context.util'; import { BatchTaskCreate, @@ -40,7 +44,7 @@ import { WorkContextService } from '../features/work-context/work-context.servic import { ProjectService } from '../features/project/project.service'; import { TagService } from '../features/tag/tag.service'; import typia from 'typia'; -import { first, map, take } from 'rxjs/operators'; +import { distinctUntilChanged, first, map, take, timeout } from 'rxjs/operators'; import { firstValueFrom } from 'rxjs'; import { selectTaskByIdWithSubTaskData } from '../features/tasks/store/task.selectors'; import { PluginUserPersistenceService } from './plugin-user-persistence.service'; @@ -135,6 +139,43 @@ export class PluginBridgeService implements OnDestroy { private readonly _sidePanelButtons = signal([]); public readonly sidePanelButtons = this._sidePanelButtons.asReadonly(); + // Track work-context-scoped header buttons registered by plugins. These are + // filtered against the active work context (project or TODAY tag) before + // rendering — see workContextHeaderButtons below. + private readonly _workContextHeaderButtons = signal( + [], + ); + + // Snapshot of the active work context, used to filter context-scoped + // buttons. Distinct on (id, type) so it does not churn when task/tag/ + // project data changes within the same context (e.g. a task is added). + private readonly _activeWorkContextSig = toSignal( + this._workContextService.activeWorkContext$.pipe( + distinctUntilChanged((a, b) => a?.id === b?.id && a?.type === b?.type), + ), + { initialValue: null }, + ); + + public readonly workContextHeaderButtons = computed(() => { + const ctx = this._activeWorkContextSig(); + const buttons = this._workContextHeaderButtons(); + if (!ctx) return [] as PluginWorkContextHeaderBtnCfg[]; + let key: 'PROJECT' | 'TAG' | 'TODAY'; + if (ctx.type === 'PROJECT') { + key = 'PROJECT'; + } else if (ctx.id === 'TODAY') { + key = 'TODAY'; + } else { + key = 'TAG'; + } + return buttons.filter((b) => b.showFor.includes(key)); + }); + + // Holds the pluginId currently embedded in the work-view body, or null when + // the normal task list should render. Set via showInWorkContext(). + private readonly _workContextEmbedPluginId = signal(null); + public readonly workContextEmbedPluginId = this._workContextEmbedPluginId.asReadonly(); + // Track config handlers registered by plugins (for settings button on plugin card) private readonly _configHandlers = new Map void>(); @@ -158,8 +199,14 @@ export class PluginBridgeService implements OnDestroy { registerHeaderButton: (cfg: PluginHeaderBtnCfg) => void; registerMenuEntry: (cfg: Omit) => void; registerSidePanelButton: (cfg: Omit) => void; + registerWorkContextHeaderButton: ( + cfg: Omit, + ) => void; registerShortcut: (cfg: PluginShortcutCfg) => void; showIndexHtmlAsView: () => void; + showInWorkContext: () => void; + closeWorkContextView: () => void; + getActiveWorkContext: () => Promise; triggerSync: () => Promise; dispatchAction: (action: { type: string; [key: string]: unknown }) => void; executeNodeScript: ( @@ -202,12 +249,18 @@ export class PluginBridgeService implements OnDestroy { this._registerMenuEntry(pluginId, cfg), registerSidePanelButton: (cfg: Omit) => this._registerSidePanelButton(pluginId, cfg), + registerWorkContextHeaderButton: ( + cfg: Omit, + ) => this._registerWorkContextHeaderButton(pluginId, cfg), registerShortcut: (cfg: PluginShortcutCfg) => this._registerShortcut(pluginId, cfg), registerConfigHandler: (handler: () => void) => this._configHandlers.set(pluginId, handler), // Navigation showIndexHtmlAsView: () => this._showIndexHtmlAsView(pluginId), + showInWorkContext: () => this._showInWorkContext(pluginId), + closeWorkContextView: () => this._closeWorkContextView(pluginId), + getActiveWorkContext: () => this.getActiveWorkContext(), // Sync triggerSync: () => this._triggerSync(pluginId), @@ -465,6 +518,46 @@ export class PluginBridgeService implements OnDestroy { this._router.navigate(['/plugins', pluginId, 'index']); } + /** + * Mount this plugin's index.html inside the work-view body for the active + * work context. Work-view renders it in place of the task list. + */ + private _showInWorkContext(pluginId: string): void { + this._workContextEmbedPluginId.set(pluginId); + } + + /** + * Clear the work-view embed slot if this plugin currently owns it. + */ + private _closeWorkContextView(pluginId: string): void { + if (this._workContextEmbedPluginId() === pluginId) { + this._workContextEmbedPluginId.set(null); + } + } + + /** + * Snapshot of the active work context for plugins. A context is always + * active once the app has loaded — it stays set even on non-work-view + * routes — so this normally resolves to that context. It resolves to + * null only if the initial data load has not completed within the + * timeout (e.g. a plugin calling this very early from its init hook), + * which is preferable to a Promise that never resolves. + */ + async getActiveWorkContext(): Promise { + try { + const ctx = await firstValueFrom( + this._workContextService.activeWorkContext$.pipe( + // 10s is well past normal data-load time but still bounded so + // a plugin can fall back to other behaviour instead of hanging. + timeout({ first: 10_000 }), + ), + ); + return toActiveWorkContext(ctx); + } catch { + return null; + } + } + /** * Get all tasks */ @@ -844,6 +937,25 @@ export class PluginBridgeService implements OnDestroy { } } + /** + * Select a task, opening its detail panel in the right-hand panel. Works + * regardless of the active view, including while a plugin embed occupies + * the work-view body. + */ + async selectTask(taskId: string): Promise { + typia.assert(taskId); + + const task = await firstValueFrom(this._taskService.getByIdOnce$(taskId)); + if (!task) { + throw new Error( + this._translateService.instant(T.PLUGINS.TASK_NOT_FOUND, { taskId }), + ); + } + + this._taskService.setSelectedId(taskId); + PluginLog.log('PluginBridge: Task selected', { taskId }); + } + /** * Batch update tasks for a project * Only generate IDs here - let the reducer handle all validation @@ -995,6 +1107,7 @@ export class PluginBridgeService implements OnDestroy { this._removePluginHeaderButtons(pluginId); this._removePluginMenuEntries(pluginId); this._removePluginSidePanelButtons(pluginId); + this._removePluginWorkContextHeaderButtons(pluginId); this.unregisterPluginShortcuts(pluginId); this._configHandlers.delete(pluginId); @@ -1087,6 +1200,63 @@ export class PluginBridgeService implements OnDestroy { }); } + /** + * Register a work-context-scoped header button. Visibility is controlled by + * the `showFor` field on cfg, evaluated against the active context's type + * (PROJECT/TAG) or the special TODAY tag. + */ + private _registerWorkContextHeaderButton( + pluginId: string, + cfg: Omit, + ): void { + if (!cfg.label || typeof cfg.label !== 'string') { + throw new Error('PluginBridge: registerWorkContextHeaderButton requires label'); + } + if (!cfg.onClick || typeof cfg.onClick !== 'function') { + throw new Error('PluginBridge: registerWorkContextHeaderButton requires onClick'); + } + if (!Array.isArray(cfg.showFor) || cfg.showFor.length === 0) { + throw new Error( + 'PluginBridge: registerWorkContextHeaderButton requires non-empty showFor', + ); + } + // Reject unknown showFor entries — otherwise typos are silently + // accepted and the button never shows up, which is hard to debug. + const allowedShowFor = new Set(['PROJECT', 'TAG', 'TODAY']); + const invalid = cfg.showFor.filter((v) => !allowedShowFor.has(v as string)); + if (invalid.length > 0) { + throw new Error( + `PluginBridge: registerWorkContextHeaderButton showFor contains invalid value(s): ${invalid.join(', ')}. Expected one of ${[...allowedShowFor].join(', ')}.`, + ); + } + const button: PluginWorkContextHeaderBtnCfg = { ...cfg, pluginId }; + const current = this._workContextHeaderButtons(); + const existingIdx = current.findIndex( + (b) => b.pluginId === pluginId && b.label === cfg.label, + ); + if (existingIdx >= 0) { + // Re-registration: iframe reloads (e.g. work-view embed re-mount with + // skipCleanupOnDestroy: true) produce a fresh onClick closure + // pointing at the *new* iframe Window. Replace the old entry so the + // host's wrapper posts back into the live iframe instead of a + // detached one. + const next = current.slice(); + next[existingIdx] = button; + this._workContextHeaderButtons.set(next); + return; + } + this._workContextHeaderButtons.set([...current, button]); + } + + private _removePluginWorkContextHeaderButtons(pluginId: string): void { + this._workContextHeaderButtons.set( + this._workContextHeaderButtons().filter((b) => b.pluginId !== pluginId), + ); + if (this._workContextEmbedPluginId() === pluginId) { + this._workContextEmbedPluginId.set(null); + } + } + /** * Remove all header buttons for a specific plugin */ diff --git a/src/app/plugins/plugin-hooks.effects.spec.ts b/src/app/plugins/plugin-hooks.effects.spec.ts index 356ce2a52c..08206c8a0f 100644 --- a/src/app/plugins/plugin-hooks.effects.spec.ts +++ b/src/app/plugins/plugin-hooks.effects.spec.ts @@ -1,7 +1,8 @@ import { TestBed } from '@angular/core/testing'; import { provideMockActions } from '@ngrx/effects/testing'; -import { Observable, of } from 'rxjs'; +import { EMPTY, Observable, of } from 'rxjs'; import { PluginHooksEffects } from './plugin-hooks.effects'; +import { WorkContextService } from '../features/work-context/work-context.service'; import { provideMockStore, MockStore } from '@ngrx/store/testing'; import { PluginService } from './plugin.service'; import { TaskSharedActions } from '../root-store/meta/task-shared.actions'; @@ -73,6 +74,9 @@ describe('PluginHooksEffects', () => { }, }), { provide: PluginService, useValue: pluginServiceMock }, + // workContextChange$ reads activeWorkContext$ at construction; an + // empty stream keeps that effect inert for the other effects' tests. + { provide: WorkContextService, useValue: { activeWorkContext$: EMPTY } }, ], }); diff --git a/src/app/plugins/plugin-hooks.effects.ts b/src/app/plugins/plugin-hooks.effects.ts index 43d5028e94..eca4ebbf52 100644 --- a/src/app/plugins/plugin-hooks.effects.ts +++ b/src/app/plugins/plugin-hooks.effects.ts @@ -47,6 +47,8 @@ import { import { LOCAL_ACTIONS } from '../util/local-actions.token'; import { PlannerActions } from '../features/planner/store/planner.actions'; import { LanguageCode } from '../core/locale.constants'; +import { WorkContextService } from '../features/work-context/work-context.service'; +import { toActiveWorkContext } from './util/active-work-context.util'; @Injectable() export class PluginHooksEffects { @@ -54,6 +56,7 @@ export class PluginHooksEffects { private readonly store = inject(Store); private readonly pluginService = inject(PluginService); private readonly pluginI18nService = inject(PluginI18nService); + private readonly workContextService = inject(WorkContextService); taskComplete$ = createEffect( () => @@ -328,6 +331,22 @@ export class PluginHooksEffects { { dispatch: false }, ); + // Fires once per work-context navigation. Distincts by (id, type) so it + // doesn't fire when project/tag data changes (e.g. task added). + workContextChange$ = createEffect( + () => + this.workContextService.activeWorkContext$.pipe( + distinctUntilChanged((a, b) => a?.id === b?.id && a?.type === b?.type), + tap((ctx) => { + this.pluginService.dispatchHook( + PluginHooks.WORK_CONTEXT_CHANGE, + toActiveWorkContext(ctx), + ); + }), + ), + { dispatch: false }, + ); + // private static hiddenActions = []; anyAction$ = createEffect( () => diff --git a/src/app/plugins/plugin-user-persistence.service.spec.ts b/src/app/plugins/plugin-user-persistence.service.spec.ts index d2f16736d3..e9e6698147 100644 --- a/src/app/plugins/plugin-user-persistence.service.spec.ts +++ b/src/app/plugins/plugin-user-persistence.service.spec.ts @@ -55,28 +55,52 @@ describe('PluginUserPersistenceService', () => { expect(dispatchSpy).not.toHaveBeenCalled(); }); - it('should throw error when called too frequently (rate limiting)', () => { + it('should coalesce a rapid second call instead of dropping it', () => { const baseTime = Date.now(); jasmine.clock().install(); jasmine.clock().mockDate(new Date(baseTime)); try { const pluginId = 'test-plugin'; - const data = 'test data'; - // First call should succeed - service.persistPluginUserData(pluginId, data); + // First call commits immediately. + service.persistPluginUserData(pluginId, 'first'); expect(dispatchSpy).toHaveBeenCalledTimes(1); - // Immediate second call should fail - expect(() => service.persistPluginUserData(pluginId, data)).toThrowError( - /Plugin data persist rate limited/, - ); + // A call inside the rate-limit window must not throw and must not be + // dropped — it is held until the window opens. + expect(() => service.persistPluginUserData(pluginId, 'second')).not.toThrow(); expect(dispatchSpy).toHaveBeenCalledTimes(1); - // After waiting MIN_PLUGIN_PERSIST_INTERVAL_MS, should succeed again + // Once the interval elapses the held write is committed. jasmine.clock().tick(MIN_PLUGIN_PERSIST_INTERVAL_MS); - service.persistPluginUserData(pluginId, data); expect(dispatchSpy).toHaveBeenCalledTimes(2); + expect(dispatchSpy).toHaveBeenCalledWith( + upsertPluginUserData({ + pluginUserData: { id: pluginId, data: 'second' }, + }), + ); + } finally { + jasmine.clock().uninstall(); + } + }); + + it('should keep only the most recent of several coalesced calls', () => { + const baseTime = Date.now(); + jasmine.clock().install(); + jasmine.clock().mockDate(new Date(baseTime)); + try { + const pluginId = 'test-plugin'; + + service.persistPluginUserData(pluginId, 'v1'); // committed + service.persistPluginUserData(pluginId, 'v2'); // coalesced + service.persistPluginUserData(pluginId, 'v3'); // coalesced, replaces v2 + expect(dispatchSpy).toHaveBeenCalledTimes(1); + + jasmine.clock().tick(MIN_PLUGIN_PERSIST_INTERVAL_MS); + expect(dispatchSpy).toHaveBeenCalledTimes(2); + expect(dispatchSpy).toHaveBeenCalledWith( + upsertPluginUserData({ pluginUserData: { id: pluginId, data: 'v3' } }), + ); } finally { jasmine.clock().uninstall(); } @@ -125,6 +149,27 @@ describe('PluginUserPersistenceService', () => { expect(result).toBeNull(); }); + + it('should return a pending coalesced write before it is committed', async () => { + const baseTime = Date.now(); + jasmine.clock().install(); + jasmine.clock().mockDate(new Date(baseTime)); + try { + const pluginId = 'test-plugin'; + store.overrideSelector(selectPluginUserDataFeatureState, [ + { id: pluginId, data: 'committed' }, + ]); + + service.persistPluginUserData(pluginId, 'committed'); // commits + service.persistPluginUserData(pluginId, 'pending'); // coalesced + + // A read-modify-write cycle must see its own not-yet-committed write. + const result = await service.loadPluginUserData(pluginId); + expect(result).toBe('pending'); + } finally { + jasmine.clock().uninstall(); + } + }); }); describe('removePluginUserData', () => { @@ -135,6 +180,26 @@ describe('PluginUserPersistenceService', () => { expect(dispatchSpy).toHaveBeenCalledWith(deletePluginUserData({ pluginId })); }); + + it('should cancel a pending coalesced write', () => { + const baseTime = Date.now(); + jasmine.clock().install(); + jasmine.clock().mockDate(new Date(baseTime)); + try { + const pluginId = 'test-plugin'; + service.persistPluginUserData(pluginId, 'first'); // commits + service.persistPluginUserData(pluginId, 'pending'); // coalesced + + service.removePluginUserData(pluginId); + dispatchSpy.calls.reset(); + + // The cancelled flush must not resurrect the removed data. + jasmine.clock().tick(MIN_PLUGIN_PERSIST_INTERVAL_MS * 2); + expect(dispatchSpy).not.toHaveBeenCalled(); + } finally { + jasmine.clock().uninstall(); + } + }); }); describe('getAllPluginUserData', () => { diff --git a/src/app/plugins/plugin-user-persistence.service.ts b/src/app/plugins/plugin-user-persistence.service.ts index 2a294c81e8..c65b4ca47a 100644 --- a/src/app/plugins/plugin-user-persistence.service.ts +++ b/src/app/plugins/plugin-user-persistence.service.ts @@ -21,17 +21,35 @@ export class PluginUserPersistenceService { private _store = inject(Store); /** - * Track last persist time per plugin for rate limiting + * Track the last *committed* persist time per plugin, for rate limiting. */ private _lastPersistTime = new Map(); /** - * Persist user data for a specific plugin (called by plugin via persistDataSynced) + * Data that arrived inside the rate-limit window and is waiting to be + * committed. Coalesced — only the most recent value per plugin is kept. + */ + private _pendingData = new Map(); + + /** + * Active flush timers for coalesced writes, keyed by plugin id. + */ + private _flushTimers = new Map>(); + + /** + * Persist user data for a specific plugin (called by plugin via persistDataSynced). + * + * Rate limiting *coalesces* rather than rejects: a call that arrives inside + * the MIN_PLUGIN_PERSIST_INTERVAL_MS window is not dropped — its data is + * held and committed when the window opens (latest write wins). This still + * caps the op-log/sync rate at one write per interval, but never discards + * the caller's most recent data. Dropping it silently lost edits — e.g. a + * plugin's final save on teardown landing just after a periodic save. + * * @throws Error if data exceeds MAX_PLUGIN_DATA_SIZE - * @throws Error if called too frequently (rate limited) */ persistPluginUserData(pluginId: string, data: string): void { - // Validate data size + // Validate data size — applies whether the write commits now or later. const dataSize = new Blob([data]).size; if (dataSize > MAX_PLUGIN_DATA_SIZE) { throw new Error( @@ -40,21 +58,46 @@ export class PluginUserPersistenceService { ); } - // Rate limiting: check if enough time has passed since last persist + // Rate limiting: check if enough time has passed since the last commit. const now = Date.now(); const lastPersist = this._lastPersistTime.get(pluginId) || 0; const timeSinceLastPersist = now - lastPersist; if (timeSinceLastPersist < MIN_PLUGIN_PERSIST_INTERVAL_MS) { - throw new Error( - `Plugin data persist rate limited. Please wait ${MIN_PLUGIN_PERSIST_INTERVAL_MS}ms between calls. ` + - `Time since last call: ${timeSinceLastPersist}ms`, - ); + // Inside the window: coalesce. Hold the latest data and flush it once + // the window opens, so no write is discarded. + this._pendingData.set(pluginId, data); + if (!this._flushTimers.has(pluginId)) { + const delay = MIN_PLUGIN_PERSIST_INTERVAL_MS - timeSinceLastPersist; + this._flushTimers.set( + pluginId, + setTimeout(() => this._flushPendingData(pluginId), delay), + ); + } + return; } - // Update last persist time - this._lastPersistTime.set(pluginId, now); + this._commit(pluginId, data, now); + } + /** + * Commit a coalesced write once its rate-limit window has elapsed. + */ + private _flushPendingData(pluginId: string): void { + this._flushTimers.delete(pluginId); + const data = this._pendingData.get(pluginId); + if (data === undefined) { + return; + } + this._pendingData.delete(pluginId); + this._commit(pluginId, data, Date.now()); + } + + /** + * Dispatch the persist action and record the commit time for rate limiting. + */ + private _commit(pluginId: string, data: string, at: number): void { + this._lastPersistTime.set(pluginId, at); const pluginUserData: PluginUserData = { id: pluginId, data, @@ -63,9 +106,16 @@ export class PluginUserPersistenceService { } /** - * Load user data for a specific plugin (called by plugin via loadSyncedData) + * Load user data for a specific plugin (called by plugin via loadSyncedData). + * + * Returns a coalesced-but-not-yet-committed write if one is pending, so a + * plugin's read-modify-write cycle always sees its own latest data. */ async loadPluginUserData(pluginId: string): Promise { + const pending = this._pendingData.get(pluginId); + if (pending !== undefined) { + return pending; + } const currentState = await firstValueFrom( this._store.select(selectPluginUserDataFeatureState), ); @@ -74,12 +124,26 @@ export class PluginUserPersistenceService { } /** - * Remove user data for a specific plugin + * Remove user data for a specific plugin. */ removePluginUserData(pluginId: string): void { + this._cancelPending(pluginId); this._store.dispatch(deletePluginUserData({ pluginId })); } + /** + * Drop any pending coalesced write (and its timer) for a plugin, so it + * cannot resurrect data that is being removed. + */ + private _cancelPending(pluginId: string): void { + const timer = this._flushTimers.get(pluginId); + if (timer !== undefined) { + clearTimeout(timer); + this._flushTimers.delete(pluginId); + } + this._pendingData.delete(pluginId); + } + /** * Get all plugin user data */ @@ -95,6 +159,7 @@ export class PluginUserPersistenceService { this._store.select(selectPluginUserDataFeatureState), ); for (const item of currentState) { + this._cancelPending(item.id); this._store.dispatch(deletePluginUserData({ pluginId: item.id })); } } diff --git a/src/app/plugins/plugin.service.ts b/src/app/plugins/plugin.service.ts index d68d999499..4151f097e6 100644 --- a/src/app/plugins/plugin.service.ts +++ b/src/app/plugins/plugin.service.ts @@ -51,6 +51,7 @@ const BUNDLED_PLUGIN_PATHS = [ 'assets/bundled-plugins/voice-reminder', 'assets/bundled-plugins/google-calendar-provider', 'assets/bundled-plugins/caldav-calendar-provider', + 'assets/bundled-plugins/document-mode', ] as const; @Injectable({ diff --git a/src/app/plugins/ui/plugin-index/plugin-index.component.ts b/src/app/plugins/ui/plugin-index/plugin-index.component.ts index 56b58ab874..72160eb3c8 100644 --- a/src/app/plugins/ui/plugin-index/plugin-index.component.ts +++ b/src/app/plugins/ui/plugin-index/plugin-index.component.ts @@ -74,6 +74,10 @@ export class PluginIndexComponent implements OnInit, OnDestroy { @Input() showFullUI: boolean = true; // Optional input to use side panel configuration instead of full page @Input() useSidePanelConfig: boolean = false; + // Skip tearing down hook registrations / bridge state on destroy. + // Set true when embedding the component repeatedly (e.g. work-view embed + // toggled on/off), so the plugin keeps its registrations across mounts. + @Input() skipCleanupOnDestroy: boolean = false; private readonly _route = inject(ActivatedRoute); private readonly _router = inject(Router); @@ -232,8 +236,15 @@ export class PluginIndexComponent implements OnInit, OnDestroy { this._currentIframeUrl = iframeUrl; // Store message handler for cleanup + // Filter by event.source so messages from other plugin iframes (side panel, + // other embed slots) don't get answered with this plugin's bound methods. this._messageListener = async (event: Event) => { - await handlePluginMessage(event as MessageEvent, config); + const msgEvent = event as MessageEvent; + const iframeWin = this.iframeRef?.nativeElement?.contentWindow; + if (!iframeWin || msgEvent.source !== iframeWin) { + return; + } + await handlePluginMessage(msgEvent, config); }; // Set up message communication @@ -270,8 +281,10 @@ export class PluginIndexComponent implements OnInit, OnDestroy { this._currentIframeUrl = null; } - // Clear iframe reference from cleanup service (but don't remove from DOM) - if (currentPluginId) { + // Clear iframe reference from cleanup service (but don't remove from DOM). + // Skip when embedding (work-view, side panel) so the plugin keeps its hook + // and button registrations across embed mount/unmount cycles. + if (currentPluginId && !this.skipCleanupOnDestroy) { this._cleanupService.cleanupPlugin(currentPluginId); PluginLog.log(`Cleaned up plugin references for: ${currentPluginId}`); } diff --git a/src/app/plugins/ui/plugin-work-context-header-btns.component.ts b/src/app/plugins/ui/plugin-work-context-header-btns.component.ts new file mode 100644 index 0000000000..b40f08d9c6 --- /dev/null +++ b/src/app/plugins/ui/plugin-work-context-header-btns.component.ts @@ -0,0 +1,65 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { MatIconButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { MatTooltip } from '@angular/material/tooltip'; +import { PluginBridgeService } from '../plugin-bridge.service'; +import { PluginWorkContextHeaderBtnCfg } from '../plugin-api.model'; + +@Component({ + selector: 'plugin-work-context-header-btns', + template: ` + @for (button of buttons(); track button.pluginId + button.label) { + + } + `, + styles: [ + ` + :host { + display: contents; + } + + /* Toggled state: the button's plugin currently owns the work-view + embed for the active context. A neutral ink fill — stronger than + the standard selected overlay so it reads as a deliberate toggle + rather than a hover, and distinct from the accent colour reserved + for time tracking. */ + button.isActive { + background: rgba(var(--ink-on-channel), 0.18); + transition: background var(--transition-standard); + } + + button.isActive:hover:not(:disabled) { + background: rgba(var(--ink-on-channel), 0.26); + } + `, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [MatIconButton, MatIcon, MatTooltip], +}) +export class PluginWorkContextHeaderBtnsComponent { + private readonly _pluginBridge = inject(PluginBridgeService); + + readonly buttons = this._pluginBridge.workContextHeaderButtons; + + /** + * The plugin currently embedded in the work-view body, or null. A header + * button renders toggled when its own plugin owns that embed, so a button + * that toggles a work-view embed reflects its on/off state. + */ + readonly activeEmbedPluginId = this._pluginBridge.workContextEmbedPluginId; + + async onClick(button: PluginWorkContextHeaderBtnCfg): Promise { + const ctx = await this._pluginBridge.getActiveWorkContext(); + if (ctx) { + button.onClick(ctx); + } + } +} diff --git a/src/app/plugins/util/active-work-context.util.spec.ts b/src/app/plugins/util/active-work-context.util.spec.ts new file mode 100644 index 0000000000..a2befee919 --- /dev/null +++ b/src/app/plugins/util/active-work-context.util.spec.ts @@ -0,0 +1,57 @@ +import { toActiveWorkContext } from './active-work-context.util'; +import { + WorkContext, + WorkContextType, +} from '../../features/work-context/work-context.model'; +import { TODAY_TAG } from '../../features/tag/tag.const'; + +describe('toActiveWorkContext', () => { + const makeCtx = (overrides: Partial = {}): WorkContext => ({ + type: WorkContextType.PROJECT, + id: 'project-1', + title: 'Project', + taskIds: [], + backlogTaskIds: [], + noteIds: [], + theme: {} as WorkContext['theme'], + advancedCfg: {} as WorkContext['advancedCfg'], + routerLink: '/project/project-1', + isEnableBacklog: true, + icon: null, + ...overrides, + }); + + it('carries id and title through unchanged', () => { + const result = toActiveWorkContext(makeCtx({ id: 'p-99', title: 'My Project' })); + expect(result.id).toBe('p-99'); + expect(result.title).toBe('My Project'); + }); + + it("reports type 'PROJECT' for a project context", () => { + expect(toActiveWorkContext(makeCtx({ type: WorkContextType.PROJECT })).type).toBe( + 'PROJECT', + ); + }); + + it("reports type 'TAG' for an ordinary tag context", () => { + const ctx = makeCtx({ type: WorkContextType.TAG, id: 'tag-1' }); + expect(toActiveWorkContext(ctx).type).toBe('TAG'); + }); + + it("reports type 'TODAY' for the Today tag even though its host type is TAG", () => { + const ctx = makeCtx({ type: WorkContextType.TAG, id: TODAY_TAG.id }); + expect(toActiveWorkContext(ctx).type).toBe('TODAY'); + }); + + it('copies taskIds defensively — the result does not share the source array', () => { + const sourceTaskIds = ['t1', 't2']; + const ctx = makeCtx({ taskIds: sourceTaskIds }); + const result = toActiveWorkContext(ctx); + + expect(result.taskIds).toEqual(['t1', 't2']); + expect(result.taskIds).not.toBe(sourceTaskIds); + + result.taskIds.push('t3'); + expect(sourceTaskIds).toEqual(['t1', 't2']); + }); +}); diff --git a/src/app/plugins/util/active-work-context.util.ts b/src/app/plugins/util/active-work-context.util.ts new file mode 100644 index 0000000000..61a6b3c75b --- /dev/null +++ b/src/app/plugins/util/active-work-context.util.ts @@ -0,0 +1,19 @@ +import { TODAY_TAG } from '../../features/tag/tag.const'; +import { WorkContext } from '../../features/work-context/work-context.model'; +import { ActiveWorkContext } from '../plugin-api.model'; + +/** + * Project a host `WorkContext` into the plugin-facing `ActiveWorkContext` + * snapshot used by `getActiveWorkContext()` and the `WORK_CONTEXT_CHANGE` hook. + * + * Single source of truth for that projection so the pull API and the push + * hook can never disagree. `taskIds` is copied so a plugin cannot mutate + * NgRx state through the snapshot, and the special Today tag is reported with + * type `'TODAY'` (it is otherwise an ordinary tag). + */ +export const toActiveWorkContext = (ctx: WorkContext): ActiveWorkContext => ({ + id: ctx.id, + type: ctx.id === TODAY_TAG.id ? 'TODAY' : ctx.type, + title: ctx.title, + taskIds: [...ctx.taskIds], +}); diff --git a/src/app/plugins/util/plugin-iframe.util.ts b/src/app/plugins/util/plugin-iframe.util.ts index 154ab35e1e..114e37289a 100644 --- a/src/app/plugins/util/plugin-iframe.util.ts +++ b/src/app/plugins/util/plugin-iframe.util.ts @@ -1,6 +1,6 @@ import { PluginBridgeService } from '../plugin-bridge.service'; import { PluginBaseCfg, PluginManifest } from '../plugin-api.model'; -import { PluginIframeMessageType } from '@super-productivity/plugin-api'; +import { PluginHooks, PluginIframeMessageType } from '@super-productivity/plugin-api'; import { PluginLog } from '../../core/log'; import { PLUGIN_UI_KIT_CSS } from './plugin-ui-kit.css'; @@ -161,9 +161,16 @@ export const createPluginApiScript = (config: PluginIframeConfig): string => { const pendingCalls = new Map(); const dialogButtonHandlers = new Map(); const hookHandlers = new Map(); // Store hook handlers by hook type + const workContextBtnHandlers = new Map(); // button label -> onClick // Handle responses from parent window.addEventListener('message', function(event) { + // Accept messages only from the host (our parent window). + // Without this check, any other iframe / sibling window could + // spoof API responses, hook events, or work-context button + // clicks. The host always posts via the iframe's contentWindow, + // so event.source will be window.parent in the legitimate case. + if (event.source !== window.parent) return; const data = event.data; if (data?.type === '${PluginIframeMessageType.API_RESPONSE}' && data.callId) { const resolver = pendingCalls.get(data.callId); @@ -199,6 +206,15 @@ export const createPluginApiScript = (config: PluginIframeConfig): string => { }, '*'); } } + } else if (data?.type === '${PluginIframeMessageType.WORK_CONTEXT_BTN_CLICK}') { + const handler = workContextBtnHandlers.get(data.buttonHandlerId); + if (handler) { + try { + handler(data.ctx); + } catch (error) { + console.error('Plugin work-context button handler error:', error); + } + } } else if (data?.type === '${PluginIframeMessageType.HOOK_EVENT}') { // Handle hook events const handlers = hookHandlers.get(data.hook); @@ -278,22 +294,15 @@ export const createPluginApiScript = (config: PluginIframeConfig): string => { window.PluginAPI = { cfg: ${JSON.stringify(config.baseCfg)}, - // Add Hooks enum - Hooks: { - TASK_COMPLETE: 'taskComplete', - TASK_UPDATE: 'taskUpdate', - TASK_DELETE: 'taskDelete', - CURRENT_TASK_CHANGE: 'currentTaskChange', - FINISH_DAY: 'finishDay', - LANGUAGE_CHANGE: 'languageChange', - PERSISTED_DATA_UPDATE: 'persistedDataUpdate', - ACTION: 'action' - }, + // Add Hooks enum (kept in sync with PluginHooks via JSON.stringify + // of the real source — no hand-edited mirror to drift). + Hooks: ${JSON.stringify({ ...PluginHooks })}, // Task methods getTasks: () => callApi('getTasks'), getArchivedTasks: () => callApi('getArchivedTasks'), getCurrentContextTasks: () => callApi('getCurrentContextTasks'), + selectTask: (taskId) => callApi('selectTask', [taskId]), reInitData: () => callApi('reInitData'), updateTask: (taskId, updates) => callApi('updateTask', [taskId, updates]), addTask: (taskData) => callApi('addTask', [taskData]), @@ -319,6 +328,9 @@ export const createPluginApiScript = (config: PluginIframeConfig): string => { openDialog: (cfg) => callApi('openDialog', [cfg]), showIndexHtmlAsView: () => callApi('showIndexHtmlAsView'), showIndexHtmlInSidePanel: () => callApi('showIndexHtmlInSidePanel'), + showInWorkContext: () => callApi('showInWorkContext'), + closeWorkContextView: () => callApi('closeWorkContextView'), + getActiveWorkContext: () => callApi('getActiveWorkContext'), // Persistence methods persistDataSynced: (data) => callApi('persistDataSynced', [data]), @@ -332,6 +344,17 @@ export const createPluginApiScript = (config: PluginIframeConfig): string => { registerConfigHandler: (handler) => callApi('registerConfigHandler', [handler]), registerShortcut: (cfg) => callApi('registerShortcut', [cfg]), registerSidePanelButton: (cfg) => callApi('registerSidePanelButton', [cfg]), + registerWorkContextHeaderButton: (cfg) => { + // onClick is not structured-cloneable across postMessage; keep it + // locally, keyed by the button's label, and send the rest of the + // cfg. The host rebuilds onClick as a proxy and posts a + // WORK_CONTEXT_BTN_CLICK back (carrying the label) on invocation. + // Keying by label — which the host also dedups on — means + // re-registering a button overwrites instead of leaking. + workContextBtnHandlers.set(cfg.label, cfg.onClick); + const { onClick, ...rest } = cfg; + return callApi('registerWorkContextHeaderButton', [rest]); + }, // Node execution (if available) executeNodeScript: (request) => callApi('executeNodeScript', [request]), @@ -352,6 +375,9 @@ export const createPluginApiScript = (config: PluginIframeConfig): string => { // Store the handler and set up message listener window.__pluginMessageHandler = handler; window.addEventListener('message', async (event) => { + // Same origin-check rationale as the listener above — + // accept only messages from our parent host. + if (event.source !== window.parent) return; if (event.data?.type === '${PluginIframeMessageType.MESSAGE}' && window.__pluginMessageHandler) { try { const result = await window.__pluginMessageHandler(event.data.message); @@ -472,6 +498,45 @@ export const handlePluginMessage = async ( // For iframe plugins, we need to handle API calls differently // Some methods need special handling because the bridge methods have different signatures + // registerWorkContextHeaderButton: the iframe keeps its onClick + // locally, keyed by the button label; rebuild onClick here as a proxy + // that posts WORK_CONTEXT_BTN_CLICK back into the iframe, where the + // real handler is looked up by that same label. + if (method === 'registerWorkContextHeaderButton' && args.length >= 1) { + const rawCfg = args[0] as Record; + const handlerId = rawCfg.label; + if (typeof handlerId !== 'string' || !handlerId) { + throw new Error('registerWorkContextHeaderButton requires a string label'); + } + const wrappedCfg = { + ...rawCfg, + onClick: (ctx: unknown): void => { + (event.source as Window)?.postMessage( + { + type: PluginIframeMessageType.WORK_CONTEXT_BTN_CLICK, + buttonHandlerId: handlerId, + ctx, + }, + '*', + ); + }, + }; + const boundMethods = config.boundMethods as Record; + const fn = boundMethods.registerWorkContextHeaderButton as ( + c: unknown, + ) => unknown; + const result = await fn(wrappedCfg); + event.source?.postMessage( + { + type: PluginIframeMessageType.API_RESPONSE, + callId, + result, + }, + { targetOrigin: '*' }, + ); + return; + } + // For registerHook, we need to add the pluginId parameter when calling the bridge if (method === 'registerHook') { const bridge = config.pluginBridge as unknown as Record< From 1c10ff67dd4aa8b450c3a3f454c99e2617c816b8 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 22 May 2026 17:33:22 +0200 Subject: [PATCH 31/42] feat(document-mode): add TipTap-based document-mode plugin A document-mode plugin under packages/plugin-dev/document-mode/ that renders the active work context's tasks as an editable TipTap document. Per-context doc state is persisted as a last-writer-wins JSON blob via PluginAPI.persistDataSynced; task identity (title, done state, hierarchy) stays in NgRx and is reached through PluginAPI.updateTask and the ANY_TASK_UPDATE hook. It registers a work-context header button and embeds itself into the work-view embed slot added in the preceding commit. See docs/plans/2026-05-21-document-mode-tiptap-plugin.md for the full design. Squashed from the feat/doc-mode-v4 work; full per-step history is preserved in the archive/doc-mode-v4-full-history branch and the doc-mode-v4-pre-squash tag. --- .../2026-05-21-document-mode-tiptap-plugin.md | 165 ++ packages/plugin-dev/document-mode/.gitignore | 4 + .../document-mode/package-lock.json | 1289 ++++++++++ .../plugin-dev/document-mode/package.json | 26 + .../plugin-dev/document-mode/scripts/build.js | 73 + .../document-mode/scripts/deploy.js | 39 + .../plugin-dev/document-mode/scripts/test.js | 59 + .../document-mode/src/background.ts | 122 + .../document-mode/src/doc-transform.spec.ts | 492 ++++ .../document-mode/src/doc-transform.ts | 363 +++ .../plugin-dev/document-mode/src/icon.svg | 6 + .../document-mode/src/manifest.json | 25 + .../document-mode/src/ui/doc-nav.spec.ts | 325 +++ .../document-mode/src/ui/doc-nav.ts | 216 ++ .../plugin-dev/document-mode/src/ui/editor.ts | 2067 +++++++++++++++++ .../plugin-dev/document-mode/src/ui/icons.ts | 38 + .../document-mode/src/ui/index.html | 421 ++++ .../document-mode/src/ui/task-ref-node.ts | 274 +++ .../plugin-dev/document-mode/tsconfig.json | 22 + packages/plugin-dev/scripts/build-all.js | 24 + 20 files changed, 6050 insertions(+) create mode 100644 docs/plans/2026-05-21-document-mode-tiptap-plugin.md create mode 100644 packages/plugin-dev/document-mode/.gitignore create mode 100644 packages/plugin-dev/document-mode/package-lock.json create mode 100644 packages/plugin-dev/document-mode/package.json create mode 100644 packages/plugin-dev/document-mode/scripts/build.js create mode 100644 packages/plugin-dev/document-mode/scripts/deploy.js create mode 100644 packages/plugin-dev/document-mode/scripts/test.js create mode 100644 packages/plugin-dev/document-mode/src/background.ts create mode 100644 packages/plugin-dev/document-mode/src/doc-transform.spec.ts create mode 100644 packages/plugin-dev/document-mode/src/doc-transform.ts create mode 100644 packages/plugin-dev/document-mode/src/icon.svg create mode 100644 packages/plugin-dev/document-mode/src/manifest.json create mode 100644 packages/plugin-dev/document-mode/src/ui/doc-nav.spec.ts create mode 100644 packages/plugin-dev/document-mode/src/ui/doc-nav.ts create mode 100644 packages/plugin-dev/document-mode/src/ui/editor.ts create mode 100644 packages/plugin-dev/document-mode/src/ui/icons.ts create mode 100644 packages/plugin-dev/document-mode/src/ui/index.html create mode 100644 packages/plugin-dev/document-mode/src/ui/task-ref-node.ts create mode 100644 packages/plugin-dev/document-mode/tsconfig.json diff --git a/docs/plans/2026-05-21-document-mode-tiptap-plugin.md b/docs/plans/2026-05-21-document-mode-tiptap-plugin.md new file mode 100644 index 0000000000..6f43757127 --- /dev/null +++ b/docs/plans/2026-05-21-document-mode-tiptap-plugin.md @@ -0,0 +1,165 @@ +# Document Mode → TipTap Plugin (POC) + +**Status:** proposal, post-multi-review v2 +**Date:** 2026-05-21 +**Branch:** `feat/doc-mode4-2880bb` + +## Goal + +Reimplement the current in-tree document mode (`src/app/features/document-mode/`) as an opt-in iframe plugin using TipTap as the editor. Extend the plugin API to support a work-context-scoped header button, a `WORK_CONTEXT_CHANGE` hook, and embedding the plugin's view inside the work-view body (in place of the task list). + +POC scope: no data migration, no removal of the existing in-tree feature, opt-in install only. + +## Decisions locked in + +| Question | Decision | Source | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| Embed venue | **Body-embed** (mirror today's `` placement). No side-panel variant. | User | +| Visibility scope | Host renders the button only when active context is project or TODAY tag, **but plugin still declares this** via a `showFor` field on registration | Reviewer push-back: hardcoded host filter is avoidable public-API debt | +| `taskRef` semantics | **Read-only chips** (atom node) — title + checkbox. Click on the chip opens the task panel for full edits. No inline title editing. | Both reviewers flagged the race conditions of inline editing; POC ships cleanly without it | +| Persistence | **Single existing blob, plugin-side `{[ctxId]: doc}` map**. No new persistence API for the POC. | Both reviewers recommended this — defers risky keyed-API design | +| Legacy data | No migration | User | +| Bundling | Opt-in install, not bundled by default | User | + +## Plugin-API additions + +```ts +// packages/plugin-api/src/types.ts +interface PluginAPI { + // ...existing... + getActiveWorkContext(): Promise; + registerWorkContextHeaderButton( + cfg: Omit, + ): void; + showInWorkContext(): void; + closeWorkContextView(): void; +} + +interface ActiveWorkContext { + id: string; + type: 'PROJECT' | 'TAG'; + title: string; + taskIds: string[]; +} + +interface PluginWorkContextHeaderBtnCfg { + pluginId: string; + label: string; + icon?: string; + onClick: (ctx: ActiveWorkContext) => void; + /** Where to render the button. Default ['PROJECT']. 'TODAY' is the special TODAY tag. */ + showFor: ('PROJECT' | 'TAG' | 'TODAY')[]; +} + +enum PluginHooks { + // ...existing... + ANY_TASK_UPDATE, // ← already host-side, missing from iframe enum + WORK_CONTEXT_CHANGE = 'workContextChange', // new +} +// WORK_CONTEXT_CHANGE payload: { id, type, title, taskIds } | null +// (full snapshot, not just id+type — see review finding #4) +``` + +No keyed persistence — POC reuses single blob. + +## Host-side fixes required for body-embed + +The multi-review surfaced several blockers that must be fixed inside the host before body-embed is safe. These are not optional: + +### 1. Iframe message cross-talk (Codex) + +`handlePluginMessage()` in `src/app/plugins/util/plugin-iframe.util.ts:451` accepts any `PLUGIN_API_CALL` without checking `event.source` or plugin id. Side-panel already mounts a `` (`plugin-panel-container.component.ts:25`); adding a work-view embed gives two listeners that will both answer the same API call with different bound methods. + +**Fix:** in every `handlePluginMessage` call site, verify `event.source === iframe.contentWindow` AND tag the message with the receiving plugin id; ignore mismatches. Apply to all embed sites: route page, side panel, work-view embed. + +### 2. Header-button `onClick` callback proxy (Codex) + +The iframe proxy posts `registerHeaderButton(cfg)` directly (`plugin-iframe.util.ts:329`) — `onClick` is a function, not structured-cloneable. The existing host-side `PluginAPI.registerHeaderButton()` works because it runs in the host runtime. Same applies to the new `registerWorkContextHeaderButton`. + +**Fix:** mirror the existing hook/dialog callback proxy pattern. Iframe sends `{ register: {...cfg, callbackId} }`; host wraps `onClick` to post `{ type: 'CALLBACK_INVOKE', callbackId, ctx }` back to the iframe, where the plugin's stored callback runs. + +### 3. `ANY_TASK_UPDATE` missing from iframe Hooks enum (Claude + Codex) + +`plugin-iframe.util.ts:282-291` ships `TASK_COMPLETE | TASK_UPDATE | TASK_DELETE | CURRENT_TASK_CHANGE | FINISH_DAY | LANGUAGE_CHANGE | PERSISTED_DATA_UPDATE | ACTION` only. The host fires `ANY_TASK_UPDATE` (`plugin-hooks.effects.ts:237`) but the plugin can't subscribe. + +**Fix:** add `ANY_TASK_UPDATE` (and `PROJECT_LIST_UPDATE`, `TASK_CREATED`) to the iframe enum. + +Note: `anyTaskUpdate$` does not cover subtask reorders, task moves within Today list, or project task-list reorders. For read-only chips this matters less — title + isDone is enough — but document the gap. + +### 4. `WORK_CONTEXT_CHANGE` source observable (Codex) + +`WorkContextService.activeWorkContextTypeAndId$` (`work-context.service.ts:119-127`) only emits `{activeId, activeType}` — no title, no taskIds. `activeWorkContext$` (`:148`) has them but emits on any context-data change, which would spam the hook. + +**Fix:** derive `WORK_CONTEXT_CHANGE` from a custom observable that distincts on `(type, id)` and then takes one `activeWorkContext$` snapshot for the payload. Also gate through `isContextChangingWithDelay$` so the hook fires once per nav, not during the 50 ms transition. + +### 5. `PluginIndexComponent` cleanup-on-nav (Claude) + +`plugin-index.component.ts:275` calls `cleanupPlugin(currentPluginId)` on `ngOnDestroy`. Embedding it in `work-view` means switching contexts (or toggling embed off/on) tears down hooks — the plugin re-initializes from scratch and loses its in-memory editor state. + +**Fix:** when `` is mounted with `directPluginId` (embed mode), skip `cleanupPlugin` on destroy. The cleanup belongs to the route lifecycle, not to embedded usage. Add an `@Input() skipCleanupOnDestroy = false`. + +## Host files to change + +| File | Change | +| ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `packages/plugin-api/src/types.ts` + `src/app/plugins/plugin-api.model.ts` | new types, `WORK_CONTEXT_CHANGE` + `ANY_TASK_UPDATE` enum members exposed | +| `src/app/plugins/plugin-api.ts` | bound methods + cleanup of context-buttons on plugin disable | +| `src/app/plugins/plugin-bridge.service.ts` | `_registerWorkContextHeaderButton`, `workContextHeaderButtons` computed signal (filtered by `(type, id)` against each button's `showFor`), `workContextEmbedPluginId` signal, `_showInWorkContext` / `_closeWorkContextView`, `getActiveWorkContext` | +| `src/app/plugins/util/plugin-iframe.util.ts` | (a) add `ANY_TASK_UPDATE`/`PROJECT_LIST_UPDATE`/`TASK_CREATED` to enum, (b) proxy new methods, (c) verify `event.source` in `handlePluginMessage`, (d) callback proxy for header-button `onClick` | +| `src/app/plugins/plugin-hooks.effects.ts` | emit `WORK_CONTEXT_CHANGE` from distinct-`(type,id)` observable, gated by `isContextChangingWithDelay$`; include `{id, type, title, taskIds}` in payload | +| `src/app/plugins/plugin-cleanup.service.ts` | clear context-buttons + embed slot on disable | +| `src/app/plugins/ui/plugin-index/plugin-index.component.ts` | add `@Input() skipCleanupOnDestroy`; default false; embed call sites pass `true` | +| `src/app/core-ui/main-header/main-header.component.html` + new `plugin-work-context-header-btns.component.ts` | render context-scoped buttons next to the existing `` | +| `src/app/features/work-view/work-view.component.ts/html` | branch: if `pluginBridge.workContextEmbedPluginId()` is set AND ctx is project or TODAY tag, render `` in place of task list; suppress work-view header same way `isDocumentMode()` does today | + +## Plugin (`packages/plugin-dev/document-mode/`) + +Scaffold from `sync-md`. Build with Vite. Bundle: `@tiptap/core` + `@tiptap/starter-kit` + `@tiptap/extension-placeholder` + `@tiptap/suggestion`. Vanilla node-views — no React (~150 KB gzipped). + +**Manifest:** `iFrame: true`, `isSkipMenuEntry: true`, `sidePanel: false`, hooks `WORK_CONTEXT_CHANGE` + `ANY_TASK_UPDATE`, `uiKit: true`. + +**Editor model:** ProseMirror JSON. No `DocumentBlock[]`. Custom **`taskRef`** atom node `{ atom: true, draggable: true, selectable: true, attrs: { taskId } }`: + +- NodeView renders checkbox + title from a local cache populated by `getTasks()`. Read-only display. +- Checkbox toggles → `updateTask(taskId, { isDone })`. +- Click on chip → `dispatchAction` to open the task in the existing side panel (not editable inside the editor). +- Backspace at start of a `taskRef`: confirm dialog (via host `openDialog` — `_isBareTask` heuristic is moved inside the host since the plugin's `Task` interface lacks `deadlineDay`/`reminderId`; expose a new `confirmTaskDeletion(taskId): Promise` helper on the API or just always confirm in v1). +- Enter at end of a `taskRef`: `addTask({...})` + insert sibling `taskRef`. + +**Other blocks:** StarterKit (paragraph/heading/bold/italic/strike/code), HorizontalRule (divider), Placeholder (empty state). + +**Slash menu:** `@tiptap/suggestion` with `char: '/'`, `allowedPrefixes: null` (so `/` after arbitrary text triggers — current behavior). Items: Task / Paragraph / H1 / H2 / H3 / Divider plus turn-into. + +**Block menu + drag handle:** vanilla floating UI on `.ProseMirror` mousemove. + +**Lifecycle:** + +- On load → register the context header button with `showFor: ['PROJECT', 'TODAY']`. +- On `WORK_CONTEXT_CHANGE` → flush pending save for previous ctx; load doc for new ctx from `persistDataSynced` blob (`{[ctxId]: doc}` map) → init editor. If no doc stored, seed from `payload.taskIds`. +- On `ANY_TASK_UPDATE` for current ctx, action is task-added → append `taskRef` if missing (mirrors `syncMissingTasks`). +- `editor.on('update')` → 5 s debounce → write to map, persist whole blob. Flush on `pagehide` and on `WORK_CONTEXT_CHANGE`. Note: `pagehide` inside iframe doesn't fire on Electron quit — last debounce window can be lost. Accept for POC; v2 can expose a host-side `beforeUnload` hook. + +## Order of work + +1. **Host plumbing — review fixes first** (`event.source` check, `skipCleanupOnDestroy`, `ANY_TASK_UPDATE` in iframe enum). These are bugs/gaps regardless of this feature. +2. **API extension**: new types, `WORK_CONTEXT_CHANGE` hook (with proper distinct-untils + gating), `getActiveWorkContext`, callback proxy for context-button `onClick`. +3. **Host UI**: `` + `workContextEmbedPluginId` signal + work-view branch. +4. **Plugin scaffold** + manifest + register button + open empty editor. +5. **TipTap editor**: paragraph/heading/divider + per-ctx persistence in single blob. +6. **`taskRef` read-only node** + create/delete via hooks. +7. **Slash menu + block menu + drag handle.** + +## Out of scope (deferred to v2) + +- Inline-editable `taskRef` titles (read-only chips for POC). +- Keyed `persistDataSynced(data, key)` API — using `{[ctxId]: doc}` in single blob for POC. +- Removal of in-tree `src/app/features/document-mode/`, `documentBlocks`/`isDocumentMode` fields, `isDocumentModeEnabled` flag. +- Data migration from legacy `documentBlocks`. +- Bundling as default install. +- Electron `beforeUnload` hook for the iframe. + +## Open risks (acknowledged, not resolved) + +- `anyTaskUpdate$` doesn't cover subtask reorders / Today list moves / project list reorders. For read-only chips this is small (titles + isDone), but title-on-other-context updates won't reflect until you switch back. +- `getTasks()` returns ALL tasks each call — fine for current scale, watchable as the task graph grows. +- The host-side fixes in §"Host-side fixes required" affect existing plugins (`sync-md`, etc.). Add regression tests for `plugin-bridge.service.spec.ts` and verify `sync-md` still loads. diff --git a/packages/plugin-dev/document-mode/.gitignore b/packages/plugin-dev/document-mode/.gitignore new file mode 100644 index 0000000000..465685471b --- /dev/null +++ b/packages/plugin-dev/document-mode/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +dist-test/ +*.log diff --git a/packages/plugin-dev/document-mode/package-lock.json b/packages/plugin-dev/document-mode/package-lock.json new file mode 100644 index 0000000000..af65920dc4 --- /dev/null +++ b/packages/plugin-dev/document-mode/package-lock.json @@ -0,0 +1,1289 @@ +{ + "name": "document-mode", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "document-mode", + "version": "0.1.0", + "dependencies": { + "@super-productivity/plugin-api": "file:../../plugin-api", + "@tiptap/core": "^2.10.0", + "@tiptap/extension-bubble-menu": "^2.27.2", + "@tiptap/extension-floating-menu": "^2.27.2", + "@tiptap/extension-placeholder": "^2.10.0", + "@tiptap/starter-kit": "^2.10.0", + "@tiptap/suggestion": "^2.10.0" + }, + "devDependencies": { + "@types/node": "^22.15.33", + "esbuild": "^0.25.11", + "typescript": "^5.8.3" + } + }, + "../../plugin-api": { + "name": "@super-productivity/plugin-api", + "version": "1.0.1", + "license": "MIT", + "devDependencies": { + "typescript": "^5.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@remirror/core-constants": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", + "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", + "license": "MIT" + }, + "node_modules/@super-productivity/plugin-api": { + "resolved": "../../plugin-api", + "link": true + }, + "node_modules/@tiptap/core": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.2.tgz", + "integrity": "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.27.2.tgz", + "integrity": "sha512-oIGZgiAeA4tG3YxbTDfrmENL4/CIwGuP3THtHsNhwRqwsl9SfMk58Ucopi2GXTQSdYXpRJ0ahE6nPqB5D6j/Zw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.27.2.tgz", + "integrity": "sha512-bR7J5IwjCGQ0s3CIxyMvOCnMFMzIvsc5OVZKscTN5UkXzFsaY6muUAIqtKxayBUucjtUskm5qZowJITCeCb1/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.27.2.tgz", + "integrity": "sha512-VkwlCOcr0abTBGzjPXklJ92FCowG7InU8+Od9FyApdLNmn0utRYGRhw0Zno6VgE9EYr1JY4BRnuSa5f9wlR72w==", + "license": "MIT", + "dependencies": { + "tippy.js": "^6.3.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.27.2.tgz", + "integrity": "sha512-gmFuKi97u5f8uFc/GQs+zmezjiulZmFiDYTh3trVoLRoc2SAHOjGEB7qxdx7dsqmMN7gwiAWAEVurLKIi1lnnw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.27.2.tgz", + "integrity": "sha512-7X9AgwqiIGXoZX7uvdHQsGsjILnN/JaEVtqfXZnPECzKGaWHeK/Ao4sYvIIIffsyZJA8k5DC7ny2/0sAgr2TuA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.2.tgz", + "integrity": "sha512-KgvdQHS4jXr79aU3wZOGBIZYYl9vCB7uDEuRFV4so2rYrfmiYMw3T8bTnlNEEGe4RUeAms1i4fdwwvQp9nR1Dw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.27.2.tgz", + "integrity": "sha512-CFhAYsPnyYnosDC4639sCJnBUnYH4Cat9qH5NZWHVvdgtDwu8GZgZn2eSzaKSYXWH1vJ9DSlCK+7UyC3SNXIBA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.27.2.tgz", + "integrity": "sha512-oEu/OrktNoQXq1x29NnH/GOIzQZm8ieTQl3FK27nxfBPA89cNoH4mFEUmBL5/OFIENIjiYG3qWpg6voIqzswNw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.27.2.tgz", + "integrity": "sha512-GUN6gPIGXS7ngRJOwdSmtBRBDt9Kt9CM/9pSwKebhLJ+honFoNA+Y6IpVyDvvDMdVNgBchiJLs6qA5H97gAePQ==", + "license": "MIT", + "dependencies": { + "tippy.js": "^6.3.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.27.2.tgz", + "integrity": "sha512-/c9VF1HBxj+AP54XGVgCmD9bEGYc5w5OofYCFQgM7l7PB1J00A4vOke0oPkHJnqnOOyPlFaxO/7N6l3XwFcnKA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.27.2.tgz", + "integrity": "sha512-kSRVGKlCYK6AGR0h8xRkk0WOFGXHIIndod3GKgWU49APuIGDiXd8sziXsSlniUsWmqgDmDXcNnSzPcV7AQ8YNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.27.2.tgz", + "integrity": "sha512-iM3yeRWuuQR/IRQ1djwNooJGfn9Jts9zF43qZIUf+U2NY8IlvdNsk2wTOdBgh6E0CamrStPxYGuln3ZS4fuglw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-history": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.27.2.tgz", + "integrity": "sha512-+hSyqERoFNTWPiZx4/FCyZ/0eFqB9fuMdTB4AC/q9iwu3RNWAQtlsJg5230bf/qmyO6bZxRUc0k8p4hrV6ybAw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.27.2.tgz", + "integrity": "sha512-WGWUSgX+jCsbtf9Y9OCUUgRZYuwjVoieW5n6mAUohJ9/6gc6sGIOrUpBShf+HHo6WD+gtQjRd+PssmX3NPWMpg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.27.2.tgz", + "integrity": "sha512-1OFsw2SZqfaqx5Fa5v90iNlPRcqyt+lVSjBwTDzuPxTPFY4Q0mL89mKgkq2gVHYNCiaRkXvFLDxaSvBWbmthgg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.27.2.tgz", + "integrity": "sha512-eJNee7IEGXMnmygM5SdMGDC8m/lMWmwNGf9fPCK6xk0NxuQRgmZHL6uApKcdH6gyNcRPHCqvTTkhEP7pbny/fg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.27.2.tgz", + "integrity": "sha512-M7A4tLGJcLPYdLC4CI2Gwl8LOrENQW59u3cMVa+KkwG1hzSJyPsbDpa1DI6oXPC2WtYiTf22zrbq3gVvH+KA2w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.27.2.tgz", + "integrity": "sha512-elYVn2wHJJ+zB9LESENWOAfI4TNT0jqEN34sMA/hCtA4im1ZG2DdLHwkHIshj/c4H0dzQhmsS/YmNC5Vbqab/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-placeholder": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.27.2.tgz", + "integrity": "sha512-IjsgSVYJRjpAKmIoapU0E2R4E2FPY3kpvU7/1i7PUYisylqejSJxmtJPGYw0FOMQY9oxnEEvfZHMBA610tqKpg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.27.2.tgz", + "integrity": "sha512-HHIjhafLhS2lHgfAsCwC1okqMsQzR4/mkGDm4M583Yftyjri1TNA7lzhzXWRFWiiMfJxKtdjHjUAQaHuteRTZw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.2.tgz", + "integrity": "sha512-Xk7nYcigljAY0GO9hAQpZ65ZCxqOqaAlTPDFcKerXmlkQZP/8ndx95OgUb1Xf63kmPOh3xypurGS2is3v0MXSA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-text-style": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz", + "integrity": "sha512-Omk+uxjJLyEY69KStpCw5fA9asvV+MGcAX2HOxyISDFoLaL49TMrNjhGAuz09P1L1b0KGXo4ml7Q3v/Lfy4WPA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/pm": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz", + "integrity": "sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.3.0", + "prosemirror-collab": "^1.3.1", + "prosemirror-commands": "^1.6.2", + "prosemirror-dropcursor": "^1.8.1", + "prosemirror-gapcursor": "^1.3.2", + "prosemirror-history": "^1.4.1", + "prosemirror-inputrules": "^1.4.0", + "prosemirror-keymap": "^1.2.2", + "prosemirror-markdown": "^1.13.1", + "prosemirror-menu": "^1.2.4", + "prosemirror-model": "^1.23.0", + "prosemirror-schema-basic": "^1.2.3", + "prosemirror-schema-list": "^1.4.1", + "prosemirror-state": "^1.4.3", + "prosemirror-tables": "^1.6.4", + "prosemirror-trailing-node": "^3.0.0", + "prosemirror-transform": "^1.10.2", + "prosemirror-view": "^1.37.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.27.2.tgz", + "integrity": "sha512-bb0gJvPoDuyRUQ/iuN52j1//EtWWttw+RXAv1uJxfR0uKf8X7uAqzaOOgwjknoCIDC97+1YHwpGdnRjpDkOBxw==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^2.27.2", + "@tiptap/extension-blockquote": "^2.27.2", + "@tiptap/extension-bold": "^2.27.2", + "@tiptap/extension-bullet-list": "^2.27.2", + "@tiptap/extension-code": "^2.27.2", + "@tiptap/extension-code-block": "^2.27.2", + "@tiptap/extension-document": "^2.27.2", + "@tiptap/extension-dropcursor": "^2.27.2", + "@tiptap/extension-gapcursor": "^2.27.2", + "@tiptap/extension-hard-break": "^2.27.2", + "@tiptap/extension-heading": "^2.27.2", + "@tiptap/extension-history": "^2.27.2", + "@tiptap/extension-horizontal-rule": "^2.27.2", + "@tiptap/extension-italic": "^2.27.2", + "@tiptap/extension-list-item": "^2.27.2", + "@tiptap/extension-ordered-list": "^2.27.2", + "@tiptap/extension-paragraph": "^2.27.2", + "@tiptap/extension-strike": "^2.27.2", + "@tiptap/extension-text": "^2.27.2", + "@tiptap/extension-text-style": "^2.27.2", + "@tiptap/pm": "^2.27.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/suggestion": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-2.27.2.tgz", + "integrity": "sha512-dQyvCIg0hcAVeh4fCIVCxogvbp+bF+GpbUb8sNlgnGrmHXnapGxzkvrlHnvneXZxLk/j7CxmBPKJNnm4Pbx4zw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/markdown-it": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" + }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, + "node_modules/prosemirror-changeset": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz", + "integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-collab": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz", + "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", + "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-markdown": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz", + "integrity": "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==", + "license": "MIT", + "dependencies": { + "@types/markdown-it": "^14.0.0", + "markdown-it": "^14.0.0", + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-menu": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.2.tgz", + "integrity": "sha512-6VgUJTYod0nMBlCaYJGhXGLu7Gt4AvcwcOq0YfJCY/6Uh+3S7UsWhpy6rJFCBFOmonq1hD8KyWOtZhkppd4YPg==", + "license": "MIT", + "dependencies": { + "crelt": "^1.0.0", + "prosemirror-commands": "^1.0.0", + "prosemirror-history": "^1.0.0", + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.7", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.7.tgz", + "integrity": "sha512-A79aN8QEFUwI6cax8Yq4Rpcx1TJZ3Kagn+ii7qLo4/V8H3mMiHrhFyhTyHHvpSnOgMPpWiDGSwM3etwrxE50ug==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-basic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz", + "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-trailing-node": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", + "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", + "license": "MIT", + "dependencies": { + "@remirror/core-constants": "3.0.0", + "escape-string-regexp": "^4.0.0" + }, + "peerDependencies": { + "prosemirror-model": "^1.22.1", + "prosemirror-state": "^1.4.2", + "prosemirror-view": "^1.33.8" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", + "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.41.8", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz", + "integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.20.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, + "node_modules/tippy.js": { + "version": "6.3.7", + "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz", + "integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==", + "license": "MIT", + "dependencies": { + "@popperjs/core": "^2.9.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + } + } +} diff --git a/packages/plugin-dev/document-mode/package.json b/packages/plugin-dev/document-mode/package.json new file mode 100644 index 0000000000..c2ae0c45c8 --- /dev/null +++ b/packages/plugin-dev/document-mode/package.json @@ -0,0 +1,26 @@ +{ + "name": "document-mode", + "version": "0.1.0", + "description": "Document-style editor for projects and Today using TipTap", + "private": true, + "scripts": { + "build": "node scripts/build.js", + "deploy": "npm run build && node scripts/deploy.js", + "lint": "tsc --noEmit", + "test": "node scripts/test.js" + }, + "devDependencies": { + "@types/node": "^22.15.33", + "esbuild": "^0.25.11", + "typescript": "^5.8.3" + }, + "dependencies": { + "@super-productivity/plugin-api": "file:../../plugin-api", + "@tiptap/core": "^2.10.0", + "@tiptap/extension-bubble-menu": "^2.27.2", + "@tiptap/extension-floating-menu": "^2.27.2", + "@tiptap/extension-placeholder": "^2.10.0", + "@tiptap/starter-kit": "^2.10.0", + "@tiptap/suggestion": "^2.10.0" + } +} diff --git a/packages/plugin-dev/document-mode/scripts/build.js b/packages/plugin-dev/document-mode/scripts/build.js new file mode 100644 index 0000000000..bd2f70e925 --- /dev/null +++ b/packages/plugin-dev/document-mode/scripts/build.js @@ -0,0 +1,73 @@ +#!/usr/bin/env node +/* eslint-disable @typescript-eslint/no-require-imports */ +const fs = require('fs'); +const path = require('path'); +const { build } = require('esbuild'); + +const ROOT_DIR = path.join(__dirname, '..'); +const SRC_DIR = path.join(ROOT_DIR, 'src'); +const DIST_DIR = path.join(ROOT_DIR, 'dist'); + +async function buildPlugin() { + console.log('Building document-mode plugin with esbuild...'); + + if (fs.existsSync(DIST_DIR)) { + fs.rmSync(DIST_DIR, { recursive: true }); + } + fs.mkdirSync(DIST_DIR); + + // Background script — runs in the host page context, registers UI + hooks. + console.log('Building plugin.js (background)...'); + await build({ + entryPoints: [path.join(SRC_DIR, 'background.ts')], + bundle: true, + outfile: path.join(DIST_DIR, 'plugin.js'), + platform: 'browser', + target: 'es2020', + format: 'iife', + globalName: 'DocumentModePlugin', + logLevel: 'info', + minify: true, + sourcemap: false, + }); + + // Editor bundle — runs inside the iframe, hosts TipTap. + console.log('Building editor.js (iframe)...'); + await build({ + entryPoints: [path.join(SRC_DIR, 'ui', 'editor.ts')], + bundle: true, + outfile: path.join(DIST_DIR, 'editor.js'), + platform: 'browser', + target: 'es2020', + format: 'iife', + logLevel: 'info', + minify: true, + sourcemap: false, + }); + + // The iframe is loaded via blob URL, so relative ` inside the JS so the HTML parser doesn't close the tag early. + const safeEditorJs = editorJs.replace(/<\/script>/gi, '<\\/script>'); + const inlinedHtml = rawHtml.replace( + /`, + ); + fs.writeFileSync(path.join(DIST_DIR, 'index.html'), inlinedHtml); + fs.copyFileSync( + path.join(SRC_DIR, 'manifest.json'), + path.join(DIST_DIR, 'manifest.json'), + ); + fs.copyFileSync(path.join(SRC_DIR, 'icon.svg'), path.join(DIST_DIR, 'icon.svg')); + + console.log('\nBuild complete! Output in dist/'); +} + +buildPlugin().catch((err) => { + console.error('Build failed:', err); + process.exit(1); +}); diff --git a/packages/plugin-dev/document-mode/scripts/deploy.js b/packages/plugin-dev/document-mode/scripts/deploy.js new file mode 100644 index 0000000000..480932d564 --- /dev/null +++ b/packages/plugin-dev/document-mode/scripts/deploy.js @@ -0,0 +1,39 @@ +#!/usr/bin/env node +/* eslint-disable @typescript-eslint/no-require-imports */ +const fs = require('fs'); +const path = require('path'); + +const ROOT_DIR = path.join(__dirname, '..'); +const DIST_DIR = path.join(ROOT_DIR, 'dist'); +const TARGET_DIR = path.join( + ROOT_DIR, + '..', + '..', + '..', + 'src', + 'assets', + 'bundled-plugins', + 'document-mode', +); + +// editor.js is inlined into index.html, so it doesn't need to be deployed. +const FILES = ['manifest.json', 'plugin.js', 'index.html', 'icon.svg']; + +if (!fs.existsSync(DIST_DIR)) { + console.error('Error: dist directory not found. Run "npm run build" first.'); + process.exit(1); +} +if (!fs.existsSync(TARGET_DIR)) { + fs.mkdirSync(TARGET_DIR, { recursive: true }); +} +for (const file of FILES) { + const src = path.join(DIST_DIR, file); + const dest = path.join(TARGET_DIR, file); + if (fs.existsSync(src)) { + fs.copyFileSync(src, dest); + console.log(`✓ Copied ${file}`); + } else { + console.warn(`⚠ ${file} not found in dist`); + } +} +console.log(`\n✅ Deployed to ${TARGET_DIR}`); diff --git a/packages/plugin-dev/document-mode/scripts/test.js b/packages/plugin-dev/document-mode/scripts/test.js new file mode 100644 index 0000000000..c289f76b14 --- /dev/null +++ b/packages/plugin-dev/document-mode/scripts/test.js @@ -0,0 +1,59 @@ +#!/usr/bin/env node +/* eslint-disable @typescript-eslint/no-require-imports */ +/** + * Unit-test runner for the document-mode plugin. + * + * The plugin has no browser-based test setup — its testable logic is the pure + * `doc-transform` module. This script transpiles every `*.spec.ts` under + * `src/` with esbuild (already a build dependency) and runs them with the + * built-in `node --test` runner. No extra test framework needed. + */ +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); +const { buildSync } = require('esbuild'); + +const ROOT_DIR = path.join(__dirname, '..'); +const SRC_DIR = path.join(ROOT_DIR, 'src'); +const OUT_DIR = path.join(ROOT_DIR, 'dist-test'); + +/** Recursively collect files under `dir` whose name ends with `suffix`. */ +function collect(dir, suffix) { + const out = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) out.push(...collect(full, suffix)); + else if (entry.name.endsWith(suffix)) out.push(full); + } + return out; +} + +const specs = collect(SRC_DIR, '.spec.ts'); +if (specs.length === 0) { + console.log('No *.spec.ts files found under src/.'); + process.exit(0); +} + +if (fs.existsSync(OUT_DIR)) fs.rmSync(OUT_DIR, { recursive: true }); +fs.mkdirSync(OUT_DIR, { recursive: true }); + +// Transpile + bundle each spec to CJS. On `platform: 'node'` the built-ins +// (`node:test`, `node:assert`) stay external; the plugin-api import is +// type-only and is dropped, so nothing needs runtime resolution beyond the +// bundled `doc-transform` source. +buildSync({ + entryPoints: specs, + outdir: OUT_DIR, + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node22', + logLevel: 'warning', +}); + +const compiled = collect(OUT_DIR, '.js'); +try { + execFileSync(process.execPath, ['--test', ...compiled], { stdio: 'inherit' }); +} catch { + process.exit(1); +} diff --git a/packages/plugin-dev/document-mode/src/background.ts b/packages/plugin-dev/document-mode/src/background.ts new file mode 100644 index 0000000000..d0ee67a6b6 --- /dev/null +++ b/packages/plugin-dev/document-mode/src/background.ts @@ -0,0 +1,122 @@ +/** + * Document-Mode background script. Runs once per plugin load in the host + * page context. Responsible for: + * - Registering the work-context header button + * - Tracking which contexts have document mode enabled + * - Auto-showing / -closing the work-view embed on context navigation + * + * The TipTap editor itself lives in the iframe (src/ui/editor.ts). Both + * scripts share a single persisted blob; this script owns the + * `enabledCtxIds` field, the editor owns `docs`. Each one read-modify-writes + * only its own slice so updates don't clobber each other. + */ + +import { + PluginHooks, + type ActiveWorkContext, + type PluginAPI, + type WorkContextChangePayload, +} from '@super-productivity/plugin-api'; + +declare const PluginAPI: PluginAPI; + +interface StoredState { + version: number; + docs: Record; + enabledCtxIds: string[]; +} + +const STORAGE_VERSION = 1; +const emptyState = (): StoredState => ({ + version: STORAGE_VERSION, + docs: {}, + enabledCtxIds: [], +}); + +const loadState = async (): Promise => { + try { + const raw = await PluginAPI.loadSyncedData(); + if (!raw) return emptyState(); + const parsed = JSON.parse(raw) as Partial; + return { + version: parsed.version ?? STORAGE_VERSION, + docs: parsed.docs ?? {}, + enabledCtxIds: Array.isArray(parsed.enabledCtxIds) ? parsed.enabledCtxIds : [], + }; + } catch (err) { + PluginAPI.log.err('document-mode: failed to parse stored state', err); + return emptyState(); + } +}; + +/** + * Read-modify-write helper. Re-reads the blob from storage before mutating + * so we don't overwrite changes made by the editor (which writes the `docs` + * field on its own debounce schedule). + */ +const updateEnabledCtxIds = async ( + mutate: (ids: string[]) => string[], +): Promise => { + const current = await loadState(); + const next = mutate(current.enabledCtxIds); + await PluginAPI.persistDataSynced(JSON.stringify({ ...current, enabledCtxIds: next })); + return next; +}; + +let enabledIds = new Set(); + +const init = async (): Promise => { + const state = await loadState(); + enabledIds = new Set(state.enabledCtxIds); + + // If the active context is already enabled, mount the embed immediately + // so the user sees doc mode on app start without an extra click. + const ctx = await PluginAPI.getActiveWorkContext(); + if (ctx && enabledIds.has(ctx.id)) { + PluginAPI.showInWorkContext(); + } +}; + +const onContextChange = (ctx: WorkContextChangePayload): void => { + if (ctx && enabledIds.has(ctx.id)) { + PluginAPI.showInWorkContext(); + } else { + PluginAPI.closeWorkContextView(); + } +}; + +const onButtonClick = async (ctx: ActiveWorkContext): Promise => { + const wasEnabled = enabledIds.has(ctx.id); + if (wasEnabled) { + enabledIds.delete(ctx.id); + PluginAPI.closeWorkContextView(); + } else { + enabledIds.add(ctx.id); + PluginAPI.showInWorkContext(); + } + try { + await updateEnabledCtxIds((ids) => { + const set = new Set(ids); + if (wasEnabled) set.delete(ctx.id); + else set.add(ctx.id); + return [...set]; + }); + } catch (err) { + PluginAPI.log.err('document-mode: failed to persist toggle', err); + } +}; + +PluginAPI.registerWorkContextHeaderButton({ + label: 'Document Mode', + icon: 'description', + showFor: ['PROJECT', 'TODAY'], + onClick: (ctx) => { + void onButtonClick(ctx); + }, +}); + +PluginAPI.registerHook(PluginHooks.WORK_CONTEXT_CHANGE, (payload) => { + onContextChange(payload as WorkContextChangePayload); +}); + +void init(); diff --git a/packages/plugin-dev/document-mode/src/doc-transform.spec.ts b/packages/plugin-dev/document-mode/src/doc-transform.spec.ts new file mode 100644 index 0000000000..f829db94b1 --- /dev/null +++ b/packages/plugin-dev/document-mode/src/doc-transform.spec.ts @@ -0,0 +1,492 @@ +/** + * Unit tests for the pure document-transform helpers. Run with + * `npm test` (see scripts/test.js) — esbuild transpiles this file and the + * built-in `node --test` runner executes it. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildSeedDoc, + ensureSubtasksInJSON, + isInContext, + migrateStoredDoc, + prepareStoredDoc, + reconcileTopLevelTaskRefs, + refreshChipContentFromCache, + snapshotInContextTaskIds, + taskNodeJSON, + taskRefWithSubtasksJSON, + type PMNode, + type TaskLookup, +} from './doc-transform'; +import type { ActiveWorkContext, Task } from '@super-productivity/plugin-api'; + +/* -------------------------------------------------------------------------- */ +/* Fixtures */ +/* -------------------------------------------------------------------------- */ + +const mkTask = (partial: Partial & { id: string }): Task => { + const base: Task = { + id: partial.id, + title: '', + timeEstimate: 0, + timeSpent: 0, + isDone: false, + projectId: null, + tagIds: [], + created: 0, + subTaskIds: [], + }; + return { ...base, ...partial }; +}; + +const mkLookup = (tasks: Task[]): TaskLookup => { + const map = new Map(tasks.map((t) => [t.id, t])); + return (id) => map.get(id); +}; + +const mkCtx = ( + partial: Partial & { id: string }, +): ActiveWorkContext => ({ + type: 'PROJECT', + title: 'Untitled', + taskIds: [], + ...partial, +}); + +/** Compact `[type:taskId, ...]` view of a doc's top-level children. */ +const summary = (doc: unknown): string[] => + ((doc as PMNode).content ?? []).map((n) => { + const node = n as PMNode; + if (node.type === 'taskRef' || node.type === 'subTaskRef') { + return `${node.type}:${String(node.attrs?.taskId)}`; + } + return node.type ?? '?'; + }); + +/** Concatenated inline text of a chip node. */ +const chipText = (node: PMNode): string => + (node.content ?? []).map((c) => (c as PMNode).text ?? '').join(''); + +const childAt = (doc: unknown, i: number): PMNode => + ((doc as PMNode).content ?? [])[i] as PMNode; + +/* -------------------------------------------------------------------------- */ +/* taskNodeJSON / taskRefWithSubtasksJSON */ +/* -------------------------------------------------------------------------- */ + +test('taskNodeJSON: builds a content-bearing chip from the lookup', () => { + const look = mkLookup([mkTask({ id: 'a', title: 'Buy milk', isDone: true })]); + assert.deepEqual(taskNodeJSON('a', 'taskRef', look), { + type: 'taskRef', + attrs: { taskId: 'a', isDone: true }, + content: [{ type: 'text', text: 'Buy milk' }], + }); +}); + +test('taskNodeJSON: unknown task yields an empty (but valid) chip', () => { + const node = taskNodeJSON('ghost', 'subTaskRef', mkLookup([])); + assert.deepEqual(node, { + type: 'subTaskRef', + attrs: { taskId: 'ghost', isDone: false }, + content: [], + }); +}); + +test('taskRefWithSubtasksJSON: emits the parent followed by each subtask', () => { + const look = mkLookup([ + mkTask({ id: 'p', title: 'Parent', subTaskIds: ['s1', 's2'] }), + mkTask({ id: 's1', title: 'Sub 1' }), + mkTask({ id: 's2', title: 'Sub 2' }), + ]); + assert.deepEqual(summary({ content: taskRefWithSubtasksJSON('p', look) }), [ + 'taskRef:p', + 'subTaskRef:s1', + 'subTaskRef:s2', + ]); +}); + +/* -------------------------------------------------------------------------- */ +/* buildSeedDoc */ +/* -------------------------------------------------------------------------- */ + +test('buildSeedDoc: heading + chips with content + trailing paragraph', () => { + const look = mkLookup([ + mkTask({ id: 'a', title: 'Alpha', subTaskIds: ['s1'] }), + mkTask({ id: 's1', title: 'Sub' }), + ]); + const doc = buildSeedDoc( + mkCtx({ id: 'P1', title: 'Project One', taskIds: ['a'] }), + look, + ); + assert.equal((doc as PMNode).type, 'doc'); + assert.deepEqual(summary(doc), ['heading', 'taskRef:a', 'subTaskRef:s1', 'paragraph']); + assert.equal(chipText(childAt(doc, 0)), 'Project One'); + assert.equal(chipText(childAt(doc, 1)), 'Alpha'); +}); + +/* -------------------------------------------------------------------------- */ +/* migrateStoredDoc */ +/* -------------------------------------------------------------------------- */ + +test('migrateStoredDoc: backfills content for old atom-shape taskRefs', () => { + const look = mkLookup([mkTask({ id: 'a', title: 'Hello', isDone: true })]); + const raw = { type: 'doc', content: [{ type: 'taskRef', attrs: { taskId: 'a' } }] }; + const out = migrateStoredDoc(raw, look) as PMNode; + assert.deepEqual(childAt(out, 0), { + type: 'taskRef', + attrs: { taskId: 'a', isDone: true }, + content: [{ type: 'text', text: 'Hello' }], + }); +}); + +test('migrateStoredDoc: leaves chips that already have content (idempotent)', () => { + const look = mkLookup([mkTask({ id: 'a', title: 'Fresh title' })]); + const raw = { + type: 'doc', + content: [ + { + type: 'taskRef', + attrs: { taskId: 'a', isDone: false }, + content: [{ type: 'text', text: 'Edited title' }], + }, + ], + }; + const out = migrateStoredDoc(raw, look) as PMNode; + assert.equal(chipText(childAt(out, 0)), 'Edited title'); +}); + +test('migrateStoredDoc: preserves non-task nodes', () => { + const raw = { + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'note' }] }], + }; + assert.deepEqual(migrateStoredDoc(raw, mkLookup([])), raw); +}); + +/* -------------------------------------------------------------------------- */ +/* reconcileTopLevelTaskRefs */ +/* -------------------------------------------------------------------------- */ + +test('reconcile: rebuilds order from ctx, drops stale chips, appends new ones', () => { + const look = mkLookup([ + mkTask({ id: 'a', title: 'A' }), + mkTask({ id: 'b', title: 'B' }), + mkTask({ id: 'c', title: 'C' }), + ]); + const stored = { + type: 'doc', + content: [ + { type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'T' }] }, + { type: 'taskRef', attrs: { taskId: 'b' }, content: [{ type: 'text', text: 'B' }] }, + { + type: 'taskRef', + attrs: { taskId: 'x' }, + content: [{ type: 'text', text: 'gone' }], + }, + { type: 'taskRef', attrs: { taskId: 'a' }, content: [{ type: 'text', text: 'A' }] }, + ], + }; + const out = reconcileTopLevelTaskRefs( + stored, + mkCtx({ id: 'P', taskIds: ['a', 'b', 'c'] }), + look, + ); + assert.deepEqual(summary(out), [ + 'heading', + 'taskRef:a', + 'taskRef:b', + 'taskRef:c', + 'paragraph', + ]); + // The freshly-appended chip carries its title, not an empty body. + assert.equal(chipText(childAt(out, 3)), 'C'); +}); + +test('reconcile: dedupes duplicate subtask rows and duplicate parent groups', () => { + const look = mkLookup([mkTask({ id: 'a', title: 'A' })]); + const stored = { + type: 'doc', + content: [ + { type: 'taskRef', attrs: { taskId: 'a' } }, + { type: 'subTaskRef', attrs: { taskId: 's1' } }, + { type: 'subTaskRef', attrs: { taskId: 's1' } }, + { type: 'subTaskRef', attrs: { taskId: 's2' } }, + { type: 'taskRef', attrs: { taskId: 'a' } }, + { type: 'subTaskRef', attrs: { taskId: 's3' } }, + ], + }; + const out = reconcileTopLevelTaskRefs(stored, mkCtx({ id: 'P', taskIds: ['a'] }), look); + assert.deepEqual(summary(out), [ + 'taskRef:a', + 'subTaskRef:s1', + 'subTaskRef:s2', + 'paragraph', + ]); +}); + +test('reconcile: drops orphan subtasks and keeps non-chip blocks in place', () => { + const look = mkLookup([mkTask({ id: 'a', title: 'A' })]); + const stored = { + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'intro' }] }, + { type: 'subTaskRef', attrs: { taskId: 'orphan' } }, + { type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'T' }] }, + { type: 'taskRef', attrs: { taskId: 'a' } }, + { type: 'paragraph', content: [{ type: 'text', text: 'mid' }] }, + ], + }; + const out = reconcileTopLevelTaskRefs(stored, mkCtx({ id: 'P', taskIds: ['a'] }), look); + // Orphan subtask dropped; intro + heading stay above the chip, mid below it. + assert.deepEqual(summary(out), [ + 'paragraph', + 'heading', + 'taskRef:a', + 'paragraph', + 'paragraph', + ]); +}); + +test('reconcile: text inserted between two tasks stays between them', () => { + const look = mkLookup([ + mkTask({ id: 'a', title: 'A' }), + mkTask({ id: 'b', title: 'B' }), + ]); + const stored = { + type: 'doc', + content: [ + { type: 'taskRef', attrs: { taskId: 'a' }, content: [{ type: 'text', text: 'A' }] }, + { type: 'paragraph', content: [{ type: 'text', text: 'a note' }] }, + { type: 'taskRef', attrs: { taskId: 'b' }, content: [{ type: 'text', text: 'B' }] }, + ], + }; + const out = reconcileTopLevelTaskRefs( + stored, + mkCtx({ id: 'P', taskIds: ['a', 'b'] }), + look, + ); + assert.deepEqual(summary(out), ['taskRef:a', 'paragraph', 'taskRef:b', 'paragraph']); + assert.equal(chipText(childAt(out, 1)), 'a note'); +}); + +test('reconcile: an anchored block follows its chip when ctx reorders', () => { + const look = mkLookup([ + mkTask({ id: 'a', title: 'A' }), + mkTask({ id: 'b', title: 'B' }), + ]); + const stored = { + type: 'doc', + content: [ + { type: 'taskRef', attrs: { taskId: 'a' } }, + { type: 'paragraph', content: [{ type: 'text', text: 'under a' }] }, + { type: 'taskRef', attrs: { taskId: 'b' } }, + ], + }; + // Context order is now b, a — the note must travel with its anchor (a). + const out = reconcileTopLevelTaskRefs( + stored, + mkCtx({ id: 'P', taskIds: ['b', 'a'] }), + look, + ); + assert.deepEqual(summary(out), ['taskRef:b', 'taskRef:a', 'paragraph', 'paragraph']); +}); + +test('reconcile: appends a trailing paragraph when the doc ends with a chip', () => { + const look = mkLookup([mkTask({ id: 'a', title: 'A' })]); + const stored = { type: 'doc', content: [{ type: 'taskRef', attrs: { taskId: 'a' } }] }; + const out = reconcileTopLevelTaskRefs(stored, mkCtx({ id: 'P', taskIds: ['a'] }), look); + assert.deepEqual(summary(out), ['taskRef:a', 'paragraph']); +}); + +test('reconcile: returns the input untouched when it is not a doc node', () => { + const notADoc = { type: 'paragraph' }; + assert.equal( + reconcileTopLevelTaskRefs(notADoc, mkCtx({ id: 'P' }), mkLookup([])), + notADoc, + ); + assert.equal(reconcileTopLevelTaskRefs(null, mkCtx({ id: 'P' }), mkLookup([])), null); +}); + +/* -------------------------------------------------------------------------- */ +/* ensureSubtasksInJSON */ +/* -------------------------------------------------------------------------- */ + +test('ensureSubtasksInJSON: backfills host subtasks missing from the doc', () => { + const look = mkLookup([ + mkTask({ id: 'a', title: 'A', subTaskIds: ['s1', 's2'] }), + mkTask({ id: 's1', title: 'S1' }), + mkTask({ id: 's2', title: 'S2' }), + ]); + const doc = { type: 'doc', content: [{ type: 'taskRef', attrs: { taskId: 'a' } }] }; + const out = ensureSubtasksInJSON(doc, look); + assert.deepEqual(summary(out), ['taskRef:a', 'subTaskRef:s1', 'subTaskRef:s2']); +}); + +test('ensureSubtasksInJSON: idempotent — keeps existing rows, adds only the gaps', () => { + const look = mkLookup([ + mkTask({ id: 'a', title: 'A', subTaskIds: ['s1', 's2'] }), + mkTask({ id: 's1', title: 'S1' }), + mkTask({ id: 's2', title: 'S2' }), + ]); + const doc = { + type: 'doc', + content: [ + { type: 'taskRef', attrs: { taskId: 'a' } }, + { type: 'subTaskRef', attrs: { taskId: 's1' } }, + ], + }; + const out = ensureSubtasksInJSON(doc, look); + assert.deepEqual(summary(out), ['taskRef:a', 'subTaskRef:s1', 'subTaskRef:s2']); +}); + +test('ensureSubtasksInJSON: skips subtasks the host does not know yet', () => { + const look = mkLookup([ + mkTask({ id: 'a', title: 'A', subTaskIds: ['s1', 'ghost'] }), + mkTask({ id: 's1', title: 'S1' }), + ]); + const doc = { type: 'doc', content: [{ type: 'taskRef', attrs: { taskId: 'a' } }] }; + const out = ensureSubtasksInJSON(doc, look); + assert.deepEqual(summary(out), ['taskRef:a', 'subTaskRef:s1']); +}); + +/* -------------------------------------------------------------------------- */ +/* refreshChipContentFromCache */ +/* -------------------------------------------------------------------------- */ + +test('refreshChipContentFromCache: replaces stale title + isDone from the lookup', () => { + const look = mkLookup([mkTask({ id: 'a', title: 'New title', isDone: true })]); + const doc = { + type: 'doc', + content: [ + { + type: 'taskRef', + attrs: { taskId: 'a', isDone: false }, + content: [{ type: 'text', text: 'Stale title' }], + }, + { type: 'paragraph', content: [{ type: 'text', text: 'untouched' }] }, + ], + }; + const out = refreshChipContentFromCache(doc, look) as PMNode; + assert.equal(chipText(childAt(out, 0)), 'New title'); + assert.equal(childAt(out, 0).attrs?.isDone, true); + assert.equal(chipText(childAt(out, 1)), 'untouched'); +}); + +test('refreshChipContentFromCache: leaves chips whose task is unknown', () => { + const doc = { + type: 'doc', + content: [ + { + type: 'taskRef', + attrs: { taskId: 'gone' }, + content: [{ type: 'text', text: 'last known' }], + }, + ], + }; + const out = refreshChipContentFromCache(doc, mkLookup([])) as PMNode; + assert.equal(chipText(childAt(out, 0)), 'last known'); +}); + +/* -------------------------------------------------------------------------- */ +/* prepareStoredDoc — end-to-end pipeline / regression guard */ +/* -------------------------------------------------------------------------- */ + +test('prepareStoredDoc: migrates, reconciles, backfills subtasks and refreshes titles', () => { + const look = mkLookup([ + mkTask({ id: 'a', title: 'Alpha', subTaskIds: ['s1'] }), + mkTask({ id: 's1', title: 'Sub one' }), + mkTask({ id: 'b', title: 'Beta' }), + ]); + const stored = { + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 1 }, + content: [{ type: 'text', text: 'My Project' }], + }, + // Old atom shape — no content array. + { type: 'taskRef', attrs: { taskId: 'a' } }, + // Duplicate parent group — must be discarded. + { type: 'taskRef', attrs: { taskId: 'a' }, content: [{ type: 'text', text: 'A' }] }, + // Stale chip for a task no longer in the context. + { + type: 'taskRef', + attrs: { taskId: 'old' }, + content: [{ type: 'text', text: 'gone' }], + }, + { type: 'paragraph', content: [{ type: 'text', text: 'a note' }] }, + ], + }; + const out = prepareStoredDoc( + stored, + mkCtx({ id: 'P', taskIds: ['a', 'b'] }), + look, + ) as PMNode; + + // 'a note' was anchored to the stale 'old' chip; with that chip dropped it + // is kept (appended), followed by the structural trailing paragraph. + assert.deepEqual(summary(out), [ + 'heading', + 'taskRef:a', + 'subTaskRef:s1', + 'taskRef:b', + 'paragraph', + 'paragraph', + ]); + // Every chip carries current host content — the invariant whose violation + // caused content-less chips to be written back as title erasures. + assert.equal(chipText(childAt(out, 1)), 'Alpha'); + assert.equal(chipText(childAt(out, 2)), 'Sub one'); + assert.equal(chipText(childAt(out, 3)), 'Beta'); + assert.equal(chipText(childAt(out, 4)), 'a note'); +}); + +/* -------------------------------------------------------------------------- */ +/* isInContext */ +/* -------------------------------------------------------------------------- */ + +test('isInContext: PROJECT matches on projectId', () => { + const ctx = mkCtx({ id: 'P1', type: 'PROJECT' }); + assert.equal(isInContext(mkTask({ id: 't', projectId: 'P1' }), ctx), true); + assert.equal(isInContext(mkTask({ id: 't', projectId: 'P2' }), ctx), false); +}); + +test('isInContext: subtasks never surface at the top level', () => { + const ctx = mkCtx({ id: 'P1', type: 'PROJECT' }); + assert.equal( + isInContext(mkTask({ id: 't', projectId: 'P1', parentId: 'a' }), ctx), + false, + ); +}); + +test('isInContext: TODAY accepts the TODAY tag, a dueDay or a dueWithTime', () => { + const today = mkCtx({ id: 'TODAY', type: 'TAG' }); + assert.equal(isInContext(mkTask({ id: 't', tagIds: ['TODAY'] }), today), true); + assert.equal(isInContext(mkTask({ id: 't', dueDay: '2026-05-22' }), today), true); + assert.equal(isInContext(mkTask({ id: 't', dueWithTime: 1_700_000_000 }), today), true); + assert.equal(isInContext(mkTask({ id: 't' }), today), false); +}); + +test('isInContext: TAG matches on tagIds membership', () => { + const ctx = mkCtx({ id: 'work', type: 'TAG' }); + assert.equal(isInContext(mkTask({ id: 't', tagIds: ['work'] }), ctx), true); + assert.equal(isInContext(mkTask({ id: 't', tagIds: ['home'] }), ctx), false); +}); + +/* -------------------------------------------------------------------------- */ +/* snapshotInContextTaskIds */ +/* -------------------------------------------------------------------------- */ + +test('snapshotInContextTaskIds: includes in-context parents and their subtask ids', () => { + const tasks = [ + mkTask({ id: 'a', projectId: 'P1', subTaskIds: ['s1', 's2'] }), + mkTask({ id: 's1', projectId: 'P1', parentId: 'a' }), + mkTask({ id: 's2', projectId: 'P1', parentId: 'a' }), + mkTask({ id: 'b', projectId: 'P2', subTaskIds: ['s3'] }), + mkTask({ id: 's3', projectId: 'P2', parentId: 'b' }), + ]; + const snapshot = snapshotInContextTaskIds(tasks, mkCtx({ id: 'P1', type: 'PROJECT' })); + assert.deepEqual([...snapshot].sort(), ['a', 's1', 's2']); +}); diff --git a/packages/plugin-dev/document-mode/src/doc-transform.ts b/packages/plugin-dev/document-mode/src/doc-transform.ts new file mode 100644 index 0000000000..f73e642cf4 --- /dev/null +++ b/packages/plugin-dev/document-mode/src/doc-transform.ts @@ -0,0 +1,363 @@ +/** + * Pure document-transform helpers for Document Mode. + * + * Everything here operates on plain ProseMirror-JSON (`PMNode`) and a task + * lookup function — no TipTap editor, no DOM, no module-global cache. That + * keeps the load-time pipeline (`prepareStoredDoc`) and the context filter + * (`isInContext`) unit-testable in plain Node; see `doc-transform.spec.ts`. + * + * The editor (`ui/editor.ts`) owns the live `taskCache` Map and passes + * `(id) => taskCache.get(id)` as the `TaskLookup` argument. + */ + +import type { ActiveWorkContext, Task } from '@super-productivity/plugin-api'; + +/** Resolves a task id to the host's current task, or `undefined` if unknown. */ +export type TaskLookup = (taskId: string) => Task | undefined; + +export type PMText = { type: 'text'; text: string }; +export type PMNode = { + type?: string; + attrs?: Record; + content?: (PMNode | PMText)[]; + text?: string; +}; + +/* -------------------------------------------------------------------------- */ +/* Node builders */ +/* -------------------------------------------------------------------------- */ + +/** + * Build a single chip node. Task title is pulled from the lookup so the + * `taskRef` / `subTaskRef` carries inline content, not just an id — a chip + * inserted without content reads back as an empty title and would be written + * back to the host as a title erasure (see `reconcileTitlesFromDoc`). + */ +export const taskNodeJSON = ( + taskId: string, + variant: 'taskRef' | 'subTaskRef', + getTask: TaskLookup, +): PMNode => { + const task = getTask(taskId); + const title = task?.title || ''; + return { + type: variant, + attrs: { taskId, isDone: !!task?.isDone }, + content: title ? [{ type: 'text', text: title }] : [], + }; +}; + +/** A parent chip followed by one `subTaskRef` per host subtask. */ +export const taskRefWithSubtasksJSON = ( + taskId: string, + getTask: TaskLookup, +): PMNode[] => { + const task = getTask(taskId); + const out: PMNode[] = [taskNodeJSON(taskId, 'taskRef', getTask)]; + for (const subId of task?.subTaskIds ?? []) { + out.push(taskNodeJSON(subId, 'subTaskRef', getTask)); + } + return out; +}; + +/** Build a fresh doc for a context that has no stored doc yet. */ +export const buildSeedDoc = (ctx: ActiveWorkContext, getTask: TaskLookup): PMNode => ({ + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 1 }, + content: [{ type: 'text', text: ctx.title }], + }, + ...ctx.taskIds.flatMap((id) => taskRefWithSubtasksJSON(id, getTask)), + { type: 'paragraph' }, + ], +}); + +/* -------------------------------------------------------------------------- */ +/* Stored-doc pipeline */ +/* -------------------------------------------------------------------------- */ + +/** + * Older docs stored taskRef as an atom node (no `content` array). Walk the + * stored JSON and populate content from the task lookup so the new + * content-bearing schema can load them. Idempotent — nodes that already have + * content are left alone. + */ +export const migrateStoredDoc = (raw: unknown, getTask: TaskLookup): unknown => { + const visit = (node: PMNode | PMText | undefined): PMNode | PMText | undefined => { + if (!node || typeof node !== 'object') return node; + if ('text' in node) return node; + if (node.type === 'taskRef' || node.type === 'subTaskRef') { + const taskId = (node.attrs?.taskId as string) || ''; + const task = getTask(taskId); + const hasContent = Array.isArray(node.content) && node.content.length > 0; + return { + ...node, + attrs: { + taskId, + isDone: (node.attrs?.isDone as boolean) ?? !!task?.isDone, + }, + content: hasContent + ? node.content + : task?.title + ? [{ type: 'text', text: task.title }] + : [], + }; + } + if (Array.isArray(node.content)) { + return { + ...node, + content: node.content + .map(visit) + .filter((n): n is PMNode | PMText => n !== undefined), + }; + } + return node; + }; + return visit(raw as PMNode); +}; + +/** A paragraph with no inline content — the editor's structural landing line. */ +const isEmptyParagraph = (n: PMNode | PMText): boolean => { + const node = n as PMNode; + return ( + node.type === 'paragraph' && + (!Array.isArray(node.content) || node.content.length === 0) + ); +}; + +/** + * Rebuild the doc against the current context. + * + * - Top-level chip ORDER comes from `ctx.taskIds` (the host's canonical + * order for the view): TODAY re-sorts daily, and reorders done in the + * regular task view must win. Stale chips (not in `ctx`) are dropped, new + * ones appended; duplicate parent groups and subtask rows are de-duped. + * - Non-chip blocks (paragraphs, headings, lists, quotes, dividers) keep + * their POSITION: each is anchored to the chip group it follows in the + * stored doc and re-emitted directly after that group; blocks before the + * first chip stay at the top. This is what stops text the user typed + * *between* two tasks from collapsing to the bottom of the doc. + * + * A block whose anchor chip has left the context loses its anchor and is + * appended after the last chip (kept, not dropped). + */ +export const reconcileTopLevelTaskRefs = ( + doc: unknown, + ctx: ActiveWorkContext, + getTask: TaskLookup, +): unknown => { + const root = doc as PMNode; + if (!root || root.type !== 'doc' || !Array.isArray(root.content)) return doc; + const src = root.content as (PMNode | PMText)[]; + + // Trailing empty paragraphs are the editor's structural landing line, not + // content. Ignore them here and re-add exactly one at the end, so they + // cannot pile up mid-doc when chips get reordered. + let end = src.length; + while (end > 0 && isEmptyParagraph(src[end - 1])) end--; + + // Pass 1: index chip groups by parent taskId (de-duping subtask rows), and + // anchor every non-chip block to the taskId of the chip group before it + // (`null` = before any chip). + const storedGroups = new Map(); + const leadingBlocks: (PMNode | PMText)[] = []; + const blocksAfter = new Map(); + let anchor: string | null = null; + + let i = 0; + while (i < end) { + const node = src[i] as PMNode; + if (node.type === 'taskRef') { + const taskId = (node.attrs?.taskId as string) || ''; + const group: PMNode[] = [node]; + const seenSubs = new Set(); + let j = i + 1; + while (j < end && (src[j] as PMNode).type === 'subTaskRef') { + const subId = ((src[j] as PMNode).attrs?.taskId as string) || ''; + if (subId && !seenSubs.has(subId)) { + seenSubs.add(subId); + group.push(src[j] as PMNode); + } + j++; + } + // First stored group for a parent wins; later duplicates are dropped. + if (taskId && !storedGroups.has(taskId)) storedGroups.set(taskId, group); + if (taskId) anchor = taskId; + i = j; + } else if (node.type === 'subTaskRef') { + // Orphan subtask (no preceding parent) — drop it. + i++; + } else { + // Any non-chip block — keep it anchored to the preceding chip group. + if (anchor === null) { + leadingBlocks.push(node); + } else { + const arr = blocksAfter.get(anchor); + if (arr) arr.push(node); + else blocksAfter.set(anchor, [node]); + } + i++; + } + } + + // Pass 2: rebuild in ctx order, re-emitting each group's anchored blocks + // straight after it. + const out: (PMNode | PMText)[] = [...leadingBlocks]; + const ctxIds = new Set(ctx.taskIds); + for (const id of ctx.taskIds) { + const group = storedGroups.get(id) ?? taskRefWithSubtasksJSON(id, getTask); + for (const n of group) out.push(n); + const after = blocksAfter.get(id); + if (after) for (const n of after) out.push(n); + } + // Blocks whose anchor chip is no longer in the context — keep, don't drop. + for (const [taskId, blocks] of blocksAfter) { + if (!ctxIds.has(taskId)) for (const n of blocks) out.push(n); + } + // Exactly one trailing empty paragraph so the cursor always has a home. + out.push({ type: 'paragraph' }); + return { ...root, content: out }; +}; + +/** + * Walk the top-level content and insert any subTaskRefs from the host that + * aren't already present right after their parent taskRef. Idempotent — + * existing subtask blocks are preserved. + */ +export const ensureSubtasksInJSON = (doc: unknown, getTask: TaskLookup): unknown => { + const root = doc as PMNode; + if (!root || root.type !== 'doc' || !Array.isArray(root.content)) return doc; + const src = root.content as (PMNode | PMText)[]; + const out: (PMNode | PMText)[] = []; + let i = 0; + while (i < src.length) { + const node = src[i]; + out.push(node); + if ((node as PMNode).type === 'taskRef') { + const parentId = ((node as PMNode).attrs?.taskId as string) || ''; + const parent = getTask(parentId); + const existing = new Set(); + let j = i + 1; + while (j < src.length && (src[j] as PMNode).type === 'subTaskRef') { + out.push(src[j]); + existing.add(((src[j] as PMNode).attrs?.taskId as string) || ''); + j++; + } + if (parent?.subTaskIds) { + for (const subId of parent.subTaskIds) { + if (!subId || existing.has(subId)) continue; + // Skip subs not known to the host yet — they would render as empty + // "ghost" rows. They show up live via onAnyTaskUpdate instead. + if (!getTask(subId)) continue; + out.push(taskNodeJSON(subId, 'subTaskRef', getTask)); + } + } + i = j; + } else { + i++; + } + } + return { ...root, content: out }; +}; + +/** + * Final pass before loading: replace each chip's inline title text and + * `isDone` attr with the current values from the task lookup. Without this, + * stored chip nodes keep the title they had at the last save and look stale + * after any external edit (host UI in another view, sync from server, app + * reload) — there is no other refresh path during context load. + * + * Trade-off: if the user was typing in a chip and switched contexts before + * the 600 ms write-back fired, those typed characters are overwritten by the + * looked-up title on return. The pending timer is already cleared in + * setActiveContext, so the host never saw that typing anyway — this just + * makes the doc match the host. + */ +export const refreshChipContentFromCache = ( + doc: unknown, + getTask: TaskLookup, +): unknown => { + const root = doc as PMNode; + if (!root || root.type !== 'doc' || !Array.isArray(root.content)) return doc; + const refreshed = (root.content as (PMNode | PMText)[]).map((n) => { + const node = n as PMNode; + if (node.type !== 'taskRef' && node.type !== 'subTaskRef') return node; + const taskId = (node.attrs?.taskId as string) || ''; + if (!taskId) return node; + const task = getTask(taskId); + if (!task) return node; + const title = task.title || ''; + return { + ...node, + attrs: { ...(node.attrs ?? {}), taskId, isDone: !!task.isDone }, + content: title ? [{ type: 'text', text: title }] : [], + }; + }); + return { ...root, content: refreshed }; +}; + +/** + * Pipeline applied to a stored doc before loading it into TipTap. Order + * matters: schema migration first (canonicalises old taskRef shapes), then + * top-level reconciliation against the current ctx (drops stale chips, + * appends new ones), then subtask backfill (inserts host subtasks under each + * kept parent), and finally a content refresh so every chip's title matches + * the current host state rather than the stored snapshot. + */ +export const prepareStoredDoc = ( + raw: unknown, + ctx: ActiveWorkContext, + getTask: TaskLookup, +): unknown => + refreshChipContentFromCache( + ensureSubtasksInJSON( + reconcileTopLevelTaskRefs(migrateStoredDoc(raw, getTask), ctx, getTask), + getTask, + ), + getTask, + ); + +/* -------------------------------------------------------------------------- */ +/* Context membership */ +/* -------------------------------------------------------------------------- */ + +/** + * Does `task` belong to the given work context? Matches the host's + * view-level filter: + * - PROJECT: task.projectId equals ctx.id + * - TODAY: task has the TODAY tag OR a dueDay OR a dueWithTime + * - TAG: task.tagIds contains ctx.id + * + * Subtasks (task.parentId set) are *not* surfaced at the top level — they + * follow their parent. + */ +export const isInContext = (task: Task, ctx: ActiveWorkContext): boolean => { + if (task.parentId) return false; + if (ctx.type === 'PROJECT') return task.projectId === ctx.id; + if (ctx.id === 'TODAY') { + return !!task.tagIds?.includes('TODAY') || !!task.dueDay || !!task.dueWithTime; + } + if (ctx.type === 'TAG') return !!task.tagIds?.includes(ctx.id); + return false; +}; + +/** + * Set of task ids that the doc for `ctx` should currently contain: every + * in-context top-level task plus the ids of its subtasks. `onAnyTaskUpdate` + * diffs successive snapshots to detect a task (or subtask) transitioning + * into the context and append a fresh chip for it. + */ +export const snapshotInContextTaskIds = ( + tasks: Iterable, + ctx: ActiveWorkContext, +): Set => { + const ids = new Set(); + for (const t of tasks) { + if (!isInContext(t, ctx)) continue; + ids.add(t.id); + for (const subId of t.subTaskIds ?? []) ids.add(subId); + } + return ids; +}; diff --git a/packages/plugin-dev/document-mode/src/icon.svg b/packages/plugin-dev/document-mode/src/icon.svg new file mode 100644 index 0000000000..bb80e1ce9b --- /dev/null +++ b/packages/plugin-dev/document-mode/src/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/plugin-dev/document-mode/src/manifest.json b/packages/plugin-dev/document-mode/src/manifest.json new file mode 100644 index 0000000000..29ece6c29e --- /dev/null +++ b/packages/plugin-dev/document-mode/src/manifest.json @@ -0,0 +1,25 @@ +{ + "id": "document-mode", + "manifestVersion": 1, + "name": "Document Mode (Alpha)", + "version": "0.1.0", + "description": "Document-style editor for projects and Today, powered by TipTap.", + "author": "SuperProductivity", + "homepage": "https://github.com/super-productivity/super-productivity", + "minSupVersion": "10.0.0", + "permissions": [ + "getTasks", + "updateTask", + "addTask", + "deleteTask", + "reorderTasks", + "selectTask", + "persistDataSynced", + "loadSyncedData", + "registerHook" + ], + "iFrame": true, + "isSkipMenuEntry": true, + "hooks": ["workContextChange", "anyTaskUpdate"], + "icon": "icon.svg" +} diff --git a/packages/plugin-dev/document-mode/src/ui/doc-nav.spec.ts b/packages/plugin-dev/document-mode/src/ui/doc-nav.spec.ts new file mode 100644 index 0000000000..1ff92bedc2 --- /dev/null +++ b/packages/plugin-dev/document-mode/src/ui/doc-nav.spec.ts @@ -0,0 +1,325 @@ +/** + * Unit tests for the pure top-level-navigation helpers. Run with + * `npm test` (see scripts/test.js) — esbuild transpiles this file and the + * built-in `node --test` runner executes it. + * + * These cover the drag / move math that was previously buried in + * `ui/editor.ts` behind the live ProseMirror editor and so had no + * coverage at all. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + childIdxAtPos, + findParentTaskIdBefore, + moveBlockTargetIdx, + positionAfterParentGroup, + sliceLenAt, + snapDropTargetIdx, + validInsertRange, + type DocLike, +} from './doc-nav'; + +/* -------------------------------------------------------------------------- */ +/* Fixtures */ +/* -------------------------------------------------------------------------- */ + +/** Spec for one top-level child in a fake doc. */ +interface ChildSpec { + name: string; + /** nodeSize — defaults to 2, varied on purpose so index ≠ position. */ + size?: number; + taskId?: string; +} + +/** + * Build a minimal `DocLike` from a list of child specs, plus a `posOf` + * helper that returns the ProseMirror start position of child `idx` + * (the sum of preceding nodeSizes). Non-uniform sizes keep the tests + * honest — a helper that confused an index for a position would pass + * with uniform size 1 and fail here. + */ +const build = ( + children: ChildSpec[], +): { doc: DocLike; posOf: (idx: number) => number } => { + const nodes = children.map((c) => ({ + type: { name: c.name }, + nodeSize: c.size ?? 2, + attrs: { taskId: c.taskId ?? '' }, + })); + const doc: DocLike = { + childCount: nodes.length, + child: (i) => { + const n = nodes[i]; + if (!n) throw new RangeError(`child ${i} out of range`); + return n; + }, + nodeAt: (pos) => { + let cursor = 0; + for (const n of nodes) { + if (cursor === pos) return n; + cursor += n.nodeSize; + } + return null; + }, + }; + const posOf = (idx: number): number => { + let p = 0; + for (let i = 0; i < idx; i++) p += nodes[i].nodeSize; + return p; + }; + return { doc, posOf }; +}; + +// Shorthand child specs. +const para = (size = 2): ChildSpec => ({ name: 'paragraph', size }); +const task = (taskId: string, size = 3): ChildSpec => ({ + name: 'taskRef', + taskId, + size, +}); +const sub = (taskId: string, size = 3): ChildSpec => ({ + name: 'subTaskRef', + taskId, + size, +}); + +/* -------------------------------------------------------------------------- */ +/* childIdxAtPos */ +/* -------------------------------------------------------------------------- */ + +test('childIdxAtPos: returns the index whose start position matches', () => { + const { doc, posOf } = build([para(), task('a'), sub('a1'), para()]); + assert.equal(childIdxAtPos(doc, posOf(0)), 0); + assert.equal(childIdxAtPos(doc, posOf(1)), 1); + assert.equal(childIdxAtPos(doc, posOf(2)), 2); + assert.equal(childIdxAtPos(doc, posOf(3)), 3); +}); + +test('childIdxAtPos: returns -1 for a position inside a node', () => { + const { doc } = build([para(4), task('a', 6)]); + // Position 1 falls inside the first child (which spans 0..3). + assert.equal(childIdxAtPos(doc, 1), -1); +}); + +test('childIdxAtPos: returns -1 past the end and for an empty doc', () => { + const { doc, posOf } = build([para(), task('a')]); + assert.equal(childIdxAtPos(doc, posOf(2)), -1); // == doc end + assert.equal(childIdxAtPos(doc, 999), -1); + assert.equal(childIdxAtPos(build([]).doc, 0), -1); +}); + +/* -------------------------------------------------------------------------- */ +/* sliceLenAt */ +/* -------------------------------------------------------------------------- */ + +test('sliceLenAt: a plain block is a slice of 1', () => { + const { doc } = build([para(), task('a'), para()]); + assert.equal(sliceLenAt(doc, 0), 1); + assert.equal(sliceLenAt(doc, 2), 1); +}); + +test('sliceLenAt: a childless taskRef is a slice of 1', () => { + const { doc } = build([task('a'), task('b')]); + assert.equal(sliceLenAt(doc, 0), 1); +}); + +test('sliceLenAt: a taskRef bundles its trailing subTaskRefs', () => { + const { doc } = build([task('a'), sub('a1'), sub('a2'), task('b')]); + assert.equal(sliceLenAt(doc, 0), 3); + assert.equal(sliceLenAt(doc, 3), 1); +}); + +test('sliceLenAt: a lone subTaskRef is a slice of 1', () => { + const { doc } = build([task('a'), sub('a1')]); + assert.equal(sliceLenAt(doc, 1), 1); +}); + +test('sliceLenAt: an out-of-range index yields 1', () => { + const { doc } = build([task('a')]); + assert.equal(sliceLenAt(doc, -1), 1); + assert.equal(sliceLenAt(doc, 5), 1); +}); + +/* -------------------------------------------------------------------------- */ +/* findParentTaskIdBefore */ +/* -------------------------------------------------------------------------- */ + +test('findParentTaskIdBefore: resolves the owning parent of a subtask', () => { + const { doc, posOf } = build([task('a'), sub('a1'), sub('a2')]); + assert.equal(findParentTaskIdBefore(doc, posOf(1)), 'a'); + assert.equal(findParentTaskIdBefore(doc, posOf(2)), 'a'); +}); + +test('findParentTaskIdBefore: a subtask with no preceding parent is an orphan', () => { + const { doc, posOf } = build([para(), sub('x1')]); + assert.equal(findParentTaskIdBefore(doc, posOf(1)), null); +}); + +test('findParentTaskIdBefore: a subtask at index 0 has no parent', () => { + const { doc, posOf } = build([sub('x1'), task('a')]); + assert.equal(findParentTaskIdBefore(doc, posOf(0)), null); +}); + +test('findParentTaskIdBefore: an unaligned position resolves to null', () => { + const { doc } = build([task('a', 4), sub('a1', 4)]); + assert.equal(findParentTaskIdBefore(doc, 2), null); +}); + +/* -------------------------------------------------------------------------- */ +/* positionAfterParentGroup */ +/* -------------------------------------------------------------------------- */ + +test('positionAfterParentGroup: lands just past a childless parent', () => { + const { doc, posOf } = build([task('a', 5), task('b', 5)]); + assert.equal(positionAfterParentGroup(doc, posOf(0)), posOf(1)); +}); + +test('positionAfterParentGroup: skips past the parent and all its subtasks', () => { + const { doc, posOf } = build([task('a', 5), sub('a1', 4), sub('a2', 6), para()]); + assert.equal(positionAfterParentGroup(doc, posOf(0)), posOf(3)); +}); + +test('positionAfterParentGroup: a non-taskRef position is returned unchanged', () => { + const { doc, posOf } = build([para(), task('a')]); + assert.equal(positionAfterParentGroup(doc, posOf(0)), posOf(0)); +}); + +test('positionAfterParentGroup: an unaligned position is returned unchanged', () => { + const { doc } = build([task('a', 4)]); + assert.equal(positionAfterParentGroup(doc, 2), 2); +}); + +/* -------------------------------------------------------------------------- */ +/* validInsertRange */ +/* -------------------------------------------------------------------------- */ + +test('validInsertRange: a subtask may move anywhere inside its group', () => { + const { doc, posOf } = build([task('a'), sub('a1'), sub('a2'), sub('a3'), para()]); + // Dragging the middle subtask: gaps 1..4 keep it inside the group. + assert.deepEqual(validInsertRange(doc, posOf(2)), { min: 1, max: 4 }); +}); + +test('validInsertRange: null when the dragged node is not a subTaskRef', () => { + const { doc, posOf } = build([task('a'), sub('a1')]); + assert.equal(validInsertRange(doc, posOf(0)), null); +}); + +test('validInsertRange: null for an orphan subtask with no parent', () => { + const { doc, posOf } = build([para(), sub('x1')]); + assert.equal(validInsertRange(doc, posOf(1)), null); +}); + +test('validInsertRange: null for an unaligned position', () => { + const { doc } = build([task('a', 4), sub('a1', 4)]); + assert.equal(validInsertRange(doc, 2), null); +}); + +/* -------------------------------------------------------------------------- */ +/* moveBlockTargetIdx */ +/* -------------------------------------------------------------------------- */ + +test('moveBlockTargetIdx: a plain block steps up and down by one', () => { + const { doc } = build([para(), para(), para()]); + // The result is an insertion-gap index: the dragged slice is removed + // before re-insertion, so a one-step move down lands past the next + // block (gap `sliceEnd + 1`), not at the next block's index. + assert.equal(moveBlockTargetIdx(doc, 1, 'up'), 0); + assert.equal(moveBlockTargetIdx(doc, 1, 'down'), 3); +}); + +test('moveBlockTargetIdx: no-op moving the first block up / last block down', () => { + const { doc } = build([para(), para()]); + assert.equal(moveBlockTargetIdx(doc, 0, 'up'), null); + assert.equal(moveBlockTargetIdx(doc, 1, 'down'), null); +}); + +test('moveBlockTargetIdx: a subtask steps within its group', () => { + const { doc } = build([task('a'), sub('a1'), sub('a2'), sub('a3')]); + assert.equal(moveBlockTargetIdx(doc, 2, 'up'), 1); + assert.equal(moveBlockTargetIdx(doc, 2, 'down'), 4); +}); + +test('moveBlockTargetIdx: a subtask never crosses the parent boundary', () => { + const { doc } = build([task('a'), sub('a1'), sub('a2'), task('b')]); + // First subtask up would escape above the parent — no-op. + assert.equal(moveBlockTargetIdx(doc, 1, 'up'), null); + // Last subtask down would escape into the next group — no-op. + assert.equal(moveBlockTargetIdx(doc, 2, 'down'), null); +}); + +test('moveBlockTargetIdx: a parent group jumps the previous group going up', () => { + const { doc } = build([task('a'), sub('a1'), task('b')]); + // Moving b up lands it before the whole [a, a1] group. + assert.equal(moveBlockTargetIdx(doc, 2, 'up'), 0); +}); + +test('moveBlockTargetIdx: a parent group jumps the next group going down', () => { + const { doc } = build([task('a'), sub('a1'), task('b'), sub('b1')]); + // Moving the [a, a1] group down lands it past the whole [b, b1] group. + assert.equal(moveBlockTargetIdx(doc, 0, 'down'), 4); +}); + +test('moveBlockTargetIdx: no-op when a parent group is already last', () => { + const { doc } = build([task('a'), task('b'), sub('b1')]); + assert.equal(moveBlockTargetIdx(doc, 1, 'down'), null); +}); + +test('moveBlockTargetIdx: null for an out-of-range index', () => { + const { doc } = build([para()]); + assert.equal(moveBlockTargetIdx(doc, 9, 'up'), null); +}); + +/* -------------------------------------------------------------------------- */ +/* snapDropTargetIdx */ +/* -------------------------------------------------------------------------- */ + +test('snapDropTargetIdx: a plain-block drag is returned unchanged', () => { + const { doc } = build([para(), task('a'), sub('a1')]); + const idx = snapDropTargetIdx(doc, 1, { + sourceType: 'other', + nodePos: 0, + fromIdx: 0, + sliceLen: 1, + }); + assert.equal(idx, 1); +}); + +test('snapDropTargetIdx: a subtask drag is clamped into its group', () => { + const { doc, posOf } = build([task('a'), sub('a1'), sub('a2'), para()]); + const drag = { + sourceType: 'subTaskRef' as const, + nodePos: posOf(1), + fromIdx: 1, + sliceLen: 1, + }; + // Raw targets outside [1, 3] snap back to the group bounds. + assert.equal(snapDropTargetIdx(doc, 0, drag), 1); + assert.equal(snapDropTargetIdx(doc, 9, drag), 3); + assert.equal(snapDropTargetIdx(doc, 2, drag), 2); +}); + +test('snapDropTargetIdx: a parent drag advances past a foreign subtask run', () => { + const { doc, posOf } = build([task('b'), sub('b1'), task('a'), para()]); + const drag = { + sourceType: 'taskRef' as const, + nodePos: posOf(2), + fromIdx: 2, + sliceLen: 1, + }; + // A raw target of 1 (between b and its subtask) is illegal — snap past it. + assert.equal(snapDropTargetIdx(doc, 1, drag), 2); +}); + +test('snapDropTargetIdx: a parent drag does not skip its own subtasks', () => { + const { doc, posOf } = build([task('a'), sub('a1'), sub('a2'), para()]); + const drag = { + sourceType: 'taskRef' as const, + nodePos: posOf(0), + fromIdx: 0, + sliceLen: 3, + }; + // Target 1 sits inside the dragged slice's own subtasks — left as-is + // (the caller treats an in-slice target as a no-op). + assert.equal(snapDropTargetIdx(doc, 1, drag), 1); +}); diff --git a/packages/plugin-dev/document-mode/src/ui/doc-nav.ts b/packages/plugin-dev/document-mode/src/ui/doc-nav.ts new file mode 100644 index 0000000000..cfa64af71f --- /dev/null +++ b/packages/plugin-dev/document-mode/src/ui/doc-nav.ts @@ -0,0 +1,216 @@ +/** + * Pure top-level-navigation helpers for the Document Mode editor. + * + * Every function here operates on a `DocLike` — the minimal structural + * slice of a ProseMirror document node that the drag / move / keyboard + * logic actually touches. A real ProseMirror `Node` satisfies `DocLike` + * structurally, so `ui/editor.ts` passes `editor.state.doc` straight in; + * `doc-nav.spec.ts` passes hand-built plain objects. No TipTap, no DOM, + * no module-global `editor` — that is what makes this file testable. + * + * "Top-level child" everywhere means a direct child of the doc node + * (a chip group's `taskRef` / `subTaskRef`, or a paragraph / heading / + * list / divider). Positions are ProseMirror document positions. + */ + +/** The slice of a ProseMirror node these helpers read. */ +export interface DocNodeLike { + readonly type: { readonly name: string }; + readonly nodeSize: number; + readonly attrs?: Record; +} + +/** The slice of a ProseMirror doc node these helpers read. */ +export interface DocLike { + readonly childCount: number; + child(index: number): DocNodeLike; + nodeAt(pos: number): DocNodeLike | null; +} + +/** Source of a grip drag — a parent chip, a subtask chip, or a plain block. */ +export type DragSourceType = 'taskRef' | 'subTaskRef' | 'other'; + +/** Top-level child index whose start position equals `pos`, or -1. */ +export const childIdxAtPos = (doc: DocLike, pos: number): number => { + let cursor = 0; + for (let i = 0; i < doc.childCount; i++) { + if (cursor === pos) return i; + cursor += doc.child(i).nodeSize; + } + return -1; +}; + +/** + * Number of contiguous top-level children that move as one atomic unit + * starting at `idx`. A parent `taskRef` bundles its trailing `subTaskRef` + * children — moving the parent out from under its subtasks would orphan + * them. Every other block is a slice of length 1. + */ +export const sliceLenAt = (doc: DocLike, idx: number): number => { + if (idx < 0 || idx >= doc.childCount) return 1; + if (doc.child(idx).type.name !== 'taskRef') return 1; + let end = idx + 1; + while (end < doc.childCount && doc.child(end).type.name === 'subTaskRef') end++; + return end - idx; +}; + +/** + * Walk backwards from the top-level child at `subTaskRefPos` to the + * `taskRef` that owns it; return its taskId, or null for an orphan + * subtask (no preceding parent). + * + * Uses manual index iteration rather than `doc.resolve(pos).index(0)` — + * the latter's gap-vs-node semantics at top-level boundaries were flagged + * as mis-resolving for certain positions. Iterating is provably correct. + */ +export const findParentTaskIdBefore = ( + doc: DocLike, + subTaskRefPos: number, +): string | null => { + const subIdx = childIdxAtPos(doc, subTaskRefPos); + if (subIdx < 0) return null; + for (let i = subIdx - 1; i >= 0; i--) { + const child = doc.child(i); + if (child.type.name === 'taskRef') return (child.attrs?.taskId as string) || null; + if (child.type.name === 'subTaskRef') continue; + return null; + } + return null; +}; + +/** + * Position immediately after the parent group at `parentNodePos` — past + * the parent `taskRef` and any `subTaskRef`s that follow it. Used so Enter + * at the end of a parent inserts the next sibling after its subtasks, not + * between parent and first child. Returns `parentNodePos` unchanged if it + * does not point at a taskRef. + */ +export const positionAfterParentGroup = (doc: DocLike, parentNodePos: number): number => { + const idx = childIdxAtPos(doc, parentNodePos); + if (idx < 0 || doc.child(idx).type.name !== 'taskRef') return parentNodePos; + let end = parentNodePos + doc.child(idx).nodeSize; + let j = idx + 1; + while (j < doc.childCount && doc.child(j).type.name === 'subTaskRef') { + end += doc.child(j).nodeSize; + j++; + } + return end; +}; + +/** + * Range of valid insertion-gap indices for a dragging `subTaskRef` so it + * stays inside its parent's subtask group. Returns null when `draggingPos` + * is not a subTaskRef or has no owning parent. + */ +export const validInsertRange = ( + doc: DocLike, + draggingPos: number, +): { min: number; max: number } | null => { + const dragNode = doc.nodeAt(draggingPos); + if (!dragNode || dragNode.type.name !== 'subTaskRef') return null; + const dragIdx = childIdxAtPos(doc, draggingPos); + if (dragIdx < 0) return null; + let parentIdx = -1; + for (let i = dragIdx - 1; i >= 0; i--) { + const c = doc.child(i); + if (c.type.name === 'taskRef') { + parentIdx = i; + break; + } + if (c.type.name !== 'subTaskRef') return null; + } + if (parentIdx < 0) return null; + let end = parentIdx + 1; + while (end < doc.childCount && doc.child(end).type.name === 'subTaskRef') end++; + return { min: parentIdx + 1, max: end }; +}; + +/** + * Target insertion-gap index for a block-menu "Move up" / "Move down". + * Returns null when the move is a no-op (already at a boundary it must + * not cross). + * + * - subTaskRef: one step within the parent's group, never crossing the + * parent boundary. + * - taskRef: the whole parent group jumps past the previous / next group. + * - any other block: a plain single-step swap. + */ +export const moveBlockTargetIdx = ( + doc: DocLike, + idx: number, + direction: 'up' | 'down', +): number | null => { + if (idx < 0 || idx >= doc.childCount) return null; + const src = doc.child(idx); + const sliceLen = sliceLenAt(doc, idx); + + if (direction === 'up') { + if (idx === 0) return null; + if (src.type.name === 'subTaskRef') { + // Stop at the parent boundary — never escape the group. + if (doc.child(idx - 1).type.name !== 'subTaskRef') return null; + return idx - 1; + } + // Walk past subtask siblings to the start of the previous group. + let prev = idx - 1; + while (prev > 0 && doc.child(prev).type.name === 'subTaskRef') prev--; + return prev; + } + + const sliceEnd = idx + sliceLen; + if (sliceEnd >= doc.childCount) return null; + if (src.type.name === 'subTaskRef') { + if (doc.child(sliceEnd).type.name !== 'subTaskRef') return null; + return sliceEnd + 1; + } + // Walk past the next group's parent + its trailing subtasks. + let groupEnd = sliceEnd + 1; + while (groupEnd < doc.childCount && doc.child(groupEnd).type.name === 'subTaskRef') { + groupEnd++; + } + return groupEnd; +}; + +/** A grip drag in progress, as far as drop-target snapping is concerned. */ +export interface DragSnapInfo { + sourceType: DragSourceType; + /** Doc position of the dragged node (subTaskRef group lookup). */ + nodePos: number; + /** Top-level index the slice started at. */ + fromIdx: number; + /** Number of children in the dragged slice. */ + sliceLen: number; +} + +/** + * Snap a raw drop-target index (from pointer-Y hit-testing) to a legal + * landing gap for the dragging slice: + * + * - subTaskRef: clamped inside its parent's subtask group. + * - taskRef: advanced past any foreign `subTaskRef` run, so the group + * never lands between someone else's parent and their first subtask. + * - other: returned unchanged. + */ +export const snapDropTargetIdx = ( + doc: DocLike, + rawTargetIdx: number, + drag: DragSnapInfo, +): number => { + if (drag.sourceType === 'subTaskRef') { + const range = validInsertRange(doc, drag.nodePos); + if (!range) return rawTargetIdx; + return Math.max(range.min, Math.min(rawTargetIdx, range.max)); + } + if (drag.sourceType === 'taskRef') { + let targetIdx = rawTargetIdx; + while ( + targetIdx < doc.childCount && + doc.child(targetIdx).type.name === 'subTaskRef' && + !(targetIdx >= drag.fromIdx && targetIdx < drag.fromIdx + drag.sliceLen) + ) { + targetIdx++; + } + return targetIdx; + } + return rawTargetIdx; +}; diff --git a/packages/plugin-dev/document-mode/src/ui/editor.ts b/packages/plugin-dev/document-mode/src/ui/editor.ts new file mode 100644 index 0000000000..0d1b378881 --- /dev/null +++ b/packages/plugin-dev/document-mode/src/ui/editor.ts @@ -0,0 +1,2067 @@ +/** + * Document-Mode editor — runs inside the plugin iframe. Notion-style UX: + * inline bubble menu on text selection, block hover gutter with insert + * (`+`) and grip (`⋮⋮`) buttons, slash menu for inserts and turn-into, + * and a custom taskRef atom node tied to Super Productivity tasks. + */ + +import { Editor } from '@tiptap/core'; +import type { Node as ProseMirrorNode } from '@tiptap/pm/model'; +import { NodeSelection } from '@tiptap/pm/state'; +import StarterKit from '@tiptap/starter-kit'; +import Placeholder from '@tiptap/extension-placeholder'; +import BubbleMenu from '@tiptap/extension-bubble-menu'; +import { + PluginHooks, + type ActiveWorkContext, + type AnyTaskUpdatePayload, + type PluginAPI, + type Task, + type WorkContextChangePayload, +} from '@super-productivity/plugin-api'; +import { + buildSeedDoc, + prepareStoredDoc, + snapshotInContextTaskIds, + taskNodeJSON, + taskRefWithSubtasksJSON, + type TaskLookup, +} from '../doc-transform'; +import { iconSvg } from './icons'; +import * as docNav from './doc-nav'; +import { createTaskRefNode, type TaskRefNodeDeps } from './task-ref-node'; + +declare const PluginAPI: PluginAPI; + +// Save cadence. This is a *throttle*, not a debounce: the host tears the +// embed iframe down on every work-context switch, so a save deferred until +// the user goes idle (or until teardown) routinely never runs. Committing +// every SAVE_THROTTLE_MS while editing keeps the doc blob fresh in host +// storage while the iframe is still alive. Kept above the host's 1 s +// persist rate limit (MIN_PLUGIN_PERSIST_INTERVAL_MS) so saves aren't +// rejected. +const SAVE_THROTTLE_MS = 2_000; +const STORAGE_VERSION = 1; + +// Action type the host emits for an in-place single-task update (NgRx +// `createActionGroup`, source 'Task Shared'). `onAnyTaskUpdate` uses it to +// fast-path the common edit case past a full `getTasks()` round-trip. A +// drift in this string degrades safely — the fast path is skipped and the +// always-correct full refresh runs instead. +const UPDATE_TASK_ACTION = '[Task Shared] Update Task'; + +interface StoredState { + version: number; + docs: Record; + [key: string]: unknown; // preserve fields owned by background script +} + +let currentCtx: ActiveWorkContext | null = null; +let storedState: StoredState = { version: STORAGE_VERSION, docs: {} }; +let taskCache = new Map(); +/** + * Stable task lookup handed to the pure `doc-transform` helpers. Defined as + * an arrow (not `taskCache.get.bind`) so it always reads the *current* + * `taskCache` binding — `refreshTaskCache` reassigns the Map wholesale. + */ +const lookupTask: TaskLookup = (id) => taskCache.get(id); +let saveTimer: ReturnType | null = null; +let editor: Editor | null = null; +let isLoadingDoc = false; +// Set when the stored doc for the current ctx failed to parse and we fell +// back to an empty seed. Gates scheduleSave so the empty seed is not +// auto-persisted on top of the original (possibly corrupt) blob. +let isDocCorrupt = false; +// True when the *whole* stored blob is in a schema version we don't +// understand (a user synced from a newer build). Gates scheduleSave so +// we never downgrade-overwrite the original blob with our empty fallback. +let isStorageUnreadable = false; +// Monotonic guard for setActiveContext: concurrent calls (rapid context +// switches) read this to drop after their awaits if a newer call has +// superseded them. +let activeContextSeq = 0; +// Snapshot of cached task ids at the last `setActiveContext` / external +// task update, used by `onAnyTaskUpdate` to detect *genuinely new* tasks +// (transition absent→present) and avoid re-appending chips the user has +// already removed. +let lastSeenTaskIds = new Set(); + +/** + * Safe error log: PluginAPI.log is declared on the type but currently not + * wired up in the iframe runtime (see plugin-iframe.util.ts). Calling + * PluginAPI.log.err crashes inside Promise catch handlers, which then + * surfaces as the user-visible "Cannot read properties of undefined + * (reading 'err')". Use this helper everywhere instead. + */ +const logErr = (msg: string, err?: unknown): void => { + try { + (PluginAPI as { log?: { err?: (...args: unknown[]) => void } }).log?.err?.(msg, err); + } catch { + // ignore — fall through to console + } + // eslint-disable-next-line no-console + console.error('[document-mode]', msg, err); +}; + +/** + * Tolerant deleteTask: a stale subTaskRef may point at a task that no + * longer exists in the host (deleted via sync, or never persisted). In + * that case the host rejects with "Task data not found", which is fine — + * the user already removed the chip locally. Swallow that specific case; + * still log anything else. + */ +const deleteTaskTolerant = async (taskId: string): Promise => { + if (!taskCache.has(taskId)) return; + try { + await PluginAPI.deleteTask(taskId); + } catch (err) { + const msg = (err as Error)?.message ?? String(err); + if (/not found/i.test(msg)) return; + logErr('deleteTask failed', err); + } +}; + +/* -------------------------------------------------------------------------- */ +/* taskRef node */ +/* -------------------------------------------------------------------------- */ + +/** + * taskRef is a content-bearing block node — its inline content IS the task + * title, so typing inside it edits the linked task. We debounce write-back + * to PluginAPI.updateTask and reconcile against ANY_TASK_UPDATE events + * from the host without clobbering an active edit. + */ +/** + * Seed `taskCache` with a freshly-created task so its chip renders (and is + * editable) immediately, without waiting for a full `getTasks()` round-trip. + * The host's ANY_TASK_UPDATE echo for the add reconciles the real entity + * shortly after; this only bridges the gap so fast typing isn't dropped. + */ +const seedTaskCache = ( + taskId: string, + title: string, + ctx: ActiveWorkContext, + parentId: string | null = null, +): void => { + if (taskCache.has(taskId)) return; + taskCache.set(taskId, { + id: taskId, + title, + isDone: false, + projectId: ctx.type === 'PROJECT' ? ctx.id : null, + parentId, + tagIds: [], + subTaskIds: [], + timeEstimate: 0, + timeSpent: 0, + created: Date.now(), + }); +}; + +/** + * Helper invoked from keyboard shortcuts to create a new empty task and + * insert a taskRef pointing at it. Hoisted so keybindings inside + * TaskRefNode can call it without needing the editor closure variable + * (which isn't initialised at extension creation time). + */ +const createTaskAfter = async (insertPos: number): Promise => { + if (!editor || !currentCtx) return; + const ctx = currentCtx; + try { + const taskId = await PluginAPI.addTask({ + title: '', + projectId: ctx.type === 'PROJECT' ? ctx.id : null, + }); + seedTaskCache(taskId, '', ctx); + editor + .chain() + .focus() + .insertContentAt(insertPos, { + type: 'taskRef', + attrs: { taskId, isDone: false }, + content: [], + }) + .run(); + // Cursor lands inside the new chip's empty title; refresh selection. + editor.commands.focus(insertPos + 1); + } catch (err) { + logErr('createTaskAfter failed', err); + } +}; + +/** + * Sibling of createTaskAfter — creates a subtask under `parentTaskId` and + * inserts a subTaskRef block at insertPos. Used by the subtask Enter handler. + */ +const createSubTaskAfter = async ( + insertPos: number, + parentTaskId: string, +): Promise => { + if (!editor || !currentCtx) return; + const ctx = currentCtx; + try { + const taskId = await PluginAPI.addTask({ + title: '', + parentId: parentTaskId, + projectId: ctx.type === 'PROJECT' ? ctx.id : null, + }); + seedTaskCache(taskId, '', ctx, parentTaskId); + editor + .chain() + .focus() + .insertContentAt(insertPos, { + type: 'subTaskRef', + attrs: { taskId, isDone: false }, + content: [], + }) + .run(); + editor.commands.focus(insertPos + 1); + } catch (err) { + logErr('createSubTaskAfter failed', err); + } +}; + +/* -------------------------------------------------------------------------- */ +/* Persistence */ +/* -------------------------------------------------------------------------- */ + +const readBlob = async (): Promise => { + try { + const raw = await PluginAPI.loadSyncedData(); + if (!raw) return { version: STORAGE_VERSION, docs: {} }; + const parsed = JSON.parse(raw) as StoredState; + if (parsed && typeof parsed === 'object') { + // Future-version guard: if we encounter a blob whose schema is + // ahead of what this build understands (e.g. user synced from a + // newer release), don't pretend we can read it — return an empty + // shell. flushSave is gated by isDocCorrupt (see setActiveContext) + // so we won't clobber the original on disk. + const parsedVersion = Number(parsed.version) || STORAGE_VERSION; + if (parsedVersion > STORAGE_VERSION) { + // Refuse to load AND gate saves so we don't overwrite the + // user's newer blob with our empty fallback. Recovery happens + // when the user updates to a build that understands the + // newer schema. + isStorageUnreadable = true; + logErr( + `Stored doc-mode blob is version ${parsedVersion}; this build understands ${STORAGE_VERSION}. Refusing to load.`, + ); + return { version: STORAGE_VERSION, docs: {} }; + } + isStorageUnreadable = false; + return { + ...parsed, + version: parsedVersion, + docs: parsed.docs || {}, + }; + } + } catch (err) { + logErr('Failed to parse stored doc state', err); + } + return { version: STORAGE_VERSION, docs: {} }; +}; + +const loadStoredState = async (): Promise => { + storedState = await readBlob(); +}; + +const flushSave = async (): Promise => { + if (saveTimer !== null) { + clearTimeout(saveTimer); + saveTimer = null; + } + if (!currentCtx || !editor) return; + try { + const latest = await readBlob(); + const merged: StoredState = { + ...latest, + docs: { ...latest.docs, [currentCtx.id]: editor.getJSON() }, + }; + storedState = merged; + await PluginAPI.persistDataSynced(JSON.stringify(merged)); + } catch (err) { + logErr('persistDataSynced failed', err); + } +}; + +/** + * Teardown-safe save. The work-context embed iframe is destroyed + * *synchronously* whenever the active context changes (the host's work-view + * drops `` from the DOM while the context is switching), so the + * async `flushSave` is unusable on the way out: its `await readBlob()` never + * receives a reply — the iframe is gone before the host responds — and the + * `persistDataSynced` call after it never runs. The doc blob is then lost, and + * because top-level chips are rebuilt from the host's task list on reload, the + * loss only shows as vanished *text* blocks (paragraphs, headings, dividers). + * + * This variant skips the round-trip: it builds the blob from the in-memory + * `storedState` and dispatches `persistDataSynced` synchronously, so the + * postMessage leaves the iframe before it dies. + * + * Trade-off: an `enabledCtxIds` change made by background.ts since our last + * `readBlob` would be written back stale. That field only changes on an + * explicit doc-mode toggle — which itself tears this iframe down — so the + * window is effectively nil, and losing the whole doc is the worse outcome. + */ +const flushSaveSync = (): void => { + if (saveTimer !== null) { + clearTimeout(saveTimer); + saveTimer = null; + } + if (!currentCtx || !editor) return; + // Same guard as scheduleSave — never overwrite a blob we couldn't read. + if (isDocCorrupt || isStorageUnreadable) return; + try { + const merged: StoredState = { + ...storedState, + docs: { ...storedState.docs, [currentCtx.id]: editor.getJSON() }, + }; + storedState = merged; + void PluginAPI.persistDataSynced(JSON.stringify(merged)); + } catch (err) { + logErr('persistDataSynced (sync flush) failed', err); + } +}; + +const scheduleSave = (): void => { + if (isLoadingDoc) return; + // Refuse to persist while the doc is a fallback (loaded from a blob we + // couldn't parse, or a future-version blob we don't understand). Saving + // here would overwrite the original blob with our empty seed. + if (isDocCorrupt || isStorageUnreadable) return; + // Throttle: if a save is already pending, leave it — do NOT reschedule. + // A debounce (reset-on-every-keystroke) would never fire for a continuous + // typist, and the iframe can be torn down at any moment. This guarantees + // a save lands at most SAVE_THROTTLE_MS after the first unsaved change. + if (saveTimer !== null) return; + saveTimer = setTimeout(() => { + saveTimer = null; + void flushSave(); + }, SAVE_THROTTLE_MS); +}; + +/* -------------------------------------------------------------------------- */ +/* Seed + task sync */ +/* -------------------------------------------------------------------------- */ + +const refreshTaskCache = async (): Promise => { + try { + const tasks = await PluginAPI.getTasks(); + taskCache = new Map(tasks.map((t) => [t.id, t])); + } catch (err) { + logErr('getTasks failed', err); + } +}; + +/* -------------------------------------------------------------------------- */ +/* Document-status banner */ +/* -------------------------------------------------------------------------- */ + +// Visible notice shown when the stored doc could not be loaded. Without it a +// corrupt / future-version blob renders as a blank editor, which reads as +// silent data loss — the banner makes clear the data is safe and untouched. +let bannerEl: HTMLDivElement | null = null; + +const updateDocStatusBanner = (): void => { + const message = isStorageUnreadable + ? 'This document was saved by a newer version of Super Productivity. ' + + 'It is shown read-only and your data is left untouched — update the ' + + 'app to edit it here.' + : isDocCorrupt + ? 'This document could not be loaded, so a blank one is shown. Your ' + + 'saved data is untouched; editing is disabled here to protect it.' + : null; + if (!message) { + bannerEl?.remove(); + bannerEl = null; + return; + } + if (!bannerEl) { + bannerEl = document.createElement('div'); + bannerEl.className = 'doc-banner'; + bannerEl.setAttribute('role', 'status'); + document.body.insertBefore(bannerEl, document.body.firstChild); + } + bannerEl.textContent = message; +}; + +const setActiveContext = async (ctx: ActiveWorkContext | null): Promise => { + // Take a sequence number for this invocation. If a newer call arrives + // (rapid context switches), the older one bails after each await so it + // can't write the previous editor doc under the new context's id. + const seq = ++activeContextSeq; + // Synchronous flush: a context change tears this iframe down right away, + // so an awaited save would not survive long enough to persist. + flushSaveSync(); + if (seq !== activeContextSeq) return; + + // Drop pending title writes from the previous context — letting them + // resolve later would mutate `taskCache` against tasks the new context + // may not even own. + for (const t of titleWriteTimers.values()) clearTimeout(t); + titleWriteTimers.clear(); + pendingTitleWrites.clear(); + lastWrittenTitles.clear(); + + // Drop any pending chip-reorder write-back — it targets the old context. + cancelPendingReorder(); + + currentCtx = ctx; + isDocCorrupt = false; + if (!ctx || !editor) return; + + isLoadingDoc = true; + await refreshTaskCache(); + if (seq !== activeContextSeq) { + isLoadingDoc = false; + return; + } + // Snapshot of "tasks already in this context" — onAnyTaskUpdate compares + // future events against this to detect transitions into the context + // (a task gaining the TODAY tag, a dueDay being set, etc.). + lastSeenTaskIds = snapshotInContextTaskIds(taskCache.values(), ctx); + + const stored = storedState.docs[ctx.id]; + const docJson = stored + ? prepareStoredDoc(stored, ctx, lookupTask) + : buildSeedDoc(ctx, lookupTask); + try { + editor.commands.setContent( + docJson as Parameters[0], + false, + ); + } catch (err) { + // Parsing the stored blob failed. Don't auto-save the fallback — + // scheduleSave is gated by isDocCorrupt so the empty seed cannot + // overwrite the (possibly recoverable) original. + logErr('setContent failed; suppressing saves to protect blob', err); + isDocCorrupt = true; + editor.commands.setContent( + buildSeedDoc(ctx, lookupTask) as Parameters[0], + false, + ); + } + updateDocStatusBanner(); + isLoadingDoc = false; +}; + +const isTaskNode = (name: string): name is 'taskRef' | 'subTaskRef' => + name === 'taskRef' || name === 'subTaskRef'; + +/** + * Toggle the done state of a task: write it back to the host and optimistically + * reflect it on every matching chip's `isDone` attr so the checkmark updates + * immediately (and the change rides the undo stack) without waiting for the + * host's `ANY_TASK_UPDATE` echo. Shared by the chip done-toggle and the + * Mod-Enter keyboard shortcut. + */ +const toggleTaskDone = (taskId: string): void => { + if (!editor) return; + const task = taskCache.get(taskId); + if (!task) return; + const next = !task.isDone; + PluginAPI.updateTask(taskId, { isDone: next }).catch((err) => { + logErr('updateTask failed', err); + }); + taskCache.set(taskId, { ...task, isDone: next }); + const tr = editor.state.tr; + editor.state.doc.descendants((node, pos) => { + if (isTaskNode(node.type.name) && node.attrs.taskId === taskId) { + tr.setNodeAttribute(pos, 'isDone', next); + return false; + } + return undefined; + }); + if (tr.docChanged) editor.view.dispatch(tr); +}; + +/** + * taskId of the chip the caret currently sits in, or null. Used by the + * Mod-Enter shortcut; gated on `editor.isFocused` so the shortcut only fires + * when the editor genuinely owns the selection. + */ +const currentChipTaskId = (): string | null => { + if (!editor || !editor.isFocused) return null; + const parent = editor.state.selection.$from.parent; + if (isTaskNode(parent.type.name)) { + return (parent.attrs.taskId as string) || null; + } + return null; +}; + +const collectKnownTaskIds = (): Set => { + const ids = new Set(); + if (!editor) return ids; + editor.state.doc.descendants((node: ProseMirrorNode): boolean | undefined => { + if (isTaskNode(node.type.name) && node.attrs.taskId) { + ids.add(node.attrs.taskId as string); + } + return undefined; + }); + return ids; +}; + +const appendMissingTask = (taskId: string): void => { + if (!editor) return; + if (collectKnownTaskIds().has(taskId)) return; + // Subtasks should be inserted next to their parent, not at the doc end. + const task = taskCache.get(taskId); + if (task?.parentId) { + insertSubtaskByParent(taskId, task.parentId); + return; + } + // Insert the parent chip *with* its title and any subtasks. Inserting a + // bare `{ type: 'taskRef' }` leaves the chip with empty inline content, + // which `reconcileTitlesFromDoc` then writes back to the host as a title + // erasure on the next `onUpdate`. + const endPos = editor.state.doc.content.size; + editor + .chain() + .focus(endPos) + .insertContentAt( + endPos, + taskRefWithSubtasksJSON(taskId, lookupTask) as Parameters< + typeof editor.commands.insertContentAt + >[1], + ) + .run(); +}; + +/** + * Insert a subTaskRef right after the parent's group (parent taskRef + + * any existing subTaskRefs). No-op if the parent is not in the doc. + */ +const insertSubtaskByParent = (taskId: string, parentTaskId: string): void => { + if (!editor) return; + const doc = editor.state.doc; + let parentEndPos = -1; + let cursor = 0; + for (let i = 0; i < doc.childCount; i++) { + const child = doc.child(i); + if ( + child.type.name === 'taskRef' && + (child.attrs.taskId as string) === parentTaskId + ) { + parentEndPos = cursor + child.nodeSize; + // Skip past existing subTaskRefs that belong to this parent. + let scan = i + 1; + let scanCursor = parentEndPos; + while (scan < doc.childCount && doc.child(scan).type.name === 'subTaskRef') { + scanCursor += doc.child(scan).nodeSize; + scan++; + } + parentEndPos = scanCursor; + break; + } + cursor += child.nodeSize; + } + if (parentEndPos < 0) return; + // Insert with title content from the cache — see appendMissingTask for why + // a content-less chip would be written back to the host as an empty title. + editor + .chain() + .focus(parentEndPos) + .insertContentAt( + parentEndPos, + taskNodeJSON(taskId, 'subTaskRef', lookupTask) as Parameters< + typeof editor.commands.insertContentAt + >[1], + ) + .run(); +}; + +/** + * Per-task debouncers for writing edited titles back to the host. Pending + * writes prevent ANY_TASK_UPDATE echoes from clobbering the user's typing. + * `lastWrittenTitles` holds the value we last successfully wrote, so we + * can distinguish our own echo from a genuine remote change in + * refreshTaskRef. + */ +const titleWriteTimers = new Map>(); +const pendingTitleWrites = new Set(); +const lastWrittenTitles = new Map(); + +const writeTitleBack = (taskId: string, newTitle: string): void => { + const existing = titleWriteTimers.get(taskId); + if (existing) clearTimeout(existing); + pendingTitleWrites.add(taskId); + titleWriteTimers.set( + taskId, + setTimeout(() => { + titleWriteTimers.delete(taskId); + PluginAPI.updateTask(taskId, { title: newTitle }) + .then(() => { + // Record what we wrote so refreshTaskRef can recognise the echo + // and skip it without needing the time-based pendingTitleWrites + // guard (which races with genuine remote edits). + lastWrittenTitles.set(taskId, newTitle); + const cached = taskCache.get(taskId); + if (cached) taskCache.set(taskId, { ...cached, title: newTitle }); + }) + .catch((err) => { + logErr('updateTask (title) failed', err); + }) + .finally(() => { + // Keep the "pending" marker briefly to absorb the echo from our + // own write that will arrive via ANY_TASK_UPDATE. + setTimeout(() => pendingTitleWrites.delete(taskId), 500); + }); + }, 600), + ); +}; + +/** + * Walk all taskRef nodes in the current doc and emit write-backs for any + * whose inline content drifted from the task cache. + */ +const reconcileTitlesFromDoc = (): void => { + if (!editor || isLoadingDoc) return; + editor.state.doc.descendants((node) => { + if (!isTaskNode(node.type.name)) return; + const taskId = node.attrs.taskId as string; + if (!taskId) return; + const docTitle = node.textContent; + const cached = taskCache.get(taskId); + if (!cached) return; + if (docTitle !== cached.title) { + writeTitleBack(taskId, docTitle); + } + }); +}; + +const isTaskRefFocused = (taskId: string): boolean => { + if (!editor) return false; + const { from, to } = editor.state.selection; + let focused = false; + editor.state.doc.nodesBetween(from, to, (node) => { + if (isTaskNode(node.type.name) && node.attrs.taskId === taskId) { + focused = true; + return false; + } + return undefined; + }); + return focused; +}; + +/** + * Refresh inline content + isDone attr for one taskRef from the cache. + * Skips nodes that the user is currently editing or that have a pending + * write-back (so we don't undo their typing). + * + * The selection-in-chip check is gated on `editor.isFocused` because + * `editor.state.selection` persists across DOM-focus changes — without + * the gate, a chip the user once clicked into stays "focused" forever + * from this function's perspective, and an external title change + * (host UI, sync) never propagates back to the chip. + */ +const refreshTaskRef = (taskId: string): void => { + if (!editor) return; + if (pendingTitleWrites.has(taskId)) return; + if (editor.isFocused && isTaskRefFocused(taskId)) return; + const task = taskCache.get(taskId); + if (!task) return; + + const updates: { pos: number; nodeSize: number; node: ProseMirrorNode }[] = []; + editor.state.doc.descendants((node, pos) => { + if (isTaskNode(node.type.name) && node.attrs.taskId === taskId) { + updates.push({ pos, nodeSize: node.nodeSize, node }); + return false; + } + return undefined; + }); + if (updates.length === 0) return; + + const tr = editor.state.tr; + for (const { pos, nodeSize, node } of updates) { + if (node.attrs.isDone !== !!task.isDone) { + tr.setNodeAttribute(pos, 'isDone', !!task.isDone); + } + if (node.textContent !== task.title) { + const schema = editor.schema; + const titleText = task.title || ''; + const newContent = titleText ? schema.text(titleText) : null; + // Replace inline content of the node: positions are [pos+1, pos+nodeSize-1]. + const from = pos + 1; + const to = pos + nodeSize - 1; + if (newContent) { + tr.replaceWith(from, to, newContent); + } else { + tr.delete(from, to); + } + } + } + if (tr.docChanged) { + isLoadingDoc = true; + editor.view.dispatch(tr); + isLoadingDoc = false; + } +}; + +/** + * Shared tail of `onAnyTaskUpdate`: refresh the affected chip and detect a + * task transitioning into the current context. Runs against whatever + * `taskCache` currently holds — the caller decides whether to fully + * re-fetch first or patch a single entry. + * + * Auto-append fires on transitions out→in for THIS context's filter, not + * for the cache globally. A global check would make a task that exists in + * another project invisible the moment it gains the TODAY tag (still + * "known", so no transition detected) — the "today not working" symptom. + * Per-context scoping fixes that and still avoids a chip-replay storm + * (an already-in-context task stays in-context → `wasInCtx` true → no + * append). + */ +const processTaskUpdate = ( + payload: AnyTaskUpdatePayload, + ctxSnapshot: ActiveWorkContext, +): void => { + if (payload.taskId) refreshTaskRef(payload.taskId); + const newInCtx = snapshotInContextTaskIds(taskCache.values(), ctxSnapshot); + if (payload.task && payload.taskId) { + const wasInCtx = lastSeenTaskIds.has(payload.taskId); + const isInCtx = newInCtx.has(payload.taskId); + lastSeenTaskIds = newInCtx; + if (!wasInCtx && isInCtx) appendMissingTask(payload.taskId); + } else { + // Deletion or non-payload event — refresh the snapshot so future + // transitions are detected against the new state. + lastSeenTaskIds = newInCtx; + } +}; + +const onAnyTaskUpdate = (payload: AnyTaskUpdatePayload): void => { + if (!currentCtx || !editor) return; + const ctxSnapshot = currentCtx; + + // Fast path: an in-place single-task update for a task we already hold. + // `getTasks()` returns ALL tasks, so a cache hit means no genuinely new + // task can be hiding, and `handleUpdateTask` mutates only that one task + // entity. Patch the single entry and skip the round-trip. Adds, deletes, + // subtask moves and unknown tasks still take the full refresh — a new + // task or a sibling reorder is not visible from `payload.task` alone. + if ( + payload.action === UPDATE_TASK_ACTION && + payload.task && + payload.taskId && + taskCache.has(payload.taskId) + ) { + taskCache.set(payload.taskId, payload.task); + processTaskUpdate(payload, ctxSnapshot); + return; + } + + void refreshTaskCache().then(() => processTaskUpdate(payload, ctxSnapshot)); +}; + +/* -------------------------------------------------------------------------- */ +/* Slash menu + block menu (Notion-style) */ +/* -------------------------------------------------------------------------- */ + +interface MenuItem { + label: string; + icon: string; + hint?: string; + action: () => void; +} + +const insertItems = (): MenuItem[] => { + if (!editor) return []; + const ed = editor; + return [ + { + label: 'Paragraph', + icon: 'segment', + action: () => ed.chain().focus().setParagraph().run(), + }, + { + label: 'Heading 1', + icon: 'title', + hint: '#', + action: () => ed.chain().focus().setHeading({ level: 1 }).run(), + }, + { + label: 'Heading 2', + icon: 'text_fields', + hint: '##', + action: () => ed.chain().focus().setHeading({ level: 2 }).run(), + }, + { + label: 'Heading 3', + icon: 'short_text', + hint: '###', + action: () => ed.chain().focus().setHeading({ level: 3 }).run(), + }, + { + label: 'Bullet list', + icon: 'format_list_bulleted', + hint: '-', + action: () => ed.chain().focus().toggleBulletList().run(), + }, + { + label: 'Numbered list', + icon: 'format_list_numbered', + hint: '1.', + action: () => ed.chain().focus().toggleOrderedList().run(), + }, + { + label: 'Quote', + icon: 'format_quote', + hint: '>', + action: () => ed.chain().focus().setBlockquote().run(), + }, + { + label: 'Code block', + icon: 'code', + hint: '```', + action: () => ed.chain().focus().toggleCodeBlock().run(), + }, + { + label: 'Divider', + icon: 'horizontal_rule', + hint: '---', + action: () => ed.chain().focus().setHorizontalRule().run(), + }, + { + label: 'New task', + icon: 'check_circle_outline', + action: async () => { + if (slashActionInFlight) return; + const ctxAtStart = currentCtx; + if (!ctxAtStart) return; + slashActionInFlight = true; + try { + const taskId = await PluginAPI.addTask({ + title: '', + projectId: ctxAtStart.type === 'PROJECT' ? ctxAtStart.id : null, + }); + seedTaskCache(taskId, '', ctxAtStart); + // Skip the doc insert if the user switched contexts during the + // host round-trip — the task is still saved in the host, it just + // doesn't belong in this editor doc. + if (currentCtx?.id === ctxAtStart.id) { + const insertPos = ed.state.selection.from; + ed.chain() + .focus() + .insertContent( + taskNodeJSON(taskId, 'taskRef', lookupTask) as Parameters< + typeof ed.commands.insertContent + >[0], + ) + .run(); + // Land the caret inside the new empty chip so the user types the + // title straight away (mirrors the Enter-to-create behaviour). + ed.commands.focus(insertPos + 1); + } + } finally { + slashActionInFlight = false; + } + }, + }, + ]; +}; + +// Re-entrance guard for awaited slash-menu actions (e.g. "New task"). +// The menu closes before the action awaits its host round-trip; without +// this, a second fast trigger could double-create a task or insert into +// a stale context. +let slashActionInFlight = false; + +let menuEl: HTMLDivElement | null = null; +let menuActiveIndex = 0; +let menuFilter = ''; +let menuCurrentItems: MenuItem[] = []; +// Doc position of the `/` that opened a slash-triggered menu, or null when +// the menu was opened some other way (gutter "+", block menu). When set, +// picking an item first deletes the `/query` text the user typed. +let slashQueryFrom: number | null = null; + +const closeMenu = (): void => { + if (menuEl) { + menuEl.remove(); + menuEl = null; + } + menuFilter = ''; + menuActiveIndex = 0; + menuCurrentItems = []; + slashQueryFrom = null; +}; + +/** + * Run a chosen menu item. For a slash-triggered menu, first delete the + * literal `/query` text the user typed — the `/` and every filter character + * land in the doc as ordinary input, so picking "Heading 1" would otherwise + * leave "/head" behind inside the new heading. + */ +const runMenuItem = (action: () => void): void => { + if (slashQueryFrom !== null && editor) { + const to = editor.state.selection.from; + const from = Math.min(slashQueryFrom, to); + if (to > from) { + try { + editor.chain().focus().deleteRange({ from, to }).run(); + } catch { + // Position no longer maps (doc changed under us) — leave the text. + } + } + } + closeMenu(); + action(); +}; + +/** + * Position a popover relative to an anchor rect. Default opens below; flips + * above when there isn't room. The anchor rect is viewport-relative; we add + * scrollX/Y because the popover is `position: absolute` in document space. + * Set styles BEFORE measuring offsetHeight so the first paint is at the + * final spot (no visual flicker). + */ +const positionPopover = (el: HTMLElement, rect: DOMRect): void => { + el.style.left = `${rect.left + window.scrollX}px`; + el.style.top = `${rect.bottom + window.scrollY + 4}px`; + el.style.visibility = 'hidden'; + document.body.appendChild(el); + const h = el.offsetHeight; + const overflowsBelow = rect.bottom + 4 + h > window.innerHeight; + const fitsAbove = rect.top - 4 - h > 0; + if (overflowsBelow && fitsAbove) { + el.style.top = `${rect.top + window.scrollY - 4 - h}px`; + } + el.style.visibility = ''; +}; + +const renderMenu = (rect: DOMRect, items: MenuItem[]): void => { + if (menuEl) menuEl.remove(); + menuCurrentItems = items; + if (items.length === 0) { + menuEl = document.createElement('div'); + menuEl.className = 'slash-menu'; + const empty = document.createElement('div'); + empty.className = 'slash-menu-empty'; + empty.textContent = 'No matches'; + menuEl.appendChild(empty); + positionPopover(menuEl, rect); + return; + } + menuEl = document.createElement('div'); + menuEl.className = 'slash-menu'; + menuEl.setAttribute('role', 'listbox'); + items.forEach((item, idx) => { + const el = document.createElement('div'); + el.className = 'slash-menu-item'; + el.setAttribute('role', 'option'); + const isActive = idx === menuActiveIndex; + el.classList.toggle('is-active', isActive); + el.setAttribute('aria-selected', isActive ? 'true' : 'false'); + // Icon is a constant string we control (SVG path map), so innerHTML is + // safe; label/hint may come from task titles or other dynamic sources + // so build those as text nodes to avoid an XSS vector. + el.innerHTML = iconSvg(item.icon, 'slash-menu-icon'); + const labelEl = document.createElement('span'); + labelEl.className = 'slash-menu-label'; + labelEl.textContent = item.label; + el.appendChild(labelEl); + if (item.hint) { + const hintEl = document.createElement('span'); + hintEl.className = 'slash-menu-hint'; + hintEl.textContent = item.hint; + el.appendChild(hintEl); + } + el.addEventListener('mousedown', (ev) => { + ev.preventDefault(); + runMenuItem(item.action); + }); + el.addEventListener('mouseenter', () => { + menuActiveIndex = idx; + menuEl?.querySelectorAll('.slash-menu-item').forEach((n, i) => { + const on = i === idx; + n.classList.toggle('is-active', on); + n.setAttribute('aria-selected', on ? 'true' : 'false'); + }); + }); + menuEl!.appendChild(el); + }); + positionPopover(menuEl, rect); + // Keep the keyboard-active item visible when the list scrolls. + menuEl.querySelector('.slash-menu-item.is-active')?.scrollIntoView({ + block: 'nearest', + }); +}; + +/** + * Selection rect for the slash menu. `getRangeAt(0).getBoundingClientRect()` + * returns a zero-sized rect at (0,0) for empty blocks (e.g. the paragraph + * we just inserted from the gutter "+"), which would place the menu in + * the top-left of the iframe. ProseMirror's `coordsAtPos` always returns + * useful coords, so prefer that when possible. + */ +const caretRect = (): DOMRect => { + if (editor) { + try { + const c = editor.view.coordsAtPos(editor.state.selection.from); + return new DOMRect(c.left, c.top, 0, c.bottom - c.top); + } catch { + // fall through to selection-based rect + } + } + const sel = window.getSelection(); + if (sel && sel.rangeCount > 0) { + return sel.getRangeAt(0).getBoundingClientRect(); + } + return new DOMRect(0, 0, 0, 0); +}; + +/** + * Open the insert menu. `slashTriggered` records whether a literal `/` was + * typed (so `runMenuItem` knows to delete the `/query` text on pick); the + * gutter "+" button opens the same menu without a slash to remove. + */ +const showSlashMenu = (slashTriggered: boolean): void => { + if (!editor) return; + menuActiveIndex = 0; + menuFilter = ''; + slashQueryFrom = slashTriggered ? Math.max(0, editor.state.selection.from - 1) : null; + renderMenu(caretRect(), insertItems()); +}; + +const filterAndRender = (rect: DOMRect): void => { + const items = insertItems().filter((i) => + i.label.toLowerCase().includes(menuFilter.toLowerCase()), + ); + if (menuActiveIndex >= items.length) menuActiveIndex = 0; + renderMenu(rect, items); +}; + +/* -------------------------------------------------------------------------- */ +/* Block hover gutter (Notion-style + / drag handle) */ +/* -------------------------------------------------------------------------- */ + +let gutterEl: HTMLDivElement | null = null; +let hoveredBlock: HTMLElement | null = null; +let hideGutterTimer: ReturnType | null = null; + +// Drag-and-drop state for grip-based block reordering. We drive this via +// pointer events rather than the HTML5 drag API — the native API was +// fragile across browsers when the drag source lived outside the editor +// (the grip sits in document.body, not inside ProseMirror's view). +interface PendingDrag { + startX: number; + startY: number; + nodePos: number; + block: HTMLElement; + pointerId: number; + active: boolean; + targetIdx: number | null; + // Source slice: index in doc.content + how many children move together. + // For a parent taskRef, sliceLen covers the parent and any trailing + // subTaskRefs so the whole group is dragged atomically. + fromIdx: number; + sliceLen: number; + sourceType: 'taskRef' | 'subTaskRef' | 'other'; +} + +let pendingDrag: PendingDrag | null = null; +let dropIndicatorEl: HTMLDivElement | null = null; +const DRAG_THRESHOLD_PX = 4; + +const ensureDropIndicator = (): HTMLDivElement => { + if (dropIndicatorEl) return dropIndicatorEl; + dropIndicatorEl = document.createElement('div'); + dropIndicatorEl.className = 'doc-drop-indicator'; + dropIndicatorEl.style.display = 'none'; + document.body.appendChild(dropIndicatorEl); + return dropIndicatorEl; +}; + +const positionDropIndicator = (y: number, x: number, width: number): void => { + const el = ensureDropIndicator(); + el.style.display = 'block'; + el.style.top = `${y + window.scrollY}px`; + el.style.left = `${x + window.scrollX}px`; + el.style.width = `${width}px`; +}; + +const hideDropIndicator = (): void => { + if (dropIndicatorEl) dropIndicatorEl.style.display = 'none'; +}; + +const computeDropTarget = ( + clientY: number, +): { targetIdx: number; indicatorY: number; rootRect: DOMRect } | null => { + if (!editor) return null; + const editorRoot = editor.view.dom as HTMLElement; + const blocks = Array.from(editorRoot.children) as HTMLElement[]; + if (blocks.length === 0) return null; + const rootRect = editorRoot.getBoundingClientRect(); + let targetIdx = blocks.length; + let indicatorY = blocks[blocks.length - 1].getBoundingClientRect().bottom; + for (let i = 0; i < blocks.length; i++) { + const r = blocks[i].getBoundingClientRect(); + const mid = r.top + r.height / 2; + if (clientY < mid) { + targetIdx = i; + indicatorY = r.top; + break; + } + indicatorY = r.bottom; + } + // Snap the raw hit-test index to a legal landing gap for the dragged + // slice (see docNav.snapDropTargetIdx), then recompute the indicator Y. + if (pendingDrag) { + targetIdx = docNav.snapDropTargetIdx(editor.state.doc, targetIdx, pendingDrag); + if (targetIdx === 0) { + indicatorY = blocks[0].getBoundingClientRect().top; + } else if (targetIdx >= blocks.length) { + indicatorY = blocks[blocks.length - 1].getBoundingClientRect().bottom; + } else { + indicatorY = blocks[targetIdx - 1].getBoundingClientRect().bottom; + } + } + return { targetIdx, indicatorY, rootRect }; +}; + +const endBlockDrag = (commit: boolean): void => { + const drag = pendingDrag; + pendingDrag = null; + hideDropIndicator(); + if (drag) { + // Clear the dim on every block in the slice (and as a safety net any + // stray .is-dragging the DOM might have). + if (editor) { + editor.view.dom + .querySelectorAll('.is-dragging') + .forEach((el) => el.classList.remove('is-dragging')); + } else { + drag.block.classList.remove('is-dragging'); + } + try { + (document.body as HTMLElement).releasePointerCapture(drag.pointerId); + } catch { + // pointer may already be released + } + } + if (commit && drag && drag.active && drag.targetIdx !== null) { + moveContentSliceToIndex(drag.fromIdx, drag.sliceLen, drag.targetIdx); + } +}; + +const attachGripPointerHandlers = (grip: HTMLElement): void => { + grip.addEventListener('pointerdown', (ev) => { + ev.stopPropagation(); + // Only react to primary button (left mouse / touch / pen tip). + if (ev.button !== 0) return; + if (!hoveredBlock || !editor) return; + const block = hoveredBlock; + let nodePos: number; + try { + const pos = editor.view.posAtDOM(block, 0); + if (pos < 0) return; + const resolved = editor.state.doc.resolve(pos); + if (resolved.depth === 0) return; + nodePos = resolved.before(resolved.depth); + } catch { + return; + } + const fromIdx = childIdxAtPos(nodePos); + if (fromIdx < 0) return; + const srcNode = editor.state.doc.child(fromIdx); + const sourceType: PendingDrag['sourceType'] = + srcNode.type.name === 'taskRef' + ? 'taskRef' + : srcNode.type.name === 'subTaskRef' + ? 'subTaskRef' + : 'other'; + const sliceLen = sliceLenAt(fromIdx); + pendingDrag = { + startX: ev.clientX, + startY: ev.clientY, + nodePos, + block, + pointerId: ev.pointerId, + active: false, + targetIdx: null, + fromIdx, + sliceLen, + sourceType, + }; + // Select the node so the user sees what they're about to drag. + try { + editor.view.dispatch( + editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, nodePos)), + ); + } catch { + // selection may not be valid (e.g. doc root) + } + }); + + // Suppress the click that follows pointerup when a real drag occurred — + // otherwise the block menu would pop open right after dropping. + grip.addEventListener('click', (ev) => { + if (grip.dataset.justDragged === '1') { + ev.preventDefault(); + ev.stopPropagation(); + delete grip.dataset.justDragged; + return; + } + ev.preventDefault(); + ev.stopPropagation(); + if (!hoveredBlock || !editor) return; + openBlockMenu(grip.getBoundingClientRect()); + }); +}; + +// Document-level pointer handlers; armed once at mount time. They drive any +// in-progress grip drag regardless of which gutter instance started it. +const installDocumentDragHandlers = (): void => { + document.addEventListener('pointermove', (ev) => { + const drag = pendingDrag; + if (!drag || drag.pointerId !== ev.pointerId) return; + const dx = ev.clientX - drag.startX; + const dy = ev.clientY - drag.startY; + if (!drag.active) { + if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return; + drag.active = true; + // Dim every block in the dragged slice (a parent + its subtasks + // move together — the user expects to see the whole group lifted). + if (editor) { + const root = editor.view.dom as HTMLElement; + for (let i = 0; i < drag.sliceLen; i++) { + const el = root.children[drag.fromIdx + i]; + el?.classList.add('is-dragging'); + } + } + if (gutterEl) { + gutterEl.style.display = 'none'; + gutterEl.classList.remove('is-visible'); + } + try { + (document.body as HTMLElement).setPointerCapture(drag.pointerId); + } catch { + // setPointerCapture can fail in odd states; the listener still works + } + } + const target = computeDropTarget(ev.clientY); + if (!target) { + drag.targetIdx = null; + hideDropIndicator(); + return; + } + drag.targetIdx = target.targetIdx; + positionDropIndicator(target.indicatorY, target.rootRect.left, target.rootRect.width); + }); + document.addEventListener('pointerup', (ev) => { + const drag = pendingDrag; + if (!drag || drag.pointerId !== ev.pointerId) return; + const wasActive = drag.active; + endBlockDrag(true); + if (wasActive) { + // Mark all grips so the synthetic click that follows pointerup is + // ignored (browsers fire click after pointerup unless preventDefault'd + // on pointerdown, which would also break selection). + document + .querySelectorAll('.block-gutter-btn[data-action="grip"]') + .forEach((el) => { + el.dataset.justDragged = '1'; + }); + setTimeout(() => { + document + .querySelectorAll('.block-gutter-btn[data-action="grip"]') + .forEach((el) => delete el.dataset.justDragged); + }, 0); + } + }); + document.addEventListener('pointercancel', () => { + if (pendingDrag) endBlockDrag(false); + }); + // Safety net: pointerup / pointercancel may not fire when the drag leaves + // the iframe entirely (drag into an Electron menu, browser dragging into + // another tab, focus stolen by an OS-level overlay). Without this, the + // drop indicator and dim state would stay forever. + window.addEventListener('blur', () => { + if (pendingDrag) endBlockDrag(false); + }); + document.documentElement.addEventListener('pointerleave', (ev) => { + if (!pendingDrag) return; + // pointerleave fires when pointer crosses the iframe boundary. Treat + // that as "drag aborted" — committing would land the slice based on + // stale coords. + if (!ev.relatedTarget) endBlockDrag(false); + }); +}; + +const scheduleHideGutter = (): void => { + if (hideGutterTimer) clearTimeout(hideGutterTimer); + hideGutterTimer = setTimeout(() => { + hideGutterTimer = null; + positionGutter(null); + }, 200); +}; + +const cancelHideGutter = (): void => { + if (hideGutterTimer) { + clearTimeout(hideGutterTimer); + hideGutterTimer = null; + } +}; + +const createGutter = (): HTMLDivElement => { + const g = document.createElement('div'); + g.className = 'block-gutter'; + g.setAttribute('role', 'toolbar'); + g.setAttribute('aria-label', 'Block actions'); + g.innerHTML = ` + + + + `; + g.style.display = 'none'; + document.body.appendChild(g); + + // "Open task details" — only shown for task chips (see positionGutter). + // Opens the host's task-detail panel via the PluginAPI bridge. + g.querySelector('[data-action="details"]')?.addEventListener('mousedown', (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + const taskId = hoveredBlock?.dataset.taskId; + if (!taskId) return; + PluginAPI.selectTask(taskId).catch((err) => logErr('selectTask failed', err)); + }); + + g.querySelector('[data-action="add"]')?.addEventListener('mousedown', (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + if (!hoveredBlock || !editor) return; + // posAtDOM returns -1 if the block is no longer mapped (re-rendered between + // hover and click). Bail rather than throwing on resolve(-1). + const pos = editor.view.posAtDOM(hoveredBlock, 0); + if (pos < 0) return; + const $pos = editor.state.doc.resolve(pos); + const blockEnd = $pos.end($pos.depth); + editor + .chain() + .focus(blockEnd + 1) + .insertContentAt(blockEnd + 1, { type: 'paragraph' }) + .run(); + requestAnimationFrame(() => showSlashMenu(false)); + }); + + const grip = g.querySelector('[data-action="grip"]') as HTMLElement | null; + if (grip) { + attachGripPointerHandlers(grip); + } + + return g; +}; + +const positionGutter = (block: HTMLElement | null): void => { + if (!gutterEl) return; + if (!block) { + gutterEl.style.display = 'none'; + gutterEl.classList.remove('is-visible'); + hoveredBlock = null; + return; + } + const rect = block.getBoundingClientRect(); + // The "open details" button is only meaningful for task chips. + const detailsBtn = gutterEl.querySelector('[data-action="details"]'); + if (detailsBtn) { + detailsBtn.style.display = block.classList.contains('task-ref') ? '' : 'none'; + } + gutterEl.style.display = 'flex'; + gutterEl.style.top = `${rect.top + window.scrollY}px`; + gutterEl.style.height = `${Math.max(28, rect.height)}px`; + // Right-align the gutter just left of the block; measure its own width so + // the layout holds whether or not the details button is showing. + gutterEl.style.left = `${rect.left + window.scrollX - gutterEl.offsetWidth + 6}px`; + gutterEl.classList.add('is-visible'); + hoveredBlock = block; +}; + +const findBlockFromEvent = (ev: MouseEvent): HTMLElement | null => { + if (!editor) return null; + const target = ev.target as HTMLElement | null; + if (!target) return null; + const root = editor.view.dom as HTMLElement; + if (!root.contains(target) && target !== gutterEl && !gutterEl?.contains(target)) { + return null; + } + // Walk up to the direct child of .ProseMirror. + let node: HTMLElement | null = target; + while (node && node.parentElement && node.parentElement !== root) { + node = node.parentElement; + } + return node && node.parentElement === root ? node : null; +}; + +// Thin `editor`-bound wrappers over the pure `doc-nav` helpers used by the +// grip drag and block-menu move code. See `ui/doc-nav.ts` for the logic. +const sliceLenAt = (idx: number): number => + editor ? docNav.sliceLenAt(editor.state.doc, idx) : 1; + +const childIdxAtPos = (pos: number): number => + editor ? docNav.childIdxAtPos(editor.state.doc, pos) : -1; + +/* -------------------------------------------------------------------------- */ +/* Chip-reorder write-back */ +/* -------------------------------------------------------------------------- */ + +// A chip reorder inside the doc is written back to the host so it survives +// a reload — the load pipeline rebuilds top-level order from `ctx.taskIds` +// and subtask order from each parent's `subTaskIds`, so an un-persisted +// reorder would silently revert. Top-level order is only persistable for +// PROJECT contexts (`reorderTasks` has no TODAY/TAG context type); +// subtask order persists in any context (it is keyed on the parent task). +const REORDER_DEBOUNCE_MS = 400; +let reorderTimer: ReturnType | null = null; +let pendingTopLevelReorder = false; +const pendingSubtaskParents = new Set(); + +const cancelPendingReorder = (): void => { + if (reorderTimer !== null) { + clearTimeout(reorderTimer); + reorderTimer = null; + } + pendingTopLevelReorder = false; + pendingSubtaskParents.clear(); +}; + +/** Top-level `taskRef` ids in current doc order. */ +const collectTopLevelTaskIds = (): string[] => { + if (!editor) return []; + const doc = editor.state.doc; + const ids: string[] = []; + for (let i = 0; i < doc.childCount; i++) { + const child = doc.child(i); + if (child.type.name === 'taskRef' && child.attrs.taskId) { + ids.push(child.attrs.taskId as string); + } + } + return ids; +}; + +/** `subTaskRef` ids that currently sit under `parentTaskId` in the doc. */ +const collectSubtaskIds = (parentTaskId: string): string[] => { + if (!editor) return []; + const doc = editor.state.doc; + const ids: string[] = []; + let i = 0; + while (i < doc.childCount) { + const child = doc.child(i); + if (child.type.name === 'taskRef' && child.attrs.taskId === parentTaskId) break; + i++; + } + for (i += 1; i < doc.childCount; i++) { + const child = doc.child(i); + if (child.type.name !== 'subTaskRef') break; + if (child.attrs.taskId) ids.push(child.attrs.taskId as string); + } + return ids; +}; + +/** Order-independent equality of two id lists. */ +const sameIdSet = (a: readonly string[], b: readonly string[]): boolean => { + if (a.length !== b.length) return false; + const setB = new Set(b); + return a.every((id) => setB.has(id)); +}; + +/** + * Write pending chip reorders back to the host. Each `reorderTasks` call + * replaces an ordering list wholesale, so it is guarded to fire ONLY for a + * true permutation: the doc's chip id set must still equal the host's + * current id set. A membership mismatch (a deleted chip, a task added + * elsewhere) means this is not a pure reorder — skip it and let the next + * context reload reconcile, rather than risk dropping a task from + * `project.taskIds` / `task.subTaskIds`. + */ +const flushReorder = async (): Promise => { + reorderTimer = null; + const ctx = currentCtx; + const wantTopLevel = pendingTopLevelReorder; + const parents = [...pendingSubtaskParents]; + pendingTopLevelReorder = false; + pendingSubtaskParents.clear(); + if (!ctx || !editor) return; + + // Top-level order — only PROJECT contexts have a reorderTasks target. + if (wantTopLevel && ctx.type === 'PROJECT') { + const docIds = collectTopLevelTaskIds(); + try { + const fresh = await PluginAPI.getActiveWorkContext(); + if ( + docIds.length > 0 && + fresh && + fresh.id === ctx.id && + currentCtx?.id === ctx.id && + sameIdSet(docIds, fresh.taskIds) + ) { + await PluginAPI.reorderTasks(docIds, ctx.id, 'project'); + } + } catch (err) { + logErr('reorderTasks (project) failed', err); + } + } + + // Subtask order — keyed on the parent task, so any context can persist it. + for (const parentId of parents) { + if (currentCtx?.id !== ctx.id) break; // context switched mid-flush + const docSubIds = collectSubtaskIds(parentId); + const parent = taskCache.get(parentId); + if (docSubIds.length === 0 || !parent) continue; + if (!sameIdSet(docSubIds, parent.subTaskIds ?? [])) continue; + try { + await PluginAPI.reorderTasks(docSubIds, parentId, 'task'); + } catch (err) { + logErr('reorderTasks (subtasks) failed', err); + } + } +}; + +const scheduleReorder = (): void => { + if (reorderTimer !== null) clearTimeout(reorderTimer); + reorderTimer = setTimeout(() => void flushReorder(), REORDER_DEBOUNCE_MS); +}; + +/** + * Move a contiguous slice of top-level children to a new insertion-gap + * index. `targetIdx` is interpreted as the gap (0 = before first child, + * doc.childCount = after last). No-op when the target falls inside the + * slice itself. + * + * Used for single-block moves AND for parent-with-subtasks moves: those + * are the same operation with a different slice length. + */ +const moveContentSliceToIndex = ( + fromIdx: number, + sliceLen: number, + targetIdx: number, +): void => { + if (!editor) return; + const ed = editor; + const doc = ed.state.doc; + if (fromIdx < 0 || sliceLen <= 0 || fromIdx + sliceLen > doc.childCount) return; + if (targetIdx >= fromIdx && targetIdx <= fromIdx + sliceLen) return; + + // Snapshot the slice's nodes and total size BEFORE building the tr. + let fromPos = 0; + for (let i = 0; i < fromIdx; i++) fromPos += doc.child(i).nodeSize; + let sliceSize = 0; + const sliceNodes: ProseMirrorNode[] = []; + for (let i = 0; i < sliceLen; i++) { + const child = doc.child(fromIdx + i); + sliceNodes.push(child); + sliceSize += child.nodeSize; + } + + let toPos = 0; + for (let i = 0; i < targetIdx && i < doc.childCount; i++) { + toPos += doc.child(i).nodeSize; + } + // After deletion, positions past fromPos shift left by sliceSize. + const adjustedInsert = toPos > fromPos ? toPos - sliceSize : toPos; + + const tr = ed.state.tr; + tr.delete(fromPos, fromPos + sliceSize); + let insertCursor = adjustedInsert; + for (const node of sliceNodes) { + tr.insert(insertCursor, node); + insertCursor += node.nodeSize; + } + tr.setSelection(NodeSelection.create(tr.doc, adjustedInsert)); + ed.view.dispatch(tr.scrollIntoView()); + ed.view.focus(); + + // Persist the reorder back to the host. A taskRef slice changed the + // top-level order; a subTaskRef slice changed one parent's subtask + // order — the dragged subtask is constrained to stay in its group, so + // its parent at the new position owns the change. + const movedType = sliceNodes[0]?.type.name; + if (movedType === 'taskRef') { + pendingTopLevelReorder = true; + scheduleReorder(); + } else if (movedType === 'subTaskRef') { + const parentId = docNav.findParentTaskIdBefore(ed.state.doc, adjustedInsert); + if (parentId) { + pendingSubtaskParents.add(parentId); + scheduleReorder(); + } + } +}; + +/** + * Move up / Move down from the block menu. Handles three cases: + * + * - subTaskRef: moves a single step within the parent's subtask group, + * refusing to cross the parent boundary (Move up on the first subtask + * is a no-op; Move down on the last subtask is a no-op). + * - taskRef parent (with or without trailing subtasks): moves the whole + * group atomically past the previous / next sibling group. The + * "previous group" is the prior taskRef + any subTaskRefs between it + * and us; the "next group" is the next taskRef + its trailing subs. + * - any other block: behaves like the old single-block swap. + */ +const moveBlock = (nodePos: number, direction: 'up' | 'down'): void => { + if (!editor) return; + const doc = editor.state.doc; + const idx = childIdxAtPos(nodePos); + if (idx < 0) return; + const targetIdx = docNav.moveBlockTargetIdx(doc, idx, direction); + if (targetIdx === null) return; // no-op at a boundary + moveContentSliceToIndex(idx, sliceLenAt(idx), targetIdx); +}; + +/** + * Promote a paragraph / heading into a task chip: create a host task whose + * title is the block's text, then swap the block for a taskRef. Bails if the + * doc shifted under the host round-trip (the task is still created and + * surfaces via reconcile) so a stale position can't replace the wrong range. + */ +const turnBlockIntoTask = async (nodePos: number): Promise => { + if (!editor || !currentCtx) return; + const ed = editor; + const ctx = currentCtx; + const node = ed.state.doc.nodeAt(nodePos); + if (!node) return; + const nodeName = node.type.name; + const title = node.textContent; + try { + const taskId = await PluginAPI.addTask({ + title, + projectId: ctx.type === 'PROJECT' ? ctx.id : null, + }); + seedTaskCache(taskId, title, ctx); + if (currentCtx?.id !== ctx.id) return; + const current = ed.state.doc.nodeAt(nodePos); + if (!current || current.type.name !== nodeName || current.textContent !== title) { + return; + } + ed.chain() + .focus() + .insertContentAt( + { from: nodePos, to: nodePos + current.nodeSize }, + taskNodeJSON(taskId, 'taskRef', lookupTask) as Parameters< + typeof ed.commands.insertContentAt + >[1], + ) + .run(); + } catch (err) { + logErr('turnBlockIntoTask failed', err); + } +}; + +/** + * Delete a task chip — and, for a parent, its whole subtask group — from + * both the document and the host. Removing the chip without deleting the + * task would leave the task alive, so it would reappear on the next reload. + */ +const deleteTaskChipGroup = (blockIdx: number): void => { + if (!editor) return; + const ed = editor; + const doc = ed.state.doc; + if (blockIdx < 0 || blockIdx >= doc.childCount) return; + const len = Math.min(sliceLenAt(blockIdx), doc.childCount - blockIdx); + const ids: string[] = []; + let fromPos = 0; + for (let i = 0; i < blockIdx; i++) fromPos += doc.child(i).nodeSize; + let size = 0; + for (let i = 0; i < len; i++) { + const child = doc.child(blockIdx + i); + size += child.nodeSize; + if (isTaskNode(child.type.name) && child.attrs.taskId) { + ids.push(child.attrs.taskId as string); + } + } + // Remove the chips from the doc first (instant feedback), then delete the + // host tasks. deleteTaskTolerant absorbs a subtask the host already + // removed by cascading the parent delete. + ed.view.dispatch(ed.state.tr.delete(fromPos, fromPos + size)); + ed.view.focus(); + for (const id of ids) void deleteTaskTolerant(id); +}; + +const openBlockMenu = (anchorRect: DOMRect): void => { + if (!editor || !hoveredBlock) return; + const ed = editor; + const pos = ed.view.posAtDOM(hoveredBlock, 0); + if (pos < 0) return; + const $pos = ed.state.doc.resolve(pos); + if ($pos.depth === 0) return; + const nodePos = $pos.before($pos.depth); + const blockIdx = $pos.index(0); + const node = ed.state.doc.nodeAt(nodePos); + if (!node) return; + const nodeName = node.type.name; + const isTask = isTaskNode(nodeName); + const childCount = ed.state.doc.childCount; + const canMoveUp = blockIdx > 0; + const canMoveDown = blockIdx < childCount - 1; + + const items: MenuItem[] = []; + + // Turn-into options are text-block only: a task chip already *is* the task + // form, and converting it to text would orphan the host task. + if (!isTask) { + items.push( + { + label: 'Turn into paragraph', + icon: 'segment', + action: () => ed.chain().focus().setNodeSelection(nodePos).setParagraph().run(), + }, + { + label: 'Turn into H1', + icon: 'title', + action: () => + ed.chain().focus().setNodeSelection(nodePos).setHeading({ level: 1 }).run(), + }, + { + label: 'Turn into H2', + icon: 'text_fields', + action: () => + ed.chain().focus().setNodeSelection(nodePos).setHeading({ level: 2 }).run(), + }, + { + label: 'Turn into H3', + icon: 'short_text', + action: () => + ed.chain().focus().setNodeSelection(nodePos).setHeading({ level: 3 }).run(), + }, + ); + // Promote a paragraph / heading into a task — its text becomes the title. + if (nodeName === 'paragraph' || nodeName === 'heading') { + items.push({ + label: 'Turn into task', + icon: 'check_circle_outline', + action: () => void turnBlockIntoTask(nodePos), + }); + } + } + + if (canMoveUp) { + items.push({ + label: 'Move up', + icon: 'arrow_upward', + action: () => moveBlock(nodePos, 'up'), + }); + } + if (canMoveDown) { + items.push({ + label: 'Move down', + icon: 'arrow_downward', + action: () => moveBlock(nodePos, 'down'), + }); + } + + // Duplicate is text-block only: cloning a chip would place a second node + // with the same taskId in the doc and break every taskId-keyed invariant. + if (!isTask) { + items.push({ + label: 'Duplicate', + icon: 'content_copy', + action: () => { + const n = ed.state.doc.nodeAt(nodePos); + if (!n) return; + ed.chain() + .focus() + .insertContentAt(nodePos + n.nodeSize, n.toJSON()) + .run(); + }, + }); + } + + items.push({ + label: isTask ? 'Delete task' : 'Delete', + icon: 'delete', + action: () => { + if (isTask) { + deleteTaskChipGroup(blockIdx); + } else { + ed.chain().focus().setNodeSelection(nodePos).deleteSelection().run(); + } + }, + }); + + menuActiveIndex = 0; + menuFilter = ''; + // The block menu is not slash-triggered — nothing to delete on pick. + slashQueryFrom = null; + renderMenu(anchorRect, items); +}; + +/* -------------------------------------------------------------------------- */ +/* Mount */ +/* -------------------------------------------------------------------------- */ + +// Guards against re-entering mount(). The iframe is rebuilt from scratch +// when the embed slot is closed and re-opened, but in dev HMR or odd +// host re-init flows we could land here twice — every listener and +// body-appended element would then duplicate. One source of truth. +let isMounted = false; + +const mount = async (): Promise => { + if (isMounted) return; + isMounted = true; + await loadStoredState(); + updateDocStatusBanner(); + const initialCtx = await PluginAPI.getActiveWorkContext(); + + const root = document.getElementById('editor-root'); + if (!root) { + logErr('Document mode: #editor-root not found'); + isMounted = false; + return; + } + + const bubbleEl = document.createElement('div'); + bubbleEl.className = 'bubble-menu'; + bubbleEl.setAttribute('role', 'toolbar'); + bubbleEl.setAttribute('aria-label', 'Text formatting'); + bubbleEl.innerHTML = ` + + + + + `; + document.body.appendChild(bubbleEl); + + // Editor-side collaborators handed to both task-ref node variants. + // `getEditor` is late-bound — the nodes are constructed before the + // `Editor` exists. + const taskRefDeps: TaskRefNodeDeps = { + getEditor: () => editor, + lookupTask, + toggleTaskDone, + deleteTaskTolerant, + createTaskAfter, + createSubTaskAfter, + }; + + editor = new Editor({ + element: root, + extensions: [ + StarterKit, + Placeholder.configure({ + placeholder: "Press '/' to add tasks, headings and more…", + }), + createTaskRefNode('taskRef', taskRefDeps), + createTaskRefNode('subTaskRef', taskRefDeps), + BubbleMenu.configure({ + element: bubbleEl, + shouldShow: ({ from, to, state }) => { + if (from === to) return false; + // A selection that *crosses* an atom (e.g. paragraph → divider + // → paragraph) shouldn't show the inline-mark menu either — + // toggling bold across the divider would do nothing useful. + // Walk the whole range, not just the start, to catch this. + let hasAtom = false; + state.doc.nodesBetween(from, to, (node) => { + if (node.isAtom) hasAtom = true; + return !hasAtom; + }); + return !hasAtom; + }, + }), + ], + content: { type: 'doc', content: [{ type: 'paragraph' }] }, + onUpdate: () => { + reconcileTitlesFromDoc(); + scheduleSave(); + }, + }); + + bubbleEl.querySelectorAll('button[data-action]').forEach((btn) => { + btn.addEventListener('mousedown', (ev) => { + ev.preventDefault(); + const action = (btn as HTMLElement).dataset.action; + if (!editor) return; + const chain = editor.chain().focus(); + if (action === 'bold') chain.toggleBold().run(); + else if (action === 'italic') chain.toggleItalic().run(); + else if (action === 'strike') chain.toggleStrike().run(); + else if (action === 'code') chain.toggleCode().run(); + }); + }); + + gutterEl = createGutter(); + + root.addEventListener('mousemove', (ev) => { + cancelHideGutter(); + const block = findBlockFromEvent(ev); + if (block !== hoveredBlock) positionGutter(block); + }); + root.addEventListener('mouseleave', (ev) => { + const next = ev.relatedTarget as HTMLElement | null; + if (next && gutterEl?.contains(next)) return; + // Debounce: gives the mouse ~200 ms to reach the gutter across the gap. + scheduleHideGutter(); + }); + gutterEl.addEventListener('mouseenter', () => { + cancelHideGutter(); + }); + gutterEl.addEventListener('mouseleave', (ev) => { + const next = ev.relatedTarget as HTMLElement | null; + if (next && (root.contains(next) || gutterEl?.contains(next))) return; + scheduleHideGutter(); + }); + + installDocumentDragHandlers(); + + // Mod-Enter inside a task chip toggles its done state. Registered on + // `document` in the capture phase so it runs before ProseMirror's keymap — + // StarterKit's HardBreak binds the same chord, and a same-element listener + // (capture or bubble) would still fire after ProseMirror's. + document.addEventListener( + 'keydown', + (ev) => { + if (ev.key !== 'Enter' || !(ev.metaKey || ev.ctrlKey) || ev.shiftKey || ev.altKey) { + return; + } + const taskId = currentChipTaskId(); + if (!taskId) return; + ev.preventDefault(); + ev.stopPropagation(); + toggleTaskDone(taskId); + }, + true, + ); + + editor.view.dom.addEventListener('keydown', (ev: KeyboardEvent) => { + if (menuEl) { + if (ev.key === 'Escape') { + ev.preventDefault(); + closeMenu(); + return; + } + if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') { + ev.preventDefault(); + if (menuCurrentItems.length === 0) return; + if (ev.key === 'ArrowDown') { + menuActiveIndex = (menuActiveIndex + 1) % menuCurrentItems.length; + } else { + menuActiveIndex = + (menuActiveIndex - 1 + menuCurrentItems.length) % menuCurrentItems.length; + } + renderMenu(caretRect(), menuCurrentItems); + return; + } + if (ev.key === 'Enter') { + if (menuCurrentItems.length === 0) { + // Empty result set — let Enter through to the editor instead of + // eating it. Previously the menu stayed open with "No matches" + // and Enter did nothing. + closeMenu(); + return; + } + ev.preventDefault(); + if (menuCurrentItems[menuActiveIndex]) { + runMenuItem(menuCurrentItems[menuActiveIndex].action); + } + return; + } + if (ev.key === 'Backspace') { + if (menuFilter === '') { + closeMenu(); + } else { + menuFilter = menuFilter.slice(0, -1); + filterAndRender(caretRect()); + } + return; + } + if (ev.key.length === 1) { + // Skip when an OS shortcut (Ctrl/Cmd/Alt + char) or IME composition + // is in progress — those keys should NOT extend the slash filter. + if (ev.ctrlKey || ev.metaKey || ev.altKey || ev.isComposing) return; + menuFilter += ev.key; + filterAndRender(caretRect()); + return; + } + } else if (ev.key === '/') { + // Defer so the `/` is in the doc before we read the caret position. + setTimeout(() => showSlashMenu(true), 0); + } + }); + + document.addEventListener('mousedown', (ev) => { + if (menuEl && ev.target instanceof globalThis.Node && !menuEl.contains(ev.target)) { + closeMenu(); + } + }); + + await setActiveContext(initialCtx); + + PluginAPI.registerHook(PluginHooks.WORK_CONTEXT_CHANGE, (payload) => { + void setActiveContext(payload as WorkContextChangePayload); + }); + PluginAPI.registerHook(PluginHooks.ANY_TASK_UPDATE, (payload) => { + onAnyTaskUpdate(payload as AnyTaskUpdatePayload); + }); + + // Best-effort teardown flush. The throttled save above is the real + // safety net (it commits while the iframe is unquestionably alive); this + // only catches edits made in the last < SAVE_THROTTLE_MS before the + // iframe is discarded. `pagehide` and `unload` are both wired up because + // browsers are inconsistent about which fires when an iframe element is + // removed from the DOM — flushSaveSync is idempotent, so double-firing + // is harmless. + window.addEventListener('pagehide', () => flushSaveSync()); + window.addEventListener('unload', () => flushSaveSync()); +}; + +/** + * Wait for the host to inject the PluginAPI global before bootstrapping. + * Bounded so a misconfigured host (no injection) doesn't leave us busy- + * polling forever — log and bail after ~5s, after which mount() will be + * unable to run anyway. + */ +const waitForPluginAPI = (): Promise => + new Promise((resolve, reject) => { + const INTERVAL_MS = 20; + const MAX_ATTEMPTS = 250; // ~5s total + let attempts = 0; + const check = (): void => { + if ( + typeof (window as unknown as { PluginAPI?: unknown }).PluginAPI !== 'undefined' + ) { + resolve(); + return; + } + attempts++; + if (attempts >= MAX_ATTEMPTS) { + // eslint-disable-next-line no-console + console.error( + '[document-mode] PluginAPI not injected after', + MAX_ATTEMPTS * INTERVAL_MS, + 'ms — giving up', + ); + reject(new Error('PluginAPI injection timed out')); + return; + } + setTimeout(check, INTERVAL_MS); + }; + check(); + }); + +void waitForPluginAPI() + .then(() => mount()) + .catch(() => { + // Already logged in waitForPluginAPI. Replace the blank editor area with + // a visible message so a failed mount doesn't look like an empty / + // broken panel. + const root = document.getElementById('editor-root'); + if (root) { + root.textContent = ''; + const msg = document.createElement('div'); + msg.className = 'doc-error-state'; + msg.textContent = + 'Document Mode could not connect to Super Productivity. ' + + 'Try closing and reopening this panel.'; + root.appendChild(msg); + } + }); diff --git a/packages/plugin-dev/document-mode/src/ui/icons.ts b/packages/plugin-dev/document-mode/src/ui/icons.ts new file mode 100644 index 0000000000..159812bdac --- /dev/null +++ b/packages/plugin-dev/document-mode/src/ui/icons.ts @@ -0,0 +1,38 @@ +/** + * Inline SVG icons for Document Mode. Kept as a static path map so the + * iframe renders icons offline — no Material Icons web font, no Google + * Fonts request. Paths are the standard Material Symbols 24×24 outlines. + */ + +const ICON_PATHS: Record = { + add: 'M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z', + drag_indicator: + 'M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zm-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z', + arrow_upward: 'M4 12l1.41 1.41L11 7.83V20h2V7.83l5.58 5.59L20 12l-8-8-8 8z', + arrow_downward: 'M20 12l-1.41-1.41L13 16.17V4h-2v12.17l-5.58-5.59L4 12l8 8 8-8z', + content_copy: + 'M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z', + delete: 'M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z', + segment: 'M9 18h12v-2H9v2zM3 6v2h18V6H3zm6 7h12v-2H9v2z', + title: 'M5 4v3h5.5v12h3V7H19V4z', + text_fields: 'M2.5 4v3h5v12h3V7h5V4h-13zm19 5h-9v3h3v7h3v-7h3V9z', + short_text: 'M4 9h16v2H4zm0 4h10v2H4z', + format_list_bulleted: + 'M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z', + format_list_numbered: + 'M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1v.9h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z', + format_quote: 'M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z', + code: 'M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z', + horizontal_rule: 'M4 11h16v2H4z', + open_in_new: + 'M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z', + check_circle_outline: + 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-2-7.7L7.7 10.7l-1.4 1.4L10 15.8l8-8-1.4-1.4z', +}; + +/** Render a named icon as an inline `` string. Unknown names render empty. */ +export const iconSvg = (name: string, extraClass = ''): string => { + const path = ICON_PATHS[name] ?? ''; + const cls = extraClass ? `doc-icon ${extraClass}` : 'doc-icon'; + return ``; +}; diff --git a/packages/plugin-dev/document-mode/src/ui/index.html b/packages/plugin-dev/document-mode/src/ui/index.html new file mode 100644 index 0000000000..0690fd8530 --- /dev/null +++ b/packages/plugin-dev/document-mode/src/ui/index.html @@ -0,0 +1,421 @@ + + + + + Document Mode + + + +
+ + + diff --git a/packages/plugin-dev/document-mode/src/ui/task-ref-node.ts b/packages/plugin-dev/document-mode/src/ui/task-ref-node.ts new file mode 100644 index 0000000000..a1a94dec58 --- /dev/null +++ b/packages/plugin-dev/document-mode/src/ui/task-ref-node.ts @@ -0,0 +1,274 @@ +/** + * The `taskRef` / `subTaskRef` TipTap node — a content-bearing block whose + * inline content IS the linked task's title, so typing inside it edits the + * task. `taskRef` is a top-level chip; `subTaskRef` is the indented subtask + * variant. The two are otherwise identical, so a single factory builds + * both — the only differences are the node name, the `.sub-task-ref` CSS + * hook, and which task the Enter-at-end shortcut creates. + * + * The factory takes a `deps` object instead of reaching into `ui/editor.ts` + * module globals: the node is constructed before the `Editor` exists, so + * `getEditor()` is a late-bound getter, and the task-mutating callbacks + * live in editor.ts where the host `PluginAPI` and caches are owned. + */ + +import { Editor, Node, mergeAttributes } from '@tiptap/core'; +import type { NodeViewRendererProps } from '@tiptap/core'; +import type { Node as ProseMirrorNode } from '@tiptap/pm/model'; +import type { TaskLookup } from '../doc-transform'; +import * as docNav from './doc-nav'; + +export type TaskRefVariant = 'taskRef' | 'subTaskRef'; + +/** Editor-side collaborators a task-ref node needs to do its job. */ +export interface TaskRefNodeDeps { + /** Late-bound editor accessor — the node is created before the Editor is. */ + getEditor: () => Editor | null; + /** Resolves a task id to the host's current task. */ + lookupTask: TaskLookup; + /** Toggle a task's done state (host write-back + optimistic chip update). */ + toggleTaskDone: (taskId: string) => void; + /** Delete a task, tolerating an already-gone one. */ + deleteTaskTolerant: (taskId: string) => Promise; + /** Create a new empty top-level task and insert its chip at `insertPos`. */ + createTaskAfter: (insertPos: number) => Promise; + /** Create a new empty subtask under `parentTaskId` and insert its chip. */ + createSubTaskAfter: (insertPos: number, parentTaskId: string) => Promise; +} + +/** Done-toggle markup — a squircle outline with an animated checkmark. */ +const DONE_TOGGLE_SVG = ` + +`; + +/** The chip the caret currently sits in, or null. */ +interface ChipInfo { + atStart: boolean; + atEnd: boolean; + isEmpty: boolean; + taskId: string; + nodePos: number; + nodeSize: number; +} + +/** + * Build the `taskRef` or `subTaskRef` node. Both share content/attrs/parse/ + * render plumbing and the NodeView; `variant` selects the name, the + * `.sub-task-ref` class, the parse/render data attribute, and the + * Enter-at-end behaviour. + */ +export const createTaskRefNode = ( + variant: TaskRefVariant, + deps: TaskRefNodeDeps, +): Node => { + const isSub = variant === 'subTaskRef'; + const dataAttr = isSub ? 'data-sub-task-ref' : 'data-task-ref'; + const cssClass = isSub ? 'task-ref sub-task-ref' : 'task-ref'; + + return Node.create({ + name: variant, + group: 'block', + content: 'inline*', + selectable: true, + draggable: true, + + addKeyboardShortcuts() { + // Chip the caret is in, or null if the selection is elsewhere. + const inChip = (): ChipInfo | null => { + const editor = deps.getEditor(); + if (!editor) return null; + const { $from } = editor.state.selection; + if ($from.parent.type.name !== variant) return null; + const node = $from.parent; + return { + atStart: $from.parentOffset === 0, + atEnd: $from.parentOffset === node.content.size, + isEmpty: node.content.size === 0, + taskId: node.attrs.taskId as string, + nodePos: $from.before($from.depth), + nodeSize: node.nodeSize, + }; + }; + + return { + Enter: () => { + const info = inChip(); + if (!info) return false; + const editor = deps.getEditor(); + if (!editor) return false; + if (info.isEmpty) { + // Empty chip + Enter → convert to a paragraph, delete the + // empty task. Drop the NodeSelection afterwards so a follow-up + // Enter behaves normally (a NodeSelection routes Enter through + // a different path). + if (info.taskId) void deps.deleteTaskTolerant(info.taskId); + editor + .chain() + .focus() + .setNodeSelection(info.nodePos) + .setParagraph() + .setTextSelection(info.nodePos + 1) + .run(); + return true; + } + if (info.atEnd) { + if (isSub) { + // Enter at end of a subtask → another subtask, same parent. + const parentTaskId = docNav.findParentTaskIdBefore( + editor.state.doc, + info.nodePos, + ); + if (!parentTaskId) return false; + void deps.createSubTaskAfter(info.nodePos + info.nodeSize, parentTaskId); + return true; + } + // Enter at end of a parent chip → new empty task below, after + // any subtasks of this task so it lands after the whole group. + const insertAfter = docNav.positionAfterParentGroup( + editor.state.doc, + info.nodePos, + ); + void deps.createTaskAfter(insertAfter); + return true; + } + // Enter in the middle: swallow — splitting would yield two chips + // with the same taskId. + return true; + }, + Backspace: () => { + const info = inChip(); + if (!info) return false; + if (!info.atStart) return false; + if (info.isEmpty) { + // Empty chip + Backspace at start → delete task + remove chip. + if (info.taskId) void deps.deleteTaskTolerant(info.taskId); + const editor = deps.getEditor(); + if (!editor) return false; + editor.chain().focus().setNodeSelection(info.nodePos).deleteSelection().run(); + return true; + } + // Non-empty chip + Backspace at start: suppress the default so + // the chip content isn't merged into the previous block (which + // would detach the title from the task). + return true; + }, + }; + }, + + addAttributes() { + return { + taskId: { default: '' }, + isDone: { + default: false, + parseHTML: (el: HTMLElement) => el.getAttribute('data-done') === 'true', + renderHTML: (attrs) => ({ 'data-done': attrs.isDone ? 'true' : 'false' }), + }, + }; + }, + + parseHTML() { + return [ + { + tag: `div[${dataAttr}]`, + getAttrs: (el: HTMLElement | string) => { + if (typeof el === 'string') return false; + return { + taskId: el.getAttribute('data-task-id') || '', + isDone: el.getAttribute('data-done') === 'true', + }; + }, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'div', + mergeAttributes(HTMLAttributes, { + [dataAttr]: '', + 'data-task-id': HTMLAttributes.taskId, + class: cssClass, + }), + 0, + ]; + }, + + addNodeView() { + return ({ node }: NodeViewRendererProps) => { + const dom = document.createElement('div'); + dom.className = cssClass; + dom.dataset[isSub ? 'subTaskRef' : 'taskRef'] = ''; + dom.dataset.taskId = node.attrs.taskId; + + // Done-toggle: matches the app's . + const toggle = document.createElement('span'); + toggle.className = 'done-toggle'; + toggle.contentEditable = 'false'; + toggle.setAttribute('role', 'checkbox'); + // Keyboard-focusable so a task can be completed without a mouse — + // the chip's only other route is the undiscoverable Mod-Enter. + toggle.setAttribute('tabindex', '0'); + toggle.innerHTML = DONE_TOGGLE_SVG; + + const title = document.createElement('span'); + title.className = 'title'; + + const applyState = (n: ProseMirrorNode): void => { + const task = deps.lookupTask(n.attrs.taskId as string); + if (!task) { + dom.classList.add('is-missing'); + dom.classList.remove('is-done'); + toggle.setAttribute('aria-checked', 'false'); + toggle.setAttribute('aria-disabled', 'true'); + toggle.setAttribute('aria-label', 'Task not found'); + } else { + dom.classList.remove('is-missing'); + // Trust task.isDone (the host's source of truth) — the attr is + // optimistic and only useful for the undo stack. OR-ing would + // keep "done" stuck visually if the host clears it but the doc + // node's attr hasn't been refreshed (e.g. while focused). + const done = !!task.isDone; + dom.classList.toggle('is-done', done); + toggle.setAttribute('aria-checked', done ? 'true' : 'false'); + toggle.removeAttribute('aria-disabled'); + toggle.setAttribute('aria-label', done ? 'Mark as not done' : 'Mark as done'); + } + }; + + toggle.addEventListener('mousedown', (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + deps.toggleTaskDone(node.attrs.taskId as string); + }); + + // Enter / Space activate the toggle for keyboard users. stopPropagation + // keeps the keypress out of ProseMirror's keymap; the document-level + // Mod-Enter handler already ignores an unmodified Enter. + toggle.addEventListener('keydown', (ev) => { + if (ev.key !== 'Enter' && ev.key !== ' ') return; + ev.preventDefault(); + ev.stopPropagation(); + deps.toggleTaskDone(node.attrs.taskId as string); + }); + + dom.appendChild(toggle); + dom.appendChild(title); + applyState(node); + + return { + dom, + contentDOM: title, + update: (updatedNode: ProseMirrorNode): boolean => { + if (updatedNode.type.name !== variant) return false; + if (updatedNode.attrs.taskId !== node.attrs.taskId) return false; + applyState(updatedNode); + return true; + }, + }; + }; + }, + }); +}; diff --git a/packages/plugin-dev/document-mode/tsconfig.json b/packages/plugin-dev/document-mode/tsconfig.json new file mode 100644 index 0000000000..c9434bff69 --- /dev/null +++ b/packages/plugin-dev/document-mode/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "node", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noImplicitAny": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "declaration": false, + "outDir": "./dist", + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/plugin-dev/scripts/build-all.js b/packages/plugin-dev/scripts/build-all.js index e9306a6647..9c5f347b67 100755 --- a/packages/plugin-dev/scripts/build-all.js +++ b/packages/plugin-dev/scripts/build-all.js @@ -346,6 +346,30 @@ const plugins = [ return 'Built and copied to assets'; }, }, + { + name: 'document-mode', + path: 'document-mode', + needsInstall: true, + copyToAssets: true, + buildCommand: async (pluginPath) => { + await execAsync(`cd ${pluginPath} && npm run build`); + const targetDir = path.join( + __dirname, + '../../../src/assets/bundled-plugins/document-mode', + ); + if (!fs.existsSync(targetDir)) { + fs.mkdirSync(targetDir, { recursive: true }); + } + // editor.js is inlined into index.html; only ship the runtime files. + const distFiles = ['manifest.json', 'plugin.js', 'index.html', 'icon.svg']; + for (const file of distFiles) { + const src = path.join(pluginPath, 'dist', file); + const dest = path.join(targetDir, file); + if (fs.existsSync(src)) copyRecursive(src, dest); + } + return 'Built and copied to assets'; + }, + }, ]; async function buildPlugin(plugin) { From e7b4bb1b7f5f5b311f76f00e7617c71d3a4ec0e5 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 22 May 2026 17:33:29 +0200 Subject: [PATCH 32/42] fix(tasks): guard short-syntax effect against deleted tasks The short-syntax effect could run for a task that was removed between dispatch and effect execution. Guard against the missing task so it no longer throws when the entity is gone. --- src/app/features/tasks/store/short-syntax.effects.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/features/tasks/store/short-syntax.effects.ts b/src/app/features/tasks/store/short-syntax.effects.ts index ef42cd4578..f7880b910e 100644 --- a/src/app/features/tasks/store/short-syntax.effects.ts +++ b/src/app/features/tasks/store/short-syntax.effects.ts @@ -71,6 +71,7 @@ export class ShortSyntaxEffects { })), ); }), + filter(({ task }) => !!task), withLatestFrom( this._tagService.tagsNoMyDayAndNoList$, this._projectService.list$, From 508998c6a106f7a049c40980f64ae76bba9459b7 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 22 May 2026 17:49:25 +0200 Subject: [PATCH 33/42] Improve on sync (#7736) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(android): restore share title derivation and dedupe shared tasks Commit d32f7037a3 accidentally reverted the EXTRA_SUBJECT handling from edb102534e, so the share handler stopped sending the page subject and defaulted the title to the literal "Shared Content". The frontend's subject -> title -> derived title chain then always fell through to that placeholder, producing blank-looking shared tasks. - Restore EXTRA_SUBJECT extraction; leave title/subject empty when absent so the frontend can derive a meaningful title from the URL or note. - Ignore empty/blank shared text instead of creating a useless task. - Skip handleIntent() on Activity recreation (config change) so the same share Intent isn't re-processed into a duplicate task. - Extract buildTaskTitle/readableUrl as pure functions with unit tests and guard the effect against empty payloads. * fix(snack): scale error/warning snack duration with message length Long error messages (e.g. multi-sentence sync errors) were auto-dismissed after a fixed 8s, too short to read. Error/warning snacks now stay visible proportional to message length (~90ms/char), clamped to 10-30s. * feat(tasks): skip undo snack when deleting a blank task A sub task or parent task with an empty title and no data (notes, time, estimate, attachments, issue link, reminder, repeat, scheduling, deadline, non-blank sub tasks) no longer shows the undo-delete snack. * fix(sync): include archive data in REPAIR operations validateAndRepairCurrentState built the REPAIR op from the synchronous getStateSnapshot(), which hardcodes empty archiveYoung/archiveOld (archives live in IndexedDB, not NgRx state). The resulting REPAIR op carried empty archives, so every other client that applied it overwrote its archive with nothing — wiping archived tasks on all devices except the one that ran the repair. - Use getStateSnapshotAsync() so the REPAIR op carries real archives. - Extend the empty-archive overwrite guard in ArchiveOperationHandler._handleLoadAllData() to also cover OpType.Repair (previously only SYNC_IMPORT/BACKUP_IMPORT), as defense in depth. * test(sync): add archive REPAIR round-trip integration test Wires the real StateSnapshotService, ArchiveDbAdapter, ArchiveStoreService and ArchiveOperationHandler against real IndexedDB to verify archive data survives the REPAIR-op round-trip: - getStateSnapshotAsync() loads IndexedDB archives; getStateSnapshot() does not - archive round-trips from client A's IndexedDB through a REPAIR op into a fresh client B's IndexedDB - a REPAIR op carrying empty archives no longer wipes a client that has archive data (empty-archive guard regression) * perf(sync): skip archive IndexedDB reads when post-sync state is valid validateAndRepairCurrentState validated the full async snapshot (two IndexedDB archive reads + structured-clone deserialization) on every Checkpoint D, even when state was valid and no repair was needed. It now validates the cheap synchronous snapshot first and only loads the async snapshot (with archives) when a repair is actually required — the rare path. The REPAIR op still carries archive data. Also addresses multi-review follow-ups: - archive-operation-handler: reword the empty-archive guard comment so it no longer over-promises reconciliation for REPAIR ops. - archive-repair-roundtrip test: add isPersistent to the applied-op meta to match the real applier; scope the file docstring accurately. * fix(task-repeat-cfg): schedule inbox task for today when made recurring When an Inbox task (no dueDay) was made repeatable via the dialog with a recurrence starting today, it stayed unscheduled. The TODAY-first-occurrence branch of updateTaskAfterMakingItRepeatable$ derived currentDueDay from task.created as a fallback, so a task created today looked already scheduled and dueDay was never set. Key the decision on task.dueDay directly. Skip timed tasks and tasks that already have dueWithTime, since dueDay/dueWithTime are mutually exclusive and timed scheduling is handled by addRepeatCfgToTaskUpdateTask$. Closes #7725 * fix(tasks): correct monthly first/last-day recurrence anchoring The "Every month on the first day" and "Every month on the last day" quick settings scheduled the first task instance in the past. Both presets produced a backdated startDate (1st of the current month; hardcoded January 31), which getFirstRepeatOccurrence returns verbatim for monthly recurrences. - MONTHLY_FIRST_DAY now anchors startDate to the next 1st-of-month that is today or later. - MONTHLY_LAST_DAY anchors startDate to the current month's last day and sets a new monthlyLastDay flag, so the occurrence engine clamps to month-end every month regardless of startDate's day-of-month. - _normalizeMonthlyAnchor strips a stale monthlyLastDay flag when a config leaves the preset (CUSTOM mode has no control for it). Closes #7726 * fix(task-repeat-cfg): re-anchor start date after instance deleted When the user moved a repeat config's startDate earlier after deleting its only live task instance, the stale lastTaskCreationDay anchor kept suppressing every projected/created instance between the new startDate and the old anchor. rescheduleTaskOnRepeatCfgUpdate$ only re-anchored lastTaskCreationDay when a live task instance existed (the #7423 fix) — it returned early before the re-anchoring when there was none. Hoist the isStartDateMovedEarlier detection above that early return: when no live instance exists but startDate moved earlier, re-anchor to the day before the new first occurrence so it and every following day is created and projected fresh. Closes #7724 * test(task-repeat-cfg): cover startDate re-anchor with no live instance Add coverage for the #7724 fix beyond the effect unit test: - Selector integration tests: feed a config re-anchored to the day before the new startDate through selectTaskRepeatCfgsForExactDay and assert it projects the new startDate and every following day, while still excluding the anchor day and earlier. Also documents that the stale anchor suppresses the gap days. - E2E reproduction (recurring-move-start-date-earlier-no-instance): create a recurring task, delete its live instance, move startDate earlier via a transparent projection, and assert the new days appear in the planner. Verified to fail on pre-fix code. * feat(sync): move clientId from pf into SUP_OPS for atomic rotation Migrate the sync clientId out of the legacy `pf` IndexedDB database into `SUP_OPS` (new `client_id` store, schema v6). The clientId write now joins the atomic transaction in `runDestructiveStateReplacement`, so destructive flows (clean-slate, backup-restore) rotate it atomically with OPS/STATE_CACHE/VECTOR_CLOCK instead of a hand-rolled cross-database two-phase commit. - ClientIdService rewritten: SUP_OPS-backed via an independent connection, inline one-time pf->SUP_OPS migration, error-aware resolver. Read failures propagate (getOrGenerateClientId never mints a fresh id over a transient error — that would orphan the device's non-regenerable identity); loadClientId never throws. - Delete withRotation, generateNewClientId and the CAS/rollback machinery. - Extract pure generateClientId() + isValidClientIdFormat() into core/util/generate-client-id.ts. - pf becomes a read-only, one-time migration source (never written/deleted). Closes #7732 * fix(sync): dedup SUP_OPS connection open in ClientIdService Address multi-agent review findings on the clientId migration: - _getSupOpsDb() shares a single in-flight open via _supOpsDbPromise; concurrent cold-start callers previously each opened their own SUP_OPS connection, leaking all but the last. - _putClientIdIfAbsent() collapsed to a single tx.done / exit point. - db-upgrade.spec.ts: cover the v6 client_id store; the createObjectStore count assertions were stale and failing after the schema bump. - operation-log-migration.service.ts: correct a misleading comment about the genesis-op clientId fallback. * refactor(sync): align ClientIdService SUP_OPS open with in-house idiom Re-review of the connection-leak fix recommended matching OperationLogStoreService._ensureInit's pattern: - _getSupOpsDb() clears the in-flight promise in .catch (failure only) instead of an unconditional finally — the resolved handle lives in _supOpsDb, so the promise field is pure in-flight coordination state. - close/versionchange handlers now also null _supOpsDbPromise, so a stale (closed) connection is never re-handed-out. - Add a regression test asserting _openSupOpsDb runs exactly once for concurrent cold-start callers. * docs(sync): link the single-connection follow-up to #7735 Reference the tracked follow-up issue from the ClientIdService JSDoc and the plan's out-of-scope section, so the deliberate trade-off (one extra SUP_OPS connection) is traceable rather than forgotten. * test(sync): update ClientIdService spies for getOrGenerateClientId The op-log capture effect now resolves the clientId via getOrGenerateClientId() (was loadClientId() ?? generateNewClientId()). These two specs still mocked only loadClientId, so the effect called an undefined method, captured no op, and 4 tests failed in the full suite. - task-done-replay.integration.spec.ts - operation-log-lock-reentry.regression.spec.ts * test(sync): open SUP_OPS versionless in e2e read helpers DB_VERSION was bumped 5->6; five e2e helpers still opened SUP_OPS at the hardcoded old version 5 to read state after the app had already upgraded it to v6, throwing VersionError. Open versionless instead — matches the ~10 other e2e files that already do, and is future-proof against the next schema bump. - migration/legacy-data-migration.spec.ts (x2) - sync/supersync-legacy-migration-sync.spec.ts - sync/webdav-legacy-migration-sync.spec.ts - recurring/invalid-clock-string-bug-7067.spec.ts --- .../CapacitorMainActivity.kt | 20 +- .../2026-05-22-clientid-migrate-to-sup-ops.md | 491 ++++++++++++++++++ .../operation-log-architecture.md | 12 + .../migration/legacy-data-migration.spec.ts | 4 +- .../invalid-clock-string-bug-7067.spec.ts | 2 +- ...-date-earlier-no-instance-bug-7724.spec.ts | 144 +++++ .../supersync-legacy-migration-sync.spec.ts | 2 +- .../sync/webdav-legacy-migration-sync.spec.ts | 2 +- src/app/core/snack/snack.service.ts | 21 +- src/app/core/util/client-id.service.spec.ts | 436 +++++++--------- src/app/core/util/client-id.service.ts | 417 +++++++-------- src/app/core/util/generate-client-id.spec.ts | 48 ++ src/app/core/util/generate-client-id.ts | 73 +++ src/app/features/android/android-interface.ts | 14 +- .../android/store/android.effects.spec.ts | 87 ++++ .../features/android/store/android.effects.ts | 103 ++-- ...log-edit-task-repeat-cfg.component.spec.ts | 29 ++ .../dialog-edit-task-repeat-cfg.component.ts | 33 +- .../get-quick-setting-updates.spec.ts | 69 ++- .../get-quick-setting-updates.ts | 36 +- .../get-first-repeat-occurrence.util.spec.ts | 56 ++ .../store/get-first-repeat-occurrence.util.ts | 7 + .../get-newest-possible-due-date.util.spec.ts | 34 ++ .../get-newest-possible-due-date.util.ts | 6 +- .../get-next-repeat-occurrence.util.spec.ts | 44 ++ .../store/get-next-repeat-occurrence.util.ts | 6 +- .../store/task-repeat-cfg.effects.spec.ts | 147 ++++++ .../store/task-repeat-cfg.effects.ts | 46 +- .../store/task-repeat-cfg.selectors.spec.ts | 49 ++ .../task-repeat-cfg/task-repeat-cfg.model.ts | 8 + .../tasks/store/task-ui.effects.spec.ts | 103 +++- .../features/tasks/store/task-ui.effects.ts | 3 + .../features/tasks/util/is-blank-task.spec.ts | 118 +++++ src/app/features/tasks/util/is-blank-task.ts | 30 ++ .../archive-operation-handler.service.spec.ts | 44 ++ .../archive-operation-handler.service.ts | 35 +- src/app/op-log/backup/backup.service.spec.ts | 43 +- src/app/op-log/backup/backup.service.ts | 72 ++- .../capture/operation-log.effects.spec.ts | 31 +- .../op-log/capture/operation-log.effects.ts | 12 +- .../clean-slate/clean-slate.service.spec.ts | 51 +- .../op-log/clean-slate/clean-slate.service.ts | 93 ++-- src/app/op-log/persistence/db-keys.const.ts | 4 +- src/app/op-log/persistence/db-upgrade.spec.ts | 33 +- src/app/op-log/persistence/db-upgrade.ts | 9 + .../operation-log-migration.service.spec.ts | 10 +- .../operation-log-migration.service.ts | 18 +- .../operation-log-store.service.spec.ts | 57 ++ .../operation-log-store.service.ts | 38 +- .../sync-hydration.service.spec.ts | 1 - ...ration-log-lock-reentry.regression.spec.ts | 6 +- ...chive-repair-roundtrip.integration.spec.ts | 148 ++++++ .../clean-slate-interrupt.integration.spec.ts | 41 +- .../legacy-data-migration.integration.spec.ts | 2 +- ...emote-apply-store-port.integration.spec.ts | 1 + .../task-done-replay.integration.spec.ts | 4 +- .../op-log/util/client-id.provider.spec.ts | 9 +- src/app/op-log/util/client-id.provider.ts | 19 +- .../validation/validate-state.service.spec.ts | 64 ++- .../validation/validate-state.service.ts | 20 +- 60 files changed, 2754 insertions(+), 811 deletions(-) create mode 100644 docs/plans/2026-05-22-clientid-migrate-to-sup-ops.md create mode 100644 e2e/tests/recurring/recurring-move-start-date-earlier-no-instance-bug-7724.spec.ts create mode 100644 src/app/core/util/generate-client-id.spec.ts create mode 100644 src/app/core/util/generate-client-id.ts create mode 100644 src/app/features/android/store/android.effects.spec.ts create mode 100644 src/app/features/tasks/util/is-blank-task.spec.ts create mode 100644 src/app/features/tasks/util/is-blank-task.ts create mode 100644 src/app/op-log/testing/integration/archive-repair-roundtrip.integration.spec.ts diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/CapacitorMainActivity.kt b/android/app/src/main/java/com/superproductivity/superproductivity/CapacitorMainActivity.kt index ebf420817c..b2b5ad53ec 100644 --- a/android/app/src/main/java/com/superproductivity/superproductivity/CapacitorMainActivity.kt +++ b/android/app/src/main/java/com/superproductivity/superproductivity/CapacitorMainActivity.kt @@ -223,8 +223,13 @@ class CapacitorMainActivity : BridgeActivity() { SyncReminderScheduler.ensureScheduled(this) } - // Handle initial intent (cold start) - handleIntent(intent) + // Handle initial intent (cold start) only on a fresh launch. + // On Activity recreation (config change) savedInstanceState is non-null + // and getIntent() still holds the original share/reminder Intent — re-running + // handleIntent() there would create a duplicate task from the same share. + if (savedInstanceState == null) { + handleIntent(intent) + } } private fun showWebViewInitFailureOrThrow(message: String, error: Throwable) { @@ -350,13 +355,20 @@ class CapacitorMainActivity : BridgeActivity() { if (Intent.ACTION_SEND == intent.action && intent.type != null) { if (intent.type?.startsWith("text/") == true) { val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT) - val sharedTitle = intent.getStringExtra(Intent.EXTRA_TITLE) ?: "Shared Content" + // Leave title/subject empty when absent so the frontend can derive a + // meaningful title from the URL or note content. Defaulting to a literal + // "Shared Content" here masks that derivation (issue: blank shared tasks). + val sharedTitle = intent.getStringExtra(Intent.EXTRA_TITLE) ?: "" + val sharedSubject = intent.getStringExtra(Intent.EXTRA_SUBJECT) ?: "" Log.d("SP_SHARE", "Shared text: $sharedText") Log.d("SP_SHARE", "Shared title: $sharedTitle") + Log.d("SP_SHARE", "Shared subject: $sharedSubject") - if (sharedText != null) { + // Ignore empty/blank shares — they only produce useless blank tasks. + if (!sharedText.isNullOrBlank()) { val json = JSONObject() json.put("title", sharedTitle) + json.put("subject", sharedSubject) val type = if (sharedText.startsWith("http")) "LINK" else "NOTE" json.put("type", type) json.put("path", sharedText) diff --git a/docs/plans/2026-05-22-clientid-migrate-to-sup-ops.md b/docs/plans/2026-05-22-clientid-migrate-to-sup-ops.md new file mode 100644 index 0000000000..092d30008d --- /dev/null +++ b/docs/plans/2026-05-22-clientid-migrate-to-sup-ops.md @@ -0,0 +1,491 @@ +# Migrate sync `clientId` from `pf` into `SUP_OPS` + +**Issue:** #7732 — follow-up to PR #7712 / issue #7709 +**Status:** Draft plan — revised after three multi-agent review rounds +**Prerequisite:** #7712 merged (2026-05-22) ✅ + +## Goal + +Move the sync `clientId` out of the legacy `pf` IndexedDB database into +`SUP_OPS` so the clientId write joins the atomic multi-store transaction in +`OperationLogStoreService.runDestructiveStateReplacement()`. Once it does, the +hand-rolled two-phase commit `ClientIdService.withRotation()` (and its CAS guard +and rollback-failure logging) is deleted. + +## Design decisions (and why) + +Three review rounds converged on these. Two reverse earlier-draft mistakes — +kept here as explicit decisions so they are not "re-improved" into bugs again. + +1. **No permanent `pf` mirror.** Downgrading past this schema bump opens + `SUP_OPS` at a lower version than stored → `VersionError` → the op-log/sync + subsystem is dead regardless of where the clientId lives. A mirror cannot + rescue that. `pf` becomes a **read-only, one-time migration source.** + +2. **Self-healing read, no separate migration service.** The `pf → SUP_OPS` + copy happens inline in the clientId resolver, triggered lazily by the first + read. This removes the init-ordering failure mode — the clientId is read very + early and is _non-regenerable_, so a self-gating read is safer than call-order + discipline plus an ordering test. + +3. **`getOrGenerateClientId()` must never generate on a read _failure_.** + _(Reverses an earlier draft.)_ An earlier draft made `loadClientId()` "never + throw" and had `getOrGenerateClientId()` generate whenever it returned + `null`. That converts a transient IndexedDB hiccup into a brand-new clientId + that orphans the device's real, history-bearing id — the exact + non-regenerable loss this issue exists to prevent. The resolver therefore + **propagates IndexedDB read errors**; generation happens _only_ when reads + succeed and confirm no id exists anywhere. This matches today's behavior + (today `getOrGenerateClientId` throws on a DB-open failure rather than + generating). + +4. **`OperationLogMigrationService`'s genesis-op clientId resolution is left + unchanged.** _(Reverses an earlier draft.)_ An earlier draft routed it + through `getOrGenerateClientId()`. That is unsafe: the legacy genesis op is + built as `{ clientId, vectorClock: meta.vectorClock || { [clientId]: 1 } }`, + and `meta.vectorClock` is keyed by the _legacy PFAPI_ identity — the `pf` + `CLIENT_ID` key. The migration must keep resolving the genesis clientId from + `CLIENT_ID` so the op's `clientId` matches its own `vectorClock` keys. + `persistClientId` is therefore **kept** (not deleted), and now also seeds the + new `SUP_OPS` store. + +5. **`ClientIdService` is _not_ relocated in this PR.** The relocation to + `op-log/util/` is a pure rename touching ~12 import sites for zero behavioral + benefit (the `core ↔ op-log` coupling already exists via + `client-id.provider.ts`). Per "minimize changes / stay in scope" it is a + separate follow-up. `ClientIdService` stays in `core/util/` and imports the + `SUP_OPS` schema constants from `op-log/persistence/` (a layering smell, but + no lint rule forbids it and the provider already crosses that boundary). + +Net effect: roughly line-neutral versus today's `withRotation` machinery, but a +clear win in _conceptual_ complexity — cross-database two-phase commit is +replaced by a single in-transaction `put` plus a one-time idempotent copy. + +## Files touched + +| File | Change | +| --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/app/op-log/persistence/db-keys.const.ts` | `DB_VERSION` 5→6; add `STORE_NAMES.CLIENT_ID` | +| `src/app/op-log/persistence/db-upgrade.ts` | Version 6 branch: create `client_id` store | +| `src/app/op-log/persistence/operation-log-store.service.ts` | `OpLogDB` schema entry; `client_id` `put` in `runDestructiveStateReplacement` + post-commit `clearCache()`; `_clearAllDataForTesting` | +| `src/app/core/util/generate-client-id.ts` | **New** — pure `generateClientId()` + `isValidClientIdFormat()` | +| `src/app/core/util/client-id.service.ts` | Rewritten in place — `SUP_OPS`-backed, inline one-time `pf` migration, error-aware resolver, no `withRotation` | +| `src/app/op-log/util/client-id.provider.ts` | Add `clearCache()` to the `ClientIdProvider` interface + factory; doc-note the migration side effect | +| `src/app/op-log/clean-slate/clean-slate.service.ts` | Rewrite: pure id gen, no `withRotation`, no cache handling | +| `src/app/op-log/backup/backup.service.ts` | Rewrite: pure id gen, no `withRotation`, no cache handling | +| `src/app/op-log/capture/operation-log.effects.ts` | `loadClientId() ?? generateNewClientId()` → `getOrGenerateClientId()` | +| `src/app/op-log/persistence/operation-log-migration.service.ts` | `generateNewClientId()` → `getOrGenerateClientId()` for the fallback only; **fix `:249` — stop logging the full clientId**. Clientid resolution otherwise unchanged. | +| `docs/sync-and-op-log/` | Note the clientId now lives in `SUP_OPS` v6 (per CLAUDE.md: op-log changes need doc updates) | +| Specs | see Step 6 | + +`LegacyPfDbService` is **not** modified or used by `ClientIdService` — see Step 4 +for why its error-swallowing `load()` is unsuitable here. + +## Step 1 — Schema bump + +### `db-keys.const.ts` + +```ts +export const DB_VERSION = 6; // was 5 + +export const STORE_NAMES = { + // ...existing... + /** Client ID - sync device identity (singleton, key = SINGLETON_KEY) */ + CLIENT_ID: 'client_id' as const, +} as const; +``` + +`SINGLETON_KEY = 'current'` is reused as the key. + +> **Hard pre-merge gate:** confirm no other in-flight PR also bumps `DB_VERSION` +> to 6. `db-upgrade.ts` runs exactly one callback per version transition; a +> collided version corrupts the `SUP_OPS` schema irrecoverably. + +### `db-upgrade.ts` + +```ts +// Version 6: Add client_id store for atomic clientId rotation. +// Consolidates the sync clientId from legacy 'pf' (key '__client_id_') into +// SUP_OPS so destructive-flow rotation joins runDestructiveStateReplacement's +// atomic transaction. See issue #7732. The runtime copy from 'pf' happens in +// ClientIdService (a versionchange tx cannot read another database). +if (oldVersion < 6) { + db.createObjectStore(STORE_NAMES.CLIENT_ID); +} +``` + +Keyless store (out-of-line key, like `vector_clock`). + +### `operation-log-store.service.ts` — `OpLogDB` schema + +```ts +[STORE_NAMES.CLIENT_ID]: { + key: string; // SINGLETON_KEY + value: string; // the clientId +}; +``` + +Add `STORE_NAMES.CLIENT_ID` to the `_clearAllDataForTesting()` store list and a +matching `.clear()`. (`ArchiveDBSchema` in `archive-store.service.ts` does _not_ +need the entry — that service never touches the store; the shared `runDbUpgrade` +still creates it.) + +## Step 2 — `generate-client-id.ts` (new pure util) + +Extract the existing pure generation logic out of `ClientIdService` into +`src/app/core/util/generate-client-id.ts`: + +```ts +/** Generates a compact client ID: {platform}_{4-char-base62}, e.g. "B_a7Kx". */ +export const generateClientId = (): string => { + /* _generateClientId body */ +}; + +/** True if the id matches a known valid format (legacy length>=10, or new). */ +export const isValidClientIdFormat = (id: unknown): id is string => { + /* ... */ +}; +``` + +`_getEnvironmentId` / `_generateBase62` move here as module-private helpers. +Pure, no DI, no I/O — unit-testable directly, and importable by the +destructive-flow callers (`op-log → core` is the legal dependency direction) +without going through the stateful service. `isValidClientIdFormat` is a type +guard so callers narrow `unknown` reads from IndexedDB cleanly. No external code +imports the current `private _isValidClientIdFormat`, so the extraction is clean. + +## Step 3 — `runDestructiveStateReplacement` joins the clientId write + +In `operation-log-store.service.ts`, in `runDestructiveStateReplacement` (~line +1584): + +- Add `STORE_NAMES.CLIENT_ID` to the `storeNames` array **unconditionally** + (~line 1597) — both callers always rotate; unlike the archive stores it is not + conditional. +- Inside the `try`, **before `await opsStore.clear()` (~line 1615)**, write the + clientId first: + + ```ts + await tx.objectStore(STORE_NAMES.CLIENT_ID).put(syncImportOp.clientId, SINGLETON_KEY); + ``` + + Use an inline `tx.objectStore(...)` call (the value is written once; no hoisted + handle needed). The rotated id is already on the op — no new parameter. + **First-in-tx is deliberate:** the interrupt tests inject failure into + `opsStore.add`; placing the `client_id` `put` first means that injected + failure occurs _after_ the `client_id` `put` is queued, so the abort genuinely + exercises "client_id put queued → tx aborts → `client_id` unchanged." + Atomicity itself is order-independent. + +- After `await tx.done` (~line 1654), invalidate the clientId cache so the next + read sees the rotated value: + + ```ts + this.clientIdProvider.clearCache(); + ``` + + `OperationLogStoreService` already injects `CLIENT_ID_PROVIDER`. Doing the + cache-clear _inside_ `runDestructiveStateReplacement`, bound to `tx.done`, + makes it impossible for a future edit to open a window between commit and + cache-clear. On `catch`/abort, `clearCache()` is not reached and the cache + correctly keeps the old id. + +- Replace the doc-comment paragraph about "Atomicity holds within the `SUP_OPS` + database only … callers own the clientId rollback" with: the clientId now + lives in `SUP_OPS` and rotates atomically with `OPS`/`STATE_CACHE`/`VECTOR_CLOCK`. + +## Step 4 — `ClientIdService` rewrite + +Rewritten **in place** at `src/app/core/util/client-id.service.ts` (no +relocation — decision 5). + +### Databases this service touches + +- **`SUP_OPS`** — an **independent connection** opened via the shared + `runDbUpgrade` + `DB_NAME`/`DB_VERSION`. Independent because + `OperationLogStoreService` injects `CLIENT_ID_PROVIDER` (→ `ClientIdService`), + so delegating back would be a DI cycle. Two same-origin connections to one + store are fine — IndexedDB serializes transactions across them. Register a + `'close'` handler (null the cached handle, reopen on next access — mirror + `OperationLogStoreService.init` at `:194-200`) and a `versionchange` handler + (`db.close()`) so a future v7 upgrade is not blocked. The open does **not** + replicate `OperationLogStoreService`'s heavy retry logic; a transient + `SUP_OPS` open failure surfaces as a thrown error (handled per the resolver + contract below). +- **`pf`** — opened **read-only, directly by this service**, per-call + (open-read-close, no cached handle). It is **not** routed through + `LegacyPfDbService`: that service's `load()`/`loadClientId()` _swallow_ + IndexedDB errors and return `null`, which makes "key genuinely absent" + indistinguishable from "read failed" — and that distinction is exactly what + decision 3 depends on. `ClientIdService`'s own `pf` read lets IndexedDB errors + **propagate**. (Opening a non-existent `pf` creates an empty one; this is + harmless and is already the current behavior.) + +### Final public surface + +```ts +loadClientId(): Promise // never throws; null on absence OR read failure +getOrGenerateClientId(): Promise // resolves, else generates; throws on read failure +persistClientId(id: string): Promise // legacy-migration genesis seed; validated; unconditional +clearCache(): void // invalidate the in-memory cache +``` + +`getOrGenerateClientId` and `loadClientId` keep their current names/signatures, +so `CLIENT_ID_PROVIDER` and its consumers are unaffected. `clearCache()` is +promoted from a test helper to a documented production method (used by +`runDestructiveStateReplacement`); its JSDoc must say so. + +**Deleted:** `withRotation`, `_restorePriorClientIdIfCurrentMatches`, +`_errorName`, `generateNewClientId`, and the in-service generation helpers +(moved to `generate-client-id.ts`). + +### `_resolve()` — the shared resolver (private) + +The one place that answers "what is this device's clientId, migrating it +forward if needed". **Read failures propagate; only a failed copy-forward is +swallowed.** + +``` +private async _resolve(): Promise { + const fromOps = await this._readSupOps(); // throws on IndexedDB read error + if (fromOps) return fromOps; + const fromPf = await this._readPf(); // throws on IndexedDB read error + if (!fromPf) return null; // reads succeeded -> confirmed: no id anywhere + try { + return await this._putClientIdIfAbsent(() => fromPf); + } catch { + // Copy-forward to SUP_OPS failed (quota, closed conn). The pf id is valid; + // return it and let a later launch retry the copy. Worst case: redundant copy. + return fromPf; + } +} +``` + +- `_readSupOps()` — open `SUP_OPS`, `get(client_id, SINGLETON_KEY)`, + `isValidClientIdFormat`-gate (invalid → `null`, never throw on bad _format_ — + issue #6197). IndexedDB _errors_ propagate. +- `_readPf()` — open `pf` read-only, read `__client_id_` then `CLIENT_ID`, + format-gate, return first valid or `null`. IndexedDB _errors_ propagate. + +> **`pf` key precedence.** `__client_id_` is the key `ClientIdService` has always +> operated on (every current `loadClientId()` reads it); on an op-log-era device +> it is the live identity. `CLIENT_ID` is the original PFAPI key, read only as a +> fallback to seed a legacy profile. On a legacy device migrating for the first +> time, `OperationLogMigrationService` resolves the genesis op from `CLIENT_ID` +> and `persistClientId` writes it **unconditionally** to `SUP_OPS` — so it wins +> over any `__client_id_`-derived copy, keeping the genesis op consistent with +> its `meta.vectorClock` (decision 4). + +### `_putClientIdIfAbsent(factory)` — shared single-tx CAS (private) + +``` +private async _putClientIdIfAbsent(factory: () => string | null): Promise { + const tx = supOpsDb.transaction(CLIENT_ID, 'readwrite'); + const raced = await tx.store.get(SINGLETON_KEY); + if (isValidClientIdFormat(raced)) { await tx.done; return raced; } + const next = factory(); + if (next) await tx.store.put(next, SINGLETON_KEY); + await tx.done; + return next; +} +``` + +The in-tx re-check is load-bearing: IndexedDB serializes same-store transactions +across same-origin connections, so a write that committed first (another tab's +generate, or a rotation) is observed by `raced` and **wins** — the helper never +clobbers it. Comment it as a _multi-tab / rotation_ guard. `persistClientId` and +`runDestructiveStateReplacement` are the two _unconditional_ writers (they know +the exact intended value); `_putClientIdIfAbsent` is the _establish-if-absent_ +writer — that asymmetry is deliberate. + +### `loadClientId()` — swallowing reader + +``` +if (_cachedClientId) return _cachedClientId; +try { + const id = await this._resolve(); + if (id) _cachedClientId = id; + return id; +} catch { + return null; // never throws — callers (hydrator, sync readers) tolerate null +} +``` + +The cache is the migration's memoization: once warm, `_resolve()` (and the `pf` +read) never run again. Concurrent first-launch callers may each run `_resolve()` +— harmless, `_putClientIdIfAbsent` is idempotent. + +### `getOrGenerateClientId()` — resolves or generates + +``` +if (_cachedClientId) return _cachedClientId; +const existing = await this._resolve(); // PROPAGATES read failures — does not swallow +if (existing) { _cachedClientId = existing; return existing; } +// Reads succeeded and confirmed empty everywhere -> safe to generate. +const id = await this._putClientIdIfAbsent(() => generateClientId()); +_cachedClientId = id; +return id; +``` + +If `_resolve()` throws (transient `SUP_OPS`/`pf` read failure), +`getOrGenerateClientId()` throws — it does **not** generate. This is the same +contract as today (`generateNewClientId` already throws on IDB failure); callers +(`operation-log.effects.ts:171-175`, and via the provider +`file-based-encryption.service.ts`, `snapshot-upload.service.ts`) already treat +a failed clientId resolution as fatal-and-retryable. + +### `persistClientId(id)` — legacy-migration genesis seed + +Validate format, **unconditionally** `put` into `SUP_OPS.client_id`, set the +cache. Unconditional (not CAS) because it carries the authoritative legacy +`CLIENT_ID` value that the genesis op is built from and must win over any +`__client_id_`-derived migration copy. No `pf` write. + +## Step 5 — Rewrite the callers + +### `clean-slate.service.ts` / `backup.service.ts` + +Both already rotate inside `lockService.request(LOCK_NAMES.OPERATION_LOG, …)`. +The rewrite — no `withRotation`, no try/catch, no cache handling: + +```ts +import { generateClientId } from '../../core/util/generate-client-id'; + +const newClientId = generateClientId(); // pure — persisted only inside the tx +const syncImportOp: Operation = { + /* ...clientId: newClientId... */ +}; +await this.opLogStore.runDestructiveStateReplacement({ syncImportOp /* ... */ }); +// runDestructiveStateReplacement committed the new clientId and cleared the +// cache; nothing else to do. On throw, the tx aborted and the old id stands. +``` + +Update the class/method doc comments that describe the cross-DB rollback. + +### `operation-log.effects.ts` + +Replace `loadClientId() ?? generateNewClientId()` (`:168-170`) with +`await this.clientIdService.getOrGenerateClientId()`. + +### `operation-log-migration.service.ts` + +Clientid resolution is **unchanged** (decision 4) — keep `:239` +(`loadMetaModel`), keep `legacyPfDb.loadClientId()` reading `CLIENT_ID`, keep +`persistClientId(legacyClientId)`. Two edits only: + +- `:241` fallback: `legacyClientId || generateNewClientId()` → + `legacyClientId ?? (await this.clientIdService.getOrGenerateClientId())` + (`generateNewClientId` is deleted; the fallback only fires when there is no + legacy identity to preserve, so generating is correct). +- `:249`: stop logging the literal clientId + (`OpLog.normal(\`...Using client ID: ${clientId}\`)`— a CLAUDE.md sync-rule-9 +violation, log history is user-exportable). Log a 3-char suffix only, +consistent with`clean-slate.service.ts`. Audit the remaining `OpLog` calls in + every touched file (spot-checked: the rest are already value-free). + +## Step 6 — Tests + +### `client-id.service.spec.ts` (rewritten, stays in `core/util/`) + +Drop all `withRotation` tests. Behavioral matrix: + +| Case | Expectation | +| ------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `SUP_OPS.client_id` populated | returned directly; `pf` not opened | +| `SUP_OPS` empty, `pf.__client_id_` valid | migrated into `SUP_OPS`; id unchanged | +| `SUP_OPS` empty, only `pf.CLIENT_ID` | migrated; covers the bridge-ordering gap | +| `SUP_OPS` empty, both `pf` keys valid and differ | `__client_id_` wins | +| `SUP_OPS` invalid format, `pf` valid | `pf` value wins, overwrites `SUP_OPS` | +| nothing anywhere | `loadClientId()` → `null`; `getOrGenerateClientId()` generates | +| multi-tab fresh generate | two `getOrGenerateClientId()` over one fake-IDB converge on one id | +| **`SUP_OPS` read throws** | `loadClientId()` → `null` (no throw); `getOrGenerateClientId()` **throws, does not generate** | +| **`pf` read throws** | same — `loadClientId()` → `null`; `getOrGenerateClientId()` throws, no generation | +| **migration copy-forward write fails (quota)** | `loadClientId()` & `getOrGenerateClientId()` return the `pf` id; no throw, no generation | +| `persistClientId` | unconditional `SUP_OPS` write; cache set; rejects invalid format | +| `generateClientId` / `isValidClientIdFormat` util | pure; correct format / guard | + +The three **bold** rows are the data-safety core — they prove a transient +IndexedDB failure cannot mint a new clientId. They must use a fake-IDB that can +be made to throw, not just be empty. + +### Destructive-flow atomicity + +`clean-slate-interrupt.integration.spec.ts` must be **extended**: seed +`SUP_OPS.client_id` with a valid-format id, run the existing `opsStore.add`-throw +interrupt, and assert `SUP_OPS.client_id` is unchanged after the abort (this is +the property `withRotation` used to provide by hand). Existing fixtures that seed +short ids like `'cPrior'` must switch to valid-format ids (`B_xxxx` / length ≥ +10), or the new format guard treats them as absent. + +### Other specs to update + +Grep `withRotation`, `generateNewClientId`, `persistClientId`. Known set: + +- **`withRotation` removed:** `clean-slate.service.spec.ts`, + `backup.service.spec.ts`, `clean-slate-interrupt.integration.spec.ts`, + `operation-log.effects.spec.ts` — delete `withRotation` mocks/expectations; + the `ClientIdService` spy surface becomes `getOrGenerateClientId` (+ no manual + cache handling — `runDestructiveStateReplacement` owns `clearCache`). +- **`generateNewClientId` removed:** `client-id.provider.spec.ts`, + `sync-hydration.service.spec.ts`, + `legacy-data-migration.integration.spec.ts` — remove it from + `jasmine.createSpyObj` arrays; `client-id.provider.spec.ts:15` assertion + dropped. +- **`operation-log-migration.service.spec.ts`:** `persistClientId` is **kept**, + so its tests at `:418`/`:434` largely stand; only swap the `generateNewClientId` + spy/`callFake` (incl. the manual logic at `:374-379`) for + `getOrGenerateClientId`. + +### Test teardown + +`_clearAllDataForTesting()` clears `SUP_OPS.client_id` but not +`ClientIdService._cachedClientId` (separate service/connection). Specs that clear +data then expect a fresh id must also call `clientIdService.clearCache()`. Most +`_clearAllDataForTesting()` callers never mint a clientId mid-test, so the blast +radius is small — but the relocated/rewritten spec and the caller specs must be +audited. + +## Risks & mitigations + +1. **Non-regenerable clientId (lead risk).** `pf` is never deleted or written. + `_resolve()` only ever _copies_; a failed copy still returns the valid `pf` + id. `getOrGenerateClientId()` generates **only** after reads succeed and + confirm absence everywhere — a transient failure throws, never generates. + Worst case is a redundant copy. +2. **No downgrade support.** Downgrading past v6 → `VersionError` → op-log dead + regardless of the clientId. True of every prior schema bump; not regressed, + not pretended-to-be-supported. No `pf` mirror. +3. **Init ordering.** Eliminated — `_resolve()` self-heals on first read. +4. **Multi-tab.** `_putClientIdIfAbsent`'s single-tx CAS converges concurrent + same-origin runs. Mixed-version tabs cannot serialize, but an old app post-v6 + has a `VersionError`'d op-log anyway — non-functional, not a data-loss path. +5. **Schema-upgrade coordination.** The new `ClientIdService` connection gets a + `versionchange` handler. The pre-existing absence of `versionchange` handlers + on `OperationLogStoreService`/`ArchiveStoreService` is **out of scope** — + adding them helps only future (v6→v7) upgrades, not this one, and is left as + a follow-up to keep this PR's diff minimal. +6. **Legacy genesis-op continuity.** `OperationLogMigrationService`'s clientId + resolution is unchanged (decision 4); the genesis op keeps using `CLIENT_ID`, + matching `meta.vectorClock`'s keys. + +## Out of scope / follow-ups + +The first three are tracked together in **#7735**: + +- Relocating `ClientIdService` to `op-log/util/` (a pure rename, ~12 import + sites) — separate PR. +- Adding `versionchange` handlers to `OperationLogStoreService` / + `ArchiveStoreService`. +- Breaking the `OperationLogStoreService` ↔ `CLIENT_ID_PROVIDER` DI cycle to + collapse onto one shared `SUP_OPS` connection. + +Not yet tracked: + +- Tightening `isValidClientIdFormat` (the legacy `length >= 10` branch accepts + almost any string) — pre-existing, not this PR. +- Deleting the `pf` database — it remains a read-only fallback. + +## Sequencing + +#7712 is merged. Land as a single PR: the schema bump, the service rewrite, and +the caller rewrites are interdependent and cannot be split safely. diff --git a/docs/sync-and-op-log/operation-log-architecture.md b/docs/sync-and-op-log/operation-log-architecture.md index b3515f421f..5d7a925e34 100644 --- a/docs/sync-and-op-log/operation-log-architecture.md +++ b/docs/sync-and-op-log/operation-log-architecture.md @@ -196,6 +196,7 @@ interface StateCache { │ │ meta - Vector clocks, sync state │ │ │ │ archive_young - Recent archived tasks │ │ │ │ archive_old - Old archived tasks │ │ +│ │ client_id - Sync device identity (v6) │ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ │ ALL model data persisted here │ @@ -1841,6 +1842,17 @@ What if data exists in both `pf` AND `SUP_OPS` databases? **Mitigation (current):** Genesis migration is a one-time event. Once SUP_OPS is established, all writes go there. Risk is limited to the migration moment itself. +**Sync `clientId` (SUP_OPS schema v6, issue #7732):** The sync `clientId` — +the device's stable sync identity — lives in the `SUP_OPS` `client_id` store +(key `current`). It used to live in the legacy `pf` database; storing it in +`SUP_OPS` lets destructive flows (clean-slate, backup-restore) rotate it +atomically inside `runDestructiveStateReplacement`'s transaction, instead of a +hand-rolled cross-database two-phase commit. `pf` remains a read-only, one-time +migration source: the first read on a not-yet-migrated device copies the id +forward (`ClientIdService`). The clientId is non-regenerable (it keys the vector +clock), so a transient IndexedDB read failure propagates rather than minting a +fresh id. + ### Compaction During Active Sync **Status:** Handled via Locks diff --git a/e2e/tests/migration/legacy-data-migration.spec.ts b/e2e/tests/migration/legacy-data-migration.spec.ts index 756abc0d7d..a795db30fa 100644 --- a/e2e/tests/migration/legacy-data-migration.spec.ts +++ b/e2e/tests/migration/legacy-data-migration.spec.ts @@ -74,7 +74,7 @@ const readMigratedState = async ( }> => { return page.evaluate(async () => { return new Promise((resolve, reject) => { - const request = indexedDB.open('SUP_OPS', 5); + const request = indexedDB.open('SUP_OPS'); request.onsuccess = (event) => { const db = (event.target as IDBOpenDBRequest).result; const tx = db.transaction('state_cache', 'readonly'); @@ -107,7 +107,7 @@ const readMigratedArchive = async ( }> => { return page.evaluate(async (storeKey) => { return new Promise((resolve, reject) => { - const request = indexedDB.open('SUP_OPS', 5); + const request = indexedDB.open('SUP_OPS'); request.onsuccess = (event) => { const db = (event.target as IDBOpenDBRequest).result; const tx = db.transaction(storeKey, 'readonly'); diff --git a/e2e/tests/recurring/invalid-clock-string-bug-7067.spec.ts b/e2e/tests/recurring/invalid-clock-string-bug-7067.spec.ts index 3a9cc261fa..b8bca45480 100644 --- a/e2e/tests/recurring/invalid-clock-string-bug-7067.spec.ts +++ b/e2e/tests/recurring/invalid-clock-string-bug-7067.spec.ts @@ -113,7 +113,7 @@ test('should not crash when a repeat config has an invalid startTime in the stor // hydrator replays it. const idbCorrupted = await page.evaluate(async (): Promise => { return new Promise((resolve) => { - const req = indexedDB.open('SUP_OPS', 5); + const req = indexedDB.open('SUP_OPS'); req.onerror = () => resolve(false); req.onsuccess = () => { const db = req.result; diff --git a/e2e/tests/recurring/recurring-move-start-date-earlier-no-instance-bug-7724.spec.ts b/e2e/tests/recurring/recurring-move-start-date-earlier-no-instance-bug-7724.spec.ts new file mode 100644 index 0000000000..bafbac14c2 --- /dev/null +++ b/e2e/tests/recurring/recurring-move-start-date-earlier-no-instance-bug-7724.spec.ts @@ -0,0 +1,144 @@ +import { Locator, Page } from '@playwright/test'; +import { expect, test } from '../../fixtures/test.fixture'; + +/** + * Bug: https://github.com/super-productivity/super-productivity/issues/7724 + * + * Sibling of #7423. A recurring config carries `lastTaskCreationDay` — a + * watermark that the planner's projection logic treats as a hard lower bound: + * days at or before it are never projected/created. + * + * `rescheduleTaskOnRepeatCfgUpdate$` re-anchors that watermark when the user + * moves `startDate` earlier — but it used to bail out early when no live task + * instance existed, so the re-anchoring never ran. Result: after the user + * DELETES the materialised instance and then moves `startDate` earlier, the + * stale watermark keeps suppressing every day between the new `startDate` and + * the old watermark. + * + * Repro from the issue (today fixed to 2026-05-01): + * 1. Create task → make recurring (daily) → startDate = 2026-05-04 → save + * 2. Delete the live (non-transparent) instance + * 3. Open the repeat config from a transparent projection → startDate = + * 2026-05-02 → save + * Expected post-fix: the task projects onto May 2, 3 and 4. + * Bug: May 2, 3 and 4 stay empty; projections resume on May 5. + * + * Dates kept close to today so they fit inside the planner's default 15-day + * desktop window without horizontal scrolling. + */ + +const FIXED_TODAY = new Date('2026-05-01T10:00:00'); + +const openRecurDialog = async (page: Page): Promise => { + const recurItem = page + .locator('task-detail-item') + .filter({ has: page.locator('mat-icon', { hasText: /^repeat$/ }) }); + await expect(recurItem).toBeVisible({ timeout: 5000 }); + await recurItem.click(); + const dialog = page.locator('mat-dialog-container'); + await dialog.waitFor({ state: 'visible', timeout: 10000 }); + return dialog; +}; + +// After the live instance is deleted, the repeat config can only be edited via +// a transparent projection in the planner — clicking it opens the same dialog. +const openRecurDialogFromProjection = async ( + page: Page, + taskTitle: string, +): Promise => { + const projection = page + .locator('planner-repeat-projection') + .filter({ hasText: taskTitle }) + .first(); + await expect(projection).toBeVisible({ timeout: 15000 }); + await projection.click(); + const dialog = page.locator('mat-dialog-container'); + await dialog.waitFor({ state: 'visible', timeout: 10000 }); + return dialog; +}; + +// Set the Start date by typing into the matInput directly (en-GB → "DD/MM/YYYY"). +const setStartDate = async (page: Page, ddmmyyyy: string): Promise => { + const dialog = page.locator('mat-dialog-container'); + const startDateInput = dialog + .locator('mat-form-field') + .filter({ hasText: /Start date/i }) + .locator('input') + .first(); + await expect(startDateInput).toBeVisible({ timeout: 5000 }); + await startDateInput.fill(''); + await startDateInput.fill(ddmmyyyy); + await startDateInput.press('Tab'); + await expect(startDateInput).toHaveValue(ddmmyyyy, { timeout: 3000 }); +}; + +const saveDialog = async (page: Page): Promise => { + const dialog = page.locator('mat-dialog-container'); + const saveBtn = dialog.getByRole('button', { name: /Save/i }); + await expect(saveBtn).toBeEnabled({ timeout: 5000 }); + await saveBtn.click(); + await dialog.waitFor({ state: 'hidden', timeout: 10000 }); +}; + +test.describe('Recurring Task - Move Start Date Earlier With No Live Instance (#7724)', () => { + test('moving startDate earlier still projects the new days after the instance is deleted', async ({ + page, + workViewPage, + taskPage, + testPrefix, + }) => { + const taskTitle = `${testPrefix}-MoveStartEarlierNoInstance7724`; + + // Fix today to Friday, May 1, 2026 so calendar interactions are deterministic. + await page.clock.setFixedTime(FIXED_TODAY); + await page.reload(); + await workViewPage.waitForTaskList(); + + // 1. Create the task and make it recurring (daily) starting May 4, 2026. + await workViewPage.addTask(taskTitle); + const task = taskPage.getTaskByText(taskTitle).first(); + await expect(task).toBeVisible({ timeout: 10000 }); + await taskPage.openTaskDetail(task); + await openRecurDialog(page); + await setStartDate(page, '04/05/2026'); + await saveDialog(page); + + // 2. Delete the live (non-transparent) instance. After the first save the + // task has dueDay = May 4; the Inbox project view lists it regardless of + // due day. A plain task delete does NOT touch the repeat config's + // lastTaskCreationDay or deletedInstanceDates. + await page.goto('/#/project/INBOX_PROJECT/tasks'); + await page.waitForLoadState('networkidle'); + const taskRows = page.locator('task').filter({ hasText: taskTitle }); + await expect(taskRows.first()).toBeVisible({ timeout: 15000 }); + await taskRows.first().click({ button: 'right' }); + await page.locator('.mat-mdc-menu-content button.color-warn').click(); + const confirmBtn = page.locator('[e2e="confirmBtn"]'); + await expect(confirmBtn).toBeVisible({ timeout: 5000 }); + await confirmBtn.click(); + await expect(taskRows).toHaveCount(0, { timeout: 10000 }); + + // 3. Re-open the repeat config from a transparent projection and move + // startDate to May 2 — earlier than the stale anchor (May 4). + await page.goto('/#/planner'); + await page.waitForLoadState('networkidle'); + await openRecurDialogFromProjection(page, taskTitle); + await setStartDate(page, '02/05/2026'); + await saveDialog(page); + + // 4. Verify: the task now projects onto May 2, 3 and 4 — the days the stale + // anchor used to suppress. May 5 is the control (it projected pre-fix + // too). No live instance was recreated, so every day is a projection. + await page.goto('/#/planner'); + await page.waitForLoadState('networkidle'); + + for (const date of [/^2\/5$/, /^3\/5$/, /^4\/5$/, /^5\/5$/]) { + const day = page + .locator('planner-day') + .filter({ has: page.locator('.date', { hasText: date }) }); + await expect( + day.locator('planner-repeat-projection').filter({ hasText: taskTitle }), + ).toHaveCount(1, { timeout: 15000 }); + } + }); +}); diff --git a/e2e/tests/sync/supersync-legacy-migration-sync.spec.ts b/e2e/tests/sync/supersync-legacy-migration-sync.spec.ts index c3d14f6a5f..428d013773 100644 --- a/e2e/tests/sync/supersync-legacy-migration-sync.spec.ts +++ b/e2e/tests/sync/supersync-legacy-migration-sync.spec.ts @@ -589,7 +589,7 @@ test.describe('@supersync @migration SuperSync Legacy Migration Sync', () => { // Verify archive data via IndexedDB (archived tasks aren't visible in UI by default) const archiveData = await pageB.evaluate(async () => { return new Promise((resolve, reject) => { - const dbRequest = indexedDB.open('SUP_OPS', 5); + const dbRequest = indexedDB.open('SUP_OPS'); dbRequest.onsuccess = (event) => { const db = (event.target as IDBOpenDBRequest).result; const tx = db.transaction('archive_young', 'readonly'); diff --git a/e2e/tests/sync/webdav-legacy-migration-sync.spec.ts b/e2e/tests/sync/webdav-legacy-migration-sync.spec.ts index 677644cfaa..43af1db5f6 100644 --- a/e2e/tests/sync/webdav-legacy-migration-sync.spec.ts +++ b/e2e/tests/sync/webdav-legacy-migration-sync.spec.ts @@ -729,7 +729,7 @@ test.describe('@webdav @migration WebDAV Legacy Migration Sync', () => { // Verify archive data via IndexedDB (archived tasks aren't visible in UI by default) const archiveData = await pageB.evaluate(async () => { return new Promise((resolve, reject) => { - const dbRequest = indexedDB.open('SUP_OPS', 5); + const dbRequest = indexedDB.open('SUP_OPS'); dbRequest.onsuccess = (event) => { const db = (event.target as IDBOpenDBRequest).result; const tx = db.transaction('archive_young', 'readonly'); diff --git a/src/app/core/snack/snack.service.ts b/src/app/core/snack/snack.service.ts index 6231894a6c..9a5833cb4d 100644 --- a/src/app/core/snack/snack.service.ts +++ b/src/app/core/snack/snack.service.ts @@ -46,8 +46,13 @@ export class SnackService { } } - private _getDefaultDuration(type: SnackParams['type']): number { - return type === 'ERROR' || type === 'WARNING' ? 8000 : DEFAULT_SNACK_CFG.duration; + // ERROR/WARNING snacks scale with message length so long messages stay readable. + private _getDefaultDuration(type: SnackParams['type'], msg: unknown): number { + if (type !== 'ERROR' && type !== 'WARNING') { + return DEFAULT_SNACK_CFG.duration; + } + const length = typeof msg === 'string' ? msg.length : 0; + return Math.min(Math.max(10000, length * 90), 30000); } @debounce(100) @@ -71,16 +76,18 @@ export class SnackService { isSpinner, } = params; + const translatedMsg = isSkipTranslate + ? msg + : typeof (msg as unknown) === 'string' && + this._translateService.instant(msg, translateParams); + const cfg = { ...DEFAULT_SNACK_CFG, - duration: this._getDefaultDuration(type), + duration: this._getDefaultDuration(type, translatedMsg), ...config, data: { ...params, - msg: isSkipTranslate - ? msg - : typeof (msg as unknown) === 'string' && - this._translateService.instant(msg, translateParams), + msg: translatedMsg, }, }; diff --git a/src/app/core/util/client-id.service.spec.ts b/src/app/core/util/client-id.service.spec.ts index 66d1e2ac6e..24db5c4f63 100644 --- a/src/app/core/util/client-id.service.spec.ts +++ b/src/app/core/util/client-id.service.spec.ts @@ -1,285 +1,241 @@ import { TestBed } from '@angular/core/testing'; -import { ClientIdService } from './client-id.service'; -import { SnackService } from '../snack/snack.service'; import { openDB } from 'idb'; -import { OpLog } from '../log'; +import { ClientIdService } from './client-id.service'; +import { + DB_NAME, + DB_VERSION, + SINGLETON_KEY, + STORE_NAMES, +} from '../../op-log/persistence/db-keys.const'; +import { runDbUpgrade } from '../../op-log/persistence/db-upgrade'; -// Constants that mirror the private constants in ClientIdService -const DB_NAME = 'pf'; -const DB_STORE_NAME = 'main'; -const DB_VERSION = 1; -const CLIENT_ID_KEY = '__client_id_'; +// --- raw IndexedDB helpers (bypass ClientIdService to set up / inspect state) --- -/** - * Helper to write a raw value directly to the 'pf' IndexedDB store, - * bypassing ClientIdService validation so we can test recovery paths. - */ -const writeRawClientId = async (value: unknown): Promise => { - const db = await openDB(DB_NAME, DB_VERSION, { - upgrade: (database) => { - if (!database.objectStoreNames.contains(DB_STORE_NAME)) { - database.createObjectStore(DB_STORE_NAME); - } - }, +const openSupOps = (): ReturnType => + openDB(DB_NAME, DB_VERSION, { + upgrade: (db, oldVersion, _newVersion, tx) => runDbUpgrade(db, oldVersion, tx), }); - await db.put(DB_STORE_NAME, value, CLIENT_ID_KEY); + +const seedSupOps = async (value: unknown): Promise => { + const db = await openSupOps(); + await db.put(STORE_NAMES.CLIENT_ID, value, SINGLETON_KEY); db.close(); }; +const readSupOps = async (): Promise => { + const db = await openSupOps(); + const value = await db.get(STORE_NAMES.CLIENT_ID, SINGLETON_KEY); + db.close(); + return value; +}; + +const clearSupOps = async (): Promise => { + const db = await openSupOps(); + await db.clear(STORE_NAMES.CLIENT_ID); + db.close(); +}; + +const PF_DB_NAME = 'pf'; +const PF_STORE = 'main'; +const PF_CLIENT_ID_KEY = '__client_id_'; +const PF_LEGACY_CLIENT_ID_KEY = 'CLIENT_ID'; + +const openPf = (): ReturnType => + openDB(PF_DB_NAME, 1, { + upgrade: (db) => { + if (!db.objectStoreNames.contains(PF_STORE)) { + db.createObjectStore(PF_STORE); + } + }, + }); + +const seedPf = async (key: string, value: unknown): Promise => { + const db = await openPf(); + await db.put(PF_STORE, value, key); + db.close(); +}; + +const clearPf = async (): Promise => { + const db = await openPf(); + await db.delete(PF_STORE, PF_CLIENT_ID_KEY); + await db.delete(PF_STORE, PF_LEGACY_CLIENT_ID_KEY); + db.close(); +}; + +const idbError = (): DOMException => new DOMException('read failed', 'UnknownError'); + describe('ClientIdService', () => { let service: ClientIdService; - let mockSnackService: jasmine.SpyObj; - beforeEach(() => { - mockSnackService = jasmine.createSpyObj('SnackService', ['open']); - - TestBed.configureTestingModule({ - providers: [ClientIdService, { provide: SnackService, useValue: mockSnackService }], - }); + beforeEach(async () => { + TestBed.configureTestingModule({ providers: [ClientIdService] }); service = TestBed.inject(ClientIdService); + await clearSupOps(); + await clearPf(); }); afterEach(async () => { - // Clean up IndexedDB between tests - const db = await openDB(DB_NAME, DB_VERSION, { - upgrade: (database) => { - if (!database.objectStoreNames.contains(DB_STORE_NAME)) { - database.createObjectStore(DB_STORE_NAME); - } - }, - }); - await db.delete(DB_STORE_NAME, CLIENT_ID_KEY); - db.close(); + await clearSupOps(); + await clearPf(); service.clearCache(); }); - describe('loadClientId()', () => { - it('should return null when no clientId is stored', async () => { - const result = await service.loadClientId(); - expect(result).toBeNull(); + describe('resolution from SUP_OPS', () => { + it('returns a populated SUP_OPS id directly without opening pf', async () => { + await seedSupOps('B_H8AR'); + const pfSpy = spyOn(service as any, '_readPf').and.callThrough(); + + expect(await service.loadClientId()).toBe('B_H8AR'); + expect(pfSpy).not.toHaveBeenCalled(); }); - it('should return a valid new-format clientId (e.g. "B_H8AR")', async () => { - await writeRawClientId('B_H8AR'); - const result = await service.loadClientId(); - expect(result).toBe('B_H8AR'); - }); - - it('should return a valid old-format clientId (10+ chars)', async () => { - const oldFormatId = 'LongClientId123'; - await writeRawClientId(oldFormatId); - const result = await service.loadClientId(); - expect(result).toBe(oldFormatId); - }); - - it('should return null (not throw) for an invalid clientId format, enabling recovery (#6197)', async () => { - // Simulate a corrupted or truncated clientId that doesn't match either format - await writeRawClientId('BAD'); - // Must NOT throw - returning null allows caller to generate a fresh clientId - const result = await service.loadClientId(); - expect(result).toBeNull(); - }); - - it('should warn the user when clientId is invalid and will be regenerated', async () => { - await writeRawClientId('BAD'); - await service.loadClientId(); - expect(mockSnackService.open).toHaveBeenCalledWith( - jasmine.objectContaining({ type: 'WARNING' }), - ); - }); - - it('should return null (not throw) for empty string clientId', async () => { - await writeRawClientId(''); - const result = await service.loadClientId(); - expect(result).toBeNull(); - }); - - it('should cache a valid clientId after first load', async () => { - await writeRawClientId('B_H8AR'); + it('caches the id after the first load', async () => { + await seedSupOps('B_H8AR'); const first = await service.loadClientId(); - // Delete from DB to confirm cache is used on second call - const db = await openDB(DB_NAME, DB_VERSION); - await db.delete(DB_STORE_NAME, CLIENT_ID_KEY); - db.close(); - const second = await service.loadClientId(); - expect(second).toBe(first); + // Remove the stored value — a second load must still return the cache. + await clearSupOps(); + expect(await service.loadClientId()).toBe(first); }); }); - describe('generateNewClientId()', () => { - it('should generate a clientId matching the new format', async () => { - const id = await service.generateNewClientId(); - expect(/^[BEAI]_[a-zA-Z0-9]{4}$/.test(id)).toBeTrue(); + describe('one-time migration from legacy pf', () => { + it('migrates pf.__client_id_ into SUP_OPS, unchanged', async () => { + await seedPf(PF_CLIENT_ID_KEY, 'B_H8AR'); + + expect(await service.loadClientId()).toBe('B_H8AR'); + expect(await readSupOps()).toBe('B_H8AR'); }); - it('should persist the generated clientId so loadClientId() returns it', async () => { - const generated = await service.generateNewClientId(); + it('migrates pf.CLIENT_ID when __client_id_ is absent (bridge-ordering gap)', async () => { + await seedPf(PF_LEGACY_CLIENT_ID_KEY, 'LegacyId123456'); + + expect(await service.loadClientId()).toBe('LegacyId123456'); + expect(await readSupOps()).toBe('LegacyId123456'); + }); + + it('prefers __client_id_ over CLIENT_ID when both pf keys are valid', async () => { + await seedPf(PF_CLIENT_ID_KEY, 'B_aaaa'); + await seedPf(PF_LEGACY_CLIENT_ID_KEY, 'LegacyId123456'); + + expect(await service.loadClientId()).toBe('B_aaaa'); + expect(await readSupOps()).toBe('B_aaaa'); + }); + + it('lets a valid pf id win over an invalid-format SUP_OPS value', async () => { + await seedSupOps('BAD'); + await seedPf(PF_CLIENT_ID_KEY, 'B_good'); + + expect(await service.loadClientId()).toBe('B_good'); + expect(await readSupOps()).toBe('B_good'); + }); + }); + + describe('nothing stored anywhere', () => { + it('loadClientId() resolves to null', async () => { + expect(await service.loadClientId()).toBeNull(); + }); + + it('getOrGenerateClientId() generates and persists a fresh id', async () => { + const id = await service.getOrGenerateClientId(); + expect(/^[BEAI]_[a-zA-Z0-9]{4}$/.test(id)).toBeTrue(); + expect(await readSupOps()).toBe(id); + + // Persisted: a fresh service instance resolves to the same id. service.clearCache(); - const loaded = await service.loadClientId(); - expect(loaded).toBe(generated); + expect(await service.loadClientId()).toBe(id); + }); + + it('converges two concurrent getOrGenerateClientId() calls on one id', async () => { + const [a, b] = await Promise.all([ + service.getOrGenerateClientId(), + service.getOrGenerateClientId(), + ]); + expect(a).toBe(b); + expect(await readSupOps()).toBe(a); + }); + + it('opens the SUP_OPS connection once for concurrent cold-start callers', async () => { + // Regression guard: without the in-flight-promise dedup in _getSupOpsDb, + // each racing caller opens — and leaks — its own SUP_OPS connection. + const openSpy = spyOn(service as any, '_openSupOpsDb').and.callThrough(); + + await Promise.all([ + service.getOrGenerateClientId(), + service.getOrGenerateClientId(), + ]); + + expect(openSpy).toHaveBeenCalledTimes(1); + }); + }); + + // The data-safety core: a transient IndexedDB read failure must NEVER mint a + // brand new clientId — that would orphan the device's real identity (#7732). + describe('IndexedDB read failures never generate a new id', () => { + it('SUP_OPS read throws: loadClientId() -> null, getOrGenerateClientId() throws', async () => { + spyOn(service as any, '_getSupOpsDb').and.returnValue( + Promise.resolve({ get: () => Promise.reject(idbError()) }), + ); + + expect(await service.loadClientId()).toBeNull(); + service.clearCache(); + await expectAsync(service.getOrGenerateClientId()).toBeRejected(); + // Nothing was minted into SUP_OPS. + expect(await readSupOps()).toBeUndefined(); + }); + + it('pf read throws: loadClientId() -> null, getOrGenerateClientId() throws', async () => { + spyOn(service as any, '_readPf').and.returnValue(Promise.reject(idbError())); + + expect(await service.loadClientId()).toBeNull(); + service.clearCache(); + await expectAsync(service.getOrGenerateClientId()).toBeRejected(); + expect(await readSupOps()).toBeUndefined(); + }); + + it('copy-forward write fails: returns the valid pf id, no throw, no generation', async () => { + await seedPf(PF_CLIENT_ID_KEY, 'B_good'); + spyOn(service as any, '_putClientIdIfAbsent').and.returnValue( + Promise.reject(new DOMException('quota', 'QuotaExceededError')), + ); + + expect(await service.loadClientId()).toBe('B_good'); + service.clearCache(); + expect(await service.getOrGenerateClientId()).toBe('B_good'); + // The failed copy means SUP_OPS stays empty — a later launch retries it. + expect(await readSupOps()).toBeUndefined(); }); }); describe('getOrGenerateClientId()', () => { - it('should return existing valid clientId', async () => { - await writeRawClientId('B_H8AR'); - const result = await service.getOrGenerateClientId(); - expect(result).toBe('B_H8AR'); + it('returns an existing valid SUP_OPS id without generating', async () => { + await seedSupOps('B_H8AR'); + expect(await service.getOrGenerateClientId()).toBe('B_H8AR'); }); - it('should generate and persist a new clientId when none is stored', async () => { - const result = await service.getOrGenerateClientId(); - expect(/^[BEAI]_[a-zA-Z0-9]{4}$/.test(result)).toBeTrue(); - // Verify it was persisted: clear cache and reload - service.clearCache(); - const loaded = await service.loadClientId(); - expect(loaded).toBe(result); - }); - - it('should generate a new clientId when stored value is invalid', async () => { - await writeRawClientId('BAD'); - const result = await service.getOrGenerateClientId(); - expect(/^[BEAI]_[a-zA-Z0-9]{4}$/.test(result)).toBeTrue(); + it('generates when the stored value is an invalid format', async () => { + await seedSupOps('BAD'); + const id = await service.getOrGenerateClientId(); + expect(/^[BEAI]_[a-zA-Z0-9]{4}$/.test(id)).toBeTrue(); }); }); describe('persistClientId()', () => { - it('should persist a valid new-format clientId', async () => { + it('writes the id into SUP_OPS and sets the cache', async () => { await service.persistClientId('E_abcd'); - service.clearCache(); - const loaded = await service.loadClientId(); - expect(loaded).toBe('E_abcd'); + expect(await readSupOps()).toBe('E_abcd'); + // Cache is set — loadClientId() returns it without re-reading. + expect(await service.loadClientId()).toBe('E_abcd'); }); - it('should persist a valid old-format clientId', async () => { - const oldId = 'OldFormatClientId1'; - await service.persistClientId(oldId); - service.clearCache(); - const loaded = await service.loadClientId(); - expect(loaded).toBe(oldId); + it('writes unconditionally, overwriting an existing SUP_OPS id', async () => { + await seedSupOps('B_old1'); + await service.persistClientId('LegacyId123456'); + expect(await readSupOps()).toBe('LegacyId123456'); }); - it('should throw for invalid format without persisting', async () => { - await expectAsync(service.persistClientId('INVALID')).toBeRejected(); - }); - }); - - describe('withRotation()', () => { - it('should call fn with a fresh clientId and return its value', async () => { - await service.persistClientId('B_Prio'); - service.clearCache(); - - const result = await service.withRotation('[Test]', async (newId) => { - expect(newId).not.toBe('B_Prio'); - return { ok: true, id: newId }; - }); - - expect(result.ok).toBe(true); - const persisted = await service.loadClientId(); - expect(persisted).toBe(result.id); - }); - - it('should restore the prior clientId when fn throws', async () => { - await service.persistClientId('B_Prio'); - service.clearCache(); - - await expectAsync( - service.withRotation('[Test]', async () => { - throw new Error('work failed'); - }), - ).toBeRejectedWith(jasmine.objectContaining({ message: 'work failed' })); - - service.clearCache(); - expect(await service.loadClientId()).toBe('B_Prio'); - }); - - it('should restore the persisted prior clientId even when the cache is stale', async () => { - await service.persistClientId('B_Cach'); - await writeRawClientId('B_Fres'); - - await expectAsync( - service.withRotation('[Test]', async () => { - throw new Error('work failed'); - }), - ).toBeRejectedWith(jasmine.objectContaining({ message: 'work failed' })); - - service.clearCache(); - expect(await service.loadClientId()).toBe('B_Fres'); - }); - - it('should not roll back over a newer persisted clientId from another context', async () => { - await service.persistClientId('B_Prio'); - service.clearCache(); - - await expectAsync( - service.withRotation('[Test]', async () => { - await writeRawClientId('B_Othr'); - throw new Error('work failed'); - }), - ).toBeRejectedWith(jasmine.objectContaining({ message: 'work failed' })); - - service.clearCache(); - expect(await service.loadClientId()).toBe('B_Othr'); - }); - - it('should leave the rotated clientId in place when there was no prior id', async () => { - // Wholly fresh device — `pf` is empty. - expect(await service.loadClientId()).toBeNull(); - - await expectAsync( - service.withRotation('[Test]', async () => { - throw new Error('work failed'); - }), - ).toBeRejectedWith(jasmine.objectContaining({ message: 'work failed' })); - - service.clearCache(); - const persisted = await service.loadClientId(); - expect(persisted).not.toBeNull(); - }); - - it('should propagate the original fn error when rollback also fails', async () => { - await service.persistClientId('B_Prio'); - service.clearCache(); - spyOn(service as any, '_restorePriorClientIdIfCurrentMatches').and.rejectWith( - new Error('pf write also broken'), - ); - - await expectAsync( - service.withRotation('[Test]', async () => { - throw new Error('work failed'); - }), - ).toBeRejectedWith(jasmine.objectContaining({ message: 'work failed' })); - }); - - it('should not log clientIds or raw error messages when rollback fails', async () => { - await service.persistClientId('B_Prio'); - service.clearCache(); - spyOn(service as any, '_restorePriorClientIdIfCurrentMatches').and.rejectWith( - new Error('rollback failed with B_Prio'), - ); - const opLogSpy = spyOn(OpLog, 'critical'); - - await expectAsync( - service.withRotation('[Test]', async () => { - throw new Error('work failed with B_Prio'); - }), - ).toBeRejectedWith( - jasmine.objectContaining({ message: 'work failed with B_Prio' }), - ); - - expect(opLogSpy).toHaveBeenCalled(); - const payload = opLogSpy.calls.mostRecent().args[1] as Record; - const serializedPayload = JSON.stringify(payload); - expect(payload).toEqual( - jasmine.objectContaining({ - hadPriorClientId: true, - originalErrorName: 'Error', - rollbackErrorName: 'Error', - }), - ); - expect(serializedPayload).not.toContain('B_Prio'); - expect(serializedPayload).not.toContain('work failed'); - expect(serializedPayload).not.toContain('rollback failed'); + it('rejects an invalid-format id without persisting', async () => { + await expectAsync(service.persistClientId('BAD')).toBeRejected(); + expect(await readSupOps()).toBeUndefined(); }); }); }); diff --git a/src/app/core/util/client-id.service.ts b/src/app/core/util/client-id.service.ts index d614a726d6..b18c84d5e5 100644 --- a/src/app/core/util/client-id.service.ts +++ b/src/app/core/util/client-id.service.ts @@ -1,287 +1,274 @@ -import { Injectable, inject } from '@angular/core'; -import { openDB, IDBPDatabase } from 'idb'; +import { Injectable } from '@angular/core'; +import { IDBPDatabase, openDB } from 'idb'; import { OpLog } from '../log'; -import { SnackService } from '../snack/snack.service'; -import { T } from '../../t.const'; +import { generateClientId, isValidClientIdFormat } from './generate-client-id'; +import { + DB_NAME as SUP_OPS_DB_NAME, + DB_VERSION as SUP_OPS_DB_VERSION, + SINGLETON_KEY, + STORE_NAMES, +} from '../../op-log/persistence/db-keys.const'; +import { runDbUpgrade } from '../../op-log/persistence/db-upgrade'; -// Database constants - must match PFAPI's storage -const DB_NAME = 'pf'; -const DB_STORE_NAME = 'main'; -const DB_VERSION = 1; -const CLIENT_ID_KEY = '__client_id_'; +// Legacy 'pf' database — a read-only, one-time migration source for the +// clientId. Never written or deleted by this service. See issue #7732. +const PF_DB_NAME = 'pf'; +const PF_DB_STORE_NAME = 'main'; +const PF_DB_VERSION = 1; +/** + * Key ClientIdService has always operated on in `pf`. On an op-log-era device + * this is the live identity. + */ +const PF_CLIENT_ID_KEY = '__client_id_'; +/** + * The original PFAPI key. Read only as a fallback to seed a legacy profile + * that never received a `__client_id_` entry. + */ +const PF_LEGACY_CLIENT_ID_KEY = 'CLIENT_ID'; /** - * Service for managing the sync client ID. + * Service for managing the sync client ID — the device's stable sync identity. * - * Reads/writes directly to IndexedDB to preserve existing client IDs - * and avoid dependency on PfapiService. + * The clientId lives in the `SUP_OPS` database (`client_id` store, schema v6). + * Storing it there lets destructive flows (clean-slate, backup-restore) rotate + * it atomically inside runDestructiveStateReplacement's transaction, instead of + * a hand-rolled cross-database two-phase commit (issue #7732). * - * Uses the same database name ('pf') and key ('__client_id_') as the - * legacy MetaModelCtrl to ensure backward compatibility. + * The legacy `pf` database is a read-only, one-time migration source: the first + * read on a device whose clientId still lives only in `pf` copies it forward + * into `SUP_OPS`. The clientId is non-regenerable (it keys the vector clock), + * so the resolver propagates IndexedDB read errors rather than risk minting a + * fresh id over a transient failure. */ @Injectable({ providedIn: 'root', }) export class ClientIdService { - private _snackService = inject(SnackService, { optional: true }); - private _db: IDBPDatabase | null = null; + private _supOpsDb: IDBPDatabase | null = null; + private _supOpsDbPromise: Promise | null = null; private _cachedClientId: string | null = null; /** - * Loads the client ID. - * - * Uses caching to avoid repeated IndexedDB reads. Rotation paths bypass or - * refresh the cache when they need cross-context freshness. - * - * @returns The client ID, or null if not yet generated + * Loads the client ID. Never throws — returns null on absence OR on a + * transient IndexedDB read failure. Callers (hydrator, sync readers) treat + * a null clientId as a tolerable, retryable condition. */ async loadClientId(): Promise { if (this._cachedClientId) { return this._cachedClientId; } - - const clientId = await this._readPersistedValidClientId({ warnOnInvalid: true }); - - if (clientId) { - this._cachedClientId = clientId; - OpLog.normal('ClientIdService.loadClientId() loaded'); + try { + const id = await this._resolve(); + if (id) { + this._cachedClientId = id; + } + return id; + } catch { + // Read failure — never throw, never generate. Returning null lets the + // caller retry on a later launch without orphaning the real clientId. + return null; } - return clientId; } /** - * Generates a new client ID and saves it. + * Returns the existing client ID, or generates and persists a new one if + * none exists. The preferred entry point for callers that always need an ID. * - * Format: {platform}_{4-char-base62} - * Examples: "B_a7Kx", "E_m2Pq", "A_x9Yz" - * - * @returns The newly generated client ID + * Propagates IndexedDB read failures: if a read throws, this throws too — it + * does NOT generate. Generating on a transient failure would mint a brand + * new clientId that orphans the device's real, history-bearing identity (the + * non-regenerable loss issue #7732 exists to prevent). Generation happens + * only after reads succeed and confirm no id exists anywhere. */ - async generateNewClientId(): Promise { - const newClientId = this._generateClientId(); - - const db = await this._getDb(); - await db.put(DB_STORE_NAME, newClientId, CLIENT_ID_KEY); - - this._cachedClientId = newClientId; - OpLog.normal('ClientIdService.generateNewClientId() generated'); - return newClientId; + async getOrGenerateClientId(): Promise { + if (this._cachedClientId) { + return this._cachedClientId; + } + // PROPAGATES read failures — does not swallow, does not generate on error. + const existing = await this._resolve(); + if (existing) { + this._cachedClientId = existing; + return existing; + } + // Reads succeeded and confirmed empty everywhere — safe to generate. + const id = await this._putClientIdIfAbsent(generateClientId); + this._cachedClientId = id; + OpLog.normal('ClientIdService.getOrGenerateClientId() generated'); + return id; } /** - * Persists an existing client ID (e.g., from legacy migration). + * Persists an existing client ID (legacy-migration genesis seed). * - * Validates the format before writing to prevent invalid IDs from - * being stored. loadClientId() treats an invalid stored ID as missing - * and returns null, causing the caller to regenerate a fresh clientId. + * Writes UNCONDITIONALLY (not a CAS) into SUP_OPS: it carries the + * authoritative legacy PFAPI `CLIENT_ID` value that OperationLogMigration's + * genesis op is built from, and must win over any `__client_id_`-derived + * migration copy so the genesis op stays consistent with its vectorClock. */ async persistClientId(clientId: string): Promise { - if (!this._isValidClientIdFormat(clientId)) { - throw new Error(`Cannot persist invalid clientId: ${clientId}`); + if (!isValidClientIdFormat(clientId)) { + // The clientId value is sensitive (it keys the vector clock) and log + // history is user-exportable — never interpolate it into the message. + throw new Error('Cannot persist invalid clientId'); } - const db = await this._getDb(); - await db.put(DB_STORE_NAME, clientId, CLIENT_ID_KEY); + const db = await this._getSupOpsDb(); + await db.put(STORE_NAMES.CLIENT_ID, clientId, SINGLETON_KEY); this._cachedClientId = clientId; OpLog.normal('ClientIdService.persistClientId() persisted'); } /** - * Returns the existing client ID, or generates and persists a new one if - * none is stored or the stored value is invalid. - * - * This is the preferred entry point for callers that always need a valid ID. - */ - async getOrGenerateClientId(): Promise { - return (await this.loadClientId()) ?? (await this.generateNewClientId()); - } - - /** - * Clears the cached client ID. - * - * Used for testing or when the client ID storage needs to be re-read. + * Invalidates the cached client ID so the next read re-resolves from + * IndexedDB. A documented production method (not just a test helper): + * runDestructiveStateReplacement calls it after rotating the clientId. */ clearCache(): void { this._cachedClientId = null; } /** - * Rotate the clientId for the duration of `fn`. Captures the prior id, - * generates and persists a new one, runs `fn(newClientId)`. If `fn` throws, - * the prior id is restored so the `pf` database stays consistent with any - * caller-side state that didn't get updated. + * Resolves "what is this device's clientId", migrating it forward from the + * legacy `pf` database if needed. * - * Edge case: if there was no prior clientId (wholly fresh device), the new - * id is intentionally left in `pf` on failure — there is nothing to restore - * to. If the restore itself throws, the original `fn` error is propagated - * and the restore failure is logged at critical level for forensics. - * - * `logPrefix` is used to tag the critical-log entry on rollback failure so - * forensics can attribute it to the caller (e.g. `'[CleanSlate]'`). + * Read failures propagate (the caller decides whether to swallow). Only a + * failed copy-forward is swallowed — the `pf` id is still valid and a later + * launch retries the copy. */ - async withRotation( - logPrefix: string, - fn: (newClientId: string) => Promise, - ): Promise { - const priorClientId = await this._readPersistedValidClientId(); - const newClientId = await this.generateNewClientId(); + private async _resolve(): Promise { + const fromOps = await this._readSupOps(); // throws on IndexedDB read error + if (fromOps) { + return fromOps; + } + const fromPf = await this._readPf(); // throws on IndexedDB read error + if (!fromPf) { + // Both reads succeeded -> confirmed: no id stored anywhere. + return null; + } try { - return await fn(newClientId); - } catch (e) { - if (priorClientId) { - try { - await this._restorePriorClientIdIfCurrentMatches(priorClientId, newClientId); - } catch (rollbackErr) { - OpLog.critical( - `${logPrefix} Failed to roll back clientId rotation after failure`, - { - hadPriorClientId: true, - originalErrorName: this._errorName(e), - rollbackErrorName: this._errorName(rollbackErr), - }, - ); - } - } - throw e; + return await this._putClientIdIfAbsent(() => fromPf); + } catch { + // Copy-forward to SUP_OPS failed (quota, closed connection). The `pf` id + // is valid — return it and let a later launch retry the copy. Worst case + // is a redundant copy, never a lost identity. + return fromPf; } } /** - * Returns true if the clientId matches a known valid format. - * Old format: any string of length >= 10 (legacy IDs). - * New format: {platform}_{4-char-base62} e.g. "B_a7Kx". + * Reads the clientId from `SUP_OPS.client_id`. An invalid format is treated + * as absent (returns null — never throws on bad format; see issue #6197). + * IndexedDB *errors* propagate. */ - private _isValidClientIdFormat(clientId: string): boolean { - return clientId.length >= 10 || /^[BEAI]_[a-zA-Z0-9]{4}$/.test(clientId); - } - - private async _readPersistedValidClientId( - options: { warnOnInvalid?: boolean } = {}, - ): Promise { - const db = await this._getDb(); - const clientId = await db.get(DB_STORE_NAME, CLIENT_ID_KEY); - - if (typeof clientId !== 'string') { - return null; - } - - if (!this._isValidClientIdFormat(clientId)) { - if (options.warnOnInvalid) { - // Unrecognized format — log but treat as missing rather than throwing. - // Throwing here permanently blocks sync (issue #6197: "Invalid clientId loaded: B_H8AR"). - // Returning null causes the caller to generate a fresh clientId, which unblocks sync. - // Length only — the literal clientId value is sensitive (vector-clock - // key) and log history is user-exportable (CLAUDE.md sync rule 9). - OpLog.critical( - 'ClientIdService.loadClientId() Invalid clientId format, will regenerate:', - { - length: clientId.length, - }, - ); - this._snackService?.open({ - msg: T.F.SYNC.S.WARN_CLIENT_ID_REGENERATED, - type: 'WARNING', - }); - } - return null; - } - - return clientId; - } - - private async _restorePriorClientIdIfCurrentMatches( - priorClientId: string, - expectedCurrentClientId: string, - ): Promise { - const db = await this._getDb(); - const tx = db.transaction(DB_STORE_NAME, 'readwrite'); - const store = tx.objectStore(DB_STORE_NAME); - const currentClientId = await store.get(CLIENT_ID_KEY); - - if (currentClientId === expectedCurrentClientId) { - await store.put(priorClientId, CLIENT_ID_KEY); - await tx.done; - this._cachedClientId = priorClientId; - return; - } - - await tx.done; - this._cachedClientId = - typeof currentClientId === 'string' && this._isValidClientIdFormat(currentClientId) - ? currentClientId - : null; - } - - private _errorName(error: unknown): string { - return error instanceof Error ? error.name : typeof error; + private async _readSupOps(): Promise { + const db = await this._getSupOpsDb(); + const raw = await db.get(STORE_NAMES.CLIENT_ID, SINGLETON_KEY); + return isValidClientIdFormat(raw) ? raw : null; } /** - * Gets or opens the IndexedDB database. + * Reads the clientId from the legacy `pf` database, read-only, per-call. + * + * Routed directly through `openDB` rather than LegacyPfDbService because that + * service's load() swallows IndexedDB errors and returns null — which makes + * "key absent" indistinguishable from "read failed". This service needs that + * distinction: a read failure must propagate (it must never generate over a + * transient failure). + * + * `__client_id_` (the live identity on op-log-era devices) wins over the + * original PFAPI `CLIENT_ID` key. IndexedDB *errors* propagate. */ - private async _getDb(): Promise { - if (this._db) { - return this._db; - } - - this._db = await openDB(DB_NAME, DB_VERSION, { - upgrade: (db) => { - if (!db.objectStoreNames.contains(DB_STORE_NAME)) { - db.createObjectStore(DB_STORE_NAME); + private async _readPf(): Promise { + const db = await openDB(PF_DB_NAME, PF_DB_VERSION, { + upgrade: (database) => { + if (!database.objectStoreNames.contains(PF_DB_STORE_NAME)) { + database.createObjectStore(PF_DB_STORE_NAME); } }, }); - - return this._db; + try { + const live = await db.get(PF_DB_STORE_NAME, PF_CLIENT_ID_KEY); + if (isValidClientIdFormat(live)) { + return live; + } + const legacy = await db.get(PF_DB_STORE_NAME, PF_LEGACY_CLIENT_ID_KEY); + return isValidClientIdFormat(legacy) ? legacy : null; + } finally { + db.close(); + } } /** - * Generates a compact 6-char client ID. - * Format: {platform}_{4-char-base62-random} + * Establish-if-absent writer: writes `factory()` into SUP_OPS.client_id only + * if no valid id is already there, in a single transaction. + * + * MULTI-TAB / ROTATION GUARD: the in-tx re-check is load-bearing. IndexedDB + * serializes same-store transactions across same-origin connections, so an + * id that committed first (another tab's generate, or a destructive + * rotation) is observed by `raced` and WINS — this helper never clobbers it. + * + * persistClientId and runDestructiveStateReplacement are the *unconditional* + * writers (they know the exact intended value); this is the conditional one. */ - private _generateClientId(): string { - const prefix = this._getEnvironmentId(); - const randomPart = this._generateBase62(4); - return `${prefix}_${randomPart}`; + private async _putClientIdIfAbsent(factory: () => string): Promise { + const db = await this._getSupOpsDb(); + const tx = db.transaction(STORE_NAMES.CLIENT_ID, 'readwrite'); + const raced = await tx.store.get(SINGLETON_KEY); + // A valid id already there (another tab, a rotation) wins — never clobbered. + const resolved = isValidClientIdFormat(raced) ? raced : factory(); + if (resolved !== raced) { + await tx.store.put(resolved, SINGLETON_KEY); + } + await tx.done; + return resolved; } /** - * Returns a single-character platform identifier for compact client IDs. - * B = Browser, E = Electron, A = Android, I = iOS + * Opens (and caches) an independent connection to the `SUP_OPS` database. + * + * Independent — not delegated to OperationLogStoreService — because that + * service injects CLIENT_ID_PROVIDER (-> this service), so delegating back + * would form a DI cycle. Two same-origin connections to one store are safe: + * IndexedDB serializes transactions across them. Collapsing onto a single + * shared connection (by breaking that DI cycle) is tracked in #7735. + * + * Concurrent first callers share one in-flight open via `_supOpsDbPromise` + * (mirrors OperationLogStoreService._ensureInit): without it each racing + * caller would open — and leak — its own connection. The in-flight promise + * is cleared on open failure so the next call retries, and in the + * close/versionchange handlers so a stale handle is never re-handed-out. */ - private _getEnvironmentId(): string { - // Detect Electron - const isElectron = - typeof process !== 'undefined' && (process as any).versions?.electron; - if (isElectron) { - return 'E'; + private async _getSupOpsDb(): Promise { + if (this._supOpsDb) { + return this._supOpsDb; } - - // Detect Android WebView - if (/Android/.test(navigator.userAgent) && /wv/.test(navigator.userAgent)) { - return 'A'; + if (!this._supOpsDbPromise) { + this._supOpsDbPromise = this._openSupOpsDb().catch((e) => { + // A failed open must be retryable — clear so the next call reopens. + this._supOpsDbPromise = null; + throw e; + }); } - - // Detect iOS - if ( - navigator.userAgent.includes('iOS') || - navigator.userAgent.includes('iPhone') || - navigator.userAgent.includes('iPad') - ) { - return 'I'; - } - - // Default: Browser - return 'B'; + return this._supOpsDbPromise; } - /** - * Generates a random base62 string of the specified length. - * Uses crypto.getRandomValues() for non-predictable randomness. - */ - private _generateBase62(length: number): string { - const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - const bytes = new Uint8Array(length); - crypto.getRandomValues(bytes); - return Array.from(bytes, (b) => chars[b % chars.length]).join(''); + private async _openSupOpsDb(): Promise { + const db = await openDB(SUP_OPS_DB_NAME, SUP_OPS_DB_VERSION, { + upgrade: (database, oldVersion, _newVersion, transaction) => { + runDbUpgrade(database, oldVersion, transaction); + }, + }); + // Browser closed the connection — drop both handles, reopen on next access. + db.addEventListener('close', () => { + this._supOpsDb = null; + this._supOpsDbPromise = null; + }); + // Don't block a future (v7) upgrade opened by another connection/tab. + db.addEventListener('versionchange', () => { + db.close(); + this._supOpsDb = null; + this._supOpsDbPromise = null; + }); + this._supOpsDb = db; + return db; } } diff --git a/src/app/core/util/generate-client-id.spec.ts b/src/app/core/util/generate-client-id.spec.ts new file mode 100644 index 0000000000..44d878fb01 --- /dev/null +++ b/src/app/core/util/generate-client-id.spec.ts @@ -0,0 +1,48 @@ +import { generateClientId, isValidClientIdFormat } from './generate-client-id'; + +describe('generate-client-id', () => { + describe('generateClientId()', () => { + it('produces an id matching the new {platform}_{4-char} format', () => { + expect(/^[BEAI]_[a-zA-Z0-9]{4}$/.test(generateClientId())).toBeTrue(); + }); + + it('produces a distinct id on each call', () => { + // 50 random 4-char base62 ids — a collision is astronomically unlikely. + const ids = new Set(Array.from({ length: 50 }, () => generateClientId())); + expect(ids.size).toBe(50); + }); + + it('always passes its own format guard', () => { + for (let i = 0; i < 20; i++) { + expect(isValidClientIdFormat(generateClientId())).toBeTrue(); + } + }); + }); + + describe('isValidClientIdFormat()', () => { + it('accepts the new compact format', () => { + expect(isValidClientIdFormat('B_a7Kx')).toBeTrue(); + expect(isValidClientIdFormat('E_0000')).toBeTrue(); + expect(isValidClientIdFormat('I_ZzZz')).toBeTrue(); + }); + + it('accepts legacy ids of length >= 10', () => { + expect(isValidClientIdFormat('LongClientId123')).toBeTrue(); + expect(isValidClientIdFormat('0123456789')).toBeTrue(); + }); + + it('rejects short, non-conforming strings', () => { + expect(isValidClientIdFormat('BAD')).toBeFalse(); + expect(isValidClientIdFormat('')).toBeFalse(); + expect(isValidClientIdFormat('B_a7K')).toBeFalse(); // 3-char suffix + expect(isValidClientIdFormat('X_a7Kx')).toBeFalse(); // unknown platform + }); + + it('rejects non-string values', () => { + expect(isValidClientIdFormat(undefined)).toBeFalse(); + expect(isValidClientIdFormat(null)).toBeFalse(); + expect(isValidClientIdFormat(42)).toBeFalse(); + expect(isValidClientIdFormat({})).toBeFalse(); + }); + }); +}); diff --git a/src/app/core/util/generate-client-id.ts b/src/app/core/util/generate-client-id.ts new file mode 100644 index 0000000000..3a757f59dc --- /dev/null +++ b/src/app/core/util/generate-client-id.ts @@ -0,0 +1,73 @@ +/** + * Pure client-ID generation and format validation. + * + * Extracted from ClientIdService so destructive-flow callers (clean-slate, + * backup-restore) can mint an id without going through the stateful service — + * the new id is persisted only inside the atomic SUP_OPS transaction in + * OperationLogStoreService.runDestructiveStateReplacement. See issue #7732. + * + * No DI, no I/O — directly unit-testable. + */ + +/** + * Returns a single-character platform identifier for compact client IDs. + * B = Browser, E = Electron, A = Android, I = iOS. + */ +const _getEnvironmentId = (): string => { + // Detect Electron + const isElectron = + typeof process !== 'undefined' && + !!(process as { versions?: { electron?: string } }).versions?.electron; + if (isElectron) { + return 'E'; + } + + // Detect Android WebView + if (/Android/.test(navigator.userAgent) && /wv/.test(navigator.userAgent)) { + return 'A'; + } + + // Detect iOS + if ( + navigator.userAgent.includes('iOS') || + navigator.userAgent.includes('iPhone') || + navigator.userAgent.includes('iPad') + ) { + return 'I'; + } + + // Default: Browser + return 'B'; +}; + +/** + * Generates a random base62 string of the specified length. + * Uses crypto.getRandomValues() for non-predictable randomness. + */ +const _generateBase62 = (length: number): string => { + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + const bytes = new Uint8Array(length); + crypto.getRandomValues(bytes); + return Array.from(bytes, (b) => chars[b % chars.length]).join(''); +}; + +/** + * Generates a compact client ID: {platform}_{4-char-base62}, e.g. "B_a7Kx". + */ +export const generateClientId = (): string => { + return `${_getEnvironmentId()}_${_generateBase62(4)}`; +}; + +/** + * Type guard: true if `id` matches a known valid client-ID format. + * - Legacy format: any string of length >= 10 (legacy IDs). + * - New format: {platform}_{4-char-base62}, e.g. "B_a7Kx". + * + * Used to narrow `unknown` values read from IndexedDB. An invalid format is + * treated as "absent" rather than fatal — see issue #6197. + */ +export const isValidClientIdFormat = (id: unknown): id is string => { + return ( + typeof id === 'string' && (id.length >= 10 || /^[BEAI]_[a-zA-Z0-9]{4}$/.test(id)) + ); +}; diff --git a/src/app/features/android/android-interface.ts b/src/app/features/android/android-interface.ts index e81cd903da..2d6e022814 100644 --- a/src/app/features/android/android-interface.ts +++ b/src/app/features/android/android-interface.ts @@ -4,6 +4,13 @@ import { BehaviorSubject, merge, Observable, ReplaySubject, Subject } from 'rxjs import { mapTo } from 'rxjs/operators'; import { DroidLog } from '../../core/log'; +export interface AndroidShareData { + title: string; + subject: string; + type: 'FILE' | 'LINK' | 'IMG' | 'COMMAND' | 'NOTE'; + path: string; +} + export interface AndroidInterface { getVersion?(): string; @@ -100,12 +107,7 @@ export interface AndroidInterface { isInBackground$: Observable; isKeyboardShown$: Subject; - onShareWithAttachment$: Subject<{ - title: string; - subject: string; - type: 'FILE' | 'LINK' | 'IMG' | 'COMMAND' | 'NOTE'; - path: string; - }>; + onShareWithAttachment$: Subject; // Notification action callbacks onPauseTracking$: Subject; diff --git a/src/app/features/android/store/android.effects.spec.ts b/src/app/features/android/store/android.effects.spec.ts new file mode 100644 index 0000000000..e641b46cc1 --- /dev/null +++ b/src/app/features/android/store/android.effects.spec.ts @@ -0,0 +1,87 @@ +import { buildTaskTitle, readableUrl } from './android.effects'; + +describe('android share helpers', () => { + describe('buildTaskTitle', () => { + it('prefers the subject (page title from browsers) over everything else', () => { + expect( + buildTaskTitle({ + subject: 'Great Article', + title: 'Some Title', + type: 'LINK', + path: 'https://example.com/post', + }), + ).toBe('Great Article'); + }); + + it('falls back to the explicit title when there is no subject', () => { + expect( + buildTaskTitle({ + subject: '', + title: 'Some Title', + type: 'LINK', + path: 'https://example.com/post', + }), + ).toBe('Some Title'); + }); + + // Regression: a share without subject/title must derive a readable title + // from the link, not the generic "Shared Content" placeholder. + it('derives a readable title from the URL for links without subject/title', () => { + expect( + buildTaskTitle({ + subject: '', + title: '', + type: 'LINK', + path: 'https://www.example.com/some-cool-article', + }), + ).toBe('example.com: some cool article'); + }); + + it('uses the first line of content for notes without subject/title', () => { + expect( + buildTaskTitle({ + subject: '', + title: '', + type: 'NOTE', + path: 'Buy milk\nand bread', + }), + ).toBe('Buy milk'); + }); + + it('falls back to "Shared note" for empty note content', () => { + expect(buildTaskTitle({ subject: '', title: '', type: 'NOTE', path: '' })).toBe( + 'Shared note', + ); + }); + + it('tolerates missing fields', () => { + expect(buildTaskTitle({})).toBe('Shared note'); + }); + + it('truncates very long titles to 150 chars', () => { + const result = buildTaskTitle({ + subject: 'x'.repeat(300), + type: 'NOTE', + path: 'p', + }); + expect(result.length).toBe(150); + expect(result.endsWith('...')).toBeTrue(); + }); + }); + + describe('readableUrl', () => { + it('returns host and decoded path', () => { + expect(readableUrl('https://www.example.com/foo/bar-baz')).toBe( + 'example.com: foo bar baz', + ); + }); + + it('returns just the host when there is no path', () => { + expect(readableUrl('https://example.com/')).toBe('example.com'); + }); + + it('returns the original string for invalid URLs', () => { + expect(readableUrl('not a url')).toBe('not a url'); + }); + }); +}); diff --git a/src/app/features/android/store/android.effects.ts b/src/app/features/android/store/android.effects.ts index d9091dc943..d7d4350fce 100644 --- a/src/app/features/android/store/android.effects.ts +++ b/src/app/features/android/store/android.effects.ts @@ -4,7 +4,7 @@ import { tap } from 'rxjs/operators'; import { SnackService } from '../../../core/snack/snack.service'; import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view'; import { DroidLog } from '../../../core/log'; -import { androidInterface } from '../android-interface'; +import { androidInterface, AndroidShareData } from '../android-interface'; import { TaskService } from '../../tasks/task.service'; import { TaskAttachmentService } from '../../tasks/task-attachment/task-attachment.service'; import { T } from '../../../t.const'; @@ -23,7 +23,13 @@ export class AndroidEffects { () => androidInterface.onShareWithAttachment$.pipe( tap((shareData) => { - const taskTitle = this._buildTaskTitle(shareData); + // Guard against empty payloads (e.g. stale share data persisted by an + // older app version) so we never create a blank, attachment-less task. + if (!shareData?.path?.trim()) { + DroidLog.warn('Ignoring share intent with empty content'); + return; + } + const taskTitle = buildTaskTitle(shareData); const taskId = this._taskService.add(taskTitle); const icon = shareData.type === 'LINK' ? 'link' : 'file_present'; this._taskAttachmentService.addAttachment(taskId, { @@ -150,51 +156,6 @@ export class AndroidEffects { { dispatch: false }, ); - private _buildTaskTitle(shareData: { - title: string; - subject: string; - type: string; - path: string; - }): string { - const subject = shareData.subject?.trim() || ''; - const title = shareData.title?.trim() || ''; - const path = shareData.path?.trim() || ''; - - let taskTitle: string; - - // Prefer subject (page title from browsers), then title, then type-specific fallback - if (subject) { - taskTitle = subject; - } else if (title) { - taskTitle = title; - } else if (shareData.type === 'LINK') { - taskTitle = this._readableUrl(path); - } else { - const firstLine = path.split('\n')[0].trim(); - taskTitle = firstLine || 'Shared note'; - } - - return taskTitle.length > 150 ? taskTitle.substring(0, 147) + '...' : taskTitle; - } - - private _readableUrl(url: string): string { - try { - const parsed = new URL(url); - const host = parsed.hostname.replace(/^www\./, ''); - const pathPart = parsed.pathname.replace(/\/$/, ''); - if (pathPart && pathPart !== '/') { - const decoded = decodeURIComponent(pathPart) - .replace(/[/_-]/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - return decoded ? `${host}: ${decoded}` : host; - } - return host; - } catch { - return url; - } - } - // Check for pending share data on resume (catches app killed after receiving share) checkPendingShareOnResume$ = IS_ANDROID_WEB_VIEW && @@ -217,3 +178,51 @@ export class AndroidEffects { { dispatch: false }, ); } + +/** + * Build a meaningful task title from Android share intent data. + * Prefers the page subject (EXTRA_SUBJECT, sent by browsers), then an explicit + * title (EXTRA_TITLE), then a type-specific fallback derived from the shared + * content itself. Never returns the unhelpful literal "Shared Content". + */ +export const buildTaskTitle = (shareData: Partial): string => { + const subject = shareData.subject?.trim() || ''; + const title = shareData.title?.trim() || ''; + const path = shareData.path?.trim() || ''; + + let taskTitle: string; + + if (subject) { + taskTitle = subject; + } else if (title) { + taskTitle = title; + } else if (shareData.type === 'LINK') { + taskTitle = readableUrl(path); + } else { + const firstLine = path.split('\n')[0].trim(); + taskTitle = firstLine || 'Shared note'; + } + + return taskTitle.length > 150 ? taskTitle.substring(0, 147) + '...' : taskTitle; +}; + +/** + * Turn a URL into a human-readable "host: path" string for use as a task title. + */ +export const readableUrl = (url: string): string => { + try { + const parsed = new URL(url); + const host = parsed.hostname.replace(/^www\./, ''); + const pathPart = parsed.pathname.replace(/\/$/, ''); + if (pathPart && pathPart !== '/') { + const decoded = decodeURIComponent(pathPart) + .replace(/[/_-]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + return decoded ? `${host}: ${decoded}` : host; + } + return host; + } catch { + return url; + } +}; diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.spec.ts b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.spec.ts index cdc3f0110d..2a7c2cf68d 100644 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.spec.ts +++ b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.spec.ts @@ -397,6 +397,35 @@ describe('DialogEditTaskRepeatCfgComponent', () => { }); }); + describe('_normalizeMonthlyAnchor strips stale monthlyLastDay (#7726)', () => { + it('clears monthlyLastDay when quickSetting is no longer MONTHLY_LAST_DAY', async () => { + const fixture = await setupTestBed({ task: mockTask }); + const normalized = (fixture.componentInstance as any)._normalizeMonthlyAnchor({ + quickSetting: 'CUSTOM', + monthlyLastDay: true, + }); + expect(normalized.monthlyLastDay).toBeUndefined(); + }); + + it('keeps monthlyLastDay for the MONTHLY_LAST_DAY preset', async () => { + const fixture = await setupTestBed({ task: mockTask }); + const normalized = (fixture.componentInstance as any)._normalizeMonthlyAnchor({ + quickSetting: 'MONTHLY_LAST_DAY', + monthlyLastDay: true, + }); + expect(normalized.monthlyLastDay).toBe(true); + }); + + it('still converts the monthlyWeekOfMonth null sentinel to undefined', async () => { + const fixture = await setupTestBed({ task: mockTask }); + const normalized = (fixture.componentInstance as any)._normalizeMonthlyAnchor({ + quickSetting: 'CUSTOM', + monthlyWeekOfMonth: null, + }); + expect(normalized.monthlyWeekOfMonth).toBeUndefined(); + }); + }); + describe('save button disabled state (issue #5828)', () => { it('should not allow save while isLoading is true', fakeAsync(async () => { const taskWithRepeatCfg = { diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.ts b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.ts index 61f259ab7c..49bb4acd5d 100644 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.ts +++ b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.ts @@ -284,11 +284,8 @@ export class DialogEditTaskRepeatCfgComponent { } } - // The form uses `null` as the "(Day of month)" sentinel on the - // monthlyWeekOfMonth select. Persisted cfgs use `undefined` for absent - // optional fields (project convention). Normalize at the boundary so - // existing day-of-month cfgs don't produce spurious change diffs and the - // op-log stays consistent with the model type. + // Normalize the monthly anchor fields at the boundary: convert the form's + // `null` sentinel to `undefined`, and strip a stale `monthlyLastDay` flag. const finalRepeatCfg = this._normalizeMonthlyAnchor(this.repeatCfg()); if (this.isEdit()) { @@ -321,11 +318,29 @@ export class DialogEditTaskRepeatCfgComponent { } } - private _normalizeMonthlyAnchor(cfg: T): T { - if (cfg.monthlyWeekOfMonth === null) { - return { ...cfg, monthlyWeekOfMonth: undefined }; + private _normalizeMonthlyAnchor< + T extends { + monthlyWeekOfMonth?: unknown; + monthlyLastDay?: boolean; + quickSetting?: string; + }, + >(cfg: T): T { + let result = cfg; + // The form uses `null` as the "(Day of month)" sentinel on the + // monthlyWeekOfMonth select. Persisted cfgs use `undefined` for absent + // optional fields (project convention). Normalizing here keeps existing + // day-of-month cfgs from producing spurious change diffs. + if (result.monthlyWeekOfMonth === null) { + result = { ...result, monthlyWeekOfMonth: undefined }; } - return cfg; + // `monthlyLastDay` has no CUSTOM-mode form control, so a flag left over + // from the MONTHLY_LAST_DAY preset would silently override the + // day-of-month a CUSTOM cfg shows. It is only ever valid for that + // preset — strip it for any other quick setting (#7726). + if (result.monthlyLastDay && result.quickSetting !== 'MONTHLY_LAST_DAY') { + result = { ...result, monthlyLastDay: undefined }; + } + return result; } remove(): void { diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/get-quick-setting-updates.spec.ts b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/get-quick-setting-updates.spec.ts index 885d958c6f..e633c9c946 100644 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/get-quick-setting-updates.spec.ts +++ b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/get-quick-setting-updates.spec.ts @@ -135,6 +135,8 @@ describe('getQuickSettingUpdates', () => { }); describe('MONTHLY_FIRST_DAY', () => { + afterEach(() => jasmine.clock().uninstall()); + it('should return MONTHLY cycle with repeatEvery 1', () => { const result = getQuickSettingUpdates('MONTHLY_FIRST_DAY'); expect(result).toBeDefined(); @@ -142,14 +144,39 @@ describe('getQuickSettingUpdates', () => { expect(result!.repeatEvery).toBe(1); }); - it('should set startDate to the 1st of the month', () => { + it('should set startDate to the 1st of a month', () => { const result = getQuickSettingUpdates('MONTHLY_FIRST_DAY'); const startDate = new Date(result!.startDate + 'T00:00:00'); expect(startDate.getDate()).toBe(1); }); + + it('should anchor to the NEXT 1st when today is not the 1st (#7726)', () => { + // Issue scenario: setting this up on 2026-05-22 must schedule June 1, + // never the already-past May 1. + jasmine.clock().install(); + jasmine.clock().mockDate(new Date(2026, 4, 22)); + const result = getQuickSettingUpdates('MONTHLY_FIRST_DAY'); + expect(result!.startDate).toBe('2026-06-01'); + }); + + it('should anchor to today when today IS the 1st', () => { + jasmine.clock().install(); + jasmine.clock().mockDate(new Date(2026, 4, 1)); + const result = getQuickSettingUpdates('MONTHLY_FIRST_DAY'); + expect(result!.startDate).toBe('2026-05-01'); + }); + + it('should roll over the year in December', () => { + jasmine.clock().install(); + jasmine.clock().mockDate(new Date(2026, 11, 15)); + const result = getQuickSettingUpdates('MONTHLY_FIRST_DAY'); + expect(result!.startDate).toBe('2027-01-01'); + }); }); describe('MONTHLY_LAST_DAY', () => { + afterEach(() => jasmine.clock().uninstall()); + it('should return MONTHLY cycle with repeatEvery 1', () => { const result = getQuickSettingUpdates('MONTHLY_LAST_DAY'); expect(result).toBeDefined(); @@ -157,10 +184,25 @@ describe('getQuickSettingUpdates', () => { expect(result!.repeatEvery).toBe(1); }); - it('should always set startDate with day=31 regardless of current month', () => { + it('should set the monthlyLastDay flag', () => { const result = getQuickSettingUpdates('MONTHLY_LAST_DAY'); - const startDate = new Date(result!.startDate + 'T00:00:00'); - expect(startDate.getDate()).toBe(31); + expect(result!.monthlyLastDay).toBe(true); + }); + + it('should anchor startDate to the last day of the current month (#7726)', () => { + // Issue scenario: setting this up on 2026-05-22 must schedule May 31, + // never the already-past Jan 31. + jasmine.clock().install(); + jasmine.clock().mockDate(new Date(2026, 4, 22)); + const result = getQuickSettingUpdates('MONTHLY_LAST_DAY'); + expect(result!.startDate).toBe('2026-05-31'); + }); + + it('should anchor to a short month-end when set up in a 30-day month', () => { + jasmine.clock().install(); + jasmine.clock().mockDate(new Date(2026, 5, 10)); + const result = getQuickSettingUpdates('MONTHLY_LAST_DAY'); + expect(result!.startDate).toBe('2026-06-30'); }); }); @@ -220,6 +262,25 @@ describe('getQuickSettingUpdates', () => { }); }); + describe('monthlyLastDay anchor is mutually exclusive with other presets', () => { + it('MONTHLY_CURRENT_DATE clears monthlyLastDay', () => { + expect( + getQuickSettingUpdates('MONTHLY_CURRENT_DATE')!.monthlyLastDay, + ).toBeUndefined(); + }); + + it('MONTHLY_FIRST_DAY clears monthlyLastDay', () => { + expect(getQuickSettingUpdates('MONTHLY_FIRST_DAY')!.monthlyLastDay).toBeUndefined(); + }); + + it('MONTHLY_NTH_WEEKDAY clears monthlyLastDay', () => { + const ref = new Date(2026, 0, 12); + expect( + getQuickSettingUpdates('MONTHLY_NTH_WEEKDAY', ref)!.monthlyLastDay, + ).toBeUndefined(); + }); + }); + describe('CUSTOM', () => { it('should return undefined', () => { const result = getQuickSettingUpdates('CUSTOM'); diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/get-quick-setting-updates.ts b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/get-quick-setting-updates.ts index a18e173296..01e45be9a7 100644 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/get-quick-setting-updates.ts +++ b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/get-quick-setting-updates.ts @@ -23,12 +23,13 @@ const _buildWeeklyForDay = (date: Date): Partial => { }; }; -// Switching from a day-of-week preset to a day-of-month preset must clear the -// Nth-weekday anchor — anchor presence is the discriminator, so stale fields -// would silently take effect. -const DAY_OF_MONTH_RESET: Partial = { +// Switching between monthly presets must clear every monthly anchor — +// anchor presence is the discriminator, so a stale Nth-weekday or last-day +// field would silently take effect. +const MONTHLY_ANCHOR_RESET: Partial = { monthlyWeekOfMonth: undefined, monthlyWeekday: undefined, + monthlyLastDay: undefined, }; /** @@ -74,30 +75,38 @@ export const getQuickSettingUpdates = ( repeatCycle: 'MONTHLY', repeatEvery: 1, startDate: getDbDateStr(referenceDate || today), - ...DAY_OF_MONTH_RESET, + ...MONTHLY_ANCHOR_RESET, }; } case 'MONTHLY_FIRST_DAY': { - const firstDay = new Date(today.getFullYear(), today.getMonth(), 1); + // Anchor to the next 1st-of-month that is today or later, so the first + // generated instance is never backdated (#7726). `month + 1` rolls the + // year over correctly in December. + const firstDay = + today.getDate() === 1 + ? new Date(today.getFullYear(), today.getMonth(), 1) + : new Date(today.getFullYear(), today.getMonth() + 1, 1); return { repeatCycle: 'MONTHLY', repeatEvery: 1, startDate: getDbDateStr(firstDay), - ...DAY_OF_MONTH_RESET, + ...MONTHLY_ANCHOR_RESET, }; } case 'MONTHLY_LAST_DAY': { - // Always use day=31 so the occurrence calculator clamps via - // Math.min(31, lastDayOfMonth), producing true "last day" behavior. - // Using the current month's last day would fail in short months (e.g. Feb=28). - const day31 = new Date(today.getFullYear(), 0, 31); + // First occurrence = the upcoming last day of the current month, which + // is always today or later. The `monthlyLastDay` flag tells the + // occurrence engine to clamp to month-end every month, so `startDate`'s + // day-of-month no longer needs to be a hardcoded 31 (#7726). + const lastDayThisMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0); return { repeatCycle: 'MONTHLY', repeatEvery: 1, - startDate: getDbDateStr(day31), - ...DAY_OF_MONTH_RESET, + startDate: getDbDateStr(lastDayThisMonth), + ...MONTHLY_ANCHOR_RESET, + monthlyLastDay: true, }; } @@ -115,6 +124,7 @@ export const getQuickSettingUpdates = ( startDate: getDbDateStr(ref), monthlyWeekOfMonth: weekOfMonth, monthlyWeekday: weekday, + monthlyLastDay: undefined, }; } diff --git a/src/app/features/task-repeat-cfg/store/get-first-repeat-occurrence.util.spec.ts b/src/app/features/task-repeat-cfg/store/get-first-repeat-occurrence.util.spec.ts index afcff5c653..af803d0c65 100644 --- a/src/app/features/task-repeat-cfg/store/get-first-repeat-occurrence.util.spec.ts +++ b/src/app/features/task-repeat-cfg/store/get-first-repeat-occurrence.util.spec.ts @@ -173,6 +173,62 @@ describe('getFirstRepeatOccurrence', () => { }); }); + describe('MONTHLY last day of month (issue #7726)', () => { + it('returns the last day of a 31-day startDate month', () => { + const result = getFirstRepeatOccurrence( + mkCfg({ + repeatCycle: 'MONTHLY', + repeatEvery: 1, + startDate: '2026-05-31', + monthlyLastDay: true, + }), + ); + expect(result!.getFullYear()).toBe(2026); + expect(result!.getMonth()).toBe(4); + expect(result!.getDate()).toBe(31); + }); + + it('returns the last day of a 30-day startDate month', () => { + const result = getFirstRepeatOccurrence( + mkCfg({ + repeatCycle: 'MONTHLY', + repeatEvery: 1, + startDate: '2026-06-30', + monthlyLastDay: true, + }), + ); + expect(result!.getMonth()).toBe(5); + expect(result!.getDate()).toBe(30); + }); + + it('resolves to the month-end regardless of startDate day-of-month', () => { + const result = getFirstRepeatOccurrence( + mkCfg({ + repeatCycle: 'MONTHLY', + repeatEvery: 1, + startDate: '2026-02-15', + monthlyLastDay: true, + }), + ); + expect(result!.getMonth()).toBe(1); + expect(result!.getDate()).toBe(28); + }); + + it('resolves to February 29 in a leap year', () => { + const result = getFirstRepeatOccurrence( + mkCfg({ + repeatCycle: 'MONTHLY', + repeatEvery: 1, + startDate: '2024-02-10', + monthlyLastDay: true, + }), + ); + expect(result!.getFullYear()).toBe(2024); + expect(result!.getMonth()).toBe(1); + expect(result!.getDate()).toBe(29); + }); + }); + describe('MONTHLY Nth weekday (issue #6040)', () => { it('returns the Nth weekday in the start month when on/after startDate', () => { // 2026-01-01 (Thu) is the 1st Thursday of Jan 2026 → returns Jan 1. diff --git a/src/app/features/task-repeat-cfg/store/get-first-repeat-occurrence.util.ts b/src/app/features/task-repeat-cfg/store/get-first-repeat-occurrence.util.ts index 28d502e54d..de0f8a963f 100644 --- a/src/app/features/task-repeat-cfg/store/get-first-repeat-occurrence.util.ts +++ b/src/app/features/task-repeat-cfg/store/get-first-repeat-occurrence.util.ts @@ -44,6 +44,13 @@ export const getFirstRepeatOccurrence = (taskRepeatCfg: TaskRepeatCfg): Date | n accept: (candidate) => candidate >= checkDate, }); } + if (taskRepeatCfg.monthlyLastDay) { + // Last calendar day of startDate's month — day 0 of the next month + // (#7726). + const lastDay = new Date(checkDate.getFullYear(), checkDate.getMonth() + 1, 0); + lastDay.setHours(12, 0, 0, 0); + return lastDay; + } return checkDate; } diff --git a/src/app/features/task-repeat-cfg/store/get-newest-possible-due-date.util.spec.ts b/src/app/features/task-repeat-cfg/store/get-newest-possible-due-date.util.spec.ts index c0f86e5652..422975e7d7 100644 --- a/src/app/features/task-repeat-cfg/store/get-newest-possible-due-date.util.spec.ts +++ b/src/app/features/task-repeat-cfg/store/get-newest-possible-due-date.util.spec.ts @@ -473,6 +473,40 @@ describe('getNewestPossibleDueDate()', () => { ); }); + describe('MONTHLY monthlyLastDay (issue #7726)', () => { + it('returns the last day of the current month', () => { + const cfg = dummyRepeatable('ID1', { + repeatCycle: 'MONTHLY', + repeatEvery: 1, + monthlyLastDay: true, + lastTaskCreationDay: '2026-05-31', + }); + testCase(cfg, new Date(2026, 5, 30), new Date(2026, 4, 31), new Date(2026, 5, 30)); + }); + + it('clamps to month-end even when startDate day-of-month is 30', () => { + // A recurrence set up in a 30-day month must still hit 31 in long + // months — the anchor is decoupled from startDate's day. + const cfg = dummyRepeatable('ID1', { + repeatCycle: 'MONTHLY', + repeatEvery: 1, + monthlyLastDay: true, + lastTaskCreationDay: '2026-06-30', + }); + testCase(cfg, new Date(2026, 6, 31), new Date(2026, 5, 30), new Date(2026, 6, 31)); + }); + + it('returns null when the latest month-end is already created', () => { + const cfg = dummyRepeatable('ID1', { + repeatCycle: 'MONTHLY', + repeatEvery: 1, + monthlyLastDay: true, + lastTaskCreationDay: '2026-06-30', + }); + testCase(cfg, new Date(2026, 6, 15), new Date(2026, 4, 31), null); + }); + }); + describe('MONTHLY Nth weekday (issue #6040)', () => { it('returns the Nth weekday of this month when today equals it', () => { // 1st Thursday of Jan 2026 = Jan 1 (Thu). today = Jan 1. diff --git a/src/app/features/task-repeat-cfg/store/get-newest-possible-due-date.util.ts b/src/app/features/task-repeat-cfg/store/get-newest-possible-due-date.util.ts index 6a3994a63b..f446cf7f4f 100644 --- a/src/app/features/task-repeat-cfg/store/get-newest-possible-due-date.util.ts +++ b/src/app/features/task-repeat-cfg/store/get-newest-possible-due-date.util.ts @@ -107,7 +107,11 @@ export const getNewestPossibleDueDate = ( }); } - const dayOfMonthRepeat = startDateDate.getDate(); + // `monthlyLastDay` anchors to month-end: day 31 makes setDateSafely's + // Math.min(31, lastDayOfMonth) clamp to the true last day every month. + const dayOfMonthRepeat = taskRepeatCfg.monthlyLastDay + ? 31 + : startDateDate.getDate(); // Handle month-end dates properly const setDateSafely = (date: Date, day: number): void => { diff --git a/src/app/features/task-repeat-cfg/store/get-next-repeat-occurrence.util.spec.ts b/src/app/features/task-repeat-cfg/store/get-next-repeat-occurrence.util.spec.ts index 472bd48b90..928aad7330 100644 --- a/src/app/features/task-repeat-cfg/store/get-next-repeat-occurrence.util.spec.ts +++ b/src/app/features/task-repeat-cfg/store/get-next-repeat-occurrence.util.spec.ts @@ -264,6 +264,50 @@ describe('getNextRepeatOccurrence()', () => { }); }); + describe('MONTHLY monthlyLastDay (issue #7726)', () => { + it('returns the last day of the next month', () => { + const cfg = dummyRepeatable('ID1', { + repeatCycle: 'MONTHLY', + repeatEvery: 1, + monthlyLastDay: true, + lastTaskCreationDay: getDbDateStr(new Date(2026, 4, 31)), + }); + testCase(cfg, new Date(2026, 5, 1), new Date(2026, 4, 31), new Date(2026, 5, 30)); + }); + + it('clamps to month-end even when startDate day-of-month is 30', () => { + // A recurrence set up in a 30-day month must still hit 31 in long + // months — the anchor is decoupled from startDate's day. + const cfg = dummyRepeatable('ID1', { + repeatCycle: 'MONTHLY', + repeatEvery: 1, + monthlyLastDay: true, + lastTaskCreationDay: getDbDateStr(new Date(2026, 5, 30)), + }); + testCase(cfg, new Date(2026, 6, 1), new Date(2026, 5, 30), new Date(2026, 6, 31)); + }); + + it('clamps to February month-end', () => { + const cfg = dummyRepeatable('ID1', { + repeatCycle: 'MONTHLY', + repeatEvery: 1, + monthlyLastDay: true, + lastTaskCreationDay: getDbDateStr(new Date(2026, 0, 31)), + }); + testCase(cfg, new Date(2026, 1, 1), new Date(2026, 0, 31), new Date(2026, 1, 28)); + }); + + it('clamps to February 29 in a leap year', () => { + const cfg = dummyRepeatable('ID1', { + repeatCycle: 'MONTHLY', + repeatEvery: 1, + monthlyLastDay: true, + lastTaskCreationDay: getDbDateStr(new Date(2024, 0, 31)), + }); + testCase(cfg, new Date(2024, 1, 1), new Date(2024, 0, 31), new Date(2024, 1, 29)); + }); + }); + describe('MONTHLY Nth weekday (issue #6040)', () => { it('returns the first Thursday of next month', () => { const startDate = new Date(2026, 0, 1); // Jan 1, 2026 (Thursday) diff --git a/src/app/features/task-repeat-cfg/store/get-next-repeat-occurrence.util.ts b/src/app/features/task-repeat-cfg/store/get-next-repeat-occurrence.util.ts index 34e235dfb8..c5b48073eb 100644 --- a/src/app/features/task-repeat-cfg/store/get-next-repeat-occurrence.util.ts +++ b/src/app/features/task-repeat-cfg/store/get-next-repeat-occurrence.util.ts @@ -98,7 +98,11 @@ export const getNextRepeatOccurrence = ( }); } - const dayOfMonthRepeat = startDateDate.getDate(); + // `monthlyLastDay` anchors to month-end: day 31 makes setDateSafely's + // Math.min(31, lastDayOfMonth) clamp to the true last day every month. + const dayOfMonthRepeat = taskRepeatCfg.monthlyLastDay + ? 31 + : startDateDate.getDate(); // Handle month-end dates properly const setDateSafely = (date: Date, day: number): void => { diff --git a/src/app/features/task-repeat-cfg/store/task-repeat-cfg.effects.spec.ts b/src/app/features/task-repeat-cfg/store/task-repeat-cfg.effects.spec.ts index 6ad24728b8..e53d9839c8 100644 --- a/src/app/features/task-repeat-cfg/store/task-repeat-cfg.effects.spec.ts +++ b/src/app/features/task-repeat-cfg/store/task-repeat-cfg.effects.spec.ts @@ -332,6 +332,98 @@ describe('TaskRepeatCfgEffects - Repeatable Subtasks', () => { expect(taskService.update).not.toHaveBeenCalled(); }); + it('should set dueDay to today when an Inbox task (no dueDay) is made repeatable starting today (#7725)', (done) => { + // Scenario from issue #7725: a task added to the Inbox (no dueDay) is + // converted to a recurring task via the dialog with start date = today. + // It must be scheduled for today instead of staying unscheduled. + const today = new Date(); + const todayStr = getDbDateStr(today); + + const inboxTask: TaskWithSubTasks = { + ...mockTask, + subTasks: [], + dueDay: undefined, // Inbox task — not scheduled + dueWithTime: undefined, + created: today.getTime(), + }; + + const dailyRepeatCfg: TaskRepeatCfgCopy = { + ...mockRepeatCfg, + repeatCycle: 'DAILY', + repeatEvery: 1, + startDate: todayStr, + }; + + const action = addTaskRepeatCfgToTask({ + taskRepeatCfg: dailyRepeatCfg, + taskId: 'parent-task-id', + }); + + actions$ = of(action); + taskService.getByIdWithSubTaskData$.and.returnValue(of(inboxTask)); + + spyOn(effects as any, '_updateRegularTaskInstance'); + + let emitted = false; + effects.updateTaskAfterMakingItRepeatable$.subscribe(() => { + emitted = true; + }); + + setTimeout(() => { + expect(emitted).toBe(false); // today occurrence = no planTaskForDay + expect(taskService.update).toHaveBeenCalledWith('parent-task-id', { + dueDay: todayStr, + }); + done(); + }, 0); + }); + + it('should NOT set dueDay for a non-timed config when the task already has dueWithTime', (done) => { + // A task scheduled with a time (dueWithTime, no dueDay) is made + // repeatable, but the user cleared the start-time field so the config is + // non-timed. dueDay must NOT be set: dueDay/dueWithTime are mutually + // exclusive and a plain update would not clear dueWithTime. + const today = new Date(); + const todayStr = getDbDateStr(today); + const todayNoon = new Date(today); + todayNoon.setHours(12, 0, 0, 0); + + const timedTask: TaskWithSubTasks = { + ...mockTask, + subTasks: [], + dueDay: undefined, + dueWithTime: todayNoon.getTime(), + created: today.getTime(), + }; + + const dailyRepeatCfg: TaskRepeatCfgCopy = { + ...mockRepeatCfg, + repeatCycle: 'DAILY', + repeatEvery: 1, + startDate: todayStr, + }; + + const action = addTaskRepeatCfgToTask({ + taskRepeatCfg: dailyRepeatCfg, + taskId: 'parent-task-id', + }); + + actions$ = of(action); + taskService.getByIdWithSubTaskData$.and.returnValue(of(timedTask)); + + spyOn(effects as any, '_updateRegularTaskInstance'); + + effects.updateTaskAfterMakingItRepeatable$.subscribe(); + + setTimeout(() => { + const dueDayUpdate = taskService.update.calls + .allArgs() + .find(([, changes]) => 'dueDay' in (changes as object)); + expect(dueDayUpdate).toBeUndefined(); + done(); + }, 0); + }); + it('should update task created when first occurrence is today but task was created earlier', () => { const today = new Date(); const todayStr = getDbDateStr(today); @@ -2099,6 +2191,9 @@ describe('TaskRepeatCfgEffects - Repeatable Subtasks', () => { setTimeout(() => { expect(emitted).toBe(false); + // #7724 guard: startDate present in changes but not moved earlier + // (equal to lastTaskCreationDay) must not re-anchor the config. + expect(taskRepeatCfgService.updateTaskRepeatCfg).not.toHaveBeenCalled(); done(); }, 0); }); @@ -2500,6 +2595,58 @@ describe('TaskRepeatCfgEffects - Repeatable Subtasks', () => { done(); }); }); + + // Issue #7724: moving startDate earlier must re-anchor lastTaskCreationDay + // even when no live task instance exists (e.g. the user deleted it). The + // stale anchor would otherwise suppress every projected/created instance + // between the new startDate and the old anchor. + it('should re-anchor lastTaskCreationDay when startDate moved earlier and no live instance exists (#7724)', (done) => { + const today = new Date(); + // lastTaskCreationDay set when the (now deleted) instance was created. + const oldStartDateStr = getDbDateStr(addDays(today, 8)); + const newStartDateStr = getDbDateStr(addDays(today, 3)); + // The fix anchors to the day before the new first occurrence so the new + // startDate itself is created/projected fresh. + const expectedAnchorStr = getDbDateStr(addDays(today, 2)); + + const updatedCfg: TaskRepeatCfgCopy = { + ...mockRepeatCfg, + repeatCycle: 'DAILY', + repeatEvery: 1, + startDate: newStartDateStr, + lastTaskCreationDay: oldStartDateStr, + }; + + const action = updateTaskRepeatCfg({ + taskRepeatCfg: { + id: 'repeat-cfg-id', + changes: { startDate: newStartDateStr }, + }, + }); + + actions$ = of(action); + taskRepeatCfgService.getTaskRepeatCfgById$.and.returnValue(of(updatedCfg)); + // No live instance — it was deleted. + taskService.getTasksByRepeatCfgId$.and.returnValue(of([])); + + let emitted = false; + effects.rescheduleTaskOnRepeatCfgUpdate$.subscribe(() => { + emitted = true; + }); + + setTimeout(() => { + // No live task to reschedule, so no action is dispatched... + expect(emitted).toBe(false); + // ...but the stale anchor is still corrected. + expect(taskRepeatCfgService.updateTaskRepeatCfg).toHaveBeenCalledWith( + 'repeat-cfg-id', + jasmine.objectContaining({ + lastTaskCreationDay: expectedAnchorStr, + }), + ); + done(); + }, 0); + }); }); }); diff --git a/src/app/features/task-repeat-cfg/store/task-repeat-cfg.effects.ts b/src/app/features/task-repeat-cfg/store/task-repeat-cfg.effects.ts index 560a3a3cdc..93a2c6a6fc 100644 --- a/src/app/features/task-repeat-cfg/store/task-repeat-cfg.effects.ts +++ b/src/app/features/task-repeat-cfg/store/task-repeat-cfg.effects.ts @@ -199,12 +199,17 @@ export class TaskRepeatCfgEffects { // TODAY FIRST OCCURRENCE: // Keep the stable occurrence identity aligned even if the task was // originally created before it became repeatable. - const currentDueDay = task.dueDay || getDbDateStr(task.created); const update: Partial = {}; if (firstOccurrence && getDbDateStr(task.created) !== firstOccurrenceStr) { update.created = firstOccurrence.getTime(); } - if (currentDueDay !== firstOccurrenceStr) { + // Schedule the task for its first occurrence day. An Inbox task has + // no dueDay, so set it explicitly (#7725); task.created is not a + // valid fallback — being created today does not imply it is + // scheduled. Skip already time-scheduled tasks: dueDay/dueWithTime + // are mutually exclusive and this plain update cannot clear + // dueWithTime (timed scheduling: addRepeatCfgToTaskUpdateTask$). + if (!isTimedTask && !task.dueWithTime && task.dueDay !== firstOccurrenceStr) { update.dueDay = firstOccurrenceStr; } if (Object.keys(update).length > 0) { @@ -268,12 +273,6 @@ export class TaskRepeatCfgEffects { first(), switchMap((liveInstances) => { const undoneInstances = liveInstances.filter((t) => !t.isDone); - if (undoneInstances.length === 0) { - return EMPTY; - } - const task = undoneInstances.reduce((a, b) => - a.created > b.created ? a : b, - ); // If the user moved startDate earlier than the existing // lastTaskCreationDay, the anchor is stale — re-anchor on @@ -293,6 +292,37 @@ export class TaskRepeatCfgEffects { const firstOccurrence = isStartDateMovedEarlier ? getFirstRepeatOccurrence(fullCfg) : getNextRepeatOccurrence(fullCfg, new Date()); + + if (undoneInstances.length === 0) { + // No live instance to reschedule. But when startDate moved + // earlier, the stale lastTaskCreationDay would still suppress + // every projected/created instance between the new startDate + // and the old anchor (#7724). Re-anchor to the day before the + // new first occurrence so it — and every following day — is + // created and projected fresh. + // The re-dispatched updateTaskRepeatCfg only touches + // lastTaskCreation* fields, which are absent from + // SCHEDULE_AFFECTING_FIELDS, so it does not re-enter this + // effect. + if (isStartDateMovedEarlier && firstOccurrence) { + const dayBeforeFirstOccurrence = new Date(firstOccurrence); + dayBeforeFirstOccurrence.setDate( + dayBeforeFirstOccurrence.getDate() - 1, + ); + this._taskRepeatCfgService.updateTaskRepeatCfg(cfgId, { + lastTaskCreationDay: this._dateService.todayStr( + dayBeforeFirstOccurrence, + ), + lastTaskCreation: dayBeforeFirstOccurrence.getTime(), + }); + } + return EMPTY; + } + + const task = undoneInstances.reduce((a, b) => + a.created > b.created ? a : b, + ); + const firstOccurrenceStr = firstOccurrence ? this._dateService.todayStr(firstOccurrence) : this._dateService.todayStr(); diff --git a/src/app/features/task-repeat-cfg/store/task-repeat-cfg.selectors.spec.ts b/src/app/features/task-repeat-cfg/store/task-repeat-cfg.selectors.spec.ts index 1b12634406..7db83d17b4 100644 --- a/src/app/features/task-repeat-cfg/store/task-repeat-cfg.selectors.spec.ts +++ b/src/app/features/task-repeat-cfg/store/task-repeat-cfg.selectors.spec.ts @@ -436,6 +436,55 @@ describe('selectTaskRepeatCfgsDueOnDay', () => { expect(resultIds).toEqual([]); }); }); + + // Issue #7724: after the user moves a DAILY config's startDate earlier with no + // live instance, rescheduleTaskOnRepeatCfgUpdate$ re-anchors lastTaskCreationDay + // to the day BEFORE the new startDate. This verifies the projector then surfaces + // the config for the new startDate and every following day (the days the bug + // suppressed), and still excludes days on/before the anchor. + describe('#7724 - DAILY startDate moved earlier, re-anchored to day before', () => { + const reAnchoredCfg = (): TaskRepeatCfg => + dummyRepeatable('R1', { + repeatCycle: 'DAILY', + repeatEvery: 1, + startDate: '2025-06-25', + // value written by the fix: the day before the new startDate + lastTaskCreationDay: '2025-06-24', + }); + + ['2025-06-25', '2025-06-26', '2025-06-30', '2025-07-05'].forEach((dayStr) => { + it(`projects the config on ${dayStr}`, () => { + const result = selectTaskRepeatCfgsForExactDay.projector([reAnchoredCfg()], { + dayDate: dateStrToUtcDate(dayStr).getTime(), + }); + expect(result.map((r) => r.id)).toEqual(['R1']); + }); + }); + + ['2025-06-24', '2025-06-23'].forEach((dayStr) => { + it(`does not project on ${dayStr} (on/before the anchor)`, () => { + const result = selectTaskRepeatCfgsForExactDay.projector([reAnchoredCfg()], { + dayDate: dateStrToUtcDate(dayStr).getTime(), + }); + expect(result.map((r) => r.id)).toEqual([]); + }); + }); + + it('is suppressed by the stale anchor without the fix', () => { + // Pre-fix state: lastTaskCreationDay still points at the old startDate, + // so a day between the new startDate and the old anchor is suppressed. + const staleCfg = dummyRepeatable('R1', { + repeatCycle: 'DAILY', + repeatEvery: 1, + startDate: '2025-06-25', + lastTaskCreationDay: '2025-06-30', + }); + const result = selectTaskRepeatCfgsForExactDay.projector([staleCfg], { + dayDate: dateStrToUtcDate('2025-06-27').getTime(), + }); + expect(result.map((r) => r.id)).toEqual([]); + }); + }); }); // ----------------------------------------------------------------------------------- diff --git a/src/app/features/task-repeat-cfg/task-repeat-cfg.model.ts b/src/app/features/task-repeat-cfg/task-repeat-cfg.model.ts index d912b7489b..4bf6e01545 100644 --- a/src/app/features/task-repeat-cfg/task-repeat-cfg.model.ts +++ b/src/app/features/task-repeat-cfg/task-repeat-cfg.model.ts @@ -72,6 +72,14 @@ export interface TaskRepeatCfgCopy { monthlyWeekOfMonth?: MonthlyWeekOfMonth; monthlyWeekday?: MonthlyWeekday; + // MONTHLY-only: when true, the recurrence anchors to the last calendar day + // of every month (28/29/30/31) regardless of `startDate`'s day-of-month. + // Decouples the anchor from `startDate` so the first occurrence is never + // backdated. Mutually exclusive with the Nth-weekday anchor above; if a + // malformed payload sets both, the Nth-weekday anchor wins (checked first + // by all recurrence calc utils). Issue #7726. + monthlyLastDay?: boolean; + // advanced notes: string | undefined; // ... possible sub tasks & attachments diff --git a/src/app/features/tasks/store/task-ui.effects.spec.ts b/src/app/features/tasks/store/task-ui.effects.spec.ts index 4a92bf27bb..178a79ae1d 100644 --- a/src/app/features/tasks/store/task-ui.effects.spec.ts +++ b/src/app/features/tasks/store/task-ui.effects.spec.ts @@ -13,7 +13,7 @@ import { GlobalConfigService } from '../../config/global-config.service'; import { Router } from '@angular/router'; import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions'; import { T } from '../../../t.const'; -import { Task } from '../task.model'; +import { Task, TaskWithSubTasks } from '../task.model'; import { WorkContextType } from '../../work-context/work-context.model'; import { selectProjectById } from '../../project/store/project.selectors'; import { LOCAL_ACTIONS } from '../../../util/local-actions.token'; @@ -248,6 +248,107 @@ describe('TaskUiEffects', () => { }); }); + describe('snackDelete$', () => { + const createMockTaskWithSubTasks = ( + overrides: Partial = {}, + ): TaskWithSubTasks => ({ + ...createMockTask(), + subTasks: [], + ...overrides, + }); + + beforeEach(() => { + actions$ = new Subject(); + snackServiceMock = jasmine.createSpyObj('SnackService', ['open']); + taskServiceMock = jasmine.createSpyObj('TaskService', ['setSelectedId']); + navigateToTaskServiceMock = jasmine.createSpyObj('NavigateToTaskService', [ + 'navigate', + ]); + layoutServiceMock = jasmine.createSpyObj('LayoutService', ['hideAddTaskBar']); + + TestBed.configureTestingModule({ + providers: [ + TaskUiEffects, + { provide: LOCAL_ACTIONS, useValue: actions$ }, + provideMockStore({ + initialState: {}, + selectors: [{ selector: selectProjectById, value: null }], + }), + { provide: SnackService, useValue: snackServiceMock }, + { provide: TaskService, useValue: taskServiceMock }, + { provide: NavigateToTaskService, useValue: navigateToTaskServiceMock }, + { provide: LayoutService, useValue: layoutServiceMock }, + { provide: WorkContextService, useValue: { mainListTaskIds$: of([]) } }, + { + provide: NotifyService, + useValue: jasmine.createSpyObj('NotifyService', ['notify']), + }, + { + provide: BannerService, + useValue: jasmine.createSpyObj('BannerService', ['open', 'dismiss']), + }, + { + provide: GlobalConfigService, + useValue: { sound$: of({ doneSound: null }) }, + }, + { provide: Router, useValue: jasmine.createSpyObj('Router', ['navigate']) }, + ], + }); + + effects = TestBed.inject(TaskUiEffects); + }); + + it('should show the undo snack when deleting a task that has a title', () => { + const sub = effects.snackDelete$.subscribe(); + actions$.next( + TaskSharedActions.deleteTask({ + task: createMockTaskWithSubTasks({ title: 'Real task' }), + }), + ); + + expect(snackServiceMock.open).toHaveBeenCalled(); + const snackParams = snackServiceMock.open.calls.mostRecent().args[0] as SnackParams; + expect(snackParams.actionStr).toBe(T.G.UNDO); + sub.unsubscribe(); + }); + + it('should NOT show the undo snack when deleting a blank task', () => { + const sub = effects.snackDelete$.subscribe(); + actions$.next( + TaskSharedActions.deleteTask({ + task: createMockTaskWithSubTasks({ title: '' }), + }), + ); + + expect(snackServiceMock.open).not.toHaveBeenCalled(); + sub.unsubscribe(); + }); + + it('should NOT show the undo snack when deleting a blank sub task', () => { + const sub = effects.snackDelete$.subscribe(); + actions$.next( + TaskSharedActions.deleteTask({ + task: createMockTaskWithSubTasks({ title: ' ', parentId: 'parent-1' }), + }), + ); + + expect(snackServiceMock.open).not.toHaveBeenCalled(); + sub.unsubscribe(); + }); + + it('should show the undo snack when deleting a blank-titled task that has data', () => { + const sub = effects.snackDelete$.subscribe(); + actions$.next( + TaskSharedActions.deleteTask({ + task: createMockTaskWithSubTasks({ title: '', timeSpent: 5000 }), + }), + ); + + expect(snackServiceMock.open).toHaveBeenCalled(); + sub.unsubscribe(); + }); + }); + describe('deadlineTodayBanner$', () => { let store: MockStore; diff --git a/src/app/features/tasks/store/task-ui.effects.ts b/src/app/features/tasks/store/task-ui.effects.ts index d5808885e8..6764905478 100644 --- a/src/app/features/tasks/store/task-ui.effects.ts +++ b/src/app/features/tasks/store/task-ui.effects.ts @@ -42,6 +42,7 @@ import { LayoutService } from '../../../core-ui/layout/layout.service'; import { LS } from '../../../core/persistence/storage-keys.const'; import { skipWhileApplyingRemoteOps } from '../../../util/skip-during-sync.operator'; import { DateService } from '../../../core/date/date.service'; +import { isBlankTask } from '../util/is-blank-task'; @Injectable() export class TaskUiEffects { @@ -116,6 +117,8 @@ export class TaskUiEffects { () => this._actions$.pipe( ofType(TaskSharedActions.deleteTask), + // Skip the undo snack for accidentally created blank tasks + filter(({ task }) => !isBlankTask(task)), tap(({ task }) => { this._snackService.open({ translateParams: { diff --git a/src/app/features/tasks/util/is-blank-task.spec.ts b/src/app/features/tasks/util/is-blank-task.spec.ts new file mode 100644 index 0000000000..07d38425aa --- /dev/null +++ b/src/app/features/tasks/util/is-blank-task.spec.ts @@ -0,0 +1,118 @@ +import { isBlankTask } from './is-blank-task'; +import { Task, TaskWithSubTasks } from '../task.model'; + +const createTask = (overrides: Partial = {}): Task => + ({ + id: 'task-1', + title: '', + projectId: 'project-1', + tagIds: [], + subTaskIds: [], + parentId: undefined, + timeSpentOnDay: {}, + timeSpent: 0, + timeEstimate: 0, + isDone: false, + notes: '', + attachments: [], + created: Date.now(), + ...overrides, + }) as Task; + +const createTaskWithSubTasks = ( + overrides: Partial = {}, +): TaskWithSubTasks => ({ + ...createTask(), + subTasks: [], + ...overrides, +}); + +describe('isBlankTask', () => { + it('should be true for a freshly created task with an empty title', () => { + expect(isBlankTask(createTaskWithSubTasks())).toBe(true); + }); + + it('should be true when the title is only whitespace', () => { + expect(isBlankTask(createTaskWithSubTasks({ title: ' ' }))).toBe(true); + }); + + it('should ignore context-derived fields (tagIds, projectId)', () => { + expect( + isBlankTask( + createTaskWithSubTasks({ tagIds: ['some-tag'], projectId: 'other-project' }), + ), + ).toBe(true); + }); + + it('should be false when the task has a title', () => { + expect(isBlankTask(createTaskWithSubTasks({ title: 'Real task' }))).toBe(false); + }); + + it('should be false when the task has notes', () => { + expect(isBlankTask(createTaskWithSubTasks({ notes: 'some note' }))).toBe(false); + }); + + it('should be false when time was tracked', () => { + expect(isBlankTask(createTaskWithSubTasks({ timeSpent: 1000 }))).toBe(false); + }); + + it('should be false when a time estimate is set', () => { + expect(isBlankTask(createTaskWithSubTasks({ timeEstimate: 60000 }))).toBe(false); + }); + + it('should be false when the task has attachments', () => { + expect( + isBlankTask( + createTaskWithSubTasks({ + attachments: [{ id: 'a', type: 'LINK', title: 't', path: 'p' }], + }), + ), + ).toBe(false); + }); + + it('should be false when the task is linked to an issue', () => { + expect(isBlankTask(createTaskWithSubTasks({ issueId: 'issue-1' }))).toBe(false); + }); + + it('should be false when the task has a reminder', () => { + expect(isBlankTask(createTaskWithSubTasks({ reminderId: 'reminder-1' }))).toBe(false); + }); + + it('should be false when the task has a repeat config', () => { + expect(isBlankTask(createTaskWithSubTasks({ repeatCfgId: 'repeat-1' }))).toBe(false); + }); + + it('should be false when the task is scheduled', () => { + expect(isBlankTask(createTaskWithSubTasks({ dueWithTime: Date.now() }))).toBe(false); + expect(isBlankTask(createTaskWithSubTasks({ dueDay: '2026-05-22' }))).toBe(false); + }); + + it('should be false when the task has a deadline', () => { + expect(isBlankTask(createTaskWithSubTasks({ deadlineDay: '2026-05-22' }))).toBe( + false, + ); + expect(isBlankTask(createTaskWithSubTasks({ deadlineWithTime: Date.now() }))).toBe( + false, + ); + }); + + it('should be false for a parent task with a sub task that has data', () => { + expect( + isBlankTask( + createTaskWithSubTasks({ + subTasks: [createTask({ id: 'sub-1', title: 'Sub task' })], + }), + ), + ).toBe(false); + }); + + it('should be true for a parent task whose sub tasks are all blank', () => { + expect( + isBlankTask( + createTaskWithSubTasks({ + subTasks: [createTask({ id: 'sub-1' }), createTask({ id: 'sub-2' })], + }), + ), + ).toBe(true); + }); +}); diff --git a/src/app/features/tasks/util/is-blank-task.ts b/src/app/features/tasks/util/is-blank-task.ts new file mode 100644 index 0000000000..23377e8fcb --- /dev/null +++ b/src/app/features/tasks/util/is-blank-task.ts @@ -0,0 +1,30 @@ +import { Task, TaskWithSubTasks } from '../task.model'; + +/** + * Returns true when a task carries no user data worth undoing: an empty title + * plus no notes, time tracking, estimate, attachments, issue link, scheduling, + * deadline, repeat config or non-blank sub tasks. + * + * Used to suppress the undo-delete snack when an accidentally created blank + * task (or sub task) is deleted right away. Context-derived fields like + * `tagIds` and `projectId` are intentionally ignored — they are not data the + * user would lose. + */ +export const isBlankTask = (task: Task | TaskWithSubTasks): boolean => { + const subTasks = (task as TaskWithSubTasks).subTasks; + return ( + !task.title.trim() && + !task.notes?.trim() && + !task.timeSpent && + !task.timeEstimate && + !task.attachments?.length && + !task.issueId && + !task.reminderId && + !task.repeatCfgId && + !task.dueWithTime && + !task.dueDay && + !task.deadlineDay && + !task.deadlineWithTime && + (!subTasks?.length || subTasks.every(isBlankTask)) + ); +}; diff --git a/src/app/op-log/apply/archive-operation-handler.service.spec.ts b/src/app/op-log/apply/archive-operation-handler.service.spec.ts index d199f7d7c8..da7215e984 100644 --- a/src/app/op-log/apply/archive-operation-handler.service.spec.ts +++ b/src/app/op-log/apply/archive-operation-handler.service.spec.ts @@ -1096,6 +1096,50 @@ describe('ArchiveOperationHandler', () => { }); }); + describe('REPAIR', () => { + // Regression: a REPAIR op built from the sync getStateSnapshot() carries + // empty archives. Without this guard it overwrites (wipes) the local + // archive on every other client that applies the REPAIR op. + it('should preserve local archiveYoung when REPAIR has empty archive (likely bug)', async () => { + const action = { + type: loadAllData.type, + appDataComplete: { archiveYoung: emptyArchive }, + meta: { isPersistent: true, isRemote: true, opType: OpType.Repair }, + } as unknown as PersistentAction; + + await service.handleOperation(action); + + expect(mockArchiveDbAdapter.saveArchiveYoung).not.toHaveBeenCalled(); + }); + + it('should preserve local archiveOld when REPAIR has empty archive (likely bug)', async () => { + const action = { + type: loadAllData.type, + appDataComplete: { archiveOld: emptyArchive }, + meta: { isPersistent: true, isRemote: true, opType: OpType.Repair }, + } as unknown as PersistentAction; + + await service.handleOperation(action); + + expect(mockArchiveDbAdapter.saveArchiveOld).not.toHaveBeenCalled(); + }); + + it('should allow writing non-empty archive over existing archive', async () => { + const newArchive = createArchiveModelForTest(['new-task']); + const action = { + type: loadAllData.type, + appDataComplete: { archiveYoung: newArchive }, + meta: { isPersistent: true, isRemote: true, opType: OpType.Repair }, + } as unknown as PersistentAction; + + await service.handleOperation(action); + + expect(mockArchiveDbAdapter.saveArchiveYoung).toHaveBeenCalledWith( + newArchive, + ); + }); + }); + describe('BACKUP_IMPORT', () => { let originalConfirm: typeof window.confirm; diff --git a/src/app/op-log/apply/archive-operation-handler.service.ts b/src/app/op-log/apply/archive-operation-handler.service.ts index bdacfc5a18..43a611a7d2 100644 --- a/src/app/op-log/apply/archive-operation-handler.service.ts +++ b/src/app/op-log/apply/archive-operation-handler.service.ts @@ -469,8 +469,9 @@ export class ArchiveOperationHandler implements ArchiveSideEffectPort 0; const hasExistingOld = (originalArchiveOld?.task?.ids?.length ?? 0) > 0; const isIncomingYoungEmpty = !archiveYoung?.task?.ids?.length; @@ -480,12 +481,17 @@ export class ArchiveOperationHandler implements ArchiveSideEffectPort { let mockImexViewService: jasmine.SpyObj; let mockStateSnapshotService: jasmine.SpyObj; let mockOpLogStore: jasmine.SpyObj; - let mockClientIdService: jasmine.SpyObj; let mockOperationWriteFlushService: jasmine.SpyObj; let mockLockService: jasmine.SpyObj; @@ -107,7 +105,6 @@ describe('BackupService', () => { 'saveImportBackup', 'runDestructiveStateReplacement', ]); - mockClientIdService = jasmine.createSpyObj('ClientIdService', ['withRotation']); mockOperationWriteFlushService = jasmine.createSpyObj('OperationWriteFlushService', [ 'flushPendingWrites', ]); @@ -119,12 +116,6 @@ describe('BackupService', () => { ); mockOpLogStore.saveImportBackup.and.resolveTo(); mockOpLogStore.runDestructiveStateReplacement.and.resolveTo(); - // ClientIdService.withRotation owns the rollback semantics (see its own - // spec); here we just invoke the callback with a fresh id. - mockClientIdService.withRotation.and.callFake( - async (_logPrefix: string, fn: (newClientId: string) => Promise) => - fn('newClientId'), - ); mockOperationWriteFlushService.flushPendingWrites.and.resolveTo(); mockLockService.request.and.callFake(async (_lockName, fn) => fn()); @@ -135,7 +126,6 @@ describe('BackupService', () => { { provide: ImexViewService, useValue: mockImexViewService }, { provide: StateSnapshotService, useValue: mockStateSnapshotService }, { provide: OperationLogStoreService, useValue: mockOpLogStore }, - { provide: ClientIdService, useValue: mockClientIdService }, { provide: OperationWriteFlushService, useValue: mockOperationWriteFlushService, @@ -308,21 +298,22 @@ describe('BackupService', () => { const args = mockOpLogStore.runDestructiveStateReplacement.calls.mostRecent() .args[0] as Parameters[0]; - expect(args.syncImportOp.vectorClock).toEqual({ newClientId: 1 }); + // The clientId is freshly minted (generateClientId) — new compact format. + const op = args.syncImportOp; + expect(op.clientId).toMatch(/^[BEAI]_[a-zA-Z0-9]{4}$/); + expect(op.vectorClock).toEqual({ [op.clientId]: 1 }); }); it('should pass a fresh clock to the atomic helper on force-import', async () => { - mockClientIdService.withRotation.and.callFake( - async (_logPrefix: string, fn: (newClientId: string) => Promise) => - fn('newForceClient'), - ); const backupData = createMinimalValidBackup(); await service.importCompleteBackup(backupData as any, true, true, true); const args = mockOpLogStore.runDestructiveStateReplacement.calls.mostRecent() .args[0] as Parameters[0]; - expect(args.syncImportOp.vectorClock).toEqual({ newForceClient: 1 }); + const op = args.syncImportOp; + expect(op.clientId).toMatch(/^[BEAI]_[a-zA-Z0-9]{4}$/); + expect(op.vectorClock).toEqual({ [op.clientId]: 1 }); }); /** @@ -349,17 +340,14 @@ describe('BackupService', () => { }); it('should produce fresh { [clientId]: 1 } clock on import', async () => { - mockClientIdService.withRotation.and.callFake( - async (_logPrefix: string, fn: (newClientId: string) => Promise) => - fn('newForceClient'), - ); const backupData = createMinimalValidBackup(); await service.importCompleteBackup(backupData as any, true, true, true); const args = mockOpLogStore.runDestructiveStateReplacement.calls.mostRecent() .args[0] as Parameters[0]; - expect(args.syncImportOp.vectorClock).toEqual({ newForceClient: 1 }); + const op = args.syncImportOp; + expect(op.vectorClock).toEqual({ [op.clientId]: 1 }); }); it('should abort the import (and not dispatch loadAllData) when the pre-import backup fails', async () => { @@ -393,19 +381,6 @@ describe('BackupService', () => { expect(mockOpLogStore.saveImportBackup).toHaveBeenCalledWith(currentState); }); - it('should delegate cross-DB clientId rollback to ClientIdService.withRotation', async () => { - // ClientIdService.withRotation owns the rollback semantics — capture - // prior id, run callback, restore on failure, log critical if rollback - // also fails. Tested directly in client-id.service.spec.ts; here we - // only verify BackupService routes through it with the right log tag. - await service.importCompleteBackup(createMinimalValidBackup() as any, true, true); - - expect(mockClientIdService.withRotation).toHaveBeenCalledWith( - 'BackupService:', - jasmine.any(Function), - ); - }); - it('should pass snapshotEntityKeys derived from the imported data', async () => { const backupData = createMinimalValidBackup(); diff --git a/src/app/op-log/backup/backup.service.ts b/src/app/op-log/backup/backup.service.ts index f6bf5ef27e..5d9ddd5b5e 100644 --- a/src/app/op-log/backup/backup.service.ts +++ b/src/app/op-log/backup/backup.service.ts @@ -3,7 +3,7 @@ import { Store } from '@ngrx/store'; import { ImexViewService } from '../../imex/imex-meta/imex-view.service'; import { StateSnapshotService } from './state-snapshot.service'; import { OperationLogStoreService } from '../persistence/operation-log-store.service'; -import { ClientIdService } from '../../core/util/client-id.service'; +import { generateClientId } from '../../core/util/generate-client-id'; import { Operation, OpType, ActionType } from '../core/operation.types'; import { CURRENT_SCHEMA_VERSION } from '../persistence/schema-migration.service'; import { uuidv7 } from '../../util/uuid-v7'; @@ -39,7 +39,6 @@ export class BackupService { private _store = inject(Store); private _stateSnapshotService = inject(StateSnapshotService); private _opLogStore = inject(OperationLogStoreService); - private _clientIdService = inject(ClientIdService); private _operationWriteFlushService = inject(OperationWriteFlushService); private _lockService = inject(LockService); @@ -188,8 +187,7 @@ export class BackupService { } catch (e) { // `message` is intentionally omitted: log history is user-exportable // (CLAUDE.md sync rule 9), and a future validator/IDB error type could - // interpolate user content into its message. Match the `name`-only - // pattern used by `ClientIdService.withRotation` rollback logging. + // interpolate user content into its message. Log the error `name` only. OpLog.warn('BackupService: Failed to backup state before import:', { name: (e as Error | undefined)?.name, }); @@ -198,40 +196,40 @@ export class BackupService { ); } - // Rotate the clientId for the duration of the destructive replacement. - // `pf` (clientId) is a separate IDB DB and so cannot share the atomic - // SUP_OPS tx; ClientIdService.withRotation rolls it back on failure. - await this._clientIdService.withRotation('BackupService:', async (clientId) => { - const newClock = { [clientId]: 1 }; - const opId = uuidv7(); - // IMPORTANT: Uses OpType.BackupImport which maps to reason='recovery' on the server. - // This allows backup imports to succeed even when a SYNC_IMPORT already exists. - // See server validation at sync.routes.ts:703-733 - const op: Operation = { - id: opId, - actionType: ActionType.LOAD_ALL_DATA, - opType: OpType.BackupImport, - entityType: 'ALL', - entityId: opId, - payload: importedData, - clientId, - vectorClock: newClock, - timestamp: Date.now(), - schemaVersion: CURRENT_SCHEMA_VERSION, - syncImportReason: 'BACKUP_RESTORE', - }; + // Mint a fresh clientId for the new sync baseline. It is pure here — + // persisted only inside runDestructiveStateReplacement's atomic SUP_OPS + // transaction, which also clears the ClientIdService cache. On a throw the + // tx aborts and the prior id stands. + const clientId = generateClientId(); + const newClock = { [clientId]: 1 }; + const opId = uuidv7(); + // IMPORTANT: Uses OpType.BackupImport which maps to reason='recovery' on the server. + // This allows backup imports to succeed even when a SYNC_IMPORT already exists. + // See server validation at sync.routes.ts:703-733 + const op: Operation = { + id: opId, + actionType: ActionType.LOAD_ALL_DATA, + opType: OpType.BackupImport, + entityType: 'ALL', + entityId: opId, + payload: importedData, + clientId, + vectorClock: newClock, + timestamp: Date.now(), + schemaVersion: CURRENT_SCHEMA_VERSION, + syncImportReason: 'BACKUP_RESTORE', + }; - // Issue #7709: replace OPS + state_cache + vector_clock atomically so an - // interrupt during backup-restore can't leave the device in the - // `isWhollyFreshClient + meaningful store data` state that triggers the - // multi-device data-loss chain. - OpLog.normal('BackupService: Replacing op-log + state cache atomically'); - await this._opLogStore.runDestructiveStateReplacement({ - syncImportOp: op, - snapshotEntityKeys: extractEntityKeysFromState(importedData), - archiveYoung: importedData.archiveYoung, - archiveOld: importedData.archiveOld, - }); + // Issue #7709: replace OPS + state_cache + vector_clock + clientId + // atomically so an interrupt during backup-restore can't leave the device + // in the `isWhollyFreshClient + meaningful store data` state that triggers + // the multi-device data-loss chain. + OpLog.normal('BackupService: Replacing op-log + state cache atomically'); + await this._opLogStore.runDestructiveStateReplacement({ + syncImportOp: op, + snapshotEntityKeys: extractEntityKeysFromState(importedData), + archiveYoung: importedData.archiveYoung, + archiveOld: importedData.archiveOld, }); OpLog.normal('BackupService: Import persisted to operation log.'); diff --git a/src/app/op-log/capture/operation-log.effects.spec.ts b/src/app/op-log/capture/operation-log.effects.spec.ts index 7a68d4d5ff..8abd44edce 100644 --- a/src/app/op-log/capture/operation-log.effects.spec.ts +++ b/src/app/op-log/capture/operation-log.effects.spec.ts @@ -72,8 +72,7 @@ describe('OperationLogEffects', () => { 'trigger', ]); mockClientIdService = jasmine.createSpyObj('ClientIdService', [ - 'loadClientId', - 'generateNewClientId', + 'getOrGenerateClientId', ]); mockOperationCaptureService = jasmine.createSpyObj('OperationCaptureService', [ 'dequeue', @@ -92,9 +91,8 @@ describe('OperationLogEffects', () => { mockCompactionService.compact.and.returnValue(Promise.resolve()); mockCompactionService.emergencyCompact.and.returnValue(Promise.resolve(true)); mockStore.select.and.returnValue(of({})); // Return empty state observable - mockClientIdService.loadClientId.and.returnValue(Promise.resolve('testClient')); - mockClientIdService.generateNewClientId.and.returnValue( - Promise.resolve('generatedClient'), + mockClientIdService.getOrGenerateClientId.and.returnValue( + Promise.resolve('testClient'), ); mockOperationCaptureService.dequeue.and.returnValue([]); @@ -198,7 +196,7 @@ describe('OperationLogEffects', () => { return fn(); }, ); - mockClientIdService.loadClientId.and.callFake(async () => { + mockClientIdService.getOrGenerateClientId.and.callFake(async () => { callOrder.push('clientId'); return 'testClient'; }); @@ -219,11 +217,11 @@ describe('OperationLogEffects', () => { }); it('should capture the post-rotation clientId when a destructive replacement rotates it while this op is queued (#7709)', (done) => { - // Simulates the race the fix at operation-log.effects.ts:163-171 closes: - // a clean-slate / backup-import is already holding the operation-log lock - // and rotates the clientId via ClientIdService.withRotation. A queued op - // waiting behind that lock must read the rotated id, not the pre-rotation - // id captured before lock acquisition. + // Simulates the race the fix at operation-log.effects.ts closes: a + // clean-slate / backup-import is already holding the operation-log lock + // and rotates the clientId inside runDestructiveStateReplacement. A + // queued op waiting behind that lock must read the rotated id, not the + // pre-rotation id captured before lock acquisition. let persistedClientId = 'oldClient'; mockLockService.request.and.callFake( @@ -234,7 +232,9 @@ describe('OperationLogEffects', () => { return fn(); }, ); - mockClientIdService.loadClientId.and.callFake(async () => persistedClientId); + mockClientIdService.getOrGenerateClientId.and.callFake( + async () => persistedClientId, + ); const action = createPersistentAction(ActionType.TASK_SHARED_UPDATE); actions$ = of(action); @@ -448,9 +448,10 @@ describe('OperationLogEffects', () => { mockOpLogStore.appendWithVectorClockUpdate.calls.mostRecent().args[0]; expect(firstOp.clientId).toBe('testClient'); - // Simulate backup import generating a new client ID - // (BackupService calls generateNewClientId which updates the cached value) - mockClientIdService.loadClientId.and.returnValue( + // Simulate a backup import rotating the client ID (BackupService's + // destructive replacement persists a fresh id; the next read of + // getOrGenerateClientId() picks it up after the cache is cleared). + mockClientIdService.getOrGenerateClientId.and.returnValue( Promise.resolve('newImportClient'), ); diff --git a/src/app/op-log/capture/operation-log.effects.ts b/src/app/op-log/capture/operation-log.effects.ts index 7000cfc8be..396ed909cc 100644 --- a/src/app/op-log/capture/operation-log.effects.ts +++ b/src/app/op-log/capture/operation-log.effects.ts @@ -165,14 +165,10 @@ export class OperationLogEffects implements DeferredLocalActionsPort { // Clean-slate and backup import rotate the clientId while holding this // same lock; reading here prevents a queued operation from capturing // the old id before waiting behind a destructive replacement. - const clientId = - (await this.clientIdService.loadClientId()) ?? - (await this.clientIdService.generateNewClientId()); - if (!clientId) { - throw new Error( - 'Failed to load or generate clientId - cannot persist operation', - ); - } + // getOrGenerateClientId() throws on a transient IndexedDB read failure + // rather than minting a fresh id — the catch below treats that as a + // retryable persistence failure. + const clientId = await this.clientIdService.getOrGenerateClientId(); // MULTI-TAB SAFETY: Clear vector clock cache to ensure fresh read after other tabs // may have written while we were waiting for the lock. Each tab has its own in-memory diff --git a/src/app/op-log/clean-slate/clean-slate.service.spec.ts b/src/app/op-log/clean-slate/clean-slate.service.spec.ts index d6941a0755..647e3c2f86 100644 --- a/src/app/op-log/clean-slate/clean-slate.service.spec.ts +++ b/src/app/op-log/clean-slate/clean-slate.service.spec.ts @@ -2,7 +2,6 @@ import { TestBed } from '@angular/core/testing'; import { CleanSlateService } from './clean-slate.service'; import { StateSnapshotService } from '../backup/state-snapshot.service'; import { OperationLogStoreService } from '../persistence/operation-log-store.service'; -import { ClientIdService } from '../../core/util/client-id.service'; import { OpType, OperationLogEntry } from '../core/operation.types'; import { ActionType } from '../core/action-types.enum'; import { CURRENT_SCHEMA_VERSION } from '../persistence/schema-migration.service'; @@ -15,7 +14,6 @@ describe('CleanSlateService', () => { let service: CleanSlateService; let mockStateSnapshotService: jasmine.SpyObj; let mockOpLogStore: jasmine.SpyObj; - let mockClientIdService: jasmine.SpyObj; let mockOperationWriteFlushService: jasmine.SpyObj; let mockLockService: jasmine.SpyObj; @@ -35,7 +33,6 @@ describe('CleanSlateService', () => { 'getVectorClock', 'getUnsynced', ]); - mockClientIdService = jasmine.createSpyObj('ClientIdService', ['withRotation']); mockOperationWriteFlushService = jasmine.createSpyObj('OperationWriteFlushService', [ 'flushPendingWrites', ]); @@ -46,7 +43,6 @@ describe('CleanSlateService', () => { CleanSlateService, { provide: StateSnapshotService, useValue: mockStateSnapshotService }, { provide: OperationLogStoreService, useValue: mockOpLogStore }, - { provide: ClientIdService, useValue: mockClientIdService }, { provide: OperationWriteFlushService, useValue: mockOperationWriteFlushService, @@ -59,13 +55,6 @@ describe('CleanSlateService', () => { // Setup default mock responses mockStateSnapshotService.getStateSnapshotAsync.and.resolveTo(mockState as any); - // Default: withRotation invokes its callback with the new clientId and - // propagates whatever the callback returns or throws. ClientIdService's - // own spec covers the rollback semantics. - mockClientIdService.withRotation.and.callFake( - async (_logPrefix: string, fn: (newClientId: string) => Promise) => - fn('eNewC'), - ); mockOpLogStore.runDestructiveStateReplacement.and.resolveTo(); mockOpLogStore.getVectorClock.and.resolveTo(null); mockOpLogStore.getUnsynced.and.resolveTo([]); @@ -80,13 +69,7 @@ describe('CleanSlateService', () => { // Should get current state (async version to include archives) expect(mockStateSnapshotService.getStateSnapshotAsync).toHaveBeenCalled(); - // Should rotate client ID via the shared helper - expect(mockClientIdService.withRotation).toHaveBeenCalledWith( - '[CleanSlate]', - jasmine.any(Function), - ); - - // Should route through the atomic helper (issue #7709) + // Should route through the atomic helper (issues #7709, #7732) expect(mockOpLogStore.runDestructiveStateReplacement).toHaveBeenCalledTimes(1); const args = mockOpLogStore.runDestructiveStateReplacement.calls.mostRecent() .args[0] as Parameters[0]; @@ -96,8 +79,10 @@ describe('CleanSlateService', () => { expect(appendedOp.opType).toBe(OpType.SyncImport); expect(appendedOp.entityType).toBe('ALL'); expect(appendedOp.payload).toBe(mockState); - expect(appendedOp.clientId).toBe('eNewC'); - expect(appendedOp.vectorClock).toEqual({ eNewC: 1 }); + // The clientId is freshly minted (generateClientId) — new compact format. + // runDestructiveStateReplacement persists it inside its atomic tx. + expect(appendedOp.clientId).toMatch(/^[BEAI]_[a-zA-Z0-9]{4}$/); + expect(appendedOp.vectorClock).toEqual({ [appendedOp.clientId]: 1 }); expect(appendedOp.schemaVersion).toBe(CURRENT_SCHEMA_VERSION); }); @@ -199,7 +184,8 @@ describe('CleanSlateService', () => { const args = mockOpLogStore.runDestructiveStateReplacement.calls.mostRecent() .args[0] as Parameters[0]; - expect(args.syncImportOp.vectorClock).toEqual({ eNewC: 1 }); + const op = args.syncImportOp; + expect(op.vectorClock).toEqual({ [op.clientId]: 1 }); }); it('should create operation with valid UUIDv7', async () => { @@ -223,25 +209,10 @@ describe('CleanSlateService', () => { ).toBeRejectedWith(jasmine.objectContaining({ message: 'State error' })); }); - it('should propagate errors from withRotation', async () => { - // ClientIdService.withRotation owns the cross-DB rollback semantics - // (see its own spec). Here we only verify that CleanSlateService - // surfaces failures from the rotation/replacement chain to its caller. - mockClientIdService.withRotation.and.rejectWith( - new Error('Atomic replacement failed'), - ); - - await expectAsync( - service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED'), - ).toBeRejectedWith( - jasmine.objectContaining({ message: 'Atomic replacement failed' }), - ); - }); - - it('should propagate errors from runDestructiveStateReplacement through withRotation', async () => { - // The destructive helper runs inside the withRotation callback. - // withRotation must re-throw whatever the callback throws so the - // caller sees the real failure. + it('should propagate errors from runDestructiveStateReplacement', async () => { + // The clientId now rotates inside runDestructiveStateReplacement's atomic + // transaction; a failure there aborts the whole replacement and the + // caller must see the real error. mockOpLogStore.runDestructiveStateReplacement.and.rejectWith( new Error('Atomic replacement failed'), ); diff --git a/src/app/op-log/clean-slate/clean-slate.service.ts b/src/app/op-log/clean-slate/clean-slate.service.ts index a1a75ea17c..363d3fa9b5 100644 --- a/src/app/op-log/clean-slate/clean-slate.service.ts +++ b/src/app/op-log/clean-slate/clean-slate.service.ts @@ -2,7 +2,7 @@ import { inject, Injectable } from '@angular/core'; import { uuidv7 } from '../../util/uuid-v7'; import { StateSnapshotService } from '../backup/state-snapshot.service'; import { OperationLogStoreService } from '../persistence/operation-log-store.service'; -import { ClientIdService } from '../../core/util/client-id.service'; +import { generateClientId } from '../../core/util/generate-client-id'; import { OpLog } from '../../core/log'; import { Operation, OpType, SyncImportReason } from '../core/operation.types'; import { ActionType } from '../core/action-types.enum'; @@ -22,15 +22,16 @@ export type CleanSlateReason = 'ENCRYPTION_CHANGE' | 'MANUAL'; /** * Service for performing "clean slate" operations on the sync state. * - * Atomically replaces the local op-log + state_cache + vector_clock with a - * fresh baseline derived from current state. Used by encryption-password + * Atomically replaces the local op-log + state_cache + vector_clock + clientId + * with a fresh baseline derived from current state. Used by encryption-password * changes (which need a fresh sync baseline) and by user-initiated sync * recovery. * - * Atomicity within `SUP_OPS` is guaranteed by - * `OperationLogStoreService.runDestructiveStateReplacement`; cross-DB - * rotation of the clientId (which lives in `pf`) is rolled back on failure - * — see issue #7709. + * Atomicity is guaranteed by + * `OperationLogStoreService.runDestructiveStateReplacement`. The clientId now + * lives in `SUP_OPS` too, so it rotates atomically with the op-log inside that + * single transaction — no cross-database rollback is needed (issues #7709, + * #7732). * * @example * ```typescript @@ -44,7 +45,6 @@ export type CleanSlateReason = 'ENCRYPTION_CHANGE' | 'MANUAL'; export class CleanSlateService { private stateSnapshotService = inject(StateSnapshotService); private opLogStore = inject(OperationLogStoreService); - private clientIdService = inject(ClientIdService); private operationWriteFlushService = inject(OperationWriteFlushService); private lockService = inject(LockService); @@ -52,10 +52,9 @@ export class CleanSlateService { * Creates a clean slate by resetting local operation log and preparing * a fresh SYNC_IMPORT operation for upload. * - * The destructive sequence (clear OPS, append SYNC_IMPORT, write vector - * clock, write state_cache) runs as a single atomic transaction. On - * failure, the rotated clientId is rolled back so `pf` and `SUP_OPS` stay - * consistent. + * The destructive sequence (rotate clientId, clear OPS, append SYNC_IMPORT, + * write vector clock, write state_cache) runs as a single atomic + * transaction. On failure it aborts wholesale and the prior id stands. * * Does NOT upload to the server — that happens on the next sync, with the * `isCleanSlate=true` flag, which makes the server delete its operations @@ -101,43 +100,41 @@ export class CleanSlateService { // which causes data loss. const currentState = await this.stateSnapshotService.getStateSnapshotAsync(); - // Rotate the clientId for the duration of the destructive replacement. - // The clientId lives in a separate IDB database (`pf`) and so cannot - // share the atomic SUP_OPS tx below; ClientIdService.withRotation rolls - // it back on failure so `pf` and `SUP_OPS` agree on the device's - // clientId after this method returns or throws. - return this.clientIdService.withRotation('[CleanSlate]', async (newClientId) => { - OpLog.normal('[CleanSlate] Generated new client ID', { - newClientIdSuffix: newClientId.slice(-3), - }); - - const newVectorClock = { [newClientId]: 1 }; - const syncImportOp: Operation = { - id: uuidv7(), - actionType: ActionType.LOAD_ALL_DATA, - opType: OpType.SyncImport, - entityType: 'ALL', - entityId: undefined, - payload: currentState, - clientId: newClientId, - vectorClock: newVectorClock, - timestamp: Date.now(), - schemaVersion: CURRENT_SCHEMA_VERSION, - syncImportReason, - }; - - OpLog.normal('[CleanSlate] Created SYNC_IMPORT operation', { - opId: syncImportOp.id, - }); - - OpLog.normal('[CleanSlate] Replacing op-log + state cache atomically'); - await this.opLogStore.runDestructiveStateReplacement({ - syncImportOp, - snapshotEntityKeys: extractEntityKeysFromState(currentState), - }); - - return { syncImportId: syncImportOp.id }; + // Mint a fresh clientId for the new sync baseline. It is pure here — + // persisted only inside runDestructiveStateReplacement's atomic + // SUP_OPS transaction, which also clears the ClientIdService cache. + // On a throw the tx aborts and the prior id stands. + const newClientId = generateClientId(); + OpLog.normal('[CleanSlate] Generated new client ID', { + newClientIdSuffix: newClientId.slice(-3), }); + + const newVectorClock = { [newClientId]: 1 }; + const syncImportOp: Operation = { + id: uuidv7(), + actionType: ActionType.LOAD_ALL_DATA, + opType: OpType.SyncImport, + entityType: 'ALL', + entityId: undefined, + payload: currentState, + clientId: newClientId, + vectorClock: newVectorClock, + timestamp: Date.now(), + schemaVersion: CURRENT_SCHEMA_VERSION, + syncImportReason, + }; + + OpLog.normal('[CleanSlate] Created SYNC_IMPORT operation', { + opId: syncImportOp.id, + }); + + OpLog.normal('[CleanSlate] Replacing op-log + state cache atomically'); + await this.opLogStore.runDestructiveStateReplacement({ + syncImportOp, + snapshotEntityKeys: extractEntityKeysFromState(currentState), + }); + + return { syncImportId: syncImportOp.id }; }, ); diff --git a/src/app/op-log/persistence/db-keys.const.ts b/src/app/op-log/persistence/db-keys.const.ts index 65623d9fa2..fd190b8635 100644 --- a/src/app/op-log/persistence/db-keys.const.ts +++ b/src/app/op-log/persistence/db-keys.const.ts @@ -12,7 +12,7 @@ import { CompleteBackup } from '../sync-exports'; export const DB_NAME = 'SUP_OPS'; /** Current database schema version */ -export const DB_VERSION = 5; +export const DB_VERSION = 6; /** Object store names */ export const STORE_NAMES = { @@ -30,6 +30,8 @@ export const STORE_NAMES = { ARCHIVE_OLD: 'archive_old' as const, /** Profile data - stores complete backups for user profile switching */ PROFILE_DATA: 'profile_data' as const, + /** Client ID - sync device identity (singleton, key = SINGLETON_KEY) */ + CLIENT_ID: 'client_id' as const, } as const; /** Common key used for singleton entries */ diff --git a/src/app/op-log/persistence/db-upgrade.spec.ts b/src/app/op-log/persistence/db-upgrade.spec.ts index 1b5af36016..3f0710cb6e 100644 --- a/src/app/op-log/persistence/db-upgrade.spec.ts +++ b/src/app/op-log/persistence/db-upgrade.spec.ts @@ -153,8 +153,8 @@ describe('runDbUpgrade', () => { // Version 2 upgrade doesn't create any stores (only version 3+ run) // Version 3 only adds an index, doesn't create stores - // Version 4 creates archive stores, version 5 creates profile_data - expect(db.createObjectStore).toHaveBeenCalledTimes(3); // archive_young, archive_old, profile_data + // Version 4 creates archive stores, version 5 profile_data, version 6 client_id + expect(db.createObjectStore).toHaveBeenCalledTimes(4); // archive_young, archive_old, profile_data, client_id expect(db.createObjectStore).not.toHaveBeenCalledWith( STORE_NAMES.OPS, jasmine.anything(), @@ -200,7 +200,27 @@ describe('runDbUpgrade', () => { }); }); - describe('full upgrade path (version 0 to 4)', () => { + describe('version 6 upgrade (from version 5)', () => { + it('should create client_id store', () => { + const preExisting = new Map([[STORE_NAMES.OPS, { store: createMockStore() }]]); + const { db, tx } = createMocks(preExisting); + + runDbUpgrade(db, 5, tx); + + expect(db.createObjectStore).toHaveBeenCalledWith(STORE_NAMES.CLIENT_ID); + }); + + it('should not recreate earlier stores', () => { + const preExisting = new Map([[STORE_NAMES.OPS, { store: createMockStore() }]]); + const { db, tx } = createMocks(preExisting); + + runDbUpgrade(db, 5, tx); + + expect(db.createObjectStore).toHaveBeenCalledTimes(1); // client_id only + }); + }); + + describe('full upgrade path (version 0 to 6)', () => { it('should create all stores and indexes when upgrading from version 0', () => { const { db, tx } = createMocks(); @@ -239,8 +259,11 @@ describe('runDbUpgrade', () => { jasmine.anything(), ); - // Total: 7 stores created - expect(db.createObjectStore).toHaveBeenCalledTimes(7); + // Version 6 store + expect(db.createObjectStore).toHaveBeenCalledWith(STORE_NAMES.CLIENT_ID); + + // Total: 8 stores created + expect(db.createObjectStore).toHaveBeenCalledTimes(8); }); it('should create all indexes on ops store when upgrading from version 0', () => { diff --git a/src/app/op-log/persistence/db-upgrade.ts b/src/app/op-log/persistence/db-upgrade.ts index 74657bbdf7..67dd2e2488 100644 --- a/src/app/op-log/persistence/db-upgrade.ts +++ b/src/app/op-log/persistence/db-upgrade.ts @@ -67,4 +67,13 @@ export const runDbUpgrade = ( if (oldVersion < 5) { db.createObjectStore(STORE_NAMES.PROFILE_DATA, { keyPath: 'id' }); } + + // Version 6: Add client_id store for atomic clientId rotation. + // Consolidates the sync clientId from legacy 'pf' (key '__client_id_') into + // SUP_OPS so destructive-flow rotation joins runDestructiveStateReplacement's + // atomic transaction. See issue #7732. The runtime copy from 'pf' happens in + // ClientIdService (a versionchange tx cannot read another database). + if (oldVersion < 6) { + db.createObjectStore(STORE_NAMES.CLIENT_ID); + } }; diff --git a/src/app/op-log/persistence/operation-log-migration.service.spec.ts b/src/app/op-log/persistence/operation-log-migration.service.spec.ts index 323c22dd67..a8cf80bf31 100644 --- a/src/app/op-log/persistence/operation-log-migration.service.spec.ts +++ b/src/app/op-log/persistence/operation-log-migration.service.spec.ts @@ -47,7 +47,7 @@ describe('OperationLogMigrationService', () => { mockMatDialog = jasmine.createSpyObj('MatDialog', ['open']); mockStore = jasmine.createSpyObj('Store', ['dispatch']); mockClientIdService = jasmine.createSpyObj('ClientIdService', [ - 'generateNewClientId', + 'getOrGenerateClientId', 'persistClientId', ]); mockTranslateService = jasmine.createSpyObj('TranslateService', [ @@ -373,7 +373,7 @@ describe('OperationLogMigrationService', () => { const meta = await mockLegacyPfDb.loadMetaModel(); const legacyClientId = await mockLegacyPfDb.loadClientId(); const clientId = - legacyClientId || (await mockClientIdService.generateNewClientId()); + legacyClientId ?? (await mockClientIdService.getOrGenerateClientId()); if (legacyClientId) { await mockClientIdService.persistClientId(legacyClientId); @@ -427,18 +427,18 @@ describe('OperationLogMigrationService', () => { expect(mockClientIdService.persistClientId).toHaveBeenCalledWith( 'legacyClientId1234', ); - expect(mockClientIdService.generateNewClientId).not.toHaveBeenCalled(); + expect(mockClientIdService.getOrGenerateClientId).not.toHaveBeenCalled(); expect(mockOpLogStore.append).toHaveBeenCalled(); }); it('should generate new client ID and NOT call persistClientId when legacy ID is null', async () => { mockLegacyPfDb.loadClientId.and.resolveTo(null); mockLegacyPfDb.loadMetaModel.and.resolveTo({ vectorClock: {} }); - mockClientIdService.generateNewClientId.and.resolveTo('B_xYz1'); + mockClientIdService.getOrGenerateClientId.and.resolveTo('B_xYz1'); await service.checkAndMigrate(); - expect(mockClientIdService.generateNewClientId).toHaveBeenCalled(); + expect(mockClientIdService.getOrGenerateClientId).toHaveBeenCalled(); expect(mockClientIdService.persistClientId).not.toHaveBeenCalled(); expect(mockOpLogStore.append).toHaveBeenCalled(); }); diff --git a/src/app/op-log/persistence/operation-log-migration.service.ts b/src/app/op-log/persistence/operation-log-migration.service.ts index 296ffbf898..2998e8481e 100644 --- a/src/app/op-log/persistence/operation-log-migration.service.ts +++ b/src/app/op-log/persistence/operation-log-migration.service.ts @@ -235,18 +235,28 @@ export class OperationLogMigrationService { OpLog.normal('OperationLogMigrationService: Data repair successful'); } - // 3. Get client ID (inherit from legacy or generate new) + // 3. Get client ID (inherit from legacy or resolve via ClientIdService). + // The genesis op's clientId MUST come from the legacy PFAPI `CLIENT_ID` + // key, because meta.vectorClock below is keyed by that same identity. + // getOrGenerateClientId() is the fallback only when `CLIENT_ID` is absent: + // it resolves whatever id this device already has (e.g. pf `__client_id_`) + // and generates a fresh one only when no id exists anywhere. const meta = await this.legacyPfDb.loadMetaModel(); const legacyClientId = await this.legacyPfDb.loadClientId(); - const clientId = legacyClientId || (await this.clientIdService.generateNewClientId()); + const clientId = + legacyClientId ?? (await this.clientIdService.getOrGenerateClientId()); - // Persist legacy client ID under __client_id_ so loadClientId() finds it. + // Persist the legacy client ID into SUP_OPS so loadClientId() finds it. // Without this, a brand new ID is generated on next write, doubling IDs. if (legacyClientId) { await this.clientIdService.persistClientId(legacyClientId); } - OpLog.normal(`OperationLogMigrationService: Using client ID: ${clientId}`); + // Log only a short suffix — the literal clientId keys the vector clock and + // log history is user-exportable (CLAUDE.md sync rule 9). + OpLog.normal('OperationLogMigrationService: Resolved client ID', { + clientIdSuffix: clientId.slice(-3), + }); // 4. Create MIGRATION genesis operation const migrationOp: Operation = { diff --git a/src/app/op-log/persistence/operation-log-store.service.spec.ts b/src/app/op-log/persistence/operation-log-store.service.spec.ts index 848222a128..06236f31e3 100644 --- a/src/app/op-log/persistence/operation-log-store.service.spec.ts +++ b/src/app/op-log/persistence/operation-log-store.service.spec.ts @@ -30,6 +30,7 @@ describe('OperationLogStoreService', () => { const mockClientIdProvider: ClientIdProvider = { loadClientId: () => Promise.resolve('testClient'), getOrGenerateClientId: () => Promise.resolve('testClient'), + clearCache: () => {}, }; // Helper to create test operations @@ -1712,6 +1713,62 @@ describe('OperationLogStoreService', () => { priorArchiveOld, ); }); + + it('writes the rotated clientId into the client_id store and clears the cache', async () => { + const clearCacheSpy = spyOn(mockClientIdProvider, 'clearCache'); + + await service.runDestructiveStateReplacement({ + syncImportOp: createTestOperation({ + opType: OpType.SyncImport, + entityType: 'ALL' as EntityType, + entityId: undefined, + clientId: 'rotatedClient', + vectorClock: { rotatedClient: 1 }, + payload: { task: { ids: [], entities: {} } }, + }), + snapshotEntityKeys: [], + }); + + const db = (service as any).db; + expect(await db.get(STORE_NAMES.CLIENT_ID, SINGLETON_KEY)).toBe('rotatedClient'); + // Cache-clear runs after a committed tx.done so the next clientId read + // sees the rotated value. + expect(clearCacheSpy).toHaveBeenCalledTimes(1); + }); + + it('leaves the client_id store and cache untouched when the destructive tx aborts', async () => { + const db = (service as any).db; + // Seed a prior clientId — the aborted put must roll back to this. + await db.put(STORE_NAMES.CLIENT_ID, 'priorClient', SINGLETON_KEY); + const clearCacheSpy = spyOn(mockClientIdProvider, 'clearCache'); + + const realTransaction = db.transaction.bind(db); + spyOn(db, 'transaction').and.callFake((stores: any, mode: any) => { + const tx = realTransaction(stores, mode); + if (Array.isArray(stores) && stores.includes(STORE_NAMES.OPS)) { + const opsStore = tx.objectStore(STORE_NAMES.OPS); + opsStore.add = async () => { + throw new Error('Simulated interrupt inside destructive tx'); + }; + } + return tx; + }); + + await expectAsync( + service.runDestructiveStateReplacement({ + syncImportOp: createTestOperation({ + opType: OpType.SyncImport, + entityType: 'ALL' as EntityType, + clientId: 'abortClient', + vectorClock: { abortClient: 1 }, + }), + snapshotEntityKeys: [], + }), + ).toBeRejected(); + + expect(await db.get(STORE_NAMES.CLIENT_ID, SINGLETON_KEY)).toBe('priorClient'); + expect(clearCacheSpy).not.toHaveBeenCalled(); + }); }); describe('appendWithVectorClockUpdate', () => { diff --git a/src/app/op-log/persistence/operation-log-store.service.ts b/src/app/op-log/persistence/operation-log-store.service.ts index 701f31162b..37158db181 100644 --- a/src/app/op-log/persistence/operation-log-store.service.ts +++ b/src/app/op-log/persistence/operation-log-store.service.ts @@ -158,6 +158,15 @@ interface OpLogDB extends DBSchema { key: string; // profile ID value: ProfileDataStoreEntry; }; + /** + * Stores the sync clientId (device identity). Consolidated from legacy 'pf' + * in version 6 so destructive-flow rotation joins the atomic transaction in + * runDestructiveStateReplacement. See issue #7732. + */ + [STORE_NAMES.CLIENT_ID]: { + key: string; // SINGLETON_KEY ('current') + value: string; // the clientId + }; } type OpLogStoreName = (typeof STORE_NAMES)[keyof typeof STORE_NAMES]; @@ -1207,6 +1216,7 @@ export class OperationLogStoreService implements RemoteOperationApplyStorePort tx aborts -> client_id unchanged" path. Atomicity itself is + // order-independent. + await tx + .objectStore(STORE_NAMES.CLIENT_ID) + .put(syncImportOp.clientId, SINGLETON_KEY); + await opsStore.clear(); const entry: Omit = { @@ -1657,6 +1680,11 @@ export class OperationLogStoreService implements RemoteOperationApplyStorePort { mockOpLogStore.loadStateCache.and.resolveTo(null); mockClientIdService = jasmine.createSpyObj('ClientIdService', [ 'loadClientId', - 'generateNewClientId', 'getOrGenerateClientId', ]); mockVectorClockService = jasmine.createSpyObj('VectorClockService', [ diff --git a/src/app/op-log/sync/operation-log-lock-reentry.regression.spec.ts b/src/app/op-log/sync/operation-log-lock-reentry.regression.spec.ts index 4a3a24cdd2..7f059d0160 100644 --- a/src/app/op-log/sync/operation-log-lock-reentry.regression.spec.ts +++ b/src/app/op-log/sync/operation-log-lock-reentry.regression.spec.ts @@ -103,8 +103,10 @@ describe('regression #7700: operation-log lock reentry', () => { const immediateUploadSpy = jasmine.createSpyObj('ImmediateUploadService', [ 'trigger', ]); - const clientIdSpy = jasmine.createSpyObj('ClientIdService', ['loadClientId']); - clientIdSpy.loadClientId.and.resolveTo('testClient'); + const clientIdSpy = jasmine.createSpyObj('ClientIdService', [ + 'getOrGenerateClientId', + ]); + clientIdSpy.getOrGenerateClientId.and.resolveTo('testClient'); const operationCaptureSpy = jasmine.createSpyObj('OperationCaptureService', [ 'dequeue', diff --git a/src/app/op-log/testing/integration/archive-repair-roundtrip.integration.spec.ts b/src/app/op-log/testing/integration/archive-repair-roundtrip.integration.spec.ts new file mode 100644 index 0000000000..761f059364 --- /dev/null +++ b/src/app/op-log/testing/integration/archive-repair-roundtrip.integration.spec.ts @@ -0,0 +1,148 @@ +import { TestBed } from '@angular/core/testing'; +import { provideMockStore } from '@ngrx/store/testing'; +import { OperationLogStoreService } from '../../persistence/operation-log-store.service'; +import { StateSnapshotService } from '../../backup/state-snapshot.service'; +import { ArchiveDbAdapter } from '../../../core/persistence/archive-db-adapter.service'; +import { ArchiveOperationHandler } from '../../apply/archive-operation-handler.service'; +import { ArchiveService } from '../../../features/archive/archive.service'; +import { TaskArchiveService } from '../../../features/archive/task-archive.service'; +import { TimeTrackingService } from '../../../features/time-tracking/time-tracking.service'; +import { loadAllData } from '../../../root-store/meta/load-all-data.action'; +import { OpType } from '../../core/operation.types'; +import { PersistentAction } from '../../core/persistent-action.interface'; +import { ArchiveModel } from '../../../features/archive/archive.model'; + +/** + * Integration test for archive data surviving the REPAIR-op round-trip, + * exercised against real IndexedDB. + * + * Guards the *consumer* side of the bug where a data repair on one client + * wiped archived tasks on every *other* client. The *producer* side + * (`validateAndRepairCurrentState()` calling `getStateSnapshotAsync()`) is + * guarded by the unit spec `validate-state.service.spec.ts`; these tests do + * not exercise `validateAndRepairCurrentState()` itself. The bug had two parts: + * + * 1. `validateAndRepairCurrentState()` built the REPAIR op from the synchronous + * `getStateSnapshot()`, which hardcodes empty archives (archiveYoung/archiveOld + * live in IndexedDB, not NgRx state). + * 2. Other clients applied that REPAIR op via `ArchiveOperationHandler`, whose + * empty-archive guard only covered SYNC_IMPORT/BACKUP_IMPORT — so a REPAIR op + * with empty archives overwrote (wiped) the local archive. + * + * These tests wire the *real* `StateSnapshotService`, `ArchiveDbAdapter`, + * `ArchiveStoreService` and `ArchiveOperationHandler` against real IndexedDB, + * so they exercise the actual archive read/write seams — not mocks. + * + * The complementary unit spec `validate-state.service.spec.ts` proves that + * `validateAndRepairCurrentState()` itself now calls `getStateSnapshotAsync()`. + */ +describe('Archive REPAIR round-trip integration', () => { + let storeService: OperationLogStoreService; + let stateSnapshot: StateSnapshotService; + let archiveDb: ArchiveDbAdapter; + let archiveHandler: ArchiveOperationHandler; + + // Cast: the archive round-trip only reads `task.ids`; entity contents are + // stored/loaded as an opaque blob, so minimal task stubs are sufficient. + const archiveModel = (taskIds: string[]): ArchiveModel => + ({ + task: { + ids: taskIds, + entities: Object.fromEntries( + taskIds.map((id) => [id, { id, title: `Archived ${id}`, isDone: true }]), + ), + }, + timeTracking: { project: {}, tag: {} }, + lastTimeTrackingFlush: 0, + }) as unknown as ArchiveModel; + + /** Builds the action `OperationApplierService` dispatches when applying a REPAIR op. */ + const repairApplyAction = (appDataComplete: unknown): PersistentAction => + ({ + ...loadAllData({ appDataComplete: appDataComplete as never }), + meta: { isPersistent: true, isRemote: true, opType: OpType.Repair }, + }) as unknown as PersistentAction; + + beforeEach(async () => { + TestBed.configureTestingModule({ + providers: [ + provideMockStore(), + OperationLogStoreService, + StateSnapshotService, + ArchiveDbAdapter, + ArchiveOperationHandler, + // Lazy deps of ArchiveOperationHandler — never reached by the loadAllData + // path, stubbed so construction does not pull their real dependency trees. + { provide: ArchiveService, useValue: {} }, + { provide: TaskArchiveService, useValue: {} }, + { provide: TimeTrackingService, useValue: {} }, + ], + }); + + storeService = TestBed.inject(OperationLogStoreService); + stateSnapshot = TestBed.inject(StateSnapshotService); + archiveDb = TestBed.inject(ArchiveDbAdapter); + archiveHandler = TestBed.inject(ArchiveOperationHandler); + + await storeService.init(); + await storeService._clearAllDataForTesting(); + // Archive stores are SUP_OPS singletons shared across tests — reset explicitly. + await archiveDb.saveArchiveYoung(archiveModel([])); + await archiveDb.saveArchiveOld(archiveModel([])); + }); + + it('getStateSnapshotAsync() includes IndexedDB archives; getStateSnapshot() returns them empty', async () => { + await archiveDb.saveArchiveYoung(archiveModel(['y1', 'y2'])); + await archiveDb.saveArchiveOld(archiveModel(['o1'])); + + // The sync snapshot hardcodes empty archives — this is what made REPAIR ops + // built from it wipe other clients. + const syncSnap = stateSnapshot.getStateSnapshot(); + expect(syncSnap.archiveYoung.task.ids).toEqual([]); + expect(syncSnap.archiveOld.task.ids).toEqual([]); + + // The async snapshot loads the real archives — the REPAIR op now relies on this. + const asyncSnap = await stateSnapshot.getStateSnapshotAsync(); + expect(asyncSnap.archiveYoung.task.ids).toEqual(['y1', 'y2']); + expect(asyncSnap.archiveOld.task.ids).toEqual(['o1']); + }); + + it('archive round-trips from client A IndexedDB through a REPAIR op into client B IndexedDB', async () => { + // --- Client A has archived tasks --- + await archiveDb.saveArchiveYoung(archiveModel(['y1', 'y2'])); + await archiveDb.saveArchiveOld(archiveModel(['o1'])); + + // Client A builds the REPAIR op payload from the async snapshot (the fix). + const repairAppData = await stateSnapshot.getStateSnapshotAsync(); + + // --- Handoff: client B is a fresh client with no archive --- + await archiveDb.saveArchiveYoung(archiveModel([])); + await archiveDb.saveArchiveOld(archiveModel([])); + expect((await archiveDb.loadArchiveYoung())!.task.ids).toEqual([]); + expect((await archiveDb.loadArchiveOld())!.task.ids).toEqual([]); + + // --- Client B applies the REPAIR op --- + await archiveHandler.handleOperation(repairApplyAction(repairAppData)); + + // --- Client B now has client A's archive --- + expect((await archiveDb.loadArchiveYoung())!.task.ids).toEqual(['y1', 'y2']); + expect((await archiveDb.loadArchiveOld())!.task.ids).toEqual(['o1']); + }); + + it('a REPAIR op built from the sync snapshot has empty archives and no longer wipes a client that has data', async () => { + // --- Client B already has archived tasks --- + await archiveDb.saveArchiveYoung(archiveModel(['keep-young-1'])); + await archiveDb.saveArchiveOld(archiveModel(['keep-old-1'])); + + // A pre-fix REPAIR op, built from the sync snapshot, carries empty archives. + const buggyAppData = stateSnapshot.getStateSnapshot(); + expect(buggyAppData.archiveYoung.task.ids).toEqual([]); + expect(buggyAppData.archiveOld.task.ids).toEqual([]); + + await archiveHandler.handleOperation(repairApplyAction(buggyAppData)); + + // The empty-archive guard preserves the local archive instead of wiping it. + expect((await archiveDb.loadArchiveYoung())!.task.ids).toEqual(['keep-young-1']); + expect((await archiveDb.loadArchiveOld())!.task.ids).toEqual(['keep-old-1']); + }); +}); diff --git a/src/app/op-log/testing/integration/clean-slate-interrupt.integration.spec.ts b/src/app/op-log/testing/integration/clean-slate-interrupt.integration.spec.ts index f4fb992c00..e44d2ae999 100644 --- a/src/app/op-log/testing/integration/clean-slate-interrupt.integration.spec.ts +++ b/src/app/op-log/testing/integration/clean-slate-interrupt.integration.spec.ts @@ -2,12 +2,11 @@ import { TestBed } from '@angular/core/testing'; import { OperationLogStoreService } from '../../persistence/operation-log-store.service'; import { CleanSlateService } from '../../clean-slate/clean-slate.service'; import { StateSnapshotService } from '../../backup/state-snapshot.service'; -import { ClientIdService } from '../../../core/util/client-id.service'; import { SyncLocalStateService } from '../../sync/sync-local-state.service'; import { TranslateService } from '@ngx-translate/core'; import { CURRENT_SCHEMA_VERSION } from '../../persistence/schema-migration.service'; import { Operation } from '../../core/operation.types'; -import { STORE_NAMES } from '../../persistence/db-keys.const'; +import { SINGLETON_KEY, STORE_NAMES } from '../../persistence/db-keys.const'; /** * Integration tests for issue #7709 — `createCleanSlate` / `BackupService` import @@ -30,7 +29,6 @@ describe('CleanSlate / Backup interrupt (issue #7709 regression)', () => { let syncLocalState: SyncLocalStateService; let cleanSlate: CleanSlateService; let mockStateSnapshot: jasmine.SpyObj; - let mockClientId: jasmine.SpyObj; let mockTranslate: jasmine.SpyObj; const meaningfulState = { @@ -53,14 +51,6 @@ describe('CleanSlate / Backup interrupt (issue #7709 regression)', () => { mockStateSnapshot.getStateSnapshot.and.returnValue(meaningfulState as any); mockStateSnapshot.getStateSnapshotAsync.and.resolveTo(meaningfulState as any); - mockClientId = jasmine.createSpyObj('ClientIdService', ['withRotation']); - // Default: invoke the rotation callback with a fresh id. Rollback - // semantics are exercised in ClientIdService's own spec. - mockClientId.withRotation.and.callFake( - async (_logPrefix: string, fn: (newClientId: string) => Promise) => - fn('cNew1'), - ); - mockTranslate = jasmine.createSpyObj('TranslateService', ['instant']); mockTranslate.instant.and.callFake((k: string) => k); @@ -70,7 +60,6 @@ describe('CleanSlate / Backup interrupt (issue #7709 regression)', () => { SyncLocalStateService, CleanSlateService, { provide: StateSnapshotService, useValue: mockStateSnapshot }, - { provide: ClientIdService, useValue: mockClientId }, { provide: TranslateService, useValue: mockTranslate }, ], }); @@ -94,6 +83,13 @@ describe('CleanSlate / Backup interrupt (issue #7709 regression)', () => { expect(cache).not.toBeNull(); expect(cache!.state).toEqual(meaningfulState as any); expect(await syncLocalState.isWhollyFreshClient()).toBe(false); + + // The clientId rotated atomically inside the destructive replacement. + const rotatedId = await (storeService as any).db.get( + STORE_NAMES.CLIENT_ID, + SINGLETON_KEY, + ); + expect(rotatedId).toMatch(/^[BEAI]_[a-zA-Z0-9]{4}$/); }); }); @@ -108,8 +104,8 @@ describe('CleanSlate / Backup interrupt (issue #7709 regression)', () => { entityType: 'TASK' as any, entityId: `t${i}`, payload: { id: `t${i}` }, - clientId: 'cPrior', - vectorClock: { cPrior: i + 1 }, + clientId: 'cPriorClient', + vectorClock: { cPriorClient: i + 1 }, timestamp: Date.now() + i, schemaVersion: CURRENT_SCHEMA_VERSION, })); @@ -119,11 +115,15 @@ describe('CleanSlate / Backup interrupt (issue #7709 regression)', () => { await storeService.saveStateCache({ state: { sentinel: 'prior-state' } as any, lastAppliedOpSeq: 0, - vectorClock: { cPrior: 3 }, + vectorClock: { cPriorClient: 3 }, compactedAt: Date.now(), schemaVersion: CURRENT_SCHEMA_VERSION, }); - await storeService.setVectorClock({ cPrior: 3 }); + await storeService.setVectorClock({ cPriorClient: 3 }); + + // Seed a prior clientId in SUP_OPS — the aborted rotation must not touch + // it. This is the property withRotation used to provide by hand (#7732). + await (storeService as any).db.put(STORE_NAMES.CLIENT_ID, 'B_seed', SINGLETON_KEY); const seqBefore = await storeService.getLastSeq(); const cacheBefore = await storeService.loadStateCache(); @@ -159,6 +159,11 @@ describe('CleanSlate / Backup interrupt (issue #7709 regression)', () => { expect((cacheAfter!.state as any).sentinel).toBe('prior-state'); expect(cacheAfter!.vectorClock).toEqual(cacheBefore!.vectorClock); expect(await storeService.getVectorClock()).toEqual(clockBefore); + // The rotated clientId was queued first inside the tx; the abort unwinds + // it — SUP_OPS.client_id still holds the prior id. + expect( + await (storeService as any).db.get(STORE_NAMES.CLIENT_ID, SINGLETON_KEY), + ).toBe('B_seed'); }); }); @@ -172,8 +177,8 @@ describe('CleanSlate / Backup interrupt (issue #7709 regression)', () => { entityType: 'TASK' as any, entityId: `t${i}`, payload: { id: `t${i}` }, - clientId: 'cPrior', - vectorClock: { cPrior: i + 1 }, + clientId: 'cPriorClient', + vectorClock: { cPriorClient: i + 1 }, timestamp: Date.now() + i, schemaVersion: CURRENT_SCHEMA_VERSION, })); diff --git a/src/app/op-log/testing/integration/legacy-data-migration.integration.spec.ts b/src/app/op-log/testing/integration/legacy-data-migration.integration.spec.ts index e44c8440f8..86624b02fa 100644 --- a/src/app/op-log/testing/integration/legacy-data-migration.integration.spec.ts +++ b/src/app/op-log/testing/integration/legacy-data-migration.integration.spec.ts @@ -38,7 +38,7 @@ describe('Legacy Data Migration Integration', () => { 'loadClientId', ]); mockClientIdService = jasmine.createSpyObj('ClientIdService', [ - 'generateNewClientId', + 'getOrGenerateClientId', ]); mockMatDialog = jasmine.createSpyObj('MatDialog', ['open']); mockTranslateService = jasmine.createSpyObj('TranslateService', [ diff --git a/src/app/op-log/testing/integration/remote-apply-store-port.integration.spec.ts b/src/app/op-log/testing/integration/remote-apply-store-port.integration.spec.ts index df6a11b181..63de18c79f 100644 --- a/src/app/op-log/testing/integration/remote-apply-store-port.integration.spec.ts +++ b/src/app/op-log/testing/integration/remote-apply-store-port.integration.spec.ts @@ -11,6 +11,7 @@ describe('RemoteOperationApplyStorePort Integration', () => { const mockClientIdProvider: ClientIdProvider = { loadClientId: () => Promise.resolve('testClient'), getOrGenerateClientId: () => Promise.resolve('testClient'), + clearCache: () => {}, }; const createOperation = ( diff --git a/src/app/op-log/testing/integration/task-done-replay.integration.spec.ts b/src/app/op-log/testing/integration/task-done-replay.integration.spec.ts index 6ff89e5b79..644b887c2b 100644 --- a/src/app/op-log/testing/integration/task-done-replay.integration.spec.ts +++ b/src/app/op-log/testing/integration/task-done-replay.integration.spec.ts @@ -122,7 +122,7 @@ describe('done task operation replay', () => { ['trigger'], ); const mockClientIdService = jasmine.createSpyObj('ClientIdService', [ - 'loadClientId', + 'getOrGenerateClientId', ]); const mockSuperSyncStatusService = jasmine.createSpyObj( 'SuperSyncStatusService', @@ -140,7 +140,7 @@ describe('done task operation replay', () => { mockCompactionService.compact.and.returnValue(Promise.resolve()); mockCompactionService.emergencyCompact.and.returnValue(Promise.resolve(true)); mockOperationCaptureService.dequeue.and.returnValue([]); - mockClientIdService.loadClientId.and.returnValue(Promise.resolve('clientA')); + mockClientIdService.getOrGenerateClientId.and.returnValue(Promise.resolve('clientA')); TestBed.configureTestingModule({ providers: [ diff --git a/src/app/op-log/util/client-id.provider.spec.ts b/src/app/op-log/util/client-id.provider.spec.ts index 867b8621df..357334dd27 100644 --- a/src/app/op-log/util/client-id.provider.spec.ts +++ b/src/app/op-log/util/client-id.provider.spec.ts @@ -9,10 +9,9 @@ describe('CLIENT_ID_PROVIDER', () => { beforeEach(() => { mockClientIdService = jasmine.createSpyObj('ClientIdService', [ 'loadClientId', - 'generateNewClientId', 'getOrGenerateClientId', + 'clearCache', ]); - mockClientIdService.generateNewClientId.and.resolveTo('B_new1'); mockClientIdService.getOrGenerateClientId.and.resolveTo('test-client-id-123'); TestBed.configureTestingModule({ @@ -59,4 +58,10 @@ describe('CLIENT_ID_PROVIDER', () => { expect(result).toBe('B_a7Kx'); expect(mockClientIdService.getOrGenerateClientId).toHaveBeenCalledTimes(1); }); + + it('should delegate clearCache to ClientIdService', () => { + provider.clearCache(); + + expect(mockClientIdService.clearCache).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/app/op-log/util/client-id.provider.ts b/src/app/op-log/util/client-id.provider.ts index 5fda068e18..5e68ec391f 100644 --- a/src/app/op-log/util/client-id.provider.ts +++ b/src/app/op-log/util/client-id.provider.ts @@ -15,13 +15,27 @@ import { ClientIdService } from '../../core/util/client-id.service'; * - ClientIdService handles lazy PfapiService resolution */ export interface ClientIdProvider { + /** + * Returns the stored client ID, or null if none exists (or a read failed). + * + * SIDE EFFECT: the first read of a device that still has its clientId only + * in the legacy 'pf' database copies it forward into SUP_OPS (one-time, + * idempotent migration — see ClientIdService). All later reads are cached. + */ loadClientId(): Promise; /** * Returns the stored client ID, or generates and persists a new one if - * none is stored or the stored value is invalid. Preferred over calling - * loadClientId() with a manual fallback. + * none is stored. Preferred over calling loadClientId() with a manual + * fallback. Propagates IndexedDB read failures (it must never mint a fresh + * id on a transient error — that would orphan the device's real identity). */ getOrGenerateClientId(): Promise; + /** + * Invalidates the in-memory clientId cache so the next read re-resolves + * from IndexedDB. Called by runDestructiveStateReplacement after it rotates + * the clientId inside the atomic SUP_OPS transaction. + */ + clearCache(): void; } /** @@ -45,6 +59,7 @@ export const CLIENT_ID_PROVIDER = new InjectionToken( return { loadClientId: () => clientIdService.loadClientId(), getOrGenerateClientId: () => clientIdService.getOrGenerateClientId(), + clearCache: () => clientIdService.clearCache(), }; }, }, diff --git a/src/app/op-log/validation/validate-state.service.spec.ts b/src/app/op-log/validation/validate-state.service.spec.ts index 8671899862..f005c595ef 100644 --- a/src/app/op-log/validation/validate-state.service.spec.ts +++ b/src/app/op-log/validation/validate-state.service.spec.ts @@ -64,6 +64,7 @@ describe('ValidateStateService', () => { mockStateSnapshotService = jasmine.createSpyObj('StateSnapshotService', [ 'getStateSnapshot', + 'getStateSnapshotAsync', ]); mockClientIdProvider = { loadClientId: jasmine @@ -246,19 +247,16 @@ describe('ValidateStateService', () => { } }); - it('should return true when state is valid', async () => { + it('should return true (and skip the async archive snapshot) when state is valid', async () => { const validState = createEmptyState(); mockStateSnapshotService.getStateSnapshot.and.returnValue(validState as any); - - // Mock validateAndRepair to return valid state (empty state doesn't pass full Typia validation) - spyOn(service, 'validateAndRepair').and.resolveTo({ - isValid: true, - wasRepaired: false, - }); + // Quick validation passes → the expensive async (archive) snapshot is skipped. + spyOn(service, 'validateState').and.resolveTo({ isValid: true, typiaErrors: [] }); const result = await service.validateAndRepairCurrentState('test-context'); expect(result).toBeTrue(); + expect(mockStateSnapshotService.getStateSnapshotAsync).not.toHaveBeenCalled(); expect(mockRepairService.createRepairOperation).not.toHaveBeenCalled(); expect(store.dispatch).not.toHaveBeenCalled(); }); @@ -275,6 +273,7 @@ describe('ValidateStateService', () => { projectTree: [{ id: 'ORPHAN', k: MenuTreeKind.PROJECT }], }; mockStateSnapshotService.getStateSnapshot.and.returnValue(invalidState as any); + mockStateSnapshotService.getStateSnapshotAsync.and.resolveTo(invalidState as any); mockClientIdProvider.loadClientId.and.returnValue(Promise.resolve(null)); const result = await service.validateAndRepairCurrentState('sync'); @@ -289,6 +288,7 @@ describe('ValidateStateService', () => { it('should pass skipLock option to repair service when callerHoldsLock is true', async () => { const state = createEmptyState(); mockStateSnapshotService.getStateSnapshot.and.returnValue(state as any); + mockStateSnapshotService.getStateSnapshotAsync.and.resolveTo(state as any); // Mock validateAndRepair to return repaired state spyOn(service, 'validateAndRepair').and.resolveTo({ @@ -311,6 +311,7 @@ describe('ValidateStateService', () => { it('should start/end hydration state for sync contexts', async () => { const state = createEmptyState(); mockStateSnapshotService.getStateSnapshot.and.returnValue(state as any); + mockStateSnapshotService.getStateSnapshotAsync.and.resolveTo(state as any); // Mock validateAndRepair to return repaired state spyOn(service, 'validateAndRepair').and.resolveTo({ @@ -338,6 +339,7 @@ describe('ValidateStateService', () => { projectTree: [{ id: 'ORPHAN', k: MenuTreeKind.PROJECT }], }; mockStateSnapshotService.getStateSnapshot.and.returnValue(invalidState as any); + mockStateSnapshotService.getStateSnapshotAsync.and.resolveTo(invalidState as any); await service.validateAndRepairCurrentState('other-context'); @@ -359,6 +361,7 @@ describe('ValidateStateService', () => { projectTree: [{ id: 'ORPHAN', k: MenuTreeKind.PROJECT }], }; mockStateSnapshotService.getStateSnapshot.and.returnValue(invalidState as any); + mockStateSnapshotService.getStateSnapshotAsync.and.resolveTo(invalidState as any); mockHydrationStateService.isApplyingRemoteOps.and.returnValue(true); await service.validateAndRepairCurrentState('sync'); @@ -374,6 +377,7 @@ describe('ValidateStateService', () => { it('should dispatch loadAllData with isRemote flag for sync contexts', async () => { const state = createEmptyState(); mockStateSnapshotService.getStateSnapshot.and.returnValue(state as any); + mockStateSnapshotService.getStateSnapshotAsync.and.resolveTo(state as any); // Mock validateAndRepair to return repaired state spyOn(service, 'validateAndRepair').and.resolveTo({ @@ -393,6 +397,7 @@ describe('ValidateStateService', () => { it('should dispatch loadAllData without isRemote flag for non-sync contexts', async () => { const state = createEmptyState(); mockStateSnapshotService.getStateSnapshot.and.returnValue(state as any); + mockStateSnapshotService.getStateSnapshotAsync.and.resolveTo(state as any); // Mock validateAndRepair to return repaired state spyOn(service, 'validateAndRepair').and.resolveTo({ @@ -408,5 +413,50 @@ describe('ValidateStateService', () => { const dispatchedAction = (store.dispatch as jasmine.Spy).calls.mostRecent().args[0]; expect(dispatchedAction.meta?.isRemote).toBeFalsy(); }); + + // Regression: archives (archiveYoung/archiveOld) live in IndexedDB, not NgRx. + // The sync getStateSnapshot() hardcodes empty archives, so a REPAIR op built + // from it wiped archives on every other client that applied it. When a repair + // is needed, the REPAIR op must be built from the async snapshot. + it('should build the REPAIR op from the async snapshot so it carries archive data', async () => { + const archivedTaskId = 'archived-task-1'; + const stateWithArchive = createEmptyState(); + stateWithArchive.archiveYoung = { + task: { + ids: [archivedTaskId], + entities: { [archivedTaskId]: { id: archivedTaskId } }, + }, + timeTracking: initialTimeTrackingState, + lastTimeTrackingFlush: 0, + }; + // Quick (sync) validation fails → the repair path loads the async snapshot. + mockStateSnapshotService.getStateSnapshot.and.returnValue( + createEmptyState() as any, + ); + mockStateSnapshotService.getStateSnapshotAsync.and.resolveTo( + stateWithArchive as any, + ); + + const validateAndRepairSpy = spyOn(service, 'validateAndRepair').and.resolveTo({ + isValid: true, + wasRepaired: true, + repairedState: stateWithArchive, + repairSummary: { typeErrorsFixed: 1 } as any, + }); + + await service.validateAndRepairCurrentState('sync'); + + // The repair path must load the async snapshot (archives from IndexedDB). + expect(mockStateSnapshotService.getStateSnapshotAsync).toHaveBeenCalled(); + + // The state fed into validation/repair must include the archive... + const validatedState = validateAndRepairSpy.calls.mostRecent().args[0]; + expect((validatedState.archiveYoung as any).task.ids).toEqual([archivedTaskId]); + + // ...and so must the REPAIR operation payload. + const repairedState = mockRepairService.createRepairOperation.calls.mostRecent() + .args[0] as any; + expect(repairedState.archiveYoung.task.ids).toEqual([archivedTaskId]); + }); }); }); diff --git a/src/app/op-log/validation/validate-state.service.ts b/src/app/op-log/validation/validate-state.service.ts index ef56f9494f..a954618323 100644 --- a/src/app/op-log/validation/validate-state.service.ts +++ b/src/app/op-log/validation/validate-state.service.ts @@ -99,7 +99,25 @@ export class ValidateStateService { `[ValidateStateService:${context}] Running post-operation validation...`, ); - const currentState = this.stateSnapshotService.getStateSnapshot(); + // Validate cheaply first using the synchronous snapshot. archiveYoung/ + // archiveOld live in IndexedDB (not NgRx state), so the sync snapshot omits + // them — but Checkpoint D only detects NgRx-entity corruption, so the sync + // snapshot is enough to decide whether a repair is needed. This keeps the + // common (valid) path off the IndexedDB archive reads that + // getStateSnapshotAsync() performs on every sync. + const quickState = this.stateSnapshotService.getStateSnapshot(); + const quickValidation = await this.validateState( + quickState as unknown as Record, + ); + if (quickValidation.isValid) { + OpLog.normal(`[ValidateStateService:${context}] State valid`); + return true; + } + + // State is invalid — load the full snapshot including archives so the REPAIR + // operation carries archive data. A REPAIR op built from the sync snapshot + // would ship empty archives and wipe them on every client that applies it. + const currentState = await this.stateSnapshotService.getStateSnapshotAsync(); const result = await this.validateAndRepair( currentState as unknown as Record, From b6ca93cb7876114123323045fc24a5c43641e3ee Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 22 May 2026 18:22:26 +0200 Subject: [PATCH 34/42] test(plugins): mock activeWorkContext in dispatchAction privacy specs PluginBridgeService now reads activeWorkContext in a toSignal field initializer; the privacy specs mocked WorkContextService as an empty object, throwing on construction. --- src/app/plugins/plugin-bridge.service.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/plugins/plugin-bridge.service.spec.ts b/src/app/plugins/plugin-bridge.service.spec.ts index 3eefeac096..1567b00b17 100644 --- a/src/app/plugins/plugin-bridge.service.spec.ts +++ b/src/app/plugins/plugin-bridge.service.spec.ts @@ -846,7 +846,7 @@ describe('PluginBridgeService - dispatchAction privacy (#7619)', () => { { provide: MatDialog, useValue: {} }, { provide: PluginHooksService, useValue: {} }, { provide: TaskService, useValue: {} }, - { provide: WorkContextService, useValue: {} }, + { provide: WorkContextService, useValue: { activeWorkContext$: of(null) } }, { provide: ProjectService, useValue: {} }, { provide: TagService, useValue: {} }, { provide: PluginUserPersistenceService, useValue: {} }, From 06477e9fd971dd0134d8daccb90770f8e829542a Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 22 May 2026 16:04:23 +0200 Subject: [PATCH 35/42] feat(super-sync-server): add recover-user data-recovery script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconstructs a single user's AppDataComplete by replaying their op log up to a chosen serverSeq and decrypting E2E-encrypted payloads — the recovery path the server's restore endpoint cannot offer for encrypted accounts (it throws EncryptedOpsNotSupportedError). Read-only on the DB; documented in docs/backup-and-recovery.md. Unverified against real encrypted data — see the script header. --- .../docs/backup-and-recovery.md | 51 +++ packages/super-sync-server/package.json | 1 + .../super-sync-server/scripts/recover-user.ts | 350 ++++++++++++++++++ 3 files changed, 402 insertions(+) create mode 100644 packages/super-sync-server/scripts/recover-user.ts diff --git a/packages/super-sync-server/docs/backup-and-recovery.md b/packages/super-sync-server/docs/backup-and-recovery.md index b066430255..2791a1f46f 100644 --- a/packages/super-sync-server/docs/backup-and-recovery.md +++ b/packages/super-sync-server/docs/backup-and-recovery.md @@ -120,6 +120,57 @@ Server is down / data lost │ Accept data loss since last backup ``` +## Per-User Recovery (single account wiped) + +The procedures above recover the **whole server**. A different situation: one +user's account is wiped — usually because a bad `SYNC_IMPORT` propagated an +empty or stale snapshot across their devices — and you need to roll *that one +user* back to a point in time. + +The in-app **Restore from History** handles this for unencrypted accounts. It +does **not** work for E2E-encrypted accounts: the server cannot decrypt the op +payloads, so `generateSnapshotAtSeq` throws `EncryptedOpsNotSupportedError`. + +`scripts/recover-user.ts` fills that gap. It replays the user's operation log up +to a chosen `serverSeq`, decrypting encrypted payloads with the user's +passphrase, and writes an importable `AppDataComplete` JSON file. It is +**read-only** on the database. + +> **Status: unverified against real encrypted data.** The script lints, builds, +> and its module graph loads, but it has not been run end-to-end against an +> actual encrypted account. Before relying on it in an incident, verify it +> against a known account (e.g. your own): recover at the latest seq and confirm +> the entity counts match the live app. + +**1. Inspect** — find the cutoff sequence (no encryption key needed): + +```bash +DATABASE_URL=... npm run recover-user -- --user --inspect +``` + +This lists every full-state op (`SYNC_IMPORT` / `BACKUP_IMPORT` / `REPAIR`) with +timestamps. Identify the bad import; the cutoff is its `serverSeq` minus 1. + +**2. Recover** — replay up to the cutoff and write the importable file: + +```bash +DATABASE_URL=... RECOVER_ENCRYPT_KEY='' \ + npm run recover-user -- --user --target-seq --out ./recovered.json +``` + +Add `--dry-run` to preview entity counts without writing. The user imports the +resulting file via **Settings → Import/Export → Import from File**. + +**Notes:** + +- The encryption key is read only from `RECOVER_ENCRYPT_KEY` or `--key-file` — + never a CLI argument (process lists / shell history). +- Run it from a dev checkout — it needs `ts-node` and the Prisma client, which + the production image does not include. Pointing `DATABASE_URL` at a restored + dump keeps the run fully isolated from production. +- The output file holds the user's complete **plaintext** data — transmit it + over a secure channel and delete every copy once recovery is confirmed. + ## Hoster Backups If your VPS hoster provides incremental backups (e.g., daily snapshots), these serve as an additional safety net. However: diff --git a/packages/super-sync-server/package.json b/packages/super-sync-server/package.json index a15232bcc5..78f4d3f38b 100644 --- a/packages/super-sync-server/package.json +++ b/packages/super-sync-server/package.json @@ -20,6 +20,7 @@ "docker:shell": "./scripts/docker-monitor.sh shell", "delete-user": "ts-node scripts/delete-user.ts", "clear-data": "ts-node scripts/clear-data.ts", + "recover-user": "ts-node --transpile-only scripts/recover-user.ts", "monitor": "node dist/scripts/monitor.js", "monitor:dev": "tsx scripts/monitor.ts", "analyze-storage": "node dist/scripts/analyze-storage.js", diff --git a/packages/super-sync-server/scripts/recover-user.ts b/packages/super-sync-server/scripts/recover-user.ts new file mode 100644 index 0000000000..c6989a8953 --- /dev/null +++ b/packages/super-sync-server/scripts/recover-user.ts @@ -0,0 +1,350 @@ +/** + * recover-user.ts — operator data-recovery tool for SuperSync accounts. + * + * Reconstructs a user's full app state (`AppDataComplete`) as of a chosen + * server sequence by replaying their operation log — the SAME replay the + * server's `generateSnapshotAtSeq` uses, but it ALSO decrypts E2E-encrypted op + * payloads, which the server-side restore endpoint refuses to do. Use when a + * bad SYNC_IMPORT wiped an account: replay up to the seq just before the wipe. + * + * Read-only on the database. Run with `--help` for usage. The encryption key is + * read only from RECOVER_ENCRYPT_KEY / --key-file, never a CLI arg. The output + * file holds the user's COMPLETE plaintext data — transmit it securely and + * delete every copy once recovery is confirmed. The script logs only sequence + * numbers, timestamps and entity-type counts — never task content, never the key. + * + * Status: UNVERIFIED against real encrypted data — test against a known account + * first. See docs/backup-and-recovery.md for the full procedure. + */ + +import { readFileSync, writeFileSync } from 'node:fs'; +import { prisma, disconnectDb } from '../src/db'; +import { replayOpsToState, type ReplayOperationRow } from '../src/sync/op-replay'; + +// sync-core lives in a sibling package. Loaded via require() so the server's +// `tsc` build (rootDir = this package) never tries to compile its source; +// ts-node --transpile-only resolves and transpiles the .ts on demand at runtime. +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { decryptBatch } = require('../../sync-core/src/encryption') as { + decryptBatch: (items: string[], password: string) => Promise; +}; + +const FULL_STATE_OP_TYPES = ['SYNC_IMPORT', 'BACKUP_IMPORT', 'REPAIR']; + +interface Args { + user?: string; + inspect: boolean; + targetSeq?: number; + out?: string; + keyFile?: string; + dryRun: boolean; +} + +const USAGE = ` +recover-user — reconstruct a SuperSync user's state from the operation log + + Inspect (find the cutoff sequence, no key needed): + npm run recover-user -- --user --inspect + + Recover (replay up to the cutoff, write an importable JSON file): + RECOVER_ENCRYPT_KEY='' \\ + npm run recover-user -- --user --target-seq --out ./recovered.json + + Options: + --user Target user (required). + --inspect List full-state ops + sequence range, then exit. + --target-seq Replay operations up to and including this server seq. + --out Where to write the recovered AppDataComplete JSON. + --key-file Read the encryption key from a file instead of the env var. + --dry-run Replay and print a summary, but write nothing. + + The encryption key comes from RECOVER_ENCRYPT_KEY or --key-file — never a flag. +`; + +const parseArgs = (argv: string[]): Args => { + if (argv.includes('--help') || argv.includes('-h')) { + console.log(USAGE); + process.exit(0); + } + const args: Args = { inspect: false, dryRun: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + switch (a) { + case '--user': + args.user = argv[++i]; + break; + case '--inspect': + args.inspect = true; + break; + case '--target-seq': + args.targetSeq = Number(argv[++i]); + break; + case '--out': + args.out = argv[++i]; + break; + case '--key-file': + args.keyFile = argv[++i]; + break; + case '--dry-run': + args.dryRun = true; + break; + default: + throw new Error(`Unknown argument: ${a}`); + } + } + if (args.targetSeq !== undefined && !Number.isInteger(args.targetSeq)) { + throw new Error('--target-seq must be an integer'); + } + return args; +}; + +const readKey = (keyFile?: string): string | undefined => { + if (keyFile) { + const fromFile = readFileSync(keyFile, 'utf8').trim(); + return fromFile.length > 0 ? fromFile : undefined; + } + const env = process.env.RECOVER_ENCRYPT_KEY; + return env && env.length > 0 ? env : undefined; +}; + +const resolveUserId = async (userArg: string): Promise => { + const asId = Number(userArg); + if (Number.isInteger(asId) && asId > 0) { + const byId = await prisma.user.findUnique({ + where: { id: asId }, + select: { id: true }, + }); + if (byId) return byId.id; + } + const byEmail = await prisma.user.findUnique({ + where: { email: userArg }, + select: { id: true }, + }); + if (byEmail) return byEmail.id; + throw new Error(`No user found for "${userArg}" (tried both id and email).`); +}; + +const inspect = async (userId: number): Promise => { + const syncState = await prisma.userSyncState.findUnique({ where: { userId } }); + const total = await prisma.operation.count({ where: { userId } }); + const encrypted = await prisma.operation.count({ + where: { userId, isPayloadEncrypted: true }, + }); + const fullStateOps = await prisma.operation.findMany({ + where: { userId, opType: { in: FULL_STATE_OP_TYPES } }, + orderBy: { serverSeq: 'asc' }, + select: { + serverSeq: true, + opType: true, + clientId: true, + syncImportReason: true, + isPayloadEncrypted: true, + clientTimestamp: true, + receivedAt: true, + }, + }); + + console.log(`\nUser ${userId}`); + console.log(` lastSeq (server): ${syncState?.lastSeq ?? '(no sync state row)'}`); + console.log(` total operations: ${total}`); + console.log(` encrypted payloads: ${encrypted} / ${total}`); + console.log(`\nFull-state operations (restore points / wipe events):`); + if (fullStateOps.length === 0) { + console.log(' (none)'); + } + for (const op of fullStateOps) { + const received = new Date(Number(op.receivedAt)).toISOString(); + const client = new Date(Number(op.clientTimestamp)).toISOString(); + console.log( + ` seq ${String(op.serverSeq).padStart(8)} ${op.opType.padEnd(13)}` + + ` received ${received} client ${client}` + + ` enc=${op.isPayloadEncrypted ? 'Y' : 'N'}` + + (op.syncImportReason ? ` reason=${op.syncImportReason}` : ''), + ); + } + console.log( + `\nNext: pick the bad import above, then replay up to (its seq - 1):\n` + + ` RECOVER_ENCRYPT_KEY='' npm run recover-user -- ` + + `--user ${userId} --target-seq --out ./recovered.json\n`, + ); +}; + +/** Decrypt the encrypted ops in place, returning a seq-indexed payload map. */ +const decryptPayloads = async ( + ops: { id: string; isPayloadEncrypted: boolean; payload: unknown }[], + key: string, +): Promise> => { + const encryptedIndexes: number[] = []; + for (let i = 0; i < ops.length; i++) { + if (ops[i].isPayloadEncrypted) encryptedIndexes.push(i); + } + const decrypted = new Map(); + if (encryptedIndexes.length === 0) return decrypted; + + const ciphertexts = encryptedIndexes.map((i) => { + const payload = ops[i].payload; + if (typeof payload !== 'string') { + throw new Error( + `Op ${ops[i].id} is flagged encrypted but its payload is not a string.`, + ); + } + return payload; + }); + + console.log( + `Decrypting ${ciphertexts.length} encrypted payloads ` + + `(Argon2id key derivation — this can take a while)...`, + ); + let plaintexts: string[]; + try { + plaintexts = await decryptBatch(ciphertexts, key); + } catch (e) { + throw new Error( + 'Decryption failed — the encryption key is almost certainly incorrect. ' + + `(${(e as Error).message})`, + ); + } + encryptedIndexes.forEach((opIndex, j) => { + try { + decrypted.set(opIndex, JSON.parse(plaintexts[j])); + } catch { + throw new Error( + `Decrypted payload for op ${ops[opIndex].id} is not valid JSON ` + + `(unexpected — possible key mismatch or data corruption).`, + ); + } + }); + return decrypted; +}; + +const recover = async ( + userId: number, + targetSeq: number, + key: string | undefined, + outPath: string | undefined, + dryRun: boolean, +): Promise => { + const syncState = await prisma.userSyncState.findUnique({ where: { userId } }); + const lastSeq = syncState?.lastSeq ?? 0; + if (targetSeq < 1) throw new Error('--target-seq must be >= 1'); + if (targetSeq > lastSeq) { + throw new Error(`--target-seq ${targetSeq} exceeds the user's lastSeq ${lastSeq}`); + } + + const ops = await prisma.operation.findMany({ + where: { userId, serverSeq: { lte: targetSeq } }, + orderBy: { serverSeq: 'asc' }, + select: { + id: true, + serverSeq: true, + opType: true, + entityType: true, + entityId: true, + payload: true, + schemaVersion: true, + isPayloadEncrypted: true, + }, + }); + if (ops.length === 0) { + throw new Error('No operations found at or below the target sequence.'); + } + + // Contiguity: a mid-stream gap means lost data. A leading gap (log not + // starting at seq 1) is only safe if the first op is a full-state op, since + // replay resets state on those — mirrors op-replay's _resolveExpectedFirstSeq. + const first = ops[0]; + if (first.serverSeq !== 1 && !FULL_STATE_OP_TYPES.includes(first.opType)) { + throw new Error( + `Operation log starts at seq ${first.serverSeq} (not 1) and that op is ` + + `'${first.opType}', not a full-state op — cannot reconstruct a complete state.`, + ); + } + for (let i = 1; i < ops.length; i++) { + if (ops[i].serverSeq !== ops[i - 1].serverSeq + 1) { + throw new Error( + `Gap in operation log: seq ${ops[i - 1].serverSeq} -> ${ops[i].serverSeq}. ` + + `Operations are missing; recovery would be incomplete.`, + ); + } + } + + const encryptedCount = ops.filter((o) => o.isPayloadEncrypted).length; + if (encryptedCount > 0 && !key) { + throw new Error( + `${encryptedCount} of ${ops.length} operations are encrypted, but no ` + + `encryption key was provided. Set RECOVER_ENCRYPT_KEY or pass --key-file.`, + ); + } + const decryptedByIndex = + encryptedCount > 0 && key ? await decryptPayloads(ops, key) : new Map(); + + const rows: ReplayOperationRow[] = ops.map((op, i) => ({ + id: op.id, + serverSeq: op.serverSeq, + opType: op.opType, + entityType: op.entityType, + entityId: op.entityId, + payload: decryptedByIndex.has(i) ? decryptedByIndex.get(i) : op.payload, + schemaVersion: op.schemaVersion, + // Payloads are plaintext at this point — replay rejects encrypted rows. + isPayloadEncrypted: false, + })); + + const state = replayOpsToState(rows); + + console.log( + `\nReconstructed state at seq ${targetSeq} ` + + `(replayed ${rows.length} ops, ${encryptedCount} decrypted):`, + ); + for (const [entityType, value] of Object.entries(state)) { + if (value && typeof value === 'object') { + const map = value as Record; + const count = Array.isArray(map.ids) ? map.ids.length : Object.keys(map).length; + console.log(` ${entityType.padEnd(26)} ~${count}`); + } + } + + if (dryRun) { + console.log('\n--dry-run: nothing written. Re-run with --out to save the file.'); + return; + } + if (!outPath) { + throw new Error('--out is required (or use --dry-run to preview).'); + } + writeFileSync(outPath, JSON.stringify(state, null, 2), 'utf8'); + console.log(`\nWrote recovered state to ${outPath}`); + console.log( + "This file contains the user's COMPLETE plaintext data. Transmit it over a\n" + + 'secure channel and delete every copy once recovery is confirmed.\n' + + 'The user imports it via Settings -> Import/Export -> Import from File.', + ); +}; + +const main = async (): Promise => { + const args = parseArgs(process.argv.slice(2)); + if (!args.user) { + console.error('Missing --user .'); + console.error(USAGE); + process.exitCode = 1; + return; + } + const userId = await resolveUserId(args.user); + + if (args.inspect) { + await inspect(userId); + return; + } + if (args.targetSeq === undefined) { + console.error('Missing --target-seq . Run with --inspect first to find it.'); + process.exitCode = 1; + return; + } + await recover(userId, args.targetSeq, readKey(args.keyFile), args.out, args.dryRun); +}; + +main() + .catch((e) => { + console.error(`\nERROR: ${(e as Error).message}`); + process.exitCode = 1; + }) + .finally(() => disconnectDb()); From 12b56fa783f545345778207f48e367b8ee36229b Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 22 May 2026 16:30:36 +0200 Subject: [PATCH 36/42] test(e2e): pin browser locale to en-GB for deterministic dates PR #7731 makes the app's "System default" date/time locale follow navigator.language. The Playwright config did not pin a locale, so CI Chromium reported en-US and date-format-dependent tests broke (DD/MM vs MM/DD, 24h vs 12h). Set use.locale to en-GB so navigator.language is deterministic across machines, matching the en-GB format the recurring/date tests assume. --- e2e/playwright.config.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 464604ae94..8e70477ba9 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -72,6 +72,13 @@ export default defineConfig({ /* Base URL to use in actions like `await page.goto('/')`. */ baseURL: process.env.E2E_BASE_URL || 'http://localhost:4242', + /* + * Pin the browser locale so navigator.language is deterministic across + * machines. The app's "System default" date/time locale follows + * navigator.language, and many tests assume en-GB (DD/MM/YYYY, 24h). + */ + locale: 'en-GB', + /* Configure downloads to go to test output directory, not ~/Downloads */ downloadsPath: path.join(__dirname, '..', '.tmp', 'e2e-test-results', 'downloads'), From 9546adbfd4a8414cf9dbe18be44b012e88091db9 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 22 May 2026 16:41:40 +0200 Subject: [PATCH 37/42] fix(context-menu): scope background menu to work-view routes The background context menu was bound globally to .route-wrapper with allowedSelectors that also matched non-work-view pages (.page-wrapper, .route-wrapper > *). Right-clicking blank space on Habits, Config, etc. opened a menu whose actions targeted the stale active work context. Add an isEnabled input to ContextMenuComponent and gate the background menu on the task-list routes (tag-tasks / project-tasks) only. Closes #7734 --- src/app/app.component.html | 3 +- src/app/app.component.ts | 14 +++++ .../context-menu.component.spec.ts | 60 +++++++++++++++++++ .../ui/context-menu/context-menu.component.ts | 7 +++ 4 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 src/app/ui/context-menu/context-menu.component.spec.ts diff --git a/src/app/app.component.html b/src/app/app.component.html index 669de75ac3..946ec5a276 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -93,6 +93,7 @@ + `, +}) +class HostComponent { + readonly triggerRef = viewChild.required>('trigger'); + readonly contextMenu = viewChild.required(ContextMenuComponent); + isEnabled = true; +} + +const dispatchRightClick = (el: HTMLElement): void => { + el.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })); +}; + +describe('ContextMenuComponent', () => { + let fixture: ComponentFixture; + let host: HostComponent; + + // Returns a spy on the underlying menu trigger so tests can assert whether + // the menu actually opened. + const setup = (isEnabled: boolean): jasmine.Spy => { + fixture = TestBed.createComponent(HostComponent); + host = fixture.componentInstance; + host.isEnabled = isEnabled; + fixture.detectChanges(); + return spyOn(host.contextMenu().contextMenuTriggerEl(), 'openMenu'); + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HostComponent, NoopAnimationsModule], + }); + }); + + it('opens the menu on right-click when enabled', () => { + const openMenuSpy = setup(true); + dispatchRightClick(host.triggerRef().nativeElement); + expect(openMenuSpy).toHaveBeenCalled(); + }); + + it('does not open the menu on right-click when disabled (#7734)', () => { + const openMenuSpy = setup(false); + dispatchRightClick(host.triggerRef().nativeElement); + expect(openMenuSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/ui/context-menu/context-menu.component.ts b/src/app/ui/context-menu/context-menu.component.ts index bf3ea526b2..347354073e 100644 --- a/src/app/ui/context-menu/context-menu.component.ts +++ b/src/app/ui/context-menu/context-menu.component.ts @@ -28,6 +28,7 @@ export class ContextMenuComponent implements OnInit { rightClickTriggerEl = input.required(); contextMenu = input.required>(); allowedSelectors = input(''); + isEnabled = input(true); readonly contextMenuTriggerEl = viewChild.required('contextMenuTriggerEl', { read: MatMenuTrigger, @@ -65,6 +66,12 @@ export class ContextMenuComponent implements OnInit { } private openContextMenu(event: TouchEvent | MouseEvent): void { + // When disabled the menu must stay inert (e.g. the background menu is only + // valid on the work-view, not on unrelated pages like Habits). See #7734. + if (!this.isEnabled()) { + return; + } + // If allowedSelectors is provided, check if the target matches any of the selectors const allowedSelectors = this.allowedSelectors(); if (allowedSelectors) { From c300df912f231b70e89619be9ed70bde300c9137 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 22 May 2026 18:21:47 +0200 Subject: [PATCH 38/42] fix(tasks): suppress created snack for tasks with no title --- .../tasks/store/task-ui.effects.spec.ts | 21 +++++++++++++++++++ .../features/tasks/store/task-ui.effects.ts | 2 ++ 2 files changed, 23 insertions(+) diff --git a/src/app/features/tasks/store/task-ui.effects.spec.ts b/src/app/features/tasks/store/task-ui.effects.spec.ts index 178a79ae1d..3dffec100a 100644 --- a/src/app/features/tasks/store/task-ui.effects.spec.ts +++ b/src/app/features/tasks/store/task-ui.effects.spec.ts @@ -243,6 +243,27 @@ describe('TaskUiEffects', () => { actions$.next(createAddTaskAction(task)); }); + it('should NOT show snack when task has no title', (done) => { + const task = createMockTask({ + id: 'new-task-456', + projectId: 'project-1', + title: '', + }); + let emitted = false; + + effects.taskCreatedSnack$.subscribe(() => { + emitted = true; + }); + + actions$.next(createAddTaskAction(task)); + + setTimeout(() => { + expect(emitted).toBe(false); + expect(snackServiceMock.open).not.toHaveBeenCalled(); + done(); + }, 10); + }); + afterEach(() => { localStorage.removeItem(LS.ONBOARDING_HINTS_DONE); }); diff --git a/src/app/features/tasks/store/task-ui.effects.ts b/src/app/features/tasks/store/task-ui.effects.ts index 6764905478..654a73914b 100644 --- a/src/app/features/tasks/store/task-ui.effects.ts +++ b/src/app/features/tasks/store/task-ui.effects.ts @@ -63,6 +63,8 @@ export class TaskUiEffects { () => this._actions$.pipe( ofType(TaskSharedActions.addTask), + // Skip the created snack for accidentally created tasks with no title + filter(({ task }) => !!task.title.trim()), withLatestFrom(this._workContextService.mainListTaskIds$), switchMap(([{ task }, activeContextTaskIds]) => { if (task.projectId) { From 713245e6f0d433b7fa9a6cbb276c1653dd34e02b Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 22 May 2026 18:54:12 +0200 Subject: [PATCH 39/42] docs(sync): add SUP_OPS versionchange handler plan (#7735) Rescopes #7735 from the original three-connection consolidation down to its one genuine correctness fix: register versionchange handlers on the OperationLogStoreService and ArchiveStoreService SUP_OPS connections so a future schema bump cannot be stalled by a handler-less connection. The connection consolidation is documented as not planned (no behavioral benefit, net-additive LOC on a safety-critical path); the rejected "OperationLogStoreService as connection owner" alternative is kept for the record. Both decisions followed multi-agent review rounds. --- ...2026-05-22-supops-single-connection-alt.md | 197 +++++++++++++ .../2026-05-22-supops-single-connection.md | 261 ++++++++++++++++++ 2 files changed, 458 insertions(+) create mode 100644 docs/plans/2026-05-22-supops-single-connection-alt.md create mode 100644 docs/plans/2026-05-22-supops-single-connection.md diff --git a/docs/plans/2026-05-22-supops-single-connection-alt.md b/docs/plans/2026-05-22-supops-single-connection-alt.md new file mode 100644 index 0000000000..6983fb296e --- /dev/null +++ b/docs/plans/2026-05-22-supops-single-connection-alt.md @@ -0,0 +1,197 @@ +# Collapse `SUP_OPS` onto a single connection — Alternative (Plan B) + +**Issue:** #7735 — follow-up to #7732 / #7712 / #7709 +**Status:** ❌ **REJECTED** by a 4-agent evaluation (2026-05-22) in favour of +[`2026-05-22-supops-single-connection.md`](./2026-05-22-supops-single-connection.md) +(Plan A). Kept for the record. Reasons: (1) Plan B makes other services depend +on the 1,745-line `OperationLogStoreService` for a DB handle, cutting against +this codebase's small-leaf-service convention (`LockService` is the precedent +Plan A follows); (2) the `mergeRemoteOpClocks` signature change has a wide, +silent-failure-prone blast radius; (3) Step 2 below rests on a **factual error** +— see the ⚠️ correction inline. Plan A is lower-risk and architecturally +cleaner. Do not implement this plan. +**Prerequisite (hard gate):** #7732 merged to `master`. Not yet merged — see +Plan A for detail. + +## The core idea + +Plan A breaks the DI cycle by **adding** a new leaf service +(`SupOpsConnectionService`) that all three openers delegate to. + +Plan B breaks the cycle by **removing an edge** instead. The key fact, verified: + +> `OperationLogStoreService` injects **exactly one dependency** — +> `CLIENT_ID_PROVIDER` (`operation-log-store.service.ts:186`) — and uses it in +> **exactly two places**: `loadClientId()` in `mergeRemoteOpClocks()` (`:1417`) +> and `clearCache()` in `runDestructiveStateReplacement()` (`:1687`). + +Cut that one edge and `OperationLogStoreService` becomes a **dependency-free +leaf**. Once it is a leaf, `ClientIdService` (and optionally `ArchiveStoreService`) +can depend on it directly and **borrow its existing `SUP_OPS` connection** — no +new service, no cycle. `OperationLogStoreService` already owns the canonical +connection: the retry/backoff opener, `runDbUpgrade` wiring, the `OpLogDB` +schema type. Plan B reuses that owner instead of extracting a new one. + +## Why it is cycle-free (verified) + +- `OperationLogStoreService`'s only injected dependency is `CLIENT_ID_PROVIDER`. + After Steps 1–3 it injects nothing → it cannot be part of any cycle. +- `CLIENT_ID_PROVIDER` has **11 consumer services** (op-log persistence, op-log + sync, validation, `imex/sync`). Only `OperationLogStoreService` is in the + (prospective) cycle. The token is **kept unchanged** for the other 10; this + plan does not touch the `CLIENT_ID_PROVIDER` abstraction. +- The cycle #7735 describes is *prospective*, not current: post-#7732 + `ClientIdService` injects nothing. The cycle would only appear *if* + `ClientIdService` delegated DB access to `OperationLogStoreService` while the + latter still injected `CLIENT_ID_PROVIDER`. Plan B removes that edge first, so + the delegation becomes safe. + +## Steps + +### Step 1 — Make `mergeRemoteOpClocks` take the clientId as a parameter + +`mergeRemoteOpClocks(ops)` → `mergeRemoteOpClocks(ops, currentClientId: string | null)`. +The method body keeps its existing null-check / "cannot prune" warning; it just +reads the parameter instead of `await this.clientIdProvider.loadClientId()`. + +Callers (~5, all already inject `CLIENT_ID_PROVIDER`): +- `operation-log-hydrator.service.ts` — 4 call sites (`:213`, `:239`, `:291`, + `:315`). Field `clientIdProvider` already present (`:57`). Load once per + hydration pass and pass it in. +- `conflict-resolution.service.ts` — 1 call site (`:432`). Field already present + (`:100`); it already loads the clientId at `:614`/`:654`. + +This is arguably *better design* — `mergeRemoteOpClocks` is a pure-ish clock +operation; taking its inputs explicitly beats reaching into DI. + +### Step 2 — Move the `clearCache()` call to `runDestructiveStateReplacement`'s callers + +Delete `this.clientIdProvider.clearCache()` from +`runDestructiveStateReplacement()` (`:1687`). Its 2 callers — +`backup.service.ts:228` and `clean-slate.service.ts:132` — would call +`clientIdService.clearCache()` themselves immediately after the `await` returns. + +> ⚠️ **Factual error (caught by the evaluation).** This step originally claimed +> "both services already import `ClientIdService`." That is **false** — +> `backup.service.ts` and `clean-slate.service.ts` import only the pure +> `generateClientId` util, not `ClientIdService`, and both carry comments +> stating the cache-clear happens *inside* `runDestructiveStateReplacement`'s +> transaction. So Step 2 must **add** a new `ClientIdService` injection to two +> more services and **move** a correctness guarantee (cache-clear bound to a +> committed `tx.done`) out into callers that cannot see `tx.done`. This widens +> the blast radius and decentralises an invariant — a core reason Plan B lost. + +### Step 3 — Delete the `CLIENT_ID_PROVIDER` field + import from `OperationLogStoreService` + +`OperationLogStoreService` now injects nothing → a verifiable leaf. + +### Step 4 — Expose the connection on `OperationLogStoreService` + +Add a narrow public surface. Two options (decision point — see Open questions): +- **4a** — `getDb(): Promise>` (or `init()` + `db` getter): + consumers get the raw handle. +- **4b** — narrow typed methods (`readClientId()`, `putClientId()`, …): consumers + never see the handle. + +4b is better encapsulation; 4a is less code. Recommend 4b for `ClientIdService`'s +needs (small, fixed surface) — it keeps the IndexedDB handle private. + +### Step 5 — Repoint `ClientIdService` at `OperationLogStoreService` + +`ClientIdService` injects `OperationLogStoreService`; delete `_getSupOpsDb`, +`_openSupOpsDb`, `_supOpsDb`, `_supOpsDbPromise` (~50 lines). Route its +`SUP_OPS.client_id` reads/writes through the Step-4 surface. The legacy `pf` +reader (`_readPf`) is untouched. + +### Step 6 — versionchange handler + +`OperationLogStoreService.init()` registers a `versionchange` handler (mirroring +the one `ClientIdService` has today). Since it is now the connection owner, this +is the single handler the app needs for the op-log/clientId connection. + +> Ship Step 6 **first**, as its own tiny PR, exactly as in Plan A's Phase 1 — +> the latent v6→v7 upgrade-hang fix should not wait behind this refactor. +> `ArchiveStoreService` also gets one in that first PR. + +### Step 7 — (optional) Fold in `ArchiveStoreService` + +`ArchiveStoreService` does not inject `CLIENT_ID_PROVIDER`, so +`ArchiveStoreService → OperationLogStoreService` is cycle-free today. To reach +*one connection process-wide*, `ArchiveStoreService` injects +`OperationLogStoreService` and drops its own opener; `_withRetryOnClose` (iOS +#6643) stays, repointed at an invalidate hook. Without Step 7 the end state is +two connections (op-log+clientId shared, archive separate). + +### Step 8 — (optional) Relocate `ClientIdService` + +`ClientIdService` still imports `OperationLogStoreService` from `op-log/`, so the +`core → op-log` inversion persists (unchanged from today, not worsened). To kill +it, relocate `ClientIdService` to `op-log/util/` and add the ESLint +`no-restricted-imports` guard — identical to Plan A's Phase 3, and equally +optional / separable. + +## Files touched + +`operation-log-store.service.ts`, `operation-log-hydrator.service.ts`, +`conflict-resolution.service.ts`, `backup.service.ts`, `clean-slate.service.ts`, +`client-id.service.ts`, plus their specs and the op-log integration helpers +that reference `mergeRemoteOpClocks`. Optional Step 7: `archive-store.service.ts`. +Optional Step 8: ~16 import sites + `eslint.config.js` + docs. **No new file.** + +## Estimated size + +| Scope | Diff churn | +| --- | --- | +| Steps 1–6 (core: cut the edge, share the connection) | ~300–450 | +| + Step 7 (archive folded in) | +~80 | +| + Step 8 (relocation + ESLint) | +~80 | +| **Plan B full** | **~450–600** | +| (Plan A full, for comparison) | ~750–1,100 | + +Plan B is smaller mainly because it adds no service and no new spec file, and +because the connection machinery is *reused in place* rather than relocated. + +## Acceptance criteria + +- `OperationLogStoreService` injects nothing — a verifiable leaf (greppable: no + `inject(` in the class). +- `ClientIdService` no longer opens `SUP_OPS`; it routes through + `OperationLogStoreService`. +- `mergeRemoteOpClocks` and `runDestructiveStateReplacement` no longer reference + `CLIENT_ID_PROVIDER`; the `clearCache` responsibility is at both destructive + callers and covered by tests. +- The op-log/clientId connection registers `close` + `versionchange` handlers. +- `client-id.service.spec.ts` data-safety matrix passes after the test-double + rewrite (private seams moved to `OperationLogStoreService`). +- `runDestructiveStateReplacement` atomicity unchanged; concurrency regression + test added. +- With Step 7: one `SUP_OPS` connection process-wide. +- Full unit suite (both timezone variants) + op-log integration specs green. + +## Risks & mitigations + +| Risk | Mitigation | +| --- | --- | +| A future cycle if `OperationLogStoreService` gains an injected dep that reaches `ClientIdService` | "Injects nothing" is an enforced acceptance criterion; a store service *should* be a leaf | +| `clearCache` now at 2 caller sites — one could be forgotten on a future destructive flow | Only 2 call sites today; cover both with tests; consider an ESLint/review note. (Plan A keeps it centralized — a genuine point for A.) | +| `ClientIdService` / `ArchiveStoreService` depend on the whole 1,745-line `OperationLogStoreService` for a DB handle | Step 4b: expose only narrow typed methods, not the raw handle — the DI dependency is on the class but the *used* surface is tiny | +| `mergeRemoteOpClocks` signature change ripples to specs + integration helpers | ~5 call sites + helpers; mechanical, enumerated | +| `OperationLogStoreService` becomes the connection "landlord" (dual responsibility) | This is the central A-vs-B trade-off — see below | + +## The central trade-off vs Plan A + +- **Plan A** adds a dedicated `SupOpsConnectionService` (single-responsibility: + it *only* owns the connection). Cleaner separation; one more service/file; + larger diff; keeps `CLIENT_ID_PROVIDER` on `OperationLogStoreService`. +- **Plan B** removes a dependency edge so `OperationLogStoreService` itself can + be the connection owner. Fewer moving parts; no new file; smaller diff; makes + `OperationLogStoreService` a proper leaf and `mergeRemoteOpClocks` explicit — + but `OperationLogStoreService` carries connection-ownership *and* op storage, + and other services depend on it for a DB handle. + +KISS leans B; single-responsibility purity leans A. Both are correct; both are +optional cleanup on top of the one must-do fix (the `versionchange` handlers). + +## Out of scope + +Same as Plan A: the legacy `pf` reader is untouched; no new schema version. diff --git a/docs/plans/2026-05-22-supops-single-connection.md b/docs/plans/2026-05-22-supops-single-connection.md new file mode 100644 index 0000000000..c016928778 --- /dev/null +++ b/docs/plans/2026-05-22-supops-single-connection.md @@ -0,0 +1,261 @@ +# `SUP_OPS` `versionchange` handlers — #7735 (scoped down) + + + +**Issue:** #7735 — filed as a follow-up to #7732 / #7712 / #7709. +**Status:** Active plan. Rescoped from #7735's original connection-consolidation +proposal (dropped — see the final section) and revised across multi-agent review +rounds, which corrected several factual errors in earlier drafts. +**Prerequisite:** none. The fix is **independent of #7732** and can land on +`master` directly, before or after #7732 (see "Baseline independence"). + +## Goal + +Register an IndexedDB `versionchange` handler on the two `SUP_OPS` connections +that lack one — `OperationLogStoreService` and `ArchiveStoreService` — so the +next schema bump is not stalled by a handler-less connection. This is the one +genuine correctness fix in #7735. + +It removes **one known latent hang**. It is necessary but not by itself +sufficient for a fully hang-proof upgrade — see "Limits". + +## The latent bug + +When a connection or tab opens `SUP_OPS` at a higher `DB_VERSION` (a schema +bump), IndexedDB dispatches a `versionchange` event on every other open +connection. The upgrade proceeds only once those connections close; any that +**stay open block the upgrade** — the upgrading `openDB` sits on `blocked`. A +connection with no `versionchange` handler never closes itself, so it stalls the +upgrade until the user manually closes that tab. + +`SUP_OPS` connections and their `versionchange` handler status: + +| Connection owner | `versionchange` handler today | +| --- | --- | +| `OperationLogStoreService` (`init()`) | ❌ **no** — fix here | +| `ArchiveStoreService` (`_init()`) | ❌ **no** — fix here | +| `ClientIdService` (`_openSupOpsDb()`) | ✅ yes — exists only post-#7732 | + +This fix must land before any `DB_VERSION` increase. + +### Baseline independence + +`OperationLogStoreService` and `ArchiveStoreService` open `SUP_OPS` and register +a `close` (but no `versionchange`) handler **identically before and after +#7732** — the fix is the same two-line addition either way. Pre-#7732 there are +two `SUP_OPS` connections (both handler-less); #7732 adds a third +(`ClientIdService`, which ships with its own `versionchange` handler). So this +fix is not gated on #7732 and may land first. The post-#7732 `ClientIdService` +handler is a *consistency reference*, not a dependency. + +## The fix + +A standard close-and-null `versionchange` listener, added next to each service's +existing `close` listener. (Post-#7732, `ClientIdService._openSupOpsDb()` has an +identical one — keep all three consistent.) + +### `OperationLogStoreService.init()` + +```ts +async init(): Promise { + const db = await this._openDbWithRetry(); + db.addEventListener('close', () => { + Log.warn( + '[OpLogStore] IndexedDB connection closed by browser. Will re-open on next access.', + ); + this._db = undefined; + this._initPromise = undefined; + }); + // A newer tab is upgrading SUP_OPS (a future schema bump). Close now so this + // connection does not block the upgrade; the next op-log access reopens + // transparently via _ensureInit(). + db.addEventListener('versionchange', () => { + db.close(); + this._db = undefined; + this._initPromise = undefined; + }); + this._db = db; +} +``` + +### `ArchiveStoreService._init()` + +The identical addition next to its existing `close` listener (fields `_db` / +`_initPromise`; log tag `[ArchiveStore]`). + +### Behaviour notes + +- **No behaviour change in normal operation.** The handler only acts on a + `versionchange` — i.e. a schema bump — which today has no defined behaviour at + all (the upgrade just hangs). The fix turns that hang into a graceful + close-and-reopen; nothing else changes. +- **`db.close()` is graceful — it does NOT abort in-flight transactions.** Per + the IndexedDB spec, a scripted `close()` (the `forced` flag is false) sets a + *close-pending* flag, lets every transaction already running on the connection + **run to completion**, and only then closes — it raises no `AbortError`. So a + `versionchange` landing mid-transaction (e.g. during + `runDestructiveStateReplacement`) lets that transaction commit normally; the + upgrading tab simply waits for it. New work after the handler runs goes + through `_ensureInit()` and opens a fresh connection (the handler nulled + `_db`). The `forced`-flag abort path (`AbortError`) applies to browser-forced + closes / `deleteDatabase`, not to this handler. +- A scripted `db.close()` also does **not** fire the `close` event, so the + `versionchange` handler must null `_db` / `_initPromise` itself — exactly as + `ClientIdService`'s handler does. +- The handler closes over `db` and nulls `this._db` unconditionally. A stale + `versionchange` cannot clobber a *newer* connection: a closed IndexedDB + connection receives no further events, and there is **no `await`** between + `addEventListener('versionchange', …)` and `this._db = db` (they run in one + synchronous tick, so the event cannot fire between them). Keep them adjacent — + a future refactor inserting an `await` there would open a stale-handle window. +- `idb`'s `openDB({ blocking })` callback is the alternative API; rejected for + consistency — the existing `close` handlers and `ClientIdService`'s + `versionchange` handler all use `addEventListener`. + +## Tests + +Specs run against **real Chrome IndexedDB** (Karma `ChromeHeadless`); there is no +`fake-indexeddb`. That rules out an "open a 2nd connection at `DB_VERSION + 1`" +test — a real upgrade persistently bumps the shared `SUP_OPS` database for the +rest of the Karma run (`_clearAllDataForTesting()` clears object stores, **not** +the DB version), poisoning every later test with `VersionError`. + +**Primary test (deterministic, per service)** — dispatch a synthetic +`versionchange` event; no DB version is mutated, so nothing is poisoned: + +1. Force initialization through the **lazy `_ensureInit()` path** so + `_initPromise` is genuinely populated: reset `(service as any)._db` and + `_initPromise` to `undefined`, then call a cheap read-only method (op-log: + e.g. `getLastSeq()`; archive: e.g. `loadArchiveYoung()`) and `await` it to + completion. (The specs' `beforeEach` uses a *direct* `init()`, which leaves + `_initPromise` unset — asserting it without the lazy path makes the + `_initPromise` check vacuous.) Awaiting the read keeps the test + deterministic. +2. `import { unwrap } from 'idb'`; capture the raw handle: + `const raw = unwrap((service as any)._db)`. +3. `raw.dispatchEvent(new Event('versionchange'))` — synchronous; the handler + reads no event fields, so a plain `Event` suffices. +4. Assert `(service as any)._db` and `_initPromise` are both `undefined`, **and** + that the connection was actually closed — e.g. `raw.transaction()` + throws `InvalidStateError`. (Asserting the close, not just the field nulling, + guards the load-bearing `db.close()` call — the thing that actually unblocks + the upgrade.) +5. Call the read again; assert it succeeds — the connection reopened + transparently (this also proves `_initPromise` was nulled: a stale resolved + `_initPromise` would make `_ensureInit()` skip the reopen and the `db` getter + throw). + +This proves *our* code: the handler closes the connection and the service +reopens. That Chrome fires `versionchange` on a real cross-connection upgrade is +browser behaviour, not ours — a true end-to-end "the upgrade completes across +tabs" check is e2e territory and out of scope here. + +Also add a minimal **`close`-handler test** in the same describe block (dispatch +`close`, assert `_db`/`_initPromise` cleared) — neither service has one today, +and the fix adds a sibling listener right next to it; cheap regression cover. + +Spec files: +- `OperationLogStoreService` has `operation-log-store.service.spec.ts` — add the + tests in a new `describe`. Use the **real** `init()` path, not the fully-faked + `_openDbOnce` db used by the `_openDbWithRetry` describe block. +- `ArchiveStoreService` has **no spec file** — `archive-store.service.spec.ts` + must be **created**. `ArchiveStoreService` injects nothing, so `TestBed` setup + is trivial (no providers/mocks). The new file must mirror the op-log spec's + teardown — `_clearAllDataForTesting()` in `afterEach` — so it does not leave + archive rows or an open connection for later specs. + +## Limits — what this fix does NOT do + +- It does not help against an **old tab still running pre-fix code** — that + connection has no `versionchange` handler and still blocks the upgrade. A + fully hang-proof v-bump rollout also needs a `blocked`-path UX story; that + belongs with the schema-bump PR, not here. +- An `openDB({ blocked })` diagnostic callback (logs when *this* service's own + open is stalled by other tabs) is **deferred** — it is diagnostics, not the + fix; keep this PR minimal. + +## Acceptance criteria + +- `OperationLogStoreService` and `ArchiveStoreService` each register a + `versionchange` handler that calls `db.close()` and clears `_db` / + `_initPromise`. +- A unit test per service — including a newly created + `archive-store.service.spec.ts` — proves the dispatch-`versionchange` → close + (connection unusable) → transparent-reopen contract, plus a `close`-handler + regression test. +- Tests mutate no `SUP_OPS` DB version; the full unit suite (both timezone + variants) stays green. +- `npm run checkFile` clean on every modified or created `.ts` file. + +## Estimated size + +~10–12 production lines across 2 files; ~35–45 test lines added to +`operation-log-store.service.spec.ts` plus a new `archive-store.service.spec.ts` +(~60–80 lines incl. `TestBed` boilerplate — it also gives `ArchiveStoreService` +its first test coverage). One small, low-risk PR. + +## Commit + +`fix(sync): add versionchange handlers to SUP_OPS connections` + +## Issue disposition + +This PR closes #7735, whose original text proposed the larger consolidation. So +that decision is not buried: both plan docs +(`2026-05-22-supops-single-connection.md` and the rejected-alternative +`-alt.md`) land **in this PR**, and the PR description plus the `Closes #7735` +comment must state explicitly that the connection consolidation was evaluated +and deliberately dropped, linking this doc. No separate tracking issue is filed — +the consolidation is "not planned", not "deferred". + +--- + +## Connection consolidation: not planned + +#7735 originally proposed collapsing the three `SUP_OPS` connections onto one +shared connection, breaking a DI cycle, and relocating `ClientIdService` out of +`core/`. After two multi-agent review rounds, **that scope is dropped.** +Rationale: + +- **No behavioral benefit.** #7735 admits this itself. The DI cycle is + *prospective*, not live — post-#7732 `ClientIdService` injects nothing, so + there is no cycle today. Three same-origin connections serialize their + transactions correctly; that is the documented, working state, not a bug. +- **A bad LOC trade.** The consolidation is not a delete-and-simplify refactor — + it *adds* a service file and a few hundred lines of net-new code (mostly new + test surface) and edits ~16 files, for zero behavioral change, on the app's + most safety-critical path (op-log + clientId + destructive replacement). +- The only genuine win underneath — de-duplicating `_openDbWithRetry` across two + services — is ~50 lines and does not justify the rest. + +If a future `SUP_OPS` schema migration ever makes a single connection genuinely +worthwhile, do it **bundled with that migration** (the marginal cost of the +connection extraction is small when you are already in that code with fresh +tests) and use the reference design below. Do not pursue it standalone. + +### Reference design — extract `SupOpsConnectionService` (not planned; for a future migration only) + +Preferred approach if the consolidation is ever revived (it beat the alternative +5–0 in a multi-agent evaluation): + +- Extract a **dependency-free leaf** `SupOpsConnectionService` (in + `op-log/persistence/`) owning the `SUP_OPS` connection lifecycle: `openDB` + + `runDbUpgrade` + retry/backoff (`_openDbWithRetry`) + `close`/`versionchange` + handlers + in-flight-promise dedup. It must `inject()` nothing — that is what + makes it cycle-safe. Precedent in this codebase: `LockService`. +- `OperationLogStoreService`, `ArchiveStoreService`, `ClientIdService` delegate + to it. Keep each service's existing `_ensureInit()` / `db`-getter shape as + thin delegates so the ~50 + ~56 internal call sites are untouched. +- Keep `CLIENT_ID_PROVIDER` exactly as-is (it has 11 consumers and is unrelated + to the connection question). +- Optionally relocate `ClientIdService` to `op-log/util/` + add an ESLint + `no-restricted-imports` rule banning `core → op-log` (this part is unrelated + to a schema migration and would be separately optional even then). + +The **rejected** alternative — making `OperationLogStoreService` itself the +connection owner by cutting its `CLIENT_ID_PROVIDER` edge — is documented, with +the multi-agent evaluation's reasoning, in +[`2026-05-22-supops-single-connection-alt.md`](./2026-05-22-supops-single-connection-alt.md). +The full original phased breakdown of this consolidation is preserved in this +file's git history (pre-rescope revision). From 19deddc5ee7f760cd4dc80094f39150f1a755b74 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 22 May 2026 19:10:19 +0200 Subject: [PATCH 40/42] fix(sync): add versionchange handlers to SUP_OPS connections OperationLogStoreService and ArchiveStoreService opened the SUP_OPS IndexedDB database without a `versionchange` handler. A connection with no such handler never closes itself, so a future schema upgrade opened by another tab would be blocked indefinitely until the user manually closed the older tabs. Add a `versionchange` handler to each, mirroring the one ClientIdService already has: close the connection and clear the cached handle so the next access transparently reopens. Covered by new connection-lifecycle tests, including a first spec file for ArchiveStoreService. Refs #7735. --- .../persistence/archive-store.service.spec.ts | 68 +++++++++++++++++++ .../persistence/archive-store.service.ts | 8 +++ .../operation-log-store.service.spec.ts | 48 +++++++++++++ .../operation-log-store.service.ts | 8 +++ 4 files changed, 132 insertions(+) create mode 100644 src/app/op-log/persistence/archive-store.service.spec.ts diff --git a/src/app/op-log/persistence/archive-store.service.spec.ts b/src/app/op-log/persistence/archive-store.service.spec.ts new file mode 100644 index 0000000000..7989c8cc8c --- /dev/null +++ b/src/app/op-log/persistence/archive-store.service.spec.ts @@ -0,0 +1,68 @@ +import { TestBed } from '@angular/core/testing'; +import { IDBPDatabase, unwrap } from 'idb'; +import { ArchiveStoreService } from './archive-store.service'; +import { STORE_NAMES } from './db-keys.const'; + +describe('ArchiveStoreService', () => { + let service: ArchiveStoreService; + + beforeEach(async () => { + TestBed.configureTestingModule({ + providers: [ArchiveStoreService], + }); + service = TestBed.inject(ArchiveStoreService); + // Opens the connection and clears any data left by previous tests + // (ArchiveStoreService shares the SUP_OPS database with other specs). + await service._clearAllDataForTesting(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('connection lifecycle handlers', () => { + // Open the connection through the lazy `_ensureInit()` path so `_initPromise` + // is genuinely populated before the event is dispatched. + const openViaLazyInit = async (): Promise => { + (service as any)._db = undefined; + (service as any)._initPromise = undefined; + await service.loadArchiveYoung(); + }; + + it('closes the connection and clears cached state on versionchange, then reopens', async () => { + await openViaLazyInit(); + expect((service as any)._db).toBeDefined(); + expect((service as any)._initPromise).toBeDefined(); + + const raw = unwrap((service as any)._db as IDBPDatabase); + raw.dispatchEvent(new Event('versionchange')); + + // The handler runs synchronously: cached state cleared and the + // connection actually closed (so it cannot block a schema upgrade). + expect((service as any)._db).toBeUndefined(); + expect((service as any)._initPromise).toBeUndefined(); + let txError: unknown; + try { + raw.transaction(STORE_NAMES.ARCHIVE_YOUNG, 'readonly'); + } catch (e) { + txError = e; + } + expect((txError as DOMException | undefined)?.name).toBe('InvalidStateError'); + + // The next access transparently reopens the connection. + await expectAsync(service.loadArchiveYoung()).toBeResolved(); + }); + + it('clears cached state on the browser close event, then reopens', async () => { + await openViaLazyInit(); + + const raw = unwrap((service as any)._db as IDBPDatabase); + raw.dispatchEvent(new Event('close')); + + expect((service as any)._db).toBeUndefined(); + expect((service as any)._initPromise).toBeUndefined(); + await expectAsync(service.loadArchiveYoung()).toBeResolved(); + raw.close(); + }); + }); +}); diff --git a/src/app/op-log/persistence/archive-store.service.ts b/src/app/op-log/persistence/archive-store.service.ts index d386a73764..d219524e14 100644 --- a/src/app/op-log/persistence/archive-store.service.ts +++ b/src/app/op-log/persistence/archive-store.service.ts @@ -83,6 +83,14 @@ export class ArchiveStoreService { this._db = undefined; this._initPromise = undefined; }); + // A newer tab is upgrading SUP_OPS (a future schema bump). Close now so this + // connection does not block the upgrade; the next access reopens + // transparently via _ensureInit(). + db.addEventListener('versionchange', () => { + db.close(); + this._db = undefined; + this._initPromise = undefined; + }); this._db = db; } diff --git a/src/app/op-log/persistence/operation-log-store.service.spec.ts b/src/app/op-log/persistence/operation-log-store.service.spec.ts index 06236f31e3..3e3beb826b 100644 --- a/src/app/op-log/persistence/operation-log-store.service.spec.ts +++ b/src/app/op-log/persistence/operation-log-store.service.spec.ts @@ -1,4 +1,5 @@ import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { IDBPDatabase, unwrap } from 'idb'; import { OperationLogStoreService } from './operation-log-store.service'; import { VectorClockService } from '../sync/vector-clock.service'; import { @@ -92,6 +93,53 @@ describe('OperationLogStoreService', () => { }); }); + describe('connection lifecycle handlers', () => { + // Open the connection through the lazy `_ensureInit()` path so `_initPromise` + // is genuinely populated (the `beforeEach` above uses a direct `init()`, + // which leaves it unset — making an `_initPromise` assertion vacuous). + const openViaLazyInit = async (): Promise => { + (service as any)._db = undefined; + (service as any)._initPromise = undefined; + await service.getLastSeq(); + }; + + it('closes the connection and clears cached state on versionchange, then reopens', async () => { + await openViaLazyInit(); + expect((service as any)._db).toBeDefined(); + expect((service as any)._initPromise).toBeDefined(); + + const raw = unwrap((service as any)._db as IDBPDatabase); + raw.dispatchEvent(new Event('versionchange')); + + // The handler runs synchronously: cached state cleared and the + // connection actually closed (so it cannot block a schema upgrade). + expect((service as any)._db).toBeUndefined(); + expect((service as any)._initPromise).toBeUndefined(); + let txError: unknown; + try { + raw.transaction(STORE_NAMES.OPS, 'readonly'); + } catch (e) { + txError = e; + } + expect((txError as DOMException | undefined)?.name).toBe('InvalidStateError'); + + // The next access transparently reopens the connection. + await expectAsync(service.getLastSeq()).toBeResolved(); + }); + + it('clears cached state on the browser close event, then reopens', async () => { + await openViaLazyInit(); + + const raw = unwrap((service as any)._db as IDBPDatabase); + raw.dispatchEvent(new Event('close')); + + expect((service as any)._db).toBeUndefined(); + expect((service as any)._initPromise).toBeUndefined(); + await expectAsync(service.getLastSeq()).toBeResolved(); + raw.close(); + }); + }); + describe('unsynced/synced/rejected transitions', () => { it('should expose unsynced, synced, and rejected transitions through service methods', async () => { const port: Pick< diff --git a/src/app/op-log/persistence/operation-log-store.service.ts b/src/app/op-log/persistence/operation-log-store.service.ts index 37158db181..55ef329e77 100644 --- a/src/app/op-log/persistence/operation-log-store.service.ts +++ b/src/app/op-log/persistence/operation-log-store.service.ts @@ -207,6 +207,14 @@ export class OperationLogStoreService implements RemoteOperationApplyStorePort { + db.close(); + this._db = undefined; + this._initPromise = undefined; + }); this._db = db; } From 69b1cb287712f56553270cb16397180b0843121d Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 22 May 2026 20:21:19 +0200 Subject: [PATCH 41/42] perf(document-mode): strip redundant chip content from synced data The document-mode plugin persisted each taskRef/subTaskRef chip with its task title as inline content plus an isDone attr. Both are re-derived from the host task cache on load (migrateStoredDoc backfills content, refreshChipContentFromCache overwrites both), so storing them only inflates the synced blob -- and the title is its byte-heavy part. Add a pure stripChipContent() that collapses every chip to a bare identity atom { type, attrs: { taskId } }, and apply it in flushSave / flushSaveSync before serializing. The live editor document keeps its inline content, so title write-back is unaffected; only the persisted copy shrinks. A bare-atom chip loads through the existing unchanged pipeline, so no schema bump or migration is needed. Roughly halves the per-change sync payload. --- .../document-mode/src/doc-transform.spec.ts | 115 ++++++++++++++++++ .../document-mode/src/doc-transform.ts | 38 ++++++ .../plugin-dev/document-mode/src/ui/editor.ts | 5 +- 3 files changed, 156 insertions(+), 2 deletions(-) diff --git a/packages/plugin-dev/document-mode/src/doc-transform.spec.ts b/packages/plugin-dev/document-mode/src/doc-transform.spec.ts index f829db94b1..b1edb2e508 100644 --- a/packages/plugin-dev/document-mode/src/doc-transform.spec.ts +++ b/packages/plugin-dev/document-mode/src/doc-transform.spec.ts @@ -14,6 +14,7 @@ import { reconcileTopLevelTaskRefs, refreshChipContentFromCache, snapshotInContextTaskIds, + stripChipContent, taskNodeJSON, taskRefWithSubtasksJSON, type PMNode, @@ -164,6 +165,120 @@ test('migrateStoredDoc: preserves non-task nodes', () => { assert.deepEqual(migrateStoredDoc(raw, mkLookup([])), raw); }); +/* -------------------------------------------------------------------------- */ +/* stripChipContent */ +/* -------------------------------------------------------------------------- */ + +test('stripChipContent: strips taskRef/subTaskRef content + isDone, keeps taskId', () => { + const doc = { + type: 'doc', + content: [ + { + type: 'taskRef', + attrs: { taskId: 'a', isDone: true }, + content: [{ type: 'text', text: 'Buy milk' }], + }, + { + type: 'subTaskRef', + attrs: { taskId: 's1', isDone: false }, + content: [{ type: 'text', text: 'Sub one' }], + }, + ], + }; + const out = stripChipContent(doc) as PMNode; + assert.deepEqual(childAt(out, 0), { type: 'taskRef', attrs: { taskId: 'a' } }); + assert.deepEqual(childAt(out, 1), { type: 'subTaskRef', attrs: { taskId: 's1' } }); +}); + +test('stripChipContent: leaves prose blocks and their text content intact', () => { + const doc = { + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 1 }, + content: [{ type: 'text', text: 'Title' }], + }, + { type: 'paragraph', content: [{ type: 'text', text: 'a note' }] }, + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'item' }] }], + }, + ], + }, + { type: 'taskRef', attrs: { taskId: 'a' }, content: [{ type: 'text', text: 'A' }] }, + ], + }; + const out = stripChipContent(doc) as PMNode; + assert.deepEqual(childAt(out, 0), doc.content[0]); + assert.deepEqual(childAt(out, 1), doc.content[1]); + assert.deepEqual(childAt(out, 2), doc.content[2]); + // Chip inside the same doc is still stripped. + assert.deepEqual(childAt(out, 3), { type: 'taskRef', attrs: { taskId: 'a' } }); +}); + +test('stripChipContent: does not mutate the input object', () => { + const doc = { + type: 'doc', + content: [ + { + type: 'taskRef', + attrs: { taskId: 'a', isDone: true }, + content: [{ type: 'text', text: 'Buy milk' }], + }, + { type: 'paragraph', content: [{ type: 'text', text: 'a note' }] }, + ], + }; + const before = JSON.stringify(doc); + const out = stripChipContent(doc); + assert.notEqual(out, doc); + assert.equal(JSON.stringify(doc), before); +}); + +test('stripChipContent: round-trips through prepareStoredDoc to rebuild chip titles', () => { + const look = mkLookup([ + mkTask({ id: 'a', title: 'Alpha', isDone: true, subTaskIds: ['s1'] }), + mkTask({ id: 's1', title: 'Sub one' }), + ]); + const live = { + type: 'doc', + content: [ + { type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Proj' }] }, + { + type: 'taskRef', + attrs: { taskId: 'a', isDone: true }, + content: [{ type: 'text', text: 'Alpha' }], + }, + { + type: 'subTaskRef', + attrs: { taskId: 's1', isDone: false }, + content: [{ type: 'text', text: 'Sub one' }], + }, + { type: 'paragraph', content: [{ type: 'text', text: 'a note' }] }, + ], + }; + const stripped = stripChipContent(live); + const out = prepareStoredDoc( + stripped, + mkCtx({ id: 'P', taskIds: ['a'] }), + look, + ) as PMNode; + assert.deepEqual(summary(out), [ + 'heading', + 'taskRef:a', + 'subTaskRef:s1', + 'paragraph', + 'paragraph', + ]); + assert.equal(chipText(childAt(out, 1)), 'Alpha'); + assert.equal(childAt(out, 1).attrs?.isDone, true); + assert.equal(chipText(childAt(out, 2)), 'Sub one'); + assert.equal(chipText(childAt(out, 3)), 'a note'); +}); + /* -------------------------------------------------------------------------- */ /* reconcileTopLevelTaskRefs */ /* -------------------------------------------------------------------------- */ diff --git a/packages/plugin-dev/document-mode/src/doc-transform.ts b/packages/plugin-dev/document-mode/src/doc-transform.ts index f73e642cf4..a18454452b 100644 --- a/packages/plugin-dev/document-mode/src/doc-transform.ts +++ b/packages/plugin-dev/document-mode/src/doc-transform.ts @@ -118,6 +118,44 @@ export const migrateStoredDoc = (raw: unknown, getTask: TaskLookup): unknown => return visit(raw as PMNode); }; +/** + * Strip redundant chip data from a doc before it is serialized to storage. + * + * Each `taskRef` / `subTaskRef` chip carries inline title text (`content`) + * and an `isDone` attr, but on load both are re-derived from the host task + * cache: `migrateStoredDoc` backfills missing `content` and `refreshChipContentFromCache` + * overwrites both unconditionally. Persisting them is therefore dead weight in + * the synced payload — and the title is the byte-heavy, variable-length part. + * This collapses every chip to a bare identity atom `{ type, attrs: { taskId } }`. + * + * Pure: returns new objects, never mutates `doc`. It runs only on the + * serialized copy (`editor.getJSON()` returns a fresh object); the live + * editor document keeps its inline content, so title write-back + * (`reconcileTitlesFromDoc`, which reads the live doc) is unaffected. + */ +export const stripChipContent = (doc: unknown): unknown => { + const visit = (node: PMNode | PMText | undefined): PMNode | PMText | undefined => { + if (!node || typeof node !== 'object') return node; + if ('text' in node) return node; + if (node.type === 'taskRef' || node.type === 'subTaskRef') { + return { + type: node.type, + attrs: { taskId: (node.attrs?.taskId as string) || '' }, + }; + } + if (Array.isArray(node.content)) { + return { + ...node, + content: node.content + .map(visit) + .filter((n): n is PMNode | PMText => n !== undefined), + }; + } + return node; + }; + return visit(doc as PMNode); +}; + /** A paragraph with no inline content — the editor's structural landing line. */ const isEmptyParagraph = (n: PMNode | PMText): boolean => { const node = n as PMNode; diff --git a/packages/plugin-dev/document-mode/src/ui/editor.ts b/packages/plugin-dev/document-mode/src/ui/editor.ts index 0d1b378881..e7aae5070d 100644 --- a/packages/plugin-dev/document-mode/src/ui/editor.ts +++ b/packages/plugin-dev/document-mode/src/ui/editor.ts @@ -23,6 +23,7 @@ import { buildSeedDoc, prepareStoredDoc, snapshotInContextTaskIds, + stripChipContent, taskNodeJSON, taskRefWithSubtasksJSON, type TaskLookup, @@ -275,7 +276,7 @@ const flushSave = async (): Promise => { const latest = await readBlob(); const merged: StoredState = { ...latest, - docs: { ...latest.docs, [currentCtx.id]: editor.getJSON() }, + docs: { ...latest.docs, [currentCtx.id]: stripChipContent(editor.getJSON()) }, }; storedState = merged; await PluginAPI.persistDataSynced(JSON.stringify(merged)); @@ -314,7 +315,7 @@ const flushSaveSync = (): void => { try { const merged: StoredState = { ...storedState, - docs: { ...storedState.docs, [currentCtx.id]: editor.getJSON() }, + docs: { ...storedState.docs, [currentCtx.id]: stripChipContent(editor.getJSON()) }, }; storedState = merged; void PluginAPI.persistDataSynced(JSON.stringify(merged)); From 7a932652815e8384afc0a52cbebbf6e1c445255b Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 22 May 2026 20:21:26 +0200 Subject: [PATCH 42/42] docs(sync): add document-mode sync data model and delta-sync plans Two design docs for slimming the document-mode plugin's sync footprint: the sync-data-model plan covers the immediate fix (bare-atom chips) plus deferred per-context entities; the delta-sync plan analyses why true deltas need finer entity granularity or a commutative CRDT (Yjs) given the partially-ordered op-log. Refs #7740. --- .../2026-05-22-document-mode-delta-sync.md | 216 ++++++++++++++++++ ...026-05-22-document-mode-sync-data-model.md | 207 +++++++++++++++++ 2 files changed, 423 insertions(+) create mode 100644 docs/plans/2026-05-22-document-mode-delta-sync.md create mode 100644 docs/plans/2026-05-22-document-mode-sync-data-model.md diff --git a/docs/plans/2026-05-22-document-mode-delta-sync.md b/docs/plans/2026-05-22-document-mode-delta-sync.md new file mode 100644 index 0000000000..44644fd064 --- /dev/null +++ b/docs/plans/2026-05-22-document-mode-delta-sync.md @@ -0,0 +1,216 @@ +# Document Mode — delta sync architecture + +**Status:** proposal +**Date:** 2026-05-22 +**Branch:** `feat/how-fat-is-data-model-for-sync-for-new-fbd044` +**Supersedes:** the "Future work — per-context sync entities" section of +[`2026-05-22-document-mode-sync-data-model.md`](./2026-05-22-document-mode-sync-data-model.md) +(that doc's Phase 1 — bare-atom chips — remains the immediate, separately-tracked fix) + +## Problem + +The document-mode plugin (`packages/plugin-dev/document-mode/`) is a TipTap +editor that persists **all** its data as a single opaque JSON blob via +`PluginAPI.persistDataSynced(string)`. The blob is: + +```jsonc +{ "version": 1, + "docs": { "": , ... }, // one doc per project/tag/TODAY + "enabledCtxIds": ["..."] } +``` + +Host-side this becomes **one** `PluginUserData` entry with `entityId = pluginId`. +Every save is one op (`upsertPluginUserData`, `entityType: PLUGIN_USER_DATA`, +`opType: Update`) whose payload embeds the **entire blob string**. So the data +sent per change is the whole multi-context corpus (~26–48 KB encrypted for a +typical 5-context user, measured) — **regardless of how tiny the edit was**. +Saves are throttled ~1 op / 2 s while typing (`SAVE_THROTTLE_MS` in +`src/ui/editor.ts`; the host additionally coalesces via +`MIN_PLUGIN_PERSIST_INTERVAL_MS`). + +This is **not a delta**. Typing one character re-transmits, re-encrypts, and +re-stores every project and tag document. Phase 1 of the sibling doc shrinks the +blob ~46% by stripping redundant chip content, but the unit transmitted is still +"the whole corpus". The deeper question — *can a change send only the change?* — +is what this doc addresses. + +## Goals + +1. Lay out the design space for transmitting **less than the whole corpus** per + edit, with honest effort/payoff for each option. +2. Identify which options are compatible with the op-log's partial ordering and + conflict model (a hard constraint — see below). +3. Recommend a staged path: what to do now, mid-term, and long-term. + +## Non-goals + +- Re-deriving the Phase 1 (bare-atom chip) work — already planned and tracked in + the sibling doc; it is orthogonal and complementary to everything here. +- Committing to an implementation in this branch. This is an architecture + proposal for discussion. +- Removing the in-tree `src/app/features/document-mode/` feature. + +## Why naive deltas do not work here + +The op-log is **partially ordered and conflict-resolved**. Vector clocks reorder +ops; `SYNC_IMPORT` / `BACKUP_IMPORT` deliberately drop concurrent ops +(`CONCURRENT` / `LESS_THAN` by vector clock — by design, per the sync model). +A position-dependent patch — "insert these 3 chars at offset 4012" — is only +valid against the exact base state it was computed from. Once another op is +interleaved ahead of it, the offset is wrong and the patch corrupts the +document. + +This is precisely why the op-log replays **semantic action payloads**, never +text diffs: an action like "set task title to X" is replayable against any +state. So a *valid* delta for this system must be **either**: + +- a **semantic operation** that is replayable against any document state, **or** +- a **commutative CRDT update** that composes correctly under any order. + +A line/character/JSON-position patch is neither. This rules out the "obvious" +delta — diff the old and new blob — outright. + +## The granularity spectrum + +| Granularity | Delta unit | ~Per change sent | Effort | +| --- | --- | --- | --- | +| Whole blob (today) | entire corpus | ~26–48 KB | — | +| Per-context entity | one document | ~6–9 KB | moderate (host API change) | +| Per-block entity | one paragraph | ~hundreds of B | high (stable block ids) | +| ProseMirror steps / Yjs CRDT | the actual edit | tens of B | major (new sync channel) | + +Each row below the first is a real option. They are not mutually exclusive — +per-context is a stepping stone, not a dead end. + +### Option A — per-context sync entities (document-level "delta") + +Give each `(plugin, context)` pair its own sync entity, so editing project X's +document transmits only project X's document, not the whole corpus. + +**What it needs:** + +1. **Keyed plugin-persistence API.** `persistDataSynced` is currently + single-arg (`persistDataSynced(dataStr: string)` in + `packages/plugin-api/src/types.ts`, line 555; `loadSyncedData()` line 557 — + verified). Add an optional `key`: `persistDataSynced(data, key?)` / + `loadSyncedData(key?)`, threaded through the whole chain — `plugin-api/types.ts`, + `plugin-bridge.service.ts`, the iframe wrapper (`plugin-api.ts`), and the + iframe postMessage util (`plugin-iframe.util.ts`, which currently drops a + second argument). +2. **Composite entity id.** `PluginUserData.id` becomes `pluginId:key`, so + concurrent edits to *different* contexts stop colliding on one entity. +3. **Virtual-entity LWW support — required, not optional.** `PLUGIN_USER_DATA` + is registered as a **`virtual`** entity in + `src/app/op-log/core/entity-registry.ts` (lines 318–322, verified), and + `ConflictResolutionService.getCurrentEntityState` + (`src/app/op-log/sync/conflict-resolution.service.ts`, lines 805–873) has + branches for adapter / singleton / map / array entities but **no `virtual` + branch** — it falls through and returns `undefined`. So the LWW local-win + path (`_createLocalWinUpdateOp`) cannot read the entity and produces no + replacement op. **LWW conflict resolution cannot resolve plugin-data + conflicts at all today.** Per-context entity ids alone do not fix this; a + same-context concurrent edit still mis-resolves. Conflict resolution must + learn to read a virtual entity from `selectPluginUserDataFeatureState`. + +**Payoff:** ~5x smaller payload per typical edit (~26–48 KB → ~6–9 KB). This is +a document-level "delta" — only the *edited document* is transmitted, never +sub-document. Concurrent edits to different contexts stop conflicting entirely. + +**Limits:** a single document is still sent whole on every keystroke-batch. +Concurrent edits to the *same* document still resolve whole-doc (LWW, once the +virtual branch exists). This is the realistic, moderate-effort win. + +### Option B — per-block sync entities + +Make each top-level block (paragraph, heading, list) its own keyed entity, so a +one-paragraph edit transmits one paragraph. + +**What it needs:** stable per-node ids — a TipTap unique-id extension that +assigns and preserves an id across edits. Split, merge, and reorder of blocks +each touch multiple entities (a paragraph split = one update + one create; a +merge = one update + one delete), and the ordering of blocks becomes its own +synced structure. + +**Assessment:** this is **essentially reinventing a block-level CRDT** — stable +identity, structural ops, ordering — but without the convergence guarantees a +real CRDT gives for free. The split/merge/reorder bookkeeping is exactly the +hard part of CRDTs, done by hand. **Not recommended.** If sub-document +granularity is wanted, go straight to Option C, which solves identity and +ordering correctly and gives finer granularity for the same conceptual cost. + +### Option C — Yjs CRDT (true edit-level deltas) + +Integrate `y-prosemirror`. Yjs models the document as a CRDT and emits a small +**binary update** per edit. These updates are **commutative** — they compose +correctly under *any* order — which is exactly the property the op-log's partial +ordering requires and that text/JSON patches lack. + +**How it maps onto the op-log:** + +- Each Yjs update becomes an **append-only** op (`opType: Create`) — never an + Update-that-replaces. The op-log already handles append-only creates well. +- **Conflict resolution becomes a no-op** for these ops: a CRDT converges by + construction, so there is no conflict to resolve. The partial-order / + `SYNC_IMPORT`-drops-concurrent problem disappears — Yjs updates are designed + to be applied in any order, including after gaps. +- **Op-log compaction** snapshots the document via `Y.encodeStateAsUpdate(doc)` + — a single binary blob that supersedes all prior update ops, the CRDT + equivalent of a state snapshot. + +**Cost:** + +- A new dependency (`yjs` + `y-prosemirror`). +- `persistDataSynced` is **replace-only**; Yjs needs an **append** primitive — + a new plugin API such as `appendSyncedDelta(bytes)`. This is a genuinely new + persistence channel, not a parameter addition. +- An op-log path that treats these ops as commutative create-only ops exempt + from conflict detection. + +**Bonus:** Yjs also enables **real-time concurrent editing of the same +document** — currently an explicit Non-goal of the sibling doc. It is the *only* +option here that does. + +**Assessment:** the correct long-term answer and the only path to true +edit-level deltas. But a CRDT sync channel is a significant architecture +initiative — disproportionate for an opt-in POC plugin *today*. It becomes the +right investment if/when real-time co-editing becomes a product goal, or the +plugin ships bundled-by-default and document sync volume matters at scale. + +## Recommendation + +A staged path, each stage independently shippable: + +1. **Now — Phase 1 (bare-atom chips).** Already planned and being implemented + separately (sibling doc). ~46% smaller blob, no schema break, no host change. +2. **Mid-term — Option A (per-context entities).** The realistic document-level + "delta": moderate effort, ~5x payload reduction, and it *also* fixes the + currently-broken plugin-data conflict resolution (virtual-entity LWW). Pick + this up when document mode ships more widely or cross-context conflicts are + observed. +3. **Long-term / conditional — Option C (Yjs).** The only path to true + edit-level deltas; subsumes the conflict problem entirely and unlocks + real-time co-editing. Justified once co-editing is a goal or sync volume at + scale demands it. **Skip Option B** — it is Option C's hard parts without its + guarantees. + +## Risks + +| Risk | Mitigation | +| --- | --- | +| Option A's per-key rate-limit / size-cap weakens the per-plugin flood guard (`MIN_PLUGIN_PERSIST_INTERVAL_MS`, `MAX_PLUGIN_DATA_SIZE` become per-key) | Keep an additional per-*plugin* aggregate cap; see sibling doc's Future-work item 4. | +| Option A uninstall cleanup — `removePluginUserData(pluginId)` deletes only the exact id; `pluginId:*` entries leak | Make deletion prefix-aware (sibling doc Future-work item 5). | +| Option A migration — splitting the legacy single blob into per-key entities | One-time, idempotent, guarded on a meta key's existence (sibling doc Future-work item 7). | +| Option C dependency size / bundle weight | `yjs` + `y-prosemirror` are compact and tree-shakeable; the plugin is opt-in so it does not affect the core bundle for non-users. | +| Option C — exempting CRDT ops from conflict detection could mask a real bug if the wrong ops are routed there | Gate strictly on `entityType` + a dedicated `opType`/actionType; never a general "skip conflict detection" flag. | +| Option C op-log growth before compaction (one op per edit) | Compaction snapshots `Y.encodeStateAsUpdate`; tune `COMPACTION_THRESHOLD` for the higher op rate. | + +## Open questions + +1. Does Option A need to land before Option C, or can Option C replace the + blob entirely in one step? (Likely A first — it is lower-risk and the keyed + API it adds is reusable; but C does not strictly depend on A.) +2. Should the keyed plugin-persistence API (Option A item 1) be designed up + front to also accommodate Option C's `appendSyncedDelta`, so plugins get one + coherent persistence surface rather than two bolt-ons? +3. Is real-time co-editing a desired product direction at all? The answer + decides whether Option C is "long-term" or "never". diff --git a/docs/plans/2026-05-22-document-mode-sync-data-model.md b/docs/plans/2026-05-22-document-mode-sync-data-model.md new file mode 100644 index 0000000000..68503fa77b --- /dev/null +++ b/docs/plans/2026-05-22-document-mode-sync-data-model.md @@ -0,0 +1,207 @@ +# Document Mode — slimming the sync data model + +**Status:** proposal, revised after multi-review +**Date:** 2026-05-22 +**Branch:** `feat/how-fat-is-data-model-for-sync-for-new-fbd044` +**Follows:** [`2026-05-21-document-mode-tiptap-plugin.md`](./2026-05-21-document-mode-tiptap-plugin.md) +(the POC, which deliberately deferred a "keyed `persistDataSynced` API" — see its Limitations §) + +## Problem + +The document-mode plugin persists a **single blob** via `persistDataSynced`, +stored host-side as one `PluginUserData` entry keyed by **plugin id**: + +```jsonc +{ "version": 1, + "docs": { "": , ... }, // one doc per project/tag/TODAY + "enabledCtxIds": ["..."] } +``` + +Each save → `upsertPluginUserData` → **one op** (`entityType: PLUGIN_USER_DATA`, +`entityId: pluginId`, `opType: Update`) whose payload embeds the *entire* `data` +string. Three problems compound: + +1. **Every op carries every context.** Typing one character in TODAY's doc + emits an op containing TODAY + every project doc + every tag doc. Throttled + ~once / 2 s while typing (`SAVE_THROTTLE_MS`; the host additionally coalesces + to ≤ 1 commit/s via `MIN_PLUGIN_PERSIST_INTERVAL_MS`). Hard cap 1 MB + (`MAX_PLUGIN_DATA_SIZE`) — above it the write throws. The op-log retains up + to `COMPACTION_THRESHOLD = 500` ops over a 7-day window, so each fat blob is + re-stored in IndexedDB and re-synced many times before compaction. + +2. **Each chip stores a redundant copy of the task title.** A `taskRef` / + `subTaskRef` chip persists the task title as inline text content plus an + `isDone` attr. But on load, `prepareStoredDoc` → `migrateStoredDoc` + + `refreshChipContentFromCache` **discard** the stored title/`isDone` and + re-derive both from the live task cache; the chip NodeView likewise "trusts + `task.isDone`, not the attr" (`task-ref-node.ts`). The stored title is dead + weight in the synced payload — often the byte-heavy, variable-length part of + a doc. Chip identity, order, and subtask membership are equally derived + (rebuilt from `ctx.taskIds` / `subTaskIds`; reorders round-trip through the + host — `reorderTasks` for PROJECT contexts, `ctx.taskIds` re-sort for + TODAY/TAG). So chips are reconstructable; only the **prose between them** is + plugin-owned. + +3. **Concurrent edits do not resolve cleanly.** `entityId` is the *plugin id*, + so all N documents collapse into one sync entity: device A editing project X + and device B editing project Y produce `CONCURRENT` vector clocks on the + *same entity* → a conflict, even though they touched different documents. + Worse, `PLUGIN_USER_DATA` is registered as a **`virtual`** entity + (`entity-registry.ts`), and `ConflictResolutionService.getCurrentEntityState` + has **no `virtual` branch** — it returns `undefined`. So the LWW local-win + path (`_createLocalWinUpdateOp`) cannot read the entity and produces no + replacement op. LWW does not function correctly for `PLUGIN_USER_DATA`; + concurrent edits lose data, and not by a predictable last-writer-wins rule. + + *Note:* even today a conflict that drops the blob only loses **prose** — on + reload chips are rebuilt from the host regardless. Problem 3 is therefore a + correctness gap, separate from problems 1–2 (size). + +## Goals + +1. Shrink the synced payload by removing the data the plugin redundantly stores. +2. No schema break — keep the change readable by both old and new clients. +3. No regression in load behaviour (chip order, prose anchoring, subtask + backfill, stale-chip handling all already covered by `doc-transform.spec.ts`). + +## Non-goals + +- **Fixing problem 3 now.** It needs host-side work (per-context entities *and* + virtual-entity LWW support) and is deferred — see Future work. +- **Fine-grained concurrent editing of the same doc.** Two devices editing the + *same* doc's prose will always resolve whole-doc; character-level merge needs + a CRDT (Yjs) and is out of scope. +- Removing the in-tree `src/app/features/document-mode/` feature. + +## Phase 1 — strip redundant chip content on save (plugin-local) + +The smallest change that fixes problems 1 & 2: stop persisting the title text +and `isDone` attr on chips. Store each chip as a **bare identity atom**: + +```jsonc +{ "type": "taskRef", "attrs": { "taskId": "" } } // no content, no isDone +``` + +The persisted doc stays an ordinary ProseMirror doc (`type: "doc"`, chips + +prose interleaved) — only the chip nodes get lighter. + +### Why this needs no schema bump and no migration + +`migrateStoredDoc` was *built* to load atom-shaped chips ("Older docs stored +taskRef as an atom node (no `content` array)") — it backfills `content` from the +task cache and defaults `isDone`. `refreshChipContentFromCache` then overwrites +both unconditionally. So a bare-atom chip flows through the **existing, +unchanged** load pipeline correctly, and the change is **bidirectionally +compatible**: + +- A **v1 client** reading a Phase-1 blob: `migrateStoredDoc` backfills the + stripped chips — loads fine. No future-version guard tripped. +- A Phase-1 client reading a **legacy** blob: content-bearing chips pass through + `migrateStoredDoc` (`hasContent` true) and are refreshed as before. + +`STORAGE_VERSION` stays `1`. No per-entry migration, no cross-version handling, +no `background.ts` change. + +### The strip is applied only to the serialized copy + +`stripChipContent` operates on a **copy** — `editor.getJSON()` returns a fresh +object each call. The live `editor` document keeps its inline chip content, so +the title-editing path (`reconcileTitlesFromDoc`, which reads the *live* doc) is +untouched. Only the bytes written to storage shrink. + +### Files + +| File | Change | +| --- | --- | +| `src/doc-transform.ts` | Add pure `stripChipContent(doc): unknown` — walk content, replace each `taskRef`/`subTaskRef` node with `{ type, attrs: { taskId } }`, leave every other node (paragraphs, headings, lists — their text!) untouched. | +| `src/ui/editor.ts` | `flushSave` **and** `flushSaveSync` persist `stripChipContent(editor.getJSON())` instead of `editor.getJSON()`. No other change. | +| `src/doc-transform.spec.ts` | Test `stripChipContent` (chips emptied, prose intact); round-trip test (`stripChipContent` → `prepareStoredDoc` rebuilds full chips with titles); legacy content-bearing doc still loads. | +| `src/background.ts` | No change — it treats `docs` as opaque and only writes `enabledCtxIds`. | + +### Expected reduction + +A content-bearing chip with a typical title serialises to ~120–140 bytes; a +bare-atom chip is ~60–65 bytes (`taskId` is a 21-char nanoid). For a task-heavy +context (~60 chips) that is ~5 KB saved per doc; for a typical 5-context user +the per-op blob drops roughly 20 KB → ~12 KB. Multiplied across the op-log +retention window (up to 500 ops), that is a meaningful cut to IndexedDB volume +and sync transfer. Op *count* is unchanged (the throttles are untouched) — this +is purely a per-op *size* reduction. + +### Alternative considered — prose-only storage (rejected for now) + +A heavier option drops chips from storage entirely, persisting only +`{ leading, anchored }` prose blocks and regenerating chips on load. It saves a +further ~7 KB/op for the 5-context user, but needs two new transform functions, +a parallel data structure, a `STORAGE_VERSION` bump, per-entry v1→v2 migration, +cross-version handling, and a `background.ts` version-constant fix. For an +opt-in POC plugin that is disproportionate. Phase 1 (bare-atom chips) captures +the bulk of the win at a fraction of the surface; prose-only can be revisited +if telemetry shows the blob is still too fat. + +--- + +## Future work — per-context sync entities (host change, deferred) + +> Expanded into a dedicated architecture proposal: +> [`2026-05-22-document-mode-delta-sync.md`](./2026-05-22-document-mode-delta-sync.md) +> — covers the full delta-sync granularity spectrum (per-context, per-block, +> Yjs CRDT). The summary below remains accurate for problem 3's host-side scope. + +This is the fix for **problem 3**. Deferred, not scheduled: document mode is an +opt-in POC plugin, not bundled by default, so cross-context concurrent edits are +rare. Pick this up when the plugin ships widely or the conflict is observed. + +It is **larger than "add a `key` parameter"** — the multi-review surfaced the +full scope: + +1. **Plugin API** — add an optional `key` to `persistDataSynced` / + `loadSyncedData`, threaded through the *entire* chain: `plugin-api/types.ts`, + `plugin-bridge.service.ts`, the iframe wrapper (`plugin-api.ts`), and the + iframe postMessage util (`plugin-iframe.util.ts`) — which currently drops a + second argument. +2. **Composite entity id** — `PluginUserData.id` becomes `pluginId:key` so each + `(plugin, context)` is its own sync entity; concurrent edits to different + contexts stop conflicting. +3. **Virtual-entity LWW** — *required, not optional.* Per-context entity ids do + **not** by themselves fix problem 3: `getCurrentEntityState` still has no + `virtual` branch, so same-context conflicts still mis-resolve. Conflict + resolution must learn to read a virtual entity (`PLUGIN_USER_DATA`) from + `selectPluginUserDataFeatureState`. +4. **Rate-limit & size-cap semantics** — `PluginUserPersistenceService` keys its + coalesce/throttle Maps by id; per-key keys weaken the per-plugin flood guard + (`MIN_PLUGIN_PERSIST_INTERVAL_MS`) and make `MAX_PLUGIN_DATA_SIZE` per-key. + Keep an additional per-*plugin* aggregate cap so a many-keyed plugin cannot + bypass the limits. +5. **Uninstall cleanup** — `removePluginUserData(pluginId)` deletes only the + exact id; keyed entries `pluginId:*` would leak. Make deletion prefix-aware. +6. **`background.ts`** must move off the keyless API (e.g. `key: 'meta'` for + `enabledCtxIds`) or it desyncs from the editor's meta entity. +7. **Migration** — split the legacy single blob into per-key entities, one-time + and idempotent (guard on the meta key's existence). + +Residual after this work: concurrent edits to the **same** context still resolve +whole-doc — acceptable per Non-goals. + +--- + +## Risks + +| Risk | Mitigation | +| --- | --- | +| `migrateStoredDoc` / `refreshChipContentFromCache` stop backfilling → stripped chips render empty | Both are existing load-pipeline invariants with spec coverage; add a round-trip test that a stripped doc rebuilds full chips. | +| Strip accidentally mutates the live editor doc or non-chip text | `stripChipContent` is pure and runs on the `getJSON()` copy; only `taskRef`/`subTaskRef` nodes are altered. Unit-test both. | +| A chip stripped to empty content is written back to the host as a title erasure | Cannot happen — write-back (`reconcileTitlesFromDoc`) reads the *live* editor doc, which always has refreshed content; only the storage copy is stripped. | + +## Testing + +- `npm --prefix packages/plugin-dev/document-mode test` (esbuild + `node --test`). +- `npm run test:file packages/plugin-dev/document-mode/src/doc-transform.spec.ts`. +- Manual: type prose + toggle done states, reload, switch context and back — + chips show correct titles/done state, prose keeps its position. + +## Open question + +1. Phase 1 only, or schedule the Future-work block? Recommendation: Phase 1 now + (low-risk, no schema break); treat Future work as a documented known + limitation until the plugin is bundled by default.