@nightkatana/kronosys-app 1.0.0-beta.2 → 1.0.0-beta.21
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 +28 -1
- package/app/api/action/route.ts +39 -3
- package/app/api/action-logs/route.ts +24 -0
- package/app/api/backup/route.ts +1 -1
- package/app/api/restore/route.ts +145 -0
- package/app/changelog/page.tsx +71 -4
- package/app/globals.css +127 -0
- package/app/guide/page.tsx +61 -15
- package/app/implementation/page.tsx +700 -0
- package/app/layout.tsx +14 -3
- package/app/licenses/page.tsx +99 -37
- package/app/logs/page.tsx +258 -0
- package/app/manifest.ts +5 -5
- package/app/page.tsx +784 -229
- package/app/reporting/page.tsx +1266 -474
- package/app/settings/page.tsx +252 -18
- package/bin/kronosys.mjs +140 -15
- package/components/KronosysPayloadProvider.tsx +2 -0
- package/components/RouteTransition.tsx +18 -0
- package/components/dashboard/AppShellCommandCenterPlaceholder.tsx +17 -0
- package/components/dashboard/AppShellHeaderSessionMeta.tsx +210 -0
- package/components/dashboard/AppShellHeaderWallClock.tsx +54 -0
- package/components/dashboard/AppShellLiveSessionDrawer.tsx +154 -38
- package/components/dashboard/AppShellRouteNav.tsx +323 -48
- package/components/dashboard/DashboardPauseBackdrop.tsx +50 -0
- package/components/dashboard/DashboardSimpleModal.tsx +168 -25
- package/components/dashboard/DashboardTour.tsx +115 -29
- package/components/dashboard/GlobalPauseConfirmModal.tsx +183 -0
- package/components/dashboard/KronosysDatetimePopoverField.tsx +167 -122
- package/components/dashboard/KronosysTimePopoverField.tsx +54 -12
- package/components/dashboard/NewSessionScopeModal.tsx +211 -20
- package/components/dashboard/PlannedTaskBoundaryConflictWatcher.tsx +275 -0
- package/components/dashboard/ReportingTour.tsx +87 -21
- package/components/dashboard/SavedProjectPicker.tsx +16 -3
- package/components/dashboard/SelectedSessionSidebarBlock.tsx +512 -142
- package/components/dashboard/SessionListPanel.tsx +327 -44
- package/components/dashboard/SettingsTagsProjectsSection.tsx +1073 -264
- package/components/dashboard/SettingsTaskTemplatesSection.tsx +316 -0
- package/components/dashboard/SettingsTour.tsx +86 -21
- package/components/dashboard/TagPills.tsx +14 -1
- package/components/dashboard/TaskFocusPanel.tsx +1081 -478
- package/components/dashboard/TaskSessionLiveCard.tsx +650 -135
- package/components/dashboard/TaskTimelineGanttModal.tsx +601 -0
- package/components/dashboard/taskFieldStyles.ts +20 -4
- package/components/dashboard/useReportingInteractionState.ts +80 -0
- package/lib/appShellHeaderClasses.ts +13 -0
- package/lib/businessRulesMatrix.ts +210 -0
- package/lib/copyToClipboard.ts +43 -0
- package/lib/dashboardCopy.ts +494 -84
- package/lib/dashboardQuickSearch.ts +54 -2
- package/lib/dashboardTimeZone.ts +109 -0
- package/lib/formatAppShellWallClock.ts +66 -0
- package/lib/formatSessionNameTemplate.ts +141 -0
- package/lib/generatedUserChangelog.ts +177 -6
- package/lib/globalPausePreview.ts +292 -0
- package/lib/implementationNotes.ts +1188 -0
- package/lib/kronosysApi.ts +6 -0
- package/lib/kronosysDashboardModalGates.ts +24 -0
- package/lib/plannedBoundaryAttention.ts +9 -0
- package/lib/plannedBoundaryConflict.ts +23 -0
- package/lib/reportingAggregate.ts +517 -75
- package/lib/reportingMetricHelp.ts +8 -0
- package/lib/reportingStrings.ts +37 -3
- package/lib/sessionListMerge.ts +4 -0
- package/lib/sessionTaskSidebarStats.ts +182 -21
- package/lib/settingsCopy.ts +178 -4
- package/lib/taskParsing.ts +360 -103
- package/lib/taskTemplateDraft.ts +135 -0
- package/lib/taskTimelineGantt.ts +265 -0
- package/lib/temporalDisplayPlanned.ts +71 -0
- package/lib/userGuideCopy.ts +121 -47
- package/next.config.ts +7 -0
- package/package.json +12 -24
- package/server/actionDispatch.ts +1000 -77
- package/server/actionTaskSession.ts +337 -24
- package/server/db.ts +7 -15
- package/server/dbSchema.ts +24 -0
- package/server/defaultCfg.ts +5 -0
- package/server/gitlabTokenStore.ts +0 -12
- package/server/liveHistorySync.ts +53 -0
- package/server/mainTimerHydrate.ts +38 -2
- package/server/payloadStore.ts +33 -11
- package/server/sessionWallHydrate.ts +66 -3
- package/server/userActionLog.ts +126 -0
- package/sonar-project.properties +11 -0
- package/tsconfig.json +2 -1
- package/components/dashboard/IssuePickerModal.tsx +0 -168
- package/components/dashboard/ThemeToggle.test.tsx +0 -26
- package/lib/backupCsvExport.test.ts +0 -149
- package/lib/dashboardQuickSearchQuery.test.ts +0 -63
- package/lib/dataDir.test.ts +0 -87
- package/lib/formatIsoShort.test.ts +0 -46
- package/lib/kronoFocusRhythm.test.ts +0 -130
- package/lib/kronoFocusTimerUrgency.test.ts +0 -74
- package/lib/legacyKronoFocusStorageKeys.test.ts +0 -29
- package/lib/reportingAggregate.test.ts +0 -325
- package/lib/reportingNonFinalIndicators.test.ts +0 -157
- package/lib/reportingTagWeekBreakdown.test.ts +0 -141
- package/lib/reportingWeekLayout.test.ts +0 -239
- package/lib/sessionAssiduity.test.ts +0 -25
- package/lib/sessionEndWarnings.test.ts +0 -200
- package/lib/sessionListMerge.test.ts +0 -101
- package/lib/sessionTaskSidebarStats.test.ts +0 -24
- package/lib/taskParsing.test.ts +0 -153
- package/lib/usageProfile.test.ts +0 -84
- package/server/actionDispatch.test.ts +0 -723
- package/server/actionTaskSession.test.ts +0 -713
- package/server/kronoFocusHydrate.test.ts +0 -142
- package/server/kronoFocusMigrate.test.ts +0 -53
- package/server/mainTimerHydrate.test.ts +0 -65
- package/server/payloadStore.test.ts +0 -78
- package/server/sessionWallHydrate.test.ts +0 -46
package/lib/taskParsing.ts
CHANGED
|
@@ -7,44 +7,126 @@ const AUTO_TAG_COMBINED = /(?:^|\s)#([^\s#]+)|(?<=[^\s#])#([^\s#]+)/g;
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Détection des `@projet` (mêmes idées que les `#tags`).
|
|
10
|
+
* @deprecated Utiliser {@link findProjectTokens} pour `@` et `!`.
|
|
10
11
|
*/
|
|
11
12
|
const AUTO_PROJECT_COMBINED = /(?:^|\s)@([^\s@]+)|(?<=[^\s@])@([^\s@]+)/g;
|
|
12
13
|
|
|
14
|
+
type ProjectTokenMatch = {
|
|
15
|
+
start: number;
|
|
16
|
+
end: number;
|
|
17
|
+
plain: boolean;
|
|
18
|
+
sigil: "@" | "!";
|
|
19
|
+
projRaw: string;
|
|
20
|
+
loc?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
13
23
|
/**
|
|
14
|
-
* `@projet#
|
|
15
|
-
*
|
|
24
|
+
* Repère les jetons `@projet`, `!projet`, `@p#t` et `!p#t` (ordre d’apparition dans la chaîne).
|
|
25
|
+
*
|
|
26
|
+
* - `@` : uniquement après début de chaîne ou un espace (évite `courriel@domaine`).
|
|
27
|
+
* - `!` : idem **ou** collé au caractère précédent (comme les `#tags`), ex. `Courrier!perso#dentiste`.
|
|
16
28
|
*/
|
|
17
|
-
|
|
29
|
+
export function findProjectTokens(raw: string): ProjectTokenMatch[] {
|
|
30
|
+
const norm = raw.replace(/\s+/g, " ").trim();
|
|
31
|
+
/** Jetons `@` / `!` précédés d’une frontière « début ou espace ». */
|
|
32
|
+
const reBoundary = /(?:^|\s)([@!])([^\s@!#]+)(?:#([^\s#]+))?/g;
|
|
33
|
+
/** `!` seul : autorisé après un caractère non blanc, non `!`, non `#` (aligné sur les `#tags` collés). */
|
|
34
|
+
const reBangGlued = /(?<=[^\s!#])(!)([^\s@!#]+)(?:#([^\s#]+))?/g;
|
|
35
|
+
|
|
36
|
+
const pushMatch = (
|
|
37
|
+
out: ProjectTokenMatch[],
|
|
38
|
+
sigil: "@" | "!",
|
|
39
|
+
projRaw: string,
|
|
40
|
+
locRaw: string | undefined,
|
|
41
|
+
start: number,
|
|
42
|
+
fullLen: number,
|
|
43
|
+
) => {
|
|
44
|
+
const end = start + fullLen;
|
|
45
|
+
const proj = projRaw.trim();
|
|
46
|
+
if (!proj) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (locRaw !== undefined && locRaw.length > 0) {
|
|
50
|
+
const loc = locRaw.trim();
|
|
51
|
+
if (!loc || loc.includes("#")) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
out.push({ start, end, plain: false, sigil, projRaw: proj, loc });
|
|
55
|
+
} else if (!proj.includes("#")) {
|
|
56
|
+
out.push({ start, end, plain: true, sigil, projRaw: proj });
|
|
57
|
+
}
|
|
58
|
+
};
|
|
18
59
|
|
|
19
|
-
|
|
20
|
-
|
|
60
|
+
const buf: ProjectTokenMatch[] = [];
|
|
61
|
+
let m: RegExpExecArray | null;
|
|
62
|
+
while ((m = reBoundary.exec(norm)) !== null) {
|
|
63
|
+
pushMatch(buf, m[1] as "@" | "!", m[2], m[3], m.index, m[0].length);
|
|
64
|
+
}
|
|
65
|
+
while ((m = reBangGlued.exec(norm)) !== null) {
|
|
66
|
+
pushMatch(buf, "!", m[2], m[3], m.index, m[0].length);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
buf.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
70
|
+
const out: ProjectTokenMatch[] = [];
|
|
71
|
+
let lastEnd = -1;
|
|
72
|
+
for (const t of buf) {
|
|
73
|
+
if (t.start < lastEnd) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
out.push(t);
|
|
77
|
+
lastEnd = t.end;
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Retire uniquement les jetons de portée `@p#t` / `!p#t` (les `@p` / `!p` simples restent). */
|
|
83
|
+
function stripScopedProjectSigilTokens(raw: string): {
|
|
21
84
|
core: string;
|
|
22
85
|
scopedTags: string[];
|
|
23
86
|
scopedProjectHint?: string;
|
|
87
|
+
scopedHintPersonal?: boolean;
|
|
24
88
|
} {
|
|
89
|
+
const norm = raw.replace(/\s+/g, " ").trim();
|
|
90
|
+
const tokens = findProjectTokens(norm).filter((t) => !t.plain);
|
|
25
91
|
const scopedTags: string[] = [];
|
|
26
|
-
let scopedProjectHint: string | undefined;
|
|
27
92
|
const seen = new Set<string>();
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
93
|
+
let scopedProjectHint: string | undefined;
|
|
94
|
+
let scopedHintPersonal: boolean | undefined;
|
|
95
|
+
for (const t of tokens) {
|
|
96
|
+
if (!t.loc) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const proj = normalizeProjectKey(t.projRaw);
|
|
100
|
+
const loc = t.loc.trim();
|
|
101
|
+
if (!proj || !loc) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (scopedProjectHint === undefined) {
|
|
105
|
+
scopedProjectHint = proj;
|
|
106
|
+
scopedHintPersonal = t.sigil === "!";
|
|
107
|
+
}
|
|
108
|
+
const canon = `${proj}#${loc}`;
|
|
109
|
+
const k = canon.toLowerCase();
|
|
110
|
+
if (!seen.has(k)) {
|
|
111
|
+
seen.add(k);
|
|
112
|
+
scopedTags.push(canon);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
let core = norm;
|
|
116
|
+
for (const tok of [...tokens].sort((a, b) => b.start - a.start)) {
|
|
117
|
+
core = `${core.slice(0, tok.start)} ${core.slice(tok.end)}`;
|
|
118
|
+
}
|
|
119
|
+
core = core.replace(/\s+/g, " ").trim();
|
|
120
|
+
return { core, scopedTags, scopedProjectHint, scopedHintPersonal };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** @deprecated Préférer {@link stripProjectsAndTakeFirst} ; conservé pour tests et rétrocompat. */
|
|
124
|
+
export function stripScopedProjectAtTokens(raw: string): {
|
|
125
|
+
core: string;
|
|
126
|
+
scopedTags: string[];
|
|
127
|
+
scopedProjectHint?: string;
|
|
128
|
+
} {
|
|
129
|
+
const { core, scopedTags, scopedProjectHint } = stripScopedProjectSigilTokens(raw);
|
|
48
130
|
return { core, scopedTags, scopedProjectHint };
|
|
49
131
|
}
|
|
50
132
|
|
|
@@ -92,40 +174,64 @@ export function filterTaskTagsForProject(tags: string[], project: string | null
|
|
|
92
174
|
return tags.filter((t) => isTagAllowedForProject(t, p));
|
|
93
175
|
}
|
|
94
176
|
|
|
95
|
-
/** Retire les `@projet` simples et retient le premier
|
|
96
|
-
function
|
|
177
|
+
/** Retire les `@projet` / `!projet` simples (tous les jetons plain) et retient le **premier** en ordre de lecture. */
|
|
178
|
+
function stripPlainProjectSigilTokensAndTakeFirst(raw: string): {
|
|
179
|
+
core: string;
|
|
180
|
+
project?: string;
|
|
181
|
+
plainPersonal?: boolean;
|
|
182
|
+
} {
|
|
183
|
+
const norm = raw.replace(/\s+/g, " ").trim();
|
|
184
|
+
const tokens = findProjectTokens(norm).filter((t) => t.plain);
|
|
97
185
|
let first: string | undefined;
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
.
|
|
108
|
-
|
|
186
|
+
let plainPersonal: boolean | undefined;
|
|
187
|
+
for (const t of tokens) {
|
|
188
|
+
if (first === undefined) {
|
|
189
|
+
first = normalizeProjectKey(t.projRaw);
|
|
190
|
+
plainPersonal = t.sigil === "!";
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
let core = norm;
|
|
194
|
+
for (const tok of [...tokens].sort((a, b) => b.start - a.start)) {
|
|
195
|
+
core = `${core.slice(0, tok.start)} ${core.slice(tok.end)}`;
|
|
196
|
+
}
|
|
197
|
+
core = core.replace(/\s+/g, " ").trim();
|
|
198
|
+
return { core, project: first, plainPersonal };
|
|
109
199
|
}
|
|
110
200
|
|
|
111
|
-
/**
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
201
|
+
/**
|
|
202
|
+
* Retire `@p#t` / `!p#t` puis `@p` / `!p` ; fusionne l’indice de projet (premier jeton plain,
|
|
203
|
+
* sinon premier indice issu d’un jeton de portée).
|
|
204
|
+
*/
|
|
205
|
+
export function stripProjectsAndTakeFirst(raw: string): {
|
|
206
|
+
core: string;
|
|
207
|
+
project?: string;
|
|
208
|
+
scopedTags: string[];
|
|
209
|
+
/** Vrai lorsque le projet retenu provient d’un jeton `!` (projet personnel). */
|
|
210
|
+
personalProject: boolean;
|
|
211
|
+
} {
|
|
212
|
+
const { core: c0, scopedTags, scopedProjectHint, scopedHintPersonal } = stripScopedProjectSigilTokens(raw);
|
|
213
|
+
const { core, project: plainProject, plainPersonal } = stripPlainProjectSigilTokensAndTakeFirst(c0);
|
|
115
214
|
const project = plainProject ?? scopedProjectHint;
|
|
116
|
-
|
|
215
|
+
const personalFlag =
|
|
216
|
+
plainProject !== undefined ? plainPersonal === true : scopedHintPersonal === true;
|
|
217
|
+
return {
|
|
218
|
+
core,
|
|
219
|
+
project,
|
|
220
|
+
scopedTags,
|
|
221
|
+
personalProject: Boolean(project && personalFlag),
|
|
222
|
+
};
|
|
117
223
|
}
|
|
118
224
|
|
|
119
225
|
export function normalizeProjectKey(s: string): string {
|
|
120
|
-
return s.replace(
|
|
226
|
+
return s.replace(/^[@!]+/, "").trim();
|
|
121
227
|
}
|
|
122
228
|
|
|
123
229
|
/**
|
|
124
|
-
* Si le brouillon contient au moins un
|
|
230
|
+
* Si le brouillon contient au moins un `@` ou `!`, met à jour le projet (ou null si aucun jeton valide).
|
|
125
231
|
* Sinon ne pas modifier le champ projet côté API (`undefined`).
|
|
126
232
|
*/
|
|
127
233
|
export function resolveProjectForTaskUpdate(rawTitle: string): string | null | undefined {
|
|
128
|
-
if (
|
|
234
|
+
if (!/[@!]/.test(rawTitle)) {
|
|
129
235
|
return undefined;
|
|
130
236
|
}
|
|
131
237
|
const { project } = stripProjectsAndTakeFirst(rawTitle);
|
|
@@ -133,18 +239,49 @@ export function resolveProjectForTaskUpdate(rawTitle: string): string | null | u
|
|
|
133
239
|
}
|
|
134
240
|
|
|
135
241
|
/**
|
|
136
|
-
*
|
|
137
|
-
*
|
|
242
|
+
* Lorsque le titre contient `@` ou `!`, indique si le projet déduit est **personnel** (`!`).
|
|
243
|
+
* Sinon `undefined` (ne pas modifier `personalProject` côté API).
|
|
244
|
+
*/
|
|
245
|
+
export function resolvePersonalProjectForTaskUpdate(rawTitle: string): boolean | undefined {
|
|
246
|
+
if (!/[@!]/.test(rawTitle)) {
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
const { project, personalProject } = stripProjectsAndTakeFirst(rawTitle);
|
|
250
|
+
if (!project) {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
return personalProject;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Projet à enregistrer à partir d’une ligne de tâche complète (champ unique avec `#`, `@`, `!`, `@p#t`, `!p#t`).
|
|
258
|
+
* Aucun `@` ni `!` dans la chaîne ⇒ aucun projet (`null`). Sinon, premier projet déduit comme à la saisie.
|
|
138
259
|
*/
|
|
139
260
|
export function resolveProjectFromFullTaskLine(rawTitle: string): string | null {
|
|
140
261
|
const t = rawTitle.replace(/\s+/g, " ").trim();
|
|
141
|
-
if (
|
|
262
|
+
if (!/[@!]/.test(t)) {
|
|
142
263
|
return null;
|
|
143
264
|
}
|
|
144
265
|
const { project } = stripProjectsAndTakeFirst(t);
|
|
145
266
|
return project ? normalizeProjectKey(project) : null;
|
|
146
267
|
}
|
|
147
268
|
|
|
269
|
+
/** Même logique que {@link resolveProjectFromFullTaskLine} avec le drapeau projet personnel. */
|
|
270
|
+
export function resolveProjectMetaFromFullTaskLine(rawTitle: string): {
|
|
271
|
+
project: string | null;
|
|
272
|
+
personalProject: boolean;
|
|
273
|
+
} {
|
|
274
|
+
const t = rawTitle.replace(/\s+/g, " ").trim();
|
|
275
|
+
if (!/[@!]/.test(t)) {
|
|
276
|
+
return { project: null, personalProject: false };
|
|
277
|
+
}
|
|
278
|
+
const { project, personalProject } = stripProjectsAndTakeFirst(t);
|
|
279
|
+
return {
|
|
280
|
+
project: project ? normalizeProjectKey(project) : null,
|
|
281
|
+
personalProject: Boolean(project && personalProject),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
148
285
|
/** Découpe le titre brut pour afficher le texte et les segments `#tag` comme pastilles (mêmes règles que {@link parseTaskWithAutoTags}). */
|
|
149
286
|
export function splitTaskTitleSegments(raw: string): Array<{ kind: "text" | "tag"; value: string }> {
|
|
150
287
|
const re = new RegExp(AUTO_TAG_COMBINED.source, "g");
|
|
@@ -205,10 +342,10 @@ function mergeDedupedTagLists(primary: string[], secondary: string[]): string[]
|
|
|
205
342
|
}
|
|
206
343
|
|
|
207
344
|
function collectAutoTagsFromRaw(raw: string): string[] {
|
|
208
|
-
const { core
|
|
345
|
+
const { core, scopedTags } = stripProjectsAndTakeFirst(raw);
|
|
209
346
|
const fromHash: string[] = [];
|
|
210
347
|
const seen = new Set<string>();
|
|
211
|
-
for (const segment of splitTaskTitleSegments(
|
|
348
|
+
for (const segment of splitTaskTitleSegments(core)) {
|
|
212
349
|
if (segment.kind !== "tag") {
|
|
213
350
|
continue;
|
|
214
351
|
}
|
|
@@ -235,47 +372,71 @@ function parseNameAndTagsFromCore(core: string): { name: string; tags: string[]
|
|
|
235
372
|
return { name, tags };
|
|
236
373
|
}
|
|
237
374
|
|
|
238
|
-
export function parseTaskWithAutoTags(raw: string): {
|
|
239
|
-
|
|
375
|
+
export function parseTaskWithAutoTags(raw: string): {
|
|
376
|
+
name: string;
|
|
377
|
+
tags: string[];
|
|
378
|
+
project?: string;
|
|
379
|
+
personalProject: boolean;
|
|
380
|
+
} {
|
|
381
|
+
const { core, project, scopedTags, personalProject } = stripProjectsAndTakeFirst(raw);
|
|
240
382
|
const { name, tags } = parseNameAndTagsFromCore(core);
|
|
241
383
|
const merged = mergeDedupedTagLists(scopedTags, tags);
|
|
242
|
-
return {
|
|
384
|
+
return {
|
|
385
|
+
name,
|
|
386
|
+
tags: filterTaskTagsForProject(merged, project ?? null),
|
|
387
|
+
project,
|
|
388
|
+
personalProject: Boolean(project && personalProject),
|
|
389
|
+
};
|
|
243
390
|
}
|
|
244
391
|
|
|
245
392
|
/**
|
|
246
393
|
* Interprète une ligne de saisie rapide sous une tâche (`#tag`, `@projet`, `@p#code`, texte seul comme étiquette).
|
|
247
394
|
* Les étiquettes retournées sont déjà filtrées par projet si un `@projet` est détecté dans la même chaîne.
|
|
248
395
|
*/
|
|
249
|
-
export function parseQuickAddTagOrProjectLine(input: string): {
|
|
396
|
+
export function parseQuickAddTagOrProjectLine(input: string): {
|
|
397
|
+
tags: string[];
|
|
398
|
+
project?: string | null;
|
|
399
|
+
personalProject?: boolean;
|
|
400
|
+
} {
|
|
250
401
|
const v = input.trim();
|
|
251
402
|
if (!v) {
|
|
252
403
|
return { tags: [] };
|
|
253
404
|
}
|
|
254
|
-
/** Seul `@` : retirer le projet (équivalent d’un clic « désactiver » sur la pastille projet
|
|
255
|
-
if (v === "@") {
|
|
256
|
-
return { tags: [], project: null };
|
|
405
|
+
/** Seul `@` ou `!` : retirer le projet (équivalent d’un clic « désactiver » sur la pastille projet). */
|
|
406
|
+
if (v === "@" || v === "!") {
|
|
407
|
+
return { tags: [], project: null, personalProject: false };
|
|
257
408
|
}
|
|
258
409
|
const parsed = parseTaskWithAutoTags(v);
|
|
259
410
|
const name = parsed.name.replace(/\s+/g, "").trim();
|
|
260
411
|
if (parsed.tags.length === 0 && parsed.project === undefined && name.length > 0) {
|
|
261
|
-
return { tags: [normalizeTagKey(name)], project: undefined };
|
|
412
|
+
return { tags: [normalizeTagKey(name)], project: undefined, personalProject: undefined };
|
|
262
413
|
}
|
|
263
414
|
if (parsed.project === undefined) {
|
|
264
|
-
return { tags: parsed.tags, project: undefined };
|
|
415
|
+
return { tags: parsed.tags, project: undefined, personalProject: undefined };
|
|
265
416
|
}
|
|
266
417
|
const proj = parsed.project ? normalizeProjectKey(parsed.project) : null;
|
|
267
|
-
return {
|
|
418
|
+
return {
|
|
419
|
+
tags: parsed.tags,
|
|
420
|
+
project: proj,
|
|
421
|
+
personalProject: proj ? parsed.personalProject : false,
|
|
422
|
+
};
|
|
268
423
|
}
|
|
269
424
|
|
|
270
425
|
/**
|
|
271
426
|
* Fusionne plusieurs lignes choisies (ex. valeurs d’un `<select multiple>` : `#tag`, `@projet`, `@`).
|
|
272
427
|
* Les étiquettes sont dédupliquées (casse ignorée). Le dernier jeton qui fixe le projet l’emporte ; `@` seul retire le projet.
|
|
273
428
|
*/
|
|
274
|
-
export function mergeQuickAddFromOptionValues(values: string[]): {
|
|
429
|
+
export function mergeQuickAddFromOptionValues(values: string[]): {
|
|
430
|
+
tags: string[];
|
|
431
|
+
project?: string | null;
|
|
432
|
+
personalProject?: boolean;
|
|
433
|
+
} {
|
|
275
434
|
const seen = new Set<string>();
|
|
276
435
|
const tags: string[] = [];
|
|
277
436
|
let project: string | null | undefined = undefined;
|
|
278
437
|
let projectTouched = false;
|
|
438
|
+
let personalProject: boolean | undefined = undefined;
|
|
439
|
+
let personalTouched = false;
|
|
279
440
|
|
|
280
441
|
for (const raw of values) {
|
|
281
442
|
const r = parseQuickAddTagOrProjectLine(raw);
|
|
@@ -295,12 +456,19 @@ export function mergeQuickAddFromOptionValues(values: string[]): { tags: string[
|
|
|
295
456
|
projectTouched = true;
|
|
296
457
|
project = r.project;
|
|
297
458
|
}
|
|
459
|
+
if (r.personalProject !== undefined) {
|
|
460
|
+
personalTouched = true;
|
|
461
|
+
personalProject = r.personalProject;
|
|
462
|
+
}
|
|
298
463
|
}
|
|
299
464
|
|
|
300
|
-
const out: { tags: string[]; project?: string | null } = { tags };
|
|
465
|
+
const out: { tags: string[]; project?: string | null; personalProject?: boolean } = { tags };
|
|
301
466
|
if (projectTouched) {
|
|
302
467
|
out.project = project;
|
|
303
468
|
}
|
|
469
|
+
if (personalTouched) {
|
|
470
|
+
out.personalProject = personalProject;
|
|
471
|
+
}
|
|
304
472
|
return out;
|
|
305
473
|
}
|
|
306
474
|
|
|
@@ -339,22 +507,38 @@ export function taskTitleEditBaseline(storedName: string): string {
|
|
|
339
507
|
* Même extraction que {@link parseTaskWithAutoTags}, mais sans rogner le titre :
|
|
340
508
|
* nécessaire pour le champ de saisie contrôlé.
|
|
341
509
|
*/
|
|
342
|
-
export function parseTaskDraftParts(raw: string): {
|
|
343
|
-
|
|
510
|
+
export function parseTaskDraftParts(raw: string): {
|
|
511
|
+
name: string;
|
|
512
|
+
tags: string[];
|
|
513
|
+
project?: string;
|
|
514
|
+
personalProject: boolean;
|
|
515
|
+
} {
|
|
516
|
+
const { core, project, scopedTags, personalProject } = stripProjectsAndTakeFirst(raw);
|
|
344
517
|
const fromCore = collectAutoTagsFromRaw(core);
|
|
345
518
|
const tags = mergeDedupedTagLists(scopedTags, fromCore);
|
|
346
519
|
const name = core.replace(new RegExp(AUTO_TAG_COMBINED.source, "g"), "");
|
|
347
|
-
return { name, tags, project };
|
|
520
|
+
return { name, tags, project, personalProject: Boolean(project && personalProject) };
|
|
348
521
|
}
|
|
349
522
|
|
|
350
|
-
function appendTagTokenToDraft(
|
|
523
|
+
function appendTagTokenToDraft(
|
|
524
|
+
s: string,
|
|
525
|
+
tag: string,
|
|
526
|
+
ctx?: { taskProject?: string | null; personalProject?: boolean },
|
|
527
|
+
): string {
|
|
351
528
|
const t = normalizeTagKey(tag).trim();
|
|
352
529
|
if (!t) {
|
|
353
530
|
return s;
|
|
354
531
|
}
|
|
355
532
|
const scoped = parseProjectScopedTag(t);
|
|
356
533
|
if (scoped) {
|
|
357
|
-
const
|
|
534
|
+
const pk = ctx?.taskProject ? normalizeProjectKey(String(ctx.taskProject)) : "";
|
|
535
|
+
const useBang =
|
|
536
|
+
Boolean(ctx?.personalProject) &&
|
|
537
|
+
pk.length > 0 &&
|
|
538
|
+
scoped.projectKey.toLowerCase() === pk.toLowerCase();
|
|
539
|
+
const piece = useBang
|
|
540
|
+
? `!${scoped.projectKey}#${scoped.localTag}`
|
|
541
|
+
: `@${scoped.projectKey}#${scoped.localTag}`;
|
|
358
542
|
const trimmed = s.replace(/\s+$/, "");
|
|
359
543
|
return trimmed ? `${trimmed}${piece}` : piece;
|
|
360
544
|
}
|
|
@@ -362,11 +546,20 @@ function appendTagTokenToDraft(s: string, tag: string): string {
|
|
|
362
546
|
return trimmedEnd ? `${trimmedEnd}#${t}` : `#${t}`;
|
|
363
547
|
}
|
|
364
548
|
|
|
365
|
-
/** Reconstruit le brouillon (titre + #tags +
|
|
366
|
-
export function rebuildTaskDraftCore(
|
|
549
|
+
/** Reconstruit le brouillon (titre + #tags + jetons de portée + `@` / `!` projet optionnel). */
|
|
550
|
+
export function rebuildTaskDraftCore(
|
|
551
|
+
name: string,
|
|
552
|
+
tags: string[],
|
|
553
|
+
project?: string,
|
|
554
|
+
personalProject?: boolean,
|
|
555
|
+
): string {
|
|
367
556
|
let s = name;
|
|
557
|
+
const pTrim = project?.trim();
|
|
558
|
+
const ctx = pTrim
|
|
559
|
+
? { taskProject: normalizeProjectKey(pTrim), personalProject: Boolean(personalProject) }
|
|
560
|
+
: undefined;
|
|
368
561
|
for (const t of tags) {
|
|
369
|
-
s = appendTagTokenToDraft(s, t);
|
|
562
|
+
s = appendTagTokenToDraft(s, t, ctx);
|
|
370
563
|
}
|
|
371
564
|
const p = project?.trim();
|
|
372
565
|
if (p) {
|
|
@@ -377,18 +570,28 @@ export function rebuildTaskDraftCore(name: string, tags: string[], project?: str
|
|
|
377
570
|
});
|
|
378
571
|
if (!implied) {
|
|
379
572
|
const trimmed = s.replace(/\s+$/, "");
|
|
380
|
-
|
|
573
|
+
const projTok = personalProject ? `!${pk}` : `@${pk}`;
|
|
574
|
+
s = trimmed ? `${trimmed}${projTok}` : projTok;
|
|
381
575
|
}
|
|
382
576
|
}
|
|
383
577
|
return s;
|
|
384
578
|
}
|
|
385
579
|
|
|
386
|
-
/** Reconstruit la chaîne « titre + #tags + `@p#t` [+ @projet] » alignée sur {@link parseTaskWithAutoTags}. */
|
|
387
|
-
export function rebuildTaskRawString(
|
|
580
|
+
/** Reconstruit la chaîne « titre + #tags + `@p#t` / `!p#t` [+ @ ou ! projet] » alignée sur {@link parseTaskWithAutoTags}. */
|
|
581
|
+
export function rebuildTaskRawString(
|
|
582
|
+
name: string,
|
|
583
|
+
tags: string[],
|
|
584
|
+
project?: string | null,
|
|
585
|
+
personalProject?: boolean,
|
|
586
|
+
): string {
|
|
388
587
|
const n = name.replace(/\s+/g, " ").trim();
|
|
588
|
+
const pTrim = project?.trim();
|
|
589
|
+
const ctx = pTrim
|
|
590
|
+
? { taskProject: normalizeProjectKey(pTrim), personalProject: Boolean(personalProject) }
|
|
591
|
+
: undefined;
|
|
389
592
|
let tagPart = n;
|
|
390
593
|
for (const t of tags) {
|
|
391
|
-
tagPart = appendTagTokenToDraft(tagPart, t);
|
|
594
|
+
tagPart = appendTagTokenToDraft(tagPart, t, ctx);
|
|
392
595
|
}
|
|
393
596
|
const p = project?.trim();
|
|
394
597
|
if (!p) {
|
|
@@ -403,27 +606,35 @@ export function rebuildTaskRawString(name: string, tags: string[], project?: str
|
|
|
403
606
|
return tagPart;
|
|
404
607
|
}
|
|
405
608
|
const trimmed = tagPart.replace(/\s+$/, "");
|
|
406
|
-
|
|
609
|
+
const projTok = personalProject ? `!${pk}` : `@${pk}`;
|
|
610
|
+
return trimmed ? `${trimmed}${projTok}` : projTok;
|
|
407
611
|
}
|
|
408
612
|
|
|
409
613
|
/**
|
|
410
|
-
* Chaîne pour le champ d’édition : nom stocké sans `#` / `@` + pastilles fusionnées + projet.
|
|
614
|
+
* Chaîne pour le champ d’édition : nom stocké sans `#` / `@` / `!` + pastilles fusionnées + projet.
|
|
411
615
|
*/
|
|
412
616
|
export function displayRawTaskTitle(
|
|
413
617
|
storedName: string,
|
|
414
618
|
mergedTags: string[],
|
|
415
|
-
mergedProject?: string | null
|
|
619
|
+
mergedProject?: string | null,
|
|
620
|
+
mergedPersonalProject?: boolean,
|
|
416
621
|
): string {
|
|
417
622
|
const { name: stripped } = parseTaskWithAutoTags(storedName);
|
|
418
623
|
const base = stripped.replace(/\s+/g, " ").trim();
|
|
419
624
|
const baseKey = base.toLowerCase();
|
|
420
625
|
const suffixTags = mergedTags.filter((t) => normalizeTagKey(t).toLowerCase() !== baseKey);
|
|
626
|
+
const personal = Boolean(mergedPersonalProject);
|
|
627
|
+
const pTrim = mergedProject?.trim();
|
|
628
|
+
const pkForScoped =
|
|
629
|
+
pTrim && personal ? normalizeProjectKey(pTrim).toLowerCase() : "";
|
|
421
630
|
const suffix = suffixTags
|
|
422
631
|
.map((t) => {
|
|
423
632
|
const tk = normalizeTagKey(t);
|
|
424
633
|
const sc = parseProjectScopedTag(tk);
|
|
425
634
|
if (sc) {
|
|
426
|
-
|
|
635
|
+
const useBang =
|
|
636
|
+
personal && sc.projectKey.toLowerCase() === pkForScoped;
|
|
637
|
+
return useBang ? `!${sc.projectKey}#${sc.localTag}` : `@${sc.projectKey}#${sc.localTag}`;
|
|
427
638
|
}
|
|
428
639
|
return `#${tk}`;
|
|
429
640
|
})
|
|
@@ -445,13 +656,14 @@ export function displayRawTaskTitle(
|
|
|
445
656
|
});
|
|
446
657
|
if (!implied) {
|
|
447
658
|
const trimmed = out.replace(/\s+$/, "");
|
|
448
|
-
|
|
659
|
+
const projTok = personal ? `!${pk}` : `@${pk}`;
|
|
660
|
+
out = trimmed ? `${trimmed}${projTok}` : projTok;
|
|
449
661
|
}
|
|
450
662
|
}
|
|
451
663
|
return out;
|
|
452
664
|
}
|
|
453
665
|
|
|
454
|
-
/** Ajoute un tag enregistré à la fin du brouillon (`#tag` ou `@projet#code`). */
|
|
666
|
+
/** Ajoute un tag enregistré à la fin du brouillon (`#tag` ou `@projet#code` / `!projet#code`). */
|
|
455
667
|
export function appendSavedTagToDraft(current: string, tag: string): string {
|
|
456
668
|
const token = normalizeTagKey(tag);
|
|
457
669
|
if (!token) {
|
|
@@ -459,9 +671,9 @@ export function appendSavedTagToDraft(current: string, tag: string): string {
|
|
|
459
671
|
}
|
|
460
672
|
const parsed = parseTaskDraftParts(current);
|
|
461
673
|
const nextTags = mergeDedupedTagLists(parsed.tags, [token]);
|
|
462
|
-
const prevCore = rebuildTaskDraftCore(parsed.name, parsed.tags, parsed.project);
|
|
674
|
+
const prevCore = rebuildTaskDraftCore(parsed.name, parsed.tags, parsed.project, parsed.personalProject);
|
|
463
675
|
const suff = current.startsWith(prevCore) ? current.slice(prevCore.length) : "";
|
|
464
|
-
const core = rebuildTaskDraftCore(parsed.name, nextTags, parsed.project);
|
|
676
|
+
const core = rebuildTaskDraftCore(parsed.name, nextTags, parsed.project, parsed.personalProject);
|
|
465
677
|
return core + suff;
|
|
466
678
|
}
|
|
467
679
|
|
|
@@ -474,17 +686,21 @@ export function removeSavedTagFromDraft(current: string, tag: string): string {
|
|
|
474
686
|
const key = token.toLowerCase();
|
|
475
687
|
const parsed = parseTaskDraftParts(current);
|
|
476
688
|
const nextTags = parsed.tags.filter((t) => normalizeTagKey(t).toLowerCase() !== key);
|
|
477
|
-
return rebuildTaskRawString(parsed.name, nextTags, parsed.project ?? null);
|
|
689
|
+
return rebuildTaskRawString(parsed.name, nextTags, parsed.project ?? null, parsed.personalProject);
|
|
478
690
|
}
|
|
479
691
|
|
|
480
|
-
/** Retire le projet (`@projet`) du brouillon sans modifier le libellé ni les étiquettes. */
|
|
692
|
+
/** Retire le projet (`@projet` / `!projet`) du brouillon sans modifier le libellé ni les étiquettes. */
|
|
481
693
|
export function removeProjectFromDraft(current: string): string {
|
|
482
694
|
const parsed = parseTaskDraftParts(current);
|
|
483
|
-
return rebuildTaskRawString(parsed.name, parsed.tags, null);
|
|
695
|
+
return rebuildTaskRawString(parsed.name, parsed.tags, null, false);
|
|
484
696
|
}
|
|
485
697
|
|
|
486
|
-
/** Ajoute un projet connu à la fin du brouillon (`@nom
|
|
487
|
-
export function appendSavedProjectToDraft(
|
|
698
|
+
/** Ajoute un projet connu à la fin du brouillon (`@nom` ou `!nom`). */
|
|
699
|
+
export function appendSavedProjectToDraft(
|
|
700
|
+
current: string,
|
|
701
|
+
project: string,
|
|
702
|
+
opts?: { personal?: boolean },
|
|
703
|
+
): string {
|
|
488
704
|
const token = normalizeProjectKey(project);
|
|
489
705
|
if (!token) {
|
|
490
706
|
return current;
|
|
@@ -493,16 +709,30 @@ export function appendSavedProjectToDraft(current: string, project: string): str
|
|
|
493
709
|
if (parsed.project && parsed.project.toLowerCase() === token.toLowerCase()) {
|
|
494
710
|
return current;
|
|
495
711
|
}
|
|
496
|
-
const
|
|
712
|
+
const personal = opts?.personal ?? false;
|
|
713
|
+
const core = rebuildTaskDraftCore(parsed.name, parsed.tags, token, personal);
|
|
497
714
|
const suff = current.startsWith(core) ? current.slice(core.length) : "";
|
|
498
715
|
return core + suff;
|
|
499
716
|
}
|
|
500
717
|
|
|
501
|
-
export function buildStartTaskFromInput(raw: string): {
|
|
718
|
+
export function buildStartTaskFromInput(raw: string): {
|
|
719
|
+
name: string;
|
|
720
|
+
tags: string[];
|
|
721
|
+
project?: string;
|
|
722
|
+
personalProject?: boolean;
|
|
723
|
+
} {
|
|
502
724
|
const trimmed = raw.trim();
|
|
503
|
-
const { name, tags, project } = parseTaskWithAutoTags(trimmed);
|
|
725
|
+
const { name, tags, project, personalProject } = parseTaskWithAutoTags(trimmed);
|
|
504
726
|
const displayName = name || tags[0] || trimmed;
|
|
505
|
-
|
|
727
|
+
const out: { name: string; tags: string[]; project?: string; personalProject?: boolean } = {
|
|
728
|
+
name: displayName,
|
|
729
|
+
tags,
|
|
730
|
+
};
|
|
731
|
+
if (project) {
|
|
732
|
+
out.project = project;
|
|
733
|
+
out.personalProject = personalProject;
|
|
734
|
+
}
|
|
735
|
+
return out;
|
|
506
736
|
}
|
|
507
737
|
|
|
508
738
|
/**
|
|
@@ -512,24 +742,35 @@ export function buildStartTaskFromDraft(
|
|
|
512
742
|
titleLine: string,
|
|
513
743
|
pickerTags: string[],
|
|
514
744
|
/** `undefined` : projet issu du titre ; chaîne vide : aucun projet (pastille désactivée). */
|
|
515
|
-
pickerProject: string | undefined
|
|
516
|
-
|
|
745
|
+
pickerProject: string | undefined,
|
|
746
|
+
opts?: { pickerPersonalProject?: boolean },
|
|
747
|
+
): { name: string; tags: string[]; project?: string; personalProject?: boolean } {
|
|
517
748
|
const trimmed = titleLine.trim();
|
|
518
749
|
const parsed = parseTaskWithAutoTags(trimmed);
|
|
519
750
|
const allTags = mergeTagsForDisplay(trimmed, pickerTags);
|
|
520
751
|
const project =
|
|
521
752
|
pickerProject === undefined ? parsed.project : pickerProject.trim() ? pickerProject : undefined;
|
|
753
|
+
const personalProject =
|
|
754
|
+
pickerProject === undefined
|
|
755
|
+
? parsed.personalProject
|
|
756
|
+
: pickerProject.trim()
|
|
757
|
+
? opts?.pickerPersonalProject ?? false
|
|
758
|
+
: false;
|
|
522
759
|
const tags = filterTaskTagsForProject(allTags, project ?? null);
|
|
523
760
|
const displayName =
|
|
524
761
|
parsed.name.trim() ||
|
|
525
762
|
(tags.length > 0 ? tags[0] : "") ||
|
|
526
763
|
trimmed ||
|
|
527
764
|
(project ? normalizeProjectKey(project) : "");
|
|
528
|
-
|
|
765
|
+
const out: { name: string; tags: string[]; project?: string; personalProject?: boolean } = {
|
|
529
766
|
name: displayName,
|
|
530
767
|
tags,
|
|
531
|
-
...(project ? { project } : {}),
|
|
532
768
|
};
|
|
769
|
+
if (project) {
|
|
770
|
+
out.project = project;
|
|
771
|
+
out.personalProject = personalProject;
|
|
772
|
+
}
|
|
773
|
+
return out;
|
|
533
774
|
}
|
|
534
775
|
|
|
535
776
|
export function normalizeTagKey(s: string): string {
|
|
@@ -624,21 +865,27 @@ export function mergeTagsForDisplay(rawName: string, storedTags?: string[] | nul
|
|
|
624
865
|
return out;
|
|
625
866
|
}
|
|
626
867
|
|
|
627
|
-
export function formatTagDisplay(tag: string): string {
|
|
868
|
+
export function formatTagDisplay(tag: string, opts?: { personalScoped?: boolean }): string {
|
|
628
869
|
const trimmed = tag.trim();
|
|
629
870
|
if (!trimmed) {
|
|
630
871
|
return trimmed;
|
|
631
872
|
}
|
|
632
873
|
const scoped = parseProjectScopedTag(trimmed.replace(/^#/, ""));
|
|
633
874
|
if (scoped) {
|
|
634
|
-
return
|
|
875
|
+
return opts?.personalScoped
|
|
876
|
+
? `!${scoped.projectKey}#${scoped.localTag}`
|
|
877
|
+
: `@${scoped.projectKey}#${scoped.localTag}`;
|
|
635
878
|
}
|
|
636
879
|
const noHash = trimmed.replace(/^#/, "");
|
|
637
880
|
return trimmed.startsWith("#") ? trimmed : `#${noHash}`;
|
|
638
881
|
}
|
|
639
882
|
|
|
640
883
|
/** Libellé lisible pour une pastille d’étiquette ; `defaultBucketLabel` remplace l’affichage de l’étiquette réservée {@link DEFAULT_FALLBACK_TASK_TAG}. */
|
|
641
|
-
export function formatTagDisplayForTask(
|
|
884
|
+
export function formatTagDisplayForTask(
|
|
885
|
+
tag: string,
|
|
886
|
+
defaultBucketLabel?: string,
|
|
887
|
+
opts?: { personalProject?: boolean; taskProject?: string | null },
|
|
888
|
+
): string {
|
|
642
889
|
const trimmed = normalizeTagKey(String(tag)).trim();
|
|
643
890
|
if (!trimmed) {
|
|
644
891
|
return "";
|
|
@@ -646,15 +893,25 @@ export function formatTagDisplayForTask(tag: string, defaultBucketLabel?: string
|
|
|
646
893
|
if (trimmed.toLowerCase() === DEFAULT_FALLBACK_TASK_TAG && defaultBucketLabel?.trim()) {
|
|
647
894
|
return defaultBucketLabel.trim();
|
|
648
895
|
}
|
|
649
|
-
|
|
896
|
+
const sc = parseProjectScopedTag(trimmed);
|
|
897
|
+
const tp = opts?.taskProject;
|
|
898
|
+
const usePersonalScoped =
|
|
899
|
+
sc !== null &&
|
|
900
|
+
Boolean(opts?.personalProject && tp) &&
|
|
901
|
+
sc.projectKey.toLowerCase() === normalizeProjectKey(String(tp)).toLowerCase();
|
|
902
|
+
return formatTagDisplay(tag, { personalScoped: usePersonalScoped });
|
|
650
903
|
}
|
|
651
904
|
|
|
652
|
-
export function formatProjectDisplay(project: string): string {
|
|
905
|
+
export function formatProjectDisplay(project: string, opts?: { personal?: boolean }): string {
|
|
653
906
|
const trimmed = project.trim();
|
|
654
907
|
if (!trimmed) {
|
|
655
908
|
return trimmed;
|
|
656
909
|
}
|
|
657
|
-
|
|
910
|
+
const k = normalizeProjectKey(trimmed);
|
|
911
|
+
if (opts?.personal) {
|
|
912
|
+
return trimmed.startsWith("!") ? trimmed : `!${k}`;
|
|
913
|
+
}
|
|
914
|
+
return trimmed.startsWith("@") ? trimmed : `@${k}`;
|
|
658
915
|
}
|
|
659
916
|
|
|
660
917
|
/**
|