@nightkatana/kronosys-app 1.0.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -0
- package/app/api/action/route.ts +16 -0
- package/app/api/backup/route.ts +84 -0
- package/app/api/health/route.ts +22 -0
- package/app/api/state/route.ts +27 -0
- package/app/apple-icon.png +0 -0
- package/app/changelog/page.tsx +122 -0
- package/app/globals.css +210 -0
- package/app/guide/layout.tsx +11 -0
- package/app/guide/page.tsx +278 -0
- package/app/icon.png +0 -0
- package/app/layout.tsx +77 -0
- package/app/licenses/layout.tsx +11 -0
- package/app/licenses/page.tsx +246 -0
- package/app/manifest.ts +32 -0
- package/app/page.tsx +1610 -0
- package/app/reporting/page.tsx +2943 -0
- package/app/settings/layout.tsx +10 -0
- package/app/settings/page.tsx +3518 -0
- package/bin/kronosys.mjs +46 -0
- package/components/KronosysPackageVersionProvider.tsx +19 -0
- package/components/KronosysPayloadProvider.tsx +109 -0
- package/components/PwaRegister.tsx +25 -0
- package/components/SiteLegalFooter.tsx +21 -0
- package/components/ThemeProvider.tsx +78 -0
- package/components/dashboard/AppShellLiveSessionDrawer.tsx +394 -0
- package/components/dashboard/AppShellRouteNav.tsx +131 -0
- package/components/dashboard/AppVersionStamp.tsx +16 -0
- package/components/dashboard/DashboardCollapsibleSection.tsx +57 -0
- package/components/dashboard/DashboardColumnHintsBanner.tsx +159 -0
- package/components/dashboard/DashboardCommandCenter.tsx +470 -0
- package/components/dashboard/DashboardLangGateModal.tsx +118 -0
- package/components/dashboard/DashboardLoadingOverlay.tsx +42 -0
- package/components/dashboard/DashboardSimpleModal.tsx +337 -0
- package/components/dashboard/DashboardSuspenseFallback.tsx +52 -0
- package/components/dashboard/DashboardToastProvider.tsx +64 -0
- package/components/dashboard/DashboardTour.tsx +435 -0
- package/components/dashboard/DeferredDescriptionPopoverWrap.tsx +39 -0
- package/components/dashboard/DeleteSessionModal.tsx +130 -0
- package/components/dashboard/DescriptionTooltipPortaled.tsx +31 -0
- package/components/dashboard/GitIdentityQuickSetupModal.tsx +211 -0
- package/components/dashboard/HeaderIntegrationBadges.tsx +69 -0
- package/components/dashboard/InlineMetricHelpTrigger.tsx +102 -0
- package/components/dashboard/IssuePickerModal.tsx +168 -0
- package/components/dashboard/KronoFocusPanel.tsx +834 -0
- package/components/dashboard/KronosysDatetimePopoverField.tsx +357 -0
- package/components/dashboard/KronosysTimePopoverField.tsx +233 -0
- package/components/dashboard/LanguageMenu.tsx +123 -0
- package/components/dashboard/MongoMirrorSyncLine.tsx +57 -0
- package/components/dashboard/NewSessionScopeModal.tsx +410 -0
- package/components/dashboard/PageRefreshButton.tsx +130 -0
- package/components/dashboard/PlainHelpPopover.tsx +97 -0
- package/components/dashboard/ReportingPageToc.tsx +68 -0
- package/components/dashboard/ReportingTour.tsx +342 -0
- package/components/dashboard/SavedProjectPicker.tsx +92 -0
- package/components/dashboard/SavedTagPicker.tsx +115 -0
- package/components/dashboard/ScrollToTopFab.tsx +41 -0
- package/components/dashboard/SelectedSessionSidebarBlock.tsx +630 -0
- package/components/dashboard/SessionEndReasonEditor.tsx +114 -0
- package/components/dashboard/SessionListPanel.tsx +320 -0
- package/components/dashboard/SessionLocMetricsSection.tsx +128 -0
- package/components/dashboard/SettingsTagsProjectsSection.tsx +993 -0
- package/components/dashboard/SettingsTour.tsx +332 -0
- package/components/dashboard/TagPills.tsx +149 -0
- package/components/dashboard/TagsHelpTrigger.tsx +84 -0
- package/components/dashboard/TaskFocusPanel.tsx +1261 -0
- package/components/dashboard/TaskSessionLiveCard.tsx +832 -0
- package/components/dashboard/TaskSubtasksBlock.tsx +748 -0
- package/components/dashboard/ThemeToggle.test.tsx +26 -0
- package/components/dashboard/ThemeToggle.tsx +36 -0
- package/components/dashboard/UserGuideBodyText.tsx +62 -0
- package/components/dashboard/WorkspaceGitRepoCard.tsx +191 -0
- package/components/dashboard/taskFieldStyles.ts +139 -0
- package/components/dashboard/useAnchoredFloatingPortalStyle.ts +71 -0
- package/components/dashboard/useDescriptionPopoverAfterMs.ts +220 -0
- package/components/dashboard/useKronoFocusLiveSeconds.ts +36 -0
- package/components/dashboard/useSmoothStopwatchMs.ts +25 -0
- package/lib/appShellHeaderClasses.ts +12 -0
- package/lib/backupCsvExport.test.ts +149 -0
- package/lib/backupCsvExport.ts +392 -0
- package/lib/changelogCopy.ts +34 -0
- package/lib/concurrentTaskStartPreference.ts +29 -0
- package/lib/dashboardClockFormat.ts +13 -0
- package/lib/dashboardColumnChrome.ts +3 -0
- package/lib/dashboardColumnHintsStorage.ts +57 -0
- package/lib/dashboardCopy.ts +1831 -0
- package/lib/dashboardDetachedUrlHintStorage.ts +24 -0
- package/lib/dashboardGitIdentityBannerStorage.ts +36 -0
- package/lib/dashboardLangStorage.ts +72 -0
- package/lib/dashboardQuickSearch.ts +476 -0
- package/lib/dashboardQuickSearchQuery.test.ts +63 -0
- package/lib/dashboardQuickSearchQuery.ts +179 -0
- package/lib/dashboardSessionNav.ts +33 -0
- package/lib/dashboardShortcuts.ts +268 -0
- package/lib/dashboardTimeZone.ts +91 -0
- package/lib/dashboardTourStorage.ts +68 -0
- package/lib/dataDir.test.ts +87 -0
- package/lib/dataDir.ts +83 -0
- package/lib/devDataPreferenceFile.ts +55 -0
- package/lib/devDataRuntimeInfo.ts +34 -0
- package/lib/formatIsoShort.test.ts +46 -0
- package/lib/formatIsoShort.ts +29 -0
- package/lib/generatedUserChangelog.ts +34 -0
- package/lib/gitlabIssueSearch.ts +8 -0
- package/lib/kronoFocusDurationHistory.ts +71 -0
- package/lib/kronoFocusRhythm.test.ts +130 -0
- package/lib/kronoFocusRhythm.ts +46 -0
- package/lib/kronoFocusTimerUrgency.test.ts +74 -0
- package/lib/kronoFocusTimerUrgency.ts +24 -0
- package/lib/kronosysApi.ts +143 -0
- package/lib/legacyEditorPayloadKeys.ts +52 -0
- package/lib/legacyKronoFocusStorageKeys.test.ts +29 -0
- package/lib/legacyKronoFocusStorageKeys.ts +32 -0
- package/lib/licensesCopy.ts +128 -0
- package/lib/openPlainTextInNewTab.ts +49 -0
- package/lib/readKronosysPackageVersion.ts +10 -0
- package/lib/reportingAggregate.test.ts +325 -0
- package/lib/reportingAggregate.ts +819 -0
- package/lib/reportingDatePresets.ts +41 -0
- package/lib/reportingMetricHelp.ts +430 -0
- package/lib/reportingNonFinalIndicators.test.ts +157 -0
- package/lib/reportingNonFinalIndicators.ts +102 -0
- package/lib/reportingStrings.ts +491 -0
- package/lib/reportingTagWeekBreakdown.test.ts +141 -0
- package/lib/reportingTagWeekBreakdown.ts +181 -0
- package/lib/reportingWeekLayout.test.ts +239 -0
- package/lib/reportingWeekLayout.ts +313 -0
- package/lib/sessionAssiduity.test.ts +25 -0
- package/lib/sessionAssiduity.ts +33 -0
- package/lib/sessionEndReason.ts +55 -0
- package/lib/sessionEndWarnings.test.ts +200 -0
- package/lib/sessionEndWarnings.ts +125 -0
- package/lib/sessionListMerge.test.ts +101 -0
- package/lib/sessionListMerge.ts +70 -0
- package/lib/sessionTaskSidebarStats.test.ts +24 -0
- package/lib/sessionTaskSidebarStats.ts +54 -0
- package/lib/settingsCopy.ts +1276 -0
- package/lib/taskParsing.test.ts +153 -0
- package/lib/taskParsing.ts +737 -0
- package/lib/theme.ts +15 -0
- package/lib/translucentButtonClasses.ts +34 -0
- package/lib/usageProfile.test.ts +84 -0
- package/lib/usageProfile.ts +52 -0
- package/lib/userGuideCopy.ts +464 -0
- package/lib/workspaceLocDefaults.ts +21 -0
- package/next-env.d.ts +6 -0
- package/next.config.ts +15 -0
- package/package.json +87 -0
- package/postcss.config.mjs +12 -0
- package/public/apple-icon.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon.png +0 -0
- package/public/next.svg +1 -0
- package/public/sw.js +13 -0
- package/public/traceback.png +0 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/server/actionDispatch.test.ts +723 -0
- package/server/actionDispatch.ts +1476 -0
- package/server/actionTaskSession.test.ts +713 -0
- package/server/actionTaskSession.ts +717 -0
- package/server/db.ts +42 -0
- package/server/defaultCfg.ts +87 -0
- package/server/gitlabTokenStore.ts +34 -0
- package/server/kronoFocusHydrate.test.ts +142 -0
- package/server/kronoFocusHydrate.ts +69 -0
- package/server/kronoFocusMigrate.test.ts +53 -0
- package/server/kronoFocusMigrate.ts +78 -0
- package/server/mainTimerHydrate.test.ts +65 -0
- package/server/mainTimerHydrate.ts +53 -0
- package/server/payloadStore.test.ts +78 -0
- package/server/payloadStore.ts +83 -0
- package/server/sessionWallHydrate.test.ts +46 -0
- package/server/sessionWallHydrate.ts +88 -0
- package/tsconfig.json +41 -0
|
@@ -0,0 +1,1476 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import type { KronosysUpdatePayload } from "@/lib/kronosysApi";
|
|
4
|
+
import { workspaceFolderPathStrings } from "@/lib/legacyEditorPayloadKeys";
|
|
5
|
+
import { GITLAB_ISSUES_SEARCH_TIMEOUT_MS } from "@/lib/gitlabIssueSearch";
|
|
6
|
+
import {
|
|
7
|
+
mergeTagsForDisplay,
|
|
8
|
+
normalizeProjectKey,
|
|
9
|
+
normalizeTagKey,
|
|
10
|
+
normalizeTaskTagsForStorage,
|
|
11
|
+
parseTaskWithAutoTags,
|
|
12
|
+
readTaskDefaultTagBucketEnabled,
|
|
13
|
+
type TaskTagsStorageNormalizeOpts,
|
|
14
|
+
} from "@/lib/taskParsing";
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
addHistoricalTaskToSession,
|
|
18
|
+
addSubtaskInSession,
|
|
19
|
+
asRecord,
|
|
20
|
+
deleteSubtaskInSession,
|
|
21
|
+
deleteTaskInSession,
|
|
22
|
+
finishTaskInSession,
|
|
23
|
+
prepareSessionForExclusiveMainTimerUnpaused,
|
|
24
|
+
purgeProjectEverywhere,
|
|
25
|
+
purgeTagEverywhere,
|
|
26
|
+
reorderSubtasksInSession,
|
|
27
|
+
resolveTaskSession,
|
|
28
|
+
setActiveSubtaskTimerInSession,
|
|
29
|
+
setTaskPausedInSession,
|
|
30
|
+
toggleSubtaskInSession,
|
|
31
|
+
updateSubtaskTitleInSession,
|
|
32
|
+
updateTaskStartTimeInSession,
|
|
33
|
+
updateTaskEndTimeInSession,
|
|
34
|
+
updateTaskInSession,
|
|
35
|
+
} from "./actionTaskSession";
|
|
36
|
+
import { normalizeSessionEndReasonKind, normalizeSessionEndReasonNote } from "@/lib/sessionEndReason";
|
|
37
|
+
import { assiduityFromScheduledStart, type SessionStartAssiduity } from "@/lib/sessionAssiduity";
|
|
38
|
+
|
|
39
|
+
import {
|
|
40
|
+
isTruthyDevUseProdEnv,
|
|
41
|
+
writeUseProductionDataInDevelopmentToFile,
|
|
42
|
+
} from "@/lib/devDataPreferenceFile";
|
|
43
|
+
import {
|
|
44
|
+
DEFAULT_DASHBOARD_TIME_ZONE,
|
|
45
|
+
isValidIanaTimeZone,
|
|
46
|
+
} from "@/lib/dashboardTimeZone";
|
|
47
|
+
import { defaultKronosysCfg } from "./defaultCfg";
|
|
48
|
+
import { clearGitlabPatFromStore, readGitlabPatFromStore, writeGitlabPatToStore } from "./gitlabTokenStore";
|
|
49
|
+
import {
|
|
50
|
+
LEGACY_ACTION_PAUSE,
|
|
51
|
+
LEGACY_ACTION_RESET,
|
|
52
|
+
LEGACY_ACTION_SET_WORK_DURATION,
|
|
53
|
+
LEGACY_ACTION_START,
|
|
54
|
+
LEGACY_START_TASK_WITH_TIMER_BODY_KEY,
|
|
55
|
+
LEGACY_TIMER_DEADLINE_MS_KEY,
|
|
56
|
+
} from "@/lib/legacyKronoFocusStorageKeys";
|
|
57
|
+
import {
|
|
58
|
+
clampBreakDurationSeconds,
|
|
59
|
+
clampWorkDurationSeconds,
|
|
60
|
+
readWorkDurationSeconds,
|
|
61
|
+
} from "@/lib/kronoFocusRhythm";
|
|
62
|
+
import { readPayload, writePayload } from "./payloadStore";
|
|
63
|
+
import {
|
|
64
|
+
ensureSessionWallSegmentOnLive,
|
|
65
|
+
flushSessionWallSegmentOnLive,
|
|
66
|
+
SESSION_WALL_SEGMENT_STARTED_AT,
|
|
67
|
+
} from "./sessionWallHydrate";
|
|
68
|
+
|
|
69
|
+
type ActionResult = {
|
|
70
|
+
ok: boolean;
|
|
71
|
+
result?: Record<string, unknown>;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
function tryPersistDevDataPreferenceFromCfg(merged: Record<string, unknown>): void {
|
|
75
|
+
if (process.env.NODE_ENV !== "development") {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (process.env.TRACE_DATA_DIR?.trim()) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (isTruthyDevUseProdEnv(process.env.KRONOSYS_DEV_USE_PROD_DATA)) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (typeof merged.developmentUseProductionData === "boolean") {
|
|
85
|
+
writeUseProductionDataInDevelopmentToFile(merged.developmentUseProductionData);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function syncLiveIntoHistory(p: KronosysUpdatePayload): void {
|
|
90
|
+
const cur = asRecord(p.current);
|
|
91
|
+
if (!cur) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const sid = typeof cur.sessionId === "string" ? cur.sessionId.trim() : "";
|
|
95
|
+
if (!sid) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const hist = ([...(p.history || [])] as Record<string, unknown>[]).filter((h) => h.sessionId !== sid);
|
|
99
|
+
const snap: Record<string, unknown> = {
|
|
100
|
+
sessionId: sid,
|
|
101
|
+
sessionName: cur.sessionName ?? "",
|
|
102
|
+
savedAt: new Date().toISOString(),
|
|
103
|
+
createdAt: cur.createdAt ?? cur.startAt ?? null,
|
|
104
|
+
startAt: cur.startAt ?? null,
|
|
105
|
+
endAt: cur.endAt ?? null,
|
|
106
|
+
sessionDurationMinutes: cur.sessionDurationMinutes,
|
|
107
|
+
tasks: cur.tasks ?? [],
|
|
108
|
+
activeTasks: cur.activeTasks ?? [],
|
|
109
|
+
activeTask: cur.activeTask ?? null,
|
|
110
|
+
archived: cur.archived === true,
|
|
111
|
+
};
|
|
112
|
+
if (typeof cur.scheduledStartAt === "string" && cur.scheduledStartAt.trim() !== "") {
|
|
113
|
+
snap.scheduledStartAt = cur.scheduledStartAt;
|
|
114
|
+
}
|
|
115
|
+
if (typeof cur.sessionStartOffsetMinutes === "number" && Number.isFinite(cur.sessionStartOffsetMinutes)) {
|
|
116
|
+
snap.sessionStartOffsetMinutes = cur.sessionStartOffsetMinutes;
|
|
117
|
+
}
|
|
118
|
+
const rk = normalizeSessionEndReasonKind(cur.sessionEndReasonKind);
|
|
119
|
+
if (rk) {
|
|
120
|
+
snap.sessionEndReasonKind = rk;
|
|
121
|
+
}
|
|
122
|
+
const rn = normalizeSessionEndReasonNote(cur.sessionEndReasonNote);
|
|
123
|
+
if (rn.length > 0) {
|
|
124
|
+
snap.sessionEndReasonNote = rn;
|
|
125
|
+
}
|
|
126
|
+
hist.unshift(snap);
|
|
127
|
+
p.history = hist;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function ensureLive(p: KronosysUpdatePayload): Record<string, unknown> {
|
|
131
|
+
if (!p.current) {
|
|
132
|
+
p.current = {};
|
|
133
|
+
}
|
|
134
|
+
return p.current as Record<string, unknown>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Enrichit `knownTags` avec des étiquettes vues sur une tâche (suggestions / datalist). */
|
|
138
|
+
function mergeDiscoveredTagsIntoPayloadKnownTags(p: KronosysUpdatePayload, tagList: string[]): void {
|
|
139
|
+
const known = [...((p.knownTags || []) as string[])];
|
|
140
|
+
const seen = new Set(known.map((t) => normalizeTagKey(String(t)).toLowerCase()));
|
|
141
|
+
for (const raw of tagList) {
|
|
142
|
+
const nk = normalizeTagKey(typeof raw === "string" ? raw : String(raw));
|
|
143
|
+
const lk = nk.toLowerCase();
|
|
144
|
+
if (nk && !seen.has(lk)) {
|
|
145
|
+
seen.add(lk);
|
|
146
|
+
known.push(nk);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
p.knownTags = known;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Enrichit `knownProjects` avec un projet vu sur une tâche (suggestions / datalist, Paramètres). */
|
|
153
|
+
function mergeDiscoveredProjectIntoPayloadKnownProjects(
|
|
154
|
+
p: KronosysUpdatePayload,
|
|
155
|
+
project: string | null | undefined
|
|
156
|
+
): void {
|
|
157
|
+
if (project === undefined || project === null) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const pk = normalizeProjectKey(String(project).trim());
|
|
161
|
+
if (!pk) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const lk = pk.toLowerCase();
|
|
165
|
+
const known = [...((p.knownProjects || []) as string[])];
|
|
166
|
+
if (known.some((x) => normalizeProjectKey(String(x)).toLowerCase() === lk)) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
known.push(pk);
|
|
170
|
+
p.knownProjects = known;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function mergeCfg(p: KronosysUpdatePayload, patch: Record<string, unknown>): void {
|
|
174
|
+
const base = { ...defaultKronosysCfg(), ...(asRecord(p.cfg) ?? {}) };
|
|
175
|
+
p.cfg = { ...base, ...patch };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const CFG_KEYS_PRESERVED_ON_RESET: readonly string[] = [
|
|
179
|
+
"gitlabTokenStored",
|
|
180
|
+
"gitlabTokenFromEnv",
|
|
181
|
+
"mongodbUriConfigured",
|
|
182
|
+
"mongodbManualUriConfigured",
|
|
183
|
+
"mongodbPasswordConfigured",
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
/** Réinitialise `cfg` aux valeurs de `defaultKronosysCfg()`, sans effacer les drapeaux liés aux secrets déjà enregistrés. */
|
|
187
|
+
function resetCfgToDefaults(p: KronosysUpdatePayload): void {
|
|
188
|
+
const prev = asRecord(p.cfg) ?? {};
|
|
189
|
+
const next: Record<string, unknown> = { ...defaultKronosysCfg() };
|
|
190
|
+
for (const key of CFG_KEYS_PRESERVED_ON_RESET) {
|
|
191
|
+
if (Object.prototype.hasOwnProperty.call(prev, key)) {
|
|
192
|
+
next[key] = prev[key];
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
p.cfg = next;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Chaîne vide ou origine https (sans chemin). `null` si la valeur est non vide mais illisible. */
|
|
199
|
+
function parseGitlabInstanceOrigin(raw: string): string | null {
|
|
200
|
+
const t = raw.trim();
|
|
201
|
+
if (t.length === 0) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
let s = t.replace(/\/$/, "").replace(/\/api\/v4\/?$/i, "");
|
|
205
|
+
if (!/^https?:\/\//i.test(s)) {
|
|
206
|
+
s = `https://${s}`;
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
const u = new URL(s);
|
|
210
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
return u.origin;
|
|
214
|
+
} catch {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
type ResolvedGitlabOrigin = { ok: true; origin: string } | { ok: false; reason: "invalid_url" };
|
|
220
|
+
|
|
221
|
+
function resolveGitlabApiOrigin(cfg: Record<string, unknown>, body: Record<string, unknown>): ResolvedGitlabOrigin {
|
|
222
|
+
const bodyRaw = typeof body.gitlabApiBaseUrl === "string" ? body.gitlabApiBaseUrl.trim() : "";
|
|
223
|
+
if (bodyRaw.length > 0) {
|
|
224
|
+
const o = parseGitlabInstanceOrigin(bodyRaw);
|
|
225
|
+
if (!o) {
|
|
226
|
+
return { ok: false, reason: "invalid_url" };
|
|
227
|
+
}
|
|
228
|
+
return { ok: true, origin: o };
|
|
229
|
+
}
|
|
230
|
+
const cfgRaw = typeof cfg.gitlabApiBaseUrl === "string" ? cfg.gitlabApiBaseUrl.trim() : "";
|
|
231
|
+
if (cfgRaw.length > 0) {
|
|
232
|
+
const o = parseGitlabInstanceOrigin(cfgRaw);
|
|
233
|
+
if (o) {
|
|
234
|
+
return { ok: true, origin: o };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const envRaw = (process.env.GITLAB_INSTANCE_URL ?? "").trim();
|
|
238
|
+
if (envRaw.length > 0) {
|
|
239
|
+
const o = parseGitlabInstanceOrigin(envRaw);
|
|
240
|
+
if (o) {
|
|
241
|
+
return { ok: true, origin: o };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return { ok: true, origin: "https://gitlab.com" };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function resolveGitlabPat(cfg: Record<string, unknown>, body: Record<string, unknown>): string {
|
|
248
|
+
const fromBody = typeof body.token === "string" ? body.token.trim() : "";
|
|
249
|
+
if (fromBody.length > 0) {
|
|
250
|
+
return fromBody;
|
|
251
|
+
}
|
|
252
|
+
const fromStore = readGitlabPatFromStore().trim();
|
|
253
|
+
if (fromStore.length > 0) {
|
|
254
|
+
return fromStore;
|
|
255
|
+
}
|
|
256
|
+
if (cfg.gitlabTokenFromEnv === true) {
|
|
257
|
+
return (process.env.GITLAB_TOKEN ?? "").trim();
|
|
258
|
+
}
|
|
259
|
+
return "";
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function readGitlabApiErrorMessage(res: Response): Promise<string> {
|
|
263
|
+
const raw = await res.text();
|
|
264
|
+
let message = res.statusText || `HTTP ${res.status}`;
|
|
265
|
+
const trimmed = raw.trim();
|
|
266
|
+
if (trimmed.length > 0) {
|
|
267
|
+
try {
|
|
268
|
+
const j = JSON.parse(trimmed) as { message?: unknown };
|
|
269
|
+
if (typeof j.message === "string" && j.message.trim()) {
|
|
270
|
+
message = j.message.trim();
|
|
271
|
+
} else {
|
|
272
|
+
message = trimmed.slice(0, 240);
|
|
273
|
+
}
|
|
274
|
+
} catch {
|
|
275
|
+
message = trimmed.slice(0, 240);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return message;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function isGitlabIssuesFetchTimeoutError(e: unknown): boolean {
|
|
282
|
+
if (e instanceof Error && e.name === "TimeoutError") {
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
if (typeof DOMException !== "undefined" && e instanceof DOMException && e.name === "TimeoutError") {
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
if (e instanceof Error && e.name === "AbortError") {
|
|
289
|
+
const m = e.message.toLowerCase();
|
|
290
|
+
return m.includes("timeout") || m.includes("timed out");
|
|
291
|
+
}
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function remoteIssuesGitlabTimeoutMessage(langFr: boolean): string {
|
|
296
|
+
const s = GITLAB_ISSUES_SEARCH_TIMEOUT_MS / 1000;
|
|
297
|
+
return langFr
|
|
298
|
+
? `Dépassement du délai (${s}s) lors de l’appel à l’API GitLab. Vérifiez l’URL d’instance, le réseau ou réessayez.`
|
|
299
|
+
: `Timed out (${s}s) calling the GitLab API. Check instance URL, network, or try again.`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function remoteIssuesFailureMessage(e: unknown, langFr: boolean): string {
|
|
303
|
+
if (isGitlabIssuesFetchTimeoutError(e)) {
|
|
304
|
+
return remoteIssuesGitlabTimeoutMessage(langFr);
|
|
305
|
+
}
|
|
306
|
+
return e instanceof Error ? e.message : String(e);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function gitlabAuthorizedGet(url: string, token: string, timeoutMs: number): Promise<Response> {
|
|
310
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
311
|
+
let res = await fetch(url, { headers: { "PRIVATE-TOKEN": token }, signal });
|
|
312
|
+
if (res.status === 401) {
|
|
313
|
+
await res.text().catch(() => undefined);
|
|
314
|
+
res = await fetch(url, { headers: { Authorization: `Bearer ${token}` }, signal });
|
|
315
|
+
}
|
|
316
|
+
return res;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const GITLAB_REMOTE_ISSUES_MAX = 30;
|
|
320
|
+
|
|
321
|
+
function compactGitlabIssueSearchInput(raw: string): string {
|
|
322
|
+
return raw.trim().replace(/\s+/g, "");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Seul un IID numérique : `GET /issues?search=` ne cherche que titre/description ;
|
|
327
|
+
* GitLab attend `iids[]` pour filtrer par numéro d’issue dans les projets accessibles.
|
|
328
|
+
*/
|
|
329
|
+
function parseGitlabIssuesNumericOnlyIid(searchTrim: string): number | null {
|
|
330
|
+
const compact = compactGitlabIssueSearchInput(searchTrim);
|
|
331
|
+
const m = /^#?(\d{1,10})$/.exec(compact);
|
|
332
|
+
if (!m) {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
const n = Number.parseInt(m[1], 10);
|
|
336
|
+
return Number.isFinite(n) && n >= 0 ? n : null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function dedupeKeyForGitlabIssueRow(o: Record<string, unknown>): string | null {
|
|
340
|
+
if (typeof o.id === "number" && Number.isFinite(o.id)) {
|
|
341
|
+
return `id:${o.id}`;
|
|
342
|
+
}
|
|
343
|
+
const pid = o.project_id;
|
|
344
|
+
const iid = o.iid;
|
|
345
|
+
if (typeof pid === "number" && Number.isFinite(pid) && typeof iid === "number" && Number.isFinite(iid)) {
|
|
346
|
+
return `p${pid}:i${iid}`;
|
|
347
|
+
}
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function mapGitlabIssueRowToRemote(o: Record<string, unknown>): {
|
|
352
|
+
title: string;
|
|
353
|
+
number: number | string;
|
|
354
|
+
source: string;
|
|
355
|
+
} {
|
|
356
|
+
const iid = o.iid ?? o.id;
|
|
357
|
+
const title = typeof o.title === "string" ? o.title : "";
|
|
358
|
+
const web = typeof o.web_url === "string" ? o.web_url : "";
|
|
359
|
+
const refs = asRecord(o.references);
|
|
360
|
+
const full = typeof refs?.full === "string" ? refs.full : "";
|
|
361
|
+
let source = "";
|
|
362
|
+
if (full.length > 0) {
|
|
363
|
+
source = full;
|
|
364
|
+
} else if (web.length > 0) {
|
|
365
|
+
source = web;
|
|
366
|
+
}
|
|
367
|
+
let num: number | string = "";
|
|
368
|
+
if (typeof iid === "number" && Number.isFinite(iid)) {
|
|
369
|
+
num = iid;
|
|
370
|
+
} else if (typeof iid === "string" && iid.trim().length > 0) {
|
|
371
|
+
num = iid.trim();
|
|
372
|
+
} else if (typeof o.id === "number" && Number.isFinite(o.id)) {
|
|
373
|
+
num = o.id as number;
|
|
374
|
+
}
|
|
375
|
+
return { title, number: num, source };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function mergeGitlabIssueRows(primary: unknown[], secondary: unknown[], max: number) {
|
|
379
|
+
const seen = new Set<string>();
|
|
380
|
+
const out: ReturnType<typeof mapGitlabIssueRowToRemote>[] = [];
|
|
381
|
+
const consider = (rows: unknown[]) => {
|
|
382
|
+
if (!Array.isArray(rows)) {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
for (const item of rows) {
|
|
386
|
+
if (!item || typeof item !== "object") {
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
const o = item as Record<string, unknown>;
|
|
390
|
+
const k = dedupeKeyForGitlabIssueRow(o);
|
|
391
|
+
if (k) {
|
|
392
|
+
if (seen.has(k)) {
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
seen.add(k);
|
|
396
|
+
}
|
|
397
|
+
out.push(mapGitlabIssueRowToRemote(o));
|
|
398
|
+
if (out.length >= max) {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
consider(primary);
|
|
404
|
+
consider(secondary);
|
|
405
|
+
return out;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function gitlabGetJsonArray(
|
|
409
|
+
urlStr: string,
|
|
410
|
+
token: string,
|
|
411
|
+
timeoutMs: number,
|
|
412
|
+
langFr: boolean,
|
|
413
|
+
): Promise<{ ok: true; rows: unknown[] } | { ok: false; message: string }> {
|
|
414
|
+
try {
|
|
415
|
+
const res = await gitlabAuthorizedGet(urlStr, token, timeoutMs);
|
|
416
|
+
if (!res.ok) {
|
|
417
|
+
const message = await readGitlabApiErrorMessage(res);
|
|
418
|
+
return { ok: false, message };
|
|
419
|
+
}
|
|
420
|
+
const data: unknown = await res.json();
|
|
421
|
+
return { ok: true, rows: Array.isArray(data) ? data : [] };
|
|
422
|
+
} catch (e) {
|
|
423
|
+
return { ok: false, message: remoteIssuesFailureMessage(e, langFr) };
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function dispatchFetchRemoteIssues(
|
|
428
|
+
p: KronosysUpdatePayload,
|
|
429
|
+
body: Record<string, unknown>,
|
|
430
|
+
): Promise<ActionResult> {
|
|
431
|
+
const cfg = { ...defaultKronosysCfg(), ...(asRecord(p.cfg) ?? {}) };
|
|
432
|
+
const langFr = body.lang === "fr";
|
|
433
|
+
const token = resolveGitlabPat(cfg, body);
|
|
434
|
+
if (!token) {
|
|
435
|
+
const hadStoredFlag = cfg.gitlabTokenStored === true;
|
|
436
|
+
const msg = hadStoredFlag
|
|
437
|
+
? langFr
|
|
438
|
+
? "Ce tableau de bord n’a pas de copie locale du jeton (un clic sur « Enregistrer le jeton » avec le champ vide l’efface). Collez le jeton, enregistrez avec le champ rempli, puis « Tester la connexion ». Sinon définissez GITLAB_TOKEN sur le processus serveur."
|
|
439
|
+
: "This dashboard has no local copy of the token (clicking Save token with an empty field clears it). Paste your token, save with a non-empty field, then Test connection. Or set GITLAB_TOKEN on the server process."
|
|
440
|
+
: langFr
|
|
441
|
+
? "Aucun jeton GitLab disponible pour ce serveur : enregistrez le jeton dans les paramètres, ou définissez GITLAB_TOKEN si vous utilisez le jeton d’environnement."
|
|
442
|
+
: "No GitLab token available to this server: save your token in Settings, or set GITLAB_TOKEN when using the environment token.";
|
|
443
|
+
return {
|
|
444
|
+
ok: true,
|
|
445
|
+
result: {
|
|
446
|
+
remoteIssues: [],
|
|
447
|
+
remoteIssuesError: msg,
|
|
448
|
+
},
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
const resolved = resolveGitlabApiOrigin(cfg, body);
|
|
452
|
+
if (!resolved.ok) {
|
|
453
|
+
return {
|
|
454
|
+
ok: true,
|
|
455
|
+
result: {
|
|
456
|
+
remoteIssues: [],
|
|
457
|
+
remoteIssuesError: langFr ? "URL d’instance GitLab invalide." : "Invalid GitLab instance URL.",
|
|
458
|
+
},
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
const searchTrim = typeof body.search === "string" ? body.search.trim() : "";
|
|
462
|
+
if (searchTrim.length === 0) {
|
|
463
|
+
return { ok: true, result: { remoteIssues: [] } };
|
|
464
|
+
}
|
|
465
|
+
const origin = resolved.origin;
|
|
466
|
+
const timeout = GITLAB_ISSUES_SEARCH_TIMEOUT_MS;
|
|
467
|
+
const iidOnly = parseGitlabIssuesNumericOnlyIid(searchTrim);
|
|
468
|
+
|
|
469
|
+
try {
|
|
470
|
+
if (iidOnly !== null) {
|
|
471
|
+
const listUrl = new URL(`${origin}/api/v4/issues`);
|
|
472
|
+
listUrl.searchParams.set("state", "all");
|
|
473
|
+
listUrl.searchParams.set("scope", "all");
|
|
474
|
+
listUrl.searchParams.set("per_page", String(GITLAB_REMOTE_ISSUES_MAX));
|
|
475
|
+
listUrl.searchParams.set("order_by", "updated_at");
|
|
476
|
+
listUrl.searchParams.set("sort", "desc");
|
|
477
|
+
listUrl.searchParams.append("iids[]", String(iidOnly));
|
|
478
|
+
const first = await gitlabGetJsonArray(listUrl.toString(), token, timeout, langFr);
|
|
479
|
+
if (!first.ok) {
|
|
480
|
+
return { ok: true, result: { remoteIssues: [], remoteIssuesError: first.message } };
|
|
481
|
+
}
|
|
482
|
+
const remoteIssues = mergeGitlabIssueRows(first.rows, [], GITLAB_REMOTE_ISSUES_MAX);
|
|
483
|
+
return { ok: true, result: { remoteIssues } };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const issuesUrl = new URL(`${origin}/api/v4/issues`);
|
|
487
|
+
issuesUrl.searchParams.set("state", "all");
|
|
488
|
+
issuesUrl.searchParams.set("scope", "all");
|
|
489
|
+
issuesUrl.searchParams.set("per_page", String(GITLAB_REMOTE_ISSUES_MAX));
|
|
490
|
+
issuesUrl.searchParams.set("order_by", "updated_at");
|
|
491
|
+
issuesUrl.searchParams.set("sort", "desc");
|
|
492
|
+
issuesUrl.searchParams.set("search", searchTrim);
|
|
493
|
+
|
|
494
|
+
const searchUrl = new URL(`${origin}/api/v4/search`);
|
|
495
|
+
searchUrl.searchParams.set("scope", "issues");
|
|
496
|
+
searchUrl.searchParams.set("search", searchTrim);
|
|
497
|
+
searchUrl.searchParams.set("per_page", String(Math.min(20, GITLAB_REMOTE_ISSUES_MAX)));
|
|
498
|
+
|
|
499
|
+
const [issuesRes, searchRes] = await Promise.all([
|
|
500
|
+
gitlabGetJsonArray(issuesUrl.toString(), token, timeout, langFr),
|
|
501
|
+
gitlabGetJsonArray(searchUrl.toString(), token, timeout, langFr),
|
|
502
|
+
]);
|
|
503
|
+
|
|
504
|
+
if (!issuesRes.ok && !searchRes.ok) {
|
|
505
|
+
return {
|
|
506
|
+
ok: true,
|
|
507
|
+
result: { remoteIssues: [], remoteIssuesError: issuesRes.message },
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const rowsIssues = issuesRes.ok ? issuesRes.rows : [];
|
|
512
|
+
const rowsSearch = searchRes.ok ? searchRes.rows : [];
|
|
513
|
+
const remoteIssues = mergeGitlabIssueRows(rowsIssues, rowsSearch, GITLAB_REMOTE_ISSUES_MAX);
|
|
514
|
+
return { ok: true, result: { remoteIssues } };
|
|
515
|
+
} catch (e) {
|
|
516
|
+
const message = remoteIssuesFailureMessage(e, langFr);
|
|
517
|
+
return { ok: true, result: { remoteIssues: [], remoteIssuesError: message } };
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function dispatchGitlabConnectionTest(
|
|
522
|
+
p: KronosysUpdatePayload,
|
|
523
|
+
body: Record<string, unknown>,
|
|
524
|
+
): Promise<ActionResult> {
|
|
525
|
+
const cfg = { ...defaultKronosysCfg(), ...(asRecord(p.cfg) ?? {}) };
|
|
526
|
+
const token = resolveGitlabPat(cfg, body);
|
|
527
|
+
if (!token) {
|
|
528
|
+
mergeCfg(p, { gitlabApiVerified: false });
|
|
529
|
+
writePayload(p);
|
|
530
|
+
return {
|
|
531
|
+
ok: true,
|
|
532
|
+
result: { gitlabConnectionTest: { outcome: "failed" as const, reason: "no_token" as const } },
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
const resolved = resolveGitlabApiOrigin(cfg, body);
|
|
536
|
+
if (!resolved.ok) {
|
|
537
|
+
const msg =
|
|
538
|
+
body.lang === "fr"
|
|
539
|
+
? "URL d’instance GitLab invalide (ex. https://gitlab.com ou https://gitlab.entreprise.com)."
|
|
540
|
+
: "Invalid GitLab instance URL (e.g. https://gitlab.com or https://gitlab.company.com).";
|
|
541
|
+
mergeCfg(p, { gitlabApiVerified: false });
|
|
542
|
+
writePayload(p);
|
|
543
|
+
return {
|
|
544
|
+
ok: true,
|
|
545
|
+
result: { gitlabConnectionTest: { outcome: "failed" as const, message: msg } },
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
const { origin } = resolved;
|
|
549
|
+
const url = `${origin}/api/v4/user`;
|
|
550
|
+
|
|
551
|
+
try {
|
|
552
|
+
const res = await gitlabAuthorizedGet(url, token, 15_000);
|
|
553
|
+
if (!res.ok) {
|
|
554
|
+
const message = await readGitlabApiErrorMessage(res);
|
|
555
|
+
mergeCfg(p, { gitlabApiVerified: false });
|
|
556
|
+
writePayload(p);
|
|
557
|
+
return {
|
|
558
|
+
ok: true,
|
|
559
|
+
result: { gitlabConnectionTest: { outcome: "failed" as const, message } },
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
mergeCfg(p, { gitlabApiVerified: true });
|
|
563
|
+
writePayload(p);
|
|
564
|
+
return { ok: true, result: { gitlabConnectionTest: { outcome: "connected" as const } } };
|
|
565
|
+
} catch (e) {
|
|
566
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
567
|
+
mergeCfg(p, { gitlabApiVerified: false });
|
|
568
|
+
writePayload(p);
|
|
569
|
+
return {
|
|
570
|
+
ok: true,
|
|
571
|
+
result: { gitlabConnectionTest: { outcome: "failed" as const, message } },
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function ensureKronoFocus(cur: Record<string, unknown>): Record<string, unknown> {
|
|
577
|
+
let pm = asRecord(cur.kronoFocus);
|
|
578
|
+
if (!pm) {
|
|
579
|
+
pm = {
|
|
580
|
+
mode: "work",
|
|
581
|
+
status: "idle",
|
|
582
|
+
timeLeftSeconds: 25 * 60,
|
|
583
|
+
sessionsCompleted: 0,
|
|
584
|
+
workDurationSeconds: 25 * 60,
|
|
585
|
+
shortBreakDurationSeconds: 5 * 60,
|
|
586
|
+
longBreakDurationSeconds: 15 * 60,
|
|
587
|
+
};
|
|
588
|
+
cur.kronoFocus = pm;
|
|
589
|
+
}
|
|
590
|
+
return pm;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function kronoFocusWorkSeconds(cur: Record<string, unknown>): number {
|
|
594
|
+
return readWorkDurationSeconds(asRecord(cur.kronoFocus));
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function newTaskRecord(
|
|
598
|
+
input: {
|
|
599
|
+
name: string;
|
|
600
|
+
tags?: string[];
|
|
601
|
+
project?: string | null;
|
|
602
|
+
},
|
|
603
|
+
tagNormOpts: TaskTagsStorageNormalizeOpts
|
|
604
|
+
): Record<string, unknown> {
|
|
605
|
+
const now = new Date().toISOString();
|
|
606
|
+
return {
|
|
607
|
+
id: randomUUID(),
|
|
608
|
+
name: input.name.trim(),
|
|
609
|
+
startTime: now,
|
|
610
|
+
durationMs: 0,
|
|
611
|
+
isDone: false,
|
|
612
|
+
kronoFocusCycles: 0,
|
|
613
|
+
tags: normalizeTaskTagsForStorage(input.tags, tagNormOpts),
|
|
614
|
+
project: input.project ?? null,
|
|
615
|
+
subtasks: [],
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
export async function dispatchKronosysAction(body: Record<string, unknown>): Promise<ActionResult> {
|
|
620
|
+
const type = typeof body.type === "string" ? body.type : "";
|
|
621
|
+
const p = readPayload();
|
|
622
|
+
const tagNormOpts: TaskTagsStorageNormalizeOpts = {
|
|
623
|
+
assignDefaultTagBucket: readTaskDefaultTagBucketEnabled(p.cfg),
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
const finish = (): ActionResult => {
|
|
627
|
+
writePayload(p);
|
|
628
|
+
return { ok: true, result: {} };
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
switch (type) {
|
|
632
|
+
case "updateKronosysSettings": {
|
|
633
|
+
const s = asRecord(body.settings);
|
|
634
|
+
if (s) {
|
|
635
|
+
const merged = { ...s };
|
|
636
|
+
if (typeof merged.gitlabApiBaseUrl === "string") {
|
|
637
|
+
const parsed = parseGitlabInstanceOrigin(merged.gitlabApiBaseUrl);
|
|
638
|
+
merged.gitlabApiBaseUrl = parsed ?? "";
|
|
639
|
+
}
|
|
640
|
+
tryPersistDevDataPreferenceFromCfg(merged);
|
|
641
|
+
if (typeof merged.dashboardDisplayTimeZone === "string") {
|
|
642
|
+
const z = merged.dashboardDisplayTimeZone.trim();
|
|
643
|
+
merged.dashboardDisplayTimeZone = isValidIanaTimeZone(z) ? z : DEFAULT_DASHBOARD_TIME_ZONE;
|
|
644
|
+
}
|
|
645
|
+
if ("dashboardUse24HourClock" in merged) {
|
|
646
|
+
merged.dashboardUse24HourClock = merged.dashboardUse24HourClock !== false;
|
|
647
|
+
}
|
|
648
|
+
p.cfg = { ...defaultKronosysCfg(), ...(p.cfg as Record<string, unknown>), ...merged };
|
|
649
|
+
}
|
|
650
|
+
return finish();
|
|
651
|
+
}
|
|
652
|
+
case "resetKronosysSettings": {
|
|
653
|
+
resetCfgToDefaults(p);
|
|
654
|
+
tryPersistDevDataPreferenceFromCfg({ developmentUseProductionData: false });
|
|
655
|
+
return finish();
|
|
656
|
+
}
|
|
657
|
+
case "setGitIdentity": {
|
|
658
|
+
const name = typeof body.gitUserName === "string" ? body.gitUserName.trim() : "";
|
|
659
|
+
const email = typeof body.gitUserEmail === "string" ? body.gitUserEmail.trim() : "";
|
|
660
|
+
const login = typeof body.gitAccountLogin === "string" ? body.gitAccountLogin.trim() : "";
|
|
661
|
+
p.gitIdentity = {
|
|
662
|
+
gitUserName: name.length > 0 ? name : null,
|
|
663
|
+
gitUserEmail: email.length > 0 ? email : null,
|
|
664
|
+
gitAccountLogin: login.length > 0 ? login : null,
|
|
665
|
+
};
|
|
666
|
+
return finish();
|
|
667
|
+
}
|
|
668
|
+
case "setLanguage": {
|
|
669
|
+
const cur = ensureLive(p);
|
|
670
|
+
cur.language = body.lang === "fr" ? "fr" : "en";
|
|
671
|
+
syncLiveIntoHistory(p);
|
|
672
|
+
return finish();
|
|
673
|
+
}
|
|
674
|
+
case "inspectSession": {
|
|
675
|
+
p.inspectingSessionId = body.sessionId === null || body.sessionId === undefined ? null : String(body.sessionId);
|
|
676
|
+
return finish();
|
|
677
|
+
}
|
|
678
|
+
case "setPaused": {
|
|
679
|
+
const cur = ensureLive(p);
|
|
680
|
+
if (body.paused === true) {
|
|
681
|
+
flushSessionWallSegmentOnLive(cur);
|
|
682
|
+
cur.isPaused = true;
|
|
683
|
+
} else {
|
|
684
|
+
cur.isPaused = false;
|
|
685
|
+
ensureSessionWallSegmentOnLive(cur);
|
|
686
|
+
}
|
|
687
|
+
syncLiveIntoHistory(p);
|
|
688
|
+
return finish();
|
|
689
|
+
}
|
|
690
|
+
case "setSessionName": {
|
|
691
|
+
const name = typeof body.name === "string" ? body.name : "";
|
|
692
|
+
const sid = typeof body.sessionId === "string" ? body.sessionId : undefined;
|
|
693
|
+
const cur = asRecord(p.current);
|
|
694
|
+
if (sid && cur?.sessionId === sid) {
|
|
695
|
+
cur.sessionName = name;
|
|
696
|
+
} else if (!sid && cur) {
|
|
697
|
+
cur.sessionName = name;
|
|
698
|
+
}
|
|
699
|
+
const hist = (p.history || []) as Record<string, unknown>[];
|
|
700
|
+
const id = sid ?? (typeof cur?.sessionId === "string" ? cur.sessionId : "");
|
|
701
|
+
const row = hist.find((h) => h.sessionId === id);
|
|
702
|
+
if (row) {
|
|
703
|
+
row.sessionName = name;
|
|
704
|
+
}
|
|
705
|
+
const arch = (p.historyArchived || []) as Record<string, unknown>[];
|
|
706
|
+
const ar = arch.find((h) => h.sessionId === id);
|
|
707
|
+
if (ar) {
|
|
708
|
+
ar.sessionName = name;
|
|
709
|
+
}
|
|
710
|
+
syncLiveIntoHistory(p);
|
|
711
|
+
return finish();
|
|
712
|
+
}
|
|
713
|
+
case "setSessionStartTime": {
|
|
714
|
+
const sidFromBody = typeof body.sessionId === "string" ? body.sessionId.trim() : "";
|
|
715
|
+
const isoRaw = typeof body.startAt === "string" ? body.startAt.trim() : "";
|
|
716
|
+
const startMs = Date.parse(isoRaw);
|
|
717
|
+
if (!Number.isFinite(startMs)) {
|
|
718
|
+
return finish();
|
|
719
|
+
}
|
|
720
|
+
const nowMs = Date.now();
|
|
721
|
+
const startAtIso = new Date(startMs).toISOString();
|
|
722
|
+
const cur = asRecord(p.current);
|
|
723
|
+
const id =
|
|
724
|
+
sidFromBody || (typeof cur?.sessionId === "string" ? String(cur.sessionId).trim() : "");
|
|
725
|
+
if (!id) {
|
|
726
|
+
return finish();
|
|
727
|
+
}
|
|
728
|
+
const applyToRow = (row: Record<string, unknown> | undefined): void => {
|
|
729
|
+
if (!row) {
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
row.startAt = startAtIso;
|
|
733
|
+
const endRaw = typeof row.endAt === "string" ? row.endAt.trim() : "";
|
|
734
|
+
const endMs = Date.parse(endRaw);
|
|
735
|
+
if (Number.isFinite(endMs) && endMs >= startMs) {
|
|
736
|
+
row.sessionDurationMinutes = (endMs - startMs) / 60000;
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
row.sessionDurationMinutes = Math.max(0, (nowMs - startMs) / 60000);
|
|
740
|
+
if (row === cur && row.isPaused !== true && row.archived !== true) {
|
|
741
|
+
row[SESSION_WALL_SEGMENT_STARTED_AT] = new Date(nowMs).toISOString();
|
|
742
|
+
}
|
|
743
|
+
};
|
|
744
|
+
if (cur?.sessionId === id) {
|
|
745
|
+
applyToRow(cur);
|
|
746
|
+
}
|
|
747
|
+
const hist = (p.history || []) as Record<string, unknown>[];
|
|
748
|
+
applyToRow(hist.find((h) => h.sessionId === id));
|
|
749
|
+
const arch = (p.historyArchived || []) as Record<string, unknown>[];
|
|
750
|
+
applyToRow(arch.find((h) => h.sessionId === id));
|
|
751
|
+
syncLiveIntoHistory(p);
|
|
752
|
+
return finish();
|
|
753
|
+
}
|
|
754
|
+
case "setSessionEndTime": {
|
|
755
|
+
const sidFromBody = typeof body.sessionId === "string" ? body.sessionId.trim() : "";
|
|
756
|
+
const isoRaw = typeof body.endAt === "string" ? body.endAt.trim() : "";
|
|
757
|
+
const endMs = Date.parse(isoRaw);
|
|
758
|
+
if (!Number.isFinite(endMs)) {
|
|
759
|
+
return finish();
|
|
760
|
+
}
|
|
761
|
+
const endAtIso = new Date(endMs).toISOString();
|
|
762
|
+
const cur = asRecord(p.current);
|
|
763
|
+
const id =
|
|
764
|
+
sidFromBody || (typeof cur?.sessionId === "string" ? String(cur.sessionId).trim() : "");
|
|
765
|
+
if (!id) {
|
|
766
|
+
return finish();
|
|
767
|
+
}
|
|
768
|
+
const applyToRow = (row: Record<string, unknown> | undefined): void => {
|
|
769
|
+
if (!row) {
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
const prevEnd = typeof row.endAt === "string" ? row.endAt.trim() : "";
|
|
773
|
+
if (!prevEnd) {
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
const startRaw = typeof row.startAt === "string" ? row.startAt.trim() : "";
|
|
777
|
+
const startMs = Date.parse(startRaw);
|
|
778
|
+
if (!Number.isFinite(startMs) || endMs < startMs) {
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
row.endAt = endAtIso;
|
|
782
|
+
row.sessionDurationMinutes = (endMs - startMs) / 60000;
|
|
783
|
+
};
|
|
784
|
+
if (cur?.sessionId === id) {
|
|
785
|
+
applyToRow(cur);
|
|
786
|
+
}
|
|
787
|
+
const hist = (p.history || []) as Record<string, unknown>[];
|
|
788
|
+
applyToRow(hist.find((h) => h.sessionId === id));
|
|
789
|
+
const arch = (p.historyArchived || []) as Record<string, unknown>[];
|
|
790
|
+
applyToRow(arch.find((h) => h.sessionId === id));
|
|
791
|
+
syncLiveIntoHistory(p);
|
|
792
|
+
return finish();
|
|
793
|
+
}
|
|
794
|
+
case "setSessionEndReason": {
|
|
795
|
+
const sidFromBody = typeof body.sessionId === "string" ? body.sessionId.trim() : "";
|
|
796
|
+
const cur = asRecord(p.current);
|
|
797
|
+
const id =
|
|
798
|
+
sidFromBody || (typeof cur?.sessionId === "string" ? String(cur.sessionId).trim() : "");
|
|
799
|
+
if (!id) {
|
|
800
|
+
return finish();
|
|
801
|
+
}
|
|
802
|
+
const rk = normalizeSessionEndReasonKind(body.sessionEndReasonKind);
|
|
803
|
+
const rn = normalizeSessionEndReasonNote(body.sessionEndReasonNote);
|
|
804
|
+
const applyToRow = (row: Record<string, unknown> | undefined): void => {
|
|
805
|
+
if (!row) {
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
if (rk) {
|
|
809
|
+
row.sessionEndReasonKind = rk;
|
|
810
|
+
} else {
|
|
811
|
+
delete row.sessionEndReasonKind;
|
|
812
|
+
}
|
|
813
|
+
if (rn.length > 0) {
|
|
814
|
+
row.sessionEndReasonNote = rn;
|
|
815
|
+
} else {
|
|
816
|
+
delete row.sessionEndReasonNote;
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
if (cur?.sessionId === id) {
|
|
820
|
+
applyToRow(cur);
|
|
821
|
+
}
|
|
822
|
+
const hist = (p.history || []) as Record<string, unknown>[];
|
|
823
|
+
applyToRow(hist.find((h) => h.sessionId === id));
|
|
824
|
+
const arch = (p.historyArchived || []) as Record<string, unknown>[];
|
|
825
|
+
applyToRow(arch.find((h) => h.sessionId === id));
|
|
826
|
+
syncLiveIntoHistory(p);
|
|
827
|
+
return finish();
|
|
828
|
+
}
|
|
829
|
+
case "newSession": {
|
|
830
|
+
const id = randomUUID();
|
|
831
|
+
const startMs = Date.now();
|
|
832
|
+
const now = new Date(startMs).toISOString();
|
|
833
|
+
const prevCur = asRecord(p.current);
|
|
834
|
+
const prevLang = typeof prevCur?.language === "string" ? String(prevCur.language) : "en";
|
|
835
|
+
const bodyRec = body as Record<string, unknown>;
|
|
836
|
+
const schedIn = bodyRec.scheduledStartAt;
|
|
837
|
+
const ass: SessionStartAssiduity | undefined =
|
|
838
|
+
typeof schedIn === "string" && schedIn.trim() !== ""
|
|
839
|
+
? (assiduityFromScheduledStart(startMs, schedIn) ?? undefined)
|
|
840
|
+
: undefined;
|
|
841
|
+
p.current = {
|
|
842
|
+
sessionId: id,
|
|
843
|
+
sessionName: "",
|
|
844
|
+
archived: false,
|
|
845
|
+
isPaused: false,
|
|
846
|
+
savedAt: now,
|
|
847
|
+
createdAt: now,
|
|
848
|
+
startAt: now,
|
|
849
|
+
endAt: null,
|
|
850
|
+
...(ass
|
|
851
|
+
? { scheduledStartAt: ass.scheduledStartAt, sessionStartOffsetMinutes: ass.sessionStartOffsetMinutes }
|
|
852
|
+
: {}),
|
|
853
|
+
[SESSION_WALL_SEGMENT_STARTED_AT]: now,
|
|
854
|
+
sessionDurationMinutes: 0,
|
|
855
|
+
codingMinutesSession: 0,
|
|
856
|
+
activeMinutes: 0,
|
|
857
|
+
totalEvents: 0,
|
|
858
|
+
language: prevLang,
|
|
859
|
+
tasks: [],
|
|
860
|
+
activeTasks: [],
|
|
861
|
+
activeTask: null,
|
|
862
|
+
sessionScope: body.sessionScope,
|
|
863
|
+
kronoFocus: {
|
|
864
|
+
mode: "work",
|
|
865
|
+
status: "idle",
|
|
866
|
+
timeLeftSeconds: 25 * 60,
|
|
867
|
+
sessionsCompleted: 0,
|
|
868
|
+
workDurationSeconds: 25 * 60,
|
|
869
|
+
shortBreakDurationSeconds: 5 * 60,
|
|
870
|
+
longBreakDurationSeconds: 15 * 60,
|
|
871
|
+
},
|
|
872
|
+
};
|
|
873
|
+
syncLiveIntoHistory(p);
|
|
874
|
+
return finish();
|
|
875
|
+
}
|
|
876
|
+
case "startTask": {
|
|
877
|
+
const cur = ensureLive(p);
|
|
878
|
+
const name = typeof body.name === "string" ? body.name : "";
|
|
879
|
+
const tags = normalizeTaskTagsForStorage(
|
|
880
|
+
Array.isArray(body.tags) ? (body.tags as string[]) : [],
|
|
881
|
+
tagNormOpts
|
|
882
|
+
);
|
|
883
|
+
const project = typeof body.project === "string" ? body.project : undefined;
|
|
884
|
+
mergeDiscoveredTagsIntoPayloadKnownTags(p, mergeTagsForDisplay(name, tags));
|
|
885
|
+
mergeDiscoveredProjectIntoPayloadKnownProjects(p, project);
|
|
886
|
+
mergeDiscoveredProjectIntoPayloadKnownProjects(p, parseTaskWithAutoTags(name).project);
|
|
887
|
+
const task = newTaskRecord({ name, tags, project }, tagNormOpts);
|
|
888
|
+
const active = Array.isArray(cur.activeTasks) ? ([...cur.activeTasks] as Record<string, unknown>[]) : [];
|
|
889
|
+
active.push(task);
|
|
890
|
+
cur.activeTasks = active;
|
|
891
|
+
cur.activeTask = task;
|
|
892
|
+
prepareSessionForExclusiveMainTimerUnpaused(cur, String(task.id));
|
|
893
|
+
const bodyRec = body as Record<string, unknown>;
|
|
894
|
+
if (body.startKronoFocus === true || bodyRec[LEGACY_START_TASK_WITH_TIMER_BODY_KEY] === true) {
|
|
895
|
+
const pm = ensureKronoFocus(cur);
|
|
896
|
+
const ws = kronoFocusWorkSeconds(cur);
|
|
897
|
+
pm.mode = "work";
|
|
898
|
+
pm.status = "running";
|
|
899
|
+
pm.timeLeftSeconds = ws;
|
|
900
|
+
pm.kronoFocusDeadlineAtMs = Date.now() + ws * 1000;
|
|
901
|
+
pm.linkedTaskId = String(task.id);
|
|
902
|
+
pm.linkedTaskName = typeof task.name === "string" ? task.name : "";
|
|
903
|
+
}
|
|
904
|
+
syncLiveIntoHistory(p);
|
|
905
|
+
return finish();
|
|
906
|
+
}
|
|
907
|
+
case "deleteHistorySession": {
|
|
908
|
+
const sessionId = String(body.sessionId ?? "");
|
|
909
|
+
p.history = ((p.history || []) as Record<string, unknown>[]).filter((h) => h.sessionId !== sessionId);
|
|
910
|
+
p.historyArchived = ((p.historyArchived || []) as Record<string, unknown>[]).filter(
|
|
911
|
+
(h) => h.sessionId !== sessionId
|
|
912
|
+
);
|
|
913
|
+
const cur = asRecord(p.current);
|
|
914
|
+
if (cur?.sessionId === sessionId) {
|
|
915
|
+
p.current = undefined;
|
|
916
|
+
}
|
|
917
|
+
return finish();
|
|
918
|
+
}
|
|
919
|
+
case "archiveSession": {
|
|
920
|
+
const sessionId = String(body.sessionId ?? "");
|
|
921
|
+
const archived = body.archived === true;
|
|
922
|
+
const histOrig = (p.history || []) as Record<string, unknown>[];
|
|
923
|
+
const archOrig = (p.historyArchived || []) as Record<string, unknown>[];
|
|
924
|
+
const found =
|
|
925
|
+
histOrig.find((h) => h.sessionId === sessionId) || archOrig.find((h) => h.sessionId === sessionId);
|
|
926
|
+
if (!found) {
|
|
927
|
+
return finish();
|
|
928
|
+
}
|
|
929
|
+
const hist = histOrig.filter((h) => h.sessionId !== sessionId);
|
|
930
|
+
const arch = archOrig.filter((h) => h.sessionId !== sessionId);
|
|
931
|
+
const row = { ...found, archived };
|
|
932
|
+
if (archived) {
|
|
933
|
+
arch.unshift(row);
|
|
934
|
+
} else {
|
|
935
|
+
hist.unshift(row);
|
|
936
|
+
}
|
|
937
|
+
p.history = hist;
|
|
938
|
+
p.historyArchived = arch;
|
|
939
|
+
const cur = asRecord(p.current);
|
|
940
|
+
if (cur?.sessionId === sessionId) {
|
|
941
|
+
cur.archived = archived;
|
|
942
|
+
}
|
|
943
|
+
syncLiveIntoHistory(p);
|
|
944
|
+
return finish();
|
|
945
|
+
}
|
|
946
|
+
case "clearHistory": {
|
|
947
|
+
p.history = [];
|
|
948
|
+
p.historyArchived = [];
|
|
949
|
+
p.current = undefined;
|
|
950
|
+
p.inspectingSessionId = null;
|
|
951
|
+
p.knownTags = [];
|
|
952
|
+
p.knownProjects = [];
|
|
953
|
+
p.userKnownTags = [];
|
|
954
|
+
p.excludedSuggestionTags = [];
|
|
955
|
+
p.tagDescriptions = {};
|
|
956
|
+
p.projectDescriptions = {};
|
|
957
|
+
p.gitIdentity = {};
|
|
958
|
+
delete p.gitStats;
|
|
959
|
+
delete p.dismissArchiveSessionConfirm;
|
|
960
|
+
delete p.workspaceCodeSnapshot;
|
|
961
|
+
mergeCfg(p, { showWelcomeOnStartup: true });
|
|
962
|
+
return finish();
|
|
963
|
+
}
|
|
964
|
+
case "endLiveSession": {
|
|
965
|
+
const cur = asRecord(p.current);
|
|
966
|
+
if (cur) {
|
|
967
|
+
flushSessionWallSegmentOnLive(cur);
|
|
968
|
+
cur.endAt = new Date().toISOString();
|
|
969
|
+
const rk = normalizeSessionEndReasonKind(body.sessionEndReasonKind);
|
|
970
|
+
if (rk) {
|
|
971
|
+
cur.sessionEndReasonKind = rk;
|
|
972
|
+
} else {
|
|
973
|
+
delete cur.sessionEndReasonKind;
|
|
974
|
+
}
|
|
975
|
+
const rn = normalizeSessionEndReasonNote(body.sessionEndReasonNote);
|
|
976
|
+
if (rn.length > 0) {
|
|
977
|
+
cur.sessionEndReasonNote = rn;
|
|
978
|
+
} else {
|
|
979
|
+
delete cur.sessionEndReasonNote;
|
|
980
|
+
}
|
|
981
|
+
syncLiveIntoHistory(p);
|
|
982
|
+
}
|
|
983
|
+
p.current = undefined;
|
|
984
|
+
return finish();
|
|
985
|
+
}
|
|
986
|
+
case "updateTask": {
|
|
987
|
+
const ctx = resolveTaskSession(p, body.sessionId);
|
|
988
|
+
if (!ctx) {
|
|
989
|
+
return finish();
|
|
990
|
+
}
|
|
991
|
+
const taskId = String(body.taskId ?? "");
|
|
992
|
+
const projectPatch =
|
|
993
|
+
body.project === null
|
|
994
|
+
? (null as string | null)
|
|
995
|
+
: typeof body.project === "string"
|
|
996
|
+
? body.project
|
|
997
|
+
: undefined;
|
|
998
|
+
if (
|
|
999
|
+
!updateTaskInSession(
|
|
1000
|
+
ctx.session,
|
|
1001
|
+
taskId,
|
|
1002
|
+
{
|
|
1003
|
+
name: typeof body.name === "string" ? body.name : undefined,
|
|
1004
|
+
tags: Array.isArray(body.tags) ? (body.tags as string[]) : undefined,
|
|
1005
|
+
project: projectPatch,
|
|
1006
|
+
},
|
|
1007
|
+
tagNormOpts
|
|
1008
|
+
)
|
|
1009
|
+
) {
|
|
1010
|
+
return finish();
|
|
1011
|
+
}
|
|
1012
|
+
if (Array.isArray(body.tags)) {
|
|
1013
|
+
mergeDiscoveredTagsIntoPayloadKnownTags(p, body.tags as string[]);
|
|
1014
|
+
} else if (typeof body.name === "string") {
|
|
1015
|
+
mergeDiscoveredTagsIntoPayloadKnownTags(p, mergeTagsForDisplay(body.name, []));
|
|
1016
|
+
}
|
|
1017
|
+
if (typeof projectPatch === "string") {
|
|
1018
|
+
mergeDiscoveredProjectIntoPayloadKnownProjects(p, projectPatch);
|
|
1019
|
+
} else if (typeof body.name === "string") {
|
|
1020
|
+
mergeDiscoveredProjectIntoPayloadKnownProjects(p, parseTaskWithAutoTags(body.name).project);
|
|
1021
|
+
}
|
|
1022
|
+
if (ctx.isLive) {
|
|
1023
|
+
syncLiveIntoHistory(p);
|
|
1024
|
+
}
|
|
1025
|
+
return finish();
|
|
1026
|
+
}
|
|
1027
|
+
case "setTaskStartTime": {
|
|
1028
|
+
const ctx = resolveTaskSession(p, body.sessionId);
|
|
1029
|
+
if (!ctx) {
|
|
1030
|
+
return finish();
|
|
1031
|
+
}
|
|
1032
|
+
const taskId = String(body.taskId ?? "");
|
|
1033
|
+
const startTime = typeof body.startTime === "string" ? body.startTime.trim() : "";
|
|
1034
|
+
if (!taskId || !startTime) {
|
|
1035
|
+
return finish();
|
|
1036
|
+
}
|
|
1037
|
+
const ok = updateTaskStartTimeInSession(ctx.session, taskId, startTime);
|
|
1038
|
+
if (ok && ctx.isLive) {
|
|
1039
|
+
syncLiveIntoHistory(p);
|
|
1040
|
+
}
|
|
1041
|
+
return finish();
|
|
1042
|
+
}
|
|
1043
|
+
case "setTaskEndTime": {
|
|
1044
|
+
const ctx = resolveTaskSession(p, body.sessionId);
|
|
1045
|
+
if (!ctx) {
|
|
1046
|
+
return finish();
|
|
1047
|
+
}
|
|
1048
|
+
const taskId = String(body.taskId ?? "");
|
|
1049
|
+
const endTime = typeof body.endTime === "string" ? body.endTime.trim() : "";
|
|
1050
|
+
if (!taskId || !endTime) {
|
|
1051
|
+
return finish();
|
|
1052
|
+
}
|
|
1053
|
+
const ok = updateTaskEndTimeInSession(ctx.session, taskId, endTime);
|
|
1054
|
+
if (ok && ctx.isLive) {
|
|
1055
|
+
syncLiveIntoHistory(p);
|
|
1056
|
+
}
|
|
1057
|
+
return finish();
|
|
1058
|
+
}
|
|
1059
|
+
case "finishTask": {
|
|
1060
|
+
const ctx = resolveTaskSession(p, body.sessionId);
|
|
1061
|
+
if (!ctx) {
|
|
1062
|
+
return finish();
|
|
1063
|
+
}
|
|
1064
|
+
const okFinish = finishTaskInSession(
|
|
1065
|
+
ctx.session,
|
|
1066
|
+
String(body.taskId ?? ""),
|
|
1067
|
+
body.shouldCommit === true,
|
|
1068
|
+
tagNormOpts
|
|
1069
|
+
);
|
|
1070
|
+
if (okFinish && ctx.isLive) {
|
|
1071
|
+
syncLiveIntoHistory(p);
|
|
1072
|
+
}
|
|
1073
|
+
return finish();
|
|
1074
|
+
}
|
|
1075
|
+
case "deleteTask": {
|
|
1076
|
+
const ctx = resolveTaskSession(p, body.sessionId);
|
|
1077
|
+
if (!ctx) {
|
|
1078
|
+
return finish();
|
|
1079
|
+
}
|
|
1080
|
+
const okDel = deleteTaskInSession(ctx.session, String(body.taskId ?? ""));
|
|
1081
|
+
if (okDel && ctx.isLive) {
|
|
1082
|
+
syncLiveIntoHistory(p);
|
|
1083
|
+
}
|
|
1084
|
+
return finish();
|
|
1085
|
+
}
|
|
1086
|
+
case "setTaskTimerPaused": {
|
|
1087
|
+
const ctx = resolveTaskSession(p, body.sessionId);
|
|
1088
|
+
if (!ctx) {
|
|
1089
|
+
return finish();
|
|
1090
|
+
}
|
|
1091
|
+
setTaskPausedInSession(ctx.session, String(body.taskId ?? ""), body.paused === true);
|
|
1092
|
+
if (ctx.isLive) {
|
|
1093
|
+
syncLiveIntoHistory(p);
|
|
1094
|
+
}
|
|
1095
|
+
return finish();
|
|
1096
|
+
}
|
|
1097
|
+
case "resumePausedTask": {
|
|
1098
|
+
const ctx = resolveTaskSession(p, body.sessionId);
|
|
1099
|
+
if (!ctx) {
|
|
1100
|
+
return finish();
|
|
1101
|
+
}
|
|
1102
|
+
setTaskPausedInSession(ctx.session, String(body.taskId ?? ""), false);
|
|
1103
|
+
if (ctx.isLive) {
|
|
1104
|
+
syncLiveIntoHistory(p);
|
|
1105
|
+
}
|
|
1106
|
+
return finish();
|
|
1107
|
+
}
|
|
1108
|
+
case "addHistoricalTask": {
|
|
1109
|
+
const ctx = resolveTaskSession(p, body.sessionId);
|
|
1110
|
+
if (!ctx) {
|
|
1111
|
+
return finish();
|
|
1112
|
+
}
|
|
1113
|
+
const name = typeof body.name === "string" ? body.name : "";
|
|
1114
|
+
const tags = normalizeTaskTagsForStorage(
|
|
1115
|
+
Array.isArray(body.tags) ? (body.tags as string[]) : [],
|
|
1116
|
+
tagNormOpts
|
|
1117
|
+
);
|
|
1118
|
+
const project =
|
|
1119
|
+
body.project === null ? null : typeof body.project === "string" ? body.project : undefined;
|
|
1120
|
+
const durationMs = typeof body.durationMs === "number" ? Math.round(body.durationMs) : 0;
|
|
1121
|
+
const startTime = typeof body.startTime === "string" ? body.startTime : "";
|
|
1122
|
+
const endTime = typeof body.endTime === "string" ? body.endTime : "";
|
|
1123
|
+
addHistoricalTaskToSession(
|
|
1124
|
+
ctx.session,
|
|
1125
|
+
{ name, tags, project, durationMs, startTime, endTime },
|
|
1126
|
+
randomUUID(),
|
|
1127
|
+
tagNormOpts
|
|
1128
|
+
);
|
|
1129
|
+
mergeDiscoveredTagsIntoPayloadKnownTags(p, mergeTagsForDisplay(name, tags));
|
|
1130
|
+
if (typeof project === "string") {
|
|
1131
|
+
mergeDiscoveredProjectIntoPayloadKnownProjects(p, project);
|
|
1132
|
+
}
|
|
1133
|
+
mergeDiscoveredProjectIntoPayloadKnownProjects(p, parseTaskWithAutoTags(name).project);
|
|
1134
|
+
if (ctx.isLive) {
|
|
1135
|
+
syncLiveIntoHistory(p);
|
|
1136
|
+
}
|
|
1137
|
+
return finish();
|
|
1138
|
+
}
|
|
1139
|
+
case "toggleSubtask": {
|
|
1140
|
+
const ctx = resolveTaskSession(p, body.sessionId);
|
|
1141
|
+
if (!ctx) {
|
|
1142
|
+
return finish();
|
|
1143
|
+
}
|
|
1144
|
+
toggleSubtaskInSession(ctx.session, String(body.taskId ?? ""), String(body.subtaskId ?? ""));
|
|
1145
|
+
if (ctx.isLive) {
|
|
1146
|
+
syncLiveIntoHistory(p);
|
|
1147
|
+
}
|
|
1148
|
+
return finish();
|
|
1149
|
+
}
|
|
1150
|
+
case "updateSubtaskTitle": {
|
|
1151
|
+
const ctx = resolveTaskSession(p, body.sessionId);
|
|
1152
|
+
if (!ctx) {
|
|
1153
|
+
return finish();
|
|
1154
|
+
}
|
|
1155
|
+
updateSubtaskTitleInSession(
|
|
1156
|
+
ctx.session,
|
|
1157
|
+
String(body.taskId ?? ""),
|
|
1158
|
+
String(body.subtaskId ?? ""),
|
|
1159
|
+
typeof body.title === "string" ? body.title : ""
|
|
1160
|
+
);
|
|
1161
|
+
if (ctx.isLive) {
|
|
1162
|
+
syncLiveIntoHistory(p);
|
|
1163
|
+
}
|
|
1164
|
+
return finish();
|
|
1165
|
+
}
|
|
1166
|
+
case "setActiveSubtaskTimer": {
|
|
1167
|
+
const ctx = resolveTaskSession(p, body.sessionId);
|
|
1168
|
+
if (!ctx) {
|
|
1169
|
+
return finish();
|
|
1170
|
+
}
|
|
1171
|
+
const sid =
|
|
1172
|
+
body.subtaskId === null || body.subtaskId === undefined ? null : String(body.subtaskId);
|
|
1173
|
+
setActiveSubtaskTimerInSession(ctx.session, String(body.taskId ?? ""), sid);
|
|
1174
|
+
if (ctx.isLive) {
|
|
1175
|
+
syncLiveIntoHistory(p);
|
|
1176
|
+
}
|
|
1177
|
+
return finish();
|
|
1178
|
+
}
|
|
1179
|
+
case "deleteSubtask": {
|
|
1180
|
+
const ctx = resolveTaskSession(p, body.sessionId);
|
|
1181
|
+
if (!ctx) {
|
|
1182
|
+
return finish();
|
|
1183
|
+
}
|
|
1184
|
+
deleteSubtaskInSession(ctx.session, String(body.taskId ?? ""), String(body.subtaskId ?? ""));
|
|
1185
|
+
if (ctx.isLive) {
|
|
1186
|
+
syncLiveIntoHistory(p);
|
|
1187
|
+
}
|
|
1188
|
+
return finish();
|
|
1189
|
+
}
|
|
1190
|
+
case "reorderSubtasks": {
|
|
1191
|
+
const ctx = resolveTaskSession(p, body.sessionId);
|
|
1192
|
+
if (!ctx) {
|
|
1193
|
+
return finish();
|
|
1194
|
+
}
|
|
1195
|
+
const raw = body.orderedSubtaskIds;
|
|
1196
|
+
const ids = Array.isArray(raw) ? raw.map((x) => String(x)) : [];
|
|
1197
|
+
reorderSubtasksInSession(ctx.session, String(body.taskId ?? ""), ids);
|
|
1198
|
+
if (ctx.isLive) {
|
|
1199
|
+
syncLiveIntoHistory(p);
|
|
1200
|
+
}
|
|
1201
|
+
return finish();
|
|
1202
|
+
}
|
|
1203
|
+
case "addSubtask": {
|
|
1204
|
+
const ctx = resolveTaskSession(p, body.sessionId);
|
|
1205
|
+
if (!ctx) {
|
|
1206
|
+
return finish();
|
|
1207
|
+
}
|
|
1208
|
+
addSubtaskInSession(ctx.session, String(body.taskId ?? ""), typeof body.title === "string" ? body.title : "");
|
|
1209
|
+
if (ctx.isLive) {
|
|
1210
|
+
syncLiveIntoHistory(p);
|
|
1211
|
+
}
|
|
1212
|
+
return finish();
|
|
1213
|
+
}
|
|
1214
|
+
case "startKronoFocus":
|
|
1215
|
+
case LEGACY_ACTION_START: {
|
|
1216
|
+
const cur = asRecord(p.current);
|
|
1217
|
+
if (!cur) {
|
|
1218
|
+
return { ok: true, result: {} };
|
|
1219
|
+
}
|
|
1220
|
+
const pm = ensureKronoFocus(cur);
|
|
1221
|
+
const ws = kronoFocusWorkSeconds(cur);
|
|
1222
|
+
pm.mode = "work";
|
|
1223
|
+
pm.status = "running";
|
|
1224
|
+
pm.timeLeftSeconds = ws;
|
|
1225
|
+
pm.kronoFocusDeadlineAtMs = Date.now() + ws * 1000;
|
|
1226
|
+
if (body.linkToActiveTask === true) {
|
|
1227
|
+
const stack = Array.isArray(cur.activeTasks) ? (cur.activeTasks as Record<string, unknown>[]) : [];
|
|
1228
|
+
const at = asRecord(cur.activeTask);
|
|
1229
|
+
const pick = stack[0] ?? at;
|
|
1230
|
+
if (pick && typeof pick.id === "string") {
|
|
1231
|
+
pm.linkedTaskId = String(pick.id);
|
|
1232
|
+
pm.linkedTaskName = typeof pick.name === "string" ? pick.name : "";
|
|
1233
|
+
} else {
|
|
1234
|
+
delete pm.linkedTaskId;
|
|
1235
|
+
delete pm.linkedTaskName;
|
|
1236
|
+
}
|
|
1237
|
+
} else {
|
|
1238
|
+
delete pm.linkedTaskId;
|
|
1239
|
+
delete pm.linkedTaskName;
|
|
1240
|
+
}
|
|
1241
|
+
syncLiveIntoHistory(p);
|
|
1242
|
+
return finish();
|
|
1243
|
+
}
|
|
1244
|
+
case "pauseKronoFocus":
|
|
1245
|
+
case LEGACY_ACTION_PAUSE: {
|
|
1246
|
+
const cur = asRecord(p.current);
|
|
1247
|
+
if (!cur) {
|
|
1248
|
+
return { ok: true, result: {} };
|
|
1249
|
+
}
|
|
1250
|
+
const pm = ensureKronoFocus(cur);
|
|
1251
|
+
if (pm.status === "running") {
|
|
1252
|
+
const dl = pm.kronoFocusDeadlineAtMs ?? pm[LEGACY_TIMER_DEADLINE_MS_KEY];
|
|
1253
|
+
if (typeof dl === "number" && Number.isFinite(dl)) {
|
|
1254
|
+
pm.timeLeftSeconds = Math.max(0, Math.ceil((dl - Date.now()) / 1000));
|
|
1255
|
+
}
|
|
1256
|
+
delete pm.kronoFocusDeadlineAtMs;
|
|
1257
|
+
delete pm[LEGACY_TIMER_DEADLINE_MS_KEY];
|
|
1258
|
+
pm.status = "paused";
|
|
1259
|
+
}
|
|
1260
|
+
syncLiveIntoHistory(p);
|
|
1261
|
+
return finish();
|
|
1262
|
+
}
|
|
1263
|
+
case "resetKronoFocus":
|
|
1264
|
+
case LEGACY_ACTION_RESET: {
|
|
1265
|
+
const cur = asRecord(p.current);
|
|
1266
|
+
if (!cur) {
|
|
1267
|
+
return { ok: true, result: {} };
|
|
1268
|
+
}
|
|
1269
|
+
const pm = ensureKronoFocus(cur);
|
|
1270
|
+
pm.mode = "work";
|
|
1271
|
+
pm.status = "idle";
|
|
1272
|
+
pm.timeLeftSeconds = kronoFocusWorkSeconds(cur);
|
|
1273
|
+
delete pm.kronoFocusDeadlineAtMs;
|
|
1274
|
+
delete pm[LEGACY_TIMER_DEADLINE_MS_KEY];
|
|
1275
|
+
delete pm.linkedTaskId;
|
|
1276
|
+
delete pm.linkedTaskName;
|
|
1277
|
+
syncLiveIntoHistory(p);
|
|
1278
|
+
return finish();
|
|
1279
|
+
}
|
|
1280
|
+
case "setKronoFocusWorkDuration":
|
|
1281
|
+
case LEGACY_ACTION_SET_WORK_DURATION: {
|
|
1282
|
+
const cur = asRecord(p.current);
|
|
1283
|
+
if (!cur) {
|
|
1284
|
+
return { ok: true, result: {} };
|
|
1285
|
+
}
|
|
1286
|
+
const secRaw = body.seconds;
|
|
1287
|
+
const seconds =
|
|
1288
|
+
typeof secRaw === "number" && Number.isFinite(secRaw)
|
|
1289
|
+
? clampWorkDurationSeconds(secRaw)
|
|
1290
|
+
: kronoFocusWorkSeconds(cur);
|
|
1291
|
+
const pm = ensureKronoFocus(cur);
|
|
1292
|
+
pm.workDurationSeconds = seconds;
|
|
1293
|
+
if (pm.status === "idle" && pm.mode === "work") {
|
|
1294
|
+
pm.timeLeftSeconds = seconds;
|
|
1295
|
+
delete pm.kronoFocusDeadlineAtMs;
|
|
1296
|
+
delete pm[LEGACY_TIMER_DEADLINE_MS_KEY];
|
|
1297
|
+
}
|
|
1298
|
+
syncLiveIntoHistory(p);
|
|
1299
|
+
return finish();
|
|
1300
|
+
}
|
|
1301
|
+
case "setKronoFocusDurations": {
|
|
1302
|
+
const cur = asRecord(p.current);
|
|
1303
|
+
if (!cur) {
|
|
1304
|
+
return { ok: true, result: {} };
|
|
1305
|
+
}
|
|
1306
|
+
const pm = ensureKronoFocus(cur);
|
|
1307
|
+
const bodyRec = body as Record<string, unknown>;
|
|
1308
|
+
let touchedWork = false;
|
|
1309
|
+
if (typeof bodyRec.workSeconds === "number" && Number.isFinite(bodyRec.workSeconds)) {
|
|
1310
|
+
pm.workDurationSeconds = clampWorkDurationSeconds(bodyRec.workSeconds);
|
|
1311
|
+
touchedWork = true;
|
|
1312
|
+
}
|
|
1313
|
+
if (typeof bodyRec.shortBreakSeconds === "number" && Number.isFinite(bodyRec.shortBreakSeconds)) {
|
|
1314
|
+
pm.shortBreakDurationSeconds = clampBreakDurationSeconds(bodyRec.shortBreakSeconds);
|
|
1315
|
+
}
|
|
1316
|
+
if (typeof bodyRec.longBreakSeconds === "number" && Number.isFinite(bodyRec.longBreakSeconds)) {
|
|
1317
|
+
pm.longBreakDurationSeconds = clampBreakDurationSeconds(bodyRec.longBreakSeconds);
|
|
1318
|
+
}
|
|
1319
|
+
if (pm.status === "idle" && pm.mode === "work" && touchedWork) {
|
|
1320
|
+
pm.timeLeftSeconds = readWorkDurationSeconds(pm);
|
|
1321
|
+
delete pm.kronoFocusDeadlineAtMs;
|
|
1322
|
+
delete pm[LEGACY_TIMER_DEADLINE_MS_KEY];
|
|
1323
|
+
}
|
|
1324
|
+
syncLiveIntoHistory(p);
|
|
1325
|
+
return finish();
|
|
1326
|
+
}
|
|
1327
|
+
case "addUserKnownTag": {
|
|
1328
|
+
const raw = typeof body.tag === "string" ? body.tag.trim() : "";
|
|
1329
|
+
if (!raw) {
|
|
1330
|
+
return finish();
|
|
1331
|
+
}
|
|
1332
|
+
const canon = normalizeTagKey(raw) || raw;
|
|
1333
|
+
const lk = canon.toLowerCase();
|
|
1334
|
+
const known = [...((p.knownTags || []) as string[])];
|
|
1335
|
+
const user = [...((p.userKnownTags || []) as string[])];
|
|
1336
|
+
if (!known.some((t) => normalizeTagKey(t).toLowerCase() === lk)) {
|
|
1337
|
+
known.push(canon);
|
|
1338
|
+
}
|
|
1339
|
+
if (!user.some((t) => normalizeTagKey(t).toLowerCase() === lk)) {
|
|
1340
|
+
user.push(canon);
|
|
1341
|
+
}
|
|
1342
|
+
p.knownTags = known;
|
|
1343
|
+
p.userKnownTags = user;
|
|
1344
|
+
return finish();
|
|
1345
|
+
}
|
|
1346
|
+
case "removeUserKnownTag": {
|
|
1347
|
+
const raw = typeof body.tag === "string" ? body.tag : "";
|
|
1348
|
+
const lk = normalizeTagKey(raw).toLowerCase();
|
|
1349
|
+
p.userKnownTags = ((p.userKnownTags || []) as string[]).filter(
|
|
1350
|
+
(t) => normalizeTagKey(t).toLowerCase() !== lk
|
|
1351
|
+
);
|
|
1352
|
+
return finish();
|
|
1353
|
+
}
|
|
1354
|
+
case "setTagDescription": {
|
|
1355
|
+
const raw = typeof body.tag === "string" ? body.tag : "";
|
|
1356
|
+
const key = normalizeTagKey(raw).toLowerCase();
|
|
1357
|
+
const desc = typeof body.description === "string" ? body.description : "";
|
|
1358
|
+
const td = { ...(asRecord(p.tagDescriptions) ?? {}) } as Record<string, string>;
|
|
1359
|
+
td[key] = desc;
|
|
1360
|
+
p.tagDescriptions = td;
|
|
1361
|
+
return finish();
|
|
1362
|
+
}
|
|
1363
|
+
case "setProjectDescription": {
|
|
1364
|
+
const raw = typeof body.name === "string" ? body.name : "";
|
|
1365
|
+
const key = normalizeProjectKey(raw).toLowerCase();
|
|
1366
|
+
const desc = typeof body.description === "string" ? body.description : "";
|
|
1367
|
+
const pd = { ...(asRecord(p.projectDescriptions) ?? {}) } as Record<string, string>;
|
|
1368
|
+
pd[key] = desc;
|
|
1369
|
+
p.projectDescriptions = pd;
|
|
1370
|
+
return finish();
|
|
1371
|
+
}
|
|
1372
|
+
case "includeTagFromSuggestions": {
|
|
1373
|
+
const raw = typeof body.tag === "string" ? body.tag : "";
|
|
1374
|
+
const lk = normalizeTagKey(raw).toLowerCase();
|
|
1375
|
+
p.excludedSuggestionTags = ((p.excludedSuggestionTags || []) as string[]).filter(
|
|
1376
|
+
(t) => normalizeTagKey(t).toLowerCase() !== lk
|
|
1377
|
+
);
|
|
1378
|
+
return finish();
|
|
1379
|
+
}
|
|
1380
|
+
case "excludeTagFromSuggestions": {
|
|
1381
|
+
const raw = typeof body.tag === "string" ? body.tag : "";
|
|
1382
|
+
const canon = normalizeTagKey(raw) || raw;
|
|
1383
|
+
const lk = canon.toLowerCase();
|
|
1384
|
+
const ex = [...((p.excludedSuggestionTags || []) as string[])];
|
|
1385
|
+
if (!ex.some((t) => normalizeTagKey(t).toLowerCase() === lk)) {
|
|
1386
|
+
ex.push(canon);
|
|
1387
|
+
}
|
|
1388
|
+
p.excludedSuggestionTags = ex;
|
|
1389
|
+
p.userKnownTags = ((p.userKnownTags || []) as string[]).filter(
|
|
1390
|
+
(t) => normalizeTagKey(t).toLowerCase() !== lk
|
|
1391
|
+
);
|
|
1392
|
+
return finish();
|
|
1393
|
+
}
|
|
1394
|
+
case "purgeTagMetadata": {
|
|
1395
|
+
const raw = typeof body.tag === "string" ? body.tag : "";
|
|
1396
|
+
purgeTagEverywhere(p, raw, tagNormOpts);
|
|
1397
|
+
return finish();
|
|
1398
|
+
}
|
|
1399
|
+
case "purgeProjectMetadata": {
|
|
1400
|
+
const raw = typeof body.name === "string" ? body.name : "";
|
|
1401
|
+
purgeProjectEverywhere(p, raw);
|
|
1402
|
+
return finish();
|
|
1403
|
+
}
|
|
1404
|
+
case "fetchRemoteIssues":
|
|
1405
|
+
return await dispatchFetchRemoteIssues(p, body);
|
|
1406
|
+
case "pushSessionToMongo":
|
|
1407
|
+
return {
|
|
1408
|
+
ok: true,
|
|
1409
|
+
result: { pushSessionToMongo: { ok: false, error: "disabled" } },
|
|
1410
|
+
};
|
|
1411
|
+
case "testMongoDbConnection":
|
|
1412
|
+
return {
|
|
1413
|
+
ok: true,
|
|
1414
|
+
result: { mongoConnectionTest: { outcome: "disabled" as const } },
|
|
1415
|
+
};
|
|
1416
|
+
case "testGitlabConnection":
|
|
1417
|
+
return await dispatchGitlabConnectionTest(p, body);
|
|
1418
|
+
case "resyncMongoMirror":
|
|
1419
|
+
return {
|
|
1420
|
+
ok: true,
|
|
1421
|
+
result: { mongoResync: { ok: false, reason: "disabled" as const } },
|
|
1422
|
+
};
|
|
1423
|
+
case "setMongoDbConnectionUri": {
|
|
1424
|
+
const uri = typeof body.uri === "string" ? body.uri.trim() : "";
|
|
1425
|
+
if (uri.length > 0) {
|
|
1426
|
+
mergeCfg(p, { mongodbUriConfigured: true, mongodbManualUriConfigured: true });
|
|
1427
|
+
} else {
|
|
1428
|
+
mergeCfg(p, { mongodbUriConfigured: false, mongodbManualUriConfigured: false });
|
|
1429
|
+
}
|
|
1430
|
+
return finish();
|
|
1431
|
+
}
|
|
1432
|
+
case "setMongoDbPassword": {
|
|
1433
|
+
const pass = typeof body.password === "string" ? body.password.trim() : "";
|
|
1434
|
+
mergeCfg(p, { mongodbPasswordConfigured: pass.length > 0 });
|
|
1435
|
+
return finish();
|
|
1436
|
+
}
|
|
1437
|
+
case "setGitlabToken": {
|
|
1438
|
+
const tok = typeof body.token === "string" ? body.token.trim() : "";
|
|
1439
|
+
if (tok.length > 0) {
|
|
1440
|
+
writeGitlabPatToStore(tok);
|
|
1441
|
+
} else {
|
|
1442
|
+
clearGitlabPatFromStore();
|
|
1443
|
+
}
|
|
1444
|
+
mergeCfg(p, {
|
|
1445
|
+
gitlabTokenStored: tok.length > 0,
|
|
1446
|
+
gitlabTokenFromEnv: false,
|
|
1447
|
+
gitlabApiVerified: false,
|
|
1448
|
+
});
|
|
1449
|
+
return finish();
|
|
1450
|
+
}
|
|
1451
|
+
case "createGlabRemoteRepo":
|
|
1452
|
+
return {
|
|
1453
|
+
ok: true,
|
|
1454
|
+
result: { glabRepoCreate: { ok: false, error: "Désactivé dans le tableau de bord Next local." } },
|
|
1455
|
+
};
|
|
1456
|
+
case "pickStoragePath":
|
|
1457
|
+
return finish();
|
|
1458
|
+
case "refreshWorkspaceCodeSnapshot": {
|
|
1459
|
+
const paths = workspaceFolderPathStrings(p);
|
|
1460
|
+
const workspaceFolder = (paths[0] ?? "").trim() || process.cwd();
|
|
1461
|
+
p.workspaceCodeSnapshot = {
|
|
1462
|
+
ok: true,
|
|
1463
|
+
totalLines: 0,
|
|
1464
|
+
fileCount: 0,
|
|
1465
|
+
byLanguage: [],
|
|
1466
|
+
source: "walk",
|
|
1467
|
+
computedAt: new Date().toISOString(),
|
|
1468
|
+
workspaceFolder,
|
|
1469
|
+
};
|
|
1470
|
+
return finish();
|
|
1471
|
+
}
|
|
1472
|
+
default:
|
|
1473
|
+
// No-op : maintient l’UI réactive ; affiner au fil du portage.
|
|
1474
|
+
return finish();
|
|
1475
|
+
}
|
|
1476
|
+
}
|