From ed26c2a9ff94e47bf5566e05ff4e7922827217f3 Mon Sep 17 00:00:00 2001 From: Lane Sawyer Date: Fri, 10 Jul 2026 02:02:59 -0700 Subject: [PATCH 01/25] fix(project): carry sections forward when duplicating a project (#8872) * fix(project): carry sections forward when duplicating a project Duplicating a project recreated its tasks and notes but never touched the section store, so the copy came up with a flat, uncategorized task list. Duplicate the template's sections too, remapping each section's taskIds through an old->new task id map so tasks keep their section membership in the copy. Fixes #8293 Co-Authored-By: Claude Opus 4.8 * Address PR feedback --------- Co-authored-by: Claude Opus 4.8 --- .../features/project/project.service.spec.ts | 79 +++++++++++++++++++ src/app/features/project/project.service.ts | 42 +++++++++- 2 files changed, 119 insertions(+), 2 deletions(-) diff --git a/src/app/features/project/project.service.spec.ts b/src/app/features/project/project.service.spec.ts index cc64fa8181..ce0c566c30 100644 --- a/src/app/features/project/project.service.spec.ts +++ b/src/app/features/project/project.service.spec.ts @@ -19,6 +19,8 @@ import { WorkContextType } from '../work-context/work-context.model'; import { T } from '../../t.const'; import { selectNoteFeatureState } from '../note/store/note.reducer'; import { NoteState } from '../note/note.model'; +import { selectSectionFeatureState } from '../section/store/section.selectors'; +import { SectionState } from '../section/section.model'; import { DateService } from '../../core/date/date.service'; import { selectUnarchivedProjects, @@ -88,6 +90,45 @@ describe('ProjectService', () => { }, todayOrder: [], }; + + const initialSectionState: SectionState = { + ids: ['section-1', 'section-2', 'section-other', 'section-today'], + entities: { + 'section-1': { + id: 'section-1', + contextId: 'project-1', + contextType: WorkContextType.PROJECT, + title: 'Section 1', + isExpanded: true, + taskIds: ['task-1'], + }, + 'section-2': { + id: 'section-2', + contextId: 'project-1', + contextType: WorkContextType.PROJECT, + title: 'Section 2', + isExpanded: true, + taskIds: ['task-2'], + }, + // Foreign-context sections must NOT be copied when duplicating project-1 + 'section-other': { + id: 'section-other', + contextId: 'project-2', + contextType: WorkContextType.PROJECT, + title: 'Other Project Section', + isExpanded: true, + taskIds: [], + }, + 'section-today': { + id: 'section-today', + contextId: 'TODAY', + contextType: WorkContextType.TAG, + title: 'Today Section', + isExpanded: true, + taskIds: [], + }, + }, + }; /* eslint-enable @typescript-eslint/naming-convention */ beforeEach(() => { @@ -191,6 +232,7 @@ describe('ProjectService', () => { store = TestBed.inject(Store) as MockStore; store.overrideSelector(selectTaskFeatureState, initialTaskState); store.overrideSelector(selectNoteFeatureState, initialNoteState); + store.overrideSelector(selectSectionFeatureState, initialSectionState); }); afterEach(() => { @@ -379,6 +421,43 @@ describe('ProjectService', () => { expect(addNoteCalls.length).toBe(1); expect((addNoteCalls[0][0] as any).note.isPinnedToToday).toBe(false); })); + + it('should duplicate sections and remap their task membership', fakeAsync(() => { + const project = createProject({ + id: 'project-1', + title: 'Project 1', + taskIds: ['task-1', 'task-2'], + }); + spyOn(service, 'getByIdOnce$').and.returnValue(of(project)); + const dispatchSpy = spyOn(store, 'dispatch').and.callThrough(); + service.duplicateProject('project-1'); + tick(); + const addSectionCalls = dispatchSpy.calls + .allArgs() + .filter((args: any) => args[0]?.type === '[Section] Add Section'); + // Only project-1's sections — not project-2's or the TODAY section + expect(addSectionCalls.length).toBe(2); + const copiedTitles = addSectionCalls.map((args: any) => args[0].section.title); + expect(copiedTitles).not.toContain('Other Project Section'); + expect(copiedTitles).not.toContain('Today Section'); + // task-1 -> new-task-1 (first parent duplicated) + // task-2 -> new-task-3 (after task-1's subtask new-task-2) + expect((addSectionCalls[0][0] as any).section).toEqual( + jasmine.objectContaining({ + contextId: 'new-project-id', + contextType: WorkContextType.PROJECT, + title: 'Section 1', + taskIds: ['new-task-1'], + }), + ); + expect((addSectionCalls[1][0] as any).section).toEqual( + jasmine.objectContaining({ + contextId: 'new-project-id', + title: 'Section 2', + taskIds: ['new-task-3'], + }), + ); + })); }); describe('unarchive', () => { diff --git a/src/app/features/project/project.service.ts b/src/app/features/project/project.service.ts index 03caa373e7..05a955261d 100644 --- a/src/app/features/project/project.service.ts +++ b/src/app/features/project/project.service.ts @@ -48,6 +48,9 @@ import { sortByTitle } from '../../util/sort-by-title'; import { Note } from '../note/note.model'; import { selectNoteFeatureState } from '../note/store/note.reducer'; import { addNote } from '../note/store/note.actions'; +import { Section } from '../section/section.model'; +import { addSection } from '../section/store/section.actions'; +import { selectSectionsByContextIdMap } from '../section/store/section.selectors'; import { DialogConfirmComponent } from '../../ui/dialog-confirm/dialog-confirm.component'; import { LOCAL_ACTIONS } from '../../util/local-actions.token'; import { DateService } from '../../core/date/date.service'; @@ -502,9 +505,16 @@ export class ProjectService { const newNoteIds = this._duplicateNotesToProject(notesToCopy, newProjectId); this.update(newProjectId, { noteIds: newNoteIds }); - this._duplicateTasksToProject(parentTasks, newProjectId, false, taskState); + const sectionsMap = await firstValueFrom( + this._store$.select(selectSectionsByContextIdMap), + ); + const sectionsToCopy = sectionsMap.get(templateProjectId) ?? []; - this._duplicateTasksToProject(backlogTasks, newProjectId, true, taskState); + const taskIdMap = new Map(); + this._duplicateTasksToProject(parentTasks, newProjectId, false, taskState, taskIdMap); + this._duplicateTasksToProject(backlogTasks, newProjectId, true, taskState, taskIdMap); + + this._duplicateSectionsToProject(sectionsToCopy, newProjectId, taskIdMap); return newProjectId; } @@ -514,6 +524,7 @@ export class ProjectService { newProjectId: string, isBacklog: boolean, taskState: TaskState, + taskIdMap: Map, ): void { // For each parent task create a copy in the new project and then copy its subtasks for (const p of tasks) { @@ -541,6 +552,7 @@ export class ProjectService { workContextType: WorkContextType.PROJECT, workContextId: newProjectId, }); + taskIdMap.set(p.id, newParentTask.id); // dispatch addTask for the parent task this._store$.dispatch( @@ -580,6 +592,7 @@ export class ProjectService { workContextType: WorkContextType.PROJECT, workContextId: newProjectId, }); + taskIdMap.set(st.id, newSub.id); this._store$.dispatch(addSubTask({ task: newSub, parentId: newParentTask.id })); } @@ -607,4 +620,29 @@ export class ProjectService { } return newNoteIds; } + + private _duplicateSectionsToProject( + sections: Section[], + newProjectId: string, + taskIdMap: Map, + ): void { + for (const section of sections) { + const newTaskIds = section.taskIds + .map((id) => taskIdMap.get(id)) + .filter((id): id is string => !!id); + + this._store$.dispatch( + addSection({ + section: { + id: nanoid(), + contextId: newProjectId, + contextType: WorkContextType.PROJECT, + title: section.title, + isExpanded: section.isExpanded, + taskIds: newTaskIds, + }, + }), + ); + } + } } From 23b1d4adf0a13e0ab7c4798cc4fa11f09408f154 Mon Sep 17 00:00:00 2001 From: Rushikesh Barve <84287593+Rishi943@users.noreply.github.com> Date: Fri, 10 Jul 2026 05:38:38 -0400 Subject: [PATCH 02/25] fix(a11y): name icon-only buttons, add keyboard access, fix task tabindex (#8885) * fix(a11y): name icon-only buttons, add keyboard access, fix task tabindex Addresses the three groups from #8826: - Add translated aria-labels to unnamed icon-only buttons (search clear, note/side-nav/feature more_vert triggers, focus-mode close, board edit, track-time dialog suffix buttons); simple-counter buttons use the counter title as their accessible name. Adds one new global key G.MORE_ACTIONS. - Make mouse-only controls keyboard-accessible (role=button, tabindex=0, Enter/Space handlers, aria-expanded where they toggle): formly-collapsible header, worklog week day rows, task detail panel created/completed date editors, and the task time/estimate cell (attributes conditional so parents with subtasks gain no tab stop). - Change task host tabindex from 1 to 0 so tasks no longer jump ahead of all other page content in tab order (WCAG 2.4.3). Fixes #8826 Co-Authored-By: Claude Fable 5 * fix(a11y): import TranslatePipe in focus-mode-overlay FocusModeOverlayComponent's template uses the translate pipe (added aria-label) but the standalone component did not import TranslatePipe, breaking the production AOT build (NG8004). Add it to imports, matching every other component touched in this change. --------- Co-authored-by: Claude Fable 5 Co-authored-by: Johannes Millan --- .../magic-side-nav/nav-item/nav-item.component.html | 3 +++ .../boards/board-panel/board-panel.component.html | 1 + .../focus-mode-overlay/focus-mode-overlay.component.html | 1 + .../focus-mode-overlay/focus-mode-overlay.component.ts | 2 ++ .../dialog-track-time/dialog-track-time.component.html | 2 ++ src/app/features/note/note/note.component.html | 1 + .../simple-counter-button.component.html | 3 +++ .../task-detail-panel/task-detail-panel.component.html | 8 ++++++++ src/app/features/tasks/task/task.component.html | 7 +++++++ src/app/features/tasks/task/task.component.ts | 2 +- .../worklog/worklog-week/worklog-week.component.html | 5 +++++ src/app/pages/search-page/search-page.component.html | 1 + src/app/t.const.ts | 1 + .../formly-collapsible/formly-collapsible.component.html | 5 +++++ src/assets/i18n/en.json | 1 + 15 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/app/core-ui/magic-side-nav/nav-item/nav-item.component.html b/src/app/core-ui/magic-side-nav/nav-item/nav-item.component.html index 8c5ff5df7b..3694392bc6 100644 --- a/src/app/core-ui/magic-side-nav/nav-item/nav-item.component.html +++ b/src/app/core-ui/magic-side-nav/nav-item/nav-item.component.html @@ -38,6 +38,7 @@ #settingsBtn class="additional-btn" mat-icon-button + [attr.aria-label]="T.G.MORE_ACTIONS | translate" > more_vert @@ -81,6 +82,7 @@ #folderSettingsBtn class="additional-btn" mat-icon-button + [attr.aria-label]="T.G.MORE_ACTIONS | translate" > more_vert @@ -148,6 +150,7 @@ #featureSettingsBtn class="additional-btn" mat-icon-button + [attr.aria-label]="T.G.MORE_ACTIONS | translate" > more_vert diff --git a/src/app/features/boards/board-panel/board-panel.component.html b/src/app/features/boards/board-panel/board-panel.component.html index 2f21d1cd57..5954954d53 100644 --- a/src/app/features/boards/board-panel/board-panel.component.html +++ b/src/app/features/boards/board-panel/board-panel.component.html @@ -12,6 +12,7 @@ diff --git a/src/app/features/focus-mode/focus-mode-overlay/focus-mode-overlay.component.html b/src/app/features/focus-mode/focus-mode-overlay/focus-mode-overlay.component.html index 514baafb76..ea4afeca36 100644 --- a/src/app/features/focus-mode/focus-mode-overlay/focus-mode-overlay.component.html +++ b/src/app/features/focus-mode/focus-mode-overlay/focus-mode-overlay.component.html @@ -9,6 +9,7 @@ class="close-btn" mat-icon-button (click)="closeOverlay()" + [attr.aria-label]="T.G.CLOSE | translate" > close diff --git a/src/app/features/focus-mode/focus-mode-overlay/focus-mode-overlay.component.ts b/src/app/features/focus-mode/focus-mode-overlay/focus-mode-overlay.component.ts index 08fe071017..cceb19b08d 100644 --- a/src/app/features/focus-mode/focus-mode-overlay/focus-mode-overlay.component.ts +++ b/src/app/features/focus-mode/focus-mode-overlay/focus-mode-overlay.component.ts @@ -18,6 +18,7 @@ import { FocusModeBreakComponent } from '../focus-mode-break/focus-mode-break.co import { FocusModeService } from '../focus-mode.service'; import { FocusScreen } from '../focus-mode.model'; import { isInputElement } from '../../../util/dom-element'; +import { TranslatePipe } from '@ngx-translate/core'; @Component({ selector: 'focus-mode-overlay', @@ -32,6 +33,7 @@ import { isInputElement } from '../../../util/dom-element'; FocusModeMainComponent, FocusModeSessionDoneComponent, FocusModeBreakComponent, + TranslatePipe, ], }) export class FocusModeOverlayComponent implements OnDestroy { diff --git a/src/app/features/issue/shared/dialog-track-time/dialog-track-time.component.html b/src/app/features/issue/shared/dialog-track-time/dialog-track-time.component.html index cefd094c4a..333441cd06 100644 --- a/src/app/features/issue/shared/dialog-track-time/dialog-track-time.component.html +++ b/src/app/features/issue/shared/dialog-track-time/dialog-track-time.component.html @@ -56,12 +56,14 @@ diff --git a/src/app/features/note/note/note.component.html b/src/app/features/note/note/note.component.html index e481780f36..77fcf385b0 100644 --- a/src/app/features/note/note/note.component.html +++ b/src/app/features/note/note/note.component.html @@ -58,6 +58,7 @@ diff --git a/src/app/features/simple-counter/simple-counter-button/simple-counter-button.component.html b/src/app/features/simple-counter/simple-counter-button/simple-counter-button.component.html index 47cded1e8d..47b45c154e 100644 --- a/src/app/features/simple-counter/simple-counter-button/simple-counter-button.component.html +++ b/src/app/features/simple-counter/simple-counter-button/simple-counter-button.component.html @@ -10,6 +10,7 @@ (longPress)="edit()" [color]="sc.isOn ? 'accent' : ''" [class.isOn]="sc.isOn" + [attr.aria-label]="sc.title" class="main-btn stopwatch" mat-icon-button > @@ -34,6 +35,7 @@ (click)="toggleCounter()" (contextmenu)="edit($event)" (longPress)="edit()" + [attr.aria-label]="sc.title" class="main-btn" color="" mat-icon-button @@ -68,6 +70,7 @@ [color]="sc.isOn && !isTimeUp() ? 'accent' : ''" [class.isOn]="sc.isOn && !isTimeUp()" [class.isTimeUp]="isTimeUp()" + [attr.aria-label]="sc.title" class="main-btn repeated-countdown" mat-icon-button > diff --git a/src/app/features/tasks/task-detail-panel/task-detail-panel.component.html b/src/app/features/tasks/task-detail-panel/task-detail-panel.component.html index dcd4c92dd8..2d10fbf578 100644 --- a/src/app/features/tasks/task-detail-panel/task-detail-panel.component.html +++ b/src/app/features/tasks/task-detail-panel/task-detail-panel.component.html @@ -361,6 +361,10 @@ class="edit-date-info" [title]="T.G.EDIT | translate" (click)="editCreated()" + (keydown.enter)="editCreated()" + (keydown.space)="$event.preventDefault(); editCreated()" + role="button" + tabindex="0" > Created on {{ task().created | localeDate: 'short' : undefined : locale() }} @@ -369,6 +373,10 @@ class="edit-date-info" [title]="T.G.EDIT | translate" (click)="editCompleted()" + (keydown.enter)="editCompleted()" + (keydown.space)="$event.preventDefault(); editCompleted()" + role="button" + tabindex="0" > Completed on {{ task().doneOn | localeDate: 'short' : undefined : locale() }} diff --git a/src/app/features/tasks/task/task.component.html b/src/app/features/tasks/task/task.component.html index 5b00b934bb..92d56f6c2e 100644 --- a/src/app/features/tasks/task/task.component.html +++ b/src/app/features/tasks/task/task.component.html @@ -56,6 +56,13 @@
diff --git a/src/app/pages/search-page/search-page.component.html b/src/app/pages/search-page/search-page.component.html index 0b288cfb79..0a27ec7b69 100644 --- a/src/app/pages/search-page/search-page.component.html +++ b/src/app/pages/search-page/search-page.component.html @@ -17,6 +17,7 @@ mat-icon-button matSuffix (click)="clearSearch()" + [attr.aria-label]="T.G.CLEAR | translate" > clear diff --git a/src/app/t.const.ts b/src/app/t.const.ts index b795c8e45b..2677f69717 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -2327,6 +2327,7 @@ const T = { ICON_INP_DESCRIPTION: 'G.ICON_INP_DESCRIPTION', INBOX_PROJECT_TITLE: 'G.INBOX_PROJECT_TITLE', MINUTES: 'G.MINUTES', + MORE_ACTIONS: 'G.MORE_ACTIONS', MOVE_BACKWARD: 'G.MOVE_BACKWARD', MOVE_FORWARD: 'G.MOVE_FORWARD', NEXT: 'G.NEXT', diff --git a/src/app/ui/formly-collapsible/formly-collapsible.component.html b/src/app/ui/formly-collapsible/formly-collapsible.component.html index 59e5b90aa6..c3400a7f15 100644 --- a/src/app/ui/formly-collapsible/formly-collapsible.component.html +++ b/src/app/ui/formly-collapsible/formly-collapsible.component.html @@ -1,5 +1,10 @@
expand_more diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index c51dd49868..eadb43d24c 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -2267,6 +2267,7 @@ "ICON_INP_DESCRIPTION": "All UTF-8 emojis are also supported!", "INBOX_PROJECT_TITLE": "Inbox", "MINUTES": "{{m}} minutes", + "MORE_ACTIONS": "More actions", "MOVE_BACKWARD": "Move backward", "MOVE_FORWARD": "Move forward", "NEXT": "Next", From d02f2271dad6361ba474c81e41724b8d041c3aa2 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 10 Jul 2026 11:39:17 +0200 Subject: [PATCH 03/25] fix(gitlab): link issues API doc for search help (#8884) (#8894) --- .../issue/mapping-helper/get-issue-provider-help-link.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/features/issue/mapping-helper/get-issue-provider-help-link.ts b/src/app/features/issue/mapping-helper/get-issue-provider-help-link.ts index 2c122078dd..eb89c5a37e 100644 --- a/src/app/features/issue/mapping-helper/get-issue-provider-help-link.ts +++ b/src/app/features/issue/mapping-helper/get-issue-provider-help-link.ts @@ -9,8 +9,11 @@ export const getIssueProviderHelpLink = ( // return 'https://support.atlassian.com/jira-service-management-cloud/docs/use-advanced-search-with-jira-query-language-jql/'; case 'GITHUB': return 'https://docs.github.com/en/search-github/searching-on-github/searching-issues-and-pull-requests'; + // NOTE: the GitLab search box hits the Issues API `search=` param (plain + // substring match), not Advanced Search — so link the issues endpoint, not + // the advanced-search syntax page (#8884). case 'GITLAB': - return 'https://docs.gitlab.com/ee/user/search/advanced_search.html'; + return 'https://docs.gitlab.com/api/issues/#list-project-issues'; // case 'GITEA': // case 'CALDAV': // case 'REDMINE': From 6bb0472549017ebdc8f9a089a027f4aff01ecf0e Mon Sep 17 00:00:00 2001 From: Lane Sawyer Date: Fri, 10 Jul 2026 02:40:35 -0700 Subject: [PATCH 04/25] chore(ui): removes dead components and features [#8260 - Tier A] (#8889) --- e2e/constants/selectors.ts | 2 +- e2e/utils/schedule-task-helper.ts | 2 +- scripts/remove-unused-log-imports.ts | 1 - .../mobile-side-panel-menu.component.scss | 55 ----- .../mobile-side-panel-menu.component.ts | 229 ------------------ .../theme/validated-color-palettes.const.ts | 196 --------------- .../dialog-edit-tags-for-task.component.html | 34 --- .../dialog-edit-tags-for-task.component.scss | 24 -- .../dialog-edit-tags-for-task.component.ts | 100 -------- .../dialog-edit-tags-for-task.payload.ts | 5 - .../dialog-task-detail-panel.component.html | 19 -- .../dialog-task-detail-panel.component.scss | 31 --- .../dialog-task-detail-panel.component.ts | 58 ----- src/app/plugins/ui/plugin-menu.component.ts | 69 ------ src/app/ui/help-box/help-box.component.html | 19 -- src/app/ui/help-box/help-box.component.scss | 33 --- src/app/ui/help-box/help-box.component.ts | 38 --- src/app/ui/stuck/stuck.directive.ts | 41 ---- .../ui/tree-dnd/tree-drop-zones.component.ts | 81 ------- 19 files changed, 2 insertions(+), 1035 deletions(-) delete mode 100644 src/app/core-ui/main-header/mobile-side-panel-menu/mobile-side-panel-menu.component.scss delete mode 100644 src/app/core-ui/main-header/mobile-side-panel-menu/mobile-side-panel-menu.component.ts delete mode 100644 src/app/core/theme/validated-color-palettes.const.ts delete mode 100644 src/app/features/tag/dialog-edit-tags/dialog-edit-tags-for-task.component.html delete mode 100644 src/app/features/tag/dialog-edit-tags/dialog-edit-tags-for-task.component.scss delete mode 100644 src/app/features/tag/dialog-edit-tags/dialog-edit-tags-for-task.component.ts delete mode 100644 src/app/features/tag/dialog-edit-tags/dialog-edit-tags-for-task.payload.ts delete mode 100644 src/app/features/tasks/dialog-task-detail-panel/dialog-task-detail-panel.component.html delete mode 100644 src/app/features/tasks/dialog-task-detail-panel/dialog-task-detail-panel.component.scss delete mode 100644 src/app/features/tasks/dialog-task-detail-panel/dialog-task-detail-panel.component.ts delete mode 100644 src/app/plugins/ui/plugin-menu.component.ts delete mode 100644 src/app/ui/help-box/help-box.component.html delete mode 100644 src/app/ui/help-box/help-box.component.scss delete mode 100644 src/app/ui/help-box/help-box.component.ts delete mode 100644 src/app/ui/stuck/stuck.directive.ts delete mode 100644 src/app/ui/tree-dnd/tree-drop-zones.component.ts diff --git a/e2e/constants/selectors.ts b/e2e/constants/selectors.ts index b0e961e3ec..d0206e7ce1 100644 --- a/e2e/constants/selectors.ts +++ b/e2e/constants/selectors.ts @@ -128,7 +128,7 @@ export const cssSelectors = { // TASK DETAIL PANEL SELECTORS // ============================================================================ RIGHT_PANEL: '.right-panel', - DETAIL_PANEL: 'dialog-task-detail-panel, task-detail-panel', + DETAIL_PANEL: 'task-detail-panel', DETAIL_PANEL_BTN: '.show-additional-info-btn', SCHEDULE_TASK_ITEM: 'task-detail-item:has(mat-icon:text("alarm")), task-detail-item:has(mat-icon:text("today")), task-detail-item:has(mat-icon:text("schedule"))', diff --git a/e2e/utils/schedule-task-helper.ts b/e2e/utils/schedule-task-helper.ts index 3401e2a69a..0249f484c7 100644 --- a/e2e/utils/schedule-task-helper.ts +++ b/e2e/utils/schedule-task-helper.ts @@ -4,7 +4,7 @@ import { fillTimeInput } from './time-input-helper'; // Selectors for scheduling const DETAIL_PANEL_BTN = '.show-additional-info-btn'; -const DETAIL_PANEL_SELECTOR = 'dialog-task-detail-panel, task-detail-panel'; +const DETAIL_PANEL_SELECTOR = 'task-detail-panel'; const DETAIL_PANEL_SCHEDULE_ITEM = 'task-detail-item:has(mat-icon:text("alarm")), ' + 'task-detail-item:has(mat-icon:text("today")), ' + diff --git a/scripts/remove-unused-log-imports.ts b/scripts/remove-unused-log-imports.ts index da9b7656b1..348065ee71 100644 --- a/scripts/remove-unused-log-imports.ts +++ b/scripts/remove-unused-log-imports.ts @@ -15,7 +15,6 @@ const filesToFix = [ 'src/app/features/schedule/map-schedule-data/insert-blocked-blocks-view-entries-for-schedule.ts', 'src/app/features/schedule/map-schedule-data/map-to-schedule-days.ts', 'src/app/features/task-repeat-cfg/sort-repeatable-task-cfg.ts', - 'src/app/features/tasks/dialog-task-detail-panel/dialog-task-detail-panel.component.ts', 'src/app/features/work-context/store/work-context-meta.helper.ts', 'src/app/root-store/index.ts', 'src/app/ui/duration/input-duration-formly/input-duration-formly.component.ts', diff --git a/src/app/core-ui/main-header/mobile-side-panel-menu/mobile-side-panel-menu.component.scss b/src/app/core-ui/main-header/mobile-side-panel-menu/mobile-side-panel-menu.component.scss deleted file mode 100644 index 6e74bfca33..0000000000 --- a/src/app/core-ui/main-header/mobile-side-panel-menu/mobile-side-panel-menu.component.scss +++ /dev/null @@ -1,55 +0,0 @@ -:host { - display: contents; -} - -// Mobile dropdown wrapper -.mobile-dropdown-wrapper { - position: relative; -} - -// Mobile dropdown -.mobile-dropdown { - position: absolute; - display: flex; - transition: var(--transition-standard); - top: 100%; - flex-direction: column; - left: 50%; - transform: translateX(-50%); - z-index: 2; - pointer-events: none; - - &.isVisible { - pointer-events: all; - } - - button { - transition: var(--transition-standard); - transform: translateY(-100%); - opacity: 0; - position: relative; - z-index: 2; - margin-top: var(--s); - margin-left: 0; - - @for $i from 2 through 7 { - &:nth-child(#{$i}) { - transform: translateY(#{$i * -100%}); - } - } - } - - &.isVisible button { - transform: translateY(0); - opacity: 1; - } -} - -// Active button styles -button.active { - background-color: var(--sidenav-bg); - - &.isCustomized { - background-color: var(--c-accent); - } -} diff --git a/src/app/core-ui/main-header/mobile-side-panel-menu/mobile-side-panel-menu.component.ts b/src/app/core-ui/main-header/mobile-side-panel-menu/mobile-side-panel-menu.component.ts deleted file mode 100644 index 5899a61041..0000000000 --- a/src/app/core-ui/main-header/mobile-side-panel-menu/mobile-side-panel-menu.component.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { - ChangeDetectionStrategy, - Component, - computed, - inject, - signal, -} from '@angular/core'; -import { MatIconModule } from '@angular/material/icon'; -import { MatButtonModule } from '@angular/material/button'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { MatMenuModule } from '@angular/material/menu'; -import { TranslateModule } from '@ngx-translate/core'; -import { LayoutService } from '../../layout/layout.service'; -import { TaskViewCustomizerService } from '../../../features/task-view-customizer/task-view-customizer.service'; -import { TaskViewCustomizerPanelComponent } from '../../../features/task-view-customizer/task-view-customizer-panel/task-view-customizer-panel.component'; -import { T } from '../../../t.const'; -import { KeyboardConfig } from '@sp/keyboard-config'; -import { GlobalConfigService } from '../../../features/config/global-config.service'; -import { Store } from '@ngrx/store'; -import { - selectActivePluginId, - selectIsShowPluginPanel, -} from '../../layout/store/layout.reducer'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { PluginBridgeService } from '../../../plugins/plugin-bridge.service'; -import { PluginIconComponent } from '../../../plugins/ui/plugin-icon/plugin-icon.component'; -import { togglePluginPanel } from '../../layout/store/layout.actions'; -import { NavigationEnd, Router } from '@angular/router'; -import { filter, map, startWith } from 'rxjs/operators'; -import { BreakpointObserver } from '@angular/cdk/layout'; - -@Component({ - selector: 'mobile-side-panel-menu', - standalone: true, - imports: [ - MatIconModule, - MatButtonModule, - MatTooltipModule, - MatMenuModule, - TranslateModule, - PluginIconComponent, - TaskViewCustomizerPanelComponent, - ], - template: ` -
- - -
- - @for (button of sidePanelButtons(); track button.pluginId) { - - } - - - - - - - @if (isIssuesPanelEnabled()) { - - - } - - @if (isProjectNotesEnabled()) { - - - } -
-
- `, - styleUrls: ['./mobile-side-panel-menu.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class MobileSidePanelMenuComponent { - readonly T = T; - readonly layoutService = inject(LayoutService); - readonly taskViewCustomizerService = inject(TaskViewCustomizerService); - readonly breakpointObserver = inject(BreakpointObserver); - - private _globalConfigService = inject(GlobalConfigService); - private _pluginBridge = inject(PluginBridgeService); - private _store = inject(Store); - private _router = inject(Router); - - // State signals - readonly isShowMobileMenu = signal(false); - - // Convert observables to signals - readonly isRouteWithSidePanel = toSignal( - this._router.events.pipe( - filter((event): event is NavigationEnd => event instanceof NavigationEnd), - map((event) => true), // Always true since right-panel is now global - startWith(true), // Always true since right-panel is now global - ), - { initialValue: true }, - ); - - readonly isWorkViewPage = toSignal( - this._router.events.pipe( - filter((event): event is NavigationEnd => event instanceof NavigationEnd), - map((event) => !!event.urlAfterRedirects.match(/tasks$/)), - startWith(!!this._router.url.match(/tasks$/)), - ), - { initialValue: !!this._router.url.match(/tasks$/) }, - ); - - readonly kb: KeyboardConfig = this._globalConfigService.cfg()?.keyboard || {}; - - // Plugin-related signals - readonly sidePanelButtons = this._pluginBridge.sidePanelButtons; - readonly activePluginId = toSignal(this._store.select(selectActivePluginId)); - readonly isShowPluginPanel = toSignal(this._store.select(selectIsShowPluginPanel)); - - // Navigation signals - readonly currentRoute = toSignal( - this._router.events.pipe( - filter((event) => event instanceof NavigationEnd), - map(() => this._router.url), - ), - { initialValue: this._router.url }, - ); - - readonly isWorkView = computed(() => { - const url = this.currentRoute(); - return url.includes('/active/') || url.includes('/tag/') || url.includes('/project/'); - }); - - // Panel state signals - readonly isShowNotes = computed(() => this.layoutService.isShowNotes()); - readonly isShowIssuePanel = computed(() => this.layoutService.isShowIssuePanel()); - readonly isIssuesPanelEnabled = computed( - () => this._globalConfigService.appFeatures().isIssuesPanelEnabled, - ); - readonly isProjectNotesEnabled = computed( - () => this._globalConfigService.appFeatures().isProjectNotesEnabled, - ); - readonly isShowTaskViewCustomizerPanel = computed(() => - this.layoutService.isShowTaskViewCustomizerPanel(), - ); - - // Computed signal for active panel - readonly hasActivePanel = computed(() => { - return !!( - this.isShowNotes() || - this.isShowIssuePanel() || - this.isShowTaskViewCustomizerPanel() || - (this.activePluginId() && this.isShowPluginPanel()) - ); - }); - - toggleMenu(): void { - this.isShowMobileMenu.update((v) => !v); - } - - onPluginButtonClick(button: { - pluginId: string; - onClick?: () => void; - label?: string; - icon?: string; - }): void { - this._store.dispatch(togglePluginPanel(button.pluginId)); - - if (button.onClick) { - button.onClick(); - } - - // Close mobile menu after action - this.isShowMobileMenu.set(false); - } - - toggleIssuePanel(): void { - this.layoutService.toggleAddTaskPanel(); - this.isShowMobileMenu.set(false); - } - - toggleNotes(): void { - this.layoutService.toggleNotes(); - this.isShowMobileMenu.set(false); - } -} diff --git a/src/app/core/theme/validated-color-palettes.const.ts b/src/app/core/theme/validated-color-palettes.const.ts deleted file mode 100644 index 87ceb21926..0000000000 --- a/src/app/core/theme/validated-color-palettes.const.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { WorkContextThemeCfg } from '../../features/work-context/work-context.model'; - -/** - * Pre-validated color palette that meets WCAG AA contrast standards (4.5:1) - * for both light and dark themes when used on standard backgrounds. - */ -export interface ValidatedColorPalette { - /** Display name for the palette */ - name: string; - /** Theme configuration with validated colors */ - theme: WorkContextThemeCfg; - /** Description for UI display */ - description?: string; -} - -/** - * Collection of pre-validated color palettes. - * All palettes are tested to meet WCAG AA contrast standards (4.5:1 ratio) - * on both light (#f8f8f7) and dark (#131314) backgrounds. - */ -export const VALIDATED_COLOR_PALETTES: readonly ValidatedColorPalette[] = [ - { - name: 'Purple & Pink (Default)', - description: 'The classic Super Productivity theme', - theme: { - isAutoContrast: true, - isDisableBackgroundTint: false, - primary: '#8b4a9d', // Darker purple for better contrast (was #a05db1) - huePrimary: '500', - accent: '#d81b60', // Darker pink for better contrast (was #ff4081) - hueAccent: '500', - warn: '#c62828', // Darker red for better contrast (was #e11826) - hueWarn: '500', - backgroundImageDark: null, - backgroundImageLight: null, - }, - }, - { - name: 'Ocean Blue', - description: 'Professional blue tones', - theme: { - isAutoContrast: true, - isDisableBackgroundTint: false, - primary: '#1976d2', // Material Blue 700 - huePrimary: '500', - accent: '#0277bd', // Light Blue 800 - hueAccent: '500', - warn: '#c62828', // Red 800 - hueWarn: '500', - backgroundImageDark: null, - backgroundImageLight: null, - }, - }, - { - name: 'Forest Green', - description: 'Calming natural greens', - theme: { - isAutoContrast: true, - isDisableBackgroundTint: false, - primary: '#388e3c', // Green 700 - huePrimary: '500', - accent: '#00796b', // Teal 700 - hueAccent: '500', - warn: '#d32f2f', // Red 700 - hueWarn: '500', - backgroundImageDark: null, - backgroundImageLight: null, - }, - }, - { - name: 'Sunset Orange', - description: 'Warm and energetic', - theme: { - isAutoContrast: true, - isDisableBackgroundTint: false, - primary: '#e64a19', // Deep Orange 700 - huePrimary: '500', - accent: '#f57c00', // Orange 700 - hueAccent: '500', - warn: '#c62828', // Red 800 - hueWarn: '500', - backgroundImageDark: null, - backgroundImageLight: null, - }, - }, - { - name: 'Deep Teal', - description: 'Modern and sophisticated', - theme: { - isAutoContrast: true, - isDisableBackgroundTint: false, - primary: '#00796b', // Teal 700 - huePrimary: '500', - accent: '#0097a7', // Cyan 700 - hueAccent: '500', - warn: '#c62828', // Red 800 - hueWarn: '500', - backgroundImageDark: null, - backgroundImageLight: null, - }, - }, - { - name: 'Ruby Red', - description: 'Bold and striking', - theme: { - isAutoContrast: true, - isDisableBackgroundTint: false, - primary: '#c62828', // Red 800 - huePrimary: '500', - accent: '#ad1457', // Pink 800 - hueAccent: '500', - warn: '#d84315', // Deep Orange 800 - hueWarn: '500', - backgroundImageDark: null, - backgroundImageLight: null, - }, - }, - { - name: 'Indigo Night', - description: 'Deep and focused', - theme: { - isAutoContrast: true, - isDisableBackgroundTint: false, - primary: '#283593', // Indigo 800 - huePrimary: '500', - accent: '#303f9f', // Indigo 700 - hueAccent: '500', - warn: '#c62828', // Red 800 - hueWarn: '500', - backgroundImageDark: null, - backgroundImageLight: null, - }, - }, - { - name: 'Amber Glow', - description: 'Bright and cheerful', - theme: { - isAutoContrast: true, - isDisableBackgroundTint: false, - primary: '#f57f17', // Yellow 800 - huePrimary: '500', - accent: '#ef6c00', // Orange 800 - hueAccent: '500', - warn: '#c62828', // Red 800 - hueWarn: '500', - backgroundImageDark: null, - backgroundImageLight: null, - }, - }, - { - name: 'Slate Gray', - description: 'Neutral and elegant', - theme: { - isAutoContrast: true, - isDisableBackgroundTint: false, - primary: '#455a64', // Blue Gray 700 - huePrimary: '500', - accent: '#546e7a', // Blue Gray 600 - hueAccent: '500', - warn: '#c62828', // Red 800 - hueWarn: '500', - backgroundImageDark: null, - backgroundImageLight: null, - }, - }, - { - name: 'Lime Fresh', - description: 'Vibrant and lively', - theme: { - isAutoContrast: true, - isDisableBackgroundTint: false, - primary: '#689f38', // Light Green 700 - huePrimary: '500', - accent: '#7cb342', // Light Green 600 - hueAccent: '500', - warn: '#c62828', // Red 800 - hueWarn: '500', - backgroundImageDark: null, - backgroundImageLight: null, - }, - }, -] as const; - -/** - * Get a validated palette by name - */ -export const getValidatedPalette = (name: string): ValidatedColorPalette | undefined => { - return VALIDATED_COLOR_PALETTES.find((p) => p.name === name); -}; - -/** - * Get the default validated palette - */ -export const getDefaultValidatedPalette = (): ValidatedColorPalette => { - return VALIDATED_COLOR_PALETTES[0]; -}; diff --git a/src/app/features/tag/dialog-edit-tags/dialog-edit-tags-for-task.component.html b/src/app/features/tag/dialog-edit-tags/dialog-edit-tags-for-task.component.html deleted file mode 100644 index 39c61b70b7..0000000000 --- a/src/app/features/tag/dialog-edit-tags/dialog-edit-tags-for-task.component.html +++ /dev/null @@ -1,34 +0,0 @@ -

- {{ - isEdit - ? (T.F.TAG.D_EDIT.EDIT | translate: { title: title }) - : (T.F.TAG.D_EDIT.ADD | translate: { title: title }) - }} -

- - -

- -
- -
-
- - - - diff --git a/src/app/features/tag/dialog-edit-tags/dialog-edit-tags-for-task.component.scss b/src/app/features/tag/dialog-edit-tags/dialog-edit-tags-for-task.component.scss deleted file mode 100644 index 3ff81a562c..0000000000 --- a/src/app/features/tag/dialog-edit-tags/dialog-edit-tags-for-task.component.scss +++ /dev/null @@ -1,24 +0,0 @@ -@use '../../../../styles/_globals.scss' as *; - -:host { - display: flex; - flex-direction: column; - justify-content: stretch; - align-items: stretch; - max-width: 600px; - - @include mq(xs) { - width: 70vw; - } - - :host-context([dir='rtl']) { - direction: rtl; - } -} - -.form-wrapper { - display: flex; - flex-direction: column; - justify-content: stretch; - align-items: stretch; -} diff --git a/src/app/features/tag/dialog-edit-tags/dialog-edit-tags-for-task.component.ts b/src/app/features/tag/dialog-edit-tags/dialog-edit-tags-for-task.component.ts deleted file mode 100644 index 610b066c82..0000000000 --- a/src/app/features/tag/dialog-edit-tags/dialog-edit-tags-for-task.component.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { ChangeDetectionStrategy, Component, inject, OnDestroy } from '@angular/core'; -import { - MAT_DIALOG_DATA, - MatDialogActions, - MatDialogContent, - MatDialogRef, - MatDialogTitle, -} from '@angular/material/dialog'; -import { T } from '../../../t.const'; -import { TaskService } from '../../tasks/task.service'; -import { DialogEditTagsForTaskPayload } from './dialog-edit-tags-for-task.payload'; -import { truncate } from '../../../util/truncate'; -import { TagService } from '../tag.service'; -import { Task } from '../../tasks/task.model'; -import { unique } from '../../../util/unique'; -import { Observable, Subscription } from 'rxjs'; -import { Tag } from '../tag.model'; -import { ChipListInputComponent } from '../../../ui/chip-list-input/chip-list-input.component'; -import { MatButton } from '@angular/material/button'; -import { AsyncPipe } from '@angular/common'; -import { TranslatePipe } from '@ngx-translate/core'; -import { Store } from '@ngrx/store'; -import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions'; -import { addTag } from '../store/tag.actions'; - -@Component({ - selector: 'dialog-edit-tags', - templateUrl: './dialog-edit-tags-for-task.component.html', - styleUrls: ['./dialog-edit-tags-for-task.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ - MatDialogTitle, - MatDialogContent, - ChipListInputComponent, - MatDialogActions, - MatButton, - AsyncPipe, - TranslatePipe, - ], -}) -export class DialogEditTagsForTaskComponent implements OnDestroy { - private _taskService = inject(TaskService); - private _tagService = inject(TagService); - private _store = inject(Store); - private _matDialogRef = - inject>(MatDialogRef); - data = inject(MAT_DIALOG_DATA); - - T: typeof T = T; - title: string = truncate(this.data.task.title, 20); - task$: Observable = this._taskService.getByIdLive$(this.data.task.id); - task: Task = this.data.task; - tagIds: string[] = [...this.data.task.tagIds]; - isEdit: boolean = this.data.task.tagIds && this.data.task.tagIds.length > 0; - tagSuggestions$: Observable = this._tagService.tagsNoMyDayAndNoList$; - - private _subs: Subscription = new Subscription(); - - constructor() { - this._subs.add( - this.task$.subscribe((task) => { - this.tagIds = task.tagIds; - this.task = task; - }), - ); - } - - ngOnDestroy(): void { - this._subs.unsubscribe(); - } - - close(): void { - this._matDialogRef.close(); - } - - addTag(id: string): void { - this._updateTags(unique([...this.tagIds, id])); - } - - addNewTag(title: string): void { - const cleanTitle = (t: string): string => { - return t.replace('#', ''); - }; - - const tag = this._tagService.createTagObject({ title: cleanTitle(title) }); - this._store.dispatch(addTag({ tag })); - this._store.dispatch( - TaskSharedActions.addTagToTask({ tagId: tag.id, taskId: this.task.id }), - ); - } - - removeTag(id: string): void { - const updatedTagIds = this.tagIds.filter((tagId) => tagId !== id); - this._updateTags(updatedTagIds); - } - - private _updateTags(newTagIds: string[]): void { - this._taskService.updateTags(this.task, unique(newTagIds)); - } -} diff --git a/src/app/features/tag/dialog-edit-tags/dialog-edit-tags-for-task.payload.ts b/src/app/features/tag/dialog-edit-tags/dialog-edit-tags-for-task.payload.ts deleted file mode 100644 index e4abcdcd27..0000000000 --- a/src/app/features/tag/dialog-edit-tags/dialog-edit-tags-for-task.payload.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Task } from '../../tasks/task.model'; - -export interface DialogEditTagsForTaskPayload { - task: Task; -} diff --git a/src/app/features/tasks/dialog-task-detail-panel/dialog-task-detail-panel.component.html b/src/app/features/tasks/dialog-task-detail-panel/dialog-task-detail-panel.component.html deleted file mode 100644 index eb7f4bab3d..0000000000 --- a/src/app/features/tasks/dialog-task-detail-panel/dialog-task-detail-panel.component.html +++ /dev/null @@ -1,19 +0,0 @@ - - - @if (task$ | async; as task) { - - } - - - - - - - - - - - diff --git a/src/app/features/tasks/dialog-task-detail-panel/dialog-task-detail-panel.component.scss b/src/app/features/tasks/dialog-task-detail-panel/dialog-task-detail-panel.component.scss deleted file mode 100644 index 79ac70fa5f..0000000000 --- a/src/app/features/tasks/dialog-task-detail-panel/dialog-task-detail-panel.component.scss +++ /dev/null @@ -1,31 +0,0 @@ -@use '../../../../styles/_globals.scss' as *; - -:host { - mat-dialog-content { - padding: 0; - border-radius: var(--card-border-radius); - max-width: 450px; - max-height: 75vh; - min-width: 340px; - - background: var(--bg-lightest) !important; - - @include mq(xxxs) { - min-width: 380px; - } - @include mq(xs) { - min-width: 450px; - max-height: 85vh; - } - } - - &::ng-deep { - .task-title-wrapper { - padding-right: var(--s) !important; - } - - task .hover-controls { - display: none !important; - } - } -} diff --git a/src/app/features/tasks/dialog-task-detail-panel/dialog-task-detail-panel.component.ts b/src/app/features/tasks/dialog-task-detail-panel/dialog-task-detail-panel.component.ts deleted file mode 100644 index 51c1711a01..0000000000 --- a/src/app/features/tasks/dialog-task-detail-panel/dialog-task-detail-panel.component.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { ChangeDetectionStrategy, Component, inject, OnDestroy } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { - MAT_DIALOG_DATA, - MatDialogContent, - MatDialogRef, -} from '@angular/material/dialog'; -import { Store } from '@ngrx/store'; -import { selectSelectedTask } from '../store/task.selectors'; -import { T } from 'src/app/t.const'; -import { TranslateModule } from '@ngx-translate/core'; -import { setSelectedTask } from '../store/task.actions'; -import { TaskDetailTargetPanel } from '../task.model'; -import { skipWhile } from 'rxjs/operators'; -import { TaskDetailPanelComponent } from '../task-detail-panel/task-detail-panel.component'; - -@Component({ - selector: 'dialog-task-detail-panel', - imports: [CommonModule, TranslateModule, MatDialogContent, TaskDetailPanelComponent], - templateUrl: './dialog-task-detail-panel.component.html', - styleUrl: './dialog-task-detail-panel.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class DialogTaskDetailPanelComponent implements OnDestroy { - data = inject<{ - taskId: string; - }>(MAT_DIALOG_DATA); - private _matDialogRef = - inject>(MatDialogRef); - private _store = inject(Store); - - T: typeof T = T; - task$ = this._store.select(selectSelectedTask).pipe(skipWhile((v) => !v)); - - constructor() { - const data = this.data; - - this._store.dispatch( - setSelectedTask({ - id: data.taskId, - taskDetailTargetPanel: TaskDetailTargetPanel.DONT_OPEN_PANEL, - isSkipToggle: true, - }), - ); - // this.task$.subscribe((v) => TaskLog.log(`task$`, v)); - } - - // close(): void { - // this._matDialogRef.close(); - // } - ngOnDestroy(): void { - this._store.dispatch( - setSelectedTask({ - id: null, - }), - ); - } -} diff --git a/src/app/plugins/ui/plugin-menu.component.ts b/src/app/plugins/ui/plugin-menu.component.ts deleted file mode 100644 index 27d572195b..0000000000 --- a/src/app/plugins/ui/plugin-menu.component.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; -import { PluginBridgeService } from '../plugin-bridge.service'; -import { MatMenuItem } from '@angular/material/menu'; -import { PluginIconComponent } from './plugin-icon/plugin-icon.component'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { filter, map } from 'rxjs/operators'; -import { NavigationEnd, Router } from '@angular/router'; - -@Component({ - selector: 'plugin-menu', - template: ` - @for (menuEntry of menuEntries(); track menuEntry.pluginId + menuEntry.label) { - - } - `, - styles: [ - ` - .plugin-menu-entry { - width: 100%; - justify-content: flex-start; - margin-bottom: 4px; - &.isActive { - color: var(--c-primary); - font-weight: bold; - - mat-icon { - color: var(--c-primary); - } - } - - mat-icon, - plugin-icon { - margin-right: 16px; - } - } - `, - ], - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [MatMenuItem, PluginIconComponent], -}) -export class PluginMenuComponent { - private readonly _pluginBridge = inject(PluginBridgeService); - private readonly _router = inject(Router); - - readonly currentRoute = toSignal( - this._router.events.pipe( - filter((event) => event instanceof NavigationEnd), - map(() => this._router.url), - ), - { initialValue: this._router.url }, - ); - - readonly menuEntries = this._pluginBridge.menuEntries; - - isActive(pluginId: string): boolean { - return this.currentRoute().includes(pluginId); - } -} diff --git a/src/app/ui/help-box/help-box.component.html b/src/app/ui/help-box/help-box.component.html deleted file mode 100644 index e461c26b77..0000000000 --- a/src/app/ui/help-box/help-box.component.html +++ /dev/null @@ -1,19 +0,0 @@ -@if (isVisible()) { -
- -
- -
-
-} diff --git a/src/app/ui/help-box/help-box.component.scss b/src/app/ui/help-box/help-box.component.scss deleted file mode 100644 index f1bee5cc93..0000000000 --- a/src/app/ui/help-box/help-box.component.scss +++ /dev/null @@ -1,33 +0,0 @@ -:host { - display: block; - position: sticky; - top: var(--s2); - background: var(--bg); - z-index: 200; - margin: var(--s); - border: 2px solid var(--separator-color); - border-radius: var(--card-border-radius); - - .help-box { - position: relative; - padding: var(--s); - border: 1px solid var(--color-border); - border-radius: var(--card-border-radius); - background-color: var(--color-bg-info); - } - - .close-btn { - position: absolute; - top: var(--s-half); - right: var(--s-half); - } - - //.help-box-content {} - - :host-context([dir='rtl']) { - .close-btn { - right: unset; - left: var(--s-half); - } - } -} diff --git a/src/app/ui/help-box/help-box.component.ts b/src/app/ui/help-box/help-box.component.ts deleted file mode 100644 index ba49bff6fd..0000000000 --- a/src/app/ui/help-box/help-box.component.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { - ChangeDetectionStrategy, - Component, - Input, - OnInit, - WritableSignal, - signal, -} from '@angular/core'; -import { MatIcon } from '@angular/material/icon'; -import { MatIconButton } from '@angular/material/button'; -import { expandFadeAnimation } from '../animations/expand.ani'; -import { lsGetBoolean, lsSetItem } from '../../util/ls-util'; - -@Component({ - selector: 'help-box', - templateUrl: './help-box.component.html', - styleUrls: ['./help-box.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, - animations: [expandFadeAnimation], - imports: [MatIcon, MatIconButton], -}) -export class HelpBoxComponent implements OnInit { - @Input({ required: true }) lsKey!: string; - - isVisible: WritableSignal = signal(true); - - ngOnInit(): void { - // Check localStorage to determine if the help box should be shown - const isDismissed = lsGetBoolean(this.lsKey, false); - this.isVisible.set(!isDismissed); - } - - onClose(): void { - // Set the localStorage key to true to indicate the box was dismissed - lsSetItem(this.lsKey, true); - this.isVisible.set(false); - } -} diff --git a/src/app/ui/stuck/stuck.directive.ts b/src/app/ui/stuck/stuck.directive.ts deleted file mode 100644 index a9dece07fd..0000000000 --- a/src/app/ui/stuck/stuck.directive.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { AfterViewInit, Directive, ElementRef, OnDestroy, inject } from '@angular/core'; -const DEFAULT_PINNED_CLASS = 'is-stuck'; - -@Directive({ - selector: '[stuck]', - standalone: true, -}) -export class StuckDirective implements AfterViewInit, OnDestroy { - private elRef = inject(ElementRef); - - private _observer?: IntersectionObserver; - - ngAfterViewInit(): void { - const el = this.elRef.nativeElement as HTMLElement; - this._observer = new IntersectionObserver( - // ([e]) => e.target.classList.toggle(DEFAULT_PINNED_CLASS, e.intersectionRatio < 1), - ([e]) => { - e.target.classList.toggle(DEFAULT_PINNED_CLASS, !e.isIntersecting); - // Log.log(e.boundingClientRect.top, e); - // Log.log('top', e.boundingClientRect.top < 0, e.isIntersecting); - // if (e.boundingClientRect.top < 0) { - // e.target.classList.toggle(DEFAULT_PINNED_CLASS, e.isIntersecting); - // } - }, - { - rootMargin: '0px', - threshold: 1, - }, - ); - - this._observer.observe(el); - } - - ngOnDestroy(): void { - if (!this._observer) { - throw new Error('Observer is not defined'); - } - this._observer.unobserve(this.elRef.nativeElement as HTMLElement); - this._observer.disconnect(); - } -} diff --git a/src/app/ui/tree-dnd/tree-drop-zones.component.ts b/src/app/ui/tree-dnd/tree-drop-zones.component.ts deleted file mode 100644 index 20228f51ae..0000000000 --- a/src/app/ui/tree-dnd/tree-drop-zones.component.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { ChangeDetectionStrategy, Component, input } from '@angular/core'; - -@Component({ - selector: 'tree-drop-zones', - standalone: true, - template: ` -
-
- `, - styles: [ - ` - .drop { - height: 50%; - pointer-events: none; - width: 100%; - background: transparent; - position: absolute; - z-index: 1; - - :host-context(.tree.is-dragging) & { - pointer-events: all; - background: rgba(0, 123, 255, 0.1); - z-index: 10; - } - - &:hover { - background: rgba(0, 123, 255, 0.15); - } - } - - .drop--before, - .drop--after { - } - - .drop--before { - top: 0px; - - :host-context(.tree.is-dragging) & { - background: rgba(255, 165, 0, 0.1); /* Light orange when dragging */ - } - - &.is-over { - background: rgba( - 255, - 165, - 0, - 0.25 - ) !important; /* Darker orange when hovering */ - } - } - - .drop--after { - bottom: 0px; - - :host-context(.tree.is-dragging) & { - background: rgba(34, 139, 34, 0.1); /* Light green when dragging */ - } - - &.is-over { - background: rgba(34, 139, 34, 0.25) !important; /* Darker green when hovering */ - } - } - `, - ], - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class TreeDropZonesComponent { - readonly nodeId = input.required(); - readonly overBefore = input(false); - readonly overAfter = input(false); -} From d2015beb4447fd48cba31ee4946896003d06004a Mon Sep 17 00:00:00 2001 From: John Costa Date: Fri, 10 Jul 2026 03:07:19 -0700 Subject: [PATCH 05/25] fix(task): schedule overdue tasks for today via Shift+T and guard stale task focus (#8867) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(task): schedule overdue tasks for today via Shift+T and guard stale task focus (#8851) * fix(planner): only bind data-task-id on planner-task when focusable The unconditional data-task-id host binding broke the e2e done-confirmation strategy in e2e/pages/task.page.ts, which selects its wait branch based on the attribute's presence. Boards render planner-task cards without an inner element, so the id-based wait could never resolve (#8851). * refactor(tasks): share overdue predicate across selector and util The rebase onto #8858 re-inlined the overdue comparison into selectOverdueTaskIds and dropped the delegation to isTaskOverdue, so the two overdue definitions (the overdue list vs. the Shift+T "Add to Today" path) were duplicated and free to drift — the exact footgun #8851 set out to avoid, and the util's JSDoc still claimed the selector delegated to it. Extract isTaskOverdueByThreshold as the single source of truth for the comparison and getLogicalTodayStartMs for the boundary. isTaskOverdue and selectOverdueTaskIds both route through it, so they cannot drift. The selector still computes the threshold once per recompute (no per-task date parsing), preserving #8858's perf posture. No behavior change. * test(e2e): base markTaskAsDone strategy on host, not attr planner-task now carries data-task-id in the Planner overdue list (#8851), so keying the done-confirmation strategy off data-task-id presence would send a wrapper down the path — document.querySelectorAll('task') finds no match and the wait hangs 10s. Key it off the element actually being a instead. No behavior change for real rows; every wrapper keeps the 300ms wrapper path. Verified: worklog-basic (real task) and boards #7498 (planner-task wrapper) both pass. --------- Co-authored-by: Johannes Millan --- e2e/pages/task.page.ts | 12 +- .../planner-day-overdue.component.html | 1 + .../planner-task/planner-task.component.ts | 10 + .../features/tasks/store/task.selectors.ts | 20 +- .../tasks/task-shortcut.service.spec.ts | 203 ++++++++++++++++-- .../features/tasks/task-shortcut.service.ts | 72 +++++-- src/app/features/tasks/task.service.ts | 18 ++ .../tasks/task/task-shortcuts.spec.ts | 73 +++++++ src/app/features/tasks/task/task.component.ts | 30 ++- .../tasks/util/is-task-overdue.spec.ts | 114 ++++++++++ .../features/tasks/util/is-task-overdue.ts | 60 ++++++ 11 files changed, 555 insertions(+), 58 deletions(-) create mode 100644 src/app/features/tasks/util/is-task-overdue.spec.ts create mode 100644 src/app/features/tasks/util/is-task-overdue.ts diff --git a/e2e/pages/task.page.ts b/e2e/pages/task.page.ts index aabe7b690c..917a9a4c17 100644 --- a/e2e/pages/task.page.ts +++ b/e2e/pages/task.page.ts @@ -53,10 +53,14 @@ export class TaskPage extends BasePage { */ async markTaskAsDone(task: Locator): Promise { await task.waitFor({ state: 'visible' }); - // `data-task-id` is only bound on the host. Wrapper components such - // as (Boards) render the same done-toggle but expose no id, - // so the done-confirmation strategy is chosen based on its presence. - const taskId = await task.getAttribute('data-task-id'); + // The done-confirmation strategy differs for real rows vs. wrapper + // components ( etc.) that render the same done-toggle. Key it + // off the element actually being a , not off `data-task-id` presence: + // also carries `data-task-id` in the Planner overdue list + // (#8851), but querying `document.querySelectorAll('task')` for it finds + // nothing, so the strategy would hang for a wrapper. + const isTaskHost = (await task.evaluate((el) => el.tagName.toLowerCase())) === 'task'; + const taskId = isTaskHost ? await task.getAttribute('data-task-id') : null; await task.hover(); // Give hover effects time to settle diff --git a/src/app/features/planner/planner-day-overdue/planner-day-overdue.component.html b/src/app/features/planner/planner-day-overdue/planner-day-overdue.component.html index 10b54390b3..e767ecf67e 100644 --- a/src/app/features/planner/planner-day-overdue/planner-day-overdue.component.html +++ b/src/app/features/planner/planner-day-overdue/planner-day-overdue.component.html @@ -27,6 +27,7 @@ [cdkDragLockAxis]="isXs() ? 'y' : ''" [task]="task" [day]="" + [focusable]="true" > }
diff --git a/src/app/features/planner/planner-task/planner-task.component.ts b/src/app/features/planner/planner-task/planner-task.component.ts index 685191763d..9806ac5a37 100644 --- a/src/app/features/planner/planner-task/planner-task.component.ts +++ b/src/app/features/planner/planner-task/planner-task.component.ts @@ -50,6 +50,13 @@ import { TranslatePipe } from '@ngx-translate/core'; ], /* eslint-disable @typescript-eslint/naming-convention */ host: { + // data-task-id + tabindex only where the id-based schedule-today shortcut + // needs them (the Planner overdue list, #8851). Elsewhere data-task-id + // must stay absent: the e2e page object (e2e/pages/task.page.ts) picks its + // done-confirmation strategy based on its presence, and tabindex would add + // a Tab stop to every planner-day/scheduled card board-wide. + '[attr.data-task-id]': 'focusable() ? task().id : null', + '[attr.tabindex]': 'focusable() ? "0" : null', '[class.isDone]': 'task().isDone', '[class.isDragReady]': 'isDragReady()', '[class.isCurrent]': 'isCurrent()', @@ -72,6 +79,9 @@ export class PlannerTaskComponent implements OnInit, OnDestroy, AfterViewInit { // TODO remove readonly day = input(); readonly tagsToHide = input(); + // Opt-in DOM focusability (only the Planner overdue list needs it, for the + // schedule-today shortcut). Off everywhere else to avoid stray Tab stops. + readonly focusable = input(false); readonly T = T; readonly isTouchActive = isTouchActive; diff --git a/src/app/features/tasks/store/task.selectors.ts b/src/app/features/tasks/store/task.selectors.ts index 6db7b64c56..4db7a89f2a 100644 --- a/src/app/features/tasks/store/task.selectors.ts +++ b/src/app/features/tasks/store/task.selectors.ts @@ -22,6 +22,10 @@ import { import { dateStrToUtcDate } from '../../../util/date-str-to-utc-date'; import { isTodayWithOffset } from '../../../util/is-today.util'; import { getTimeConflictTaskIds } from '../util/get-time-conflict-task-ids'; +import { + getLogicalTodayStartMs, + isTaskOverdueByThreshold, +} from '../util/is-task-overdue'; export const isCalendarIssueTask = (task: Task | undefined): task is Task => !!task && @@ -413,24 +417,18 @@ export const selectAllTasksWithSubTasks = createSelector( ); // selectOverdueTasks (rebased): decision reads only dueDay/dueWithTime from the -// snapshot; public re-maps ids to live Task refs. +// snapshot; public re-maps ids to live Task refs. Shares the overdue comparison +// with the isTaskOverdue util (Shift+T path) via isTaskOverdueByThreshold so the +// two definitions cannot drift; the threshold is computed once per recompute. export const selectOverdueTaskIds = createSelector( selectTaskSchedulingSnapshot, selectTodayStr, selectStartOfNextDayDiffMs, (snapshot, todayStr, startOfNextDayDiffMs): string[] => { - const today = dateStrToUtcDate(todayStr); - today.setHours(0, 0, 0, 0); - // The logical start of "today" is shifted by the offset - const todayStartMs = today.getTime() + startOfNextDayDiffMs; + const todayStartMs = getLogicalTodayStartMs(todayStr, startOfNextDayDiffMs); const ids: string[] = []; for (const snap of snapshot) { - // Note: String comparison works correctly here because dueDay is in YYYY-MM-DD format - // which is lexicographically sortable. This avoids timezone conversion issues. - if ( - (snap.dueDay && isDBDateStr(snap.dueDay) && snap.dueDay < todayStr) || - (snap.dueWithTime && snap.dueWithTime < todayStartMs) - ) { + if (isTaskOverdueByThreshold(snap, todayStr, todayStartMs)) { ids.push(snap.id); } } diff --git a/src/app/features/tasks/task-shortcut.service.spec.ts b/src/app/features/tasks/task-shortcut.service.spec.ts index db39a8c00d..e94c5c0255 100644 --- a/src/app/features/tasks/task-shortcut.service.spec.ts +++ b/src/app/features/tasks/task-shortcut.service.spec.ts @@ -74,6 +74,7 @@ describe('TaskShortcutService', () => { currentTaskId: signal(null), setCurrentId: jasmine.createSpy('setCurrentId'), toggleStartTask: jasmine.createSpy('toggleStartTask'), + scheduleForTodayById: jasmine.createSpy('scheduleForTodayById'), } as any; mockConfigService = { @@ -100,6 +101,41 @@ describe('TaskShortcutService', () => { service = TestBed.inject(TaskShortcutService); }); + // The shortcut handler now treats the DOM as authoritative for task focus + // (#8851): a task shortcut only fires when document.activeElement is inside + // the matching focusedTaskId. Helper stubs both so "a task is focused" + // tests reflect real focus. activeElement is stubbed directly rather than via + // el.focus() — headless Chrome only updates activeElement when the test iframe + // has window focus, which is not guaranteed inside a large suite. + let focusedTaskEl: HTMLElement | null = null; + let activeElementStubbed = false; + + const stubActiveElement = (el: Element | null): void => { + Object.defineProperty(document, 'activeElement', { + configurable: true, + get: () => el, + }); + activeElementStubbed = true; + }; + + const setFocusedTask = (id: string): HTMLElement => { + focusedTaskEl = document.createElement('task'); + focusedTaskEl.setAttribute('data-task-id', id); + document.body.appendChild(focusedTaskEl); + stubActiveElement(focusedTaskEl); + mockTaskFocusService.focusedTaskId.set(id); + return focusedTaskEl; + }; + + afterEach(() => { + focusedTaskEl?.remove(); + focusedTaskEl = null; + if (activeElementStubbed) { + delete (document as unknown as { activeElement?: unknown }).activeElement; + activeElementStubbed = false; + } + }); + describe('handleTaskShortcuts - togglePlay (Y key)', () => { describe('when focused task exists', () => { it('should delegate to focused task component togglePlayPause method', () => { @@ -109,7 +145,7 @@ describe('TaskShortcutService', () => { togglePlayPause: jasmine.createSpy('togglePlayPause'), taskContextMenu: () => undefined, // No context menu open }; - mockTaskFocusService.focusedTaskId.set('focused-task-1'); + setFocusedTask('focused-task-1'); mockTaskFocusService.lastFocusedTaskComponent.set(mockTaskComponent); const event = createKeyboardEvent('Y'); @@ -255,7 +291,7 @@ describe('TaskShortcutService', () => { togglePlayPause: jasmine.createSpy('togglePlayPause'), taskContextMenu: () => undefined, // No context menu open }; - mockTaskFocusService.focusedTaskId.set('focused-task-1'); + setFocusedTask('focused-task-1'); mockTaskFocusService.lastFocusedTaskComponent.set(mockTaskComponent); mockTaskService.selectedTaskId.set('selected-task-2'); // Different task selected @@ -280,7 +316,7 @@ describe('TaskShortcutService', () => { openNotesPanel: jasmine.createSpy('openNotesPanel'), taskContextMenu: () => undefined, }; - mockTaskFocusService.focusedTaskId.set('focused-task-1'); + setFocusedTask('focused-task-1'); mockTaskFocusService.lastFocusedTaskComponent.set(mockTaskComponent); const event = createKeyboardEvent('N'); @@ -299,7 +335,7 @@ describe('TaskShortcutService', () => { openDeadlineDialog: jasmine.createSpy('openDeadlineDialog'), taskContextMenu: () => undefined, }; - mockTaskFocusService.focusedTaskId.set('focused-task-1'); + setFocusedTask('focused-task-1'); mockTaskFocusService.lastFocusedTaskComponent.set(mockTaskComponent); const event = createKeyboardEvent('S', 'KeyS', { shiftKey: true }); @@ -318,7 +354,7 @@ describe('TaskShortcutService', () => { openContextMenu: jasmine.createSpy('openContextMenu'), taskContextMenu: () => undefined, }; - mockTaskFocusService.focusedTaskId.set('focused-task-1'); + setFocusedTask('focused-task-1'); mockTaskFocusService.lastFocusedTaskComponent.set(mockTaskComponent); const event = createKeyboardEvent('ContextMenu', 'ContextMenu'); @@ -337,7 +373,7 @@ describe('TaskShortcutService', () => { openContextMenu: jasmine.createSpy('openContextMenu'), taskContextMenu: () => undefined, }; - mockTaskFocusService.focusedTaskId.set('focused-task-1'); + setFocusedTask('focused-task-1'); mockTaskFocusService.lastFocusedTaskComponent.set(mockTaskComponent); const event = createKeyboardEvent('Unidentified', 'ContextMenu'); @@ -356,7 +392,7 @@ describe('TaskShortcutService', () => { openContextMenu: jasmine.createSpy('openContextMenu'), taskContextMenu: () => undefined, }; - mockTaskFocusService.focusedTaskId.set('focused-task-1'); + setFocusedTask('focused-task-1'); mockTaskFocusService.lastFocusedTaskComponent.set(mockTaskComponent); const event = createKeyboardEvent('ContextMenu', 'ContextMenu', { @@ -403,7 +439,7 @@ describe('TaskShortcutService', () => { configurable: true, value: { writeText }, }); - mockTaskFocusService.focusedTaskId.set('focused-task-1'); + setFocusedTask('focused-task-1'); mockTaskFocusService.lastFocusedTaskComponent.set({ task: () => ({ id: 'focused-task-1', title: 'Task title to copy' }), taskContextMenu: () => undefined, @@ -496,22 +532,10 @@ describe('TaskShortcutService', () => { * test iframe has window focus, which is not guaranteed inside a * large suite (other tests can steal/drop focus). */ + // Reuses the outer stubActiveElement helper (and its afterEach cleanup). let taskEl: HTMLElement; - let activeElementStubbed = false; - - const stubActiveElement = (el: Element | null): void => { - Object.defineProperty(document, 'activeElement', { - configurable: true, - get: () => el, - }); - activeElementStubbed = true; - }; afterEach(() => { - if (activeElementStubbed) { - delete (document as unknown as { activeElement?: unknown }).activeElement; - activeElementStubbed = false; - } taskEl?.remove(); }); @@ -604,4 +628,141 @@ describe('TaskShortcutService', () => { expect(mockTaskComponent.toggleDoneKeyboard).toHaveBeenCalled(); }); }); + + describe('stale-focus guard (#8851)', () => { + let taskEl: HTMLElement; + + afterEach(() => { + taskEl?.remove(); + }); + + it('drops a task shortcut when focus has left all elements', () => { + // focusedTaskId still points at a task, but the DOM contradicts it: + // the active element is , i.e. focus left every (e.g. after + // navigating to a view with no live ). The shortcut must not fire. + stubActiveElement(document.body); + const mockTaskComponent = { + task: () => ({ id: 'stale-task' }), + toggleDoneKeyboard: jasmine.createSpy('toggleDoneKeyboard'), + taskContextMenu: () => undefined, + }; + mockTaskFocusService.focusedTaskId.set('stale-task'); + mockTaskFocusService.lastFocusedTaskComponent.set(mockTaskComponent); + + const result = service.handleTaskShortcuts(createKeyboardEvent('D')); + + expect(result).toBe(false); + expect(mockTaskComponent.toggleDoneKeyboard).not.toHaveBeenCalled(); + }); + + it('uses the containing focus over a mismatched focusedTaskId', () => { + // Active element is inside a different than focusedTaskId claims — + // the DOM wins, so delegation targets the DOM task, not the stale id. + taskEl = document.createElement('task'); + taskEl.setAttribute('data-task-id', 'dom-task'); + document.body.appendChild(taskEl); + stubActiveElement(taskEl); + + const mockTaskComponent = { + task: () => ({ id: 'dom-task' }), + toggleDoneKeyboard: jasmine.createSpy('toggleDoneKeyboard'), + taskContextMenu: () => undefined, + }; + mockTaskFocusService.focusedTaskId.set('stale-task'); + mockTaskFocusService.lastFocusedTaskComponent.set(mockTaskComponent); + + const result = service.handleTaskShortcuts(createKeyboardEvent('D')); + + expect(result).toBe(true); + expect(mockTaskComponent.toggleDoneKeyboard).toHaveBeenCalled(); + }); + }); + + describe('schedule-today shortcut (#8851)', () => { + let hostEl: HTMLElement; + + afterEach(() => { + hostEl?.remove(); + }); + + it('delegates to the focused component (preserves overdue/backlog branching)', () => { + const taskComponent = { + task: () => ({ id: 'focused-task-1' }), + moveToTodayWithFocus: jasmine.createSpy('moveToTodayWithFocus'), + taskContextMenu: () => undefined, + }; + setFocusedTask('focused-task-1'); + mockTaskFocusService.lastFocusedTaskComponent.set(taskComponent); + + const event = createKeyboardEvent('F'); + spyOn(event, 'preventDefault'); + + const result = service.handleTaskShortcuts(event); + + expect(result).toBe(true); + expect(taskComponent.moveToTodayWithFocus).toHaveBeenCalled(); + expect(mockTaskService.scheduleForTodayById).not.toHaveBeenCalled(); + }); + + it('schedules by id from a focused without a live ', () => { + // The Planner overdue list renders , which is not a + // TaskComponent and never registers focus. It carries data-task-id and is + // focusable, so Shift+T resolves the id from the DOM and dispatches + // planTasksForToday by id instead of delegating to a stale . + hostEl = document.createElement('planner-task'); + hostEl.setAttribute('data-task-id', 'overdue-planner-task'); + document.body.appendChild(hostEl); + stubActiveElement(hostEl); + mockTaskFocusService.focusedTaskId.set(null); + + const event = createKeyboardEvent('F'); + spyOn(event, 'preventDefault'); + + const result = service.handleTaskShortcuts(event); + + expect(result).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + expect(mockTaskService.scheduleForTodayById).toHaveBeenCalledWith( + 'overdue-planner-task', + ); + }); + + it('does nothing when no task can be resolved from focus', () => { + stubActiveElement(document.body); + mockTaskFocusService.focusedTaskId.set(null); + + const result = service.handleTaskShortcuts(createKeyboardEvent('F')); + + expect(result).toBe(false); + expect(mockTaskService.scheduleForTodayById).not.toHaveBeenCalled(); + }); + + it('schedules the focused planner-task, not a stale focusedTaskId (literal #8851 repro)', () => { + // The exact reported shape: focusedTaskId still points at a from a + // previously-visited view, while a in the overdue list + // actually holds DOM focus. Shift+T must act on the planner task's id and + // never touch the stale component (which produced the stray sync write). + hostEl = document.createElement('planner-task'); + hostEl.setAttribute('data-task-id', 'overdue-planner-task'); + document.body.appendChild(hostEl); + stubActiveElement(hostEl); + + const staleComponent = { + task: () => ({ id: 'stale-task-elsewhere' }), + moveToTodayWithFocus: jasmine.createSpy('moveToTodayWithFocus'), + taskContextMenu: () => undefined, + }; + mockTaskFocusService.focusedTaskId.set('stale-task-elsewhere'); + mockTaskFocusService.lastFocusedTaskComponent.set(staleComponent); + + const result = service.handleTaskShortcuts(createKeyboardEvent('F')); + + expect(result).toBe(true); + expect(mockTaskService.scheduleForTodayById).toHaveBeenCalledWith( + 'overdue-planner-task', + ); + expect(mockTaskService.scheduleForTodayById).toHaveBeenCalledTimes(1); + expect(staleComponent.moveToTodayWithFocus).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/app/features/tasks/task-shortcut.service.ts b/src/app/features/tasks/task-shortcut.service.ts index 1e305314eb..1476a58737 100644 --- a/src/app/features/tasks/task-shortcut.service.ts +++ b/src/app/features/tasks/task-shortcut.service.ts @@ -63,17 +63,48 @@ export class TaskShortcutService { const keys = cfg.keyboard; let focusedTaskId: TaskId | null = this._taskFocusService.focusedTaskId(); - // Focus-tracking recovery: a `focusout` can clear focusedTaskId without a - // following `focusin` rebinding it (e.g. when focus stays on the task host - // after an inline-edit blur, the host's `.focus()` becomes a no-op and no - // new focusin fires). If the active element is still inside a , - // derive the id from its data-task-id so shortcuts don't silently drop. - if (!focusedTaskId) { - const active = document.activeElement as HTMLElement | null; - const taskEl = active?.closest('task') as HTMLElement | null; - const recoveredId = taskEl?.getAttribute('data-task-id'); - if (recoveredId) { - focusedTaskId = recoveredId; + // Make the DOM authoritative for task focus (#8851). Two problems this + // solves: + // 1. Focus-tracking recovery: a `focusout` can clear focusedTaskId without + // a following `focusin` rebinding it (e.g. focus staying on the task + // host after an inline-edit blur, where `.focus()` is a no-op and no new + // focusin fires). If the active element is still inside a , we + // recover the id so shortcuts don't silently drop. + // 2. Stale-focus guard: navigating to a view with no live (e.g. the + // Planner overdue list) leaves focusedTaskId pointing at a that + // no longer holds focus. Acting on it would mutate the wrong task. If + // the active element is not inside the matching focusedTaskId, + // drop it. + // Only the DOM actively contradicting invalidates focus, so the inline-edit + // recovery path above stays intact. + const active = document.activeElement as HTMLElement | null; + const domFocusedTaskId = + (active?.closest('task') as HTMLElement | null)?.getAttribute('data-task-id') ?? + null; + if (domFocusedTaskId) { + focusedTaskId = domFocusedTaskId; + } else if (focusedTaskId) { + focusedTaskId = null; + } + + // Schedule for today (Shift+T). This is the one task shortcut wired to work + // without a live component, so it also fires from views that render + // (the Planner overdue list). When a real is focused + // we still delegate, so the backlog→regular position-only move (#8592/#8603) + // and the overdue branch in moveToToday() are preserved. (#8851) + if (checkKeyCombo(ev, keys.taskScheduleToday)) { + if (focusedTaskId) { + this._handleTaskShortcut(focusedTaskId, 'moveToTodayWithFocus'); + ev.preventDefault(); + ev.stopPropagation(); + return true; + } + const idBasedTaskId = this._resolveTaskIdFromDom(); + if (idBasedTaskId) { + this._taskService.scheduleForTodayById(idBasedTaskId); + ev.preventDefault(); + ev.stopPropagation(); + return true; } } @@ -244,13 +275,6 @@ export class TaskShortcutService { return true; } - if (checkKeyCombo(ev, keys.taskScheduleToday)) { - this._handleTaskShortcut(focusedTaskId, 'moveToTodayWithFocus'); - ev.preventDefault(); - ev.stopPropagation(); - return true; - } - // Navigation shortcuts - only work if context menu is not open if ( !isContextMenuOpen && @@ -355,6 +379,18 @@ export class TaskShortcutService { return false; } + /** + * Resolves a task id straight from the focused element by walking up to the + * nearest host carrying `data-task-id`. Generic over the host selector (works + * for both `` and ``) so the id-based shortcut path can + * act on a task without a live `` component. (#8851) + */ + private _resolveTaskIdFromDom(): TaskId | null { + const active = document.activeElement as HTMLElement | null; + const host = active?.closest('[data-task-id]') as HTMLElement | null; + return host?.getAttribute('data-task-id') ?? null; + } + /** * Calls a method on the currently focused task component. * diff --git a/src/app/features/tasks/task.service.ts b/src/app/features/tasks/task.service.ts index 4e381c5ceb..06c48e518e 100644 --- a/src/app/features/tasks/task.service.ts +++ b/src/app/features/tasks/task.service.ts @@ -462,6 +462,24 @@ export class TaskService { ); } + /** + * Schedules a task for today by id (same effect as the "Add to My Day" + * button / Schedule → Today). Used by the id-based schedule-today shortcut + * path so it works from views without a live `` component (e.g. the + * Planner overdue list, which renders ``). (#8851) + */ + scheduleForTodayById(taskId: string): void { + const task = this._taskEntities()[taskId]; + this._store.dispatch( + TaskSharedActions.planTasksForToday({ + taskIds: [taskId], + today: this._dateService.todayStr(), + startOfNextDayDiffMs: this._dateService.getStartOfNextDayDiffMs(), + parentTaskMap: task ? { [taskId]: task.parentId } : undefined, + }), + ); + } + remove(task: TaskWithSubTasks): void { this._store.dispatch(TaskSharedActions.deleteTask({ task })); } diff --git a/src/app/features/tasks/task/task-shortcuts.spec.ts b/src/app/features/tasks/task/task-shortcuts.spec.ts index 6f2246e5d9..6e4b66c70d 100644 --- a/src/app/features/tasks/task/task-shortcuts.spec.ts +++ b/src/app/features/tasks/task/task-shortcuts.spec.ts @@ -7,6 +7,7 @@ import { of } from 'rxjs'; import { DialogFullscreenMarkdownComponent } from '../../../ui/dialog-fullscreen-markdown/dialog-fullscreen-markdown.component'; import { DateAdapter } from '@angular/material/core'; import { PlannerActions } from '../../planner/store/planner.actions'; +import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions'; import { DateService } from '../../../core/date/date.service'; import { GlobalTrackingIntervalService } from '../../../core/global-tracking-interval/global-tracking-interval.service'; import { LayoutService } from '../../../core-ui/layout/layout.service'; @@ -557,4 +558,76 @@ describe('TaskComponent shortcut handling', () => { expect(focusByIdSpy).not.toHaveBeenCalled(); })); }); + + describe('moveToToday overdue branch (#8851)', () => { + let dateService: jasmine.SpyObj; + let projectService: jasmine.SpyObj; + + beforeEach(() => { + dateService = TestBed.inject(DateService) as jasmine.SpyObj; + projectService = TestBed.inject(ProjectService) as jasmine.SpyObj; + (dateService as any).todayStr = jasmine + .createSpy('todayStr') + .and.returnValue('2026-06-01'); + (dateService as any).getStartOfNextDayDiffMs = jasmine + .createSpy('getStartOfNextDayDiffMs') + .and.returnValue(0); + }); + + it('schedules an overdue task for today instead of a position-only move', () => { + fixture.componentRef.setInput('task', { + ...createTopLevelTask('Overdue'), + dueDay: '2026-05-30', + }); + storeSpy.dispatch.calls.reset(); + + component.moveToToday(); + + expect(storeSpy.dispatch).toHaveBeenCalledWith( + TaskSharedActions.planTasksForToday({ + taskIds: ['top-1'], + today: '2026-06-01', + startOfNextDayDiffMs: 0, + parentTaskMap: { ['top-1']: undefined }, + }), + ); + expect(projectService.moveTaskToTodayList).not.toHaveBeenCalled(); + }); + + it('keeps the position-only move for a non-overdue task (#8592)', () => { + fixture.componentRef.setInput('task', { + ...createTopLevelTask('Not overdue'), + dueDay: undefined, + dueWithTime: undefined, + }); + storeSpy.dispatch.calls.reset(); + + component.moveToToday(); + + expect(projectService.moveTaskToTodayList).toHaveBeenCalledWith( + 'top-1', + 'project-1', + ); + expect(storeSpy.dispatch).not.toHaveBeenCalled(); + }); + + it('keeps the position-only move for a done task with a stale past dueDay', () => { + // A done task can sit in the backlog with an old dueDay; it must take the + // backlog→regular position-only move, not be re-added to Today. + fixture.componentRef.setInput('task', { + ...createTopLevelTask('Done + overdue'), + isDone: true, + dueDay: '2026-05-30', + }); + storeSpy.dispatch.calls.reset(); + + component.moveToToday(); + + expect(projectService.moveTaskToTodayList).toHaveBeenCalledWith( + 'top-1', + 'project-1', + ); + expect(storeSpy.dispatch).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/app/features/tasks/task/task.component.ts b/src/app/features/tasks/task/task.component.ts index 5de8bd41ab..4cd4739c91 100644 --- a/src/app/features/tasks/task/task.component.ts +++ b/src/app/features/tasks/task/task.component.ts @@ -81,6 +81,7 @@ import { PlannerActions } from '../../planner/store/planner.actions'; import { PlannerService } from '../../planner/planner.service'; import { DialogDeadlineComponent } from '../dialog-deadline/dialog-deadline.component'; import { isDeadlineOverdue as isDeadlineOverdueFn } from '../util/is-deadline-overdue'; +import { isTaskOverdue } from '../util/is-task-overdue'; import { isDeadlineApproaching as isDeadlineApproachingFn } from '../util/is-deadline-approaching'; import { TaskContextMenuComponent } from '../task-context-menu/task-context-menu.component'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -1362,11 +1363,32 @@ export class TaskComponent implements OnDestroy, AfterViewInit { moveToToday(): void { const t = this.task(); - if (t.projectId) { - // Moving to the regular list is a list-position change only; it must not - // schedule the task for today (#8592). - this._projectService.moveTaskToTodayList(t.id, t.projectId); + if (!t.projectId) { + return; } + // An overdue task is never in the backlog, so the position-only move below + // early-returns for it (moveProjectTaskToRegularListAuto) and Shift+T would + // no-op. Schedule it for today instead — the same thing the "Add to My Day" + // button and Schedule → Today do (#8851). Overdue vs. backlog→regular are + // cleanly separated because overdue tasks are never in the backlog. Exclude + // done tasks: a done task with a stale past dueDay can still sit in the + // backlog, and it should take the position-only move, not be re-added to + // Today. (isTaskOverdue stays done-agnostic — selectOverdueTasks needs + // done tasks included.) + if ( + !t.isDone && + isTaskOverdue( + t, + this._dateService.todayStr(), + this._dateService.getStartOfNextDayDiffMs(), + ) + ) { + this.addToMyDay(); + return; + } + // Moving to the regular list is a list-position change only; it must not + // schedule the task for today (#8592). + this._projectService.moveTaskToTodayList(t.id, t.projectId); } trackByProjectId(i: number, project: Project): string { diff --git a/src/app/features/tasks/util/is-task-overdue.spec.ts b/src/app/features/tasks/util/is-task-overdue.spec.ts new file mode 100644 index 0000000000..6ac24e480a --- /dev/null +++ b/src/app/features/tasks/util/is-task-overdue.spec.ts @@ -0,0 +1,114 @@ +import { + getLogicalTodayStartMs, + isTaskOverdue, + isTaskOverdueByThreshold, +} from './is-task-overdue'; +import { Task } from '../task.model'; + +const createTask = (overrides: Partial = {}): Task => + ({ + id: 'task1', + dueDay: undefined, + dueWithTime: undefined, + ...overrides, + }) as Task; + +describe('isTaskOverdue', () => { + const TODAY_STR = '2026-03-15'; + const NO_OFFSET = 0; + + describe('dueDay', () => { + it('is overdue when dueDay is before todayStr', () => { + expect( + isTaskOverdue(createTask({ dueDay: '2026-03-14' }), TODAY_STR, NO_OFFSET), + ).toBe(true); + }); + + it('is not overdue when dueDay equals todayStr', () => { + expect( + isTaskOverdue(createTask({ dueDay: '2026-03-15' }), TODAY_STR, NO_OFFSET), + ).toBe(false); + }); + + it('is not overdue when dueDay is after todayStr', () => { + expect( + isTaskOverdue(createTask({ dueDay: '2026-03-16' }), TODAY_STR, NO_OFFSET), + ).toBe(false); + }); + + it('is not overdue when dueDay is not a valid YYYY-MM-DD string', () => { + expect( + isTaskOverdue(createTask({ dueDay: '3/14/2026' }), TODAY_STR, NO_OFFSET), + ).toBe(false); + }); + }); + + describe('dueWithTime', () => { + // Boundary is local start-of-day (dateStrToUtcDate returns local midnight), + // so build timestamps with local Date constructors to stay timezone-safe. + it('is overdue when dueWithTime is before the start of today', () => { + const ts = new Date(2026, 2, 14, 23, 0, 0).getTime(); + expect(isTaskOverdue(createTask({ dueWithTime: ts }), TODAY_STR, NO_OFFSET)).toBe( + true, + ); + }); + + it('is not overdue when dueWithTime is later today', () => { + const ts = new Date(2026, 2, 15, 10, 0, 0).getTime(); + expect(isTaskOverdue(createTask({ dueWithTime: ts }), TODAY_STR, NO_OFFSET)).toBe( + false, + ); + }); + + it('respects the start-of-next-day offset', () => { + // A 4h offset pushes the logical start of "today" forward, so a moment + // just after midnight still belongs to the previous logical day → overdue. + const offset = 4 * 60 * 60 * 1000; + const justAfterMidnight = new Date(2026, 2, 15, 1, 0, 0).getTime(); + expect( + isTaskOverdue(createTask({ dueWithTime: justAfterMidnight }), TODAY_STR, offset), + ).toBe(true); + expect( + isTaskOverdue( + createTask({ dueWithTime: justAfterMidnight }), + TODAY_STR, + NO_OFFSET, + ), + ).toBe(false); + }); + }); + + describe('no due date', () => { + it('is not overdue when the task has no due fields', () => { + expect(isTaskOverdue(createTask(), TODAY_STR, NO_OFFSET)).toBe(false); + }); + }); + + describe('shared threshold contract', () => { + it('getLogicalTodayStartMs returns local midnight shifted by the offset', () => { + const localMidnight = new Date(2026, 2, 15).getTime(); + expect(getLogicalTodayStartMs(TODAY_STR, NO_OFFSET)).toBe(localMidnight); + const offset = 4 * 60 * 60 * 1000; + expect(getLogicalTodayStartMs(TODAY_STR, offset)).toBe(localMidnight + offset); + }); + + it('isTaskOverdue delegates to isTaskOverdueByThreshold with the computed threshold', () => { + // Guards against the two overdue definitions drifting: isTaskOverdue must + // equal the threshold predicate fed the same logical start-of-today. + const offset = 4 * 60 * 60 * 1000; + const threshold = getLogicalTodayStartMs(TODAY_STR, offset); + const cases: Partial[] = [ + { dueDay: '2026-03-14' }, + { dueDay: '2026-03-15' }, + { dueWithTime: new Date(2026, 2, 15, 1, 0, 0).getTime() }, + {}, + ]; + cases.forEach((overrides) => { + const task = createTask(overrides); + expect(isTaskOverdue(task, TODAY_STR, offset)).toBe( + isTaskOverdueByThreshold(task, TODAY_STR, threshold), + ); + }); + }); + }); +}); diff --git a/src/app/features/tasks/util/is-task-overdue.ts b/src/app/features/tasks/util/is-task-overdue.ts new file mode 100644 index 0000000000..65f81e3d75 --- /dev/null +++ b/src/app/features/tasks/util/is-task-overdue.ts @@ -0,0 +1,60 @@ +import { Task } from '../task.model'; +import { isDBDateStr } from '../../../util/get-db-date-str'; +import { dateStrToUtcDate } from '../../../util/date-str-to-utc-date'; + +/** + * Compute the logical start-of-today (ms) — local midnight of `todayStr` shifted + * by the start-of-next-day offset. Extracted so the overdue predicate and its + * callers agree on one boundary definition. + */ +export const getLogicalTodayStartMs = ( + todayStr: string, + startOfNextDayDiffMs: number, +): number => { + const today = dateStrToUtcDate(todayStr); + today.setHours(0, 0, 0, 0); + // The logical start of "today" is shifted by the offset. + return today.getTime() + startOfNextDayDiffMs; +}; + +/** + * Overdue comparison against a *precomputed* logical start-of-today threshold. + * This is the single source of truth for "what counts as overdue"; both + * `isTaskOverdue` and `selectOverdueTaskIds` route through it so the two overdue + * definitions can never drift. Callers that iterate many tasks (the selector) + * compute the threshold once and pass it in, instead of per task. + * + * Priority follows the dueWithTime/dueDay mutual-exclusivity pattern. + */ +export const isTaskOverdueByThreshold = ( + task: Pick, + todayStr: string, + todayStartMs: number, +): boolean => + !!( + // String comparison works because dueDay is YYYY-MM-DD (lexicographically + // sortable), avoiding timezone conversion issues. + ( + (task.dueDay && isDBDateStr(task.dueDay) && task.dueDay < todayStr) || + (task.dueWithTime && task.dueWithTime < todayStartMs) + ) + ); + +/** + * Pure predicate for "is this task overdue" — its due date is before the logical + * "today". + * + * Kept clock-free/deterministic: the caller threads in `todayStr` (a DB date + * string, e.g. from `DateService.getLogicalTodayDate()`/`todayStr()`) and the + * start-of-next-day offset so custom start-of-day settings are respected. + */ +export const isTaskOverdue = ( + task: Pick, + todayStr: string, + startOfNextDayDiffMs: number, +): boolean => + isTaskOverdueByThreshold( + task, + todayStr, + getLogicalTodayStartMs(todayStr, startOfNextDayDiffMs), + ); From 762b3c5d8d4814d68057ca6add78e0a6ae0e7169 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 10 Jul 2026 13:30:03 +0200 Subject: [PATCH 06/25] feat(plugins): add Todoist import plugin with Import/Export launcher (#8882) * feat(plugins): add Todoist import plugin with Import/Export launcher * fix(plugins): fix cross-chunk subtask loss in todoist-import + hardening * fix(plugins): clamp non-finite Todoist durations + review polish * feat(plugins): add Eisenhower priority mapping to Todoist import Replace the single "map priorities to p1-p3 tags" checkbox with one mutually-exclusive control (Nothing / p1-p3 tags / Eisenhower matrix). The new Eisenhower option reuses SP's built-in urgent/important tags by title (p1 -> urgent+important, p2 -> important, p3 -> urgent, p4 -> none), so imported tasks populate the Eisenhower Matrix board with no new tag and no new plugin API. Collapsing Todoist's single priority axis onto the 2-D matrix is opinionated, so it stays opt-in and off by default. Requested in the review of #8882. * fix(plugins): harden Todoist import data integrity * fix(plugins): improve Todoist import feedback --- .github/workflows/plugin-tests.yml | 2 +- docs/plans/2026-07-09-todoist-import.md | 229 + docs/wiki/2.00-How_To.md | 2 + docs/wiki/2.20-Import-from-Todoist.md | 55 + packages/plugin-dev/scripts/build-all.js | 29 + .../plugin-dev/todoist-import/i18n/en.json | 76 + .../plugin-dev/todoist-import/jest.config.cjs | 25 + .../todoist-import/package-lock.json | 5689 +++++++++++++++++ .../plugin-dev/todoist-import/package.json | 23 + .../todoist-import/scripts/build.js | 54 + .../plugin-dev/todoist-import/src/icon.svg | 5 + .../todoist-import/src/manifest.json | 29 + .../src/map/plan-import.spec.ts | 308 + .../todoist-import/src/map/plan-import.ts | 232 + .../todoist-import/src/map/run-import.spec.ts | 234 + .../todoist-import/src/map/run-import.ts | 225 + .../todoist-import/src/parse/from-api.spec.ts | 463 ++ .../todoist-import/src/parse/from-api.ts | 482 ++ .../src/parse/load-todoist-data.spec.ts | 53 + .../src/parse/load-todoist-data.ts | 43 + .../src/parse/normalized-model.ts | 60 + .../plugin-dev/todoist-import/src/plugin.js | 4 + .../src/ui/build-lossy-notes.spec.ts | 100 + .../src/ui/build-lossy-notes.ts | 61 + .../todoist-import/src/ui/i18n.spec.ts | 22 + .../plugin-dev/todoist-import/src/ui/i18n.ts | 47 + .../todoist-import/src/ui/index.html | 53 + .../plugin-dev/todoist-import/src/ui/main.ts | 355 + .../plugin-dev/todoist-import/tsconfig.json | 18 + .../imex/file-imex/file-imex.component.html | 10 + .../file-imex/file-imex.component.spec.ts | 9 + src/app/imex/file-imex/file-imex.component.ts | 28 + src/app/plugins/plugin-bridge.service.ts | 2 + src/app/plugins/plugin.service.ts | 2 + .../task-batch-update.reducer.spec.ts | 21 + .../task-batch-update.reducer.ts | 9 +- .../root-store/meta/task-shared.actions.ts | 2 + src/app/t.const.ts | 2 + src/assets/i18n/en.json | 2 + 39 files changed, 9060 insertions(+), 5 deletions(-) create mode 100644 docs/plans/2026-07-09-todoist-import.md create mode 100644 docs/wiki/2.20-Import-from-Todoist.md create mode 100644 packages/plugin-dev/todoist-import/i18n/en.json create mode 100644 packages/plugin-dev/todoist-import/jest.config.cjs create mode 100644 packages/plugin-dev/todoist-import/package-lock.json create mode 100644 packages/plugin-dev/todoist-import/package.json create mode 100644 packages/plugin-dev/todoist-import/scripts/build.js create mode 100644 packages/plugin-dev/todoist-import/src/icon.svg create mode 100644 packages/plugin-dev/todoist-import/src/manifest.json create mode 100644 packages/plugin-dev/todoist-import/src/map/plan-import.spec.ts create mode 100644 packages/plugin-dev/todoist-import/src/map/plan-import.ts create mode 100644 packages/plugin-dev/todoist-import/src/map/run-import.spec.ts create mode 100644 packages/plugin-dev/todoist-import/src/map/run-import.ts create mode 100644 packages/plugin-dev/todoist-import/src/parse/from-api.spec.ts create mode 100644 packages/plugin-dev/todoist-import/src/parse/from-api.ts create mode 100644 packages/plugin-dev/todoist-import/src/parse/load-todoist-data.spec.ts create mode 100644 packages/plugin-dev/todoist-import/src/parse/load-todoist-data.ts create mode 100644 packages/plugin-dev/todoist-import/src/parse/normalized-model.ts create mode 100644 packages/plugin-dev/todoist-import/src/plugin.js create mode 100644 packages/plugin-dev/todoist-import/src/ui/build-lossy-notes.spec.ts create mode 100644 packages/plugin-dev/todoist-import/src/ui/build-lossy-notes.ts create mode 100644 packages/plugin-dev/todoist-import/src/ui/i18n.spec.ts create mode 100644 packages/plugin-dev/todoist-import/src/ui/i18n.ts create mode 100644 packages/plugin-dev/todoist-import/src/ui/index.html create mode 100644 packages/plugin-dev/todoist-import/src/ui/main.ts create mode 100644 packages/plugin-dev/todoist-import/tsconfig.json diff --git a/.github/workflows/plugin-tests.yml b/.github/workflows/plugin-tests.yml index 8667a8d81a..34e7e31515 100644 --- a/.github/workflows/plugin-tests.yml +++ b/.github/workflows/plugin-tests.yml @@ -34,7 +34,7 @@ jobs: run: | set -euo pipefail # Plugins that have an `npm test` script configured. - PLUGINS=(automations azure-devops-issue-provider caldav-calendar-provider clickup-issue-provider google-calendar-provider sync-md) + PLUGINS=(automations azure-devops-issue-provider caldav-calendar-provider clickup-issue-provider google-calendar-provider sync-md todoist-import) if [ "$EVENT_NAME" = "workflow_dispatch" ]; then CHANGED=$(printf '%s\n' "${PLUGINS[@]}") diff --git a/docs/plans/2026-07-09-todoist-import.md b/docs/plans/2026-07-09-todoist-import.md new file mode 100644 index 0000000000..bf0a523a40 --- /dev/null +++ b/docs/plans/2026-07-09-todoist-import.md @@ -0,0 +1,229 @@ +# Todoist → Super Productivity migration + +Status: **revised & verified against code** · 2026-07-09 + +Goal: let a Todoist user bring their **active** projects, tasks, sub-tasks, labels, +due dates and time estimates into Super Productivity in one pass, non-destructively, +without adding permanent weight to the core app. + +## Product framing + +- Migration runs **once per user** and is then dead weight. Per the manifesto (avoid + feature creep; new UI/settings are permanent costs), it lives at the edge, not in + core hot paths. +- It must be **additive**, never destructive — most people evaluating SP already have + some data. The existing `importCompleteBackup` path (wipes all state) is the wrong tool. +- It is a **one-time import**, not a live integration. The issue-provider framework + (`src/app/features/issue/`) is built for ongoing polling + remote-linked tasks and is + deliberately **not** used here. + +## Chosen approach + +**A bundled plugin does all the work; core gets only a launcher row.** + +- Front-end + parsing + mapping + preview UI = a **bundled plugin** + (`packages/plugin-dev/todoist-import/`, built into `src/assets/bundled-plugins/`). + Matches the maintainers' direction (Trello / Linear / ClickUp / Azure moved _out_ of + core into plugins); fully replaceable / community-extensible. +- Landing the data uses only existing plugin-API methods — **zero new core API**: + - `addProject` / `addTag` for containers, + - **`batchUpdateForProject`** for all task creation (see below — this was missed in + the first draft and changes the op-log story), + - `updateTask` follow-ups only for fields the batch op doesn't carry + (`dueDay`, `dueWithTime`, `tagIds`). +- The plugin ships `isSkipMenuEntry: true` — no permanent menu noise for a one-time + tool. **Discoverability** comes from a single launcher row in the Import/Export + settings screen (`src/app/imex/file-imex/`): `PluginService.activatePlugin(id, true)` + then navigate to the existing `plugins/:pluginId/index` route. Deliberately the + **in-memory** enable (plugin-management additionally persists via + `setPluginEnabled`): after a restart the importer is dormant again — zero standing + weight; relaunching from the same row re-activates it. + +### Key correction #1: use `batchUpdateForProject`, not per-task `addTask` + +Verified in `plugin-bridge.service.ts:1209` + `task-shared-meta-reducers/task-batch-update.reducer.ts`: + +- The batch API is **backed by a meta-reducer**: one dispatched chunk (≤ 50 ops, + `MAX_BATCH_OPERATIONS_SIZE`) = **one action = one op-log entry** (sync rule #3). + A 1000-task import ≈ 20 ops for structure instead of 1000+. +- It handles **parent references via temp IDs** (bridge pre-generates real IDs and + returns the mapping) and preserves creation order — root tasks land in + `project.taskIds` in array order (verified in + `validate-and-fix-data-consistency-after-batch-update.ts`). No reorder op needed for + freshly created projects. +- Per-task `addTask` would additionally **reverse ordering** (bridge hardcodes + `isAddToBottom: false`, i.e. prepend) — another reason the first draft's approach + was wrong. +- Batch create data carries `title / notes / isDone / parentId / timeEstimate` only; + `dueDay`, `dueWithTime` and `tagIds` are applied with one `updateTask` per task that + has them. Ops ≈ `ceil(tasks/50) per project + dated/labelled tasks` — a tolerable + one-time burst, and it removes the main driver for a v2 `importData` core primitive. +- **Hard constraints found in review** (the reducer enforces these silently): + - temp IDs **must** be `temp-`/`temp_`-prefixed — anything else leaves children with + dangling `parentId`s that the consistency pass **deletes** as orphans; + - a parent's create op must sit at a **lower index than its children's** — the bridge + chunks at 50 ops/action and the roots-first sort is per-chunk only; + - the plugin **chunks its own calls at ≤ 50 ops and awaits each** — every iframe call + is its own postMessage round-trip, so this keeps one dispatch per tick (sync rule + #6) even for 5k-task projects, where the bridge's internal `forEach` chunking would + dispatch 100 actions in one tick; + - **ordering alone is NOT enough across self-chunked calls** (caught by the + post-implementation multi-review): the bridge builds `createdTaskIds` **per call**, + so a child sent in a later call cannot resolve a `temp-` parent from an earlier + call — the executor must **rewrite already-created parents to their real IDs** + before sending each chunk (`resolveKnownParents` in `run-import.ts`; real IDs are + explicitly supported in `BatchTaskCreate.parentId`); + - the result is fire-and-forget (`success: true` always, `errors` never populated) — + the **post-import summary re-reads state** (`getTasks`) and compares landed vs + planned counts instead of trusting the return value. + +### Key correction #2: what the plugin API actually can't do (verified) + +1. **No `TaskRepeatCfg` creation** — `PluginTaskRepeatCfg` is read-only. v1 degrades: + keep next due date + append `Repeats: ` to notes. +2. **SP _does_ have a Section entity now** (`src/app/features/section/`) — the first + draft claimed it didn't. But there is **no plugin API / allowed action** to create + sections, so v1 still drops Todoist sections (task order within the project is + preserved; flagged as lossy). v2 candidate: expose section creation to plugins. +3. **No ProjectFolder creation API** (`Project.folderId` exists, but nothing creates + folders) — the first draft's `parent_id → folderId` mapping is unworkable. v1 + **flattens nested projects**; when two projects collide on title, the child is + disambiguated as `Parent / Child`. Flagged as lossy. +4. **Subtasks can't hold tags** (bridge forces `tagIds: []` for subtasks — SP model) + → labels on Todoist sub-tasks are dropped, flagged in the summary. + +### v2 (only if v1 validates demand) + +`addTaskRepeatCfg` and/or plugin-visible section/folder creation. Deferring is +deliberate (YAGNI): public plugin-API surface is hard to reverse. The bulk-import +primitive from the first draft is **no longer needed** — `batchUpdateForProject` +already folds structure into few ops. One more v2 candidate (from the perf review): +extend `BatchTaskUpdate` with `dueDay`/`dueWithTime`/`tagIds` — the per-task +`updateTask` follow-ups are the dominant import cost (one op each; O(k²) planner-day +scans at 5k dated tasks) and today there is no cheaper path. + +## Input source + +**API token only in v1.** The user pastes a Todoist personal token (Settings → +Integrations → Developer); the plugin makes an initial +`POST https://api.todoist.com/api/v1/sync` with `sync_token=*`, followed immediately +by an incremental request with the returned sync token. This applies changes that +arrived while Todoist prepared a potentially delayed full snapshot. Both requests use +`resource_types=["projects","items","sections","notes"]` (`notes` = task comments, +folded into SP task notes; the `labels` resource is deliberately NOT requested — +item labels arrive as names on the items themselves) via the gated `PluginAPI.request` +(`permissions:["http"]` + `allowedHosts:["api.todoist.com"]`). The old Sync **v9** +endpoint is deprecated — use unified **v1**. + +**Token privacy (hard rule):** the token lives in iframe memory for the session only — +never `persistDataSynced` (that syncs!), not even `setSecret`. Password-type input; UI +states "sent only to api.todoist.com, never stored". + +**CSV fallback: cut from v1 (YAGNI, folded review verdict).** It is a second parser + +fixture suite + multi-file UI for strictly worse fidelity (no labels, no comments, no +tz fidelity, one tedious export per project) — and its `DATE` column holds _localized +natural-language_ strings ("every day", "5 août") that cannot be parsed faithfully. +Named contingency for users who already closed their account; fast-follow, not v1. + +Completed-task history is out of scope for v1 (the sync endpoint returns only active +items by default — nothing extra to do). + +## Mapping + +| Todoist | → Super Productivity | Notes | +| --------------------------------------------- | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| project | Project | hierarchy **flattened** (no folder API, see correction #2); `Parent / Child` title only on collision | +| Inbox project (`inbox_project`) | Project `Inbox (Todoist)` | never merged into SP's own Inbox — additive & reviewable | +| section | — | **v1: ignore** (no plugin API to create SP sections); flagged lossy. Sibling order key = `(section.section_order, item.child_order)` — sync items arrive unordered! | +| label | Tag | item `labels` are **names** in unified v1; match existing SP tags by title (case-insensitive), else `addTag`; only labels actually used by imported top-level tasks (SP subtasks can't hold tags — dropped + counted; the plugin must enforce this itself, the host won't) | +| item content | task `title` | | +| item description | task `notes` | markdown passes through | +| comments (sync `notes`) | appended to task `notes` | same sync call; file attachments → keep the URL line, flag files as not imported | +| priority (API `4`=p1 … `1`=p4) | — | **inverted vs UI!** Opt-in, default **off**, top-level only; **never tag API priority 1** (p4 is Todoist's default on every task). Single control with two mappings: `p1`…`p3` **Tags**, or **Eisenhower** (reuses SP's `urgent`/`important` tags: p1→both, p2→important, p3→urgent — added post-review per #8882 feedback) | +| due (all-day, `YYYY-MM-DD`) | `dueDay` | via `updateTask` after batch create | +| due (floating, `YYYY-MM-DDTHH:MM:SS`) | `dueWithTime` (unix ms) | parse as **local** time | +| due (fixed-tz, trailing `Z`) | `dueWithTime` (unix ms) | parse as **UTC instant** — parsing as local shifts every fixed-time task | +| deadline | `dueDay` if no due; else `Deadline: ` appended to notes | nothing silently dropped | +| duration `minute` | `timeEstimate` (ms) | | +| duration `day` | — | **skip + count in summary** — fabricating 8h would corrupt time-tracking stats | +| sub-task (`parent_id`) | sub-task (2 levels) | SP nests **2 levels only** — depth ≥ 2 re-parents to the depth-0 ancestor in reading (DFS) order, demotion counted | +| assignee (`responsible_uid`) | — | imported like any task; "N tasks had collaborator assignees" in summary | +| recurring (`due.is_recurring` + `due.string`) | keep next due + append verbatim `Repeats: ` to notes | verbatim preserves `every!` (recur-from-completion) semantics; real `TaskRepeatCfg` only with v2 core work. Imported timed tasks get **no reminder** (`updateTask` bypasses `scheduleTaskWithTime`) — noted, acceptable: Todoist reminders aren't imported anyway | +| completed items | — | skip v1 | + +## Architecture / file layout (v1) + +``` +packages/plugin-dev/todoist-import/ + package.json # esbuild + jest, modeled on sync-md (no framework) + scripts/build.js # bundle ui/main.ts, INLINE bundle into index.html, copy manifest/icon/i18n + src/ + manifest.json # iFrame:true, isSkipMenuEntry:true, permissions incl. "http", + # allowedHosts:["api.todoist.com"], hooks:[] + plugin.js # stub (all logic lives in the iframe UI) + ui/index.html # minimal shell; built JS inlined (iframe uses srcdoc → + # the document must be fully self-contained, verified in + # plugin-iframe.util.ts) + ui/main.ts # wizard: token → preview (per-project checkboxes) → import → summary + parse/from-api.ts # unified-v1 sync JSON → normalized model (pure) + parse/normalized-model.ts + map/plan-import.ts # normalized model → batch ops + follow-up updates (pure) + map/run-import.ts # executes the plan via PluginAPI, per-project failure boundary + *.spec.ts # jest over fixtures: due shapes, depth-3 nesting, section order, + # labels, priority inversion, duration units, deadline, comments +``` + +UI wizard specifics (trust items from review): + +- **Preview = per-project checkboxes** with task/subtask counts; projects whose title + already exists in SP are flagged "already exists — possibly from a previous import" + and default **unchecked** (re-run safety without rollback machinery). Lossy items + listed up front (sections, demotions, day-durations, subtask labels, assignees). +- **Import runs project-by-project** (batch + follow-ups per project before the next), + so an abort leaves whole projects, and the summary can say "4/6 imported, failed at + 'Errands'". +- Post-import summary counts from re-read state, names everything dropped. + +- Register in `packages/plugin-dev/scripts/build-all.js` and + `src/app/plugins/plugin.service.ts` bundled list. +- Core touch (discoverability only): one launcher button in + `src/app/imex/file-imex/` + one `en.json` key. + +## Milestones (each with its check) + +- **M0 · Spike — ✅ DONE, verdict GREEN.** Token path viable on web, Electron, mobile + via gated `PluginAPI.request` (`plugin-bridge.service.ts:508`); app CSP is + `connect-src *`; Electron injects ACAO:\*. Todoist's unified API documentation says + all endpoints except the initial OAuth authorization endpoint support CORS for any + origin; retain one live web-build sanity check during M3. +- **M1 · Parse + normalize.** Sync-v1 JSON → normalized model. → _verify:_ jest + fixtures: parent chains incl. depth 3+, the three `due.date` shapes, deadline, + recurring strings, durations (minute/day), priority inversion, section ordering, + comments incl. attachments, Inbox, assignees. +- **M2 · Plan + create.** Pure op-builder (normalized model → project/tag creates, + ≤50-op batch chunks parent-before-child with `temp-` IDs, follow-up updates) + + executor. → _verify:_ unit tests on the op-builder; manual import of a fixture into + a scratch profile: counts, nesting, order, due dates. +- **M3 · UI + preview + summary.** Token input, per-project preview, import progress, + honest summary. → _verify:_ manual run web + Electron incl. full + incremental sync. +- **M4 · Discoverability + docs.** Launcher row in Import/Export + (`activatePlugin` + route to `plugins/todoist-import/index`); "Switch from Todoist" + docs page (search is how the day-they-quit-Todoist persona finds this — no + onboarding banners, per the manifesto). → _verify:_ new user completes an import + from a cold start. + +## Risks & open decisions + +- **Partial import on failure** — additive, not transactional; per-project execution + bounds the blast radius to whole projects and the summary names what landed. + Accepted for v1 (KISS) — no rollback machinery. +- **Archived-project re-runs** — `getAllProjects()` exposes active projects only, so + an archived prior import cannot be collision-flagged. Document the restore/delete + workaround; do not widen a permanent public plugin API solely for this importer. +- **Follow-up `updateTask` volume** — one per dated/labelled task; bounded by the + batch op for structure; acceptable one-time burst. Watch 5k+ item accounts. +- **Todoist API drift** — unified v1 is current (v9 deprecated); parser is defensive + (unknown fields ignored, missing fields defaulted) and covered by fixtures. +- **Decided during review:** CSV cut from v1 · priority→tag default off (and API + priority 1 never tagged) · duration `day` skipped, not 8h · Inbox → "Inbox + (Todoist)" · collision projects default-unchecked in preview. diff --git a/docs/wiki/2.00-How_To.md b/docs/wiki/2.00-How_To.md index 999a666665..2ffc0b0be3 100755 --- a/docs/wiki/2.00-How_To.md +++ b/docs/wiki/2.00-How_To.md @@ -20,3 +20,5 @@ Every How-To note in the wiki. For structure and how to write How-Tos, see [[0.0 - [[2.16-Set-Up-Development-Environment]] - [[2.17-Add-a-New-Issue-Integration]] - [[2.18-Contribute-Translations]] +- [[2.19-Sync-Proton-Drive-via-rclone]] +- [[2.20-Import-from-Todoist]] diff --git a/docs/wiki/2.20-Import-from-Todoist.md b/docs/wiki/2.20-Import-from-Todoist.md new file mode 100644 index 0000000000..6eeb1b7961 --- /dev/null +++ b/docs/wiki/2.20-Import-from-Todoist.md @@ -0,0 +1,55 @@ +# Import from Todoist + +Bring your active Todoist projects, tasks, sub-tasks, labels and due dates into Super Productivity in one pass. The import is **additive**: it only creates new projects, tags and tasks and never changes or removes existing Super Productivity data. + +## What you need + +- A Todoist account and its **API token**: in Todoist, open **Settings → Integrations → Developer** and copy the token. The token is sent only to `api.todoist.com` and is never stored. + +## Steps + +1. Open **Settings** (gear icon in the sidebar). +2. Open the **Sync & Backup** tab and find the **Import/Export** section. +3. Click **Import from Todoist**. +4. Paste your API token and click **Load preview**. +5. Review the preview: pick the projects to import (projects whose name already exists — for example from a previous run — are flagged and unchecked by default) and optionally choose how to carry over Todoist priorities (see [Priorities](#priorities) below). +6. Click **Import**. The summary lists what was created and everything that could not be carried over. + +## What is imported + +- Active projects (nested project hierarchy is flattened; the Todoist Inbox becomes a project named “Inbox (Todoist)”) +- Active tasks and sub-tasks (Super Productivity nests two levels; deeper sub-tasks become direct sub-tasks of their top-level task) +- Task descriptions and comments (into task notes; HTTP(S) comment file attachments keep their link) +- Labels used by imported tasks (as tags, on top-level tasks) +- Due dates, including times, and minute-based durations (as time estimates) +- Recurring tasks keep their next due date; the recurrence rule is added to the task notes (e.g. `Repeats: every 3 days`) + +## Priorities + +Todoist priorities are **off by default**. In the preview you can pick one of: + +- **p1–p3 tags** — adds a `p1`, `p2` or `p3` tag to each top-level task (Todoist's default p4 stays untagged). +- **Eisenhower matrix** — reuses Super Productivity's built-in `urgent` / `important` tags, so imported tasks appear in the Eisenhower Matrix board: p1 → urgent + important, p2 → important, p3 → urgent, p4 → neither. Because a single Todoist priority is being split across the two axes, this mapping is a sensible default rather than an exact translation. + +Priority tags are only added to top-level tasks — sub-tasks in Super Productivity can't hold tags. +The preview counts prioritized sub-tasks that will not receive the selected priority tags. + +## What is not imported + +- Completed tasks and task history +- Nested project hierarchy and sections (project and task order is preserved) +- Labels and mapped priorities on sub-tasks +- Reminders, full-day durations, collaborator assignees and attachment files +- Recurrence rules as real repeating tasks — recreate important ones via [[2.06-Manage-Repeating-Tasks]] + +Unusually long project names, task titles, labels and task notes are shortened to +safe import limits. The preview reports how many selected values are affected. + +If the import stops midway (e.g. connection loss), delete the project named in the +error because it may be incomplete. Then re-run the import and select that project +and any remaining projects. Projects already listed as imported are complete. To +undo an import, delete the created projects. + +The duplicate-title warning can check active Super Productivity projects only. If a +previously imported project was archived, restore or delete it before re-running the +import so that it can be detected. diff --git a/packages/plugin-dev/scripts/build-all.js b/packages/plugin-dev/scripts/build-all.js index 0e09f46071..08f9b73fff 100755 --- a/packages/plugin-dev/scripts/build-all.js +++ b/packages/plugin-dev/scripts/build-all.js @@ -393,6 +393,35 @@ const plugins = [ return 'Built and copied to assets'; }, }, + { + name: 'todoist-import', + path: 'todoist-import', + needsInstall: true, + copyToAssets: true, + buildCommand: async (pluginPath) => { + await execAsync(`cd ${pluginPath} && npm run build`); + const targetDir = path.join( + __dirname, + '../../../src/assets/bundled-plugins/todoist-import', + ); + if (!fs.existsSync(targetDir)) { + fs.mkdirSync(targetDir, { recursive: true }); + } + const distPath = path.join(pluginPath, 'dist'); + if (fs.existsSync(distPath)) { + const files = fs.readdirSync(distPath); + for (const file of files) { + copyRecursive(path.join(distPath, file), path.join(targetDir, file)); + } + } + assertFilesExist( + targetDir, + ['manifest.json', 'plugin.js', 'index.html', 'icon.svg', 'i18n/en.json'], + 'todoist-import', + ); + return 'Built and copied to assets'; + }, + }, ]; async function buildPlugin(plugin) { diff --git a/packages/plugin-dev/todoist-import/i18n/en.json b/packages/plugin-dev/todoist-import/i18n/en.json new file mode 100644 index 0000000000..406695ad9f --- /dev/null +++ b/packages/plugin-dev/todoist-import/i18n/en.json @@ -0,0 +1,76 @@ +{ + "TITLE": { + "IMPORT": "Import from Todoist", + "PREVIEW": "Preview", + "IMPORTING": "Importing…", + "FINISHED": "Import finished", + "INCOMPLETE": "Import incomplete" + }, + "TOKEN": { + "LABEL": "Todoist API token", + "PLACEHOLDER": "Paste your Todoist API token", + "INTRO": "Brings your active Todoist projects, tasks, sub-tasks, labels and due dates into Super Productivity. The import only adds data — nothing in Super Productivity is changed or removed.", + "HELP": "Find the token in Todoist under Settings → Integrations → Developer. It is sent only to api.todoist.com and never stored.", + "REQUIRED": "Please paste your Todoist API token first.", + "LOADING": "Loading your Todoist data…", + "NO_PROJECTS": "No active projects found for this Todoist account." + }, + "ERROR": { + "LOAD_FAILED": "Could not load data from Todoist: {{error}} — check the token and your connection.", + "IMPORT_FAILED": "Import failed: {{error}}", + "IMPORT_STOPPED": "Import stopped at “{{project}}”: {{error}}. That project was created only partially — delete “{{project}}” before re-running, then select it and the remaining projects again." + }, + "BUTTON": { + "LOAD_PREVIEW": "Load preview", + "IMPORT": "Import", + "BACK": "Back" + }, + "PREVIEW": { + "CHOOSE_PROJECTS": "Choose the projects to import:", + "PROJECT_COUNTS": "{{title}} — tasks: {{taskCount}}, sub-tasks: {{subTaskCount}}", + "ALREADY_EXISTS": "already exists — possibly imported before", + "PRIORITY_LEGEND": "Map Todoist priorities to:", + "PRIORITY_NONE": "Nothing", + "PRIORITY_TAGS": "p1–p3 tags (p4 stays untagged)", + "PRIORITY_EISENHOWER": "Eisenhower matrix — urgent / important tags", + "LOSS_HEADING": "What will not survive the move", + "SELECT_PROJECT": "Select at least one project to import." + }, + "LOSS": { + "NESTED_PROJECTS": "Nested projects flattened into the project list: {{count}}.", + "SECTIONS": "Sections dropped (tasks keep their order in the project): {{count}}.", + "DEMOTED_SUBTASKS": "Deeply nested sub-tasks made direct sub-tasks (2 levels max): {{count}}.", + "RECURRING": "Recurring tasks keeping only their next date (the rule is noted in task notes): {{count}}.", + "DAY_DURATIONS": "Full-day durations not imported: {{count}}.", + "SUBTASK_LABELS": "Sub-task labels not imported (sub-tasks have no tags): {{count}}.", + "SUBTASK_PRIORITIES": "Prioritized sub-tasks not receiving priority tags: {{count}}.", + "ASSIGNEES": "Task collaborator assignments dropped: {{count}}.", + "ATTACHMENTS": "Comment attachment links kept without their files: {{count}}.", + "TRUNCATED_FIELDS": "Unusually long values shortened to safe import limits: {{count}}.", + "COMPLETED_AND_REMINDERS": "Completed tasks and reminders are not imported." + }, + "IMPORT": { + "STARTING": "Starting…", + "PHASE_PROJECT": "creating project", + "PHASE_TASKS": "creating tasks", + "PHASE_DETAILS": "applying dates and tags {{current}}/{{total}}", + "PROGRESS": "Project {{current}} of {{total}}: {{title}} — {{detail}}" + }, + "SUMMARY": { + "PROJECT_RESULT": "{{title}}: {{landedTasks}} of {{plannedTasks}} tasks, {{landedSubTasks}} of {{plannedSubTasks}} sub-tasks", + "SHORTFALL": "some items did not land; please review", + "UNVERIFIED": "Could not verify the imported counts — the numbers above may show 0 even for tasks that landed.", + "CREATED_TAGS": "Created tags: {{tags}}", + "NOT_CARRIED_OVER": "Not carried over", + "SNACK_FINISHED": "Todoist import finished", + "UNDO": "The import is additive — to undo it, delete the created projects." + }, + "PARSE": { + "UNTITLED_PROJECT": "Untitled project", + "UNTITLED_TASK": "Untitled task", + "REPEATS": "Repeats: {{rule}}", + "DEADLINE": "Deadline: {{date}}", + "COMMENTS": "Comments:", + "FILE": "file" + } +} diff --git a/packages/plugin-dev/todoist-import/jest.config.cjs b/packages/plugin-dev/todoist-import/jest.config.cjs new file mode 100644 index 0000000000..98d8ac7256 --- /dev/null +++ b/packages/plugin-dev/todoist-import/jest.config.cjs @@ -0,0 +1,25 @@ +/** @type {import('jest').Config} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'jsdom', + transform: { + '^.+\\.ts$': [ + 'ts-jest', + { + tsconfig: { + module: 'commonjs', + target: 'es2020', + esModuleInterop: true, + }, + }, + ], + }, + testMatch: ['**/*.spec.ts'], + moduleFileExtensions: ['ts', 'js', 'json'], + testPathIgnorePatterns: ['/node_modules/', '/dist/'], + roots: ['/src'], + moduleNameMapper: { + '^@super-productivity/plugin-api$': + '/node_modules/@super-productivity/plugin-api/src/index.ts', + }, +}; diff --git a/packages/plugin-dev/todoist-import/package-lock.json b/packages/plugin-dev/todoist-import/package-lock.json new file mode 100644 index 0000000000..f8e3c25869 --- /dev/null +++ b/packages/plugin-dev/todoist-import/package-lock.json @@ -0,0 +1,5689 @@ +{ + "name": "todoist-import", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "todoist-import", + "version": "1.0.0", + "dependencies": { + "@super-productivity/plugin-api": "file:../../plugin-api" + }, + "devDependencies": { + "@types/jest": "^30.0.0", + "@types/node": "^22.15.33", + "esbuild": "^0.28.1", + "jest": "^30.0.4", + "jest-environment-jsdom": "^30.0.4", + "ts-jest": "^29.4.0", + "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/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.4.1.tgz", + "integrity": "sha512-v3bhyxUh9Hgmo5p6hAOXe14/R3ZxZDOsvHleh4B07z3m/x4/ngPUXEm9XwK4sF4u+f+P2ORb0Ge+MgpaqRMVDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/core": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.4.2.tgz", + "integrity": "sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.4.1", + "@jest/pattern": "30.4.0", + "@jest/reporters": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "exit-x": "^0.2.2", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-changed-files": "30.4.1", + "jest-config": "30.4.2", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-resolve-dependencies": "30.4.2", + "jest-runner": "30.4.2", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "jest-watcher": "30.4.1", + "pretty-format": "30.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/diff-sequences": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", + "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", + "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-mock": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.4.1.tgz", + "integrity": "sha512-dSlKrqug3siYNHVnjwIldShY12wAH3spwRltO/+8VOjg0X+xEq7vOs3DbBs4LRKsu7OH+NUb9kuZUNBF9Ho3TA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/jsdom": "^21.1.7", + "@types/node": "*", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/@jest/expect": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "30.4.1", + "jest-snapshot": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.4.1.tgz", + "integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", + "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@sinonjs/fake-timers": "^15.4.0", + "@types/node": "*", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.4.1.tgz", + "integrity": "sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/types": "30.4.1", + "jest-mock": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", + "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.4.1.tgz", + "integrity": "sha512-/SnkPCzEQpUaBH81kjdEdDdo2WZl5hxw+BmLDGWjRkm8o7XlhjwsU36cqwe5PGBE5WYpBvDzRSdXx9rbGuJtNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@jridgewell/trace-mapping": "^0.3.25", + "@types/node": "*", + "chalk": "^4.1.2", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^5.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", + "slash": "^3.0.0", + "string-length": "^4.0.2", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/snapshot-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.4.1.tgz", + "integrity": "sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", + "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "callsites": "^3.1.0", + "graceful-fs": "^4.2.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.4.1.tgz", + "integrity": "sha512-/ZG7pgEiOmmWkN9TplKbOu4id2N5lh7FHwRwlkgBVAzGdRH+OkkQ8wX/kIxg4zmd3ZQvAL1RwL2yWsvNYYECTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.4.1", + "@jest/types": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.4.1.tgz", + "integrity": "sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.4.1", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.4.1.tgz", + "integrity": "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/types": "30.4.1", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.1", + "chalk": "^4.1.2", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", + "pirates": "^4.0.7", + "slash": "^3.0.0", + "write-file-atomic": "^5.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", + "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.50", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.50.tgz", + "integrity": "sha512-ydBWw0G6WFwWHzh9RK4B5c690UkreOG0llq0r+DaI7LgKgxigf8mhHzIPI3S0850g1BPkq/zpuCfrq4QFgUlTQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@super-productivity/plugin-api": { + "resolved": "../../plugin-api", + "link": true + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, + "node_modules/@types/jsdom": { + "version": "21.1.7", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", + "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.2.tgz", + "integrity": "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/babel-jest": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.4.1.tgz", + "integrity": "sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "30.4.1", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.4.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", + "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", + "dev": true, + "license": "BSD-3-Clause", + "workspaces": [ + "test/babel-8" + ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.4.0.tgz", + "integrity": "sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/babel__core": "^7.20.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.4.0.tgz", + "integrity": "sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "30.4.0", + "babel-preset-current-node-syntax": "^1.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-beta.1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.42", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", + "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", + "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001800", + "electron-to-chromium": "^1.5.387", + "node-releases": "^2.0.50", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001803", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz", + "integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.389", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", + "integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/exit-x": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", + "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.4.1", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jest": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.4.2.tgz", + "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.4.2", + "@jest/types": "30.4.1", + "import-local": "^3.2.0", + "jest-cli": "30.4.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.4.1.tgz", + "integrity": "sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.1.1", + "jest-util": "30.4.1", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-circus": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.4.2.tgz", + "integrity": "sha512-rvHH7VlY6LgbJXJTQ87GW62g1FntOtbhh0zT+v04kC+pgL6aBKyYINXxWukCpj3dcIBMw5/XUbtDS9dU9JTXeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "co": "^4.6.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "p-limit": "^3.1.0", + "pretty-format": "30.4.1", + "pure-rand": "^7.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-cli": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.4.2.tgz", + "integrity": "sha512-jfA2ocvVHMXS2QijrJ0d31ektP+d/W0T5RpcTX2Pq+3sVqHlsXVCM2+FmwpL+bdY8OfHpIg9xMxLF17Zg0U49Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.4.2", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "yargs": "^17.7.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.4.2.tgz", + "integrity": "sha512-rNHAShJQqQwFNoL0hbf3BphSBOWnpOUAKvidLS/AjNVLPfoj5mSf4jQMfW3cYOs6hXeZC7nF7mDHaBnbxELOzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/get-type": "30.1.0", + "@jest/pattern": "30.4.0", + "@jest/test-sequencer": "30.4.1", + "@jest/types": "30.4.1", + "babel-jest": "30.4.1", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-circus": "30.4.2", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-runner": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "parse-json": "^5.2.0", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "esbuild-register": ">=3.4.0", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild-register": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", + "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.4.0", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.4.0.tgz", + "integrity": "sha512-ZPMabUZCx5MpbZ2eBYSvZ0J8fvo3dR9oM+eeUpb3aKNQFuS2tu3Duw1TNlMoP8k3WQgKGJuhcMFvwcVuq6T7oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-each": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.4.1.tgz", + "integrity": "sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.4.1", + "chalk": "^4.1.2", + "jest-util": "30.4.1", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.4.1.tgz", + "integrity": "sha512-o3nfaN4zej7qgk2X0j8Jhq/S9nAVKs2xK3QeQxeHVvpkEPxaA1yxDGydR+iVI7zPy7Cp62Aq2h3Ja46QvfWHGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/environment-jsdom-abstract": "30.4.1", + "jsdom": "^26.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jest-environment-node": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.4.1.tgz", + "integrity": "sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-mock": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.4.1.tgz", + "integrity": "sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", + "picomatch": "^4.0.3", + "walker": "^1.0.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.3" + } + }, + "node_modules/jest-leak-detector": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.4.1.tgz", + "integrity": "sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", + "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.4.1", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.4.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", + "picomatch": "^4.0.3", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-mock": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.4.1.tgz", + "integrity": "sha512-Zry8Yq/yJcNAZ7dJ5F2heic8AheXvbFZ7XI5V+h28nrYZ7Qoyy4dItq8OodjnYD270mvX+ZudmrNV9cysqhW5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "jest-pnp-resolver": "^1.2.3", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "slash": "^3.0.0", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.4.2.tgz", + "integrity": "sha512-gDiVh1I+GxYzz9oXlyw+1wv6VOYX1WYxMOfjsA3iGKePV2oxmbHhwxfkALxNxYy1ciw6APWwkW2zZONwP97aEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "30.4.0", + "jest-snapshot": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.4.2.tgz", + "integrity": "sha512-2dw0PslVYXxffXGpLo+Ejad+KcI1Qkjn7f4X4619gf21oCUmL+SPfjqIa/losUem3yEOvfNZe/F1HWUcNpODcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.4.1", + "@jest/environment": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-haste-map": "30.4.1", + "jest-leak-detector": "30.4.1", + "jest-message-util": "30.4.1", + "jest-resolve": "30.4.1", + "jest-runtime": "30.4.2", + "jest-util": "30.4.1", + "jest-watcher": "30.4.1", + "jest-worker": "30.4.1", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.4.2.tgz", + "integrity": "sha512-3/5e8iPz2k/VLqlr8DgTftYyLUv8Su3FkCAO2/Od81UsUTpSxOrS6O5x5KkoQwyUjmpYyDJKeyAvg2T2nvpNkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/globals": "30.4.1", + "@jest/source-map": "30.0.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "cjs-module-lexer": "^2.1.0", + "collect-v8-coverage": "^1.0.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.4.1.tgz", + "integrity": "sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.4.1", + "@jest/get-type": "30.1.0", + "@jest/snapshot-utils": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "babel-preset-current-node-syntax": "^1.2.0", + "chalk": "^4.1.2", + "expect": "30.4.1", + "graceful-fs": "^4.2.11", + "jest-diff": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "pretty-format": "30.4.1", + "semver": "^7.7.2", + "synckit": "^0.11.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.4.1.tgz", + "integrity": "sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.4.1", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.4.1.tgz", + "integrity": "sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "jest-util": "30.4.1", + "string-length": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.4.1.tgz", + "integrity": "sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.4.1", + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/react-is-18": { + "name": "react-is", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-is-19": { + "name": "react-is", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-length/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-length/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/synckit": { + "version": "0.11.13", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", + "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.3.6" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-jest": { + "version": "29.4.11", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.11.tgz", + "integrity": "sha512-IrFl7l9AuB/qrNw5quqvAv/hmKMb8dhWOH4jQOGo0Oq8tCeo1O86/iTFG1FaRimgUkF13l4PcepO8ATFT6Ns4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.9", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.8.0", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <7" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "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/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/packages/plugin-dev/todoist-import/package.json b/packages/plugin-dev/todoist-import/package.json new file mode 100644 index 0000000000..8cf9161596 --- /dev/null +++ b/packages/plugin-dev/todoist-import/package.json @@ -0,0 +1,23 @@ +{ + "name": "todoist-import", + "version": "1.0.0", + "description": "One-time import of active Todoist projects, tasks, labels and due dates into Super Productivity", + "private": true, + "scripts": { + "build": "node scripts/build.js", + "test": "jest", + "test:watch": "jest --watch" + }, + "devDependencies": { + "@types/jest": "^30.0.0", + "@types/node": "^22.15.33", + "esbuild": "^0.28.1", + "jest": "^30.0.4", + "jest-environment-jsdom": "^30.0.4", + "ts-jest": "^29.4.0", + "typescript": "^5.8.3" + }, + "dependencies": { + "@super-productivity/plugin-api": "file:../../plugin-api" + } +} diff --git a/packages/plugin-dev/todoist-import/scripts/build.js b/packages/plugin-dev/todoist-import/scripts/build.js new file mode 100644 index 0000000000..23e3688a53 --- /dev/null +++ b/packages/plugin-dev/todoist-import/scripts/build.js @@ -0,0 +1,54 @@ +#!/usr/bin/env node + +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'); + +const buildPlugin = async () => { + if (fs.existsSync(DIST_DIR)) { + fs.rmSync(DIST_DIR, { recursive: true }); + } + fs.mkdirSync(DIST_DIR); + + // Bundle the UI and inline it: the host loads index.html via iframe srcdoc, + // so the document must be fully self-contained (no relative script URLs). + const result = await build({ + entryPoints: [path.join(SRC_DIR, 'ui', 'main.ts')], + bundle: true, + write: false, + platform: 'browser', + target: 'es2020', + format: 'iife', + minify: true, + sourcemap: false, + logLevel: 'info', + }); + const bundle = result.outputFiles[0].text; + + const htmlTemplate = fs.readFileSync(path.join(SRC_DIR, 'ui', 'index.html'), 'utf8'); + const marker = ''; + if (!htmlTemplate.includes(marker)) { + throw new Error(`index.html is missing the ${marker} marker`); + } + // function replacer: a literal replacement string would corrupt the bundle + // if the minified JS ever contains `$&`/`$'`-style replacement patterns + const html = htmlTemplate.replace(marker, () => ``); + fs.writeFileSync(path.join(DIST_DIR, 'index.html'), html); + + for (const file of ['manifest.json', 'plugin.js', 'icon.svg']) { + fs.copyFileSync(path.join(SRC_DIR, file), path.join(DIST_DIR, file)); + } + fs.cpSync(path.join(ROOT_DIR, 'i18n'), path.join(DIST_DIR, 'i18n'), { + recursive: true, + }); + console.log('todoist-import build complete → dist/'); +}; + +buildPlugin().catch((err) => { + console.error('todoist-import build failed:', err); + process.exit(1); +}); diff --git a/packages/plugin-dev/todoist-import/src/icon.svg b/packages/plugin-dev/todoist-import/src/icon.svg new file mode 100644 index 0000000000..3edfaf8185 --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/plugin-dev/todoist-import/src/manifest.json b/packages/plugin-dev/todoist-import/src/manifest.json new file mode 100644 index 0000000000..4cce2849ea --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/manifest.json @@ -0,0 +1,29 @@ +{ + "id": "todoist-import", + "manifestVersion": 1, + "name": "Todoist Import", + "version": "1.0.0", + "minSupVersion": "18.0.0", + "description": "One-time import of your active Todoist projects, tasks, sub-tasks, labels and due dates. Additive — your existing Super Productivity data is never touched.", + "author": "SuperProductivity", + "homepage": "https://github.com/super-productivity/super-productivity", + "hooks": [], + "permissions": [ + "http", + "getTasks", + "getAllProjects", + "addProject", + "getAllTags", + "addTag", + "batchUpdateForProject", + "updateTask", + "showSnack" + ], + "allowedHosts": ["api.todoist.com"], + "iFrame": true, + "isSkipMenuEntry": true, + "i18n": { + "languages": ["en"] + }, + "icon": "icon.svg" +} diff --git a/packages/plugin-dev/todoist-import/src/map/plan-import.spec.ts b/packages/plugin-dev/todoist-import/src/map/plan-import.spec.ts new file mode 100644 index 0000000000..cb3f0b4062 --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/map/plan-import.spec.ts @@ -0,0 +1,308 @@ +import { BatchTaskCreate } from '@super-productivity/plugin-api'; +import { TodoistImportModel, TodoistTask } from '../parse/normalized-model'; +import { BATCH_CHUNK_SIZE, planImport } from './plan-import'; + +const task = (overrides: Partial): TodoistTask => ({ + extId: 't1', + projectExtId: 'p1', + parentExtId: null, + title: 'task', + notes: '', + labels: [], + apiPriority: 1, + dueDay: null, + dueWithTime: null, + timeEstimate: null, + isRecurring: false, + wasDemoted: false, + isDayDurationSkipped: false, + hasAssignee: false, + attachmentCount: 0, + ...overrides, +}); + +const model = (overrides: Partial = {}): TodoistImportModel => ({ + projects: [ + { extId: 'p1', title: 'Work', parentExtId: null, isInbox: false, childOrder: 1 }, + ], + sections: [], + tasks: [], + ...overrides, +}); + +describe('planImport', () => { + it('creates temp- prefixed IDs and parent references', () => { + const plan = planImport( + model({ + tasks: [ + task({ extId: 'a' }), + task({ extId: 'b', parentExtId: 'a', title: 'sub' }), + ], + }), + { priorityMapping: 'none' }, + ); + const ops = plan.projects[0].batchChunks[0] as BatchTaskCreate[]; + expect(ops.map((o) => [o.tempId, o.data.parentId])).toEqual([ + ['temp-a', undefined], + ['temp-b', 'temp-a'], + ]); + }); + + it('chunks operations at the batch limit, keeping parents before children', () => { + const tasks: TodoistTask[] = []; + for (let i = 0; i < 60; i++) { + tasks.push(task({ extId: `root-${i}`, title: `t${i}` })); + tasks.push(task({ extId: `sub-${i}`, parentExtId: `root-${i}`, title: `s${i}` })); + } + const plan = planImport(model({ tasks }), { priorityMapping: 'none' }); + const chunks = plan.projects[0].batchChunks; + expect(chunks.length).toBe(Math.ceil(120 / BATCH_CHUNK_SIZE)); + chunks.forEach((chunk) => expect(chunk.length).toBeLessThanOrEqual(BATCH_CHUNK_SIZE)); + // a parent is always at a lower global index than its child + const flat = chunks.flat() as BatchTaskCreate[]; + const indexByTempId = new Map(flat.map((op, i) => [op.tempId, i])); + for (const op of flat) { + if (op.data.parentId) { + expect(indexByTempId.get(op.data.parentId)).toBeLessThan( + indexByTempId.get(op.tempId) as number, + ); + } + } + }); + + it('emits follow-ups only for tasks that need them, with due exclusivity', () => { + const plan = planImport( + model({ + tasks: [ + task({ extId: 'plain' }), + task({ extId: 'dated', dueDay: '2026-07-15' }), + task({ extId: 'timed', dueWithTime: 123456, dueDay: null }), + ], + }), + { priorityMapping: 'none' }, + ); + expect(plan.projects[0].followUps).toEqual([ + { tempId: 'temp-dated', dueDay: '2026-07-15' }, + { tempId: 'temp-timed', dueWithTime: 123456 }, + ]); + }); + + it('collects label tags for root tasks but never for sub-tasks', () => { + const plan = planImport( + model({ + tasks: [ + task({ extId: 'a', labels: ['errand'] }), + task({ extId: 'b', parentExtId: 'a', labels: ['dropped'] }), + ], + }), + { priorityMapping: 'none' }, + ); + expect(plan.tagTitles).toEqual(['errand']); + expect(plan.projects[0].followUps).toEqual([ + { tempId: 'temp-a', tagTitles: ['errand'] }, + ]); + }); + + describe('priority tags (opt-in)', () => { + it('maps API 4→p1, 3→p2, 2→p3 and never tags the default priority 1', () => { + const plan = planImport( + model({ + tasks: [ + task({ extId: 'highest', apiPriority: 4 }), + task({ extId: 'mid', apiPriority: 3 }), + task({ extId: 'low', apiPriority: 2 }), + task({ extId: 'default', apiPriority: 1 }), + ], + }), + { priorityMapping: 'priorityTags' }, + ); + expect(plan.tagTitles.sort()).toEqual(['p1', 'p2', 'p3']); + expect(plan.projects[0].followUps).toEqual([ + { tempId: 'temp-highest', tagTitles: ['p1'] }, + { tempId: 'temp-mid', tagTitles: ['p2'] }, + { tempId: 'temp-low', tagTitles: ['p3'] }, + ]); + }); + + it('is off by default', () => { + const plan = planImport(model({ tasks: [task({ extId: 'a', apiPriority: 4 })] }), { + priorityMapping: 'none', + }); + expect(plan.tagTitles).toEqual([]); + }); + }); + + describe('eisenhower priority mapping (opt-in)', () => { + it('maps 4→urgent+important, 3→important, 2→urgent, leaving default 1 untagged', () => { + const plan = planImport( + model({ + tasks: [ + task({ extId: 'p1', apiPriority: 4 }), + task({ extId: 'p2', apiPriority: 3 }), + task({ extId: 'p3', apiPriority: 2 }), + task({ extId: 'p4', apiPriority: 1 }), + ], + }), + { priorityMapping: 'eisenhower' }, + ); + // reused by title → lands in SP's existing EM_URGENT / EM_IMPORTANT tags + expect(plan.tagTitles.sort()).toEqual(['important', 'urgent']); + expect(plan.projects[0].followUps).toEqual([ + { tempId: 'temp-p1', tagTitles: ['urgent', 'important'] }, + { tempId: 'temp-p2', tagTitles: ['important'] }, + { tempId: 'temp-p3', tagTitles: ['urgent'] }, + ]); + }); + + it('merges the matrix tags after existing labels and never tags sub-tasks', () => { + const plan = planImport( + model({ + tasks: [ + task({ extId: 'root', apiPriority: 4, labels: ['errand'] }), + task({ extId: 'sub', parentExtId: 'root', apiPriority: 4, labels: ['x'] }), + ], + }), + { priorityMapping: 'eisenhower' }, + ); + expect(plan.projects[0].followUps).toEqual([ + { tempId: 'temp-root', tagTitles: ['errand', 'urgent', 'important'] }, + ]); + }); + + it('does not duplicate matrix tags already present as Todoist labels', () => { + const plan = planImport( + model({ + tasks: [ + task({ + extId: 'root', + apiPriority: 4, + labels: ['Urgent', 'important'], + }), + ], + }), + { priorityMapping: 'eisenhower' }, + ); + + expect(plan.projects[0].followUps).toEqual([ + { tempId: 'temp-root', tagTitles: ['Urgent', 'important'] }, + ]); + }); + }); + + describe('project selection and titles', () => { + it('only plans selected projects', () => { + const plan = planImport( + model({ + projects: [ + { extId: 'p1', title: 'A', parentExtId: null, isInbox: false, childOrder: 1 }, + { extId: 'p2', title: 'B', parentExtId: null, isInbox: false, childOrder: 2 }, + ], + tasks: [task({ extId: 'a', projectExtId: 'p2' })], + }), + { priorityMapping: 'none', selectedProjectExtIds: new Set(['p2']) }, + ); + expect(plan.projects.map((p) => p.extId)).toEqual(['p2']); + }); + + it('renames the inbox and disambiguates colliding nested titles', () => { + const plan = planImport( + model({ + projects: [ + { + extId: 'inbox', + title: 'Inbox', + parentExtId: null, + isInbox: true, + childOrder: 0, + }, + { + extId: 'work', + title: 'Work', + parentExtId: null, + isInbox: false, + childOrder: 1, + }, + { + extId: 'misc1', + title: 'Misc', + parentExtId: 'work', + isInbox: false, + childOrder: 2, + }, + { + extId: 'home', + title: 'Home', + parentExtId: null, + isInbox: false, + childOrder: 3, + }, + { + extId: 'misc2', + title: 'Misc', + parentExtId: 'home', + isInbox: false, + childOrder: 4, + }, + ], + }), + { priorityMapping: 'none' }, + ); + expect(plan.projects.map((p) => p.title)).toEqual([ + 'Inbox (Todoist)', + 'Work', + 'Work / Misc', + 'Home', + 'Home / Misc', + ]); + }); + + it('suffixes titles that still collide after prefixing', () => { + const plan = planImport( + model({ + projects: [ + { extId: 'a', title: 'X', parentExtId: null, isInbox: false, childOrder: 1 }, + { extId: 'b', title: 'X', parentExtId: null, isInbox: false, childOrder: 2 }, + ], + }), + { priorityMapping: 'none' }, + ); + expect(plan.projects.map((p) => p.title)).toEqual(['X', 'X (2)']); + }); + + it('never generates a suffix that collides with another project title', () => { + const plan = planImport( + model({ + projects: [ + { extId: 'a', title: 'X', parentExtId: null, isInbox: false, childOrder: 1 }, + { extId: 'b', title: 'X', parentExtId: null, isInbox: false, childOrder: 2 }, + { + extId: 'c', + title: 'X (2)', + parentExtId: null, + isInbox: false, + childOrder: 3, + }, + ], + }), + { priorityMapping: 'none' }, + ); + + expect(plan.projects.map((p) => p.title)).toEqual(['X', 'X (3)', 'X (2)']); + }); + }); + + it('reports task and sub-task counts per project', () => { + const plan = planImport( + model({ + tasks: [ + task({ extId: 'a' }), + task({ extId: 'b', parentExtId: 'a' }), + task({ extId: 'c' }), + ], + }), + { priorityMapping: 'none' }, + ); + expect(plan.projects[0].taskCount).toBe(2); + expect(plan.projects[0].subTaskCount).toBe(1); + }); +}); diff --git a/packages/plugin-dev/todoist-import/src/map/plan-import.ts b/packages/plugin-dev/todoist-import/src/map/plan-import.ts new file mode 100644 index 0000000000..79372730ca --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/map/plan-import.ts @@ -0,0 +1,232 @@ +import { BatchOperation } from '@super-productivity/plugin-api'; +import { TodoistImportModel, TodoistTask } from '../parse/normalized-model'; + +/** + * Batch chunk size. Must stay ≤ the host's MAX_BATCH_OPERATIONS_SIZE (50): + * the plugin chunks its own `batchUpdateForProject` calls and awaits each one, + * so every call is a single dispatched action in its own tick (sync rule #6); + * the bridge's internal chunking would fire all chunks in one tick. + */ +export const BATCH_CHUNK_SIZE = 50; + +/** Temp IDs MUST be `temp-`-prefixed — the batch reducer only resolves parent + * references with this prefix; anything else orphans (= deletes) sub-tasks. */ +const tempId = (extId: string): string => `temp-${extId}`; + +/** Todoist API priority (4 = highest) → SP tag title. API 1 = the default on + * every task and is deliberately never tagged. */ +const PRIORITY_TAG_BY_API_VALUE: Record = { + 4: 'p1', + 3: 'p2', + 2: 'p3', +}; + +/** + * Opt-in alternative to the p1–p3 tags: map Todoist's single priority axis onto + * Super Productivity's built-in Eisenhower-matrix tags. The two tags are reused + * by title (`ensureTags` matches case-insensitively), so imported tasks land in + * the existing `EM_URGENT`/`EM_IMPORTANT` quadrants instead of spawning new + * tags. Collapsing one axis onto the 2-D matrix is inherently opinionated; this + * is the conventional split and only ever applies when the user explicitly + * chooses it. API 1 (default p4) stays untagged. + */ +const EISENHOWER_TAGS_BY_API_VALUE: Record = { + 4: ['urgent', 'important'], + 3: ['important'], + 2: ['urgent'], +}; + +export interface TaskFollowUp { + tempId: string; + dueDay?: string; + dueWithTime?: number; + /** resolved to tag IDs at run time (existing tags are reused by title) */ + tagTitles?: string[]; +} + +export interface ProjectImportPlan { + extId: string; + title: string; + taskCount: number; + subTaskCount: number; + batchChunks: BatchOperation[][]; + followUps: TaskFollowUp[]; +} + +export interface ImportPlan { + projects: ProjectImportPlan[]; + /** all tag titles the import needs (used labels + opt-in priority tags) */ + tagTitles: string[]; +} + +/** + * How Todoist task priority is carried over — mutually exclusive (one UI + * control): `none` leaves priority off, `priorityTags` adds the p1–p3 tags, + * `eisenhower` adds SP's built-in urgent/important tags. + */ +export type PriorityMapping = 'none' | 'priorityTags' | 'eisenhower'; + +export interface PlanImportOptions { + priorityMapping: PriorityMapping; + /** omit to import everything */ + selectedProjectExtIds?: ReadonlySet; +} + +export const groupTasksByProject = ( + model: TodoistImportModel, +): Map => { + const byProject = new Map(); + for (const t of model.tasks) { + const list = byProject.get(t.projectExtId) || []; + list.push(t); + byProject.set(t.projectExtId, list); + } + return byProject; +}; + +const taskTagTitles = (task: TodoistTask, priorityMapping: PriorityMapping): string[] => { + // SP sub-tasks cannot hold tags (host model) — the plugin must enforce this + if (task.parentExtId) { + return []; + } + const titles = [...task.labels]; + if (priorityMapping === 'priorityTags') { + const priorityTag = PRIORITY_TAG_BY_API_VALUE[task.apiPriority]; + if (priorityTag) { + titles.push(priorityTag); + } + } else if (priorityMapping === 'eisenhower') { + const emTags = EISENHOWER_TAGS_BY_API_VALUE[task.apiPriority]; + if (emTags) { + titles.push(...emTags); + } + } + const seen = new Set(); + return titles.filter((title) => { + const key = title.toLowerCase(); + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +}; + +/** + * Flattened project titles must stay unique enough to review: nested projects + * keep their plain name unless it collides, then `Parent / Child`; remaining + * duplicates get a numeric suffix. The Todoist Inbox becomes `Inbox (Todoist)` + * so it never shadows SP's own Inbox. + * + * Exported so the preview shows (and collision-checks) exactly the titles the + * import will create. + */ +export const buildProjectTitles = (model: TodoistImportModel): Map => { + const byExtId = new Map(model.projects.map((p) => [p.extId, p])); + const preferredTitles = new Map(); + const counts = new Map(); + for (const p of model.projects) { + const base = p.isInbox ? 'Inbox (Todoist)' : p.title; + const key = base.toLowerCase(); + counts.set(key, (counts.get(key) || 0) + 1); + preferredTitles.set(p.extId, base); + } + for (const p of model.projects) { + let title = preferredTitles.get(p.extId) as string; + if ((counts.get(title.toLowerCase()) || 0) > 1 && p.parentExtId) { + const parent = byExtId.get(p.parentExtId); + if (parent) { + title = `${parent.title} / ${title}`; + } + } + preferredTitles.set(p.extId, title); + } + + const reserved = new Set( + [...preferredTitles.values()].map((title) => title.toLowerCase()), + ); + const used = new Set(); + const titles = new Map(); + for (const p of model.projects) { + const base = preferredTitles.get(p.extId) as string; + let title = base; + let suffix = 2; + while (used.has(title.toLowerCase())) { + do { + title = `${base} (${suffix++})`; + } while (reserved.has(title.toLowerCase()) || used.has(title.toLowerCase())); + } + used.add(title.toLowerCase()); + titles.set(p.extId, title); + } + return titles; +}; + +/** + * Normalized model → executable plan. Pure; unit-tested. Operations are + * ordered parent-before-child (guaranteed by the model's task order), which + * keeps chunk boundaries safe. + */ +export const planImport = ( + model: TodoistImportModel, + options: PlanImportOptions, +): ImportPlan => { + const titles = buildProjectTitles(model); + const tagTitles = new Set(); + const projects: ProjectImportPlan[] = []; + const tasksByProject = groupTasksByProject(model); + + for (const project of model.projects) { + if ( + options.selectedProjectExtIds && + !options.selectedProjectExtIds.has(project.extId) + ) { + continue; + } + const tasks = tasksByProject.get(project.extId) || []; + const operations: BatchOperation[] = tasks.map((t) => ({ + type: 'create', + tempId: tempId(t.extId), + data: { + title: t.title, + notes: t.notes || undefined, + parentId: t.parentExtId ? tempId(t.parentExtId) : undefined, + timeEstimate: t.timeEstimate ?? undefined, + }, + })); + + const batchChunks: BatchOperation[][] = []; + for (let i = 0; i < operations.length; i += BATCH_CHUNK_SIZE) { + batchChunks.push(operations.slice(i, i + BATCH_CHUNK_SIZE)); + } + + const followUps: TaskFollowUp[] = []; + for (const t of tasks) { + const followUp: TaskFollowUp = { tempId: tempId(t.extId) }; + if (t.dueDay) { + followUp.dueDay = t.dueDay; + } else if (t.dueWithTime) { + followUp.dueWithTime = t.dueWithTime; + } + const titlesForTask = taskTagTitles(t, options.priorityMapping); + if (titlesForTask.length) { + followUp.tagTitles = titlesForTask; + titlesForTask.forEach((title) => tagTitles.add(title)); + } + if (followUp.dueDay || followUp.dueWithTime || followUp.tagTitles) { + followUps.push(followUp); + } + } + + projects.push({ + extId: project.extId, + title: titles.get(project.extId) as string, + taskCount: tasks.filter((t) => !t.parentExtId).length, + subTaskCount: tasks.filter((t) => !!t.parentExtId).length, + batchChunks, + followUps, + }); + } + + return { projects, tagTitles: [...tagTitles] }; +}; diff --git a/packages/plugin-dev/todoist-import/src/map/run-import.spec.ts b/packages/plugin-dev/todoist-import/src/map/run-import.spec.ts new file mode 100644 index 0000000000..b58ce89013 --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/map/run-import.spec.ts @@ -0,0 +1,234 @@ +import { + BatchTaskCreate, + BatchUpdateRequest, + Tag, + Task, +} from '@super-productivity/plugin-api'; +import { TodoistImportModel, TodoistTask } from '../parse/normalized-model'; +import { planImport } from './plan-import'; +import { runImport } from './run-import'; + +const task = (overrides: Partial): TodoistTask => ({ + extId: 't1', + projectExtId: 'p1', + parentExtId: null, + title: 'task', + notes: '', + labels: [], + apiPriority: 1, + dueDay: null, + dueWithTime: null, + timeEstimate: null, + isRecurring: false, + wasDemoted: false, + isDayDurationSkipped: false, + hasAssignee: false, + attachmentCount: 0, + ...overrides, +}); + +const model = (tasks: TodoistTask[]): TodoistImportModel => ({ + projects: [ + { extId: 'p1', title: 'Work', parentExtId: null, isInbox: false, childOrder: 1 }, + ], + sections: [], + tasks, +}); + +/** In-memory fake of the PluginAPI subset the executor uses. */ +const createFakeApi = ( + opts: { + existingTags?: Partial[]; + failNthBatch?: number; + failNthTag?: number; + failGetTasks?: boolean; + } = {}, +): { + api: Parameters[0]; + sentBatches: BatchUpdateRequest[]; + updates: { taskId: string; changes: Partial }[]; + createdTags: string[]; +} => { + const sentBatches: BatchUpdateRequest[] = []; + const updates: { taskId: string; changes: Partial }[] = []; + const createdTags: string[] = []; + const createdTasks: Task[] = []; + let idCounter = 0; + let batchCounter = 0; + let tagCounter = 0; + + const api: Parameters[0] = { + getAllTags: async () => (opts.existingTags || []) as Tag[], + addTag: async (tagData) => { + tagCounter++; + if (opts.failNthTag === tagCounter) { + throw new Error('tag failed'); + } + createdTags.push(tagData.title as string); + return `tag-${createdTags.length}`; + }, + addProject: async () => `project-${++idCounter}`, + batchUpdateForProject: async (request) => { + batchCounter++; + if (opts.failNthBatch === batchCounter) { + throw new Error('batch failed'); + } + sentBatches.push(request); + const createdTaskIds: Record = {}; + for (const op of request.operations) { + if (op.type === 'create') { + const realId = `real-${op.tempId}`; + createdTaskIds[op.tempId] = realId; + const parentId = op.data.parentId || null; + createdTasks.push({ + id: realId, + title: op.data.title, + projectId: request.projectId, + // mirror the host: an unresolved temp- parent would orphan-delete + parentId: parentId && parentId.startsWith('temp-') ? undefined : parentId, + tagIds: [], + subTaskIds: [], + timeEstimate: 0, + timeSpent: 0, + isDone: false, + created: 1, + } as Task); + } + } + return { success: true, createdTaskIds }; + }, + updateTask: async (taskId, changes) => { + updates.push({ taskId, changes }); + }, + getTasks: async () => { + if (opts.failGetTasks) { + throw new Error('getTasks failed'); + } + return createdTasks; + }, + }; + return { api, sentBatches, updates, createdTags }; +}; + +describe('runImport', () => { + it('rewrites temp- parent refs to real IDs when a family straddles a chunk boundary', async () => { + // 49 filler roots + 1 root at index 49 whose 3 subtasks land in chunk 2 + const tasks: TodoistTask[] = []; + for (let i = 0; i < 49; i++) { + tasks.push(task({ extId: `filler-${i}` })); + } + tasks.push(task({ extId: 'family-root' })); + for (let i = 0; i < 3; i++) { + tasks.push(task({ extId: `family-sub-${i}`, parentExtId: 'family-root' })); + } + const plan = planImport(model(tasks), { priorityMapping: 'none' }); + expect(plan.projects[0].batchChunks.length).toBe(2); + + const { api, sentBatches } = createFakeApi(); + const result = await runImport(api, plan, () => {}); + + // the second SENT chunk must reference the parent's REAL id, not temp- + const secondChunkCreates = sentBatches[1].operations as BatchTaskCreate[]; + for (const op of secondChunkCreates) { + expect(op.data.parentId).toBe('real-temp-family-root'); + } + // and nothing was lost: 53 planned, 53 landed + expect(result.imported[0].landedTaskCount).toBe(50); + expect(result.imported[0].landedSubTaskCount).toBe(3); + expect(result.errorMessage).toBeNull(); + }); + + it('keeps temp- parent refs within the same chunk (bridge resolves those)', async () => { + const plan = planImport( + model([task({ extId: 'a' }), task({ extId: 'b', parentExtId: 'a' })]), + { priorityMapping: 'none' }, + ); + const { api, sentBatches } = createFakeApi(); + await runImport(api, plan, () => {}); + const ops = sentBatches[0].operations as BatchTaskCreate[]; + expect(ops[1].data.parentId).toBe('temp-a'); + }); + + it('never maps a label onto the virtual TODAY tag', async () => { + const plan = planImport(model([task({ extId: 'a', labels: ['Today'] })]), { + priorityMapping: 'none', + }); + const { api, updates, createdTags } = createFakeApi({ + existingTags: [{ id: 'TODAY', title: 'Today' }], + }); + await runImport(api, plan, () => {}); + expect(createdTags).toEqual(['Today']); + expect(updates[0].changes.tagIds).toEqual(['tag-1']); + }); + + it('reuses existing tags by title, case-insensitively', async () => { + const plan = planImport(model([task({ extId: 'a', labels: ['Errand'] })]), { + priorityMapping: 'none', + }); + const { api, updates, createdTags } = createFakeApi({ + existingTags: [{ id: 'tag-existing', title: 'errand' }], + }); + await runImport(api, plan, () => {}); + expect(createdTags).toEqual([]); + expect(updates[0].changes.tagIds).toEqual(['tag-existing']); + }); + + it('reports tags created before a later tag creation fails', async () => { + const plan = planImport(model([task({ extId: 'a', labels: ['first', 'second'] })]), { + priorityMapping: 'none', + }); + const { api } = createFakeApi({ failNthTag: 2 }); + + const result = await runImport(api, plan, () => {}); + + expect(result.createdTagTitles).toEqual(['first']); + expect(result.errorMessage).toBe('tag failed'); + }); + + it('records the failed project as partial and keeps earlier projects', async () => { + const m: TodoistImportModel = { + projects: [ + { extId: 'p1', title: 'A', parentExtId: null, isInbox: false, childOrder: 1 }, + { extId: 'p2', title: 'B', parentExtId: null, isInbox: false, childOrder: 2 }, + ], + sections: [], + tasks: [ + task({ extId: 'a', projectExtId: 'p1' }), + task({ extId: 'b', projectExtId: 'p2' }), + ], + }; + const plan = planImport(m, { priorityMapping: 'none' }); + const { api } = createFakeApi({ failNthBatch: 2 }); + const result = await runImport(api, plan, () => {}); + expect(result.imported.map((p) => p.title)).toEqual(['A']); + expect(result.failedProjectTitle).toBe('B'); + expect(result.errorMessage).toBe('batch failed'); + }); + + it('does not let a failed recount mask a successful import', async () => { + const plan = planImport(model([task({ extId: 'a' })]), { + priorityMapping: 'none', + }); + const { api } = createFakeApi({ failGetTasks: true }); + const result = await runImport(api, plan, () => {}); + expect(result.errorMessage).toBeNull(); + expect(result.isCountUnverified).toBe(true); + expect(result.imported.length).toBe(1); + }); + + it('reports detail progress during follow-ups', async () => { + const tasks: TodoistTask[] = []; + for (let i = 0; i < 30; i++) { + tasks.push(task({ extId: `t-${i}`, dueDay: '2026-07-15' })); + } + const plan = planImport(model(tasks), { priorityMapping: 'none' }); + const { api } = createFakeApi(); + const detailReports: (number | undefined)[] = []; + await runImport(api, plan, (p) => { + if (p.phase === 'details') { + detailReports.push(p.detailIndex); + } + }); + expect(detailReports).toEqual([0, 25]); + }); +}); diff --git a/packages/plugin-dev/todoist-import/src/map/run-import.ts b/packages/plugin-dev/todoist-import/src/map/run-import.ts new file mode 100644 index 0000000000..6e1c058cc1 --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/map/run-import.ts @@ -0,0 +1,225 @@ +import { BatchOperation, PluginAPI, Task } from '@super-productivity/plugin-api'; +import { ImportPlan, ProjectImportPlan } from './plan-import'; + +/** SP's virtual Today tag — must never land in task.tagIds (sync rule #5). */ +const TODAY_TAG_ID = 'TODAY'; + +/** Report follow-up progress in steps of this many tasks. */ +const DETAIL_PROGRESS_STEP = 25; + +export interface ImportProgress { + projectTitle: string; + projectIndex: number; + totalProjects: number; + phase: 'project' | 'tasks' | 'details'; + /** follow-up progress within the 'details' phase */ + detailIndex?: number; + detailTotal?: number; +} + +export interface ImportedProjectResult { + title: string; + projectId: string; + /** planned counts, for an honest landed-vs-planned comparison */ + plannedTaskCount: number; + plannedSubTaskCount: number; + /** counted from re-read state — the batch API is fire-and-forget */ + landedTaskCount: number; + landedSubTaskCount: number; +} + +export interface ImportResult { + imported: ImportedProjectResult[]; + createdTagTitles: string[]; + /** set when the import aborted mid-way; that project exists PARTIALLY — + * the user should delete it before re-running */ + failedProjectTitle: string | null; + errorMessage: string | null; + /** the post-import recount failed; landed counts are unknown, not zero */ + isCountUnverified: boolean; +} + +type ImportApi = Pick< + PluginAPI, + | 'getAllTags' + | 'addTag' + | 'addProject' + | 'batchUpdateForProject' + | 'updateTask' + | 'getTasks' +>; + +const ensureTags = async ( + api: ImportApi, + tagTitles: string[], + createdTagTitles: string[], +): Promise> => { + const tagIdByTitle = new Map(); + const existing = await api.getAllTags(); + for (const tag of existing) { + // never map a label onto the virtual Today tag — a Todoist label named + // "Today" gets a real tag of its own instead + if (tag.id === TODAY_TAG_ID) { + continue; + } + tagIdByTitle.set(tag.title.toLowerCase(), tag.id); + } + for (const title of tagTitles) { + const key = title.toLowerCase(); + if (!tagIdByTitle.has(key)) { + tagIdByTitle.set(key, await api.addTag({ title })); + createdTagTitles.push(title); + } + } + return tagIdByTitle; +}; + +/** + * The bridge builds its temp-ID map PER CALL — a chunk sent in a later call + * cannot resolve a `temp-` parent created by an earlier call (the reducer + * would store the dangling ref and the consistency pass would orphan-DELETE + * the child). Real task IDs are supported in `parentId`, so rewrite every + * already-known temp parent to its real ID before sending. + */ +const resolveKnownParents = ( + chunk: BatchOperation[], + idByTempId: Record, +): BatchOperation[] => + chunk.map((op) => + op.type === 'create' && op.data.parentId && idByTempId[op.data.parentId] + ? { ...op, data: { ...op.data, parentId: idByTempId[op.data.parentId] } } + : op, + ); + +const importProject = async ( + api: ImportApi, + projectPlan: ProjectImportPlan, + tagIdByTitle: Map, + onProgress: ( + detail: Partial & { phase: ImportProgress['phase'] }, + ) => void, +): Promise => { + onProgress({ phase: 'project' }); + const projectId = await api.addProject({ title: projectPlan.title }); + + onProgress({ phase: 'tasks' }); + const idByTempId: Record = {}; + for (const chunk of projectPlan.batchChunks) { + // sequential awaits: one dispatched action per tick (sync rule #6) + const result = await api.batchUpdateForProject({ + projectId, + operations: resolveKnownParents(chunk, idByTempId), + }); + Object.assign(idByTempId, result.createdTaskIds); + } + + const followUps = projectPlan.followUps; + for (let i = 0; i < followUps.length; i++) { + if (i % DETAIL_PROGRESS_STEP === 0) { + onProgress({ phase: 'details', detailIndex: i, detailTotal: followUps.length }); + } + const followUp = followUps[i]; + const taskId = idByTempId[followUp.tempId]; + if (!taskId) { + continue; + } + const updates: Partial = {}; + if (followUp.dueDay) { + updates.dueDay = followUp.dueDay; + } else if (followUp.dueWithTime) { + updates.dueWithTime = followUp.dueWithTime; + } + if (followUp.tagTitles?.length) { + const tagIds = followUp.tagTitles + .map((t) => tagIdByTitle.get(t.toLowerCase())) + .filter((id): id is string => !!id); + if (tagIds.length) { + updates.tagIds = tagIds; + } + } + if (Object.keys(updates).length) { + await api.updateTask(taskId, updates); + } + } + return projectId; +}; + +const countLanded = async (api: ImportApi, result: ImportResult): Promise => { + const allTasks = await api.getTasks(); + const rootsByProject = new Map(); + const subsByProject = new Map(); + for (const task of allTasks) { + if (!task.projectId) { + continue; + } + const target = task.parentId ? subsByProject : rootsByProject; + target.set(task.projectId, (target.get(task.projectId) || 0) + 1); + } + for (const imported of result.imported) { + imported.landedTaskCount = rootsByProject.get(imported.projectId) || 0; + imported.landedSubTaskCount = subsByProject.get(imported.projectId) || 0; + } +}; + +/** + * Executes the plan project-by-project so an abort leaves at most one partial + * project (named in the result), and counts what actually landed by re-reading + * state (`batchUpdateForProject` always reports success and silently skips + * invalid operations). + */ +export const runImport = async ( + api: ImportApi, + plan: ImportPlan, + onProgress: (progress: ImportProgress) => void, +): Promise => { + const result: ImportResult = { + imported: [], + createdTagTitles: [], + failedProjectTitle: null, + errorMessage: null, + isCountUnverified: false, + }; + + try { + const tagIdByTitle = await ensureTags(api, plan.tagTitles, result.createdTagTitles); + + for (let i = 0; i < plan.projects.length; i++) { + const projectPlan = plan.projects[i]; + const report: Parameters[3] = (detail) => + onProgress({ + projectTitle: projectPlan.title, + projectIndex: i, + totalProjects: plan.projects.length, + ...detail, + }); + try { + const projectId = await importProject(api, projectPlan, tagIdByTitle, report); + result.imported.push({ + title: projectPlan.title, + projectId, + plannedTaskCount: projectPlan.taskCount, + plannedSubTaskCount: projectPlan.subTaskCount, + landedTaskCount: 0, + landedSubTaskCount: 0, + }); + } catch (e) { + result.failedProjectTitle = projectPlan.title; + result.errorMessage = e instanceof Error ? e.message : String(e); + break; + } + } + } catch (e) { + result.errorMessage = e instanceof Error ? e.message : String(e); + } + + if (result.imported.length) { + // a failed recount must not mask a successful import (or the real error) + try { + await countLanded(api, result); + } catch { + result.isCountUnverified = true; + } + } + + return result; +}; diff --git a/packages/plugin-dev/todoist-import/src/parse/from-api.spec.ts b/packages/plugin-dev/todoist-import/src/parse/from-api.spec.ts new file mode 100644 index 0000000000..1c5ea36cfb --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/parse/from-api.spec.ts @@ -0,0 +1,463 @@ +import { mergeSyncResponses, parseSyncResponse, RawSyncResponse } from './from-api'; + +const baseFixture = (): RawSyncResponse => ({ + projects: [ + { id: 'p1', name: 'Work', child_order: 1 }, + { id: 'p2', name: 'Home', child_order: 2 }, + ], + items: [], + sections: [], + notes: [], +}); + +describe('mergeSyncResponses', () => { + it('applies incremental additions, replacements, and tombstones to a full sync', () => { + const full: RawSyncResponse = { + sync_token: 'full-token', + projects: [ + { id: 'p1', name: 'Old name' }, + { id: 'p2', name: 'Deleted later' }, + { id: 'p4', name: 'Unchanged project' }, + ], + items: [{ id: 't1', project_id: 'p1', content: 'Old task title' }], + sections: [], + notes: [], + }; + const incremental: RawSyncResponse = { + sync_token: 'incremental-token', + projects: [ + { id: 'p1', name: 'New name' }, + { id: 'p2', is_deleted: true }, + { id: 'p3', name: 'New project' }, + ], + items: [{ id: 't1', project_id: 'p1', content: 'New task title' }], + }; + + const merged = mergeSyncResponses(full, incremental); + + expect(merged.sync_token).toBe('incremental-token'); + expect(merged.projects).toEqual([ + ...incremental.projects!, + { id: 'p4', name: 'Unchanged project' }, + ]); + expect(merged.items).toEqual(incremental.items); + expect(parseSyncResponse(merged).projects.map((project) => project.extId)).toEqual([ + 'p1', + 'p3', + 'p4', + ]); + expect(parseSyncResponse(merged).tasks[0].title).toBe('New task title'); + }); +}); + +describe('parseSyncResponse', () => { + it('skips archived and deleted projects and their items', () => { + const raw = baseFixture(); + raw.projects!.push( + { id: 'p3', name: 'Old', is_archived: true }, + { id: 'p4', name: 'Gone', is_deleted: 1 }, + ); + raw.items = [ + { id: 't1', project_id: 'p3', content: 'in archived' }, + { id: 't2', project_id: 'p4', content: 'in deleted' }, + { id: 't3', project_id: 'p1', content: 'kept' }, + ]; + const model = parseSyncResponse(raw); + expect(model.projects.map((p) => p.extId)).toEqual(['p1', 'p2']); + expect(model.tasks.map((t) => t.extId)).toEqual(['t3']); + }); + + it('skips completed and deleted items', () => { + const raw = baseFixture(); + raw.items = [ + { id: 't1', project_id: 'p1', content: 'done', checked: true }, + { id: 't2', project_id: 'p1', content: 'deleted', is_deleted: true }, + { id: 't3', project_id: 'p1', content: 'open' }, + ]; + const model = parseSyncResponse(raw); + expect(model.tasks.map((t) => t.extId)).toEqual(['t3']); + }); + + it('marks the inbox project', () => { + const raw = baseFixture(); + raw.projects!.unshift({ + id: 'p0', + name: 'Inbox', + inbox_project: true, + child_order: 0, + }); + const model = parseSyncResponse(raw); + expect(model.projects[0]).toEqual( + expect.objectContaining({ extId: 'p0', isInbox: true }), + ); + }); + + describe('due dates', () => { + it('parses all-day dates as dueDay', () => { + const raw = baseFixture(); + raw.items = [ + { id: 't1', project_id: 'p1', content: 'a', due: { date: '2026-07-15' } }, + ]; + const [task] = parseSyncResponse(raw).tasks; + expect(task.dueDay).toBe('2026-07-15'); + expect(task.dueWithTime).toBeNull(); + }); + + it('parses floating datetimes as local time', () => { + const raw = baseFixture(); + raw.items = [ + { + id: 't1', + project_id: 'p1', + content: 'a', + due: { date: '2026-07-15T09:30:00' }, + }, + ]; + const [task] = parseSyncResponse(raw).tasks; + expect(task.dueDay).toBeNull(); + expect(task.dueWithTime).toBe(new Date(2026, 6, 15, 9, 30, 0).getTime()); + }); + + it('parses fixed-timezone datetimes (trailing Z) as UTC instants', () => { + const raw = baseFixture(); + raw.items = [ + { + id: 't1', + project_id: 'p1', + content: 'a', + due: { date: '2026-07-15T09:30:00Z', timezone: 'Europe/Berlin' }, + }, + ]; + const [task] = parseSyncResponse(raw).tasks; + expect(task.dueWithTime).toBe(Date.UTC(2026, 6, 15, 9, 30, 0)); + }); + + it('uses the deadline as dueDay when there is no due date', () => { + const raw = baseFixture(); + raw.items = [ + { id: 't1', project_id: 'p1', content: 'a', deadline: { date: '2026-08-01' } }, + ]; + const [task] = parseSyncResponse(raw).tasks; + expect(task.dueDay).toBe('2026-08-01'); + expect(task.notes).not.toContain('Deadline:'); + }); + + it('keeps the deadline as a notes line when a due date exists', () => { + const raw = baseFixture(); + raw.items = [ + { + id: 't1', + project_id: 'p1', + content: 'a', + due: { date: '2026-07-15' }, + deadline: { date: '2026-08-01' }, + }, + ]; + const [task] = parseSyncResponse(raw).tasks; + expect(task.dueDay).toBe('2026-07-15'); + expect(task.notes).toContain('Deadline: 2026-08-01'); + }); + }); + + describe('recurring', () => { + it('flags recurring tasks and appends the verbatim rule to notes', () => { + const raw = baseFixture(); + raw.items = [ + { + id: 't1', + project_id: 'p1', + content: 'water plants', + description: 'living room only', + due: { date: '2026-07-15', is_recurring: true, string: 'every! 3 days' }, + }, + ]; + const [task] = parseSyncResponse(raw).tasks; + expect(task.isRecurring).toBe(true); + expect(task.notes).toBe('living room only\n\nRepeats: every! 3 days'); + }); + }); + + describe('durations', () => { + it('converts minute durations to a ms time estimate', () => { + const raw = baseFixture(); + raw.items = [ + { + id: 't1', + project_id: 'p1', + content: 'a', + duration: { amount: 45, unit: 'minute' }, + }, + ]; + const [task] = parseSyncResponse(raw).tasks; + expect(task.timeEstimate).toBe(45 * 60_000); + expect(task.isDayDurationSkipped).toBe(false); + }); + + it('skips day durations and flags them', () => { + const raw = baseFixture(); + raw.items = [ + { + id: 't1', + project_id: 'p1', + content: 'a', + duration: { amount: 1, unit: 'day' }, + }, + ]; + const [task] = parseSyncResponse(raw).tasks; + expect(task.timeEstimate).toBeNull(); + expect(task.isDayDurationSkipped).toBe(true); + }); + }); + + describe('nesting', () => { + it('keeps level-1 sub-tasks under their parent', () => { + const raw = baseFixture(); + raw.items = [ + { id: 'root', project_id: 'p1', content: 'root', child_order: 1 }, + { id: 'sub', project_id: 'p1', content: 'sub', parent_id: 'root' }, + ]; + const model = parseSyncResponse(raw); + expect(model.tasks.map((t) => [t.extId, t.parentExtId, t.wasDemoted])).toEqual([ + ['root', null, false], + ['sub', 'root', false], + ]); + }); + + it('re-parents depth ≥ 2 to the root ancestor in DFS order and flags demotion', () => { + const raw = baseFixture(); + raw.items = [ + { id: 'a', project_id: 'p1', content: 'a', child_order: 1 }, + { id: 'b', project_id: 'p1', content: 'b', parent_id: 'a', child_order: 1 }, + { id: 'c', project_id: 'p1', content: 'c', parent_id: 'b', child_order: 1 }, + { id: 'd', project_id: 'p1', content: 'd', parent_id: 'c', child_order: 1 }, + { id: 'b2', project_id: 'p1', content: 'b2', parent_id: 'a', child_order: 2 }, + ]; + const model = parseSyncResponse(raw); + expect(model.tasks.map((t) => [t.extId, t.parentExtId, t.wasDemoted])).toEqual([ + ['a', null, false], + ['b', 'a', false], + ['c', 'a', true], + ['d', 'a', true], + ['b2', 'a', false], + ]); + }); + + it('treats items with a missing parent as roots', () => { + const raw = baseFixture(); + raw.items = [ + { id: 'orphan', project_id: 'p1', content: 'x', parent_id: 'completed-parent' }, + ]; + const model = parseSyncResponse(raw); + expect(model.tasks[0].parentExtId).toBeNull(); + }); + }); + + describe('ordering', () => { + it('orders roots by section order then child order; section-less first', () => { + const raw = baseFixture(); + raw.sections = [ + { id: 's2', project_id: 'p1', name: 'Later', section_order: 2 }, + { id: 's1', project_id: 'p1', name: 'Now', section_order: 1 }, + ]; + raw.items = [ + { id: 'in-s2', project_id: 'p1', section_id: 's2', content: 'x', child_order: 1 }, + { id: 'no-sec-2', project_id: 'p1', content: 'x', child_order: 2 }, + { id: 'in-s1', project_id: 'p1', section_id: 's1', content: 'x', child_order: 1 }, + { id: 'no-sec-1', project_id: 'p1', content: 'x', child_order: 1 }, + ]; + const model = parseSyncResponse(raw); + expect(model.tasks.map((t) => t.extId)).toEqual([ + 'no-sec-1', + 'no-sec-2', + 'in-s1', + 'in-s2', + ]); + }); + + it('groups tasks by project in project order', () => { + const raw = baseFixture(); + raw.items = [ + { id: 'h1', project_id: 'p2', content: 'x', child_order: 1 }, + { id: 'w1', project_id: 'p1', content: 'x', child_order: 1 }, + ]; + const model = parseSyncResponse(raw); + expect(model.tasks.map((t) => t.extId)).toEqual(['w1', 'h1']); + }); + }); + + describe('comments', () => { + it('appends comments to notes and counts attachments', () => { + const raw = baseFixture(); + raw.items = [{ id: 't1', project_id: 'p1', content: 'a', description: 'desc' }]; + raw.notes = [ + { id: 'n1', item_id: 't1', content: 'first comment' }, + { + id: 'n2', + item_id: 't1', + content: 'see file', + file_attachment: { file_name: 'report.pdf', file_url: 'https://x/report.pdf' }, + }, + { id: 'n3', item_id: 't1', content: 'deleted', is_deleted: true }, + ]; + const [task] = parseSyncResponse(raw).tasks; + expect(task.notes).toBe( + 'desc\n\nComments:\n- first comment\n- see file report.pdf: https://x/report.pdf', + ); + expect(task.attachmentCount).toBe(1); + }); + + it('uses caller-provided strings for text added to imported notes', () => { + const raw = baseFixture(); + raw.items = [ + { + id: 't1', + project_id: 'p1', + content: '', + due: { date: '2026-07-15', is_recurring: true, string: 'every day' }, + deadline: { date: '2026-08-01' }, + }, + ]; + raw.notes = [ + { + id: 'n1', + item_id: 't1', + content: 'attachment', + file_attachment: { file_url: 'https://x/file' }, + }, + ]; + + const [taskParsed] = parseSyncResponse(raw, { + untitledProject: 'Projekt ohne Titel', + untitledTask: 'Aufgabe ohne Titel', + repeats: (rule) => `Wiederholt: ${rule}`, + deadline: (date) => `Frist: ${date}`, + comments: 'Kommentare:', + file: 'Datei', + }).tasks; + + expect(taskParsed.title).toBe('Aufgabe ohne Titel'); + expect(taskParsed.notes).toBe( + 'Wiederholt: every day\nFrist: 2026-08-01\n\nKommentare:\n- attachment Datei: https://x/file', + ); + }); + }); + + describe('hostile / malformed payloads', () => { + it('rejects shape-valid but impossible calendar dates', () => { + const raw = baseFixture(); + raw.items = [ + { id: 't1', project_id: 'p1', content: 'a', due: { date: '2026-99-99' } }, + { id: 't2', project_id: 'p1', content: 'b', deadline: { date: '0000-00-00' } }, + ]; + const [t1, t2] = parseSyncResponse(raw).tasks; + expect(t1.dueDay).toBeNull(); + expect(t2.dueDay).toBeNull(); + }); + + it('treats a parent in another project as missing (child becomes root)', () => { + const raw = baseFixture(); + raw.items = [ + { id: 'other', project_id: 'p2', content: 'x' }, + { id: 'child', project_id: 'p1', content: 'y', parent_id: 'other' }, + ]; + const child = parseSyncResponse(raw).tasks.find((t) => t.extId === 'child'); + expect(child?.parentExtId).toBeNull(); + }); + + it('drops unsafe or malformed attachment URLs from notes', () => { + const raw = baseFixture(); + raw.items = [{ id: 't1', project_id: 'p1', content: 'a' }]; + raw.notes = [ + { + id: 'n1', + item_id: 't1', + content: 'evil', + file_attachment: { file_name: 'x', file_url: 'javascript:alert(1)' }, + }, + { + id: 'n2', + item_id: 't1', + content: 'malformed', + file_attachment: { + file_name: 'x', + file_url: 'https://files.example/x\n[bad](javascript:alert(1))', + }, + }, + ]; + const [taskParsed] = parseSyncResponse(raw).tasks; + expect(taskParsed.notes).toBe('Comments:\n- evil\n- malformed'); + expect(taskParsed.attachmentCount).toBe(0); + }); + + it('rejects non-finite or absurd durations', () => { + const raw = baseFixture(); + raw.items = [ + { + id: 't1', + project_id: 'p1', + content: 'a', + duration: { amount: Infinity, unit: 'minute' }, + }, + { + id: 't2', + project_id: 'p1', + content: 'b', + duration: { amount: 9_999_999, unit: 'minute' }, + }, + ]; + const [t1, t2] = parseSyncResponse(raw).tasks; + expect(t1.timeEstimate).toBeNull(); + expect(t2.timeEstimate).toBeNull(); + }); + + it('clamps oversized titles and notes', () => { + const raw = baseFixture(); + raw.projects![0].name = 'p'.repeat(5000); + raw.items = [ + { + id: 't1', + project_id: 'p1', + content: 'x'.repeat(5000), + description: 'y'.repeat(100_000), + }, + ]; + const parsed = parseSyncResponse(raw); + const [taskParsed] = parsed.tasks; + expect(parsed.projects[0].truncatedFieldCount).toBe(1); + expect(taskParsed.title.length).toBeLessThanOrEqual(1001); + expect(taskParsed.notes.length).toBeLessThanOrEqual(50_001); + expect(taskParsed.truncatedFieldCount).toBe(2); + }); + }); + + it('orders nested projects parent-first (children directly after their parent)', () => { + const raw = baseFixture(); + // child_order is per-parent in Todoist: a flat sort would interleave + raw.projects = [ + { id: 'a', name: 'A', child_order: 1 }, + { id: 'b', name: 'B', child_order: 2 }, + { id: 'a1', name: 'A1', parent_id: 'a', child_order: 1 }, + { id: 'b1', name: 'B1', parent_id: 'b', child_order: 1 }, + ]; + const model = parseSyncResponse(raw); + expect(model.projects.map((p) => p.extId)).toEqual(['a', 'a1', 'b', 'b1']); + }); + + it('captures labels and assignee flag', () => { + const raw = baseFixture(); + raw.items = [ + { + id: 't1', + project_id: 'p1', + content: 'a', + labels: ['errand', 'urgent'], + responsible_uid: 'user-2', + priority: 4, + }, + ]; + const [task] = parseSyncResponse(raw).tasks; + expect(task.labels).toEqual(['errand', 'urgent']); + expect(task.hasAssignee).toBe(true); + expect(task.apiPriority).toBe(4); + }); +}); diff --git a/packages/plugin-dev/todoist-import/src/parse/from-api.ts b/packages/plugin-dev/todoist-import/src/parse/from-api.ts new file mode 100644 index 0000000000..7d70b4346a --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/parse/from-api.ts @@ -0,0 +1,482 @@ +import { + TodoistImportModel, + TodoistProject, + TodoistSection, + TodoistTask, +} from './normalized-model'; + +/** + * Raw shapes from `POST https://api.todoist.com/api/v1/sync` (unified v1). + * Only the fields we read; everything is optional so unknown/missing fields + * from API drift degrade gracefully instead of crashing the import. + */ +export interface RawSyncResponse { + sync_token?: string; + full_sync?: boolean; + full_sync_date_utc?: string; + projects?: RawProject[]; + items?: RawItem[]; + sections?: RawSection[]; + notes?: RawNote[]; +} + +interface RawProject { + id?: string | number; + name?: string; + parent_id?: string | number | null; + child_order?: number; + inbox_project?: boolean; + is_archived?: boolean | number; + is_deleted?: boolean | number; +} + +interface RawSection { + id?: string | number; + project_id?: string | number; + name?: string; + section_order?: number; + is_deleted?: boolean | number; + is_archived?: boolean | number; +} + +interface RawDue { + /** YYYY-MM-DD | YYYY-MM-DDTHH:MM:SS (floating/local) | …Z (fixed, UTC instant) */ + date?: string; + timezone?: string | null; + is_recurring?: boolean; + string?: string; +} + +interface RawItem { + id?: string | number; + project_id?: string | number; + section_id?: string | number | null; + parent_id?: string | number | null; + content?: string; + description?: string; + /** 4 = UI p1 (highest) … 1 = default */ + priority?: number; + labels?: string[]; + due?: RawDue | null; + deadline?: { date?: string } | null; + duration?: { amount?: number; unit?: string } | null; + checked?: boolean | number; + is_deleted?: boolean | number; + child_order?: number; + responsible_uid?: string | number | null; +} + +interface RawNote { + id?: string | number; + item_id?: string | number; + content?: string; + is_deleted?: boolean | number; + file_attachment?: { file_name?: string; file_url?: string } | null; +} + +const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/; + +// Everything below lands in synced state that every client must replay — +// clamp remote-controlled sizes and reject nonsense dates instead of +// trusting the payload (shared-project collaborators control much of it). +const MAX_TITLE_LEN = 1000; +const MAX_LABEL_LEN = 200; +const MAX_NOTES_LEN = 50_000; +const MIN_DUE_MS = 0; // 1970 +const MAX_DUE_MS = 32_503_680_000_000; // year 3000 + +export interface ParseStrings { + untitledProject: string; + untitledTask: string; + repeats: (rule: string) => string; + deadline: (date: string) => string; + comments: string; + file: string; +} + +const DEFAULT_PARSE_STRINGS: ParseStrings = { + untitledProject: 'Untitled project', + untitledTask: 'Untitled task', + repeats: (rule) => `Repeats: ${rule}`, + deadline: (date) => `Deadline: ${date}`, + comments: 'Comments:', + file: 'file', +}; + +const asId = (v: string | number | null | undefined): string | null => + v === null || v === undefined || v === '' ? null : String(v); + +const mergeResources = ( + full: T[] | undefined, + incremental: T[] | undefined, +): T[] | undefined => { + if (!incremental?.length) { + return full; + } + const changedIds = new Set( + incremental.map((resource) => asId(resource.id)).filter((id): id is string => !!id), + ); + return [ + ...incremental, + ...(full || []).filter((resource) => { + const id = asId(resource.id); + return !id || !changedIds.has(id); + }), + ]; +}; + +/** + * Apply the delta returned by a Todoist incremental sync to its initial full + * snapshot. Incremental tombstones intentionally replace the old resource so + * the normal parser filters deletions instead of resurrecting stale data. + */ +export const mergeSyncResponses = ( + full: RawSyncResponse, + incremental: RawSyncResponse, +): RawSyncResponse => ({ + ...full, + ...incremental, + projects: mergeResources(full.projects, incremental.projects), + items: mergeResources(full.items, incremental.items), + sections: mergeResources(full.sections, incremental.sections), + notes: mergeResources(full.notes, incremental.notes), +}); + +const asStr = (v: unknown): string => (typeof v === 'string' ? v : ''); + +const isSupportedAttachmentUrl = (url: unknown): url is string => { + if (typeof url !== 'string' || /[\s\u0000-\u001f\u007f]/u.test(url)) { + return false; + } + try { + const protocol = new URL(url).protocol; + return protocol === 'http:' || protocol === 'https:'; + } catch { + return false; + } +}; + +const isTruthyFlag = (v: boolean | number | undefined): boolean => !!v; + +const clamp = (s: string, maxLen: number): string => + s.length > maxLen ? `${s.slice(0, maxLen)}…` : s; + +/** Shape AND calendar validity (rejects e.g. 2026-99-99). */ +const isValidDayStr = (s: unknown): s is string => { + if (typeof s !== 'string' || !DATE_ONLY_RE.test(s)) { + return false; + } + const d = new Date(`${s}T00:00:00Z`); + return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === s; +}; + +/** + * `new Date('YYYY-MM-DDTHH:MM:SS')` is local time per spec; a trailing `Z` + * makes it a UTC instant — exactly Todoist's floating vs fixed semantics. + */ +const parseDue = ( + due: RawDue | null | undefined, +): { dueDay: string | null; dueWithTime: number | null } => { + const date = due?.date; + if (!date || typeof date !== 'string') { + return { dueDay: null, dueWithTime: null }; + } + if (DATE_ONLY_RE.test(date)) { + return isValidDayStr(date) + ? { dueDay: date, dueWithTime: null } + : { dueDay: null, dueWithTime: null }; + } + const ts = new Date(date).getTime(); + return Number.isNaN(ts) || ts < MIN_DUE_MS || ts > MAX_DUE_MS + ? { dueDay: null, dueWithTime: null } + : { dueDay: null, dueWithTime: ts }; +}; + +const buildNotes = ( + item: RawItem, + comments: RawNote[], + deadlineNoted: string | null, + strings: ParseStrings, +): { notes: string; truncatedFieldCount: number } => { + const parts: string[] = []; + const description = asStr(item.description).trim(); + if (description) { + parts.push(description); + } + const extras: string[] = []; + if (item.due?.is_recurring && asStr(item.due.string)) { + extras.push(strings.repeats(asStr(item.due.string))); + } + if (deadlineNoted) { + extras.push(strings.deadline(deadlineNoted)); + } + if (extras.length) { + parts.push(extras.join('\n')); + } + if (comments.length) { + const lines = comments.map((c) => { + const content = asStr(c.content).trim(); + const att = c.file_attachment; + // scheme-filter remote URLs before they land in markdown-rendered notes + const url = asStr(att?.file_url); + const attLine = isSupportedAttachmentUrl(url) + ? ` ${asStr(att?.file_name) || strings.file}: ${url}` + : ''; + return `- ${content}${attLine}`.trimEnd(); + }); + parts.push(`${strings.comments}\n${lines.join('\n')}`); + } + const notes = parts.join('\n\n'); + return { + notes: clamp(notes, MAX_NOTES_LEN), + truncatedFieldCount: notes.length > MAX_NOTES_LEN ? 1 : 0, + }; +}; + +/** + * Sync payload → normalized model. Pure; safe to unit-test with fixtures. + * + * - completed (`checked`) and deleted items are skipped, as are archived / + * deleted projects (and their items), + * - the item tree is flattened to SP's 2 levels: any task deeper than one + * level is re-parented to its root ancestor in DFS (reading) order, + * - items whose parent is missing (completed/deleted) are treated as roots. + */ +export const parseSyncResponse = ( + raw: RawSyncResponse, + strings: ParseStrings = DEFAULT_PARSE_STRINGS, +): TodoistImportModel => { + const projects: TodoistProject[] = []; + const keptProjectIds = new Set(); + + for (const p of raw.projects || []) { + const extId = asId(p.id); + if (!extId || isTruthyFlag(p.is_archived) || isTruthyFlag(p.is_deleted)) { + continue; + } + const title = asStr(p.name).trim(); + keptProjectIds.add(extId); + projects.push({ + extId, + title: clamp(title, MAX_TITLE_LEN) || strings.untitledProject, + parentExtId: asId(p.parent_id), + isInbox: !!p.inbox_project, + childOrder: p.child_order ?? 0, + truncatedFieldCount: title.length > MAX_TITLE_LEN ? 1 : 0, + }); + } + // parent-first DFS: `child_order` is per-parent in Todoist, so a flat sort + // would interleave unrelated siblings; this keeps children next to their + // (flattened-away) parent in the SP sidebar + const orderedProjects: TodoistProject[] = []; + const projectChildren = new Map(); + for (const p of projects) { + const parentKey = + p.parentExtId && keptProjectIds.has(p.parentExtId) ? p.parentExtId : null; + const list = projectChildren.get(parentKey) || []; + list.push(p); + projectChildren.set(parentKey, list); + } + projectChildren.forEach((list) => list.sort((a, b) => a.childOrder - b.childOrder)); + const visitedProjects = new Set(); + const visitProject = (p: TodoistProject): void => { + if (visitedProjects.has(p.extId)) { + return; + } + visitedProjects.add(p.extId); + orderedProjects.push(p); + (projectChildren.get(p.extId) || []).forEach(visitProject); + }; + (projectChildren.get(null) || []).forEach(visitProject); + // a parent-cycle in a hostile payload would skip its members entirely — + // append anything unvisited so no project is silently lost + for (const p of projects) { + visitProject(p); + } + + const sections: TodoistSection[] = []; + const sectionOrderById = new Map(); + for (const s of raw.sections || []) { + const extId = asId(s.id); + const projectExtId = asId(s.project_id); + if ( + !extId || + !projectExtId || + !keptProjectIds.has(projectExtId) || + isTruthyFlag(s.is_deleted) || + isTruthyFlag(s.is_archived) + ) { + continue; + } + sectionOrderById.set(extId, s.section_order ?? 0); + sections.push({ extId, projectExtId, title: asStr(s.name).trim() }); + } + + const commentsByItemId = new Map(); + for (const n of raw.notes || []) { + const itemId = asId(n.item_id); + if (!itemId || isTruthyFlag(n.is_deleted)) { + continue; + } + const list = commentsByItemId.get(itemId) || []; + list.push(n); + commentsByItemId.set(itemId, list); + } + + const keptItems: RawItem[] = []; + const keptItemIds = new Set(); + for (const item of raw.items || []) { + const extId = asId(item.id); + const projectExtId = asId(item.project_id); + if ( + !extId || + !projectExtId || + !keptProjectIds.has(projectExtId) || + isTruthyFlag(item.checked) || + isTruthyFlag(item.is_deleted) + ) { + continue; + } + keptItems.push(item); + keptItemIds.add(extId); + } + + // parent missing (completed/deleted/filtered) OR in another project (a + // shape Todoist shouldn't produce — its temp ID could never resolve in this + // project's batch) → treat as root + const projectByItemId = new Map( + keptItems.map((i) => [asId(i.id) as string, asId(i.project_id) as string]), + ); + const childrenByParent = new Map(); + const roots: RawItem[] = []; + for (const item of keptItems) { + const parentId = asId(item.parent_id); + if ( + parentId && + keptItemIds.has(parentId) && + projectByItemId.get(parentId) === asId(item.project_id) + ) { + const list = childrenByParent.get(parentId) || []; + list.push(item); + childrenByParent.set(parentId, list); + } else { + roots.push(item); + } + } + childrenByParent.forEach((list) => + list.sort((a, b) => (a.child_order ?? 0) - (b.child_order ?? 0)), + ); + + const projectOrder = new Map(orderedProjects.map((p, i) => [p.extId, i])); + const rootSortKey = (item: RawItem): [number, number, number] => { + const sectionId = asId(item.section_id); + // section-less tasks come first in Todoist, like sections with order -1 + const sectionOrder = sectionId ? (sectionOrderById.get(sectionId) ?? 0) : -1; + return [ + projectOrder.get(asId(item.project_id) as string) ?? 0, + sectionOrder, + item.child_order ?? 0, + ]; + }; + roots.sort((a, b) => { + const [pa, sa, ca] = rootSortKey(a); + const [pb, sb, cb] = rootSortKey(b); + return pa - pb || sa - sb || ca - cb; + }); + + const toTask = ( + item: RawItem, + rootExtId: string | null, + depth: number, + ): TodoistTask => { + const extId = asId(item.id) as string; + const { dueDay: parsedDueDay, dueWithTime } = parseDue(item.due); + const deadlineDay = isValidDayStr(item.deadline?.date) ? item.deadline!.date! : null; + const hasDue = !!parsedDueDay || !!dueWithTime; + // deadline fills in as dueDay when there is no due date; otherwise it is + // preserved as a notes line so nothing is silently dropped + const dueDay = parsedDueDay ?? (!hasDue ? deadlineDay : null); + const deadlineNoted = hasDue && deadlineDay ? deadlineDay : null; + + const duration = item.duration; + // Number.isFinite guards a hostile `1e999` → Infinity, which would diverge + // across clients (Infinity JSON-serializes to null in the op-log) + const isMinuteDuration = + !!duration && + duration.unit === 'minute' && + Number.isFinite(duration.amount) && + (duration.amount as number) > 0 && + (duration.amount as number) <= 525_600; // 1 year in minutes + const isDayDurationSkipped = !!duration && duration.unit === 'day'; + + const title = asStr(item.content).trim(); + const labels = (item.labels || []).filter( + (label): label is string => typeof label === 'string' && label.trim() !== '', + ); + const builtNotes = buildNotes( + item, + commentsByItemId.get(extId) || [], + deadlineNoted, + strings, + ); + + return { + extId, + projectExtId: asId(item.project_id) as string, + parentExtId: rootExtId, + title: clamp(title, MAX_TITLE_LEN) || strings.untitledTask, + notes: builtNotes.notes, + labels: labels.map((label) => clamp(label, MAX_LABEL_LEN)), + apiPriority: item.priority ?? 1, + dueDay, + dueWithTime, + timeEstimate: isMinuteDuration ? (duration.amount as number) * 60_000 : null, + isRecurring: !!item.due?.is_recurring, + wasDemoted: depth >= 2, + isDayDurationSkipped, + hasAssignee: !!asId(item.responsible_uid), + attachmentCount: (commentsByItemId.get(extId) || []).filter((c) => + isSupportedAttachmentUrl(c.file_attachment?.file_url), + ).length, + truncatedFieldCount: + (title.length > MAX_TITLE_LEN ? 1 : 0) + + labels.filter((label) => label.length > MAX_LABEL_LEN).length + + builtNotes.truncatedFieldCount, + }; + }; + + const tasks: TodoistTask[] = []; + const emittedIds = new Set(); + const emitFamily = (root: RawItem): void => { + const rootExtId = asId(root.id) as string; + emittedIds.add(rootExtId); + tasks.push(toTask(root, null, 0)); + // all descendants become direct sub-tasks of the root, DFS keeps reading order + const stack = (childrenByParent.get(rootExtId) || []) + .map((item) => ({ item, depth: 1 })) + .reverse(); + while (stack.length) { + const { item, depth } = stack.pop() as { item: RawItem; depth: number }; + const itemId = asId(item.id) as string; + if (emittedIds.has(itemId)) { + continue; // parent-cycle guard in a hostile payload + } + emittedIds.add(itemId); + tasks.push(toTask(item, rootExtId, depth)); + const children = childrenByParent.get(itemId) || []; + for (let i = children.length - 1; i >= 0; i--) { + stack.push({ item: children[i], depth: depth + 1 }); + } + } + }; + roots.forEach(emitFamily); + // members of a parent-cycle have no root — emit them as roots so nothing + // in the payload is silently dropped + for (const item of keptItems) { + if (!emittedIds.has(asId(item.id) as string)) { + emitFamily(item); + } + } + + return { projects: orderedProjects, sections, tasks }; +}; diff --git a/packages/plugin-dev/todoist-import/src/parse/load-todoist-data.spec.ts b/packages/plugin-dev/todoist-import/src/parse/load-todoist-data.spec.ts new file mode 100644 index 0000000000..9c72372289 --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/parse/load-todoist-data.spec.ts @@ -0,0 +1,53 @@ +import { PluginAPI } from '@super-productivity/plugin-api'; +import { loadTodoistData } from './load-todoist-data'; + +describe('loadTodoistData', () => { + it('follows the initial full sync with an incremental sync and merges the delta', async () => { + const request = jest + .fn() + .mockResolvedValueOnce({ + sync_token: 'full-token', + projects: [{ id: 'p1', name: 'Old name' }], + items: [], + sections: [], + notes: [], + }) + .mockResolvedValueOnce({ + sync_token: 'incremental-token', + projects: [ + { id: 'p1', name: 'New name' }, + { id: 'p2', name: 'New project' }, + ], + }); + + const result = await loadTodoistData( + { request } as unknown as Pick, + 'secret-token', + ); + + expect(request).toHaveBeenCalledTimes(2); + expect( + new URLSearchParams(request.mock.calls[0][1].body as string).get('sync_token'), + ).toBe('*'); + expect( + new URLSearchParams(request.mock.calls[1][1].body as string).get('sync_token'), + ).toBe('full-token'); + expect(result.sync_token).toBe('incremental-token'); + expect(result.projects?.map((project) => project.name)).toEqual([ + 'New name', + 'New project', + ]); + }); + + it('fails closed when Todoist omits the incremental sync token', async () => { + const request = jest.fn().mockResolvedValue({ projects: [] }); + + await expect( + loadTodoistData( + { request } as unknown as Pick, + 'secret-token', + ), + ).rejects.toThrow('sync token'); + expect(request).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/plugin-dev/todoist-import/src/parse/load-todoist-data.ts b/packages/plugin-dev/todoist-import/src/parse/load-todoist-data.ts new file mode 100644 index 0000000000..c6c9ab7e92 --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/parse/load-todoist-data.ts @@ -0,0 +1,43 @@ +import { PluginAPI } from '@super-productivity/plugin-api'; +import { mergeSyncResponses, RawSyncResponse } from './from-api'; + +const SYNC_URL = 'https://api.todoist.com/api/v1/sync'; +const RESOURCE_TYPES = ['projects', 'items', 'sections', 'notes']; + +type TodoistRequestApi = Pick; + +const requestSync = ( + api: TodoistRequestApi, + token: string, + syncToken: string, +): Promise => + api.request(SYNC_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + sync_token: syncToken, + resource_types: JSON.stringify(RESOURCE_TYPES), + }).toString(), + }); + +/** + * Todoist can serve delayed full snapshots for large accounts. Always apply the + * immediately-following incremental delta before previewing data so recent + * creates, updates, and tombstones are represented in the import. + * + * @see https://developer.todoist.com/api/v1/#tag/Sync/Incremental-sync + */ +export const loadTodoistData = async ( + api: TodoistRequestApi, + token: string, +): Promise => { + const full = await requestSync(api, token, '*'); + if (!full.sync_token) { + throw new Error('Todoist full sync did not return an incremental sync token.'); + } + const incremental = await requestSync(api, token, full.sync_token); + return mergeSyncResponses(full, incremental); +}; diff --git a/packages/plugin-dev/todoist-import/src/parse/normalized-model.ts b/packages/plugin-dev/todoist-import/src/parse/normalized-model.ts new file mode 100644 index 0000000000..444b1d2966 --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/parse/normalized-model.ts @@ -0,0 +1,60 @@ +/** + * Normalized intermediate shape between the Todoist sync payload and the + * Super Productivity import plan. Everything lossy is flagged per task so the + * preview/summary UI can honestly report what will be / was dropped. + */ + +export interface TodoistProject { + extId: string; + title: string; + parentExtId: string | null; + isInbox: boolean; + childOrder: number; + /** number of imported values shortened to the parser's safe limits */ + truncatedFieldCount?: number; +} + +export interface TodoistSection { + extId: string; + projectExtId: string; + title: string; +} + +export interface TodoistTask { + extId: string; + projectExtId: string; + /** null = root task; set = direct sub-task after depth-flattening (SP nests 2 levels) */ + parentExtId: string | null; + title: string; + notes: string; + labels: string[]; + /** Raw Todoist API value: 4 = UI p1 (highest) … 1 = default/none */ + apiPriority: number; + /** YYYY-MM-DD — mutually exclusive with dueWithTime */ + dueDay: string | null; + /** unix ms — mutually exclusive with dueDay */ + dueWithTime: number | null; + /** ms, from minute-based durations only */ + timeEstimate: number | null; + isRecurring: boolean; + /** original depth was ≥ 2 and the task was re-parented to its root ancestor */ + wasDemoted: boolean; + /** had a day-based duration that is deliberately not imported */ + isDayDurationSkipped: boolean; + hasAssignee: boolean; + /** comment file attachments (URLs kept in notes, files not imported) */ + attachmentCount: number; + /** number of imported values shortened to the parser's safe limits */ + truncatedFieldCount?: number; +} + +export interface TodoistImportModel { + projects: TodoistProject[]; + sections: TodoistSection[]; + /** + * In final creation order: per project, root tasks (sorted by section order, + * then child order) each immediately followed by their sub-tasks in DFS + * order — a parent always precedes its children. + */ + tasks: TodoistTask[]; +} diff --git a/packages/plugin-dev/todoist-import/src/plugin.js b/packages/plugin-dev/todoist-import/src/plugin.js new file mode 100644 index 0000000000..94a9edc51a --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/plugin.js @@ -0,0 +1,4 @@ +// Todoist Import plugin — all logic lives in the iframe UI (index.html). +// The plugin is opened via the Import/Export settings screen; it registers no +// hooks, shortcuts or menu entries (isSkipMenuEntry) so it adds zero standing +// weight when idle. diff --git a/packages/plugin-dev/todoist-import/src/ui/build-lossy-notes.spec.ts b/packages/plugin-dev/todoist-import/src/ui/build-lossy-notes.spec.ts new file mode 100644 index 0000000000..7909ef2c35 --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/ui/build-lossy-notes.spec.ts @@ -0,0 +1,100 @@ +import { PriorityMapping } from '../map/plan-import'; +import { TodoistImportModel, TodoistTask } from '../parse/normalized-model'; +import { buildLossyNotes } from './build-lossy-notes'; + +const task = (overrides: Partial): TodoistTask => ({ + extId: 'task', + projectExtId: 'selected', + parentExtId: null, + title: 'Task', + notes: '', + labels: [], + apiPriority: 1, + dueDay: null, + dueWithTime: null, + timeEstimate: null, + isRecurring: false, + wasDemoted: false, + isDayDurationSkipped: false, + hasAssignee: false, + attachmentCount: 0, + ...overrides, +}); + +const model = (): TodoistImportModel => ({ + projects: [ + { + extId: 'selected', + title: 'Child', + parentExtId: 'parent', + isInbox: false, + childOrder: 0, + truncatedFieldCount: 1, + }, + { + extId: 'other', + title: 'Other', + parentExtId: null, + isInbox: false, + childOrder: 1, + }, + ], + sections: [ + { extId: 'section-selected', projectExtId: 'selected', title: 'Section' }, + { extId: 'section-other', projectExtId: 'other', title: 'Other section' }, + ], + tasks: [ + task({ + extId: 'selected-root', + isRecurring: true, + isDayDurationSkipped: true, + hasAssignee: true, + attachmentCount: 2, + truncatedFieldCount: 2, + }), + task({ + extId: 'selected-sub', + parentExtId: 'selected-root', + labels: ['label'], + apiPriority: 4, + wasDemoted: true, + }), + task({ + extId: 'other-root', + projectExtId: 'other', + isRecurring: true, + attachmentCount: 4, + }), + ], +}); + +const keysFor = (priorityMapping: PriorityMapping): string[] => + buildLossyNotes(model(), new Set(['selected']), priorityMapping).map( + (note) => note.key, + ); + +describe('buildLossyNotes', () => { + it('only reports losses for selected projects', () => { + const notes = buildLossyNotes(model(), new Set(['selected']), 'none'); + + expect(notes).toEqual( + expect.arrayContaining([ + { key: 'LOSS.NESTED_PROJECTS', params: { count: 1 } }, + { key: 'LOSS.SECTIONS', params: { count: 1 } }, + { key: 'LOSS.DEMOTED_SUBTASKS', params: { count: 1 } }, + { key: 'LOSS.RECURRING', params: { count: 1 } }, + { key: 'LOSS.DAY_DURATIONS', params: { count: 1 } }, + { key: 'LOSS.SUBTASK_LABELS', params: { count: 1 } }, + { key: 'LOSS.ASSIGNEES', params: { count: 1 } }, + { key: 'LOSS.ATTACHMENTS', params: { count: 2 } }, + { key: 'LOSS.TRUNCATED_FIELDS', params: { count: 3 } }, + ]), + ); + }); + + it('reports prioritized sub-tasks only when priority mapping is enabled', () => { + expect(keysFor('none')).not.toContain('LOSS.SUBTASK_PRIORITIES'); + expect(keysFor('priorityTags')).toContain('LOSS.SUBTASK_PRIORITIES'); + expect(keysFor('eisenhower')).toContain('LOSS.SUBTASK_PRIORITIES'); + }); +}); diff --git a/packages/plugin-dev/todoist-import/src/ui/build-lossy-notes.ts b/packages/plugin-dev/todoist-import/src/ui/build-lossy-notes.ts new file mode 100644 index 0000000000..eb0f37db2a --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/ui/build-lossy-notes.ts @@ -0,0 +1,61 @@ +import { PriorityMapping } from '../map/plan-import'; +import { TodoistImportModel } from '../parse/normalized-model'; + +export interface LossNote { + key: string; + params?: Record; +} + +export const buildLossyNotes = ( + model: TodoistImportModel, + selected: ReadonlySet, + priorityMapping: PriorityMapping, +): LossNote[] => { + const projects = model.projects.filter((project) => selected.has(project.extId)); + const tasks = model.tasks.filter((task) => selected.has(task.projectExtId)); + const notes: LossNote[] = []; + const addCount = (key: string, count: number): void => { + if (count) { + notes.push({ key, params: { count } }); + } + }; + + addCount( + 'LOSS.NESTED_PROJECTS', + projects.filter((project) => !!project.parentExtId).length, + ); + addCount( + 'LOSS.SECTIONS', + model.sections.filter((section) => selected.has(section.projectExtId)).length, + ); + addCount('LOSS.DEMOTED_SUBTASKS', tasks.filter((task) => task.wasDemoted).length); + addCount('LOSS.RECURRING', tasks.filter((task) => task.isRecurring).length); + addCount( + 'LOSS.DAY_DURATIONS', + tasks.filter((task) => task.isDayDurationSkipped).length, + ); + addCount( + 'LOSS.SUBTASK_LABELS', + tasks.filter((task) => task.parentExtId && task.labels.length).length, + ); + if (priorityMapping !== 'none') { + addCount( + 'LOSS.SUBTASK_PRIORITIES', + tasks.filter((task) => task.parentExtId && task.apiPriority > 1).length, + ); + } + addCount('LOSS.ASSIGNEES', tasks.filter((task) => task.hasAssignee).length); + addCount( + 'LOSS.ATTACHMENTS', + tasks.reduce((count, task) => count + task.attachmentCount, 0), + ); + addCount( + 'LOSS.TRUNCATED_FIELDS', + [...projects, ...tasks].reduce( + (count, item) => count + (item.truncatedFieldCount || 0), + 0, + ), + ); + notes.push({ key: 'LOSS.COMPLETED_AND_REMINDERS' }); + return notes; +}; diff --git a/packages/plugin-dev/todoist-import/src/ui/i18n.spec.ts b/packages/plugin-dev/todoist-import/src/ui/i18n.spec.ts new file mode 100644 index 0000000000..f08133e110 --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/ui/i18n.spec.ts @@ -0,0 +1,22 @@ +import { loadTranslations, t } from './i18n'; + +describe('iframe translations', () => { + it('awaits the iframe translation bridge and interpolates dynamic values locally', async () => { + const translate = jest.fn((key: string) => + Promise.resolve(key === 'BUTTON.LOAD_PREVIEW' ? 'Vorschau laden' : key), + ); + + await loadTranslations({ translate }); + + expect(t('BUTTON.LOAD_PREVIEW')).toBe('Vorschau laden'); + expect(t('ERROR.LOAD_FAILED', { error: '401' })).toContain('401'); + }); + + it('falls back to bundled English if the host translation call fails', async () => { + await loadTranslations({ + translate: () => Promise.reject(new Error('bridge unavailable')), + }); + + expect(t('BUTTON.IMPORT')).toBe('Import'); + }); +}); diff --git a/packages/plugin-dev/todoist-import/src/ui/i18n.ts b/packages/plugin-dev/todoist-import/src/ui/i18n.ts new file mode 100644 index 0000000000..d39134830c --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/ui/i18n.ts @@ -0,0 +1,47 @@ +import english from '../../i18n/en.json'; + +type TranslationParams = Record; + +interface TranslationApi { + translate: (key: string, params?: TranslationParams) => string | Promise; +} + +const flatten = (value: Record, prefix = ''): Record => { + const result: Record = {}; + for (const [key, nestedValue] of Object.entries(value)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + if (typeof nestedValue === 'string') { + result[fullKey] = nestedValue; + } else if (nestedValue && typeof nestedValue === 'object') { + Object.assign(result, flatten(nestedValue as Record, fullKey)); + } + } + return result; +}; + +const englishByKey = flatten(english); +let translatedByKey: Record = { ...englishByKey }; + +export const loadTranslations = async (api: TranslationApi): Promise => { + translatedByKey = { ...englishByKey }; + await Promise.all( + Object.keys(englishByKey).map(async (key) => { + try { + const translated = await Promise.resolve(api.translate(key)); + if (translated && translated !== key) { + translatedByKey[key] = translated; + } + } catch { + // Bundled English remains available if the iframe bridge is unavailable. + } + }), + ); +}; + +export const t = (key: string, params: TranslationParams = {}): string => { + let value = translatedByKey[key] || englishByKey[key] || key; + for (const [name, replacement] of Object.entries(params)) { + value = value.split(`{{${name}}}`).join(String(replacement)); + } + return value; +}; diff --git a/packages/plugin-dev/todoist-import/src/ui/index.html b/packages/plugin-dev/todoist-import/src/ui/index.html new file mode 100644 index 0000000000..1202698d06 --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/ui/index.html @@ -0,0 +1,53 @@ + + + + + Todoist Import + + + + +
+ + + diff --git a/packages/plugin-dev/todoist-import/src/ui/main.ts b/packages/plugin-dev/todoist-import/src/ui/main.ts new file mode 100644 index 0000000000..a1786b2b49 --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/ui/main.ts @@ -0,0 +1,355 @@ +import { PluginAPI as PluginApiType, Project } from '@super-productivity/plugin-api'; +import { parseSyncResponse, ParseStrings } from '../parse/from-api'; +import { loadTodoistData } from '../parse/load-todoist-data'; +import { TodoistImportModel } from '../parse/normalized-model'; +import { + buildProjectTitles, + groupTasksByProject, + ImportPlan, + planImport, + PriorityMapping, +} from '../map/plan-import'; +import { runImport, ImportResult } from '../map/run-import'; +import { buildLossyNotes, LossNote } from './build-lossy-notes'; +import { loadTranslations, t } from './i18n'; + +declare global { + interface Window { + PluginAPI: PluginApiType; + } +} + +const api = (): PluginApiType => window.PluginAPI; + +const el = ( + tag: K, + props: Partial & { text?: string } = {}, + children: (HTMLElement | string)[] = [], +): HTMLElementTagNameMap[K] => { + const node = document.createElement(tag); + const { text, ...rest } = props; + Object.assign(node, rest); + if (text !== undefined) { + node.textContent = text; + } + for (const child of children) { + node.append(child); + } + return node; +}; + +const app = (): HTMLElement => document.getElementById('app') as HTMLElement; + +const render = (...children: HTMLElement[]): void => { + const root = app(); + root.replaceChildren(...children); +}; + +const parseStrings = (): ParseStrings => ({ + untitledProject: t('PARSE.UNTITLED_PROJECT'), + untitledTask: t('PARSE.UNTITLED_TASK'), + repeats: (rule) => t('PARSE.REPEATS', { rule }), + deadline: (date) => t('PARSE.DEADLINE', { date }), + comments: t('PARSE.COMMENTS'), + file: t('PARSE.FILE'), +}); + +// --------------------------------------------------------------------------- +// Step 1 — token input +// --------------------------------------------------------------------------- + +const renderTokenStep = (errorMsg?: string, tokenValue?: string): void => { + const tokenInputId = 'todoist-api-token'; + const tokenInput = el('input', { + id: tokenInputId, + type: 'password', + placeholder: t('TOKEN.PLACEHOLDER'), + autocomplete: 'off', + value: tokenValue || '', + }); + const fetchBtn = el('button', { text: t('BUTTON.LOAD_PREVIEW') }); + const errorLine = errorMsg + ? [el('p', { className: 'error', role: 'alert', text: errorMsg })] + : []; + + const submit = async (): Promise => { + const token = tokenInput.value.trim(); + if (!token) { + renderTokenStep(t('TOKEN.REQUIRED')); + return; + } + render( + el('h1', { text: t('TITLE.IMPORT') }), + el('p', { + text: t('TOKEN.LOADING'), + role: 'status', + ariaLive: 'polite', + }), + ); + try { + const raw = await loadTodoistData(api(), token); + const model = parseSyncResponse(raw || {}, parseStrings()); + if (!model.projects.length) { + renderTokenStep(t('TOKEN.NO_PROJECTS'), token); + return; + } + const existingProjects = await api().getAllProjects(); + renderPreviewStep(model, existingProjects); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + renderTokenStep(t('ERROR.LOAD_FAILED', { error: msg }), tokenInput.value); + } + }; + fetchBtn.addEventListener('click', () => void submit()); + tokenInput.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter') { + void submit(); + } + }); + + render( + el('h1', { text: t('TITLE.IMPORT') }), + el('p', { text: t('TOKEN.INTRO') }), + ...errorLine, + el('label', { htmlFor: tokenInputId, text: t('TOKEN.LABEL') }), + tokenInput, + el('p', { className: 'muted', text: t('TOKEN.HELP') }), + el('div', { className: 'actions' }, [fetchBtn]), + ); +}; + +// --------------------------------------------------------------------------- +// Step 2 — preview with per-project selection +// --------------------------------------------------------------------------- + +const renderPreviewStep = ( + model: TodoistImportModel, + existingProjects: Project[], +): void => { + const existingTitles = new Set(existingProjects.map((p) => p.title.toLowerCase())); + // the exact titles the import will create (Inbox rename, `Parent / Child` + // disambiguation, `(2)` suffixes) — preview and collision check must match + const titleByExtId = buildProjectTitles(model); + const tasksByProject = groupTasksByProject(model); + const checkboxByExtId = new Map(); + const lossyList = el('ul'); + // Priority → tags is a single mutually-exclusive choice (radios): off, the + // p1–p3 tags, or SP's built-in Eisenhower urgent/important tags. + const priorityRadio = (value: PriorityMapping, checked = false): HTMLInputElement => + el('input', { type: 'radio', name: 'todoist-priority-mapping', value, checked }); + const priorityNoneRadio = priorityRadio('none', true); + const priorityTagsRadio = priorityRadio('priorityTags'); + const priorityEisenhowerRadio = priorityRadio('eisenhower'); + const selectedPriorityMapping = (): PriorityMapping => + priorityTagsRadio.checked + ? 'priorityTags' + : priorityEisenhowerRadio.checked + ? 'eisenhower' + : 'none'; + + const selectedIds = (): Set => { + const ids = new Set(); + checkboxByExtId.forEach((box, extId) => { + if (box.checked) { + ids.add(extId); + } + }); + return ids; + }; + + const refreshLossyList = (): void => { + lossyList.replaceChildren( + ...buildLossyNotes(model, selectedIds(), selectedPriorityMapping()).map((note) => + el('li', { text: t(note.key, note.params) }), + ), + ); + }; + + const projectRows = model.projects.map((project) => { + const tasks = tasksByProject.get(project.extId) || []; + const rootCount = tasks.filter((t) => !t.parentExtId).length; + const subCount = tasks.length - rootCount; + const title = titleByExtId.get(project.extId) as string; + const collides = existingTitles.has(title.toLowerCase()); + const checkbox = el('input', { type: 'checkbox', checked: !collides }); + checkbox.addEventListener('change', refreshLossyList); + checkboxByExtId.set(project.extId, checkbox); + return el('label', {}, [ + checkbox, + ` ${t('PREVIEW.PROJECT_COUNTS', { + title, + taskCount: rootCount, + subTaskCount: subCount, + })}`, + ...(collides + ? [ + el('span', { + className: 'warn', + text: ` — ${t('PREVIEW.ALREADY_EXISTS')}`, + }), + ] + : []), + ]); + }); + + for (const radio of [priorityNoneRadio, priorityTagsRadio, priorityEisenhowerRadio]) { + radio.addEventListener('change', refreshLossyList); + } + + const importBtn = el('button', { text: t('BUTTON.IMPORT') }); + const backBtn = el('button', { text: t('BUTTON.BACK') }); + backBtn.addEventListener('click', () => renderTokenStep()); + importBtn.addEventListener('click', () => { + const selected = selectedIds(); + if (!selected.size) { + api().showSnack({ msg: t('PREVIEW.SELECT_PROJECT'), type: 'WARNING' }); + return; + } + const priorityMapping = selectedPriorityMapping(); + const plan = planImport(model, { + priorityMapping, + selectedProjectExtIds: selected, + }); + void executeImport(plan, buildLossyNotes(model, selected, priorityMapping)); + }); + + refreshLossyList(); + render( + el('h1', { text: t('TITLE.PREVIEW') }), + el('p', { text: t('PREVIEW.CHOOSE_PROJECTS') }), + el('div', {}, projectRows), + el('fieldset', {}, [ + el('legend', { text: t('PREVIEW.PRIORITY_LEGEND') }), + el('label', {}, [priorityNoneRadio, ` ${t('PREVIEW.PRIORITY_NONE')}`]), + el('label', {}, [priorityTagsRadio, ` ${t('PREVIEW.PRIORITY_TAGS')}`]), + el('label', {}, [priorityEisenhowerRadio, ` ${t('PREVIEW.PRIORITY_EISENHOWER')}`]), + ]), + el('h2', { text: t('PREVIEW.LOSS_HEADING') }), + lossyList, + el('div', { className: 'actions' }, [importBtn, backBtn]), + ); +}; + +// --------------------------------------------------------------------------- +// Step 3 + 4 — import progress and summary +// --------------------------------------------------------------------------- + +const executeImport = async (plan: ImportPlan, lossyNotes: LossNote[]): Promise => { + const progressLine = el('p', { + text: t('IMPORT.STARTING'), + role: 'status', + ariaLive: 'polite', + }); + render(el('h1', { text: t('TITLE.IMPORTING') }), progressLine); + + const result = await runImport(api(), plan, (progress) => { + const detail = + progress.phase === 'details' && progress.detailTotal + ? t('IMPORT.PHASE_DETAILS', { + current: Math.min((progress.detailIndex ?? 0) + 1, progress.detailTotal), + total: progress.detailTotal, + }) + : t(progress.phase === 'project' ? 'IMPORT.PHASE_PROJECT' : 'IMPORT.PHASE_TASKS'); + progressLine.textContent = t('IMPORT.PROGRESS', { + current: progress.projectIndex + 1, + total: progress.totalProjects, + title: progress.projectTitle, + detail, + }); + }); + renderSummaryStep(result, lossyNotes); +}; + +const projectSummaryLine = (p: ImportResult['imported'][number]): HTMLElement => { + const isShortfall = + p.landedTaskCount < p.plannedTaskCount || + p.landedSubTaskCount < p.plannedSubTaskCount; + return el('li', { + className: isShortfall ? 'warn' : '', + text: + t('SUMMARY.PROJECT_RESULT', { + title: p.title, + landedTasks: p.landedTaskCount, + plannedTasks: p.plannedTaskCount, + landedSubTasks: p.landedSubTaskCount, + plannedSubTasks: p.plannedSubTaskCount, + }) + (isShortfall ? ` — ${t('SUMMARY.SHORTFALL')}` : ''), + }); +}; + +const renderSummaryStep = (result: ImportResult, lossyNotes: LossNote[]): void => { + const items = result.imported.map(projectSummaryLine); + const failure = result.errorMessage + ? [ + el('p', { + className: 'error', + role: 'alert', + text: result.failedProjectTitle + ? t('ERROR.IMPORT_STOPPED', { + project: result.failedProjectTitle, + error: result.errorMessage, + }) + : t('ERROR.IMPORT_FAILED', { error: result.errorMessage }), + }), + ] + : []; + const unverified = result.isCountUnverified + ? [ + el('p', { + className: 'warn', + role: 'status', + text: t('SUMMARY.UNVERIFIED'), + }), + ] + : []; + const tagLine = result.createdTagTitles.length + ? [ + el('p', { + text: t('SUMMARY.CREATED_TAGS', { + tags: result.createdTagTitles.join(', '), + }), + }), + ] + : []; + const lossy = lossyNotes.length + ? [ + el('h2', { text: t('SUMMARY.NOT_CARRIED_OVER') }), + el( + 'ul', + {}, + lossyNotes.map((note) => el('li', { text: t(note.key, note.params) })), + ), + ] + : []; + + if (!result.errorMessage) { + api().showSnack({ msg: t('SUMMARY.SNACK_FINISHED'), type: 'SUCCESS' }); + } + render( + el('h1', { + text: t(result.errorMessage ? 'TITLE.INCOMPLETE' : 'TITLE.FINISHED'), + }), + el('ul', {}, items), + ...unverified, + ...tagLine, + ...failure, + ...lossy, + el('p', { + className: 'muted', + text: t('SUMMARY.UNDO'), + }), + ); +}; + +// The host injects the PluginAPI bridge script at the end of ; wait for +// DOM readiness so it is guaranteed to be defined before first use. +const start = async (): Promise => { + await loadTranslations(api()); + renderTokenStep(); +}; + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => void start()); +} else { + void start(); +} diff --git a/packages/plugin-dev/todoist-import/tsconfig.json b/packages/plugin-dev/todoist-import/tsconfig.json new file mode 100644 index 0000000000..97f798a881 --- /dev/null +++ b/packages/plugin-dev/todoist-import/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "lib": ["ES2020", "DOM"], + "types": ["jest", "node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/src/app/imex/file-imex/file-imex.component.html b/src/app/imex/file-imex/file-imex.component.html index 0de602d610..6dd54644a8 100644 --- a/src/app/imex/file-imex/file-imex.component.html +++ b/src/app/imex/file-imex/file-imex.component.html @@ -56,3 +56,13 @@ compress {{ T.FILE_IMEX.BTN_COMPRESS_ARCHIVE | translate }} + + diff --git a/src/app/imex/file-imex/file-imex.component.spec.ts b/src/app/imex/file-imex/file-imex.component.spec.ts index 8efdab25c9..840c500683 100644 --- a/src/app/imex/file-imex/file-imex.component.spec.ts +++ b/src/app/imex/file-imex/file-imex.component.spec.ts @@ -18,6 +18,7 @@ import { ConfirmUrlImportDialogComponent } from '../dialog-confirm-url-import/di import { DialogImportFromUrlComponent } from '../dialog-import-from-url/dialog-import-from-url.component'; import { createAppDataCompleteMock } from '../../util/app-data-mock'; import { ImportEncryptionHandlerService } from '../sync/import-encryption-handler.service'; +import { PluginService } from '../../plugins/plugin.service'; describe('FileImexComponent', () => { let component: FileImexComponent; @@ -52,6 +53,13 @@ describe('FileImexComponent', () => { importEncryptionHandlerSpy.checkEncryptionStateChange.and.returnValue( Promise.resolve({ willChange: false }), ); + const pluginServiceSpy = jasmine.createSpyObj('PluginService', [ + 'isInitialized', + 'initializePlugins', + 'activatePlugin', + ]); + pluginServiceSpy.isInitialized.and.returnValue(true); + pluginServiceSpy.activatePlugin.and.returnValue(Promise.resolve(null)); importEncryptionHandlerSpy.handleImportEncryptionIfNeeded.and.returnValue( Promise.resolve(null), ); @@ -77,6 +85,7 @@ describe('FileImexComponent', () => { { provide: ActivatedRoute, useValue: mockActivatedRoute }, { provide: MatDialog, useValue: matDialogSpy }, { provide: ImportEncryptionHandlerService, useValue: importEncryptionHandlerSpy }, + { provide: PluginService, useValue: pluginServiceSpy }, ], }).compileComponents(); diff --git a/src/app/imex/file-imex/file-imex.component.ts b/src/app/imex/file-imex/file-imex.component.ts index 2455cec664..91c25eda5f 100644 --- a/src/app/imex/file-imex/file-imex.component.ts +++ b/src/app/imex/file-imex/file-imex.component.ts @@ -43,6 +43,9 @@ import { Log } from '../../core/log'; import { DialogArchiveCompressionComponent } from '../../features/archive/dialog-archive-compression/dialog-archive-compression.component'; import { DataValidationFailedError } from '../../op-log/core/errors/sync-errors'; import { alertDialog } from '../../util/native-dialogs'; +import { PluginService } from '../../plugins/plugin.service'; + +const TODOIST_IMPORT_PLUGIN_ID = 'todoist-import'; @Component({ selector: 'file-imex', @@ -59,6 +62,7 @@ export class FileImexComponent implements OnInit { private _matDialog = inject(MatDialog); private _http = inject(HttpClient); private _importEncryptionHandler = inject(ImportEncryptionHandlerService); + private _pluginService = inject(PluginService); readonly fileInputRef = viewChild('fileInput'); T: typeof T = T; @@ -297,4 +301,28 @@ export class FileImexComponent implements OnInit { maxWidth: '90vw', }); } + + async openTodoistImport(): Promise { + try { + if (!this._pluginService.isInitialized()) { + await this._pluginService.initializePlugins(); + } + // In-memory activation only (not persisted): the importer is a one-time + // tool and should be dormant again after a restart. + const instance = await this._pluginService.activatePlugin( + TODOIST_IMPORT_PLUGIN_ID, + true, + ); + if (!instance) { + throw new Error('Plugin activation returned no instance'); + } + await this._router.navigate(['/plugins', TODOIST_IMPORT_PLUGIN_ID, 'index']); + } catch (e) { + Log.err('Failed to open Todoist importer', e); + this._snackService.open({ + type: 'ERROR', + msg: T.FILE_IMEX.S_ERR_TODOIST_IMPORT_OPEN, + }); + } + } } diff --git a/src/app/plugins/plugin-bridge.service.ts b/src/app/plugins/plugin-bridge.service.ts index 600031d912..7c96b32675 100644 --- a/src/app/plugins/plugin-bridge.service.ts +++ b/src/app/plugins/plugin-bridge.service.ts @@ -1221,6 +1221,7 @@ export class PluginBridgeService implements OnDestroy { // Chunk large operations to prevent oversized payloads const chunks = this._chunkOperations(request.operations); + const createdTaskTimestamp = Date.now(); if (chunks.length > 1) { PluginLog.log('PluginBridge: Chunking large batch operation', { @@ -1237,6 +1238,7 @@ export class PluginBridgeService implements OnDestroy { projectId: request.projectId, operations: chunk, createdTaskIds, // Same IDs mapping for all chunks + createdTaskTimestamp, }), ); }); diff --git a/src/app/plugins/plugin.service.ts b/src/app/plugins/plugin.service.ts index 4c2a08340b..a9755b47e4 100644 --- a/src/app/plugins/plugin.service.ts +++ b/src/app/plugins/plugin.service.ts @@ -61,6 +61,7 @@ const BUNDLED_PLUGIN_PATHS = [ 'assets/bundled-plugins/google-calendar-provider', 'assets/bundled-plugins/caldav-calendar-provider', 'assets/bundled-plugins/doc-mode', + 'assets/bundled-plugins/todoist-import', ] as const; // Reserved ids: an uploaded plugin may not reuse a bundled plugin's manifest id (it would @@ -85,6 +86,7 @@ const BUNDLED_PLUGIN_IDS = new Set([ 'linear-issue-provider', 'procrastination-buster', 'sync-md', + 'todoist-import', 'trello-issue-provider', 'voice-reminder', 'yesterday-tasks', diff --git a/src/app/root-store/meta/task-shared-meta-reducers/task-batch-update.reducer.spec.ts b/src/app/root-store/meta/task-shared-meta-reducers/task-batch-update.reducer.spec.ts index 79a46d886b..53bf1979a9 100644 --- a/src/app/root-store/meta/task-shared-meta-reducers/task-batch-update.reducer.spec.ts +++ b/src/app/root-store/meta/task-shared-meta-reducers/task-batch-update.reducer.spec.ts @@ -86,6 +86,27 @@ describe('taskBatchUpdateMetaReducer', () => { expect(task.isDone).toBe(false); }); + it('should use the timestamp captured in the persistent batch action', () => { + const action = TaskSharedActions.batchUpdateForProject({ + projectId: 'project1', + operations: [ + { + type: 'create', + tempId: 'temp-stable-time', + data: { title: 'Replay-safe task' }, + } as BatchTaskCreate, + ], + createdTaskIds: { 'temp-stable-time': 'stable-time-task' }, + createdTaskTimestamp: 1_752_124_200_000, + }); + + const result = metaReducer(baseState, action); + + expect(result[TASK_FEATURE_NAME].entities['stable-time-task']?.created).toBe( + 1_752_124_200_000, + ); + }); + it('should skip task creation if title is empty', () => { const operations: BatchOperation[] = [ { diff --git a/src/app/root-store/meta/task-shared-meta-reducers/task-batch-update.reducer.ts b/src/app/root-store/meta/task-shared-meta-reducers/task-batch-update.reducer.ts index d234d90ee8..6da2c7fd1c 100644 --- a/src/app/root-store/meta/task-shared-meta-reducers/task-batch-update.reducer.ts +++ b/src/app/root-store/meta/task-shared-meta-reducers/task-batch-update.reducer.ts @@ -42,9 +42,8 @@ export const taskBatchUpdateMetaReducer = = RootSt ): ActionReducer => { return (state: T | undefined, action: Action) => { if (action.type === TaskSharedActions.batchUpdateForProject.type) { - const { projectId, operations, createdTaskIds } = action as ReturnType< - typeof TaskSharedActions.batchUpdateForProject - >; + const { projectId, operations, createdTaskIds, createdTaskTimestamp } = + action as ReturnType; // Ensure state has required properties const rootState = state as unknown as RootState; @@ -144,7 +143,9 @@ export const taskBatchUpdateMetaReducer = = RootSt notes: createOp.data.notes || '', parentId, timeEstimate: createOp.data.timeEstimate || 0, - created: Date.now(), + // New actions capture this before dispatch so replay is deterministic. + // The fallback keeps legacy operations and direct test callers valid. + created: createdTaskTimestamp ?? Date.now(), }; tasksToAdd.push(newTask); diff --git a/src/app/root-store/meta/task-shared.actions.ts b/src/app/root-store/meta/task-shared.actions.ts index 7c542a48ad..3e2a3bd280 100644 --- a/src/app/root-store/meta/task-shared.actions.ts +++ b/src/app/root-store/meta/task-shared.actions.ts @@ -381,6 +381,8 @@ export const TaskSharedActions = createActionGroup({ projectId: string; operations: BatchOperation[]; createdTaskIds: { [tempId: string]: string }; + /** Captured before dispatch so replay creates identical task state. */ + createdTaskTimestamp?: number; }) => ({ ...taskProps, meta: { diff --git a/src/app/t.const.ts b/src/app/t.const.ts index 2677f69717..08e933e3b6 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -2288,6 +2288,7 @@ const T = { }, EXPORT_DATA: 'FILE_IMEX.EXPORT_DATA', IMPORT_FROM_FILE: 'FILE_IMEX.IMPORT_FROM_FILE', + IMPORT_FROM_TODOIST: 'FILE_IMEX.IMPORT_FROM_TODOIST', IMPORT_FROM_URL: 'FILE_IMEX.IMPORT_FROM_URL', IMPORT_FROM_URL_DIALOG_DESCRIPTION: 'FILE_IMEX.IMPORT_FROM_URL_DIALOG_DESCRIPTION', IMPORT_FROM_URL_DIALOG_TITLE: 'FILE_IMEX.IMPORT_FROM_URL_DIALOG_TITLE', @@ -2299,6 +2300,7 @@ const T = { S_ERR_INVALID_DATA: 'FILE_IMEX.S_ERR_INVALID_DATA', S_ERR_INVALID_URL: 'FILE_IMEX.S_ERR_INVALID_URL', S_ERR_NETWORK: 'FILE_IMEX.S_ERR_NETWORK', + S_ERR_TODOIST_IMPORT_OPEN: 'FILE_IMEX.S_ERR_TODOIST_IMPORT_OPEN', S_IMPORT_FROM_URL_ERR_DECODE: 'FILE_IMEX.S_IMPORT_FROM_URL_ERR_DECODE', URL_PLACEHOLDER: 'FILE_IMEX.URL_PLACEHOLDER', }, diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index eadb43d24c..239aa0c847 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -2228,6 +2228,7 @@ }, "EXPORT_DATA": "Export Data", "IMPORT_FROM_FILE": "Import from File", + "IMPORT_FROM_TODOIST": "Import from Todoist", "IMPORT_FROM_URL": "Import from URL", "IMPORT_FROM_URL_DIALOG_DESCRIPTION": "Please enter the full URL of the Super Productivity backup JSON file to import.", "IMPORT_FROM_URL_DIALOG_TITLE": "Import from URL", @@ -2239,6 +2240,7 @@ "S_ERR_INVALID_DATA": "Import failed: Invalid JSON", "S_ERR_INVALID_URL": "Import failed: Invalid URL provided", "S_ERR_NETWORK": "Import failed: Network error while fetching data from URL", + "S_ERR_TODOIST_IMPORT_OPEN": "Could not open the Todoist importer", "S_IMPORT_FROM_URL_ERR_DECODE": "Error: Could not decode the URL parameter for import. Please ensure it is correctly formatted.", "URL_PLACEHOLDER": "Enter URL to import from" }, From c68929d1fb5d7559b0cc2ba0aa06978f60012ad2 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 10 Jul 2026 13:55:23 +0200 Subject: [PATCH 07/25] fix(task-repeat): allow selecting day-of-month recurrence (#8896) * fix(task-repeat): allow selecting day-of-month mode * fix(task-repeat): clarify monthly recurrence pattern * test(task-repeat): cover day-of-month persistence * docs(task-repeat): explain custom monthly patterns --- docs/wiki/2.06-Manage-Repeating-Tasks.md | 1 + ...log-edit-task-repeat-cfg.component.spec.ts | 84 +++++++++++++++++-- .../dialog-edit-task-repeat-cfg.component.ts | 7 ++ .../task-repeat-cfg-form.const.spec.ts | 17 ++++ .../task-repeat-cfg-form.const.ts | 1 + src/app/t.const.ts | 2 + src/assets/i18n/en.json | 3 +- 7 files changed, 109 insertions(+), 6 deletions(-) diff --git a/docs/wiki/2.06-Manage-Repeating-Tasks.md b/docs/wiki/2.06-Manage-Repeating-Tasks.md index 8418859e6c..b361f58c82 100755 --- a/docs/wiki/2.06-Manage-Repeating-Tasks.md +++ b/docs/wiki/2.06-Manage-Repeating-Tasks.md @@ -27,6 +27,7 @@ You can also open the dialog from a repeating task instance or from a repeat pro - Schedule type (only for intervals > 1): Fixed schedule (every X from start date) or After completion (X after I finish). - Start date — when the pattern begins (required). - For weekly cycle: check the weekdays (Monday–Sunday) on which the task should recur. + - For monthly cycle: choose a **Monthly pattern**. **Day of month** uses the day shown in **Start date**; first, second, third, fourth, and last patterns also require a **Weekday**. 5. Optionally set **Tags to add** (chip list). 6. Optionally set **Scheduled start time** (e.g. 15:00; leave blank for all-day). If you set a time, **Remind at** appears — choose when to be reminded (e.g. when it starts, 5 minutes before). 7. Optionally set **Default Estimate** (duration). **Order** (number) controls creation order when several recurring tasks are created at the same time; see the field description in the dialog. 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 18c7182665..540a74eb71 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 @@ -7,7 +7,10 @@ import { MatDialogRef, } from '@angular/material/dialog'; import { DateAdapter, MatNativeDateModule } from '@angular/material/core'; +import { MatFormFieldHarness } from '@angular/material/form-field/testing'; +import { MatSelectHarness } from '@angular/material/select/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { TranslateModule } from '@ngx-translate/core'; import { provideMockStore } from '@ngrx/store/testing'; import { Observable, of, Subject } from 'rxjs'; @@ -74,6 +77,7 @@ describe('DialogEditTaskRepeatCfgComponent', () => { getRepeatCfgReturnValue?: | Observable | Subject, + renderTemplate = false, ): Promise> => { mockDialogRef = jasmine.createSpyObj('MatDialogRef', ['close']); mockMatDialog = jasmine.createSpyObj('MatDialog', ['open']); @@ -117,7 +121,7 @@ describe('DialogEditTaskRepeatCfgComponent', () => { formatTime: () => '12:00 PM', }); - await TestBed.configureTestingModule({ + const testModule = TestBed.configureTestingModule({ imports: [ DialogEditTaskRepeatCfgComponent, MatDialogModule, @@ -140,16 +144,20 @@ describe('DialogEditTaskRepeatCfgComponent', () => { { provide: DateService, useValue: mockDateService }, { provide: DateAdapter, useClass: CustomDateAdapter }, ], - }) - .overrideComponent(DialogEditTaskRepeatCfgComponent, { + }); + + if (!renderTemplate) { + testModule.overrideComponent(DialogEditTaskRepeatCfgComponent, { set: { // Use a minimal template to avoid @ngx-formly/material select rendering, // which triggers a compareWith validation error with Angular Material 21+. // These tests verify component signals/logic, not template rendering. template: '
', }, - }) - .compileComponents(); + }); + } + + await testModule.compileComponents(); return TestBed.createComponent(DialogEditTaskRepeatCfgComponent); }; @@ -158,6 +166,72 @@ describe('DialogEditTaskRepeatCfgComponent', () => { TestBed.resetTestingModule(); }); + it('keeps Day of month selected after switching from an Nth weekday (#8886)', async () => { + const monthlyNthWeekdayCfg: TaskRepeatCfg = { + ...DEFAULT_TASK_REPEAT_CFG, + id: 'repeat-cfg-monthly-nth-weekday', + title: 'Monthly task', + quickSetting: 'CUSTOM', + repeatCycle: 'MONTHLY', + startDate: '2026-06-09', + monthlyWeekOfMonth: 2, + monthlyWeekday: 1, + }; + const fixture = await setupTestBed( + { repeatCfg: monthlyNthWeekdayCfg }, + undefined, + true, + ); + fixture.detectChanges(); + await fixture.whenStable(); + + const loader = TestbedHarnessEnvironment.loader(fixture); + const selects = await loader.getAllHarnesses(MatSelectHarness); + const formFieldsBeforeSwitch = await loader.getAllHarnesses(MatFormFieldHarness); + const labelsBeforeSwitch = await Promise.all( + formFieldsBeforeSwitch.map((formField) => formField.getLabel()), + ); + expect(labelsBeforeSwitch).toContain(T.F.TASK_REPEAT.F.WEEKDAY); + let monthlyPatternSelect: MatSelectHarness | undefined; + let dayOfMonthOptionText = ''; + + for (const select of selects) { + await select.open(); + const [dayOfMonthOption] = await select.getOptions({ + text: /MONTHLY_MODE_DAY_OF_MONTH/, + }); + if (dayOfMonthOption) { + monthlyPatternSelect = select; + dayOfMonthOptionText = await dayOfMonthOption.getText(); + await dayOfMonthOption.click(); + break; + } + await select.close(); + } + + expect(monthlyPatternSelect).toBeDefined(); + expect(await monthlyPatternSelect!.getValueText()).toBe(dayOfMonthOptionText); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.componentInstance.repeatCfg().monthlyWeekOfMonth).toBeNull(); + const formFieldsAfterSwitch = await loader.getAllHarnesses(MatFormFieldHarness); + const labelsAfterSwitch = await Promise.all( + formFieldsAfterSwitch.map((formField) => formField.getLabel()), + ); + expect(labelsAfterSwitch).not.toContain(T.F.TASK_REPEAT.F.WEEKDAY); + + fixture.componentInstance.save(); + + const changes = + mockTaskRepeatCfgService.updateTaskRepeatCfg.calls.mostRecent().args[1]; + expect( + Object.prototype.hasOwnProperty.call(changes, 'monthlyWeekOfMonth'), + ).toBeTrue(); + expect(changes.monthlyWeekOfMonth).toBeUndefined(); + }); + describe('isLoading signal', () => { it('should be false when repeatCfg is provided directly (sync path)', async () => { const fixture = await setupTestBed({ repeatCfg: mockRepeatCfg }); 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 c122b24f67..518a72b85a 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 @@ -57,6 +57,7 @@ import { getDateTimeFromClockString } from '../../../util/get-date-time-from-clo import { remindOptionToMilliseconds } from '../../tasks/util/remind-option-to-milliseconds'; import { isValidSplitTime } from '../../../util/is-valid-split-time'; import { DateService } from '../../../core/date/date.service'; +import { MAT_SELECT_CONFIG } from '@angular/material/select'; // Fields whose change requires offering "Update all task instances?" — covers // what propagates to existing tasks (vs. schedule fields, which only affect @@ -88,6 +89,12 @@ const WEEKDAY_KEYS: (keyof TaskRepeatCfgCopy)[] = [ templateUrl: './dialog-edit-task-repeat-cfg.component.html', styleUrls: ['./dialog-edit-task-repeat-cfg.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: MAT_SELECT_CONFIG, + useValue: { canSelectNullableOptions: true }, + }, + ], imports: [ MatDialogTitle, TranslatePipe, diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.spec.ts b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.spec.ts index ca068b7bb7..5882098917 100644 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.spec.ts +++ b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.spec.ts @@ -2,6 +2,7 @@ import { TASK_REPEAT_CFG_ADVANCED_FORM_CFG, TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG, } from './task-repeat-cfg-form.const'; +import { T } from '../../../t.const'; describe('TaskRepeatCfgFormConfig', () => { it('should not contain startDate in essential form fields', () => { @@ -23,6 +24,22 @@ describe('TaskRepeatCfgFormConfig', () => { expect(remindAtField).toBeUndefined(); }); + it('explains that Day of month uses the start date (#8886)', () => { + const repeatContainer = TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG.find( + (field) => field.fieldGroupClassName === 'repeat-config-container', + ); + const monthlyAnchor = repeatContainer?.fieldGroup?.find( + (field) => field.fieldGroupClassName === 'monthly-anchor', + ); + const monthlyPattern = monthlyAnchor?.fieldGroup?.find( + (field) => field.key === 'monthlyWeekOfMonth', + ); + + expect(monthlyPattern?.templateOptions?.description).toBe( + T.F.TASK_REPEAT.F.MONTHLY_MODE_DAY_OF_MONTH_DESCRIPTION, + ); + }); + describe('weekdays group visibility (issue #8025)', () => { const repeatContainer = TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG.find( (field) => field.fieldGroupClassName === 'repeat-config-container', diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.ts b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.ts index bce6326523..7adf22d56b 100644 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.ts +++ b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.ts @@ -95,6 +95,7 @@ export const TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG: FormlyFieldConfig[] = [ defaultValue: null, templateOptions: { label: T.F.TASK_REPEAT.F.WEEK_OF_MONTH, + description: T.F.TASK_REPEAT.F.MONTHLY_MODE_DAY_OF_MONTH_DESCRIPTION, options: [ { value: null, label: T.F.TASK_REPEAT.F.MONTHLY_MODE_DAY_OF_MONTH }, { value: 1, label: T.F.TASK_REPEAT.F.ORD_FIRST }, diff --git a/src/app/t.const.ts b/src/app/t.const.ts index 08e933e3b6..cdbe6cdb56 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -2054,6 +2054,8 @@ const T = { INHERIT_SUBTASKS_DESCRIPTION: 'F.TASK_REPEAT.F.INHERIT_SUBTASKS_DESCRIPTION', MONDAY: 'F.TASK_REPEAT.F.MONDAY', MONTHLY_MODE_DAY_OF_MONTH: 'F.TASK_REPEAT.F.MONTHLY_MODE_DAY_OF_MONTH', + MONTHLY_MODE_DAY_OF_MONTH_DESCRIPTION: + 'F.TASK_REPEAT.F.MONTHLY_MODE_DAY_OF_MONTH_DESCRIPTION', NOTES: 'F.TASK_REPEAT.F.NOTES', ORD_FIRST: 'F.TASK_REPEAT.F.ORD_FIRST', ORD_FIRST_NTH: 'F.TASK_REPEAT.F.ORD_FIRST_NTH', diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 239aa0c847..1d98bf3795 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1997,6 +1997,7 @@ "INHERIT_SUBTASKS_DESCRIPTION": "When enabled, subtasks from the most recent task instance will be recreated with the recurring task", "MONDAY": "Monday", "MONTHLY_MODE_DAY_OF_MONTH": "Day of month", + "MONTHLY_MODE_DAY_OF_MONTH_DESCRIPTION": "\"Day of month\" uses the day shown in Start date.", "NOTES": "Default notes", "ORD_FIRST": "first", "ORD_FIRST_NTH": "first", @@ -2038,7 +2039,7 @@ "WAIT_FOR_COMPLETION": "Only create next task after completion", "WAIT_FOR_COMPLETION_DESCRIPTION": "Prevents multiple pending recurring tasks from piling up. The next instance will only be created once the current task is completed", "WEDNESDAY": "Wednesday", - "WEEK_OF_MONTH": "Week of month", + "WEEK_OF_MONTH": "Monthly pattern", "WEEKDAY": "Weekday", "WEEKDAY_REQUIRED": "Select at least one weekday" }, From ec81c226c0cecf7d9fd171a4ca7c417734c278a8 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 10 Jul 2026 13:59:48 +0200 Subject: [PATCH 08/25] fix(config): restore day-start offset after operation replay (#8899) * chore(deps): pin npm version to remove lockfile drift Contributors on npm 10 (Node 22's bundled default) vs npm 11 reconcile the app-builder-lib.minimatch override differently, rewriting package-lock.json on npm install. Pin npm 11.7.0 via the standard packageManager field (Corepack) and complete the existing volta block so volta users pick it up automatically. Ref: discussion #8869 * chore: stop tracking .claude/skills symlink (runtime-managed) The Claude Code runtime scaffolds .claude/ each session and materializes .claude/skills as a real directory, clobbering the committed symlink (-> ../.agents/skills). Git then reported a perpetual 'D .claude/skills' in every worktree/session. Skill sources remain tracked under .agents/skills/; let the runtime own .claude/skills and ignore it via the existing /.claude/* rule. * fix(config): restore day-start offset after operation replay Reconcile DateService and app-state from config after incremental or full-state bulk replay without re-minting persistent task operations. Fixes #8890 * fix(sync): replay full-state operations through reducers Normalize canonical full-state operation types to LOAD_ALL_DATA during replay while preserving their original operation metadata. This lets repair and legacy backup operations install state through the existing reducers. --- .claude/skills | 1 - .gitignore | 1 - package.json | 4 +- .../config/store/global-config.effects.ts | 31 +++ ...config.start-of-next-day-hydration.spec.ts | 241 ++++++++++++++++++ .../apply/operation-converter.util.spec.ts | 2 + .../op-log/apply/operation-converter.util.ts | 3 +- 7 files changed, 279 insertions(+), 4 deletions(-) delete mode 120000 .claude/skills create mode 100644 src/app/features/config/store/global-config.start-of-next-day-hydration.spec.ts diff --git a/.claude/skills b/.claude/skills deleted file mode 120000 index 2b7a412b8f..0000000000 --- a/.claude/skills +++ /dev/null @@ -1 +0,0 @@ -../.agents/skills \ No newline at end of file diff --git a/.gitignore b/.gitignore index 23fa10dae8..6aba5beeb3 100644 --- a/.gitignore +++ b/.gitignore @@ -98,7 +98,6 @@ electron-builder.win-store.yaml /src/environments/environment.js /src/environments/environment.js.map /.claude/* -!/.claude/skills .aider* diff --git a/package.json b/package.json index 501c7d51f7..62db9fe4f6 100644 --- a/package.json +++ b/package.json @@ -361,7 +361,9 @@ "owner": "johannesjo" } ], + "packageManager": "npm@11.7.0", "volta": { - "node": "22.18.0" + "node": "22.18.0", + "npm": "11.7.0" } } diff --git a/src/app/features/config/store/global-config.effects.ts b/src/app/features/config/store/global-config.effects.ts index 11e6835a32..3bc2910532 100644 --- a/src/app/features/config/store/global-config.effects.ts +++ b/src/app/features/config/store/global-config.effects.ts @@ -28,6 +28,7 @@ import { updateGlobalConfigSection } from './global-config.actions'; import { selectConfigFeatureState, selectLocalizationConfig, + selectMiscConfig, } from './global-config.reducer'; import { mapKeyboardConfigToQwerty } from '../keyboard-shortcut.util'; import { AppFeaturesConfig, MiscConfig } from '../global-config.model'; @@ -37,6 +38,8 @@ import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions' import { selectAllTasks } from '../../tasks/store/task.selectors'; import { normalizeStartOfNextDayConfig } from '../normalize-start-of-next-day-config'; import { Log } from '../../../core/log'; +import { bulkApplyOperations } from '../../../op-log/apply/bulk-hydration.action'; +import { FULL_STATE_OP_TYPES } from '../../../op-log/core/operation.types'; const LAYOUT_DETECTION_TIMEOUT_MS = 1000; @@ -220,6 +223,34 @@ export class GlobalConfigEffects { ), ); + // Bulk replay intentionally hides its inner actions from effects. Reconcile this + // local, non-persistent runtime state after reducers finish, but keep the persistent + // dueDay migration in the direct local-change effect above. + setStartOfNextDayDiffOnBulkApply = createEffect(() => + this._actions$.pipe( + ofType(bulkApplyOperations), + filter(({ operations }) => + operations.some( + (op) => + (op.entityType === 'GLOBAL_CONFIG' && op.entityId === 'misc') || + FULL_STATE_OP_TYPES.has(op.opType), + ), + ), + withLatestFrom(this._store.select(selectMiscConfig)), + map(([, misc]) => { + const normalizedMisc = normalizeStartOfNextDayConfig(misc); + this._dateService.setStartOfNextDayDiff( + normalizedMisc.startOfNextDayTime, + normalizedMisc.startOfNextDay, + ); + return AppStateActions.setTodayString({ + todayStr: this._dateService.todayStr(), + startOfNextDayDiffMs: this._dateService.getStartOfNextDayDiffMs(), + }); + }), + ), + ); + notifyElectronAboutCfgChange = createEffect( () => this._actions$.pipe( diff --git a/src/app/features/config/store/global-config.start-of-next-day-hydration.spec.ts b/src/app/features/config/store/global-config.start-of-next-day-hydration.spec.ts new file mode 100644 index 0000000000..e54e92c01e --- /dev/null +++ b/src/app/features/config/store/global-config.start-of-next-day-hydration.spec.ts @@ -0,0 +1,241 @@ +import { TestBed } from '@angular/core/testing'; +import { Action, Store, StoreModule } from '@ngrx/store'; +import { Actions, EffectsModule } from '@ngrx/effects'; +import { Subscription, take } from 'rxjs'; +import { DateService } from '../../../core/date/date.service'; +import { GlobalConfigEffects } from './global-config.effects'; +import { + CONFIG_FEATURE_NAME, + globalConfigReducer, + selectMiscConfig, +} from './global-config.reducer'; +import { DEFAULT_GLOBAL_CONFIG } from '../default-global-config.const'; +import { GlobalConfigState, MiscConfig } from '../global-config.model'; +import { loadAllData } from '../../../root-store/meta/load-all-data.action'; +import { AppDataComplete } from '../../../op-log/model/model-config'; +import { bulkApplyOperations } from '../../../op-log/apply/bulk-hydration.action'; +import { bulkOperationsMetaReducer } from '../../../op-log/apply/bulk-hydration.meta-reducer'; +import { ActionType, Operation, OpType } from '../../../op-log/core/operation.types'; +import { initialTaskState, TASK_FEATURE_NAME } from '../../tasks/store/task.reducer'; +import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions'; +import { + appStateFeatureKey, + appStateReducer, +} from '../../../root-store/app-state/app-state.reducer'; +import { AppStateActions } from '../../../root-store/app-state/app-state.actions'; +import { selectStartOfNextDayDiffMs } from '../../../root-store/app-state/app-state.selectors'; +import { LanguageService } from '../../../core/language/language.service'; +import { SnackService } from '../../../core/snack/snack.service'; +import { UserProfileService } from '../../user-profile/user-profile.service'; +import { KeyboardLayoutService } from '../../../core/keyboard-layout/keyboard-layout.service'; +import { IS_ELECTRON_TOKEN } from '../../../app.constants'; +import { IS_MAC_TOKEN } from '../../../util/is-mac'; + +describe('start-of-next-day offset across operation replay', () => { + const SIX_AM_MS = 6 * 60 * 60 * 1000; + + const misc = (startOfNextDay: number, startOfNextDayTime: string): MiscConfig => ({ + ...DEFAULT_GLOBAL_CONFIG.misc, + startOfNextDay, + startOfNextDayTime, + }); + + const appDataWithMisc = (miscCfg: MiscConfig): AppDataComplete => + ({ + globalConfig: { + ...DEFAULT_GLOBAL_CONFIG, + misc: miscCfg, + } as GlobalConfigState, + }) as unknown as AppDataComplete; + + const configOp = (miscCfg: MiscConfig, clientId = 'client1'): Operation => ({ + id: `op-cfg-misc-${clientId}`, + opType: OpType.Update, + entityType: 'GLOBAL_CONFIG', + entityId: 'misc', + actionType: ActionType.GLOBAL_CONFIG_UPDATE_SECTION, + payload: { + actionPayload: { sectionKey: 'misc', sectionCfg: miscCfg }, + entityChanges: [], + }, + vectorClock: { [clientId]: 1 }, + clientId, + timestamp: 1_700_000_000_000, + schemaVersion: 1, + }); + + const fullStateOp = (miscCfg: MiscConfig, clientId = 'client2'): Operation => ({ + id: `op-full-state-${clientId}`, + opType: OpType.SyncImport, + entityType: 'ALL', + actionType: ActionType.LOAD_ALL_DATA, + payload: appDataWithMisc(miscCfg), + vectorClock: { [clientId]: 1 }, + clientId, + timestamp: 1_700_000_000_000, + schemaVersion: 1, + }); + + const repairOp = (miscCfg: MiscConfig, clientId = 'client2'): Operation => ({ + ...fullStateOp(miscCfg, clientId), + id: `op-repair-${clientId}`, + opType: OpType.Repair, + actionType: ActionType.REPAIR_AUTO, + payload: { + appDataComplete: appDataWithMisc(miscCfg), + repairSummary: { + entityStateFixed: 1, + orphanedEntitiesRestored: 0, + invalidReferencesRemoved: 0, + relationshipsFixed: 0, + structureRepaired: 0, + typeErrorsFixed: 0, + }, + }, + }); + + let store: Store; + let dateService: DateService; + let observedActions: Action[]; + let actionsSubscription: Subscription; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot( + { + [CONFIG_FEATURE_NAME]: globalConfigReducer, + [TASK_FEATURE_NAME]: () => initialTaskState, + [appStateFeatureKey]: appStateReducer, + }, + { metaReducers: [bulkOperationsMetaReducer] }, + ), + EffectsModule.forRoot([GlobalConfigEffects]), + ], + providers: [ + { + provide: LanguageService, + useValue: { setLng: (): void => undefined, tryAutoswitch: (): boolean => true }, + }, + { provide: SnackService, useValue: { open: (): void => undefined } }, + { + provide: UserProfileService, + useValue: { migrateOnFirstEnable: (): Promise => Promise.resolve() }, + }, + { provide: KeyboardLayoutService, useValue: new KeyboardLayoutService() }, + { provide: IS_ELECTRON_TOKEN, useValue: false }, + { provide: IS_MAC_TOKEN, useValue: false }, + ], + }); + + store = TestBed.inject(Store); + dateService = TestBed.inject(DateService); + observedActions = []; + actionsSubscription = TestBed.inject(Actions).subscribe((action) => + observedActions.push(action), + ); + }); + + afterEach(() => actionsSubscription.unsubscribe()); + + const readStoreHour = (): number => { + let hour = -1; + store + .select(selectMiscConfig) + .pipe(take(1)) + .subscribe((cfg) => (hour = cfg.startOfNextDay)); + return hour; + }; + + const readAppStateOffset = (): number => { + let offset = -1; + store + .select(selectStartOfNextDayDiffMs) + .pipe(take(1)) + .subscribe((value) => (offset = value)); + return offset; + }; + + const bootFromSnapshotAt = (miscCfg: MiscConfig): void => { + store.dispatch(loadAllData({ appDataComplete: appDataWithMisc(miscCfg) })); + observedActions = []; + }; + + const replay = (operations: Operation[], localClientId = 'client1'): void => { + store.dispatch(bulkApplyOperations({ operations, localClientId })); + }; + + it('installs the offset when the config op is replayed at startup', () => { + bootFromSnapshotAt(misc(0, '00:00')); + + replay([configOp(misc(6, '06:00'), 'client1')]); + + expect(readStoreHour()).toBe(6); + expect(dateService.getStartOfNextDayDiffMs()).toBe(SIX_AM_MS); + expect(readAppStateOffset()).toBe(SIX_AM_MS); + }); + + it('installs the offset when the config change arrives from another device', () => { + bootFromSnapshotAt(misc(0, '00:00')); + + replay([configOp(misc(6, '06:00'), 'client2')]); + + expect(readStoreHour()).toBe(6); + expect(dateService.getStartOfNextDayDiffMs()).toBe(SIX_AM_MS); + expect(readAppStateOffset()).toBe(SIX_AM_MS); + }); + + it('installs the offset when a full-state operation is replayed', () => { + bootFromSnapshotAt(misc(0, '00:00')); + + replay([fullStateOp(misc(6, '06:00'))]); + + expect(readStoreHour()).toBe(6); + expect(dateService.getStartOfNextDayDiffMs()).toBe(SIX_AM_MS); + expect(readAppStateOffset()).toBe(SIX_AM_MS); + }); + + it('installs the offset when a repair operation is replayed', () => { + bootFromSnapshotAt(misc(0, '00:00')); + + replay([repairOp(misc(6, '06:00'))]); + + expect(readStoreHour()).toBe(6); + expect(dateService.getStartOfNextDayDiffMs()).toBe(SIX_AM_MS); + expect(readAppStateOffset()).toBe(SIX_AM_MS); + }); + + it('does not re-mint task updates while replaying the config operation', () => { + bootFromSnapshotAt(misc(0, '00:00')); + + replay([configOp(misc(6, '06:00'))]); + + expect( + observedActions.some( + (action) => action.type === TaskSharedActions.updateTasks.type, + ), + ).toBeFalse(); + }); + + it('ignores bulk operations that cannot change the day-start config', () => { + bootFromSnapshotAt(misc(0, '00:00')); + const unrelatedOp: Operation = { + ...configOp(misc(6, '06:00')), + id: 'op-cfg-sound-client1', + entityId: 'sound', + payload: { + actionPayload: { sectionKey: 'sound', sectionCfg: {} }, + entityChanges: [], + }, + }; + + replay([unrelatedOp]); + + expect(dateService.getStartOfNextDayDiffMs()).toBe(0); + expect( + observedActions.some( + (action) => action.type === AppStateActions.setTodayString.type, + ), + ).toBeFalse(); + }); +}); diff --git a/src/app/op-log/apply/operation-converter.util.spec.ts b/src/app/op-log/apply/operation-converter.util.spec.ts index bb2c65f669..a55ee633ec 100644 --- a/src/app/op-log/apply/operation-converter.util.spec.ts +++ b/src/app/op-log/apply/operation-converter.util.spec.ts @@ -202,6 +202,7 @@ describe('operation-converter utility', () => { }); const action = convertOpToAction(op); + expect(action.type).toBe(ActionType.LOAD_ALL_DATA); expect((action as any).appDataComplete).toEqual(fullState); expect((action as any).task).toBeUndefined(); }); @@ -218,6 +219,7 @@ describe('operation-converter utility', () => { }); const action = convertOpToAction(op); + expect(action.type).toBe(ActionType.LOAD_ALL_DATA); expect((action as any).appDataComplete).toEqual(fullState); }); diff --git a/src/app/op-log/apply/operation-converter.util.ts b/src/app/op-log/apply/operation-converter.util.ts index 96becd1751..f65d20d970 100644 --- a/src/app/op-log/apply/operation-converter.util.ts +++ b/src/app/op-log/apply/operation-converter.util.ts @@ -163,6 +163,7 @@ export const convertOpToAction = (op: Operation): PersistentAction => { // Handle full-state operations (SYNC_IMPORT, BACKUP_IMPORT, Repair) specially // These need their payload wrapped in appDataComplete for the loadAllData action const isFullStateOp = FULL_STATE_OP_TYPES.has(op.opType as OpType); + const replayActionType = isFullStateOp ? ActionType.LOAD_ALL_DATA : actionType; let actionPayload: Record = isFullStateOp ? extractFullStatePayload(op.payload) : (extractActionPayload(op.payload) as Record); @@ -211,7 +212,7 @@ export const convertOpToAction = (op: Operation): PersistentAction => { // named 'type' (like SimpleCounter.type = 'ClickCounter') from overwriting the action type. return { ...actionPayload, - type: actionType, + type: replayActionType, meta: { isPersistent: true, entityType: op.entityType, From 76f2371a56b0518dda34356853c2100d9746a00a Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 10 Jul 2026 15:01:55 +0200 Subject: [PATCH 09/25] style(tasks): elevate add-subtask input --- .../tasks/add-subtask-input/add-subtask-input.component.scss | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/features/tasks/add-subtask-input/add-subtask-input.component.scss b/src/app/features/tasks/add-subtask-input/add-subtask-input.component.scss index 24e5d41637..922d26a98d 100644 --- a/src/app/features/tasks/add-subtask-input/add-subtask-input.component.scss +++ b/src/app/features/tasks/add-subtask-input/add-subtask-input.component.scss @@ -17,15 +17,16 @@ padding: 0 44px 0 var(--s2); border: 1px solid var(--extra-border-color); border-radius: var(--task-border-radius); + box-shadow: var(--whiteframe-shadow-4dp); outline: 0; - background: var(--sub-task-c-bg); + background: var(--bg-lightest); color: var(--text-color-most-intense); font-family: var(--font-primary-stack); font-size: 14px; line-height: 40px; &:focus { - border-color: var(--palette-primary-500); + border-color: var(--c-accent); } } From d5db11f329631fb5fb0683a9319dff434f6356ec Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 10 Jul 2026 17:05:50 +0200 Subject: [PATCH 10/25] fix(tasks): remove postal-mime dep and harden eml import (#8901) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * style(tasks): elevate add-subtask input * fix(tasks): parse eml files without external dependency Replace postal-mime with a bounded parser for sender, subject, and unencoded plain-text bodies. Ignore unsupported MIME body formats and document the root dependency policy. * fix(tasks): isolate and harden eml import Lazy-load the local parser, reject lossy or unsupported bodies, and store accepted text as literal notes so imported content cannot trigger remote resource loads. Document the title-only fallback and expand regression coverage. * fix(tasks): decode eml headers and harden import parsing - Decode RFC 2047 encoded-words in the subject and sender name so international titles show "Grüße" instead of raw "=?UTF-8?...?=". - Replace the charset regex with a quote-aware Content-Type parser so a charset inside another quoted parameter can't be mistaken for the real one, and ignore RFC 822 header comments (e.g. "7bit (comment)"). - Cap the synced title (300) and body (100k) so a crafted .eml can't amplify into an oversized op (the literal fence can double the body). - Document parseEml's intentional MIME omissions to prevent a later "fix" that reopens the untrusted-body attack surface. --- AGENTS.md | 1 + docs/wiki/2.03-Add-Tasks.md | 2 +- package-lock.json | 7 - package.json | 1 - .../drop-paste-input/eml-drop.directive.ts | 2 +- .../drop-paste-input/eml-drop.service.spec.ts | 105 ++++++- .../core/drop-paste-input/eml-drop.service.ts | 61 ++-- src/app/util/eml-parser.spec.ts | 281 ++++++++++++++++-- src/app/util/eml-parser.ts | 191 +++++++++++- src/app/util/is-file-eml.spec.ts | 22 ++ src/app/util/is-file-eml.ts | 3 + 11 files changed, 607 insertions(+), 69 deletions(-) create mode 100644 src/app/util/is-file-eml.spec.ts create mode 100644 src/app/util/is-file-eml.ts diff --git a/AGENTS.md b/AGENTS.md index 760ab568c5..475885b9c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,7 @@ For local SuperSync E2E (docker-compose) and the full E2E reference, see [`e2e/C - **Translations:** UI strings go through `T` / `TranslateService`. Edit only `en.json`; never other locales. - **Privacy:** no analytics or tracking — user data stays local unless explicitly synced. +- **Dependencies:** PRs must not add new packages to the root project's `dependencies` or `devDependencies`; use platform APIs, existing packages, or a small in-repo implementation instead. Dependencies scoped to an individual plugin are allowed when they are necessary and remain isolated to that plugin. - **Electron:** check `IS_ELECTRON` before using Electron-specific APIs. - **Templates:** plain HTML, minimal CSS/classes, Angular Material sparingly. See [`docs/styling-guide.md`](docs/styling-guide.md). - **Styling review:** do not locally restyle Angular Material or shared `src/app/ui/` components for one-off context needs. This includes overriding button styles via `.mat-*`, `.mdc-*`, `button[mat-*]`, or component internals in local SCSS. Prefer existing inputs/classes/tokens; if a variant must exist, make it reusable or add it to the shared style layer. diff --git a/docs/wiki/2.03-Add-Tasks.md b/docs/wiki/2.03-Add-Tasks.md index 24c8633f1b..59fed0f00c 100755 --- a/docs/wiki/2.03-Add-Tasks.md +++ b/docs/wiki/2.03-Add-Tasks.md @@ -13,7 +13,7 @@ This how-to covers adding new tasks to your list. For subtasks, scheduling, repe 1. With the add task bar open, type the task title in the input. The placeholder shows an example: **A task title #tag @16:00 t25m** (you can use **#tag**, **@time**, **@date**, and **[t]time** in the title; see below). 2. Press **Enter** or click the **Add task** button (plus icon next to the input). The task is added to the current project or context (Today / tag). -> You can also create a task by dropping an `.eml` email file onto the **add task** button (the **+** icon in the header). The task title becomes `sender: subject` and the email body is saved to the task's notes. +> You can also create a task by dropping an `.eml` email file onto the **add task** button (the **+** icon in the header). The task title becomes `sender: subject`. Simple, unencoded plain-text bodies are saved to the task's notes as literal text; unsupported body formats create a title-only task. The new task appears at the **top** or **bottom** of the list depending on the add bar setting (see “Add to top or bottom” below). diff --git a/package-lock.json b/package-lock.json index 65a1857cb7..62455fd9ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,6 @@ "hash-wasm": "^4.12.0", "https-proxy-agent": "^7.0.0", "node-fetch": "^2.7.0", - "postal-mime": "^2.7.4", "uuidv7": "^1.2.1" }, "devDependencies": { @@ -22711,12 +22710,6 @@ "node": ">= 0.4" } }, - "node_modules/postal-mime": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz", - "integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==", - "license": "MIT-0" - }, "node_modules/postcss": { "version": "8.5.12", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", diff --git a/package.json b/package.json index 62db9fe4f6..c52ee87f70 100644 --- a/package.json +++ b/package.json @@ -193,7 +193,6 @@ "hash-wasm": "^4.12.0", "https-proxy-agent": "^7.0.0", "node-fetch": "^2.7.0", - "postal-mime": "^2.7.4", "uuidv7": "^1.2.1" }, "devDependencies": { diff --git a/src/app/core/drop-paste-input/eml-drop.directive.ts b/src/app/core/drop-paste-input/eml-drop.directive.ts index d9a588ba14..ed6f5e36c3 100644 --- a/src/app/core/drop-paste-input/eml-drop.directive.ts +++ b/src/app/core/drop-paste-input/eml-drop.directive.ts @@ -1,5 +1,5 @@ import { Directive, HostListener, inject } from '@angular/core'; -import { isFileEml } from '../../util/eml-parser'; +import { isFileEml } from '../../util/is-file-eml'; import { EmlDropService } from './eml-drop.service'; @Directive({ diff --git a/src/app/core/drop-paste-input/eml-drop.service.spec.ts b/src/app/core/drop-paste-input/eml-drop.service.spec.ts index 7ab0625ff9..d566f8d620 100644 --- a/src/app/core/drop-paste-input/eml-drop.service.spec.ts +++ b/src/app/core/drop-paste-input/eml-drop.service.spec.ts @@ -52,7 +52,7 @@ describe('EmlDropService', () => { expect(taskService.add).toHaveBeenCalledWith( 'Alice Example: Hello World', false, - { notes: 'body' }, + { notes: '```\nbody\n```' }, false, true, ); @@ -65,7 +65,7 @@ describe('EmlDropService', () => { expect(taskService.add).toHaveBeenCalledWith( 'Hello World', false, - { notes: 'body' }, + { notes: '```\nbody\n```' }, false, true, ); @@ -77,7 +77,7 @@ describe('EmlDropService', () => { expect(taskService.add).toHaveBeenCalledWith( 'Alice Example', false, - { notes: 'body' }, + { notes: '```\nbody\n```' }, false, true, ); @@ -99,6 +99,67 @@ describe('EmlDropService', () => { ); }); + it('should create a title-only task when the body encoding is unsupported', async () => { + const encodedEml = [ + 'From: Alice ', + 'Subject: Encoded', + 'Content-Type: text/plain', + 'Content-Transfer-Encoding: base64', + '', + 'Ym9keQ==', + '', + ].join('\n'); + + await service.createTaskFromEml(makeFile(encodedEml)); + + expect(taskService.add).toHaveBeenCalledWith( + 'Alice: Encoded', + false, + { notes: undefined }, + false, + true, + ); + }); + + it('should store imported plain text literally so remote content stays inert', async () => { + const remoteImageEml = [ + 'From: Alice ', + 'Subject: Remote image', + '', + '![pixel](https://attacker.example/pixel)', + '\\![already escaped](https://attacker.example/pixel)', + '![reference image][pixel]', + '[pixel]: https://attacker.example/pixel', + '`C:\\tmp`', + 'ordinary markup', + '', + '```', + '', + ].join('\n'); + + await service.createTaskFromEml(makeFile(remoteImageEml)); + + expect(taskService.add).toHaveBeenCalledWith( + 'Alice: Remote image', + false, + { + notes: + '~~~\n' + + '![pixel](https://attacker.example/pixel)\n' + + '\\![already escaped](https://attacker.example/pixel)\n' + + '![reference image][pixel]\n' + + '[pixel]: https://attacker.example/pixel\n' + + '`C:\\tmp`\n' + + 'ordinary markup\n' + + '\n' + + '```\n' + + '~~~', + }, + false, + true, + ); + }); + it('should not parse a valid eml or add a task when the file exceeds the size limit', async () => { const bigFile = makeFile(VALID_EML); // Report an oversized file without allocating 10MB. @@ -113,6 +174,39 @@ describe('EmlDropService', () => { }); }); + it('should cap an oversized body so the synced note stays bounded', async () => { + const maxBodyLength = 100_000; + const longBody = 'a'.repeat(maxBodyLength + 50); + const eml = [ + 'From: Alice ', + 'Subject: Long', + '', + longBody, + '', + ].join('\n'); + + await service.createTaskFromEml(makeFile(eml)); + + const notes = taskService.add.calls.mostRecent().args[2]?.notes; + expect(notes).toBe('```\n' + 'a'.repeat(maxBodyLength) + '…\n```'); + }); + + it('should cap an oversized title', async () => { + const eml = [ + 'From: Alice ', + 'Subject: ' + 'S'.repeat(350), + '', + 'body', + '', + ].join('\n'); + + await service.createTaskFromEml(makeFile(eml)); + + const title = taskService.add.calls.mostRecent().args[0]; + expect(title).toBe('Alice: ' + 'S'.repeat(293) + '…'); + expect(title?.length).toBe(301); + }); + it('should show a warning snack and not add a task when both sender and subject are empty', async () => { await service.createTaskFromEml(makeFile(EMPTY_EML)); @@ -126,13 +220,12 @@ describe('EmlDropService', () => { it('should log and show an error snack without adding a task when parsing fails', async () => { const logErrSpy = spyOn(Log, 'err'); const file = makeFile(VALID_EML); - // postal-mime reads a Blob via arrayBuffer(), so that's the read to fail. - spyOn(file, 'arrayBuffer').and.rejectWith(new Error('read failed')); + spyOn(file, 'text').and.rejectWith(new Error('read failed')); await service.createTaskFromEml(file); expect(taskService.add).not.toHaveBeenCalled(); - expect(logErrSpy).toHaveBeenCalled(); + expect(logErrSpy).toHaveBeenCalledOnceWith('Failed to parse EML file'); expect(snackService.open).toHaveBeenCalledWith({ type: 'ERROR', msg: T.MH.EML_PARSE_ERROR, diff --git a/src/app/core/drop-paste-input/eml-drop.service.ts b/src/app/core/drop-paste-input/eml-drop.service.ts index 6d53aefc96..8c55fb3de7 100644 --- a/src/app/core/drop-paste-input/eml-drop.service.ts +++ b/src/app/core/drop-paste-input/eml-drop.service.ts @@ -2,13 +2,18 @@ import { inject, Injectable } from '@angular/core'; import { TaskService } from '../../features/tasks/task.service'; import { SnackService } from '../snack/snack.service'; import { Log } from '../log'; -import { parseEml } from '../../util/eml-parser'; import { T } from '../../t.const'; -// postal-mime parses synchronously on the main thread, and the body becomes a -// note that syncs to every device. Bound the untrusted input so a pathological -// .eml can't freeze the UI or balloon the op-log. +// Parsing runs on the main thread, and the body becomes a note that syncs to every +// device. Bound the untrusted input so a pathological .eml can't freeze the UI or +// balloon the op-log. const MAX_EML_FILE_SIZE = 10 * 1024 * 1024; // 10 MB +// The title and body sync to every device as ops. Bound them independently of the +// file size: the literal fence can up to double the body length, and a crafted +// `.eml` can carry a multi-MB Subject, either of which would bloat the op-log. +// shortcut: fixed caps — make configurable if real emails legitimately exceed them. +const MAX_EML_TITLE_LENGTH = 300; +const MAX_EML_BODY_LENGTH = 100_000; @Injectable({ providedIn: 'root', @@ -24,6 +29,7 @@ export class EmlDropService { } try { + const { parseEml } = await import('../../util/eml-parser'); const data = await parseEml(file); const sender = (data.from?.name || data.from?.address || '').trim(); @@ -35,23 +41,44 @@ export class EmlDropService { return; } - const title = [sender, subject].filter(Boolean).join(': '); - // Keep the email body as notes so the task retains context, not just a - // title. Use the plain-text part only (never data.html) — notes render as - // markdown, so injecting untrusted email HTML would be an XSS vector. - const notes = data.text?.trim() || undefined; + const title = _truncate( + [sender, subject].filter(Boolean).join(': '), + MAX_EML_TITLE_LENGTH, + ); + // A text/plain body is external text, not trusted Markdown. Store it in a + // fence that cannot occur in the body so rendering stays literal and inert. + const plainText = data.text?.trim(); + const notes = plainText + ? _asLiteralMarkdown(_truncate(plainText, MAX_EML_BODY_LENGTH)) + : undefined; // isIgnoreShortSyntax: the subject is untrusted external content — don't // let ShortSyntaxEffects parse #tag/@date/+project tokens out of it. this._taskService.add(title, false, { notes }, false, true); - } catch (e) { - // Log a bounded reason, not the raw error: the source is untrusted email - // content and log history is exportable (rule #9). postal-mime's throw - // messages are structural (no message content), so the reason is safe to keep. - Log.err( - 'Failed to parse EML file', - e instanceof Error ? e.message : 'Unknown error', - ); + } catch { + // Error details cross an untrusted file boundary and may contain user content. + Log.err('Failed to parse EML file'); this._snackService.open({ type: 'ERROR', msg: T.MH.EML_PARSE_ERROR }); } } } + +const _truncate = (text: string, max: number): string => + text.length > max ? `${text.slice(0, max)}…` : text; + +const _asLiteralMarkdown = (text: string): string => { + const backtickFence = '`'.repeat(_getFenceLength(text, /^ {0,3}(`{3,})/gm)); + const tildeFence = '~'.repeat(_getFenceLength(text, /^ {0,3}(~{3,})/gm)); + const fence = backtickFence.length <= tildeFence.length ? backtickFence : tildeFence; + + return `${fence}\n${text}\n${fence}`; +}; + +const _getFenceLength = (text: string, fencePattern: RegExp): number => { + let fenceLength = 3; + + for (const match of text.matchAll(fencePattern)) { + fenceLength = Math.max(fenceLength, match[1].length + 1); + } + + return fenceLength; +}; diff --git a/src/app/util/eml-parser.spec.ts b/src/app/util/eml-parser.spec.ts index 5092eb5c54..7c95d393d7 100644 --- a/src/app/util/eml-parser.spec.ts +++ b/src/app/util/eml-parser.spec.ts @@ -1,5 +1,4 @@ -import PostalMime from 'postal-mime'; -import { isFileEml, parseEml } from './eml-parser'; +import { parseEml } from './eml-parser'; const makeFile = (content: string, name: string, type = ''): File => new File([content], name, { type }); @@ -37,25 +36,6 @@ const NO_SUBJECT_EML = [ '', ].join('\n'); -describe('isFileEml', () => { - it('should return true if file ends with .eml', () => { - expect(isFileEml(makeFile('', 'mail.eml'))).toBeTrue(); - }); - - it('should be case-insensitive about the extension', () => { - expect(isFileEml(makeFile('', 'MAIL.EML'))).toBeTrue(); - }); - - it('should return true if file type is message/rfc822', () => { - expect(isFileEml(makeFile('', 'mail', 'message/rfc822'))).toBeTrue(); - }); - - it('should return false if file ending is not .eml and file type is not message/rfc822', () => { - expect(isFileEml(makeFile('', 'doc.pdf', 'application/pdf'))).toBeFalse(); - expect(isFileEml(makeFile('', 'notes.txt', 'text/plain'))).toBeFalse(); - }); -}); - describe('parseEml', () => { it('should parse sender and subject from a valid eml file', async () => { const data = await parseEml(makeFile(VALID_EML, 'mail.eml')); @@ -63,6 +43,23 @@ describe('parseEml', () => { expect(data.from?.address).toBe('alice@example.com'); expect(data.from?.name).toBe('Alice Example'); expect(data.subject).toBe('Hello World'); + expect(data.text).toBe('This is the body text.\n'); + }); + + it('should handle CRLF line endings and folded headers', async () => { + const foldedEml = [ + 'From: Alice ', + 'Subject: Hello', + ' World', + '', + 'body', + '', + ].join('\r\n'); + + const data = await parseEml(makeFile(foldedEml, 'mail.eml')); + + expect(data.subject).toBe('Hello World'); + expect(data.text).toBe('body\n'); }); it('should leave from undefined when there is no From header', async () => { @@ -86,18 +83,248 @@ describe('parseEml', () => { expect(data.from?.address).toBe('alice@example.com'); }); - it('should throw if parsing rejects', async () => { - // NOTE: This test should almost never happen, since postal-mime almost never fails, but its good to have. - spyOn(PostalMime, 'parse').and.rejectWith(new Error('boom')); - + it('should reject a file without a header/body separator', async () => { await expectAsync(parseEml(makeFile('whatever', 'bad.eml'))).toBeRejected(); }); it('should throw if the file cannot be read', async () => { const file = makeFile('', 'unreadable.eml'); - // postal-mime reads a Blob via arrayBuffer(), so that's the read to fail. - spyOn(file, 'arrayBuffer').and.rejectWith(new Error('read failed')); + spyOn(file, 'text').and.rejectWith(new Error('read failed')); await expectAsync(parseEml(file)).toBeRejected(); }); + + it('should omit multipart bodies instead of trying to parse MIME parts', async () => { + const multipartEml = [ + 'From: Alice ', + 'Subject: Multipart', + 'Content-Type: multipart/alternative; boundary="example"', + '', + '--example', + 'Content-Type: text/plain', + '', + 'plain body', + '--example--', + '', + ].join('\n'); + + const data = await parseEml(makeFile(multipartEml, 'multipart.eml')); + + expect(data.from?.address).toBe('alice@example.com'); + expect(data.subject).toBe('Multipart'); + expect(data.text).toBeUndefined(); + }); + + it('should omit transfer-encoded bodies instead of returning encoded content', async () => { + const encodedEml = [ + 'From: Alice ', + 'Subject: Encoded', + 'Content-Type: text/plain', + 'Content-Transfer-Encoding: base64', + '', + 'c2VjcmV0IGJvZHk=', + '', + ].join('\n'); + + const data = await parseEml(makeFile(encodedEml, 'encoded.eml')); + + expect(data.from?.address).toBe('alice@example.com'); + expect(data.subject).toBe('Encoded'); + expect(data.text).toBeUndefined(); + }); + + it('should omit HTML bodies while preserving sender and subject', async () => { + const htmlEml = [ + 'From: Alice ', + 'Subject: HTML', + 'Content-Type: text/html', + '', + '

HTML body

', + '', + ].join('\n'); + + const data = await parseEml(makeFile(htmlEml, 'html.eml')); + + expect(data.from?.address).toBe('alice@example.com'); + expect(data.subject).toBe('HTML'); + expect(data.text).toBeUndefined(); + }); + + it('should omit quoted-printable bodies while preserving sender and subject', async () => { + const quotedPrintableEml = [ + 'From: Alice ', + 'Subject: Quoted printable', + 'Content-Type: text/plain; charset=utf-8', + 'Content-Transfer-Encoding: quoted-printable', + '', + 'encoded=20body', + '', + ].join('\n'); + + const data = await parseEml(makeFile(quotedPrintableEml, 'quoted-printable.eml')); + + expect(data.from?.address).toBe('alice@example.com'); + expect(data.subject).toBe('Quoted printable'); + expect(data.text).toBeUndefined(); + }); + + it('should omit bodies with a declared unsupported charset', async () => { + const file = new File( + [ + [ + 'From: Alice ', + 'Subject: Windows charset', + 'Content-Type: text/plain; charset=windows-1252', + '', + '', + ].join('\r\n'), + new Uint8Array([0xe9]), + ], + 'windows-1252.eml', + ); + + const data = await parseEml(file); + + expect(data.from?.address).toBe('alice@example.com'); + expect(data.subject).toBe('Windows charset'); + expect(data.text).toBeUndefined(); + }); + + it('should keep an explicitly UTF-8 plain-text body', async () => { + const utf8Eml = [ + 'From: Alice ', + 'Subject: UTF-8', + 'Content-Type: text/plain; charset="UTF-8"', + '', + 'Grüße', + '', + ].join('\r\n'); + + const data = await parseEml(makeFile(utf8Eml, 'utf8.eml')); + + expect(data.text).toBe('Grüße\n'); + }); + + it('should keep an explicitly US-ASCII plain-text body', async () => { + const asciiEml = [ + 'From: Alice ', + 'Subject: ASCII', + 'Content-Type: text/plain; charset=us-ascii', + '', + 'ASCII body', + '', + ].join('\r\n'); + + const data = await parseEml(makeFile(asciiEml, 'ascii.eml')); + + expect(data.text).toBe('ASCII body\n'); + }); + + it('should only treat the exact text/plain media type as plain text', async () => { + const nonPlainTextEml = [ + 'From: Alice ', + 'Subject: Other media type', + 'Content-Type: text/plain-script', + '', + 'not plain text', + '', + ].join('\n'); + + const data = await parseEml(makeFile(nonPlainTextEml, 'other.eml')); + + expect(data.text).toBeUndefined(); + }); + + it('should decode a Q-encoded (RFC 2047) subject', async () => { + const eml = [ + 'From: Alice ', + 'Subject: =?UTF-8?Q?Gr=C3=BC=C3=9Fe?=', + '', + 'body', + '', + ].join('\n'); + + const data = await parseEml(makeFile(eml, 'q.eml')); + + expect(data.subject).toBe('Grüße'); + }); + + it('should decode a B-encoded (RFC 2047) subject', async () => { + const eml = [ + 'From: Alice ', + 'Subject: =?UTF-8?B?5LiW55WM?=', + '', + 'body', + '', + ].join('\n'); + + const data = await parseEml(makeFile(eml, 'b.eml')); + + expect(data.subject).toBe('世界'); + }); + + it('should join adjacent encoded-words and decode encoded-word display names', async () => { + const eml = [ + 'From: =?UTF-8?B?R3LDvMOfZQ==?= ', + 'Subject: =?UTF-8?Q?Hello?= =?UTF-8?Q?_World?=', + '', + 'body', + '', + ].join('\n'); + + const data = await parseEml(makeFile(eml, 'adjacent.eml')); + + expect(data.from?.name).toBe('Grüße'); + expect(data.subject).toBe('Hello World'); + }); + + it('should leave encoded-words with an unsupported charset verbatim', async () => { + const eml = [ + 'From: Alice ', + 'Subject: =?ISO-8859-1?Q?caf=E9?=', + '', + 'body', + '', + ].join('\n'); + + const data = await parseEml(makeFile(eml, 'iso.eml')); + + expect(data.subject).toBe('=?ISO-8859-1?Q?caf=E9?='); + }); + + it('should not be fooled by a charset inside another quoted parameter', async () => { + const file = new File( + [ + [ + 'From: Alice ', + 'Subject: Quoted param', + 'Content-Type: text/plain; name="x; charset=utf-8; y"; charset=windows-1252', + '', + '', + ].join('\r\n'), + new Uint8Array([0xe9]), + ], + 'quoted-param.eml', + ); + + const data = await parseEml(file); + + expect(data.text).toBeUndefined(); + }); + + it('should ignore header comments on Content-Type and Content-Transfer-Encoding', async () => { + const eml = [ + 'From: Alice ', + 'Subject: Comments', + 'Content-Type: text/plain (the plain one); charset=utf-8', + 'Content-Transfer-Encoding: 7bit (identity)', + '', + 'kept body', + '', + ].join('\n'); + + const data = await parseEml(makeFile(eml, 'comments.eml')); + + expect(data.text).toBe('kept body\n'); + }); }); diff --git a/src/app/util/eml-parser.ts b/src/app/util/eml-parser.ts index 3761f97417..450794d21a 100644 --- a/src/app/util/eml-parser.ts +++ b/src/app/util/eml-parser.ts @@ -1,13 +1,186 @@ -import { type Email } from 'postal-mime'; +export interface ParsedEmlAddress { + address: string; + name?: string; +} -export const isFileEml = (file: File): boolean => { - return file.name.toLowerCase().endsWith('.eml') || file.type === 'message/rfc822'; +export interface ParsedEml { + from?: ParsedEmlAddress; + subject?: string; + text?: string; +} + +/** + * Minimal, dependency-free RFC 822 / MIME reader for the drop-an-`.eml` feature. + * + * Intentionally NOT a full MIME parser. A body is only returned as `text` when it + * is a single, unencoded, UTF-8/US-ASCII `text/plain` part; multipart, HTML, + * transfer-encoded (base64/quoted-printable) and non-UTF-8/ASCII bodies are + * omitted by design (`text: undefined`) — never decoded. The caller stores the + * body as an untrusted, inert note, so we favour safety and simplicity over + * completeness; do not add body decoding here without revisiting that threat + * model (untrusted-HTML XSS, main-thread cost, op-log/sync size). + * + * Headers ARE decoded (RFC 2047 encoded-words), because the sender/subject become + * the always-visible task title where raw `=?UTF-8?...?=` would be unreadable. + */ +export const parseEml = async (file: File): Promise => { + const content = (await file.text()).replace(/\r\n?/g, '\n'); + const separatorIndex = content.startsWith('\n') ? 0 : content.indexOf('\n\n'); + + if (separatorIndex < 0) { + throw new Error('Invalid EML: missing header/body separator'); + } + + const headers = _parseHeaders(content.slice(0, separatorIndex)); + const bodyStart = separatorIndex === 0 ? 1 : separatorIndex + 2; + const body = content.slice(bodyStart); + + const { mediaType, charset } = _parseContentType(headers.get('content-type')); + // Take only the leading token so a value like `7bit (comment)` still matches. + const transferEncoding = headers + .get('content-transfer-encoding') + ?.trim() + .split(/[\s(;]/)[0] + .toLowerCase(); + const isPlainText = !mediaType || mediaType === 'text/plain'; + const isUnencoded = + !transferEncoding || transferEncoding === '7bit' || transferEncoding === '8bit'; + const isSupportedCharset = + charset === undefined || charset === 'us-ascii' || charset === 'utf-8'; + + return { + from: _parseAddress(headers.get('from')), + subject: _decodeEncodedWords(headers.get('subject') ?? '').trim() || undefined, + text: isPlainText && isUnencoded && isSupportedCharset ? body : undefined, + }; }; -export const parseEml = async (file: File): Promise => { - const { default: PostalMime } = await import('postal-mime'); - // Hand the File (a Blob) straight to postal-mime so it applies the message's - // own charset and transfer-encoding. file.text() would force UTF-8 and mangle - // non-UTF-8 emails. - return PostalMime.parse(file); +const _parseHeaders = (headerBlock: string): Map => { + const headers = new Map(); + const unfoldedHeaders = headerBlock.replace(/\n[ \t]+/g, ' '); + + for (const line of unfoldedHeaders.split('\n')) { + const separatorIndex = line.indexOf(':'); + if (separatorIndex <= 0) { + continue; + } + + const name = line.slice(0, separatorIndex).trim().toLowerCase(); + if (!headers.has(name)) { + headers.set(name, line.slice(separatorIndex + 1).trim()); + } + } + + return headers; +}; + +const _parseAddress = (fromHeader?: string): ParsedEmlAddress | undefined => { + if (!fromHeader) { + return undefined; + } + + const angleStart = fromHeader.indexOf('<'); + const angleEnd = fromHeader.indexOf('>', angleStart + 1); + + if (angleStart >= 0 && angleEnd > angleStart) { + const address = fromHeader.slice(angleStart + 1, angleEnd).trim(); + const rawName = fromHeader.slice(0, angleStart).trim(); + const name = _decodeEncodedWords(rawName.replace(/^"|"$/g, '')).trim(); + + return address ? { address, name: name || undefined } : undefined; + } + + const address = fromHeader.split(',', 1)[0].trim(); + return address ? { address } : undefined; +}; + +const _parseContentType = (value?: string): { mediaType?: string; charset?: string } => { + if (!value) { + return {}; + } + + // Split parameters on ';', but not inside a double-quoted value, so a `charset=` + // embedded in another quoted parameter (e.g. a filename) can't be mistaken for + // the real charset. + const parts: string[] = []; + let current = ''; + let inQuotes = false; + for (const char of value) { + if (char === '"') { + inQuotes = !inQuotes; + } else if (char === ';' && !inQuotes) { + parts.push(current); + current = ''; + continue; + } + current += char; + } + parts.push(current); + + // The media type is the first token; drop any trailing RFC 822 comment/whitespace. + const mediaType = parts[0].trim().split(/[\s(]/)[0].toLowerCase() || undefined; + + let charset: string | undefined; + for (const part of parts.slice(1)) { + const eq = part.indexOf('='); + if (eq < 0 || part.slice(0, eq).trim().toLowerCase() !== 'charset') { + continue; + } + charset = part + .slice(eq + 1) + .trim() + .replace(/^"|"$/g, '') + .toLowerCase(); + } + + return { mediaType, charset }; +}; + +// Decode RFC 2047 encoded-words (`=?charset?B|Q?text?=`) for the UTF-8/US-ASCII +// case so international subjects/names are readable. Unsupported charsets and +// malformed words are left verbatim rather than throwing. +const _decodeEncodedWords = (value: string): string => + // Whitespace separating two adjacent encoded-words is not significant (RFC 2047). + value + .replace(/(=\?[^?]*\?[BbQq]\?[^?]*\?=)\s+(?==\?[^?]*\?[BbQq]\?)/g, '$1') + .replace( + /=\?([^?]+)\?([BbQq])\?([^?]*)\?=/g, + (match, charset: string, encoding: string, text: string) => { + const cs = charset.toLowerCase(); + if (cs !== 'utf-8' && cs !== 'us-ascii' && cs !== 'ascii') { + return match; + } + try { + const bytes = + encoding.toUpperCase() === 'B' ? _base64ToBytes(text) : _qToBytes(text); + return new TextDecoder('utf-8', { fatal: false }).decode(bytes); + } catch { + return match; + } + }, + ); + +const _base64ToBytes = (input: string): Uint8Array => { + const binary = atob(input.replace(/\s+/g, '')); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +}; + +const _qToBytes = (input: string): Uint8Array => { + const bytes: number[] = []; + for (let i = 0; i < input.length; i++) { + const char = input[i]; + if (char === '_') { + bytes.push(0x20); + } else if (char === '=' && /^[0-9A-Fa-f]{2}$/.test(input.slice(i + 1, i + 3))) { + bytes.push(parseInt(input.slice(i + 1, i + 3), 16)); + i += 2; + } else { + bytes.push(char.charCodeAt(0) & 0xff); + } + } + return new Uint8Array(bytes); }; diff --git a/src/app/util/is-file-eml.spec.ts b/src/app/util/is-file-eml.spec.ts new file mode 100644 index 0000000000..173135e19e --- /dev/null +++ b/src/app/util/is-file-eml.spec.ts @@ -0,0 +1,22 @@ +import { isFileEml } from './is-file-eml'; + +const makeFile = (name: string, type = ''): File => new File([], name, { type }); + +describe('isFileEml', () => { + it('should return true if file ends with .eml', () => { + expect(isFileEml(makeFile('mail.eml'))).toBeTrue(); + }); + + it('should be case-insensitive about the extension', () => { + expect(isFileEml(makeFile('MAIL.EML'))).toBeTrue(); + }); + + it('should return true if file type is message/rfc822', () => { + expect(isFileEml(makeFile('mail', 'message/rfc822'))).toBeTrue(); + }); + + it('should return false if file ending is not .eml and file type is not message/rfc822', () => { + expect(isFileEml(makeFile('doc.pdf', 'application/pdf'))).toBeFalse(); + expect(isFileEml(makeFile('notes.txt', 'text/plain'))).toBeFalse(); + }); +}); diff --git a/src/app/util/is-file-eml.ts b/src/app/util/is-file-eml.ts new file mode 100644 index 0000000000..ae384bd167 --- /dev/null +++ b/src/app/util/is-file-eml.ts @@ -0,0 +1,3 @@ +export const isFileEml = (file: File): boolean => { + return file.name.toLowerCase().endsWith('.eml') || file.type === 'message/rfc822'; +}; From 2bc3641cfa5904f23c73e802f05f048bd1937b93 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 10 Jul 2026 17:23:35 +0200 Subject: [PATCH 11/25] 18.14.0 --- android/app/build.gradle | 4 +- .../android/en-US/changelogs/1814009000.txt | 7 +++ build/release-notes.md | 47 ++++++++++++++++++- package-lock.json | 4 +- package.json | 2 +- src/environments/versions.ts | 2 +- 6 files changed, 58 insertions(+), 8 deletions(-) create mode 100644 android/fastlane/metadata/android/en-US/changelogs/1814009000.txt diff --git a/android/app/build.gradle b/android/app/build.gradle index e5126e2f94..086cd571b8 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -20,8 +20,8 @@ android { minSdkVersion 24 targetSdkVersion 36 compileSdk 36 - versionCode 18_13_01_9000 - versionName "18.13.1" + versionCode 18_14_00_9000 + versionName "18.14.0" testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" manifestPlaceholders = [ hostName : "app.super-productivity.com", diff --git a/android/fastlane/metadata/android/en-US/changelogs/1814009000.txt b/android/fastlane/metadata/android/en-US/changelogs/1814009000.txt new file mode 100644 index 0000000000..57fc2426c2 --- /dev/null +++ b/android/fastlane/metadata/android/en-US/changelogs/1814009000.txt @@ -0,0 +1,7 @@ +- New Android home-screen widget for today’s tasks +- Import Todoist data from the Import/Export launcher +- Create tasks by dropping links or EML files into the app +- Redesigned add-task bar with improved notes, toggles, and accessibility +- New completed-task sorting and file-tree nesting options +- More reliable encrypted file sync and fewer false conflict dialogs +- Improved recurring tasks, subtasks, project duplication, calendars, and mobile layouts \ No newline at end of file diff --git a/build/release-notes.md b/build/release-notes.md index 84e78a3504..b84bd2cf72 100644 --- a/build/release-notes.md +++ b/build/release-notes.md @@ -1,5 +1,48 @@ For all current downloads, package links, and platform-specific notes: [check the wiki](https://github.com/super-productivity/super-productivity/wiki/2.01-Downloads-and-Install). -### Fixes +## Super Productivity 18.14.0 -- Fixed dangling tag references during sync archiving by scanning all tags (#8710). +### Highlights + +- Added a Todoist import plugin to the Import/Export launcher. +- Create tasks by dropping links or EML files onto the app, with hardened EML importing. +- Added an Android home-screen widget for today’s tasks. +- Redesigned the add-task bar with improved toggles, notes, layout, and accessibility. +- Added file-tree actions for creating subfolders and items within folders. +- Added optional sorting of completed tasks by completion date. + +### Tasks and planning + +- New everyday recurring tasks now skip overdue occurrences by default. +- Fixed selecting day-of-month recurrence. +- Shift+T now schedules overdue tasks for today more reliably. +- Improved subtask creation on touch devices and during IME composition. +- Collapsed subtask state now persists across restarts. +- Project sections are now retained when duplicating a project. +- Fixed navigation from search for tasks without a project or tag. +- Unified Due, Deadline, Planned, and Scheduled labels. + +### Sync and data safety + +- Added an opt-in split-file sync format for delta-based syncing. +- Avoids full downloads when the remote revision has not changed. +- Improved atomic file writes, backup recovery, and encrypted file-sync safety. +- Reduced false conflict dialogs and corrected displayed conflict-change counts. +- File-based providers can now offer end-to-end encryption before the first upload. +- Improved reporting when an encryption key is missing. + +### Accessibility, integrations, and mobile + +- Improved accessible names, keyboard controls, and task focus behavior. +- Agenda plugin events now appear without navigating away and back. +- Added support for Outlook and other allowed app deep links in notes. +- Fixed Android bottom-navigation insets and stale focus-timer completions. +- Improved handling of third-party keyboard heights on iOS. +- The Eisenhower “Not Completed” filter now persists across restarts. +- Fixed freezes in issue-provider calendar configuration. + +### Performance and polish + +- Reduced unnecessary task-list, planner, date-formatting, and work-context updates. +- Fixed sidebar icon movement, add-task scrollbar behavior, and small-screen reminder labels. +- The active project or tag icon is now shown in the header. diff --git a/package-lock.json b/package-lock.json index 62455fd9ba..06b022252b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "superProductivity", - "version": "18.13.1", + "version": "18.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "superProductivity", - "version": "18.13.1", + "version": "18.14.0", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index c52ee87f70..2ade6e84c4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "superProductivity", - "version": "18.13.1", + "version": "18.14.0", "description": "ToDo list and Time Tracking", "keywords": [ "ToDo", diff --git a/src/environments/versions.ts b/src/environments/versions.ts index f6b5174bac..5dce6c5703 100644 --- a/src/environments/versions.ts +++ b/src/environments/versions.ts @@ -1,6 +1,6 @@ // this file is automatically generated by git.version.ts script export const versions = { - version: '18.13.1', + version: '18.14.0', revision: 'NO_REV', branch: 'NO_BRANCH', }; From 0aa2941947a266c3fd89b531aacda2aa2d947759 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 10 Jul 2026 17:31:24 +0200 Subject: [PATCH 12/25] docs(sync): note local-file rev-check/write is non-atomic (#8898) (#8902) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the LocalFile check-then-write TOCTOU race as an accepted limitation and correct stale references in the reliability doc: - §5 documents the non-atomic rev-check + write as an accepted limitation, honestly scoping each mitigation: the upload lock only serializes a single client's own uploads (not across machines); the mismatch-fallback catches only a concurrent write visible at check time (never force-overwriting), so a write landing inside the check→write window can still be lost; .bak recovery is best-effort and only covers a corrupt/interrupted primary, not a valid concurrent overwrite or a fully-missing one. - Distinguishes torn writes (prevented on Electron via temp-file + renameSync; still in-place on Android SAF) from the CAS race (not closed by atomic rename; needs OS-level CAS, left accepted). - §1 corrected: _uploadWithRetry()/:474/"retries once" → current _uploadWithMismatchFallback with _MAX_UPLOAD_RETRIES=2 (3 attempts), and clarify that genuine concurrency throws immediately rather than exhausting retries. --- .../multi-client-file-sync-reliability.md | 50 +++++++++++++++++-- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/docs/long-term-plans/multi-client-file-sync-reliability.md b/docs/long-term-plans/multi-client-file-sync-reliability.md index 3a1d0a91cc..172ab3a732 100644 --- a/docs/long-term-plans/multi-client-file-sync-reliability.md +++ b/docs/long-term-plans/multi-client-file-sync-reliability.md @@ -6,9 +6,9 @@ The single-file approach (`sync-data.json`) has these specific weaknesses when multiple clients sync simultaneously: -### 1. Single retry on upload conflict +### 1. Bounded retries on upload conflict -`_uploadWithRetry()` (`file-based-sync-adapter.service.ts:474`) retries **exactly once** on rev mismatch. With 3+ clients syncing at similar intervals, the retry can also fail — the second upload attempt has no fallback. +`_uploadWithMismatchFallback()` (`file-based-sync-adapter.service.ts`) makes up to `1 + _MAX_UPLOAD_RETRIES` conditional attempts (`_MAX_UPLOAD_RETRIES` is currently `2`, so 3 total) and never force-overwrites. On a rev mismatch it re-downloads: if the remote rev actually changed it treats that as a genuine concurrent write and throws a retryable error **immediately** (the extra attempts exist only for the transient case where the re-downloaded rev is unchanged). The next sync cycle then downloads the concurrent ops and rebuilds a consistent snapshot. This handles a concurrent write that is _visible at check time_; it does **not** close the check-then-write race described in §5. ### 2. Wide race window @@ -22,9 +22,51 @@ Every upload includes the **complete application state** (line 452: `getStateSna WebDAV uses `lastmod` (seconds resolution) as the revision. Two uploads within the same second can't be distinguished. The `syncVersion` counter inside the file compensates, but only if the file is actually re-downloaded between attempts. -### 5. No atomic CAS for LocalFile +### 5. No atomic CAS for LocalFile (accepted limitation — #8898) -For local file sync (Electron/Android), there's no server-side compare-and-swap. The rev is an MD5 hash computed client-side, but the read-modify-write is not atomic. +For local file sync there is no server-side compare-and-swap. `uploadFile()` +(`local-file-sync-base.ts`) does the rev check (`downloadFile` + hash compare) +and the `writeFile` as two separate, non-atomic steps, so a concurrent writer +that lands **between** check and write is not detected and can be overwritten (a +classic TOCTOU race). This is an **accepted limitation**, LOW severity in +practice because several layers narrow the window or soften the outcome: + +- Within one client, concurrent uploads share an upload lock (`LockService`, + `LOCK_NAMES.UPLOAD` — Web Locks cross-tab, in-process mutex fallback on + Electron/Android), so a client's own upload cycles don't race on the file. This + does **not** extend across machines. (Downloads use a separate lock; only + uploads write the file.) +- Cross-machine contention needs multiple writers on the same file — an external + folder-sync tool (Syncthing/Dropbox) or a directly shared/network-mounted sync + folder. An OS-level lock wouldn't help across machines anyway. +- A concurrent write that is _visible at check time_ is caught, not clobbered: + `_uploadWithMismatchFallback` never force-overwrites; on a rev mismatch it + re-downloads and throws retryably, and the next cycle re-applies the concurrent + ops. Only a write landing inside the check→write window escapes this. +- Backup-before-overwrite (`.bak`, #8786, best-effort): the current remote content + is copied to a `.bak` before overwrite, letting the next download recover a + **corrupt/interrupted** primary. It does **not** recover a valid concurrent + overwrite, nor a primary that went fully missing (e.g. an Android + delete-then-crash), and the `.bak` write is non-fatal if it fails. + +So the residual risk is narrow but real: a writer whose write falls inside another +client's check→write window can have its update lost — recoverable only if that +client's local op-log still holds the ops and re-uploads them on a later cycle. +Two distinct problems live here; keep them separate: + +- **Torn writes** (crash mid-write → partial/corrupt file) are already prevented + on **Electron/desktop**: `FILE_SYNC_SAVE` (`electron/local-file-sync.ts`) writes + to a temp file (`flag: 'wx'`) then `renameSync` (atomic on ext4/APFS/NTFS), with + temp cleanup on failure. **Android SAF still writes in place** + (`SafBridgePlugin.writeFile` → `openOutputStream`), so a torn write is possible + there — only partly mitigated by the best-effort `.bak` recovery above (and not + at all if the primary goes missing rather than corrupt). A native + temp-DocumentFile + rename would close it, but it's low value (mobile is + effectively single-writer). +- **The check-then-write CAS race itself** is NOT closed by atomic rename — rename + only makes the write atomic, not the read-compare-write sequence. Portably + closing it needs OS-level CAS (`O_EXCL` / advisory locks) that isn't uniformly + available across the LocalFile backends. Left as accepted. ## How Bad Is It in Practice? From e972de039e7034678b36e8aa53d836813fc66580 Mon Sep 17 00:00:00 2001 From: "J. Loops" <66142072+jloops412@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:10:01 -0400 Subject: [PATCH 13/25] feat: add Home Assistant Bridge to community plugins (#8891) * feat: add Home Assistant Bridge to community plugins * feat: add Home Assistant Bridge to community plugins --- src/assets/community-plugins.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/assets/community-plugins.json b/src/assets/community-plugins.json index 673ba47ec5..9416ae41d9 100644 --- a/src/assets/community-plugins.json +++ b/src/assets/community-plugins.json @@ -118,5 +118,13 @@ "author": "coissay", "authorUrl": "https://github.com/coissay", "stars": 0 + }, + { + "name": "Home Assistant Bridge", + "shortDescription": "Bidirectional bridge between Super Productivity and Home Assistant. Rules-based automation engine: control lights, scenes, media, and notifications based on your tasks. Auto-focus mode, break reminders, daily summaries, and live HA sensor display.", + "url": "https://github.com/jloops412/ha-super-productivity", + "author": "jloops412", + "authorUrl": "https://github.com/jloops412", + "stars": 0 } ] From c6480d1caefc387baa8b4e1ec72faa27454f2b51 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 10 Jul 2026 19:06:27 +0200 Subject: [PATCH 14/25] fix(sync): harden SuperSync E2EE against metadata tampering (GHSA-8pxh-mgc7-gp3g) (#8904) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sync): defense-in-depth vs entityId retarget on encrypted ops SuperSync E2EE (AES-256-GCM) covers only op.payload; metadata fields (entityId, opType, actionType, vectorClock, isPayloadEncrypted, ...) travel as plaintext and are not bound by the auth tag. A malicious/ compromised sync server or MITM could retag an encrypted LWW-update op with a different entityId, redirecting the authenticated changes onto an attacker-chosen entity — convertOpToAction() previously trusted the tampered entityId over the authenticated payload.id (coercing even a missing payload.id) and only warned. At the decrypt boundary (where encryption origin is known) verify that an in-scope LWW op's authenticated payload carries a string id equal to op.entityId; otherwise fail closed via a new OperationIntegrityError, distinct from DecryptError so it does not trigger the enter-password dialog. The gate mirrors convertOpToAction's predicate (alias resolution + singleton exclusion) so the two boundaries cannot drift. Scoped defense-in-depth for GHSA-8pxh-mgc7-gp3g, NOT full integrity. Still open (durable AAD-envelope fix): plaintext-injection downgrade via isPayloadEncrypted=false (needs a download-side mandatory-encryption guard), opType promotion, entityType swap, vectorClock replay. Correct the overstated integrity claim in the encryption architecture doc. * fix(sync): reject plaintext ops when SuperSync encryption is mandatory The isPayloadEncrypted flag is unauthenticated plaintext metadata, so a compromised SuperSync server or MITM could set it to false and inject a fully attacker-authored plaintext op — it would skip decryption AND the payload/metadata integrity check and be applied verbatim (arbitrary op forgery). This is a strictly more powerful bypass than the ciphertext entityId retarget closed previously. assertOpsEncryptedWhenExpected rejects any inbound plaintext op (download + piggyback paths) when encryption is enabled. It gates on config INTENT (isEncryptionMandatory && isEncryptionEnabled()), not key presence, so it also fails closed in the dropped-credential state (a !!encryptKey gate would fail open there). Safe with no legacy-data loss: enabling encryption deletes the server copy and re-uploads everything encrypted, so no legitimate plaintext op remains; a never-encrypted account (isEncryptionEnabled()===false) still accepts plaintext. The SuperSync op-level twin of the file-based GHSA-vrc7 download guard and the GHSA-9544 upload guard. Also give OperationIntegrityError a dedicated sync-wrapper branch: fail closed with a calm translated message instead of the raw GHSA/technical string. Follows up the review of GHSA-8pxh-mgc7-gp3g. --- .../supersync-encryption-architecture.md | 57 +++++++++-- .../imex/sync/sync-wrapper.service.spec.ts | 25 +++++ src/app/imex/sync/sync-wrapper.service.ts | 17 ++++ src/app/op-log/core/errors/sync-errors.ts | 16 +++ src/app/op-log/sync-exports.ts | 1 + .../assert-ops-encryption-expected.spec.ts | 48 +++++++++ .../sync/assert-ops-encryption-expected.ts | 60 ++++++++++++ .../sync/operation-encryption.service.spec.ts | 57 ++++++++++- .../sync/operation-encryption.service.ts | 33 ++++--- .../operation-log-download.service.spec.ts | 79 +++++++++++++++ .../sync/operation-log-download.service.ts | 19 ++++ .../sync/operation-log-upload.service.ts | 10 ++ .../verify-decrypted-op-integrity.spec.ts | 97 +++++++++++++++++++ .../sync/verify-decrypted-op-integrity.ts | 97 +++++++++++++++++++ src/app/t.const.ts | 1 + src/assets/i18n/en.json | 1 + 16 files changed, 598 insertions(+), 20 deletions(-) create mode 100644 src/app/op-log/sync/assert-ops-encryption-expected.spec.ts create mode 100644 src/app/op-log/sync/assert-ops-encryption-expected.ts create mode 100644 src/app/op-log/sync/verify-decrypted-op-integrity.spec.ts create mode 100644 src/app/op-log/sync/verify-decrypted-op-integrity.ts diff --git a/docs/sync-and-op-log/supersync-encryption-architecture.md b/docs/sync-and-op-log/supersync-encryption-architecture.md index 283c7e54cd..c25aff6d58 100644 --- a/docs/sync-and-op-log/supersync-encryption-architecture.md +++ b/docs/sync-and-op-log/supersync-encryption-architecture.md @@ -227,13 +227,56 @@ privateCfg: { ## Security Properties -| Property | Guarantee | -| ------------------- | ------------------------------------- | -| **Confidentiality** | Server cannot read operation payloads | -| **Integrity** | GCM auth tag detects tampering | -| **Key Security** | Argon2id makes brute-force expensive | -| **Forward Secrecy** | Each operation uses random IV | -| **Wrong Password** | Decryption fails, operation rejected | +| Property | Guarantee | +| ------------------- | ---------------------------------------------- | +| **Confidentiality** | Server cannot read operation payloads | +| **Integrity** | GCM auth tag detects tampering of the _payload_ | +| **Key Security** | Argon2id makes brute-force expensive | +| **Forward Secrecy** | Each operation uses random IV | +| **Wrong Password** | Decryption fails, operation rejected | + +> **Integrity scope (important).** Only `op.payload` is encrypted and covered by +> the AES-GCM authentication tag. Every other operation field — `actionType`, +> `opType`, `entityType`, `entityId`, `entityIds`, `vectorClock`, `timestamp`, +> `schemaVersion`, `syncImportReason`, **and the `isPayloadEncrypted` flag +> itself** — travels as **plaintext** and is **not** bound as Additional +> Authenticated Data (AAD), so a malicious/compromised sync server or a TLS MITM +> can tamper with it. As **defense-in-depth**, the client fails closed on two +> tamper vectors: +> +> - **Plaintext-injection downgrade:** a forged op with `isPayloadEncrypted=false` +> would skip decryption *and* the payload check and be applied as-is — arbitrary +> op forgery on an encryption-mandatory client. `assertOpsEncryptedWhenExpected` +> rejects any inbound plaintext op (download + piggyback) when encryption is +> **enabled in config** (`isEncryptionMandatory && isEncryptionEnabled()` — +> config intent, not key presence, so it also fails closed in the +> dropped-credential state). Safe because enabling encryption deletes + +> re-uploads all data encrypted, so no legitimate plaintext op remains — this +> rests on the server contract that `deleteAllData()` removes every downloadable +> plaintext op. This is the SuperSync op-level twin of the file-based GHSA-vrc7 +> download guard and the GHSA-9544 *upload* guard. +> - **LWW `entityId` retarget:** the client rejects an *encrypted* LWW-update op +> whose authenticated `payload.id` does not equal `op.entityId` +> (`verify-decrypted-op-integrity.ts`). +> +> This is **not** full integrity. Still open pending the durable fix: +> +> - `opType` promotion to a full-state (`loadAllData`) op. +> - Within-LWW `entityType`/`actionType` swap (ids left equal, so it passes). +> - `vectorClock`/`timestamp` reorder/replay. +> - The restore-to-point path (`getStateAtSeq` → `importCompleteBackup`) applies +> server-reconstructed state without this guard; it is server-authored by +> nature and the server blocks it for encrypted accounts, but E2EE cannot +> authenticate it. +> +> Known limitation: a peer running an app version that predates the GHSA-9544 +> *upload* guard can still push plaintext ops; a keyed client then fails closed +> here with the tamper message. Recovery is to update the old peer. +> +> Full protection — binding the metadata (and the encryption flag) as GCM AAD +> behind an envelope-version migration, with a monotonic "encryption floor" to +> block downgrades — is tracked in **GHSA-8pxh-mgc7-gp3g**. Do not treat +> plaintext metadata as trusted at client decision points. ## Initial Setup — Password Dialog Selection diff --git a/src/app/imex/sync/sync-wrapper.service.spec.ts b/src/app/imex/sync/sync-wrapper.service.spec.ts index 01562c659f..ecf4d1f05a 100644 --- a/src/app/imex/sync/sync-wrapper.service.spec.ts +++ b/src/app/imex/sync/sync-wrapper.service.spec.ts @@ -23,6 +23,7 @@ import { AuthFailSPError, MissingCredentialsSPError, NetworkUnavailableSPError, + OperationIntegrityError, PotentialCorsError, SyncProviderId, SyncStatus, @@ -1036,6 +1037,30 @@ describe('SyncWrapperService', () => { ); }); + it('should handle OperationIntegrityError with a calm ERROR snack, not the password dialog', async () => { + // GHSA-8pxh-mgc7-gp3g: decryption succeeded but metadata was tampered (or a + // plaintext op arrived while encryption is mandatory). Must fail closed with + // a non-jargon message and NOT route to the enter-password recovery dialog. + mockSyncService.downloadRemoteOps.and.returnValue( + Promise.reject(new OperationIntegrityError('tampered. GHSA-8pxh-mgc7-gp3g')), + ); + + const result = await service.sync(true); + + expect(result).toBe('HANDLED_ERROR'); + expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR'); + expect(mockSnackService.open).toHaveBeenCalledWith( + jasmine.objectContaining({ + msg: T.F.SYNC.S.INTEGRITY_TAMPER_DETECTED, + type: 'ERROR', + }), + ); + // The raw GHSA/technical string must never reach the user. + expect(mockSnackService.open).not.toHaveBeenCalledWith( + jasmine.objectContaining({ msg: jasmine.stringMatching(/GHSA-/) }), + ); + }); + it('should handle NetworkUnavailableSPError with WARNING snackbar when user-triggered', async () => { mockSyncService.downloadRemoteOps.and.returnValue( Promise.reject(new NetworkUnavailableSPError()), diff --git a/src/app/imex/sync/sync-wrapper.service.ts b/src/app/imex/sync/sync-wrapper.service.ts index d98d8e213f..5ab74fc45e 100644 --- a/src/app/imex/sync/sync-wrapper.service.ts +++ b/src/app/imex/sync/sync-wrapper.service.ts @@ -36,6 +36,7 @@ import { ConflictReason, DecryptError, DecryptNoPasswordError, + OperationIntegrityError, EncryptNoPasswordError, MissingCredentialsSPError, NetworkUnavailableSPError, @@ -911,6 +912,22 @@ export class SyncWrapperService { ); this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED'); return 'HANDLED_ERROR'; + } else if (error instanceof OperationIntegrityError) { + // A decrypted op's unauthenticated metadata contradicted its authenticated + // payload, or a plaintext op arrived while encryption is mandatory + // (GHSA-8pxh-mgc7-gp3g). Fail closed with a calm, translated message so the + // generic handler below cannot surface the raw technical/GHSA string to the + // user. The technical details are already in the log. + SyncLog.err('SyncWrapperService: operation integrity check failed', { + name: error.name, + }); + this._providerManager.setSyncStatus('ERROR'); + this._snackService.open({ + msg: T.F.SYNC.S.INTEGRITY_TAMPER_DETECTED, + type: 'ERROR', + config: { duration: 15000 }, + }); + return 'HANDLED_ERROR'; } else { this._providerManager.setSyncStatus('ERROR'); const errStr = getSyncErrorStr(error); diff --git a/src/app/op-log/core/errors/sync-errors.ts b/src/app/op-log/core/errors/sync-errors.ts index b7fa8d71a8..97f326ce5f 100644 --- a/src/app/op-log/core/errors/sync-errors.ts +++ b/src/app/op-log/core/errors/sync-errors.ts @@ -132,6 +132,22 @@ export class DecryptError extends AdditionalLogErrorBase { override name = 'DecryptError'; } +/** + * Thrown when a successfully-decrypted operation's UNAUTHENTICATED metadata is + * inconsistent with its AUTHENTICATED payload — the signature of sync-server + * (or MITM) tampering with the plaintext op fields that AES-GCM does not cover. + * GHSA-8pxh-mgc7-gp3g. + * + * Distinct from DecryptError on purpose: it must not carry the raw + * message to the user, and (being a sibling, not a subclass) it never matches + * the DecryptError branch. SyncWrapperService has a dedicated branch that fails + * closed (sync stops) and shows a calm, translated message instead of the raw + * technical/GHSA string. + */ +export class OperationIntegrityError extends AdditionalLogErrorBase { + override name = 'OperationIntegrityError'; +} + export class CompressError extends AdditionalLogErrorBase { override name = 'CompressError'; } diff --git a/src/app/op-log/sync-exports.ts b/src/app/op-log/sync-exports.ts index fc773d9106..14dc4fa0b2 100644 --- a/src/app/op-log/sync-exports.ts +++ b/src/app/op-log/sync-exports.ts @@ -34,6 +34,7 @@ export { ImpossibleError, DecryptError, DecryptNoPasswordError, + OperationIntegrityError, EncryptNoPasswordError, DataRepairNotPossibleError, BackupImportFailedError, diff --git a/src/app/op-log/sync/assert-ops-encryption-expected.spec.ts b/src/app/op-log/sync/assert-ops-encryption-expected.spec.ts new file mode 100644 index 0000000000..0f535dda10 --- /dev/null +++ b/src/app/op-log/sync/assert-ops-encryption-expected.spec.ts @@ -0,0 +1,48 @@ +import { assertOpsEncryptedWhenExpected } from './assert-ops-encryption-expected'; +import { SyncOperation } from '../sync-providers/provider.interface'; +import { OperationIntegrityError } from '../core/errors/sync-errors'; + +describe('assertOpsEncryptedWhenExpected', () => { + const op = (over: Partial): SyncOperation => ({ + id: 'op-1', + clientId: 'clientA', + actionType: '[TASK] LWW Update', + opType: 'UPDATE', + entityType: 'TASK', + entityId: 'task-1', + payload: 'ciphertext', + vectorClock: { clientA: 1 }, + timestamp: 1, + schemaVersion: 1, + isPayloadEncrypted: true, + ...over, + }); + + it('rejects a plaintext op when encryption is expected (fail closed)', () => { + const ops = [op({ id: 'a' }), op({ id: 'b', isPayloadEncrypted: false })]; + expect(() => assertOpsEncryptedWhenExpected(ops, true)).toThrowError( + OperationIntegrityError, + ); + }); + + it('rejects an op whose isPayloadEncrypted is undefined when encryption is expected', () => { + const ops = [op({ id: 'a', isPayloadEncrypted: undefined })]; + expect(() => assertOpsEncryptedWhenExpected(ops, true)).toThrowError( + OperationIntegrityError, + ); + }); + + it('accepts an all-encrypted batch when encryption is expected', () => { + const ops = [op({ id: 'a' }), op({ id: 'b' })]; + expect(() => assertOpsEncryptedWhenExpected(ops, true)).not.toThrow(); + }); + + it('does not enforce when encryption is not expected (plaintext allowed)', () => { + const ops = [op({ id: 'a', isPayloadEncrypted: false })]; + expect(() => assertOpsEncryptedWhenExpected(ops, false)).not.toThrow(); + }); + + it('accepts an empty batch', () => { + expect(() => assertOpsEncryptedWhenExpected([], true)).not.toThrow(); + }); +}); diff --git a/src/app/op-log/sync/assert-ops-encryption-expected.ts b/src/app/op-log/sync/assert-ops-encryption-expected.ts new file mode 100644 index 0000000000..14d202e8fc --- /dev/null +++ b/src/app/op-log/sync/assert-ops-encryption-expected.ts @@ -0,0 +1,60 @@ +import { SyncOperation } from '../sync-providers/provider.interface'; +import { OperationIntegrityError } from '../core/errors/sync-errors'; +import { SyncLog } from '../../core/log'; + +/** + * Fails closed when a mandatory-encryption op stream contains a PLAINTEXT op. + * + * The `isPayloadEncrypted` flag is itself unauthenticated plaintext metadata + * (GHSA-8pxh-mgc7-gp3g). A compromised SuperSync server or a MITM can set it to + * `false` and supply a fully attacker-authored plaintext op. Such an op skips + * decryption AND the payload/metadata integrity check + * (`assertDecryptedOpMetadataIntegrity`) entirely and is applied verbatim — + * arbitrary op forgery on an encryption-mandatory client, strictly more powerful + * than retagging a genuine ciphertext op. + * + * Why rejecting is safe (no legit plaintext to lose): SuperSync makes encryption + * mandatory, and enabling it re-uploads all data encrypted after deleting the + * server copy (`SuperSyncEncryptionToggleService.enableEncryption` → + * `deleteAndReuploadWithNewEncryption`). So once encryption is active, NO + * legitimate plaintext op remains on the server; any inbound plaintext op is + * stale or injected → reject the whole batch (fail closed). + * + * This is the SuperSync op-level mirror of the file-based download guard + * (GHSA-vrc7) and the `EncryptNoPasswordError` upload guard (GHSA-9544). Callers + * must scope `isEncryptionExpected` to the mandatory-encryption provider + * (`isEncryptionMandatory`) with encryption enabled in config + * (`isEncryptionEnabled()`, NOT key presence — the key can be transiently gone + * in the dropped-credential state while encryption is still mandatory). + * File-snapshot providers have different, non-mandatory semantics and their own + * download guard, and a never-encrypted SuperSync account + * (`isEncryptionEnabled() === false`) legitimately carries plaintext. + * + * @throws OperationIntegrityError if any op is not flagged encrypted. + */ +export const assertOpsEncryptedWhenExpected = ( + ops: readonly SyncOperation[], + isEncryptionExpected: boolean, +): void => { + if (!isEncryptionExpected) { + return; + } + const plaintextOp = ops.find((op) => op.isPayloadEncrypted !== true); + if (!plaintextOp) { + return; + } + + // Log ids only — never payload content (op log is exportable). Rule 9. + SyncLog.err( + '[assertOpsEncryptedWhenExpected] received a plaintext op while encryption is mandatory — rejecting (possible sync-server tampering/downgrade)', + { + opId: plaintextOp.id, + entityType: plaintextOp.entityType, + actionType: plaintextOp.actionType, + }, + ); + throw new OperationIntegrityError( + `Operation ${plaintextOp.id} is unencrypted but encryption is mandatory ` + + `(possible sync-server tampering/downgrade). GHSA-8pxh-mgc7-gp3g`, + ); +}; diff --git a/src/app/op-log/sync/operation-encryption.service.spec.ts b/src/app/op-log/sync/operation-encryption.service.spec.ts index cf59393364..3c67522c31 100644 --- a/src/app/op-log/sync/operation-encryption.service.spec.ts +++ b/src/app/op-log/sync/operation-encryption.service.spec.ts @@ -1,8 +1,9 @@ import { TestBed } from '@angular/core/testing'; import { OperationEncryptionService } from './operation-encryption.service'; import { SyncOperation } from '../sync-providers/provider.interface'; -import { DecryptError } from '../core/errors/sync-errors'; +import { DecryptError, OperationIntegrityError } from '../core/errors/sync-errors'; import { ActionType } from '../core/operation.types'; +import { toLwwUpdateActionType } from '../core/lww-update-action-types'; import { clearSessionKeyCache, setArgon2ParamsForTesting } from '@sp/sync-core'; describe('OperationEncryptionService', () => { @@ -227,6 +228,60 @@ describe('OperationEncryptionService', () => { }); }); + // GHSA-8pxh-mgc7-gp3g: E2EE covers only op.payload; op.entityId travels as + // plaintext. A tampered entityId on an encrypted LWW-update op must be + // rejected on decrypt (fail closed) rather than silently retargeting the + // authenticated changes onto the attacker-chosen entity. + describe('metadata integrity (GHSA-8pxh-mgc7-gp3g)', () => { + const LWW_TASK = toLwwUpdateActionType('TASK') as ActionType; + + const createLwwOp = (entityId: string): SyncOperation => ({ + ...createMockSyncOp({ id: entityId, changes: { title: 'legit change' } }), + actionType: LWW_TASK, + opType: 'UPDATE', + entityType: 'TASK', + entityId, + }); + + it('rejects a decrypted LWW op whose entityId was tampered (single)', async () => { + const encrypted = await service.encryptOperation( + createLwwOp('task-123'), + TEST_PASSWORD, + ); + // Simulate a malicious server retagging the plaintext entityId. + const tampered: SyncOperation = { ...encrypted, entityId: 'task-999' }; + + await expectAsync( + service.decryptOperation(tampered, TEST_PASSWORD), + ).toBeRejectedWithError(OperationIntegrityError); + }); + + it('rejects a decrypted LWW op whose entityId was tampered (batch)', async () => { + const [encrypted] = await service.encryptOperations( + [createLwwOp('task-123')], + TEST_PASSWORD, + ); + const tampered: SyncOperation = { ...encrypted, entityId: 'task-999' }; + + await expectAsync( + service.decryptOperations([tampered], TEST_PASSWORD), + ).toBeRejectedWithError(OperationIntegrityError); + }); + + it('accepts a decrypted LWW op with untampered entityId', async () => { + const encrypted = await service.encryptOperation( + createLwwOp('task-123'), + TEST_PASSWORD, + ); + const decrypted = await service.decryptOperation(encrypted, TEST_PASSWORD); + expect(decrypted.entityId).toBe('task-123'); + expect(decrypted.payload).toEqual({ + id: 'task-123', + changes: { title: 'legit change' }, + }); + }); + }); + describe('encryptPayload / decryptPayload', () => { it('should encrypt and decrypt an object payload', async () => { const payload = { foo: 'bar', count: 42 }; diff --git a/src/app/op-log/sync/operation-encryption.service.ts b/src/app/op-log/sync/operation-encryption.service.ts index d51b696b15..ba88b31d5c 100644 --- a/src/app/op-log/sync/operation-encryption.service.ts +++ b/src/app/op-log/sync/operation-encryption.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@angular/core'; import { decrypt, decryptBatch, encrypt, encryptBatch } from '@sp/sync-core'; import { SyncOperation } from '../sync-providers/provider.interface'; import { DecryptError } from '../core/errors/sync-errors'; +import { assertDecryptedOpMetadataIntegrity } from './verify-decrypted-op-integrity'; /** * Handles E2E encryption/decryption of operation payloads for SuperSync. @@ -52,16 +53,20 @@ export class OperationEncryptionService { } catch (e) { throw new DecryptError('Failed to decrypt operation payload', e); } + let parsedPayload: unknown; try { - const parsedPayload = JSON.parse(decryptedStr); - return { - ...op, - payload: parsedPayload, - isPayloadEncrypted: false, - }; + parsedPayload = JSON.parse(decryptedStr); } catch (e) { throw new DecryptError('Failed to parse decrypted operation payload as JSON', e); } + // Verify the untrusted metadata against the now-authenticated payload before + // trusting it downstream (GHSA-8pxh-mgc7-gp3g). Throws on tampering. + assertDecryptedOpMetadataIntegrity(op, parsedPayload); + return { + ...op, + payload: parsedPayload, + isPayloadEncrypted: false, + }; } /** @@ -128,16 +133,20 @@ export class OperationEncryptionService { for (let i = 0; i < encryptedOps.length; i++) { const { index, op } = encryptedOps[i]; + let parsedPayload: unknown; try { - const parsedPayload = JSON.parse(decryptedStrings[i]); - results[index] = { - ...op, - payload: parsedPayload, - isPayloadEncrypted: false, - }; + parsedPayload = JSON.parse(decryptedStrings[i]); } catch (e) { throw new DecryptError('Failed to parse decrypted operation payload', e); } + // Verify the untrusted metadata against the now-authenticated payload + // before trusting it downstream (GHSA-8pxh-mgc7-gp3g). Throws on tampering. + assertDecryptedOpMetadataIntegrity(op, parsedPayload); + results[index] = { + ...op, + payload: parsedPayload, + isPayloadEncrypted: false, + }; } return results; 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 1572a1d60e..219f7a2392 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 @@ -15,6 +15,7 @@ import { T } from '../../t.const'; import { OperationEncryptionService } from './operation-encryption.service'; import { SuperSyncStatusService } from './super-sync-status.service'; import { CLIENT_ID_PROVIDER } from '../util/client-id.provider'; +import { OperationIntegrityError } from '../core/errors/sync-errors'; describe('OperationLogDownloadService', () => { let service: OperationLogDownloadService; @@ -196,6 +197,84 @@ describe('OperationLogDownloadService', () => { }); }); + // GHSA-8pxh-mgc7-gp3g: reject an inbound plaintext op when SuperSync + // encryption is enabled (isPayloadEncrypted is unauthenticated metadata a + // malicious server can forge to inject an attacker-authored plaintext op). + describe('plaintext-injection guard (GHSA-8pxh-mgc7-gp3g)', () => { + const plaintextOpResponse = (): ReturnType< + (typeof mockApiProvider)['downloadOps'] + > => + Promise.resolve({ + ops: [ + { + serverSeq: 1, + receivedAt: Date.now(), + op: { + id: 'op-forged', + clientId: 'attacker', + actionType: '[Task] Add' as ActionType, + opType: OpType.Create, + entityType: 'TASK', + payload: { title: 'forged' }, + isPayloadEncrypted: false, + vectorClock: {}, + timestamp: Date.now(), + schemaVersion: 1, + }, + }, + ], + hasMore: false, + latestSeq: 1, + }) as ReturnType<(typeof mockApiProvider)['downloadOps']>; + + beforeEach(() => { + (mockApiProvider as { isEncryptionMandatory?: boolean }).isEncryptionMandatory = + true; + mockApiProvider.getEncryptKey = jasmine + .createSpy('getEncryptKey') + .and.returnValue(Promise.resolve('the-key')); + }); + + it('rejects a plaintext op when encryption is enabled', async () => { + mockApiProvider.isEncryptionEnabled = jasmine + .createSpy('isEncryptionEnabled') + .and.returnValue(Promise.resolve(true)); + mockApiProvider.downloadOps.and.returnValue(plaintextOpResponse()); + + await expectAsync( + service.downloadRemoteOps(mockApiProvider), + ).toBeRejectedWithError(OperationIntegrityError); + }); + + it('fails closed even when the key is dropped (config intent, not key presence)', async () => { + // Dropped-credential state: encryption still enabled in config but + // getEncryptKey returns undefined. A `!!encryptKey` gate would fail OPEN + // here; gating on isEncryptionEnabled keeps it closed. + mockApiProvider.getEncryptKey = jasmine + .createSpy('getEncryptKey') + .and.returnValue(Promise.resolve(undefined)); + mockApiProvider.isEncryptionEnabled = jasmine + .createSpy('isEncryptionEnabled') + .and.returnValue(Promise.resolve(true)); + mockApiProvider.downloadOps.and.returnValue(plaintextOpResponse()); + + await expectAsync( + service.downloadRemoteOps(mockApiProvider), + ).toBeRejectedWithError(OperationIntegrityError); + }); + + it('allows plaintext when encryption is not enabled (legacy never-encrypted account)', async () => { + mockApiProvider.isEncryptionEnabled = jasmine + .createSpy('isEncryptionEnabled') + .and.returnValue(Promise.resolve(false)); + mockApiProvider.downloadOps.and.returnValue(plaintextOpResponse()); + + const result = await service.downloadRemoteOps(mockApiProvider); + expect(result.success).toBe(true); + expect(result.newOps.length).toBe(1); + }); + }); + it('should detect and warn about clock drift after retry', fakeAsync(() => { const driftMs = CLOCK_DRIFT_THRESHOLD_MS + 60000; // Threshold + 1 min const initialTime = Date.now(); diff --git a/src/app/op-log/sync/operation-log-download.service.ts b/src/app/op-log/sync/operation-log-download.service.ts index c551689a84..d3924b0041 100644 --- a/src/app/op-log/sync/operation-log-download.service.ts +++ b/src/app/op-log/sync/operation-log-download.service.ts @@ -24,6 +24,7 @@ import { } from '../core/operation-log.const'; import { OperationEncryptionService } from './operation-encryption.service'; import { DecryptNoPasswordError } from '../core/errors/sync-errors'; +import { assertOpsEncryptedWhenExpected } from './assert-ops-encryption-expected'; import { SuperSyncStatusService } from './super-sync-status.service'; import { DownloadResult } from '../core/types/sync-results.types'; import { CLIENT_ID_PROVIDER } from '../util/client-id.provider'; @@ -115,6 +116,18 @@ export class OperationLogDownloadService implements OnDestroy { ? await syncProvider.getEncryptKey() : undefined; + // Whether inbound plaintext ops must be rejected (GHSA-8pxh-mgc7-gp3g). Gate + // on config INTENT (isEncryptionEnabled), not key presence: in the + // dropped-credential state the key is transiently gone but encryption is + // still enabled, and a `!!encryptKey` gate would fail OPEN there and accept a + // forged plaintext batch. `isEncryptionMandatory` scopes this to SuperSync, + // where enabling encryption deletes + re-uploads all data encrypted, so no + // legitimate plaintext op remains. Config intent is stable across a + // gap-reset re-fetch, so compute once here. + const isEncryptionExpected = + !!syncProvider.isEncryptionMandatory && + !!(await syncProvider.isEncryptionEnabled?.()); + await this.lockService.request(LOCK_NAMES.DOWNLOAD, async () => { const lastServerSeq = forceFromSeq0 ? 0 : await syncProvider.getLastServerSeq(); const appliedOpIds = await this.opLogStore.getAppliedOpIds(); @@ -250,6 +263,12 @@ export class OperationLogDownloadService implements OnDestroy { .filter((serverOp) => !appliedOpIds.has(serverOp.op.id)) .map((serverOp) => serverOp.op); + // Fail closed on a plaintext op when SuperSync encryption is enabled: the + // server is all-encrypted once encryption is on (delete + reupload), so an + // inbound plaintext op is stale or attacker-injected and would otherwise + // skip decryption + the integrity check (GHSA-8pxh-mgc7-gp3g). + assertOpsEncryptedWhenExpected(syncOps, isEncryptionExpected); + // Decrypt encrypted operations if we have an encryption key const hasEncryptedOps = syncOps.some((op) => op.isPayloadEncrypted); if (hasEncryptedOps) { diff --git a/src/app/op-log/sync/operation-log-upload.service.ts b/src/app/op-log/sync/operation-log-upload.service.ts index a84d79d977..9a245e2644 100644 --- a/src/app/op-log/sync/operation-log-upload.service.ts +++ b/src/app/op-log/sync/operation-log-upload.service.ts @@ -35,6 +35,7 @@ import { DecryptNoPasswordError, EncryptNoPasswordError, } from '../core/errors/sync-errors'; +import { assertOpsEncryptedWhenExpected } from './assert-ops-encryption-expected'; import { stripLocalOnlySyncScheduleSettings, stripLocalOnlySyncSettingsFromAppData, @@ -355,6 +356,15 @@ export class OperationLogUploadService { ); let piggybackSyncOps = response.newOps.map((serverOp) => serverOp.op); + // Fail closed on a plaintext piggybacked op when encryption is mandatory + // (same reasoning as the download path, GHSA-8pxh-mgc7-gp3g). A keyless + // mandatory-encryption upload already returned early above, so reaching + // here implies a usable key — `isEncryptionEnabled` (=!!encryptKey) holds. + assertOpsEncryptedWhenExpected( + piggybackSyncOps, + !!syncProvider.isEncryptionMandatory && isEncryptionEnabled, + ); + // Track encryption state BEFORE decryption to detect server's actual state. // This is critical for detecting when another client disables encryption. sawAnyPiggybackOps = true; diff --git a/src/app/op-log/sync/verify-decrypted-op-integrity.spec.ts b/src/app/op-log/sync/verify-decrypted-op-integrity.spec.ts new file mode 100644 index 0000000000..069f3cbb0e --- /dev/null +++ b/src/app/op-log/sync/verify-decrypted-op-integrity.spec.ts @@ -0,0 +1,97 @@ +import { assertDecryptedOpMetadataIntegrity } from './verify-decrypted-op-integrity'; +import { SyncOperation } from '../sync-providers/provider.interface'; +import { OperationIntegrityError } from '../core/errors/sync-errors'; +import { toLwwUpdateActionType } from '../core/lww-update-action-types'; +import { SINGLETON_ENTITY_ID } from '../core/entity-registry'; + +describe('assertDecryptedOpMetadataIntegrity', () => { + const LWW_TASK = toLwwUpdateActionType('TASK'); + + const createOp = (over: Partial): SyncOperation => ({ + id: 'op-1', + clientId: 'clientA', + actionType: LWW_TASK, + opType: 'UPDATE', + entityType: 'TASK', + entityId: 'task-123', + payload: null, + vectorClock: { clientA: 1 }, + timestamp: 1, + schemaVersion: 1, + ...over, + }); + + describe('rejects tampering (fail closed)', () => { + it('throws when a LWW-update entityId does not match the authenticated payload.id', () => { + // Attacker retagged op.entityId from the real target (task-123) to task-999. + const op = createOp({ entityId: 'task-999' }); + const authenticatedPayload = { id: 'task-123', changes: { title: 'x' } }; + + expect(() => + assertDecryptedOpMetadataIntegrity(op, authenticatedPayload), + ).toThrowError(OperationIntegrityError); + }); + + it('detects tampering for a multi-entity-wrapped LWW payload (actionPayload.id)', () => { + const op = createOp({ entityId: 'task-999' }); + // Multi-entity payload shape: the real id lives under actionPayload. + const authenticatedPayload = { + actionPayload: { id: 'task-123', changes: { title: 'x' } }, + entityChanges: [{ entityType: 'TASK', entityId: 'task-123', opType: 'UPDATE' }], + }; + + expect(() => + assertDecryptedOpMetadataIntegrity(op, authenticatedPayload), + ).toThrowError(OperationIntegrityError); + }); + + it('fails closed when an in-scope LWW payload carries no string id', () => { + // convertOpToAction would coerce id = op.entityId (the tampered value) + // when payload.id is absent, so a missing id must be rejected too, not + // skipped. (Codex/correctness finding on the interim fix.) + const op = createOp({ entityId: 'task-999' }); + expect(() => + assertDecryptedOpMetadataIntegrity(op, { changes: { title: 'x' } }), + ).toThrowError(OperationIntegrityError); + }); + + it('fails closed for a non-object payload on an in-scope LWW op', () => { + const op = createOp({ entityId: 'task-123' }); + expect(() => assertDecryptedOpMetadataIntegrity(op, null)).toThrowError( + OperationIntegrityError, + ); + expect(() => assertDecryptedOpMetadataIntegrity(op, 'a string')).toThrowError( + OperationIntegrityError, + ); + }); + }); + + describe('accepts legitimate ops (no false positives)', () => { + it('passes when payload.id matches op.entityId', () => { + const op = createOp({ entityId: 'task-123' }); + expect(() => + assertDecryptedOpMetadataIntegrity(op, { id: 'task-123', changes: {} }), + ).not.toThrow(); + }); + + it('ignores non-LWW action types (e.g. plain updates)', () => { + const op = createOp({ actionType: 'UPDATE_TASK', entityId: 'task-999' }); + // Even a mismatch is out of scope: convertOpToAction won't LWW-coerce it. + expect(() => + assertDecryptedOpMetadataIntegrity(op, { id: 'task-123' }), + ).not.toThrow(); + }); + + it('ignores singleton entities (no payload.id to compare)', () => { + const op = createOp({ entityId: SINGLETON_ENTITY_ID }); + expect(() => assertDecryptedOpMetadataIntegrity(op, { foo: 'bar' })).not.toThrow(); + }); + + it('does not throw when entityId is missing (out of scope)', () => { + const op = createOp({ entityId: undefined }); + expect(() => + assertDecryptedOpMetadataIntegrity(op, { id: 'task-123' }), + ).not.toThrow(); + }); + }); +}); diff --git a/src/app/op-log/sync/verify-decrypted-op-integrity.ts b/src/app/op-log/sync/verify-decrypted-op-integrity.ts new file mode 100644 index 0000000000..52313618f3 --- /dev/null +++ b/src/app/op-log/sync/verify-decrypted-op-integrity.ts @@ -0,0 +1,97 @@ +import { extractActionPayload } from '@sp/sync-core'; +import { SyncOperation } from '../sync-providers/provider.interface'; +import { isLwwUpdateActionType } from '../core/lww-update-action-types'; +import { isSingletonEntityId } from '../core/entity-registry'; +import { OperationIntegrityError } from '../core/errors/sync-errors'; +import { ACTION_TYPE_ALIASES } from '../apply/operation-converter.util'; +import { SyncLog } from '../../core/log'; + +/** + * Verifies that a just-decrypted operation's UNAUTHENTICATED metadata is + * consistent with its AUTHENTICATED payload. + * + * SuperSync E2EE (AES-256-GCM) covers only `op.payload`; every other field — + * `entityId`, `opType`, `actionType`, `vectorClock`, `timestamp`, + * `isPayloadEncrypted`, ... — travels as plaintext beside the ciphertext and is + * NOT bound by the GCM auth tag (no Additional Authenticated Data). A + * malicious/compromised sync server or a TLS MITM therefore cannot read or + * forge payload *contents*, but it CAN tamper with the metadata. + * GHSA-8pxh-mgc7-gp3g. + * + * This is DEFENSE-IN-DEPTH that closes one vector: retagging an *encrypted* + * LWW-update op with a different `entityId` to redirect the (authenticated) + * changes onto an attacker-chosen entity. `convertOpToAction()` would otherwise + * resolve the mismatch by trusting the tampered `op.entityId` over the + * authenticated `payload.id` (it coerces `payload.id = op.entityId` — including + * when `payload.id` is absent — and only warns). Here we treat the + * authenticated `payload.id` as ground truth and fail CLOSED: an in-scope LWW op + * whose authenticated payload does not carry a string `id` equal to + * `op.entityId` is rejected. The gate mirrors `convertOpToAction`'s coercion + * predicate exactly (same alias resolution, same singleton exclusion) so the two + * boundaries cannot drift and leave a hole. + * + * Scope: only encrypted ops reach this boundary (it is called from the decrypt + * path), so unencrypted ops — where neither side is authenticated and the + * #7330 producer-drift coercion still legitimately applies — are unaffected. + * + * NOTE: interim hardening only. The plaintext-injection downgrade (a forged op + * with `isPayloadEncrypted=false` that would skip decryption AND this check) is + * handled separately by `assertOpsEncryptedWhenExpected` at the download + * boundary. Still OPEN pending the durable fix (bind metadata as GCM AAD behind + * an envelope-versioned migration): + * - `opType` promotion to a full-state (`loadAllData`) op. + * - Within-LWW `entityType`/`actionType` swap (ids left equal so this passes). + * - `vectorClock`/`timestamp` reorder/replay. + * See GHSA-8pxh-mgc7-gp3g and + * docs/sync-and-op-log/supersync-encryption-architecture.md. + * + * @throws OperationIntegrityError when tampering is detected. + */ +export const assertDecryptedOpMetadataIntegrity = ( + op: SyncOperation, + decryptedPayload: unknown, +): void => { + // Resolve aliases first, exactly like convertOpToAction (operation-converter + // .util.ts) — otherwise a future LWW-action rename in ACTION_TYPE_ALIASES + // would make this gate skip an op the converter still LWW-coerces, silently + // reopening the retarget hole. + const actionType = ACTION_TYPE_ALIASES[op.actionType] ?? op.actionType; + + // Only non-singleton LWW single-entity updates carry a canonical `payload.id` + // that must equal `op.entityId`. Singletons use SINGLETON_ENTITY_ID (no `id`). + if ( + !isLwwUpdateActionType(actionType) || + !op.entityId || + isSingletonEntityId(op.entityId) + ) { + return; + } + + const actionPayload = extractActionPayload(decryptedPayload); + const payloadId = actionPayload?.['id']; + + // Fail closed: the authenticated payload MUST carry a string `id` equal to the + // (unauthenticated) `op.entityId`. Missing / non-string / mismatched all mean + // the metadata cannot be trusted — convertOpToAction coerces `id = op.entityId` + // in every one of those cases, so anything but a positive match is rejected. + if (typeof payloadId === 'string' && payloadId === op.entityId) { + return; + } + + // Log ids only — never payload content (op log is exportable). Rule 9. + SyncLog.err( + '[assertDecryptedOpMetadataIntegrity] encrypted op entityId does not match authenticated payload.id — rejecting (possible sync-server tampering)', + { + opId: op.id, + entityType: op.entityType, + opEntityId: op.entityId, + payloadId: typeof payloadId === 'string' ? payloadId : `<${typeof payloadId}>`, + actionType: op.actionType, + }, + ); + throw new OperationIntegrityError( + `Operation ${op.id} failed metadata integrity check: encrypted payload id ` + + `does not match op.entityId (possible sync-server tampering). ` + + `GHSA-8pxh-mgc7-gp3g`, + ); +}; diff --git a/src/app/t.const.ts b/src/app/t.const.ts index cdbe6cdb56..bef2b72640 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -1622,6 +1622,7 @@ const T = { INCOMPLETE_OP_SYNC: 'F.SYNC.S.INCOMPLETE_OP_SYNC', INITIAL_SYNC_ERROR: 'F.SYNC.S.INITIAL_SYNC_ERROR', INTEGRITY_CHECK_FAILED: 'F.SYNC.S.INTEGRITY_CHECK_FAILED', + INTEGRITY_TAMPER_DETECTED: 'F.SYNC.S.INTEGRITY_TAMPER_DETECTED', INVALID_AUTH_CODE: 'F.SYNC.S.INVALID_AUTH_CODE', INVALID_OPERATION_PAYLOAD: 'F.SYNC.S.INVALID_OPERATION_PAYLOAD', LEGACY_FORMAT_DETECTED: 'F.SYNC.S.LEGACY_FORMAT_DETECTED', diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 1d98bf3795..e1083b1066 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1574,6 +1574,7 @@ "INCOMPLETE_OP_SYNC": "Sync incomplete: {{count}} operation file(s) failed to download", "INITIAL_SYNC_ERROR": "Initial Sync failed", "INTEGRITY_CHECK_FAILED": "Data integrity issue detected. Auto-repair attempted. If you experience issues, please reload the app.", + "INTEGRITY_TAMPER_DETECTED": "Sync stopped: the server returned data that failed a security integrity check. Your local data is safe. This can indicate a tampered or misconfigured sync server.", "INVALID_AUTH_CODE": "The authorization code was rejected. Please try authenticating again and make sure to copy the code exactly.", "INVALID_OPERATION_PAYLOAD": "Invalid operation detected. Changes may not be saved - please reload.", "LEGACY_FORMAT_DETECTED": "Sync format mismatch: the remote storage was last written by an older app version (v16.x or earlier) that uses a different sync format. Please update all your devices to the same app version. \"Force overwrite\" unblocks this device by writing the new format alongside the legacy files; only use it if no older device will sync to this remote anymore, otherwise they will silently diverge from this device.", From 6f1cacc5533377d583debdb14e8f2e7523b80c5c Mon Sep 17 00:00:00 2001 From: Lane Sawyer Date: Sat, 11 Jul 2026 01:36:24 -0700 Subject: [PATCH 15/25] chore(scripts): remove one-off codemod scripts [#8260 - Tier A] (#8893) * chore(scripts): remove one-off codemod scripts [#8260 - Tier A] * Address PR feedback --- .../supersync-encryption-architecture.md | 20 +- scripts/fix-duplicate-imports.ts | 92 --------- scripts/log-migration-helper.test.cjs | 55 ----- scripts/log-migration-helper.ts | 171 ---------------- scripts/migrate-console-to-log-force.ts | 189 ------------------ scripts/migrate-console-to-log.ts | 181 ----------------- scripts/migrate-to-droid-log.ts | 58 ------ scripts/migrate-to-issue-log.ts | 10 - scripts/migrate-to-plugin-log.ts | 10 - scripts/migrate-to-task-log.ts | 143 ------------- scripts/remove-unused-log-imports.ts | 80 -------- 11 files changed, 10 insertions(+), 999 deletions(-) delete mode 100755 scripts/fix-duplicate-imports.ts delete mode 100644 scripts/log-migration-helper.test.cjs delete mode 100644 scripts/log-migration-helper.ts delete mode 100755 scripts/migrate-console-to-log-force.ts delete mode 100755 scripts/migrate-console-to-log.ts delete mode 100755 scripts/migrate-to-droid-log.ts delete mode 100755 scripts/migrate-to-issue-log.ts delete mode 100755 scripts/migrate-to-plugin-log.ts delete mode 100755 scripts/migrate-to-task-log.ts delete mode 100644 scripts/remove-unused-log-imports.ts diff --git a/docs/sync-and-op-log/supersync-encryption-architecture.md b/docs/sync-and-op-log/supersync-encryption-architecture.md index c25aff6d58..50c9a4bb75 100644 --- a/docs/sync-and-op-log/supersync-encryption-architecture.md +++ b/docs/sync-and-op-log/supersync-encryption-architecture.md @@ -227,13 +227,13 @@ privateCfg: { ## Security Properties -| Property | Guarantee | -| ------------------- | ---------------------------------------------- | -| **Confidentiality** | Server cannot read operation payloads | +| Property | Guarantee | +| ------------------- | ----------------------------------------------- | +| **Confidentiality** | Server cannot read operation payloads | | **Integrity** | GCM auth tag detects tampering of the _payload_ | -| **Key Security** | Argon2id makes brute-force expensive | -| **Forward Secrecy** | Each operation uses random IV | -| **Wrong Password** | Decryption fails, operation rejected | +| **Key Security** | Argon2id makes brute-force expensive | +| **Forward Secrecy** | Each operation uses random IV | +| **Wrong Password** | Decryption fails, operation rejected | > **Integrity scope (important).** Only `op.payload` is encrypted and covered by > the AES-GCM authentication tag. Every other operation field — `actionType`, @@ -245,7 +245,7 @@ privateCfg: { > tamper vectors: > > - **Plaintext-injection downgrade:** a forged op with `isPayloadEncrypted=false` -> would skip decryption *and* the payload check and be applied as-is — arbitrary +> would skip decryption _and_ the payload check and be applied as-is — arbitrary > op forgery on an encryption-mandatory client. `assertOpsEncryptedWhenExpected` > rejects any inbound plaintext op (download + piggyback) when encryption is > **enabled in config** (`isEncryptionMandatory && isEncryptionEnabled()` — @@ -254,8 +254,8 @@ privateCfg: { > re-uploads all data encrypted, so no legitimate plaintext op remains — this > rests on the server contract that `deleteAllData()` removes every downloadable > plaintext op. This is the SuperSync op-level twin of the file-based GHSA-vrc7 -> download guard and the GHSA-9544 *upload* guard. -> - **LWW `entityId` retarget:** the client rejects an *encrypted* LWW-update op +> download guard and the GHSA-9544 _upload_ guard. +> - **LWW `entityId` retarget:** the client rejects an _encrypted_ LWW-update op > whose authenticated `payload.id` does not equal `op.entityId` > (`verify-decrypted-op-integrity.ts`). > @@ -270,7 +270,7 @@ privateCfg: { > authenticate it. > > Known limitation: a peer running an app version that predates the GHSA-9544 -> *upload* guard can still push plaintext ops; a keyed client then fails closed +> _upload_ guard can still push plaintext ops; a keyed client then fails closed > here with the tamper message. Recovery is to update the old peer. > > Full protection — binding the metadata (and the encryption flag) as GCM AAD diff --git a/scripts/fix-duplicate-imports.ts b/scripts/fix-duplicate-imports.ts deleted file mode 100755 index 9ed3110388..0000000000 --- a/scripts/fix-duplicate-imports.ts +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env ts-node - -import * as fs from 'fs'; -import * as path from 'path'; -import * as glob from 'glob'; - -// Pattern to find duplicate imports -const findDuplicateImports = (content: string): string[] => { - const importRegex = /^import\s+.*?;$/gm; - const imports = content.match(importRegex) || []; - const seen = new Set(); - const duplicates: string[] = []; - - for (const imp of imports) { - const normalized = imp.trim(); - if (seen.has(normalized)) { - duplicates.push(normalized); - } else { - seen.add(normalized); - } - } - - return duplicates; -}; - -// Remove duplicate imports from content -const removeDuplicateImports = (content: string): string => { - const lines = content.split('\n'); - const seenImports = new Set(); - const resultLines: string[] = []; - - for (const line of lines) { - const trimmedLine = line.trim(); - - // Check if it's an import statement - if (trimmedLine.startsWith('import ') && trimmedLine.endsWith(';')) { - if (seenImports.has(trimmedLine)) { - // Skip duplicate import - continue; - } - seenImports.add(trimmedLine); - } - - resultLines.push(line); - } - - return resultLines.join('\n'); -}; - -function processFile(filePath: string): boolean { - try { - const content = fs.readFileSync(filePath, 'utf8'); - const duplicates = findDuplicateImports(content); - - if (duplicates.length > 0) { - console.log( - `Found ${duplicates.length} duplicate imports in ${path.relative(process.cwd(), filePath)}:`, - ); - duplicates.forEach((dup) => console.log(` - ${dup}`)); - - const fixedContent = removeDuplicateImports(content); - fs.writeFileSync(filePath, fixedContent, 'utf8'); - return true; - } - - return false; - } catch (error) { - console.error(`Error processing ${filePath}:`, error); - return false; - } -} - -function main() { - console.log('Searching for duplicate imports...\n'); - - const files = glob.sync('src/**/*.ts', { - ignore: ['**/node_modules/**', '**/dist/**', '**/build/**'], - absolute: true, - }); - - let fixedCount = 0; - - for (const file of files) { - if (processFile(file)) { - fixedCount++; - } - } - - console.log(`\nFixed duplicate imports in ${fixedCount} files.`); -} - -main(); diff --git a/scripts/log-migration-helper.test.cjs b/scripts/log-migration-helper.test.cjs deleted file mode 100644 index ebe1a963e4..0000000000 --- a/scripts/log-migration-helper.test.cjs +++ /dev/null @@ -1,55 +0,0 @@ -require('ts-node/register/transpile-only'); - -const assert = require('node:assert/strict'); -const test = require('node:test'); -const { migrateLogContent } = require('./log-migration-helper.ts'); - -test('migrates Log calls and updates the import for the target logger', () => { - const input = [ - "import { Log, OtherLog } from '../core/log';", - '', - "Log.log('a');", - "Log.err('b');", - 'OtherLog.log();', - '', - ].join('\n'); - - const result = migrateLogContent(input, 'PluginLog'); - - assert.equal(result.changes, 2); - assert.equal( - result.content, - [ - "import { OtherLog, PluginLog } from '../core/log';", - '', - "PluginLog.log('a');", - "PluginLog.err('b');", - 'OtherLog.log();', - '', - ].join('\n'), - ); -}); - -test('keeps the Log import when non-call Log references remain', () => { - const input = [ - "import { Log } from '../core/log';", - '', - 'const logger = Log;', - "Log.info('a');", - '', - ].join('\n'); - - const result = migrateLogContent(input, 'IssueLog'); - - assert.equal(result.changes, 1); - assert.equal( - result.content, - [ - "import { Log, IssueLog } from '../core/log';", - '', - 'const logger = Log;', - "IssueLog.info('a');", - '', - ].join('\n'), - ); -}); diff --git a/scripts/log-migration-helper.ts b/scripts/log-migration-helper.ts deleted file mode 100644 index 7bd8a5128e..0000000000 --- a/scripts/log-migration-helper.ts +++ /dev/null @@ -1,171 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; -import * as glob from 'glob'; - -interface Replacement { - pattern: RegExp; - replacement: string; -} - -export interface LogMigrationConfig { - description: string; - fileLabel: string; - globPattern: string; - targetLogName: string; - ignore?: string[]; -} - -export interface MigrationResult { - content: string; - changes: number; -} - -export interface FileMigrationResult { - modified: boolean; - changes: number; -} - -const DEFAULT_IGNORE = ['**/*.spec.ts', '**/node_modules/**']; -const LOG_METHODS = ['log', 'err', 'info', 'debug', 'verbose', 'critical']; - -const createReplacements = (targetLogName: string): Replacement[] => - LOG_METHODS.map((method) => ({ - pattern: new RegExp(`\\bLog\\.${method}\\(`, 'g'), - replacement: `${targetLogName}.${method}(`, - })); - -const getTargetLogImportRegex = (targetLogName: string): RegExp => - new RegExp( - `import\\s*{[^}]*\\b${targetLogName}\\b[^}]*}\\s*from\\s*['"][^'"]*\\/log['"]`, - ); - -const LOG_IMPORT_REGEX = /import\s*{([^}]*\bLog\b[^}]*)}\s*from\s*(['"][^'"]*\/log['"])/; - -const updateImports = ( - content: string, - targetLogName: string, - replacements: Replacement[], -): string => { - if (getTargetLogImportRegex(targetLogName).test(content)) { - return content; - } - - const match = content.match(LOG_IMPORT_REGEX); - if (!match) { - return content; - } - - const [fullMatch, imports, importPath] = match; - const importList = imports.split(',').map((s) => s.trim()); - - if (!importList.includes(targetLogName)) { - importList.push(targetLogName); - } - - let tempContent = content; - for (const { pattern, replacement } of replacements) { - tempContent = tempContent.replace(pattern, replacement); - } - - tempContent = tempContent.replace(LOG_IMPORT_REGEX, ''); - - if (!/\bLog\b/.test(tempContent)) { - const logIndex = importList.indexOf('Log'); - if (logIndex > -1) { - importList.splice(logIndex, 1); - } - } - - return content.replace( - fullMatch, - `import { ${importList.join(', ')} } from ${importPath}`, - ); -}; - -export const migrateLogContent = ( - content: string, - targetLogName: string, -): MigrationResult => { - const replacements = createReplacements(targetLogName); - let nextContent = content; - let changeCount = 0; - - for (const { pattern, replacement } of replacements) { - const matches = nextContent.match(pattern); - if (matches) { - changeCount += matches.length; - nextContent = nextContent.replace(pattern, replacement); - } - } - - if (changeCount > 0) { - nextContent = updateImports(nextContent, targetLogName, replacements); - } - - return { - content: nextContent, - changes: changeCount, - }; -}; - -export const migrateLogFile = ( - filePath: string, - targetLogName: string, - dryRun: boolean = false, -): FileMigrationResult => { - try { - const originalContent = fs.readFileSync(filePath, 'utf8'); - const { content, changes } = migrateLogContent(originalContent, targetLogName); - const modified = content !== originalContent; - - if (modified && !dryRun) { - fs.writeFileSync(filePath, content, 'utf8'); - } - - return { modified, changes }; - } catch (error) { - console.error(`Error processing ${filePath}:`, error); - return { modified: false, changes: 0 }; - } -}; - -export const runLogMigration = (config: LogMigrationConfig): void => { - const dryRun = process.argv.includes('--dry-run'); - - console.log(`${config.description}\n`); - if (dryRun) { - console.log('Running in DRY RUN mode - no files will be modified\n'); - } - - const files = glob.sync(config.globPattern, { - ignore: config.ignore || DEFAULT_IGNORE, - absolute: true, - }); - - console.log(`Found ${files.length} ${config.fileLabel}\n`); - - const modifiedFiles: { path: string; changes: number }[] = []; - let totalChanges = 0; - - for (const file of files) { - const result = migrateLogFile(file, config.targetLogName, dryRun); - if (result.modified) { - modifiedFiles.push({ path: file, changes: result.changes }); - totalChanges += result.changes; - } - } - - console.log('\nMigration complete!\n'); - console.log(`Total changes: ${totalChanges}`); - console.log(`${dryRun ? 'Would modify' : 'Modified'} ${modifiedFiles.length} files:\n`); - - modifiedFiles - .sort((a, b) => b.changes - a.changes) - .forEach(({ path: filePath, changes }) => { - console.log(` - ${path.relative(process.cwd(), filePath)} (${changes} changes)`); - }); - - if (modifiedFiles.length === 0) { - console.log(' No files needed modification.'); - } -}; diff --git a/scripts/migrate-console-to-log-force.ts b/scripts/migrate-console-to-log-force.ts deleted file mode 100755 index 40171c4a98..0000000000 --- a/scripts/migrate-console-to-log-force.ts +++ /dev/null @@ -1,189 +0,0 @@ -#!/usr/bin/env ts-node - -import * as fs from 'fs'; -import * as path from 'path'; -import * as glob from 'glob'; - -interface Replacement { - pattern: RegExp; - replacement: string; - logMethod: string; -} - -const replacements: Replacement[] = [ - { pattern: /\bconsole\.log\(/g, replacement: 'Log.log(', logMethod: 'log' }, - { pattern: /\bconsole\.info\(/g, replacement: 'Log.info(', logMethod: 'info' }, - { pattern: /\bconsole\.error\(/g, replacement: 'Log.err(', logMethod: 'err' }, - { pattern: /\bconsole\.warn\(/g, replacement: 'Log.err(', logMethod: 'err' }, - { pattern: /\bconsole\.debug\(/g, replacement: 'Log.debug(', logMethod: 'debug' }, -]; - -// Files to exclude -const excludePatterns = [ - '**/node_modules/**', - '**/dist/**', - '**/build/**', - '**/*.spec.ts', - '**/core/log.ts', - '**/scripts/migrate-console-to-log*.ts', - '**/e2e/**', - '**/coverage/**', - '**/.angular/**', -]; - -function calculateImportPath(filePath: string): string { - const fileDir = path.dirname(filePath); - const logPath = path.join(__dirname, '../src/app/core/log'); - let relativePath = path.relative(fileDir, logPath).replace(/\\/g, '/'); - - // Remove .ts extension if present - relativePath = relativePath.replace(/\.ts$/, ''); - - // Ensure it starts with ./ or ../ - if (!relativePath.startsWith('.')) { - relativePath = './' + relativePath; - } - - return relativePath; -} - -function hasLogImport(content: string): boolean { - // Check if there's already a Log import from core/log - return /import\s+{[^}]*\bLog\b[^}]*}\s+from\s+['"][^'"]*\/core\/log['"]/.test(content); -} - -function addLogImport(content: string, importPath: string): string { - // Find the last import statement - const importRegex = /^import\s+.*?;$/gm; - const imports = content.match(importRegex); - - if (imports && imports.length > 0) { - const lastImport = imports[imports.length - 1]; - const lastImportIndex = content.lastIndexOf(lastImport); - const insertPosition = lastImportIndex + lastImport.length; - - return ( - content.slice(0, insertPosition) + - `\nimport { Log } from '${importPath}';` + - content.slice(insertPosition) - ); - } else { - // No imports found, add at the beginning - return `import { Log } from '${importPath}';\n\n` + content; - } -} - -function processFile( - filePath: string, - dryRun: boolean = false, -): { modified: boolean; changes: number } { - try { - let content = fs.readFileSync(filePath, 'utf8'); - const originalContent = content; - let changeCount = 0; - - // Apply replacements - this will replace even in comments, which is what we want - for (const { pattern, replacement } of replacements) { - const matches = content.match(pattern); - if (matches) { - changeCount += matches.length; - content = content.replace(pattern, replacement); - } - } - - // If we made changes and don't have the correct Log import, add it - if (changeCount > 0 && !hasLogImport(content)) { - const importPath = calculateImportPath(filePath); - content = addLogImport(content, importPath); - } - - const modified = content !== originalContent; - - if (modified && !dryRun) { - fs.writeFileSync(filePath, content, 'utf8'); - } - - return { modified, changes: changeCount }; - } catch (error) { - console.error(`Error processing ${filePath}:`, error); - return { modified: false, changes: 0 }; - } -} - -function main() { - const args = process.argv.slice(2); - const dryRun = args.includes('--dry-run'); - - console.log('Starting FORCED console to Log migration...'); - console.log('This will replace ALL console.* calls, including those in comments.'); - if (dryRun) { - console.log('Running in DRY RUN mode - no files will be modified\n'); - } - - // Find all TypeScript files - const files = glob.sync('src/**/*.ts', { - ignore: excludePatterns, - absolute: true, - }); - - console.log(`Found ${files.length} TypeScript files to check\n`); - - const modifiedFiles: { path: string; changes: number }[] = []; - const stats = { - total: 0, - log: 0, - info: 0, - error: 0, - warn: 0, - debug: 0, - }; - - for (const file of files) { - const content = fs.readFileSync(file, 'utf8'); - - // Count occurrences before processing (with word boundary) - const logCount = (content.match(/\bconsole\.log\(/g) || []).length; - const infoCount = (content.match(/\bconsole\.info\(/g) || []).length; - const errorCount = (content.match(/\bconsole\.error\(/g) || []).length; - const warnCount = (content.match(/\bconsole\.warn\(/g) || []).length; - const debugCount = (content.match(/\bconsole\.debug\(/g) || []).length; - - stats.log += logCount; - stats.info += infoCount; - stats.error += errorCount; - stats.warn += warnCount; - stats.debug += debugCount; - - const result = processFile(file, dryRun); - if (result.modified) { - modifiedFiles.push({ path: file, changes: result.changes }); - } - } - - stats.total = stats.log + stats.info + stats.error + stats.warn + stats.debug; - - console.log('\nMigration complete!\n'); - console.log('Statistics:'); - console.log(` Total console calls found: ${stats.total}`); - console.log(` - console.log: ${stats.log}`); - console.log(` - console.info: ${stats.info}`); - console.log(` - console.error: ${stats.error}`); - console.log(` - console.warn: ${stats.warn}`); - console.log(` - console.debug: ${stats.debug}`); - console.log( - `\n${dryRun ? 'Would modify' : 'Modified'} ${modifiedFiles.length} files:\n`, - ); - - modifiedFiles - .sort((a, b) => b.changes - a.changes) - .forEach(({ path: filePath, changes }) => { - console.log(` - ${path.relative(process.cwd(), filePath)} (${changes} changes)`); - }); - - if (modifiedFiles.length === 0) { - console.log(' No files needed modification.'); - } -} - -// Run the migration -main(); diff --git a/scripts/migrate-console-to-log.ts b/scripts/migrate-console-to-log.ts deleted file mode 100755 index 2d571a3836..0000000000 --- a/scripts/migrate-console-to-log.ts +++ /dev/null @@ -1,181 +0,0 @@ -#!/usr/bin/env ts-node - -import * as fs from 'fs'; -import * as path from 'path'; -import * as glob from 'glob'; - -interface Replacement { - pattern: RegExp; - replacement: string; - logMethod: string; -} - -const replacements: Replacement[] = [ - { pattern: /\bconsole\.log\(/g, replacement: 'Log.log(', logMethod: 'log' }, - { pattern: /\bconsole\.info\(/g, replacement: 'Log.info(', logMethod: 'info' }, - { pattern: /\bconsole\.error\(/g, replacement: 'Log.err(', logMethod: 'err' }, - { pattern: /\bconsole\.warn\(/g, replacement: 'Log.err(', logMethod: 'err' }, - { pattern: /\bconsole\.debug\(/g, replacement: 'Log.debug(', logMethod: 'debug' }, -]; - -// Files to exclude -const excludePatterns = [ - '**/node_modules/**', - '**/dist/**', - '**/build/**', - '**/*.spec.ts', - '**/core/log.ts', - '**/scripts/migrate-console-to-log.ts', - '**/e2e/**', - '**/coverage/**', - '**/.angular/**', -]; - -function calculateImportPath(filePath: string): string { - const fileDir = path.dirname(filePath); - const logPath = path.join(__dirname, '../src/app/core/log'); - let relativePath = path.relative(fileDir, logPath).replace(/\\/g, '/'); - - // Remove .ts extension if present - relativePath = relativePath.replace(/\.ts$/, ''); - - // Ensure it starts with ./ or ../ - if (!relativePath.startsWith('.')) { - relativePath = './' + relativePath; - } - - return relativePath; -} - -function hasLogImport(content: string): boolean { - // More flexible pattern that matches any Log import - return /import\s+{[^}]*\bLog\b[^}]*}\s+from\s+['"].*['"]/.test(content); -} - -function addLogImport(content: string, importPath: string): string { - // Find the last import statement - const importRegex = /^import\s+.*?;$/gm; - const imports = content.match(importRegex); - - if (imports && imports.length > 0) { - const lastImport = imports[imports.length - 1]; - const lastImportIndex = content.lastIndexOf(lastImport); - const insertPosition = lastImportIndex + lastImport.length; - - return ( - content.slice(0, insertPosition) + - `\nimport { Log } from '${importPath}';` + - content.slice(insertPosition) - ); - } else { - // No imports found, add at the beginning - return `import { Log } from '${importPath}';\n\n` + content; - } -} - -function processFile(filePath: string, dryRun: boolean = false): boolean { - try { - let content = fs.readFileSync(filePath, 'utf8'); - const originalContent = content; - let hasChanges = false; - - // Check if file uses any console methods (including in comments) - const usesConsole = replacements.some((r) => r.pattern.test(content)); - - if (!usesConsole) { - return false; - } - - // Apply replacements - this will replace even in comments, which is what we want - for (const { pattern, replacement } of replacements) { - if (pattern.test(content)) { - content = content.replace(pattern, replacement); - hasChanges = true; - } - } - - if (hasChanges && !hasLogImport(content)) { - const importPath = calculateImportPath(filePath); - content = addLogImport(content, importPath); - } - - if (content !== originalContent) { - if (!dryRun) { - fs.writeFileSync(filePath, content, 'utf8'); - } - return true; - } - - return false; - } catch (error) { - console.error(`Error processing ${filePath}:`, error); - return false; - } -} - -function main() { - const args = process.argv.slice(2); - const dryRun = args.includes('--dry-run'); - - console.log('Starting console to Log migration...'); - if (dryRun) { - console.log('Running in DRY RUN mode - no files will be modified\n'); - } - - // Find all TypeScript files - const files = glob.sync('src/**/*.ts', { - ignore: excludePatterns, - absolute: true, - }); - - console.log(`Found ${files.length} TypeScript files to check\n`); - - const modifiedFiles: string[] = []; - const stats = { - total: 0, - log: 0, - info: 0, - error: 0, - warn: 0, - debug: 0, - }; - - for (const file of files) { - const content = fs.readFileSync(file, 'utf8'); - - // Count occurrences before processing (with word boundary) - stats.log += (content.match(/\bconsole\.log\(/g) || []).length; - stats.info += (content.match(/\bconsole\.info\(/g) || []).length; - stats.error += (content.match(/\bconsole\.error\(/g) || []).length; - stats.warn += (content.match(/\bconsole\.warn\(/g) || []).length; - stats.debug += (content.match(/\bconsole\.debug\(/g) || []).length; - - const willModify = processFile(file, dryRun); - if (willModify) { - modifiedFiles.push(file); - } - } - - stats.total = stats.log + stats.info + stats.error + stats.warn + stats.debug; - - console.log('\nMigration complete!\n'); - console.log('Statistics:'); - console.log(` Total console calls found: ${stats.total}`); - console.log(` - console.log: ${stats.log}`); - console.log(` - console.info: ${stats.info}`); - console.log(` - console.error: ${stats.error}`); - console.log(` - console.warn: ${stats.warn}`); - console.log(` - console.debug: ${stats.debug}`); - console.log(`\n${dryRun ? 'Would modify' : 'Modified'} ${modifiedFiles.length} files:`); - - modifiedFiles.forEach((file) => { - console.log(` - ${path.relative(process.cwd(), file)}`); - }); - - if (modifiedFiles.length === 0) { - console.log(' No files needed modification.'); - } -} - -// Run the migration -main(); diff --git a/scripts/migrate-to-droid-log.ts b/scripts/migrate-to-droid-log.ts deleted file mode 100755 index ba6fac19e6..0000000000 --- a/scripts/migrate-to-droid-log.ts +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env ts-node - -import * as fs from 'fs'; - -const filesToMigrate = [ - 'src/app/core/persistence/android-db-adapter.service.ts', - 'src/app/features/android/android-interface.ts', - 'src/app/features/android/store/android.effects.ts', -]; - -const CORE_LOG_IMPORT_RE = /from\s*['"][^'"]*core\/log['"]/; - -const migrateFile = (filePath: string): void => { - console.log(`Processing ${filePath}...`); - - let content = fs.readFileSync(filePath, 'utf8'); - let modified = false; - - // Replace Log.* method calls with DroidLog.* - const logPattern = /\bLog\.(log|err|error|info|warn|debug|verbose|critical)\b/g; - if (logPattern.test(content)) { - content = content.replace(logPattern, 'DroidLog.$1'); - modified = true; - } - - // Update imports - if (modified) { - // Check if file already imports from log - if (CORE_LOG_IMPORT_RE.test(content)) { - // Replace Log import with DroidLog - content = content.replace( - /import\s*{\s*([^}]*)\bLog\b([^}]*)\}\s*from\s*['"][^'"]*core\/log['"]/g, - (match, before, after) => { - const imports = (before + after) - .split(',') - .map((s) => s.trim()) - .filter((s) => s && s !== 'Log'); - imports.push('DroidLog'); - return `import { ${imports.join(', ')} } from '${match.includes('"') ? match.split('"')[1] : match.split("'")[1]}'`; - }, - ); - } else { - console.log(`Warning: Could not find log import in ${filePath}`); - } - } - - if (modified) { - fs.writeFileSync(filePath, content); - console.log(`✓ Migrated ${filePath}`); - } else { - console.log(`- No changes needed in ${filePath}`); - } -}; - -// Process all files -filesToMigrate.forEach(migrateFile); - -console.log('\nMigration complete!'); diff --git a/scripts/migrate-to-issue-log.ts b/scripts/migrate-to-issue-log.ts deleted file mode 100755 index bd60a16c53..0000000000 --- a/scripts/migrate-to-issue-log.ts +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env ts-node - -import { runLogMigration } from './log-migration-helper'; - -runLogMigration({ - description: 'Migrating Log to IssueLog in features/issue directory...', - fileLabel: 'TypeScript files in features/issue directory', - globPattern: 'src/app/features/issue/**/*.ts', - targetLogName: 'IssueLog', -}); diff --git a/scripts/migrate-to-plugin-log.ts b/scripts/migrate-to-plugin-log.ts deleted file mode 100755 index bd9e90bd9b..0000000000 --- a/scripts/migrate-to-plugin-log.ts +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env ts-node - -import { runLogMigration } from './log-migration-helper'; - -runLogMigration({ - description: 'Migrating Log to PluginLog in plugins directory...', - fileLabel: 'TypeScript files in plugins directory', - globPattern: 'src/app/plugins/**/*.ts', - targetLogName: 'PluginLog', -}); diff --git a/scripts/migrate-to-task-log.ts b/scripts/migrate-to-task-log.ts deleted file mode 100755 index 42906ac2e1..0000000000 --- a/scripts/migrate-to-task-log.ts +++ /dev/null @@ -1,143 +0,0 @@ -#!/usr/bin/env ts-node - -import * as fs from 'fs'; -import * as path from 'path'; -import * as glob from 'glob'; - -const taskFiles = glob.sync('src/app/features/tasks/**/*.ts', { - ignore: ['**/*.spec.ts', '**/node_modules/**'], -}); - -console.log(`Found ${taskFiles.length} task files to check`); - -let totalChanges = 0; - -function getRelativeImportPath(fromFile: string, toFile: string): string { - const fromDir = path.dirname(fromFile); - let relativePath = path.relative(fromDir, toFile).replace(/\\/g, '/'); - if (!relativePath.startsWith('.')) { - relativePath = './' + relativePath; - } - return relativePath.replace(/\.ts$/, ''); -} - -function migrateFile(filePath: string): void { - let content = fs.readFileSync(filePath, 'utf8'); - let modified = false; - let localChanges = 0; - - // Skip if file already uses TaskLog - if (content.includes('TaskLog')) { - return; - } - - // Replace console.* calls - const consolePatterns = [ - { pattern: /\bconsole\.log\(/g, replacement: 'TaskLog.log(' }, - { pattern: /\bconsole\.error\(/g, replacement: 'TaskLog.err(' }, - { pattern: /\bconsole\.info\(/g, replacement: 'TaskLog.info(' }, - { pattern: /\bconsole\.warn\(/g, replacement: 'TaskLog.warn(' }, - { pattern: /\bconsole\.debug\(/g, replacement: 'TaskLog.debug(' }, - ]; - - for (const { pattern, replacement } of consolePatterns) { - const matches = content.match(pattern); - if (matches) { - content = content.replace(pattern, replacement); - modified = true; - localChanges += matches.length; - } - } - - // Replace Log.* method calls with TaskLog.* - const logPattern = /\bLog\.(log|err|error|info|warn|debug|verbose|critical)\b/g; - const logMatches = content.match(logPattern); - if (logMatches) { - content = content.replace(logPattern, 'TaskLog.$1'); - modified = true; - localChanges += logMatches.length; - } - - // Update imports - if (modified) { - const logPath = getRelativeImportPath(filePath, 'src/app/core/log.ts'); - - // Check if file already imports from log - const importRegex = new RegExp( - `import\\s*{([^}]*)}\\s*from\\s*['"]${logPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"]`, - ); - const existingImport = content.match(importRegex); - - if (existingImport) { - // Update existing import - const imports = existingImport[1] - .split(',') - .map((s) => s.trim()) - .filter((s) => s && s !== 'Log'); - if (!imports.includes('TaskLog')) { - imports.push('TaskLog'); - } - content = content.replace( - importRegex, - `import { ${imports.join(', ')} } from '${logPath}'`, - ); - } else { - // Check for any Log import - const anyLogImport = content.match( - /import\s*{\s*([^}]*)\bLog\b([^}]*)\}\s*from\s*['"][^'"]*core\/log['"]/, - ); - if (anyLogImport) { - // Replace with TaskLog - content = content.replace( - /import\s*{\s*([^}]*)\bLog\b([^}]*)\}\s*from\s*['"][^'"]*core\/log['"]/g, - (match, before, after) => { - const imports = (before + after) - .split(',') - .map((s) => s.trim()) - .filter((s) => s && s !== 'Log'); - imports.push('TaskLog'); - const importPath = match.includes('"') - ? match.split('"')[1] - : match.split("'")[1]; - return `import { ${imports.join(', ')} } from '${importPath}'`; - }, - ); - } else if (!content.includes('TaskLog')) { - // Add new import at the top - const importStatement = `import { TaskLog } from '${logPath}';\n`; - - // Find the right place to insert the import - const firstImportMatch = content.match(/^import\s+/m); - if (firstImportMatch) { - const position = firstImportMatch.index!; - content = - content.slice(0, position) + importStatement + content.slice(position); - } else { - // If no imports, add after any leading comments - const afterComments = content.match(/^(\/\*[\s\S]*?\*\/|\/\/.*$)*/m); - if (afterComments) { - const position = afterComments[0].length; - content = - content.slice(0, position) + - (position > 0 ? '\n' : '') + - importStatement + - content.slice(position); - } else { - content = importStatement + content; - } - } - } - } - } - - if (modified) { - fs.writeFileSync(filePath, content); - console.log(`✓ ${filePath} (${localChanges} changes)`); - totalChanges += localChanges; - } -} - -// Process all files -taskFiles.forEach(migrateFile); - -console.log(`\nMigration complete! Total changes: ${totalChanges}`); diff --git a/scripts/remove-unused-log-imports.ts b/scripts/remove-unused-log-imports.ts deleted file mode 100644 index 348065ee71..0000000000 --- a/scripts/remove-unused-log-imports.ts +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env ts-node - -import * as fs from 'fs'; -import * as path from 'path'; - -const filesToFix = [ - 'src/app/core-ui/drop-list/drop-list.service.ts', - 'src/app/core-ui/side-nav/side-nav.component.ts', - 'src/app/core/banner/banner.service.ts', - 'src/app/features/config/form-cfgs/domina-mode-form.const.ts', - 'src/app/features/focus-mode/focus-mode.service.ts', - 'src/app/features/schedule/create-task-placeholder/create-task-placeholder.component.ts', - 'src/app/features/schedule/map-schedule-data/create-blocked-blocks-by-day-map.ts', - 'src/app/features/schedule/map-schedule-data/create-view-entries-for-day.ts', - 'src/app/features/schedule/map-schedule-data/insert-blocked-blocks-view-entries-for-schedule.ts', - 'src/app/features/schedule/map-schedule-data/map-to-schedule-days.ts', - 'src/app/features/task-repeat-cfg/sort-repeatable-task-cfg.ts', - 'src/app/features/work-context/store/work-context-meta.helper.ts', - 'src/app/root-store/index.ts', - 'src/app/ui/duration/input-duration-formly/input-duration-formly.component.ts', - 'src/app/ui/inline-markdown/inline-markdown.component.ts', - 'src/app/util/is-touch-only.ts', -]; - -function removeUnusedLogImport(filePath: string): void { - try { - const fullPath = path.join(process.cwd(), filePath); - let content = fs.readFileSync(fullPath, 'utf8'); - - // Check if Log is actually used in the file (excluding import statement and comments) - const importRegex = /import\s+{[^}]*\bLog\b[^}]*}\s+from\s+['"][^'"]+['"]/g; - let contentWithoutImports = content.replace(importRegex, ''); - - // Remove single line comments - contentWithoutImports = contentWithoutImports.replace(/\/\/.*$/gm, ''); - // Remove multi-line comments - contentWithoutImports = contentWithoutImports.replace(/\/\*[\s\S]*?\*\//g, ''); - - const isLogUsed = /\bLog\./g.test(contentWithoutImports); - - if (!isLogUsed) { - // Remove Log from imports - content = content.replace( - /import\s+{([^}]*)\bLog\b([^}]*)}\s+from\s+(['"][^'"]+['"])/g, - (match, before, after, path) => { - // Clean up the remaining imports - const remainingImports = (before + after) - .split(',') - .map((s) => s.trim()) - .filter((s) => s && s !== 'Log') - .join(', '); - - if (remainingImports) { - return `import { ${remainingImports} } from ${path}`; - } else { - // If Log was the only import, remove the entire import line - return ''; - } - }, - ); - - // Clean up any empty lines left by removed imports - content = content.replace(/^\s*;\s*$/gm, ''); // Remove standalone semicolons - content = content.replace(/\n\n+/g, '\n\n'); // Replace multiple newlines with double newline - - fs.writeFileSync(fullPath, content, 'utf8'); - console.log(`Fixed: ${filePath}`); - } else { - console.log(`Skipped (Log is used): ${filePath}`); - } - } catch (error) { - console.error(`Error processing ${filePath}:`, error); - } -} - -console.log('Removing unused Log imports...\n'); - -filesToFix.forEach(removeUnusedLogImport); - -console.log('\nDone!'); From 5220684b7e8cec49a2b8c4756c2134a18cf9746a Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Sat, 11 Jul 2026 13:02:19 +0200 Subject: [PATCH 16/25] fix(app): hide donation page on macOS (#8915) * fix(app): hide donation page on macOS Use the stable macOS bridge instead of the MAS runtime flag and redirect direct donation-page navigation on restricted Apple builds. * fix(app): harden donation platform gating Give the restriction a behavior-specific contract, cover platform classification and real router redirects, and correct the platform documentation. --- docs/wiki/3.05-Web-App-vs-Desktop.md | 6 ++ src/app/app.constants.spec.ts | 41 ++++++++++++++ src/app/app.constants.ts | 36 +++++++++--- src/app/app.guard.spec.ts | 56 ++++++++++++++++++- src/app/app.guard.ts | 11 ++++ src/app/app.routes.ts | 3 +- .../magic-nav-config.service.ts | 14 ++--- .../form-cfgs/app-features-form.const.ts | 8 +-- .../dialog-please-rate.component.html | 2 +- .../dialog-please-rate.component.ts | 11 ++-- .../donate-page/donate-page.component.html | 2 +- .../donate-page/donate-page.component.ts | 8 +-- 12 files changed, 164 insertions(+), 34 deletions(-) create mode 100644 src/app/app.constants.spec.ts diff --git a/docs/wiki/3.05-Web-App-vs-Desktop.md b/docs/wiki/3.05-Web-App-vs-Desktop.md index 972a3a7a6d..15eccb61b8 100644 --- a/docs/wiki/3.05-Web-App-vs-Desktop.md +++ b/docs/wiki/3.05-Web-App-vs-Desktop.md @@ -122,6 +122,12 @@ The web version does not have: Certain UI elements tied to these features are hidden in the web build via platform-specific CSS or logic. +## Platform-Specific Availability + +### Donation and Contribution Links + +The Support Us page and links that can lead to GitHub Sponsors are unavailable in native iOS and macOS desktop builds. They remain available in the web app, native Android, Windows desktop, and Linux desktop builds. + ## Documented Error Conditions (Web) - **CORS errors:** WebDAV (and similar) sync may report CORS failures with localized messages suggesting server configuration checks. diff --git a/src/app/app.constants.spec.ts b/src/app/app.constants.spec.ts new file mode 100644 index 0000000000..a3dac02cf4 --- /dev/null +++ b/src/app/app.constants.spec.ts @@ -0,0 +1,41 @@ +import { isDonationUiRestricted } from './app.constants'; + +describe('isDonationUiRestricted', () => { + const cases: Array< + [ + label: string, + context: Parameters[0], + expected: boolean, + ] + > = [ + ['native iOS', { isIosNative: true, isElectron: false, isMacOS: false }, true], + ['macOS Electron DMG', { isIosNative: false, isElectron: true, isMacOS: true }, true], + [ + 'macOS Electron App Store', + { isIosNative: false, isElectron: true, isMacOS: true }, + true, + ], + [ + 'macOS Electron development build', + { isIosNative: false, isElectron: true, isMacOS: true }, + true, + ], + [ + 'Windows or Linux Electron', + { isIosNative: false, isElectron: true, isMacOS: false }, + false, + ], + [ + 'macOS web browser', + { isIosNative: false, isElectron: false, isMacOS: true }, + false, + ], + ['native Android', { isIosNative: false, isElectron: false, isMacOS: false }, false], + ]; + + cases.forEach(([label, context, expected]) => { + it(`${expected ? 'restricts' : 'allows'} donation UI on ${label}`, () => { + expect(isDonationUiRestricted(context)).toBe(expected); + }); + }); +}); diff --git a/src/app/app.constants.ts b/src/app/app.constants.ts index 8f9d7260b6..d1d3dc0b1b 100644 --- a/src/app/app.constants.ts +++ b/src/app/app.constants.ts @@ -23,15 +23,35 @@ export const IS_GNOME_WAYLAND = IS_ELECTRON && window.ea.isGnomeWayland(); // True only inside the Electron build — preload exposes process.arch. // Web builds can't reliably distinguish Apple Silicon from Intel and stay false. export const IS_APPLE_SILICON = IS_ELECTRON && window.ea.isAppleSilicon(); -// True only in a Mac App Store (sandboxed) Electron build. Reuses the existing -// dist-channel bridge (driven by Electron's process.mas); direct-download macOS -// (mac-dmg), other channels and web builds stay false. -export const IS_MAC_APP_STORE = IS_ELECTRON && window.ea.getDistChannel() === 'mac-store'; +interface DonationUiPlatformContext { + isIosNative: boolean; + isElectron: boolean; + isMacOS: boolean; +} + +export const isDonationUiRestricted = ({ + isIosNative, + isElectron, + isMacOS, +}: DonationUiPlatformContext): boolean => isIosNative || (isElectron && isMacOS); + // Apple's App Store guidelines forbid donation/contribution links that route -// around In-App Purchase (Guideline 3.1.1). True for both Apple App Store -// distribution channels (iOS App Store and Mac App Store); direct-download and -// web builds stay false, so they keep the donation UI. -export const IS_APPLE_APP_STORE = IS_IOS_NATIVE || IS_MAC_APP_STORE; +// around In-App Purchase (Guideline 3.1.1). Apply the restriction to every +// macOS Electron build so App Store, direct-download and local review behavior +// cannot diverge based on the unreliable process.mas signal. +export const IS_DONATION_UI_RESTRICTED = isDonationUiRestricted({ + isIosNative: IS_IOS_NATIVE, + isElectron: IS_ELECTRON, + isMacOS: IS_ELECTRON && window.ea.isMacOS(), +}); + +export const IS_DONATION_UI_RESTRICTED_TOKEN = new InjectionToken( + 'IS_DONATION_UI_RESTRICTED', + { + providedIn: 'root', + factory: () => IS_DONATION_UI_RESTRICTED, + }, +); export const TRACKING_INTERVAL = 1000; diff --git a/src/app/app.guard.spec.ts b/src/app/app.guard.spec.ts index 74c77b80ed..6b290e9d55 100644 --- a/src/app/app.guard.spec.ts +++ b/src/app/app.guard.spec.ts @@ -1,13 +1,23 @@ +import { Component } from '@angular/core'; +import { provideLocationMocks } from '@angular/common/testing'; import { TestBed } from '@angular/core/testing'; -import { Router, UrlTree } from '@angular/router'; +import { provideRouter, Router, UrlTree } from '@angular/router'; import { BehaviorSubject, of, throwError } from 'rxjs'; -import { DefaultStartPageGuard } from './app.guard'; +import { DefaultStartPageGuard, DonatePageGuard } from './app.guard'; +import { IS_DONATION_UI_RESTRICTED_TOKEN } from './app.constants'; import { DataInitStateService } from './core/data-init/data-init-state.service'; import { GlobalConfigService } from './features/config/global-config.service'; import { ProjectService } from './features/project/project.service'; import { TODAY_TAG } from './features/tag/tag.const'; import { INBOX_PROJECT } from './features/project/project.const'; import { Project } from './features/project/project.model'; +import { APP_ROUTES } from './app.routes'; + +@Component({ standalone: true, template: '' }) +class DonateRouteTestComponent {} + +@Component({ standalone: true, template: '' }) +class HomeRouteTestComponent {} describe('DefaultStartPageGuard', () => { let guard: DefaultStartPageGuard; @@ -164,3 +174,45 @@ describe('DefaultStartPageGuard', () => { expectUrl(await runGuard(), TODAY_URL); }); }); + +describe('DonatePageGuard', () => { + const setup = (isRestricted: boolean): Router => { + TestBed.configureTestingModule({ + providers: [ + provideLocationMocks(), + provideRouter([ + { + path: 'donate', + component: DonateRouteTestComponent, + canActivate: [DonatePageGuard], + }, + { path: '', component: HomeRouteTestComponent }, + ]), + { provide: IS_DONATION_UI_RESTRICTED_TOKEN, useValue: isRestricted }, + ], + }); + return TestBed.inject(Router); + }; + + it('navigates to the donate page on unrestricted platforms', async () => { + const router = setup(false); + + await router.navigateByUrl('/donate'); + + expect(router.url).toBe('/donate'); + }); + + it('redirects donate navigation on restricted platforms', async () => { + const router = setup(true); + + await router.navigateByUrl('/donate'); + + expect(router.url).toBe('/'); + }); + + it('protects the donate route', () => { + const donateRoute = APP_ROUTES.find((route) => route.path === 'donate'); + + expect(donateRoute?.canActivate).toContain(DonatePageGuard); + }); +}); diff --git a/src/app/app.guard.ts b/src/app/app.guard.ts index b9f918db8f..74825679fe 100644 --- a/src/app/app.guard.ts +++ b/src/app/app.guard.ts @@ -17,6 +17,17 @@ import { selectIsOverlayShown } from './features/focus-mode/store/focus-mode.sel import { DataInitStateService } from './core/data-init/data-init-state.service'; import { GlobalConfigService } from './features/config/global-config.service'; import { getStartPageUrlPath } from './features/config/default-start-page.util'; +import { IS_DONATION_UI_RESTRICTED_TOKEN } from './app.constants'; + +@Injectable({ providedIn: 'root' }) +export class DonatePageGuard { + private _isDonationUiRestricted = inject(IS_DONATION_UI_RESTRICTED_TOKEN); + private _router = inject(Router); + + canActivate(): true | UrlTree { + return this._isDonationUiRestricted ? this._router.parseUrl('/') : true; + } +} @Injectable({ providedIn: 'root' }) export class ActiveWorkContextGuard { diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index aabaa27d29..26e6a2c709 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -3,6 +3,7 @@ import { Routes } from '@angular/router'; import { ActiveWorkContextGuard, DefaultStartPageGuard, + DonatePageGuard, FocusOverlayOpenGuard, ValidProjectIdGuard, ValidTagIdGuard, @@ -94,7 +95,7 @@ export const APP_ROUTES: Routes = [ loadComponent: () => import('./routes/pages.routes').then((m) => m.DonatePageComponent), data: { page: 'donate' }, - canActivate: [FocusOverlayOpenGuard], + canActivate: [DonatePageGuard, FocusOverlayOpenGuard], }, { path: 'contrast-test', diff --git a/src/app/core-ui/magic-side-nav/magic-nav-config.service.ts b/src/app/core-ui/magic-side-nav/magic-nav-config.service.ts index 8d24b098b7..27793fb155 100644 --- a/src/app/core-ui/magic-side-nav/magic-nav-config.service.ts +++ b/src/app/core-ui/magic-side-nav/magic-nav-config.service.ts @@ -36,7 +36,7 @@ import { import { GlobalConfigService } from '../../features/config/global-config.service'; import { AppFeaturesConfig } from '../../features/config/global-config.model'; import { SnackService } from '../../core/snack/snack.service'; -import { IS_APPLE_APP_STORE } from '../../app.constants'; +import { IS_DONATION_UI_RESTRICTED } from '../../app.constants'; @Injectable({ providedIn: 'root', @@ -252,9 +252,9 @@ export class MagicNavConfigService { }, // Help Menu (rendered as mat-menu) - // Not allowed to display donation stuff on the iOS or Mac App Store per - // App Store guidelines (Guideline 3.1.1) - ...(this.isDonatePageEnabled() && !IS_APPLE_APP_STORE + // Donation links are disabled on native iOS and every macOS desktop build + // to keep App Store review behavior deterministic (Guideline 3.1.1). + ...(this.isDonatePageEnabled() && !IS_DONATION_UI_RESTRICTED ? [ { type: 'route', @@ -293,9 +293,9 @@ export class MagicNavConfigService { icon: 'feedback', href: 'https://github.com/super-productivity/super-productivity/discussions', }, - // Not allowed to display donation stuff on the iOS or Mac App Store - // per App Store guidelines (Guideline 3.1.1) - ...(!IS_APPLE_APP_STORE + // Donation links are disabled on native iOS and every macOS desktop + // build to keep App Store review behavior deterministic. + ...(!IS_DONATION_UI_RESTRICTED ? [ { type: 'href' as const, diff --git a/src/app/features/config/form-cfgs/app-features-form.const.ts b/src/app/features/config/form-cfgs/app-features-form.const.ts index 774eef6c17..e1d1431564 100644 --- a/src/app/features/config/form-cfgs/app-features-form.const.ts +++ b/src/app/features/config/form-cfgs/app-features-form.const.ts @@ -1,6 +1,6 @@ import { ConfigFormSection, AppFeaturesConfig } from '../global-config.model'; import { T } from '../../../t.const'; -import { IS_APPLE_APP_STORE } from '../../../app.constants'; +import { IS_DONATION_UI_RESTRICTED } from '../../../app.constants'; export const EXPERIMENTAL_APP_FEATURE_KEYS: ReadonlyArray = [ 'isEnableUserProfiles', @@ -94,9 +94,9 @@ export const APP_FEATURES_FORM_CFG: ConfigFormSection = { { key: 'isDonatePageEnabled', type: 'slide-toggle', - // Donations are fully hidden on Apple App Store builds (Guideline 3.1.1), - // so this toggle would be inert there — hide it to avoid a dead control. - hideExpression: () => IS_APPLE_APP_STORE, + // Donations are fully hidden on native iOS and macOS desktop builds, so + // this toggle would be inert there — hide it to avoid a dead control. + hideExpression: () => IS_DONATION_UI_RESTRICTED, templateOptions: { label: T.GCF.APP_FEATURES.DONATE_PAGE, icon: 'favorite', diff --git a/src/app/features/dialog-please-rate/dialog-please-rate.component.html b/src/app/features/dialog-please-rate/dialog-please-rate.component.html index 40dba3a88b..b25d910f43 100644 --- a/src/app/features/dialog-please-rate/dialog-please-rate.component.html +++ b/src/app/features/dialog-please-rate/dialog-please-rate.component.html @@ -79,7 +79,7 @@ - @if (!IS_APPLE_APP_STORE) { + @if (!IS_DONATION_UI_RESTRICTED) {